317 Commits

Author SHA1 Message Date
Antoine Nguyen
2c6c3a7573 Do not alter global variable 2025-08-19 17:38:25 +02:00
Antoine Nguyen
21a6f85786 Also install opendkim if antispam is amavis 2025-08-19 17:34:55 +02:00
Antoine Nguyen
4d49f182ec Make sure amavis can still be installed 2025-08-19 17:26:33 +02:00
Antoine Nguyen
a9ae8c50ad Removed wrong constructor argument 2025-08-19 15:34:54 +02:00
Antoine Nguyen
95e2010957 Few fixes 2025-08-19 15:34:54 +02:00
Antoine Nguyen
97b98c9d09 Fixed unit tests 2025-08-19 15:34:54 +02:00
Spitfireap
757c1dd48b Hide sender client IP v2 2025-08-19 15:34:54 +02:00
Spitfireap
0056ef20aa Hide sender client IP 2025-08-19 15:34:54 +02:00
Spitfireap
b9539fa33c Updated rspamd config 2025-08-19 15:34:54 +02:00
Antoine Nguyen
7ae6196793 Added missing method parameter 2025-08-19 15:34:54 +02:00
Spitfireap
fd50d62f97 Updated ARC 2025-08-19 15:34:54 +02:00
Spitap
cd280f054b Added Arc signing 2025-08-19 15:34:54 +02:00
Spitap
f980d4e86f Added rspamd dashboard info 2025-08-19 15:34:54 +02:00
Spitap
9b7489ea58 Fixed dovecot #2 2025-08-19 15:34:54 +02:00
Spitap
5c7f230647 Fixed dovecot 2025-08-19 15:34:52 +02:00
Spitap
06d65f7921 imported arguments 2025-08-19 15:33:58 +02:00
Spitap
84e82199ef fix import 2025-08-19 15:33:58 +02:00
Spitap
f0a84c81b9 imported checks 2025-08-19 15:33:58 +02:00
Spitfireap
9a582fb1d0 Update after rebase 2025-08-19 15:33:56 +02:00
Antoine Nguyen
b7106bb15a Fixed file copy issue 2025-08-19 15:33:25 +02:00
Spitfireap
a8b2f9f015 create sieve dir if needed 2025-08-19 15:33:25 +02:00
Spitfireap
86481417cf Made junk sieve optional 2025-08-19 15:33:25 +02:00
Spitfireap
e73d318e14 Made 90-sieve a template 2025-08-19 15:33:24 +02:00
Spitfireap
38eae741bf added sieve rule to move spam to junk folder 2025-08-19 15:32:55 +02:00
Spitap
5156ad0468 small fix part 4 2025-08-19 15:32:31 +02:00
Spitap
183bfd2742 small fix part 3 2025-08-19 15:32:31 +02:00
Spitap
e7e5dce778 Bug fix 2025-08-19 15:32:31 +02:00
Spitap
6771ea0028 small fix part 2 2025-08-19 15:32:31 +02:00
Spitap
a92c92c06c small fix 2025-08-19 15:32:31 +02:00
Spitap
e4d68498dd Fixed capped default choice, removed old py2 code 2025-08-19 15:32:30 +02:00
Spitap
bd91c85888 Fixed new source bug, removed bionic, added dynamic defaults 2025-08-19 15:31:53 +02:00
Spitap
b667636dcb Added possibility of if directive in each entry 2025-08-19 15:31:53 +02:00
Spitap
c0ca901353 Fixed config 2025-08-19 15:31:53 +02:00
Spitap
eb1a8ece55 Updated config and interactive mode 2025-08-19 15:31:53 +02:00
Antoine Nguyen
9f5542f07e Better custom repo installation 2025-08-19 15:31:53 +02:00
Antoine Nguyen
b4b5fa288f Fixed wrong call to mkdir_safe 2025-08-19 15:31:53 +02:00
Antoine Nguyen
9ab1b5f18e Convert codename to str 2025-08-19 15:31:53 +02:00
Antoine Nguyen
daf5338ee1 Make rspamd installation work 2025-08-19 15:31:53 +02:00
Antoine Nguyen
576c696472 Fixed tests 2025-08-19 15:31:53 +02:00
Antoine Nguyen
dea95ee1ba Fixed wrong access to config option 2025-08-19 15:31:53 +02:00
Antoine Nguyen
fb42636df0 Escape % character in config file 2025-08-19 15:31:53 +02:00
Antoine Nguyen
ec82b346a3 Fixed wrong setting names! 2025-08-19 15:31:53 +02:00
Antoine Nguyen
d44faf96b1 Consistency for variable names 2025-08-19 15:31:53 +02:00
Antoine Nguyen
2564f856bd Fixed wrong setting names 2025-08-19 15:31:53 +02:00
Antoine Nguyen
92864aa288 Fixed issues in rspamd script 2025-08-19 15:31:52 +02:00
Antoine Nguyen
0b85e2c7ef Fixed wrong settings initialization 2025-08-19 15:31:13 +02:00
Antoine Nguyen
35e9ea4bde Few fixes 2025-08-19 15:31:13 +02:00
Spitap
077e84349a import fix 2025-08-19 15:31:13 +02:00
Spitap
70e9cffd87 App incompatibility detection, updated for 2.2.0 2025-08-19 15:31:12 +02:00
Spitap
df23f4e181 fix 2025-08-19 15:30:29 +02:00
Spitap
46bbb1039b updated rspamd config 2025-08-19 15:30:29 +02:00
Spitap
69a8f08246 fixed test 2025-08-19 15:30:29 +02:00
Spitap
f7c03e8632 Removed installer.cfg 2025-08-19 15:30:29 +02:00
Spitap
1423fe0e6e Better configuration 2025-08-19 15:30:29 +02:00
Spitap
45870e20ef Fixed dict, few fixes 2025-08-19 15:30:05 +02:00
Spitap
4082d5790d Added Rspamd installation 2025-08-19 15:27:22 +02:00
github-actions[bot]
fbedc6a051 [GitHub Action] Updated version file 2025-07-03 08:44:23 +00:00
Antoine Nguyen
53669b48de Compat with Modoboa 2.4.0 2025-07-03 10:43:07 +02:00
github-actions[bot]
5fe3e49b9a [GitHub Action] Updated version file 2025-06-06 06:57:44 +00:00
Antoine Nguyen
c571462485 Merge pull request #592 from phizev/ubuntu-24.04-mysql-fix
Update MySQL (MariaDB) install to account for Ubuntu 24.04.
2025-06-06 08:56:40 +02:00
phizev
daf3ec2d42 Update MySQL (MariaDB) install to account for Ubuntu 24.04. 2025-06-01 17:41:39 +02:00
github-actions[bot]
1e4ba06764 [GitHub Action] Updated version file 2025-03-26 16:37:04 +00:00
Antoine Nguyen
0bc3a8367c Merge pull request #587 from modoboa/update/install-reqs-process
Use extras instead of requirements file
2025-03-26 17:36:00 +01:00
Adrien P
eee2c76a16 Use extras instead of requirements file 2025-03-07 12:37:06 +01:00
github-actions[bot]
24c9599ca5 [GitHub Action] Updated version file 2025-01-29 08:42:20 +00:00
Antoine Nguyen
78092509c7 Merge pull request #581 from seb4itik/master
Fix #561 and #576
2025-01-29 09:40:58 +01:00
S. Nameche
5fbf373dc2 Fix #561 and #576 2025-01-28 21:04:44 +03:00
github-actions[bot]
5313abf42b [GitHub Action] Updated version file 2025-01-28 11:47:18 +00:00
Antoine Nguyen
8f41ebd15c Merge branch 'master' of github.com:modoboa/modoboa-installer 2025-01-28 12:46:40 +01:00
Antoine Nguyen
707d44d819 Updated codecov badge 2025-01-28 12:46:08 +01:00
github-actions[bot]
6b359898a9 [GitHub Action] Updated version file 2025-01-28 11:43:29 +00:00
Antoine Nguyen
ce728b0669 Merge pull request #580 from modoboa/fix/radicale_config
Updated Radicale config
2025-01-28 12:42:17 +01:00
Antoine Nguyen
2c862e3179 Update workflow config 2025-01-28 12:40:49 +01:00
Antoine Nguyen
5efc3a4aa6 Updated test matrix 2025-01-28 12:32:03 +01:00
Antoine Nguyen
114a15b407 Updated Radicale config
dovecot auth is now part of Radicale
2025-01-28 11:49:37 +01:00
github-actions[bot]
50f632ee9a [GitHub Action] Updated version file 2024-10-19 07:48:12 +00:00
Antoine Nguyen
20b6ede211 Merge pull request #572 from FranMercedesG/fix-tls-and-ciphers-version
feature: improve security on postfix
2024-10-19 09:47:42 +02:00
github-actions[bot]
97c81a8eaf [GitHub Action] Updated version file 2024-10-19 07:46:49 +00:00
Antoine Nguyen
a35780fe4f Merge branch 'master' of github.com:modoboa/modoboa-installer 2024-10-19 09:45:49 +02:00
Antoine Nguyen
feba5ca406 Force index update after enabling backports (debian) 2024-10-19 09:45:17 +02:00
github-actions[bot]
a46b3e18ff [GitHub Action] Updated version file 2024-10-16 11:38:31 +00:00
github-actions[bot]
32a16b6ea3 [GitHub Action] Updated version file 2024-10-16 10:04:23 +00:00
Antoine Nguyen
33cad9b29b Install dovecot from backports if Debian 12 2024-10-16 12:03:19 +02:00
FranMercedesG
1bb108c62c feature: improve security on postfix 2024-09-18 15:00:55 -04:00
github-actions[bot]
69b966a030 [GitHub Action] Updated version file 2024-08-21 15:30:32 +00:00
Antoine Nguyen
336677cf8c Merge pull request #570 from modoboa/fix/updated-comp-matric
Update compatibility_matrix.py
2024-08-21 17:29:18 +02:00
Spitap
29153f8d48 Update compatibility_matrix.py 2024-08-21 16:30:34 +02:00
github-actions[bot]
79d09f2eb9 [GitHub Action] Updated version file 2024-08-02 09:31:10 +00:00
Antoine Nguyen
cb06459ea3 Merge pull request #563 from modoboa/feature/dovecot_oauth2_setup
Added setup instructions for Dovecot oauth2 support
2024-08-02 11:29:57 +02:00
Antoine Nguyen
81f1332e84 Merge branch 'feature/dovecot_oauth2_setup' of github.com:modoboa/modoboa-installer into feature/dovecot_oauth2_setup 2024-08-02 11:28:37 +02:00
Antoine Nguyen
7dbe1ea093 Few fixes 2024-08-02 11:27:57 +02:00
Spitfireap
997478704d Added uwsgi buffer-size 2024-07-22 16:02:47 +02:00
Antoine Nguyen
1e7b8209cf Fixed createapplication call 2024-07-12 18:43:50 +02:00
Antoine Nguyen
2572dd64d1 Added setup instructions for Dovecot oauth2 support 2024-07-07 10:48:05 +02:00
github-actions[bot]
d05618e53d [GitHub Action] Updated version file 2024-06-14 14:32:45 +00:00
Antoine Nguyen
e9fc8efeca Merge pull request #559 from modoboa/fix/sorbs
Removed SORBS dnsbl
2024-06-14 16:31:36 +02:00
Antoine Nguyen
290c50326e Removed SORBS dnsbl 2024-06-13 09:29:29 +02:00
github-actions[bot]
10b2e71aa4 [GitHub Action] Updated version file 2024-06-10 08:13:20 +00:00
Antoine Nguyen
c1abbe9792 Merge pull request #557 from modoboa/compat/ubuntu2404
Compat. with Ubuntu 24.04
2024-06-10 10:12:07 +02:00
Antoine Nguyen
2f93a1eddb Compat. with Ubuntu 24.04
see #556
2024-06-05 12:03:59 +02:00
github-actions[bot]
ec12104f44 [GitHub Action] Updated version file 2024-05-16 12:23:46 +00:00
Antoine Nguyen
917bd7382b Removed option.
see #554
2024-05-16 14:21:54 +02:00
github-actions[bot]
271add9b6e [GitHub Action] Updated version file 2024-05-16 08:27:33 +00:00
Antoine Nguyen
c39cd568e4 Fix #553 2024-05-16 10:26:04 +02:00
github-actions[bot]
e45a07f8cf [GitHub Action] Updated version file 2024-04-26 14:44:53 +00:00
Antoine Nguyen
b099337d24 Merge pull request #552 from modoboa/fix/le_renewal_hook
Added renewal deploy hook to reload services.
2024-04-26 16:43:42 +02:00
Antoine Nguyen
015a535d0f Safer script syntax 2024-04-26 15:04:15 +02:00
Antoine Nguyen
c0bdc22c4c Added renewal deploy hook to reload services.
see #522
2024-04-26 14:55:48 +02:00
github-actions[bot]
a187e08fe4 [GitHub Action] Updated version file 2024-04-26 11:54:06 +00:00
Antoine Nguyen
47468d3f72 Merge branch 'master' of github.com:modoboa/modoboa-installer 2024-04-26 13:52:46 +02:00
Antoine Nguyen
90daf2fd3f Removed useless workflow trigger 2024-04-26 13:52:25 +02:00
github-actions[bot]
462422af81 [GitHub Action] Updated version file 2024-04-26 11:50:07 +00:00
Antoine Nguyen
87a5a24947 Merge branch 'master' of github.com:modoboa/modoboa-installer 2024-04-26 13:48:52 +02:00
Antoine Nguyen
8eeb88dd34 Run versioning workflow after tests on master branch 2024-04-26 13:47:56 +02:00
github-actions[bot]
032e043321 [GitHub Action] Updated version file 2024-04-26 11:46:37 +00:00
Antoine Nguyen
46a19d08e5 Fix #515 2024-04-26 13:46:08 +02:00
github-actions[bot]
4f4915983e [GitHub Action] Updated version file 2024-04-26 10:08:16 +00:00
Antoine Nguyen
9dc05691b0 Merge pull request #551 from modoboa/feature/backup-wo-mail
Added skipping mail backup in backup process
2024-04-26 12:08:05 +02:00
Spitfireap
da4cddf198 Actually skip mail backup when needed 2024-04-26 12:06:21 +02:00
Spitfireap
6fe80b5ea3 Added skipping mail backup in backup process 2024-04-26 11:21:36 +02:00
github-actions[bot]
7066c2b86c [GitHub Action] Updated version file 2024-04-24 06:30:55 +00:00
Antoine Nguyen
eb8bb2138f Merge pull request #504 from modoboa/custom-tls-fix
Added custom tls cert support
2024-04-24 08:30:43 +02:00
Antoine Nguyen
18369e238c Few updates 2024-04-24 08:28:56 +02:00
Spitap
68ecf77045 Fixed for upgrade 2024-04-24 08:12:46 +02:00
Spitap
469005b528 Fixed README 2024-04-24 08:12:46 +02:00
Spitap
bc88110be6 Fixed template 2024-04-24 08:12:46 +02:00
Spitap
e900e6258f Revert generate removing 2024-04-24 08:12:46 +02:00
Spitap
4759146d99 Added custom tls cert support 2024-04-24 08:12:44 +02:00
github-actions[bot]
ccae88bb77 [GitHub Action] Updated version file 2024-04-24 06:08:44 +00:00
Antoine Nguyen
367c8a31eb Merge pull request #530 from gsloop/patch-1
Update master.cf.tpl - add missing headers
2024-04-24 08:08:30 +02:00
github-actions[bot]
5559368a3d [GitHub Action] Updated version file 2024-04-24 06:05:05 +00:00
Antoine Nguyen
ea26a6d38a Merge pull request #533 from modoboa/fix-prevent-old-ext-install
Prevent installation of incompatible extensions
2024-04-24 08:04:54 +02:00
Antoine Nguyen
c069f7e6eb updated doc 2024-04-24 08:03:30 +02:00
Antoine Nguyen
d0d19b920b Moved version conversion upper 2024-04-24 07:53:38 +02:00
Antoine Nguyen
2df9fcfd86 Moved code 2024-04-24 07:52:02 +02:00
github-actions[bot]
3a498b7c1c [GitHub Action] Updated version file 2024-04-24 05:26:37 +00:00
Antoine Nguyen
2f692e6557 Merge pull request #546 from modoboa/feature/versioning
Added version check
2024-04-24 07:26:23 +02:00
Antoine Nguyen
8fec73ebba Updated version numbers in workflow 2024-04-24 07:23:34 +02:00
Antoine Nguyen
0d6507e2dc Quote version numbers 2024-04-24 07:21:45 +02:00
Antoine Nguyen
fb961f9339 Updated worflow 2024-04-24 07:18:56 +02:00
Antoine Nguyen
0f7a63697a Merge pull request #548 from aleczdr/patch-1
LMDE compatability
2024-04-18 09:33:55 +02:00
aleczdr
237bad9078 Update package.py 2024-04-16 10:40:32 -04:00
aleczdr
40f94fa816 Update package.py
updated debian distro compatability to include linuxmint, LMDE

***untested***
but should work, or at least not break anything currently working
2024-04-16 10:31:53 -04:00
Spitfireap
c9a2f260da Merge branch 'master' into feature/versioning 2024-04-12 16:42:04 +02:00
Spitfireap
65a2802aba added version.txt 2024-04-12 16:40:33 +02:00
Spitfireap
a64c12bf9b fixed typo 2024-04-12 16:36:52 +02:00
Spitfireap
64ba5eb543 Added checks on install 2024-04-12 16:12:36 +02:00
github-actions[bot]
ef1d7670dd [GitHub Action] Updated version file 2024-04-12 12:20:19 +00:00
Spitfireap
d75f500cd8 Merge branch 'feature/versioning' of https://github.com/modoboa/modoboa-installer into feature/versioning 2024-04-12 14:19:58 +02:00
Spitfireap
ece8c30979 fixed version.txt content 2024-04-12 14:19:03 +02:00
github-actions[bot]
938629eb97 [GitHub Action] Updated version file 2024-04-12 12:11:48 +00:00
Spitfireap
6da31945d6 fixed workflow 2024-04-12 14:11:35 +02:00
Spitfireap
14f0da5c1f Updated versioning 2024-04-12 14:10:20 +02:00
Spitfireap
382a2d5a12 Added github action 2024-04-12 14:01:19 +02:00
Antoine Nguyen
748ac2087f Added sponsoring disclaimer. 2024-04-09 13:21:29 +02:00
Antoine Nguyen
e76d7d5c28 Merge pull request #539 from softwarecreations/master
Fixed frozen apt install during Modoboa upgrade when package already installed with old config file
2024-04-09 13:07:40 +02:00
Antoine Nguyen
9eda3b81be Safer way to detect python package version 2024-04-09 13:01:43 +02:00
softwarecreations
7dcf69bc36 Update package.py Fixed frozen apt install
Fixes apt-get install when package already exists with an old config file. The updated code automatically installs the package-maintainer's version of any config files that come with the package. Resolves #538
2024-02-29 14:40:51 +02:00
Antoine Nguyen
0ccd81c92b Make sure to use int vars 2024-01-22 13:46:27 +01:00
Antoine Nguyen
715a5e3c8f Make redis available before we deploy modoboa 2024-01-12 17:33:29 +01:00
Spitap
6f2ed24c1a Prevent installation of incompatible extensions 2024-01-07 11:46:54 +01:00
Antoine Nguyen
e7995ada3f Quickfix against SMTP smuggling
https://www.postfix.org/smtp-smuggling.html
2023-12-22 16:50:26 +01:00
Antoine Nguyen
7097e15ae9 Deploy supervisor config for new RQ worker 2023-12-22 16:43:33 +01:00
Antoine Nguyen
b7f378fc63 Disable all required lines when dovecot is not installed 2023-12-15 11:59:03 +01:00
Antoine Nguyen
8942836cfc Merge pull request #531 from modoboa/fix/postfix_dhe_group
Replace EDH key generation by DHE group file
2023-12-15 11:57:13 +01:00
Antoine Nguyen
7b990c9ff6 Replace EDH key generation by DHE group file 2023-12-15 11:55:11 +01:00
as_you_wish
24e334c06f Update master.cf.tpl - add missing headers
Add missing headers for mail from sasl auth'd users and mynetworks.
2023-11-22 12:12:11 -08:00
Antoine Nguyen
4a2e9f2ec6 Merge pull request #523 from modoboa/dynamic-requirements
Fetch requirements dynamically
2023-10-20 16:52:12 +02:00
Spitap
6f528c94c6 Moved block to _setup_venv() 2023-10-20 10:13:44 +02:00
Spitap
f77d6f07da Fetch requirements dynamically 2023-10-19 18:10:30 +02:00
Antoine Nguyen
960d1ad23d Merge pull request #516 from xBiei/patch-1
Typo~
2023-08-31 09:17:11 +02:00
_xB
821f72a989 Typo~ 2023-08-31 00:16:24 +03:00
Antoine Nguyen
d1e036b7b0 Merge pull request #514 from modoboa/rq
Updated for 2.2
2023-08-30 18:31:12 +02:00
Spitap
f658e5e85e Fixed escape character on dovecot config tpl 2023-08-30 17:13:59 +02:00
Spitap
9715fcc86e few fixes 2023-08-30 16:57:49 +02:00
Antoine Nguyen
23aabbfffc Updated exec_cmd to allow capturing while in debug mode 2023-08-30 14:17:04 +02:00
Spitap
4782000791 few fixes 2023-08-30 10:13:49 +02:00
Spitap
23a6101b7a fix 2023-08-30 09:58:18 +02:00
Spitap
8a0b3cda9e Added python module to base.py 2023-08-30 09:53:26 +02:00
Spitap
1a528282ce Removed duplicates 2023-08-30 09:47:10 +02:00
Spitap
b1da76cfbd Fixed venvpath 2023-08-30 09:05:42 +02:00
Spitap
941142f5f5 Fixed dkim user 2023-08-30 08:48:58 +02:00
Spitap
ef1bace29e Cleaning code the 2nd 2023-08-29 20:42:44 +02:00
Spitap
35fa19e47d Cleaning code 2023-08-29 20:41:01 +02:00
Spitap
0b0e2a4e6a Updated for 2.2 2023-08-29 20:07:26 +02:00
Antoine Nguyen
e537794af2 Merge pull request #508 from florealcab/master
Add support of Debian 12
2023-07-21 09:56:38 +02:00
Floréal Cabanettes
2cc34e9033 Merge branch 'modoboa:master' into master 2023-07-13 11:21:29 +02:00
Antoine Nguyen
393c433e9a Merge pull request #507 from samuraikid0/master
Fix http2 wrong port
2023-07-11 18:18:19 +02:00
Floréal Cabanettes
5704a0a236 Add amavis 2.13.X as a copy of 2.12.X for debian12, for postgresql too 2023-07-10 23:47:32 +02:00
Floréal Cabanettes
4b6ffa1630 Debian 12 is like debian 11 2023-07-08 07:57:47 +02:00
Floréal Cabanettes
987b43d9e9 Fix for debian12 2023-07-07 23:55:34 +02:00
Floréal Cabanettes
187790149d Add amavis 2.13.X as a copy of 2.12.X for debian12 2023-07-07 23:52:44 +02:00
Zzzz
9ad6c4db68 Fix http2 wrong port 2023-07-07 09:54:05 -11:00
Antoine Nguyen
dd668aca70 Merge pull request #500 from modoboa/http2
Added http2 for nginx
2023-06-12 16:35:12 +02:00
Spitap
6e3a232e83 Added http2 for nginx 2023-05-25 11:16:32 +02:00
Antoine Nguyen
ffb3356b46 Merge pull request #498 from modoboa/bug-fix
Fixed installation issue, Updated automx conf
2023-05-10 09:03:23 +02:00
Spitap
2873a5ae69 Updated automx config 2023-05-09 19:34:22 +02:00
Spitap
4e0b025477 added missing packages
Thanks to @ruslaan7
2023-05-09 18:30:16 +02:00
Antoine Nguyen
21435d885b Merge pull request #497 from modoboa/fix-pdf-storage
Fixed db query for pdf storage
2023-05-02 17:40:27 +02:00
Spitap
c8484406d2 Fixed db query for pdf storage 2023-05-02 08:42:02 +02:00
Antoine Nguyen
60bb5eadea Merge pull request #494 from modoboa/update-for-2.1
Updated for 2.1
2023-04-26 09:05:31 +02:00
Spitap
a6b1d9e5d8 Updated for 2.1 2023-04-26 08:21:33 +02:00
Antoine Nguyen
7752b860fa Merge pull request #492 from modoboa/fix-mysql-db-ubuntu
Added workaround for ubuntu 20 and 22
2023-04-25 17:30:17 +02:00
Antoine Nguyen
0040277380 Improved code 2023-04-25 17:28:04 +02:00
Antoine Nguyen
3767c056ca Create FUNDING.yml 2023-04-20 09:03:42 +02:00
Spitap
ff214ab8f9 Added workaround for ubuntu 20 and 22 2023-03-31 14:53:58 +02:00
Antoine Nguyen
58fc991722 Merge pull request #485 from modoboa/upgrade-config-file
Added ability to update configfile
2023-03-14 08:35:26 +01:00
Spitap
602405833c Better test 2023-03-13 14:59:30 +01:00
Spitap
85652320b6 Simplified return 2023-03-13 12:09:11 +01:00
Spitap
52bccf3393 Refactoring 2023-03-12 10:22:40 +01:00
Spitap
4cd3937fdd Updated tests 2023-03-12 00:50:34 +01:00
Spitap
6261066ccd Formating, force outdated config check 2023-03-12 00:30:04 +01:00
Spitfireap
0b29f74e08 typo, review fix 2023-03-11 12:41:16 +00:00
Spitfireap
29ff6d1933 Merge pull request #489 from softwarecreations/master
Fixed permissions of /etc/dovecot/conf.d/10-ssl-keys.try to resolve issue #2570
2023-03-10 15:19:53 +00:00
softwarecreations
9d24f17632 Fixed permissions of /etc/dovecot/conf.d/10-ssl-keys.try to resolve issue 2570
Resolves modoboa/modoboa#2570

When dovecot first starts up, root reads the conf and is able to read and load the keys in /etc/dovecot/conf.d/10-ssl-keys.try Inside that file, it can read the private key (that only root has permissions to read)

However when we try delete a user, doveconf tries to read the config (to find the user's mailbox) doveconf MUST fail to open 10-ssl-keys.try, which is fine, because 10-ssl.conf says

!include_try /etc/dovecot/conf.d/10-ssl-keys.try

So if doveconf can't open 10-ssl-keys.try it will will keep going. However if doveconf can read 10-ssl-keys.try then doveconf crashes saying something like:

Failed to retrieve mailbox location (b doveconf: Fatal: Error in configuration file /etc/dovecot/conf.d/10-ssl-keys.try line 11: ssl_key: Can't open file /etc/ssl/example.com/privkey.pem: Permission denied

And then the attempt to delete the user's mailbox fails.

According to @gsloop, "the API calls doveadm to return the directory that holds the users mailbox"

I did a new installation, the file /etc/dovecot/conf.d/10-ssl-keys.try was already owned by root:root but it had 644 permissions. So the line that I added corrects that.
2023-03-10 13:03:43 +02:00
Antoine Nguyen
b26905a97b Merge pull request #479 from n-tdi/patch-1
Make DNS understaind easier for users
2023-03-06 16:58:15 +01:00
Antoine Nguyen
a3f7b98104 Merge pull request #487 from mbaechtold/patch-1
Fix automx
2023-03-06 13:19:26 +01:00
Martin Bächtold
d547d37ece Fix automx
Relates to https://github.com/modoboa/modoboa-installer/issues/475
2023-03-05 09:27:40 +01:00
Spitap
dbfede6df1 Fixed typo, updated test 2023-03-03 09:33:32 +01:00
Spitap
335a676a1e Added ability to update configfile 2023-03-02 20:54:31 +01:00
Antoine Nguyen
06a81c7a80 Fix #481 2023-02-17 10:12:56 +01:00
Ntdi
cac6c1e7f7 Make DNS understaind easier for users
This simple change makes it easier for new users to add the DNS records they need for there Modoboa installation.
2023-02-12 17:13:35 -05:00
Antoine Nguyen
63d92b73f3 Merge pull request #474 from modoboa/security/fail2ban
Added fail2ban setup
2023-01-31 09:09:51 +01:00
Antoine Nguyen
76ec16cd45 Added missing files 2023-01-31 09:08:34 +01:00
Antoine Nguyen
5f02e1b8ed Added fail2ban setup 2023-01-30 18:02:09 +01:00
Antoine Nguyen
960f1429fd Removed temp. fix for django-webpack-loader. 2023-01-30 15:51:43 +01:00
Antoine Nguyen
8b376b0f69 Fixed typo
see #472
2023-01-24 13:02:13 +01:00
Antoine Nguyen
4fc540ddd8 Merge pull request #471 from Spitfireap/fix-dovecot-ownership
Fix dovecot ownership
2023-01-24 10:53:36 +01:00
Spitap
81129d2875 Removed globally set mail_uid and mail_gid
Co-Authored-By: Antoine Nguyen <tonio@ngyn.org>
2023-01-24 09:29:51 +01:00
Spitap
a6935bba89 Simplifeid setup_user
Co-Authored-By: Antoine Nguyen <tonio@ngyn.org>
2023-01-24 09:25:18 +01:00
Spitap
7cae12b32e Fix multiple hard-coded vmail 2023-01-23 19:24:28 +01:00
Antoine Nguyen
0fc15fc024 updated regexp
fix #312
2023-01-13 12:12:48 +01:00
Antoine Nguyen
7877de1abc Removed call to deprecated discover command
fix #403
2023-01-13 12:05:03 +01:00
Spitap
6144f7967c make use of mailbox_owner 2023-01-12 11:22:26 +01:00
Antoine Nguyen
a647edf5a5 Merge pull request #460 from Spitfireap/fix-dkim-perm
fixed dkim permissions
2023-01-10 14:07:22 +01:00
Antoine Nguyen
9f08964c59 Merge pull request #470 from Spitfireap/Fix-webmail-folder
Create subfolder on modoboas extensions install
2023-01-10 14:06:20 +01:00
Antoine Nguyen
99d229a693 Merge pull request #464 from Spitfireap/postwhite-conf-fix
Postwhite conf file not being copied
2023-01-10 14:05:23 +01:00
Spitap
cf6f34b257 Be sure to create webmail subfolder 2023-01-10 13:19:09 +01:00
Spitap
a94b5ac4b7 Refactoring 2022-12-27 20:27:28 +01:00
Spitap
4f9f433008 PEP 2022-12-27 19:56:12 +01:00
Spitap
2665e18c0a Fixed config file not copied on new install 2022-12-27 19:45:38 +01:00
Antoine Nguyen
5c22600d98 Merge pull request #462 from Spitfireap/randomize-api-call-time
randomize api call time
2022-11-29 16:54:28 +01:00
Spitap
bcdbb4a2ce fix typo 2022-11-29 14:53:05 +01:00
Spitap
bd1ddcef21 randomize api call time 2022-11-29 13:45:31 +01:00
Spitap
24f231bf1d fixed dkim permsissions 2022-11-27 13:57:35 +01:00
Antoine Nguyen
bc12ca7327 Merge pull request #458 from Spitfireap/fix-include_try
fix typo in dovecot configuration file
2022-11-14 15:49:41 +01:00
Spitap
bd0ecd0949 fix typo in dovecot configuration file 2022-11-10 14:57:43 +01:00
Antoine Nguyen
d364239348 Merge pull request #456 from modoboa/feature/improved_backup_restore
WIP: Improved backup/restore system.
2022-11-09 10:51:30 +01:00
Antoine Nguyen
37633008cb Fixed restore mode 2022-11-09 10:30:44 +01:00
Antoine Nguyen
d6f9a5b913 Few fixes. 2022-11-08 17:20:25 +01:00
Antoine Nguyen
8b1d60ee59 Few fixes 2022-11-08 17:19:23 +01:00
Antoine Nguyen
2b5edae5d5 WIP: Improved backup/restore system. 2022-11-06 10:30:24 +01:00
Antoine Nguyen
61838dbe4d Check if restore is defined before doing anything else.
fix #453
2022-11-05 09:30:50 +01:00
Antoine Nguyen
962cac3ad9 Merge pull request #450 from Spitfireap/fixed-super-call
fixed super call in modoboa's script
2022-11-04 09:41:20 +01:00
Spitap
ef2359a2a8 fixed super call 2022-11-03 23:10:21 +01:00
Antoine Nguyen
1b192c5fd5 Merge pull request #449 from Spitfireap/fixed-import-typo
fixed constants import
2022-11-03 15:34:48 +01:00
Spitap
b0b01465d9 fixed constants import 2022-11-03 15:00:07 +01:00
Antoine Nguyen
754d652fc2 Few fixes 2022-11-03 12:27:04 +01:00
Antoine Nguyen
cb5fa75693 Merge pull request #444 from Spitfireap/tighter-config-file-perm
tighter config file permission
2022-11-03 12:20:25 +01:00
Antoine Nguyen
1afb8e61fc Merge pull request #424 from Spitfireap/restore
Backup & restore system
2022-11-03 12:17:16 +01:00
Spitap
8dd0b7d497 Last camelCase 2022-11-03 10:57:03 +01:00
Spitap
554611b366 review fix 2022-11-03 10:54:06 +01:00
Antoine Nguyen
15c17796f2 Merge pull request #446 from Spitfireap/fix-ssl-min-protocol
fixed ssl_min_protocol setting
2022-10-28 09:43:30 +02:00
Spitap
84d13633a1 fixed ssl_min_protocol setting 2022-10-27 22:37:47 +02:00
Antoine Nguyen
ce8e7e6027 Merge pull request #445 from Spitfireap/dovecot-fixes
Fixes ssl permission error, updated ssl_protocol parameter
2022-10-27 17:56:37 +02:00
Spitap
e01265a4ee Merge branch 'tighter-config-file-perm' of https://github.com/Spitfireap/modoboa-installer into tighter-config-file-perm 2022-10-27 17:44:37 +02:00
Spitap
a5fba03264 tighter config file permission 2022-10-27 17:44:29 +02:00
Spitap
fe7df276fc Check dovecot version greater 2022-10-27 17:25:39 +02:00
Spitap
8f34f0af6f Fixes ssl permission error, updated ssl_protocol parameter 2022-10-27 17:00:58 +02:00
Antoine Nguyen
8e8ae5fb9c Merge pull request #439 from stefaweb/master
Update config_dict_template.py for default max_servers value
2022-10-27 16:49:20 +02:00
Spitap
235ef3befb thighter config file permission 2022-10-27 11:14:06 +02:00
Antoine Nguyen
67f6cee8ea Merge pull request #442 from Spitfireap/patch-1
Set $max_server to 2 to avoid amavis crash
2022-10-25 19:32:37 +02:00
Spitap
5c9d5c9a03 DKIM keys restore, Radicale backup/restore, fixes 2022-10-25 16:58:57 +02:00
Spitap
4c1f8710b5 Added dkim key backup 2022-10-25 16:04:55 +02:00
Spitap
e34eb4b337 fix database path 2022-10-25 13:59:28 +02:00
Spitfireap
53f7f8ef9d Update config_dict_template.py 2022-10-19 08:19:40 +00:00
Spitfireap
35778cd614 Merge branch 'modoboa:master' into restore 2022-10-18 17:17:48 +02:00
Stephane Leclerc
fefbf549a4 Update config_dict_template.py for default max_server value 2022-10-06 13:36:13 +02:00
Spitap
6726f5b1a2 Improved path generation, path mistake proofing 2022-09-26 13:39:28 +02:00
Spitap
a192cbcbd0 Updated doc, default path on conf file 2022-09-19 16:40:25 +02:00
Spitap
5bed9655ea fixed typo 2022-09-19 15:53:19 +02:00
Spitap
6b096a7470 Simplified db dumps restore 2022-09-19 15:50:03 +02:00
Spitap
e30add03fd Update from master 2022-09-19 15:39:05 +02:00
Spitap
d75d83f202 more refactoring 2022-09-19 15:13:44 +02:00
Spitap
f3811b4b39 refactoring 2022-09-19 15:00:26 +02:00
Spitap
b0d56b3989 PEP formating 2022-09-15 11:32:57 +02:00
Spitap
53e3e3ec58 Better UX, use of os to concatenate path 2022-08-05 15:20:11 +02:00
Spitap
e546d2cb23 Better UX 2022-07-27 16:32:59 +02:00
Spitap
70faa1c5cb Fixed backupdir index 2022-07-27 15:58:41 +02:00
Spitap
563979a7dd fixed mail backup/restore 2022-07-27 15:51:22 +02:00
Spitap
ee2ccf0647 Fixed postfix install, added restore to readme 2022-07-27 14:35:48 +02:00
Spitap
2077c94b52 Fix amavis config file not copied to right location 2022-07-26 17:05:00 +02:00
Spitap
4a7222bd24 Fixed nginx call to uwsgi 2022-07-26 16:53:24 +02:00
Spitap
e7b6104195 fixed install within class 2022-07-26 16:39:41 +02:00
Spitap
4a00590354 fixed restore disclamer 2022-07-26 16:20:03 +02:00
Spitap
15768c429e Restore workflow done 2022-07-26 12:07:42 +02:00
Spitap
439ffb94c4 initial commit 2022-07-26 10:37:38 +02:00
Spitap
37bc21dfd3 Backup postewhite.conf instead of custom whitelist
Postwhite.conf contains a custom host list
2022-07-26 10:36:08 +02:00
Spitap
26204143af Merge branch 'master' into backup 2022-07-25 22:10:26 +02:00
Spitap
20970557de Allow to disable mail backup 2022-07-25 22:05:35 +02:00
Spitap
632c26596e Update backup readme 2022-07-25 21:52:15 +02:00
Spitap
9e1c18cd6b Fix argument passed as list instead of string 2022-07-21 19:09:53 +02:00
Spitap
db6457c5f5 better path handling 2022-07-21 19:07:18 +02:00
Spitap
579faccfa5 added an automatic bash option (no path provided) or a path provided bash (for cron job) 2022-07-21 19:00:32 +02:00
Spitap
5318fa279b bash option 2022-07-21 18:00:50 +02:00
Spitap
74de6a9bb1 Reset pgpass before trying to backup secondary dbs 2022-07-21 17:31:56 +02:00
Spitap
54185a7c5a Fix database backup logic issue 2022-07-21 17:26:40 +02:00
Spitap
1f9d69c37c Fix copy issue 2022-07-21 17:21:59 +02:00
Spitap
8d02d2a9fb added safe mkdir in utils, use utils.mkdir_safe() in backup 2022-07-21 17:09:23 +02:00
Spitap
6f604a5fec Fix loop logic 2022-07-21 16:53:56 +02:00
Spitap
568c4a65a0 fix none-type passed to os.path 2022-07-21 16:51:32 +02:00
Spitap
dc84a79528 Note : capitalize affects only first letter 2022-07-21 14:12:35 +02:00
Spitap
304e25fa3c Fix getattr 2022-07-21 14:10:57 +02:00
Spitap
070efd61c4 Fix import 2022-07-21 14:08:39 +02:00
Spitap
9917d8023e Edited README, fix backup run process 2022-07-21 14:02:41 +02:00
Spitap
27b9de6755 database backup 2022-07-21 13:48:44 +02:00
Spitap
56ed214fb5 Starting work on backup system 2022-07-19 19:06:53 +02:00
79 changed files with 2722 additions and 419 deletions

3
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,3 @@
# These are supported funding model platforms
github: [modoboa]

61
.github/workflows/installer.yml vendored Normal file
View File

@@ -0,0 +1,61 @@
name: Modoboa installer
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.9, '3.10', '3.11', '3.12']
fail-fast: false
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
pip install -r test-requirements.txt
- name: Run tests
if: ${{ matrix.python-version != '3.12' }}
run: |
python tests.py
- name: Run tests and coverage
if: ${{ matrix.python-version == '3.12' }}
run: |
coverage run tests.py
- name: Upload coverage result
if: ${{ matrix.python-version == '3.12' }}
uses: actions/upload-artifact@v4
with:
name: coverage-results
path: .coverage
include-hidden-files: true
coverage:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: |
pip install codecov
- name: Download coverage results
uses: actions/download-artifact@v4
with:
name: coverage-results
- name: Report coverage
run: |
coverage report
codecov

32
.github/workflows/versioning.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: Update version file
on:
workflow_run:
branches: [ master ]
workflows: [Modoboa installer]
types:
- completed
jobs:
update-version:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
fetch-depth: 0 # otherwise, there would be errors pushing refs to the destination repository.
ref: ${{ github.head_ref }}
- name: Overwrite file
uses: "DamianReeves/write-file-action@master"
with:
path: version.txt
write-mode: overwrite
contents: ${{ github.sha }}
- name: Commit & Push
uses: Andro999b/push@v1.3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
branch: ${{ github.ref_name }}
force: true
message: '[GitHub Action] Updated version file'

8
.gitignore vendored
View File

@@ -55,3 +55,11 @@ docs/_build/
# PyBuilder
target/
# PyCharm
.idea/
#KDE
*.kdev4
installer.cfg

View File

@@ -1,15 +0,0 @@
sudo: false
language: python
cache: pip
python:
- "2.7"
- "3.4"
before_install:
- pip install -r test-requirements.txt
script:
- coverage run tests.py
after_success:
- codecov

View File

@@ -1,7 +1,7 @@
modoboa-installer
=================
**modoboa-installer**
=====================
|travis| |codecov|
|workflow| |codecov|
An installer which deploy a complete mail server based on Modoboa.
@@ -9,12 +9,11 @@ An installer which deploy a complete mail server based on Modoboa.
This tool is still in beta stage, it has been tested on:
* Debian Buster (10) / Bullseye (11)
* Ubuntu Bionic Beaver (18.04) and upper
* CentOS 7
* Debian 12 and upper
* Ubuntu Focal Fossa (20.04) and upper
.. warning::
``/tmp`` partition must be mounted without the ``noexec`` option.
.. note::
@@ -44,7 +43,7 @@ The following components are installed by the installer:
* Nginx and uWSGI
* Postfix
* Dovecot
* Amavis (with SpamAssassin and ClamAV)
* Amavis (with SpamAssassin and ClamAV) or Rspamd
* automx (autoconfiguration service)
* OpenDKIM
* Radicale (CalDAV and CardDAV server)
@@ -77,7 +76,7 @@ If you want more information about the installation process, add the
``--debug`` option to your command line.
Upgrade mode
------------
============
An experimental upgrade mode is available.
@@ -92,8 +91,68 @@ You can activate it as follows::
It will automatically install latest versions of modoboa and its plugins.
Backup mode
===========
An experimental backup mode is available.
.. warning::
You must keep the original configuration file, i.e. the one used for
the installation. Otherwise, you will need to recreate it manually with the right information!
You can start the process as follows::
$ sudo ./run.py --backup <your domain>
Then follow the step on the console.
There is also a non-interactive mode::
$ sudo ./run.py --silent-backup <your domain>
You can also add a path, else it will be saved in ./modoboa_backup/Backup_M_Y_d_H_M::
$ sudo ./run.py --silent-backup --backup-path "/My_Backup_Path" <your domain>
if you want to disable mail backup::
$ sudo ./run.py --backup --no-mail <your domain>
This can be useful for larger instance
1. Silent mode
Command::
$ sudo ./run.py --silent-backup <your domain>
This mode will run silently. When executed, it will create
/modoboa_backup/ and each time you execute it, it will create a new
backup directory with current date and time.
You can supply a custom path if needed::
$ sudo ./run.py --silent-backup --backup-path /path/of/backup/directory <your domain>
If you want to disable emails backup, disable dovecot in the
configuration file (set enabled to False).
This can be useful for larger instance.
Restore mode
============
An experimental restore mode is available.
You can start the process as follows::
$ sudo ./run.py --restore /path/to/backup/directory/ <your domain>
Then wait for the process to finish.
Change the generated hostname
-----------------------------
=============================
By default, the installer will setup your email server using the
following hostname: ``mail.<your domain>``. If you want a different
@@ -112,14 +171,26 @@ modifications.
Finally, run the installer without the
``--stop-after-configfile-check`` option.
Let's Encrypt certificate
-------------------------
Certificate
===========
Self-signed
-----------
It is the default type of certificate the installer will generate, it
is however not recommended for production use.
Letsencrypt
-----------
.. warning::
Please note this option requires the hostname you're using to be
valid (ie. it can be resolved with a DNS query) and to match the
server you're installing Modoboa on.
Please note that by using this option, you agree to the `ToS
<https://community.letsencrypt.org/tos>`_ of
letsencrypt and that your IP will be logged (see ToS).
Please also note this option requires the hostname you're using to be
valid (ie. it can be resolved with a DNS query) and to match the
server you're installing Modoboa on.
If you want to generate a valid certificate using `Let's Encrypt
<https://letsencrypt.org/>`_, edit the ``installer.cfg`` file and
@@ -128,6 +199,8 @@ modify the following settings::
[certificate]
generate = true
type = letsencrypt
tls_cert_file_path =
tls_key_file_path =
[letsencrypt]
email = admin@example.com
@@ -135,7 +208,43 @@ modify the following settings::
Change the ``email`` setting to a valid value since it will be used
for account recovery.
.. |travis| image:: https://travis-ci.org/modoboa/modoboa-installer.png?branch=master
:target: https://travis-ci.org/modoboa/modoboa-installer
.. |codecov| image:: http://codecov.io/github/modoboa/modoboa-installer/coverage.svg?branch=master
:target: http://codecov.io/github/modoboa/modoboa-installer?branch=master
Manual
------
.. warning::
It is not possible to configure manual certs interactively, so
you'll have to do it in 2 steps. Please run ``run.py`` with
`--stop-after-configfile-check` first, configure your file as
desired and apply the configuration as written bellow. Then run
``run.py`` again but without `--stop-after-configfile-check` or
`--interactive`.
If you want to use already generated certs, simply edit the
``installer.cfg`` file and modify the following settings::
[certificate]
generate = true
type = manual
tls_cert_file_path = *path to tls fullchain file*
tls_key_file_path = *path to tls key file*
Antispam
========
You have 3 options regarding antispam : disabled, Amavis, Rspamd
Amavis
------
Amavis
Rspamd
------
Rspamd
.. |workflow| image:: https://github.com/modoboa/modoboa-installer/workflows/Modoboa%20installer/badge.svg
.. |codecov| image:: https://codecov.io/gh/modoboa/modoboa-installer/graph/badge.svg?token=Fo2o1GdHZq
:target: https://codecov.io/gh/modoboa/modoboa-installer

View File

@@ -0,0 +1,37 @@
"""Checks to be performed before any install or upgrade"""
import sys
from urllib.request import urlopen
from modoboa_installer import utils
def check_version():
local_version = ""
with open("version.txt", "r") as version:
local_version = version.readline()
remote_version = ""
with urlopen("https://raw.githubusercontent.com/modoboa/modoboa-installer/master/version.txt") as r_version:
remote_version = r_version.read().decode()
if local_version == "" or remote_version == "":
utils.printcolor(
"Could not check that your installer is up-to-date: "
f"local version: {local_version}, "
f"remote version: {remote_version}",
utils.YELLOW
)
if remote_version != local_version:
utils.error(
"Your installer seems outdated.\n"
"Check README file for instructions about how to update.\n"
"No support will be provided without an up-to-date installer!"
)
answer = utils.user_input("Continue anyway? (y/N) ")
if not answer.lower().startswith("y"):
sys.exit(0)
else:
utils.success("Installer seems up to date!")
def handle():
check_version()

View File

@@ -20,9 +20,27 @@ COMPATIBILITY_MATRIX = {
"modoboa-pdfcredentials": ">=1.1.1",
"modoboa-sievefilters": ">=1.1.1",
"modoboa-webmail": ">=1.2.0",
}
},
}
EXTENSIONS_AVAILABILITY = {
"modoboa-contacts": "1.7.4",
}
REMOVED_EXTENSIONS = {
"modoboa-pdfcredentials": "2.1.0",
"modoboa-dmarc": "2.1.0",
"modoboa-imap-migration": "2.1.0",
"modoboa-sievefilters": "2.3.0",
"modoboa-postfix-autoreply": "2.3.0",
"modoboa-contacts": "2.4.0",
"modoboa-radicale": "2.4.0",
"modoboa-webmail": "2.4.0",
}
APP_INCOMPATIBILITY = {
"opendkim": ["rspamd"],
"amavis": ["rspamd"],
"postwhite": ["rspamd"],
"spamassassin": ["rspamd"]
}

View File

@@ -1,6 +1,8 @@
import random
import string
from .constants import DEFAULT_BACKUP_DIRECTORY
def make_password(length=16):
"""Create a random password."""
@@ -26,24 +28,49 @@ ConfigDictTemplate = [
]
},
{
"name": "certificate",
"name": "antispam",
"values": [
{
"option": "generate",
"option": "enabled",
"default": "true",
"customizable": True,
"values": ["true", "false"],
"question": "Do you want to setup an antispam utility?"
},
{
"option": "type",
"default": "amavis",
"customizable": True,
"question": "Please select your antispam utility",
"values": ["rspamd", "amavis"],
"if": ["antispam.enabled=true"]
}
]
},
{
"name": "certificate",
"values": [
{
"option": "type",
"default": "self-signed",
"customizable": True,
"question": "Please choose your certificate type",
"values": ["self-signed", "letsencrypt"],
"values": ["self-signed", "letsencrypt", "manual"],
"non_interactive_values": ["manual"],
},
{
"option": "tls_cert_file_path",
"default": ""
},
{
"option": "tls_key_file_path",
"default": ""
}
],
},
{
"name": "letsencrypt",
"if": "certificate.type=letsencrypt",
"if": ["certificate.type=letsencrypt"],
"values": [
{
"option": "email",
@@ -78,7 +105,7 @@ ConfigDictTemplate = [
},
{
"name": "postgres",
"if": "database.engine=postgres",
"if": ["database.engine=postgres"],
"values": [
{
"option": "user",
@@ -94,7 +121,7 @@ ConfigDictTemplate = [
},
{
"name": "mysql",
"if": "database.engine=mysql",
"if": ["database.engine=mysql"],
"values": [
{
"option": "user",
@@ -116,6 +143,31 @@ ConfigDictTemplate = [
}
]
},
{
"name": "fail2ban",
"values": [
{
"option": "enabled",
"default": "true",
},
{
"option": "config_dir",
"default": "/etc/fail2ban"
},
{
"option": "max_retry",
"default": "20"
},
{
"option": "ban_time",
"default": "3600"
},
{
"option": "find_time",
"default": "30"
},
]
},
{
"name": "modoboa",
"values": [
@@ -153,14 +205,16 @@ ConfigDictTemplate = [
"customizable": True,
"question": "Please enter Modoboa db password",
},
{
"option": "cron_error_recipient",
"default": "root",
"customizable": True,
"question":
"Please enter a mail recipient for cron error reports"
},
{
"option": "extensions",
"default": (
"modoboa-amavis modoboa-pdfcredentials "
"modoboa-postfix-autoreply modoboa-sievefilters "
"modoboa-webmail modoboa-contacts "
"modoboa-radicale"
),
"default": ""
},
{
"option": "devmode",
@@ -197,12 +251,60 @@ ConfigDictTemplate = [
},
]
},
{
"name": "rspamd",
"if": ["antispam.enabled=true", "antispam.type=rspamd"],
"values": [
{
"option": "enabled",
"default": ["antispam.enabled=true", "antispam.type=rspamd"],
},
{
"option": "user",
"default": "_rspamd",
},
{
"option": "password",
"default": make_password,
"customizable": True,
"question": "Please enter Rspamd interface password",
},
{
"option": "dnsbl",
"default": "true",
},
{
"option": "dkim_keys_storage_dir",
"default": "/var/lib/dkim"
},
{
"option": "key_map_path",
"default": "/var/lib/dkim/keys.path.map"
},
{
"option": "selector_map_path",
"default": "/var/lib/dkim/selectors.path.map"
},
{
"option": "greylisting",
"default": "true"
},
{
"option": "whitelist_auth",
"default": "true"
},
{
"option": "whitelist_auth_weigth",
"default": "-5"
}
],
},
{
"name": "amavis",
"values": [
{
"option": "enabled",
"default": "true",
"default": ["antispam.enabled=true", "antispam.type=amavis"],
},
{
"option": "user",
@@ -210,7 +312,7 @@ ConfigDictTemplate = [
},
{
"option": "max_servers",
"default": "1",
"default": "2",
},
{
"option": "dbname",
@@ -223,8 +325,6 @@ ConfigDictTemplate = [
{
"option": "dbpassword",
"default": make_password,
"customizable": True,
"question": "Please enter amavis db password"
},
],
},
@@ -254,7 +354,7 @@ ConfigDictTemplate = [
},
{
"option": "user",
"default": "vmail",
"default": "dovecot",
},
{
"option": "home_dir",
@@ -274,7 +374,11 @@ ConfigDictTemplate = [
},
{
"option": "radicale_auth_socket_path",
"default": "/var/run/dovecot/auth-radicale"
"default": "/var/run/dovecot/auth-radicale",
},
{
"option": "move_spam_to_junk",
"default": "true",
},
]
},
@@ -296,7 +400,7 @@ ConfigDictTemplate = [
"values": [
{
"option": "enabled",
"default": "true",
"default": "false",
},
{
"option": "config_dir",
@@ -319,6 +423,10 @@ ConfigDictTemplate = [
"option": "message_size_limit",
"default": "11534336",
},
{
"option": "dhe_group",
"default": "4096"
}
]
},
{
@@ -326,7 +434,7 @@ ConfigDictTemplate = [
"values": [
{
"option": "enabled",
"default": "true",
"default": ["antispam.enabled=true", "antispam.type=amavis"],
},
{
"option": "config_dir",
@@ -336,10 +444,11 @@ ConfigDictTemplate = [
},
{
"name": "spamassassin",
"if": ["antispam.enabled=true", "antispam.type=amavis"],
"values": [
{
"option": "enabled",
"default": "true",
"default": ["antispam.enabled=true", "antispam.type=amavis"],
},
{
"option": "config_dir",
@@ -374,7 +483,7 @@ ConfigDictTemplate = [
},
{
"option": "nb_processes",
"default": "2",
"default": "4",
},
]
},
@@ -405,10 +514,11 @@ ConfigDictTemplate = [
},
{
"name": "opendkim",
"if": ["antispam.enabled=true", "antispam.type=amavis"],
"values": [
{
"option": "enabled",
"default": "true",
"default": ["antispam.enabled=true", "antispam.type=amavis"],
},
{
"option": "user",
@@ -439,4 +549,13 @@ ConfigDictTemplate = [
]
},
{
"name": "backup",
"values": [
{
"option": "default_path",
"default": DEFAULT_BACKUP_DIRECTORY
}
]
}
]

View File

@@ -0,0 +1 @@
DEFAULT_BACKUP_DIRECTORY = "./modoboa_backup/"

View File

@@ -103,7 +103,7 @@ class PostgreSQL(Database):
def create_database(self, name, owner):
"""Create a database."""
code, output = utils.exec_cmd(
"psql -lqt | cut -d \| -f 1 | grep -w {} | wc -l"
"psql -lqt | cut -d \\| -f 1 | grep -w {} | wc -l"
.format(name), sudo_user=self.dbuser)
if code:
return
@@ -146,6 +146,15 @@ class PostgreSQL(Database):
self.dbhost, self.dbport, dbname, dbuser, path)
utils.exec_cmd(cmd, sudo_user=self.dbuser)
def dump_database(self, dbname, dbuser, dbpassword, path):
"""Dump DB to SQL file."""
# Reset pgpass since we backup multiple db (different secret set)
self._pgpass_done = False
self._setup_pgpass(dbname, dbuser, dbpassword)
cmd = "pg_dump -h {} -d {} -U {} -O -w > {}".format(
self.dbhost, dbname, dbuser, path)
utils.exec_cmd(cmd, sudo_user=self.dbuser)
class MySQL(Database):
@@ -169,12 +178,16 @@ class MySQL(Database):
if name.startswith("debian"):
if version.startswith("8"):
self.packages["deb"].append("libmysqlclient-dev")
elif version.startswith("11"):
elif version.startswith("11") or version.startswith("12"):
self.packages["deb"].append("libmariadb-dev")
else:
self.packages["deb"].append("libmariadbclient-dev")
elif name == "ubuntu":
self.packages["deb"].append("libmysqlclient-dev")
if version.startswith("2"):
# Works for Ubuntu 20, 22, and 24.
self.packages["deb"].append("libmariadb-dev")
else:
self.packages["deb"].append("libmysqlclient-dev")
super(MySQL, self).install_package()
queries = []
if name.startswith("debian"):
@@ -186,12 +199,15 @@ class MySQL(Database):
"mariadb-server", "root_password_again", "password",
self.dbpassword)
return
if version.startswith("11"):
queries = [
"SET PASSWORD FOR 'root'@'localhost' = PASSWORD('{}')"
.format(self.dbpassword),
"flush privileges"
]
if (
(name.startswith("debian") and (version.startswith("11") or version.startswith("12"))) or
(name.startswith("ubuntu") and int(version[:2]) >= 22)
):
queries = [
"SET PASSWORD FOR 'root'@'localhost' = PASSWORD('{}')"
.format(self.dbpassword),
"flush privileges"
]
if not queries:
queries = [
"UPDATE user SET plugin='' WHERE user='root'",
@@ -258,6 +274,12 @@ class MySQL(Database):
self.dbhost, self.dbport, dbuser, dbpassword, dbname, path)
)
def dump_database(self, dbname, dbuser, dbpassword, path):
"""Dump DB to SQL file."""
cmd = "mysqldump -h {} -u {} -p{} {} > {}".format(
self.dbhost, dbuser, dbpassword, dbname, path)
utils.exec_cmd(cmd, sudo_user=self.dbuser)
def get_backend(config):
"""Return appropriate backend."""

View File

@@ -0,0 +1,51 @@
from . import utils
def installation_disclaimer(args, config):
"""Display installation disclaimer."""
hostname = config.get("general", "hostname")
utils.printcolor(
"Notice:\n"
"It is recommanded to run this installer on a FRESHLY installed server.\n"
"(ie. with nothing special already installed on it)\n",
utils.CYAN
)
utils.printcolor(
"Warning:\n"
"Before you start the installation, please make sure the following "
"DNS records exist for domain '{}':\n"
" {} IN A <IP ADDRESS OF YOUR SERVER>\n"
" @ IN MX {}.\n".format(
args.domain,
hostname.replace(".{}".format(args.domain), ""),
hostname
),
utils.YELLOW
)
utils.printcolor(
"Your mail server will be installed with the following components:",
utils.BLUE)
def upgrade_disclaimer(config):
"""Display upgrade disclaimer."""
utils.printcolor(
"Your mail server is about to be upgraded and the following components"
" will be impacted:", utils.BLUE
)
def backup_disclaimer():
"""Display backup disclamer. """
utils.printcolor(
"Your mail server will be backed up locally.\n"
" !! You should really transfer the backup somewhere else...\n"
" !! Custom configuration (like for postfix) won't be saved.", utils.BLUE)
def restore_disclaimer():
"""Display restore disclamer. """
utils.printcolor(
"You are about to restore a previous installation of Modoboa.\n"
"If a new version has been released in between, please update your database!",
utils.BLUE)

View File

@@ -2,10 +2,12 @@
import re
from os.path import isfile as file_exists
from . import utils
class Package(object):
class Package:
"""Base classe."""
def __init__(self, dist_name):
@@ -29,10 +31,17 @@ class DEBPackage(Package):
FORMAT = "deb"
def __init__(self, dist_name):
super(DEBPackage, self).__init__(dist_name)
super().__init__(dist_name)
self.index_updated = False
self.policy_file = "/usr/sbin/policy-rc.d"
def enable_backports(self, codename):
code, output = utils.exec_cmd(f"grep {codename}-backports /etc/apt/sources.list")
if code:
with open(f"/etc/apt/sources.list.d/backports.list", "w") as fp:
fp.write(f"deb http://deb.debian.org/debian {codename}-backports main\n")
self.update(force=True)
def prepare_system(self):
"""Make sure services don't start at installation."""
with open(self.policy_file, "w") as fp:
@@ -42,11 +51,34 @@ class DEBPackage(Package):
def restore_system(self):
utils.exec_cmd("rm -f {}".format(self.policy_file))
def update(self):
def add_custom_repository(self,
name: str,
url: str,
key_url: str,
codename: str,
with_source: bool = True):
key_file = f"/etc/apt/keyrings/{name}.gpg"
utils.exec_cmd(
f"wget -O - {key_url} | gpg --dearmor | tee {key_file} > /dev/null"
)
line_types = ["deb"]
if with_source:
line_types.append("deb-src")
for line_type in line_types:
line = (
f"{line_type} [arch=amd64 signed-by={key_file}] "
f"{url} {codename} main"
)
target_file = f"/etc/apt/sources.list.d/{name}.list"
tee_option = "-a" if file_exists(target_file) else ""
utils.exec_cmd(f'echo "{line}" | tee {tee_option} {target_file}')
self.index_updated = False
def update(self, force=False):
"""Update local cache."""
if self.index_updated:
if self.index_updated and not force:
return
utils.exec_cmd("apt-get update --quiet")
utils.exec_cmd("apt-get -o Dpkg::Progress-Fancy=0 update --quiet")
self.index_updated = True
def preconfigure(self, name, question, qtype, answer):
@@ -57,18 +89,18 @@ class DEBPackage(Package):
def install(self, name):
"""Install a package."""
self.update()
utils.exec_cmd("apt-get install --quiet --assume-yes {}".format(name))
utils.exec_cmd("apt-get -o Dpkg::Progress-Fancy=0 install --quiet --assume-yes -o DPkg::options::=--force-confold {}".format(name))
def install_many(self, names):
"""Install many packages."""
self.update()
return utils.exec_cmd("apt-get install --quiet --assume-yes {}".format(
return utils.exec_cmd("apt-get -o Dpkg::Progress-Fancy=0 install --quiet --assume-yes -o DPkg::options::=--force-confold {}".format(
" ".join(names)))
def get_installed_version(self, name):
"""Get installed package version."""
code, output = utils.exec_cmd(
"dpkg -s {} | grep Version".format(name), capture_output=True)
"dpkg -s {} | grep Version".format(name))
match = re.match(r"Version: (\d:)?(.+)-\d", output.decode())
if match:
return match.group(2)
@@ -82,7 +114,7 @@ class RPMPackage(Package):
def __init__(self, dist_name):
"""Initialize backend."""
super(RPMPackage, self).__init__(dist_name)
super().__init__(dist_name)
if "centos" in dist_name:
self.install("epel-release")
@@ -97,7 +129,7 @@ class RPMPackage(Package):
def get_installed_version(self, name):
"""Get installed package version."""
code, output = utils.exec_cmd(
"rpm -qi {} | grep Version".format(name), capture_output=True)
"rpm -qi {} | grep Version".format(name))
match = re.match(r"Version\s+: (.+)", output.decode())
if match:
return match.group(1)
@@ -108,7 +140,7 @@ def get_backend():
"""Return the appropriate package backend."""
distname = utils.dist_name()
backend = None
if distname in ["debian", "debian gnu/linux", "ubuntu"]:
if distname in ["debian", "debian gnu/linux", "ubuntu", "linuxmint"]:
backend = DEBPackage
elif "centos" in distname:
backend = RPMPackage

View File

@@ -1,6 +1,7 @@
"""Python related tools."""
import os
import sys
from . import package
from . import utils
@@ -45,6 +46,40 @@ def install_packages(names, venv=None, upgrade=False, **kwargs):
utils.exec_cmd(cmd, **kwargs)
def get_package_version(name, venv=None, **kwargs):
"""Returns the version of an installed package."""
cmd = "{} show {}".format(
get_pip_path(venv),
name
)
exit_code, output = utils.exec_cmd(cmd, **kwargs)
if exit_code != 0:
utils.error(f"Failed to get version of {name}. "
f"Output is: {output}")
sys.exit(1)
version_list_clean = []
for line in output.decode().split("\n"):
if not line.startswith("Version:"):
continue
version_item_list = line.split(":")
version_list = version_item_list[1].split(".")
for element in version_list:
try:
version_list_clean.append(int(element))
except ValueError:
utils.printcolor(
f"Failed to decode some part of the version of {name}",
utils.YELLOW)
version_list_clean.append(element)
if len(version_list_clean) == 0:
utils.printcolor(
f"Failed to find the version of {name}",
utils.RED)
sys.exit(1)
return version_list_clean
def install_package_from_repository(name, url, vcs="git", venv=None, **kwargs):
"""Install a Python package from its repository."""
if vcs == "git":
@@ -54,26 +89,17 @@ def install_package_from_repository(name, url, vcs="git", venv=None, **kwargs):
utils.exec_cmd(cmd, **kwargs)
def setup_virtualenv(path, sudo_user=None, python_version=2):
def setup_virtualenv(path, sudo_user=None):
"""Install a virtualenv if needed."""
if os.path.exists(path):
return
if python_version == 2:
python_binary = "python"
packages = ["python-virtualenv"]
if utils.dist_name() == "debian":
packages.append("virtualenv")
if utils.dist_name().startswith("centos"):
python_binary = "python3"
packages = ["python3"]
else:
if utils.dist_name().startswith("centos"):
python_binary = "python3"
packages = ["python3"]
else:
python_binary = "python3"
packages = ["python3-venv"]
python_binary = "python3"
packages = ["python3-venv"]
package.backend.install_many(packages)
with utils.settings(sudo_user=sudo_user):
if python_version == 2:
utils.exec_cmd("virtualenv {}".format(path))
else:
utils.exec_cmd("{} -m venv {}".format(python_binary, path))
install_packages(["pip", "setuptools\<58.0.0"], venv=path, upgrade=True)
utils.exec_cmd("{} -m venv {}".format(python_binary, path))
install_packages(["pip", "setuptools"], venv=path, upgrade=True)

View File

@@ -6,20 +6,49 @@ import sys
from .. import utils
def install(appname, config, upgrade):
"""Install an application."""
if (config.has_option(appname, "enabled") and
not config.getboolean(appname, "enabled")):
return
utils.printcolor("Installing {}".format(appname), utils.MAGENTA)
def load_app_script(appname):
"""Load module corresponding to the given appname."""
try:
script = importlib.import_module(
"modoboa_installer.scripts.{}".format(appname))
except ImportError:
print("Unknown application {}".format(appname))
sys.exit(1)
return script
def install(appname: str, config, upgrade: bool, archive_path: str):
"""Install an application."""
if (config.has_option(appname, "enabled") and
not config.getboolean(appname, "enabled")):
return
utils.printcolor("Installing {}".format(appname), utils.MAGENTA)
script = load_app_script(appname)
try:
getattr(script, appname.capitalize())(config, upgrade).run()
getattr(script, appname.capitalize())(config, upgrade, archive_path).run()
except utils.FatalError as inst:
utils.printcolor(u"{}".format(inst), utils.RED)
utils.error("{}".format(inst))
sys.exit(1)
def backup(appname, config, path):
"""Backup an application."""
if (config.has_option(appname, "enabled") and
not config.getboolean(appname, "enabled")):
return
utils.printcolor("Backing up {}".format(appname), utils.MAGENTA)
script = load_app_script(appname)
try:
getattr(script, appname.capitalize())(config, False, False).backup(path)
except utils.FatalError as inst:
utils.error("{}".format(inst))
sys.exit(1)
def restore_prep(restore):
"""Restore instance"""
script = importlib.import_module(
"modoboa_installer.scripts.restore")
getattr(script, "Restore")(restore)

View File

@@ -1,13 +1,12 @@
"""Amavis related functions."""
import os
import platform
from .. import package
from .. import utils
from . import base
from . import install
from . import backup, install
class Amavis(base.Installer):
@@ -83,7 +82,7 @@ class Amavis(base.Installer):
path = self.get_file_path(
"amavis_{}_{}.sql".format(self.dbengine, version))
if not os.path.exists(path):
raise utils.FatalError("Failed to find amavis database schema")
raise utils.FatalError("Failed to find amavis database schema")
return path
def pre_run(self):
@@ -93,5 +92,25 @@ class Amavis(base.Installer):
def post_run(self):
"""Additional tasks."""
install("spamassassin", self.config, self.upgrade)
install("clamav", self.config, self.upgrade)
install("spamassassin", self.config, self.upgrade, self.archive_path)
install("clamav", self.config, self.upgrade, self.archive_path)
def custom_backup(self, path):
"""Backup custom configuration if any."""
if package.backend.FORMAT == "deb":
amavis_custom = f"{self.config_dir}/conf.d/99-custom"
if os.path.isfile(amavis_custom):
utils.copy_file(amavis_custom, path)
utils.success("Amavis custom configuration saved!")
backup("spamassassin", self.config, os.path.dirname(path))
def restore(self):
"""Restore custom config files."""
if package.backend.FORMAT != "deb":
return
amavis_custom_configuration = os.path.join(
self.archive_path, "custom/99-custom")
if os.path.isfile(amavis_custom_configuration):
utils.copy_file(amavis_custom_configuration, os.path.join(
self.config_dir, "conf.d"))
utils.success("Custom amavis configuration restored.")

View File

@@ -44,22 +44,21 @@ class Automx(base.Installer):
sql_query = (
"SELECT first_name || ' ' || last_name AS display_name, email"
", SPLIT_PART(email, '@', 2) AS domain "
"FROM core_user WHERE email='%s' AND is_active")
"FROM core_user WHERE email='%s' AND is_active;")
else:
sql_query = (
"SELECT concat(first_name, ' ', last_name) AS display_name, "
"email, SUBSTRING_INDEX(email, '@', -1) AS domain "
"FROM core_user WHERE email='%s' AND is_active=1"
"FROM core_user WHERE email='%s' AND is_active=1;"
)
context.update({"sql_dsn": sql_dsn, "sql_query": sql_query})
return context
def _setup_venv(self):
"""Prepare a python virtualenv."""
python.setup_virtualenv(
self.venv_path, sudo_user=self.user, python_version=3)
python.setup_virtualenv(self.venv_path, sudo_user=self.user)
packages = [
"future", "lxml", "ipaddress", "sqlalchemy", "python-memcached",
"future", "lxml", "ipaddress", "sqlalchemy < 2.0", "python-memcached",
"python-dateutil", "configparser"
]
if self.dbengine == "postgres":

View File

@@ -0,0 +1,226 @@
"""Backup script for pre-installed instance."""
import os
import pwd
import shutil
import stat
import sys
import datetime
from .. import database
from .. import utils
from ..constants import DEFAULT_BACKUP_DIRECTORY
class Backup:
"""
Backup structure ( {optional} ):
{{backup_directory}}
||
||--> installer.cfg
||--> custom
|--> { (copy of) /etc/amavis/conf.d/99-custom }
|--> { (copy of) /etc/postfix/custom_whitelist.cidr }
|--> { (copy of) dkim directory }
|--> {dkim.pem}...
|--> { (copy of) radicale home_dir }
||--> databases
|--> modoboa.sql
|--> { amavis.sql }
|--> { spamassassin.sql }
||--> mails
|--> vmails
"""
def __init__(self, config, silent_backup, backup_path, nomail):
self.config = config
self.backup_path = backup_path
self.nomail = nomail
self.silent_backup = silent_backup
def validate_path(self, path):
"""Check basic condition for backup directory."""
path_exists = os.path.exists(path)
if path_exists and os.path.isfile(path):
utils.error("Error, you provided a file instead of a directory!")
return False
if not path_exists:
if not self.silent_backup:
create_dir = input(
f"\"{path}\" doesn't exist, would you like to create it? [Y/n]\n").lower()
if self.silent_backup or (not self.silent_backup and create_dir.startswith("y")):
pw = pwd.getpwnam("root")
utils.mkdir_safe(path, stat.S_IRWXU |
stat.S_IRWXG, pw[2], pw[3])
else:
utils.error("Error, backup directory not present.")
return False
if len(os.listdir(path)) != 0:
if not self.silent_backup:
delete_dir = input(
"Warning: backup directory is not empty, it will be purged if you continue... [Y/n]\n").lower()
if self.silent_backup or (not self.silent_backup and delete_dir.startswith("y")):
try:
os.remove(os.path.join(path, "installer.cfg"))
except FileNotFoundError:
pass
shutil.rmtree(os.path.join(path, "custom"),
ignore_errors=False)
shutil.rmtree(os.path.join(path, "mails"), ignore_errors=False)
shutil.rmtree(os.path.join(path, "databases"),
ignore_errors=False)
else:
utils.error("Error: backup directory not clean.")
return False
self.backup_path = path
pw = pwd.getpwnam("root")
for dir in ["custom/", "databases/"]:
utils.mkdir_safe(os.path.join(self.backup_path, dir),
stat.S_IRWXU | stat.S_IRWXG, pw[2], pw[3])
return True
def set_path(self):
"""Setup backup directory."""
if self.silent_backup:
if self.backup_path is None:
if self.config.has_option("backup", "default_path"):
path = self.config.get("backup", "default_path")
else:
path = DEFAULT_BACKUP_DIRECTORY
date = datetime.datetime.now().strftime("%m_%d_%Y_%H_%M")
path = os.path.join(path, f"backup_{date}")
self.validate_path(path)
else:
if not self.validate_path(self.backup_path):
utils.printcolor(
f"Path provided: {self.backup_path}", utils.BLUE)
sys.exit(1)
else:
user_value = None
while user_value == "" or user_value is None or not self.validate_path(user_value):
utils.printcolor(
"Enter backup path (it must be an empty directory)", utils.MAGENTA)
utils.printcolor("CTRL+C to cancel", utils.MAGENTA)
user_value = utils.user_input("-> ")
def config_file_backup(self):
utils.copy_file("installer.cfg", self.backup_path)
def mail_backup(self):
if self.nomail:
utils.printcolor(
"Skipping mail backup, no-mail argument provided", utils.MAGENTA)
return
utils.printcolor("Backing up mails", utils.MAGENTA)
home_path = self.config.get("dovecot", "home_dir")
if not os.path.exists(home_path) or os.path.isfile(home_path):
utils.error("Error backing up Email, provided path "
f" ({home_path}) seems not right...")
else:
dst = os.path.join(self.backup_path, "mails/")
if os.path.exists(dst):
shutil.rmtree(dst)
shutil.copytree(home_path, dst)
utils.printcolor("Mail backup complete!", utils.GREEN)
def custom_config_backup(self):
"""
Custom config :
- DKIM keys: {{keys_storage_dir}}
- Radicale collection (calendars, contacts): {{home_dir}}
- Amavis : /etc/amavis/conf.d/99-custom
- Postwhite : /etc/postwhite.conf
Feel free to suggest to add others!
"""
utils.printcolor(
"Backing up some custom configuration...", utils.MAGENTA)
custom_path = os.path.join(
self.backup_path, "custom")
# DKIM Key
if (self.config.has_option("opendkim", "enabled") and
self.config.getboolean("opendkim", "enabled")):
dkim_keys = self.config.get(
"opendkim", "keys_storage_dir", fallback="/var/lib/dkim")
if os.path.isdir(dkim_keys):
shutil.copytree(dkim_keys, os.path.join(custom_path, "dkim"))
utils.printcolor(
"DKIM keys saved!", utils.GREEN)
# Radicale Collections
if (self.config.has_option("radicale", "enabled") and
self.config.getboolean("radicale", "enabled")):
radicale_backup = os.path.join(self.config.get(
"radicale", "home_dir", fallback="/srv/radicale"), "collections")
if os.path.isdir(radicale_backup):
shutil.copytree(radicale_backup, os.path.join(
custom_path, "radicale"))
utils.printcolor("Radicale files saved", utils.GREEN)
# AMAVIS
if (self.config.has_option("amavis", "enabled") and
self.config.getboolean("amavis", "enabled")):
amavis_custom = "/etc/amavis/conf.d/99-custom"
if os.path.isfile(amavis_custom):
utils.copy_file(amavis_custom, custom_path)
utils.printcolor(
"Amavis custom configuration saved!", utils.GREEN)
# POSTWHITE
if (self.config.has_option("postwhite", "enabled") and
self.config.getboolean("postwhite", "enabled")):
postswhite_custom = "/etc/postwhite.conf"
if os.path.isfile(postswhite_custom):
utils.copy_file(postswhite_custom, custom_path)
utils.printcolor(
"Postwhite configuration saved!", utils.GREEN)
def database_backup(self):
"""Backing up databases"""
utils.printcolor("Backing up databases...", utils.MAGENTA)
self.database_dump("modoboa")
self.database_dump("amavis")
self.database_dump("spamassassin")
def database_dump(self, app_name):
dump_path = os.path.join(self.backup_path, "databases")
backend = database.get_backend(self.config)
if app_name == "modoboa" or (self.config.has_option(app_name, "enabled") and
self.config.getboolean(app_name, "enabled")):
dbname = self.config.get(app_name, "dbname")
dbuser = self.config.get(app_name, "dbuser")
dbpasswd = self.config.get(app_name, "dbpassword")
backend.dump_database(dbname, dbuser, dbpasswd,
os.path.join(dump_path, f"{app_name}.sql"))
def backup_completed(self):
utils.printcolor("Backup process done, your backup is available here:"
f"--> {self.backup_path}", utils.GREEN)
def run(self):
self.set_path()
self.config_file_backup()
self.mail_backup()
self.custom_config_backup()
self.database_backup()
self.backup_completed()

View File

@@ -5,11 +5,12 @@ import sys
from .. import database
from .. import package
from .. import python
from .. import system
from .. import utils
class Installer(object):
class Installer:
"""Simple installer for one application."""
appname = None
@@ -20,10 +21,11 @@ class Installer(object):
with_db = False
config_files = []
def __init__(self, config, upgrade):
def __init__(self, config, upgrade: bool, archive_path: str):
"""Get configuration."""
self.config = config
self.upgrade = upgrade
self.archive_path = archive_path
if self.config.has_section(self.appname):
self.app_config = dict(self.config.items(self.appname))
self.dbengine = self.config.get("database", "engine")
@@ -41,6 +43,20 @@ class Installer(object):
self.dbuser = self.config.get(self.appname, "dbuser")
self.dbpasswd = self.config.get(self.appname, "dbpassword")
@property
def modoboa_2_2_or_greater(self):
# Check if modoboa version > 2.2
modoboa_version = python.get_package_version(
"modoboa",
self.config.get("modoboa", "venv_path"),
sudo_user=self.config.get("modoboa", "user")
)
condition = (
(int(modoboa_version[0]) == 2 and int(modoboa_version[1]) >= 2) or
int(modoboa_version[0]) > 2
)
return condition
@property
def config_dir(self):
"""Return main configuration directory."""
@@ -53,6 +69,19 @@ class Installer(object):
"""Return a schema to install."""
return None
def get_sql_schema_from_backup(self):
"""Retrieve a dump path from a previous backup."""
utils.printcolor(
f"Trying to restore {self.appname} database from backup.",
utils.MAGENTA
)
database_backup_path = os.path.join(
self.archive_path, f"databases/{self.appname}.sql")
if os.path.isfile(database_backup_path):
utils.success(f"SQL dump found in backup for {self.appname}!")
return database_backup_path
return None
def get_file_path(self, fname):
"""Return the absolute path of this file."""
return os.path.abspath(
@@ -66,7 +95,11 @@ class Installer(object):
return
self.backend.create_user(self.dbuser, self.dbpasswd)
self.backend.create_database(self.dbname, self.dbuser)
schema = self.get_sql_schema_path()
schema = None
if self.archive_path:
schema = self.get_sql_schema_from_backup()
if not schema:
schema = self.get_sql_schema_path()
if schema:
self.backend.load_sql_file(
self.dbname, self.dbuser, self.dbpasswd, schema)
@@ -113,7 +146,7 @@ class Installer(object):
return
exitcode, output = package.backend.install_many(packages)
if exitcode:
utils.printcolor("Failed to install dependencies", utils.RED)
utils.error("Failed to install dependencies")
sys.exit(1)
def get_config_files(self):
@@ -137,6 +170,20 @@ class Installer(object):
dst = os.path.join(self.config_dir, dst)
utils.copy_from_template(src, dst, context)
def backup(self, path):
if self.with_db:
self._dump_database(path)
custom_backup_path = os.path.join(path, "custom")
self.custom_backup(custom_backup_path)
def custom_backup(self, path):
"""Override this method in subscripts to add custom backup content."""
pass
def restore(self):
"""Restore from a previous backup."""
pass
def get_daemon_name(self):
"""Return daemon name if defined."""
return self.daemon_name if self.daemon_name else self.appname
@@ -157,8 +204,17 @@ class Installer(object):
self.setup_database()
self.install_config_files()
self.post_run()
if self.archive_path:
self.restore()
self.restart_daemon()
def _dump_database(self, backup_path: str):
"""Create a new database dump for this app."""
target_dir = os.path.join(backup_path, "databases")
target_file = os.path.join(target_dir, f"{self.appname}.sql")
self.backend.dump_database(
self.dbname, self.dbuser, self.dbpasswd, target_file)
def pre_run(self):
"""Tasks to execute before the installer starts."""
pass

View File

@@ -42,9 +42,10 @@ class Clamav(base.Installer):
"""Additional tasks."""
if package.backend.FORMAT == "deb":
user = self.config.get(self.appname, "user")
system.add_user_to_group(
user, self.config.get("amavis", "user")
)
if self.config.getboolean("amavis", "enabled"):
system.add_user_to_group(
user, self.config.get("amavis", "user")
)
pattern = (
"s/^AllowSupplementaryGroups false/"
"AllowSupplementaryGroups true/")
@@ -57,7 +58,7 @@ class Clamav(base.Installer):
# Check if not present before
path = "/usr/lib/systemd/system/clamd@.service"
code, output = utils.exec_cmd(
"grep 'WantedBy=multi-user.target' {}".format(path))
r"grep 'WantedBy\s*=\s*multi-user.target' {}".format(path))
if code:
utils.exec_cmd(
"""cat <<EOM >> {}

View File

@@ -3,6 +3,9 @@
import glob
import os
import pwd
import shutil
import stat
import uuid
from .. import database
from .. import package
@@ -25,13 +28,33 @@ class Dovecot(base.Installer):
"dovecot", "dovecot-pigeonhole"]
}
config_files = [
"dovecot.conf", "dovecot-dict-sql.conf.ext", "conf.d/10-ssl.conf",
"conf.d/10-master.conf", "conf.d/20-lmtp.conf"]
"dovecot.conf",
"dovecot-dict-sql.conf.ext",
"conf.d/10-ssl.conf",
"conf.d/10-master.conf",
"conf.d/20-lmtp.conf",
"conf.d/10-ssl-keys.try",
"conf.d/dovecot-oauth2.conf.ext",
]
with_user = True
def setup_user(self):
"""Setup mailbox user."""
super().setup_user()
self.mailboxes_owner = self.app_config["mailboxes_owner"]
system.create_user(self.mailboxes_owner, self.home_dir)
def get_config_files(self):
"""Additional config files."""
return self.config_files + [
_config_files = self.config_files
if self.app_config["move_spam_to_junk"]:
_config_files += [
"conf.d/custom_after_sieve/spam-to-junk.sieve",
"conf.d/90-sieve.conf",
]
return _config_files + [
"dovecot-sql-{}.conf.ext=dovecot-sql.conf.ext"
.format(self.dbengine),
"dovecot-sql-master-{}.conf.ext=dovecot-sql-master.conf.ext"
@@ -46,21 +69,37 @@ class Dovecot(base.Installer):
if package.backend.FORMAT == "deb":
if "pop3" in self.config.get("dovecot", "extra_protocols"):
packages += ["dovecot-pop3d"]
return super(Dovecot, self).get_packages() + packages
packages += super().get_packages()
backports_codename = getattr(self, "backports_codename", None)
if backports_codename:
packages = [f"{package}/{backports_codename}-backports" for package in packages]
return packages
def install_packages(self):
"""Preconfigure Dovecot if needed."""
name, version = utils.dist_info()
name = name.lower()
if name.startswith("debian") and version.startswith("12"):
package.backend.enable_backports("bookworm")
self.backports_codename = "bookworm"
package.backend.preconfigure(
"dovecot-core", "create-ssl-cert", "boolean", "false")
super(Dovecot, self).install_packages()
super().install_packages()
def get_template_context(self):
"""Additional variables."""
context = super(Dovecot, self).get_template_context()
pw = pwd.getpwnam(self.user)
context = super().get_template_context()
pw_mailbox = pwd.getpwnam(self.mailboxes_owner)
dovecot_package = {"deb": "dovecot-core", "rpm": "dovecot"}
ssl_protocol_parameter = "ssl_protocols"
if package.backend.get_installed_version(dovecot_package[package.backend.FORMAT]) > "2.3":
ssl_protocol_parameter = "ssl_min_protocol"
ssl_protocols = "!SSLv2 !SSLv3"
if package.backend.get_installed_version("openssl").startswith("1.1"):
if package.backend.get_installed_version("openssl").startswith("1.1") \
or package.backend.get_installed_version("openssl").startswith("3"):
ssl_protocols = "!SSLv3"
if ssl_protocol_parameter == "ssl_min_protocol":
ssl_protocols = "TLSv1"
if "centos" in utils.dist_name():
protocols = "protocols = imap lmtp sieve"
extra_protocols = self.config.get("dovecot", "extra_protocols")
@@ -69,22 +108,45 @@ class Dovecot(base.Installer):
else:
# Protocols are automatically guessed on debian/ubuntu
protocols = ""
oauth2_client_id, oauth2_client_secret = utils.create_oauth2_app(
"Dovecot", "dovecot", self.config)
hostname = self.config.get("general", "hostname")
oauth2_introspection_url = (
f"https://{oauth2_client_id}:{oauth2_client_secret}"
f"@{hostname}/api/o/introspect/"
)
context.update({
"db_driver": self.db_driver,
"mailboxes_owner_uid": pw[2],
"mailboxes_owner_gid": pw[3],
"mailboxes_owner_uid": pw_mailbox[2],
"mailboxes_owner_gid": pw_mailbox[3],
"mailbox_owner": self.mailboxes_owner,
"modoboa_user": self.config.get("modoboa", "user"),
"modoboa_dbname": self.config.get("modoboa", "dbname"),
"modoboa_dbuser": self.config.get("modoboa", "dbuser"),
"modoboa_dbpassword": self.config.get("modoboa", "dbpassword"),
"protocols": protocols,
"ssl_protocols": ssl_protocols,
"radicale_user": self.config.get("radicale", "user"),
"radicale_auth_socket_path": os.path.basename(
self.config.get("dovecot", "radicale_auth_socket_path"))
"ssl_protocol_parameter": ssl_protocol_parameter,
"modoboa_2_2_or_greater": "" if self.modoboa_2_2_or_greater else "#",
"not_modoboa_2_2_or_greater": "" if not self.modoboa_2_2_or_greater else "#",
"do_move_spam_to_junk": "" if self.app_config["move_spam_to_junk"] else "#",
"oauth2_introspection_url": oauth2_introspection_url
})
return context
def install_config_files(self):
"""Create sieve dir if needed."""
if self.app_config["move_spam_to_junk"]:
utils.mkdir_safe(
f"{self.config_dir}/conf.d/custom_after_sieve",
stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP |
stat.S_IROTH | stat.S_IXOTH,
0, 0
)
super().install_config_files()
def post_run(self):
"""Additional tasks."""
if self.dbengine == "postgres":
@@ -101,15 +163,20 @@ class Dovecot(base.Installer):
self.get_file_path("fix_modoboa_postgres_schema.sql")
)
for f in glob.glob("{}/*".format(self.get_file_path("conf.d"))):
utils.copy_file(f, "{}/conf.d".format(self.config_dir))
if os.path.isfile(f):
utils.copy_file(f, "{}/conf.d".format(self.config_dir))
# Make postlogin script executable
utils.exec_cmd("chmod +x /usr/local/bin/postlogin.sh")
# Only root should have read access to the 10-ssl-keys.try
# See https://github.com/modoboa/modoboa/issues/2570
utils.exec_cmd("chmod 600 /etc/dovecot/conf.d/10-ssl-keys.try")
# Add mailboxes user to dovecot group for modoboa mailbox commands.
# See https://github.com/modoboa/modoboa/issues/2157.
system.add_user_to_group(
self.config.get("dovecot", "mailboxes_owner"),
'dovecot'
)
if self.app_config["move_spam_to_junk"]:
# Compile sieve script
sieve_file = f"{self.config_dir}/conf.d/custom_after_sieve/spam-to-junk.sieve"
utils.exec_cmd(f"/usr/bin/sievec {sieve_file}")
system.add_user_to_group(self.mailboxes_owner, 'dovecot')
def restart_daemon(self):
"""Restart daemon process.
@@ -125,3 +192,39 @@ class Dovecot(base.Installer):
"service {} {} > /dev/null 2>&1".format(self.appname, action),
capture_output=False)
system.enable_service(self.get_daemon_name())
def backup(self, path):
"""Backup emails."""
home_dir = self.config.get("dovecot", "home_dir")
utils.printcolor("Backing up mails", utils.MAGENTA)
if not os.path.exists(home_dir) or os.path.isfile(home_dir):
utils.error("Error backing up emails, provided path "
f" ({home_dir}) seems not right...")
return
dst = os.path.join(path, "mails/")
if os.path.exists(dst):
shutil.rmtree(dst)
shutil.copytree(home_dir, dst)
utils.success("Mail backup complete!")
def restore(self):
"""Restore emails."""
home_dir = self.config.get("dovecot", "home_dir")
mail_dir = os.path.join(self.archive_path, "mails/")
if len(os.listdir(mail_dir)) > 0:
utils.success("Copying mail backup over dovecot directory.")
if os.path.exists(home_dir):
shutil.rmtree(home_dir)
shutil.copytree(mail_dir, home_dir)
# Resetting permission for vmail
for dirpath, dirnames, filenames in os.walk(home_dir):
shutil.chown(dirpath, self.mailboxes_owner, self.mailboxes_owner)
for filename in filenames:
shutil.chown(os.path.join(dirpath, filename),
self.mailboxes_owner, self.mailboxes_owner)
else:
utils.printcolor(
"It seems that emails were not backed up, skipping restoration.",
utils.MAGENTA
)

View File

@@ -0,0 +1,17 @@
"""fail2ban related functions."""
from . import base
class Fail2ban(base.Installer):
"""Fail2ban installer."""
appname = "fail2ban"
packages = {
"deb": ["fail2ban"],
"rpm": ["fail2ban"]
}
config_files = [
"jail.d/modoboa.conf",
"filter.d/modoboa-auth.conf",
]

View File

@@ -0,0 +1,213 @@
-- Amavis 2.11.0 MySQL schema
-- Provided by Modoboa
-- Warning: foreign key creations are enabled
-- local users
CREATE TABLE users (
id int unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY, -- unique id
priority integer NOT NULL DEFAULT '7', -- sort field, 0 is low prior.
policy_id integer unsigned NOT NULL DEFAULT '1', -- JOINs with policy.id
email varbinary(255) NOT NULL UNIQUE,
fullname varchar(255) DEFAULT NULL -- not used by amavisd-new
-- local char(1) -- Y/N (optional field, see note further down)
);
-- any e-mail address (non- rfc2822-quoted), external or local,
-- used as senders in wblist
CREATE TABLE mailaddr (
id int unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY,
priority integer NOT NULL DEFAULT '7', -- 0 is low priority
email varbinary(255) NOT NULL UNIQUE
);
-- per-recipient whitelist and/or blacklist,
-- puts sender and recipient in relation wb (white or blacklisted sender)
CREATE TABLE wblist (
rid integer unsigned NOT NULL, -- recipient: users.id
sid integer unsigned NOT NULL, -- sender: mailaddr.id
wb varchar(10) NOT NULL, -- W or Y / B or N / space=neutral / score
PRIMARY KEY (rid,sid)
);
CREATE TABLE policy (
id int unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY,
-- 'id' this is the _only_ required field
policy_name varchar(32), -- not used by amavisd-new, a comment
virus_lover char(1) default NULL, -- Y/N
spam_lover char(1) default NULL, -- Y/N
unchecked_lover char(1) default NULL, -- Y/N
banned_files_lover char(1) default NULL, -- Y/N
bad_header_lover char(1) default NULL, -- Y/N
bypass_virus_checks char(1) default NULL, -- Y/N
bypass_spam_checks char(1) default NULL, -- Y/N
bypass_banned_checks char(1) default NULL, -- Y/N
bypass_header_checks char(1) default NULL, -- Y/N
virus_quarantine_to varchar(64) default NULL,
spam_quarantine_to varchar(64) default NULL,
banned_quarantine_to varchar(64) default NULL,
unchecked_quarantine_to varchar(64) default NULL,
bad_header_quarantine_to varchar(64) default NULL,
clean_quarantine_to varchar(64) default NULL,
archive_quarantine_to varchar(64) default NULL,
spam_tag_level float default NULL, -- higher score inserts spam info headers
spam_tag2_level float default NULL, -- inserts 'declared spam' header fields
spam_tag3_level float default NULL, -- inserts 'blatant spam' header fields
spam_kill_level float default NULL, -- higher score triggers evasive actions
-- e.g. reject/drop, quarantine, ...
-- (subject to final_spam_destiny setting)
spam_dsn_cutoff_level float default NULL,
spam_quarantine_cutoff_level float default NULL,
addr_extension_virus varchar(64) default NULL,
addr_extension_spam varchar(64) default NULL,
addr_extension_banned varchar(64) default NULL,
addr_extension_bad_header varchar(64) default NULL,
warnvirusrecip char(1) default NULL, -- Y/N
warnbannedrecip char(1) default NULL, -- Y/N
warnbadhrecip char(1) default NULL, -- Y/N
newvirus_admin varchar(64) default NULL,
virus_admin varchar(64) default NULL,
banned_admin varchar(64) default NULL,
bad_header_admin varchar(64) default NULL,
spam_admin varchar(64) default NULL,
spam_subject_tag varchar(64) default NULL,
spam_subject_tag2 varchar(64) default NULL,
spam_subject_tag3 varchar(64) default NULL,
message_size_limit integer default NULL, -- max size in bytes, 0 disable
banned_rulenames varchar(64) default NULL, -- comma-separated list of ...
-- names mapped through %banned_rules to actual banned_filename tables
disclaimer_options varchar(64) default NULL,
forward_method varchar(64) default NULL,
sa_userconf varchar(64) default NULL,
sa_username varchar(64) default NULL
);
-- R/W part of the dataset (optional)
-- May reside in the same or in a separate database as lookups database;
-- REQUIRES SUPPORT FOR TRANSACTIONS; specified in @storage_sql_dsn
--
-- MySQL note ( http://dev.mysql.com/doc/mysql/en/storage-engines.html ):
-- ENGINE is the preferred term, but cannot be used before MySQL 4.0.18.
-- TYPE is available beginning with MySQL 3.23.0, the first version of
-- MySQL for which multiple storage engines were available. If you omit
-- the ENGINE or TYPE option, the default storage engine is used.
-- By default this is MyISAM.
--
-- Please create additional indexes on keys when needed, or drop suggested
-- ones as appropriate to optimize queries needed by a management application.
-- See your database documentation for further optimization hints. With MySQL
-- see Chapter 15 of the reference manual. For example the chapter 15.17 says:
-- InnoDB does not keep an internal count of rows in a table. To process a
-- SELECT COUNT(*) FROM T statement, InnoDB must scan an index of the table,
-- which takes some time if the index is not entirely in the buffer pool.
--
-- Wayne Smith adds: When using MySQL with InnoDB one might want to
-- increase buffer size for both pool and log, and might also want
-- to change flush settings for a little better performance. Example:
-- innodb_buffer_pool_size = 384M
-- innodb_log_buffer_size = 8M
-- innodb_flush_log_at_trx_commit = 0
-- The big performance increase is the first two, the third just helps with
-- lowering disk activity. Consider also adjusting the key_buffer_size.
-- provide unique id for each e-mail address, avoids storing copies
CREATE TABLE maddr (
partition_tag integer DEFAULT 0, -- see $partition_tag
id bigint unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY,
email varbinary(255) NOT NULL, -- full mail address
domain varchar(255) NOT NULL, -- only domain part of the email address
-- with subdomain fields in reverse
CONSTRAINT part_email UNIQUE (partition_tag,email)
) ENGINE=InnoDB;
-- information pertaining to each processed message as a whole;
-- NOTE: records with NULL msgs.content should be ignored by utilities,
-- as such records correspond to messages just being processes, or were lost
-- NOTE: instead of a character field time_iso, one might prefer:
-- time_iso TIMESTAMP NOT NULL DEFAULT 0,
-- but the following MUST then be set in amavisd.conf: $timestamp_fmt_mysql=1
CREATE TABLE msgs (
partition_tag integer DEFAULT 0, -- see $partition_tag
mail_id varbinary(16) NOT NULL, -- long-term unique mail id, dflt 12 ch
secret_id varbinary(16) DEFAULT '', -- authorizes release of mail_id, 12 ch
am_id varchar(20) NOT NULL, -- id used in the log
time_num integer unsigned NOT NULL, -- rx_time: seconds since Unix epoch
time_iso char(16) NOT NULL, -- rx_time: ISO8601 UTC ascii time
sid bigint unsigned NOT NULL, -- sender: maddr.id
policy varchar(255) DEFAULT '', -- policy bank path (like macro %p)
client_addr varchar(255) DEFAULT '', -- SMTP client IP address (IPv4 or v6)
size integer unsigned NOT NULL, -- message size in bytes
originating char(1) DEFAULT ' ' NOT NULL, -- sender from inside or auth'd
content char(1), -- content type: V/B/U/S/Y/M/H/O/T/C
-- virus/banned/unchecked/spam(kill)/spammy(tag2)/
-- /bad-mime/bad-header/oversized/mta-err/clean
-- is NULL on partially processed mail
-- (prior to 2.7.0 the CC_SPAMMY was logged as 's', now 'Y' is used;
-- to avoid a need for case-insenstivity in queries)
quar_type char(1), -- quarantined as: ' '/F/Z/B/Q/M/L
-- none/file/zipfile/bsmtp/sql/
-- /mailbox(smtp)/mailbox(lmtp)
quar_loc varbinary(255) DEFAULT '', -- quarantine location (e.g. file)
dsn_sent char(1), -- was DSN sent? Y/N/q (q=quenched)
spam_level float, -- SA spam level (no boosts)
message_id varchar(255) DEFAULT '', -- mail Message-ID header field
from_addr varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT '',
-- mail From header field, UTF8
subject varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT '',
-- mail Subject header field, UTF8
host varchar(255) NOT NULL, -- hostname where amavisd is running
PRIMARY KEY (partition_tag,mail_id),
FOREIGN KEY (sid) REFERENCES maddr(id) ON DELETE RESTRICT
) ENGINE=InnoDB;
CREATE INDEX msgs_idx_sid ON msgs (sid);
CREATE INDEX msgs_idx_mess_id ON msgs (message_id); -- useful with pen pals
CREATE INDEX msgs_idx_time_num ON msgs (time_num);
-- alternatively when purging based on time_iso (instead of msgs_idx_time_num):
CREATE INDEX msgs_idx_time_iso ON msgs (time_iso);
-- When using FOREIGN KEY contraints, InnoDB requires index on a field
-- (an the field must be the first field in the index). Hence create it:
CREATE INDEX msgs_idx_mail_id ON msgs (mail_id);
-- per-recipient information related to each processed message;
-- NOTE: records in msgrcpt without corresponding msgs.mail_id record are
-- orphaned and should be ignored and eventually deleted by external utilities
CREATE TABLE msgrcpt (
partition_tag integer DEFAULT 0, -- see $partition_tag
mail_id varbinary(16) NOT NULL, -- (must allow duplicates)
rseqnum integer DEFAULT 0 NOT NULL, -- recip's enumeration within msg
rid bigint unsigned NOT NULL, -- recipient: maddr.id (dupl. allowed)
is_local char(1) DEFAULT ' ' NOT NULL, -- recip is: Y=local, N=foreign
content char(1) DEFAULT ' ' NOT NULL, -- content type V/B/U/S/Y/M/H/O/T/C
ds char(1) NOT NULL, -- delivery status: P/R/B/D/T
-- pass/reject/bounce/discard/tempfail
rs char(1) NOT NULL, -- release status: initialized to ' '
bl char(1) DEFAULT ' ', -- sender blacklisted by this recip
wl char(1) DEFAULT ' ', -- sender whitelisted by this recip
bspam_level float, -- per-recipient (total) spam level
smtp_resp varchar(255) DEFAULT '', -- SMTP response given to MTA
PRIMARY KEY (partition_tag,mail_id,rseqnum),
FOREIGN KEY (rid) REFERENCES maddr(id) ON DELETE RESTRICT,
FOREIGN KEY (mail_id) REFERENCES msgs(mail_id) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE INDEX msgrcpt_idx_mail_id ON msgrcpt (mail_id);
CREATE INDEX msgrcpt_idx_rid ON msgrcpt (rid);
-- Additional index on rs since Modoboa uses it to filter its quarantine
CREATE INDEX msgrcpt_idx_rs ON msgrcpt (rs);
-- mail quarantine in SQL, enabled by $*_quarantine_method='sql:'
-- NOTE: records in quarantine without corresponding msgs.mail_id record are
-- orphaned and should be ignored and eventually deleted by external utilities
CREATE TABLE quarantine (
partition_tag integer DEFAULT 0, -- see $partition_tag
mail_id varbinary(16) NOT NULL, -- long-term unique mail id
chunk_ind integer unsigned NOT NULL, -- chunk number, starting with 1
mail_text blob NOT NULL, -- store mail as chunks of octets
PRIMARY KEY (partition_tag,mail_id,chunk_ind),
FOREIGN KEY (mail_id) REFERENCES msgs(mail_id) ON DELETE CASCADE
) ENGINE=InnoDB;

View File

@@ -0,0 +1,189 @@
CREATE TABLE policy (
id serial PRIMARY KEY, -- 'id' is the _only_ required field
policy_name varchar(32), -- not used by amavisd-new, a comment
virus_lover char(1) default NULL, -- Y/N
spam_lover char(1) default NULL, -- Y/N
unchecked_lover char(1) default NULL, -- Y/N
banned_files_lover char(1) default NULL, -- Y/N
bad_header_lover char(1) default NULL, -- Y/N
bypass_virus_checks char(1) default NULL, -- Y/N
bypass_spam_checks char(1) default NULL, -- Y/N
bypass_banned_checks char(1) default NULL, -- Y/N
bypass_header_checks char(1) default NULL, -- Y/N
virus_quarantine_to varchar(64) default NULL,
spam_quarantine_to varchar(64) default NULL,
banned_quarantine_to varchar(64) default NULL,
unchecked_quarantine_to varchar(64) default NULL,
bad_header_quarantine_to varchar(64) default NULL,
clean_quarantine_to varchar(64) default NULL,
archive_quarantine_to varchar(64) default NULL,
spam_tag_level real default NULL, -- higher score inserts spam info headers
spam_tag2_level real default NULL, -- inserts 'declared spam' header fields
spam_tag3_level real default NULL, -- inserts 'blatant spam' header fields
spam_kill_level real default NULL, -- higher score triggers evasive actions
-- e.g. reject/drop, quarantine, ...
-- (subject to final_spam_destiny setting)
spam_dsn_cutoff_level real default NULL,
spam_quarantine_cutoff_level real default NULL,
addr_extension_virus varchar(64) default NULL,
addr_extension_spam varchar(64) default NULL,
addr_extension_banned varchar(64) default NULL,
addr_extension_bad_header varchar(64) default NULL,
warnvirusrecip char(1) default NULL, -- Y/N
warnbannedrecip char(1) default NULL, -- Y/N
warnbadhrecip char(1) default NULL, -- Y/N
newvirus_admin varchar(64) default NULL,
virus_admin varchar(64) default NULL,
banned_admin varchar(64) default NULL,
bad_header_admin varchar(64) default NULL,
spam_admin varchar(64) default NULL,
spam_subject_tag varchar(64) default NULL,
spam_subject_tag2 varchar(64) default NULL,
spam_subject_tag3 varchar(64) default NULL,
message_size_limit integer default NULL, -- max size in bytes, 0 disable
banned_rulenames varchar(64) default NULL, -- comma-separated list of ...
-- names mapped through %banned_rules to actual banned_filename tables
disclaimer_options varchar(64) default NULL,
forward_method varchar(64) default NULL,
sa_userconf varchar(64) default NULL,
sa_username varchar(64) default NULL
);
-- local users
CREATE TABLE users (
id serial PRIMARY KEY, -- unique id
priority integer NOT NULL DEFAULT 7, -- sort field, 0 is low prior.
policy_id integer NOT NULL DEFAULT 1 CHECK (policy_id >= 0) REFERENCES policy(id),
email bytea NOT NULL UNIQUE, -- email address, non-rfc2822-quoted
fullname varchar(255) DEFAULT NULL -- not used by amavisd-new
-- local char(1) -- Y/N (optional, see SQL section in README.lookups)
);
-- any e-mail address (non- rfc2822-quoted), external or local,
-- used as senders in wblist
CREATE TABLE mailaddr (
id serial PRIMARY KEY,
priority integer NOT NULL DEFAULT 9, -- 0 is low priority
email bytea NOT NULL UNIQUE
);
-- per-recipient whitelist and/or blacklist,
-- puts sender and recipient in relation wb (white or blacklisted sender)
CREATE TABLE wblist (
rid integer NOT NULL CHECK (rid >= 0) REFERENCES users(id),
sid integer NOT NULL CHECK (sid >= 0) REFERENCES mailaddr(id),
wb varchar(10) NOT NULL, -- W or Y / B or N / space=neutral / score
PRIMARY KEY (rid,sid)
);
-- grant usage rights:
GRANT select ON policy TO amavis;
GRANT select ON users TO amavis;
GRANT select ON mailaddr TO amavis;
GRANT select ON wblist TO amavis;
-- R/W part of the dataset (optional)
-- May reside in the same or in a separate database as lookups database;
-- REQUIRES SUPPORT FOR TRANSACTIONS; specified in @storage_sql_dsn
--
-- Please create additional indexes on keys when needed, or drop suggested
-- ones as appropriate to optimize queries needed by a management application.
-- See your database documentation for further optimization hints.
-- provide unique id for each e-mail address, avoids storing copies
CREATE TABLE maddr (
id serial PRIMARY KEY,
partition_tag integer DEFAULT 0, -- see $partition_tag
email bytea NOT NULL, -- full e-mail address
domain varchar(255) NOT NULL, -- only domain part of the email address
-- with subdomain fields in reverse
CONSTRAINT part_email UNIQUE (partition_tag,email)
);
-- information pertaining to each processed message as a whole;
-- NOTE: records with a NULL msgs.content should be ignored by utilities,
-- as such records correspond to messages just being processed, or were lost
CREATE TABLE msgs (
partition_tag integer DEFAULT 0, -- see $partition_tag
mail_id bytea NOT NULL, -- long-term unique mail id, dflt 12 ch
secret_id bytea DEFAULT '', -- authorizes release of mail_id, 12 ch
am_id varchar(20) NOT NULL, -- id used in the log
time_num integer NOT NULL CHECK (time_num >= 0),
-- rx_time: seconds since Unix epoch
time_iso timestamp WITH TIME ZONE NOT NULL,-- rx_time: ISO8601 UTC ascii time
sid integer NOT NULL CHECK (sid >= 0), -- sender: maddr.id
policy varchar(255) DEFAULT '', -- policy bank path (like macro %p)
client_addr varchar(255) DEFAULT '', -- SMTP client IP address (IPv4 or v6)
size integer NOT NULL CHECK (size >= 0), -- message size in bytes
originating char(1) DEFAULT ' ' NOT NULL, -- sender from inside or auth'd
content char(1), -- content type: V/B/U/S/Y/M/H/O/T/C
-- virus/banned/unchecked/spam(kill)/spammy(tag2)/
-- /bad-mime/bad-header/oversized/mta-err/clean
-- is NULL on partially processed mail
-- (prior to 2.7.0 the CC_SPAMMY was logged as 's', now 'Y' is used;
--- to avoid a need for case-insenstivity in queries)
quar_type char(1), -- quarantined as: ' '/F/Z/B/Q/M/L
-- none/file/zipfile/bsmtp/sql/
-- /mailbox(smtp)/mailbox(lmtp)
quar_loc varchar(255) DEFAULT '', -- quarantine location (e.g. file)
dsn_sent char(1), -- was DSN sent? Y/N/q (q=quenched)
spam_level real, -- SA spam level (no boosts)
message_id varchar(255) DEFAULT '', -- mail Message-ID header field
from_addr varchar(255) DEFAULT '', -- mail From header field, UTF8
subject varchar(255) DEFAULT '', -- mail Subject header field, UTF8
host varchar(255) NOT NULL, -- hostname where amavisd is running
CONSTRAINT msgs_partition_mail UNIQUE (partition_tag,mail_id),
PRIMARY KEY (partition_tag,mail_id)
--FOREIGN KEY (sid) REFERENCES maddr(id) ON DELETE RESTRICT
);
CREATE INDEX msgs_idx_sid ON msgs (sid);
CREATE INDEX msgs_idx_mess_id ON msgs (message_id); -- useful with pen pals
CREATE INDEX msgs_idx_time_iso ON msgs (time_iso);
CREATE INDEX msgs_idx_time_num ON msgs (time_num); -- optional
-- per-recipient information related to each processed message;
-- NOTE: records in msgrcpt without corresponding msgs.mail_id record are
-- orphaned and should be ignored and eventually deleted by external utilities
CREATE TABLE msgrcpt (
partition_tag integer DEFAULT 0, -- see $partition_tag
mail_id bytea NOT NULL, -- (must allow duplicates)
rseqnum integer DEFAULT 0 NOT NULL, -- recip's enumeration within msg
rid integer NOT NULL, -- recipient: maddr.id (duplicates allowed)
is_local char(1) DEFAULT ' ' NOT NULL, -- recip is: Y=local, N=foreign
content char(1) DEFAULT ' ' NOT NULL, -- content type V/B/U/S/Y/M/H/O/T/C
ds char(1) NOT NULL, -- delivery status: P/R/B/D/T
-- pass/reject/bounce/discard/tempfail
rs char(1) NOT NULL, -- release status: initialized to ' '
bl char(1) DEFAULT ' ', -- sender blacklisted by this recip
wl char(1) DEFAULT ' ', -- sender whitelisted by this recip
bspam_level real, -- per-recipient (total) spam level
smtp_resp varchar(255) DEFAULT '', -- SMTP response given to MTA
CONSTRAINT msgrcpt_partition_mail_rseq UNIQUE (partition_tag,mail_id,rseqnum),
PRIMARY KEY (partition_tag,mail_id,rseqnum)
--FOREIGN KEY (rid) REFERENCES maddr(id) ON DELETE RESTRICT,
--FOREIGN KEY (mail_id) REFERENCES msgs(mail_id) ON DELETE CASCADE
);
CREATE INDEX msgrcpt_idx_mail_id ON msgrcpt (mail_id);
CREATE INDEX msgrcpt_idx_rid ON msgrcpt (rid);
-- Additional index on rs since Modoboa uses it to filter its quarantine
CREATE INDEX msgrcpt_idx_rs ON msgrcpt (rs);
-- mail quarantine in SQL, enabled by $*_quarantine_method='sql:'
-- NOTE: records in quarantine without corresponding msgs.mail_id record are
-- orphaned and should be ignored and eventually deleted by external utilities
CREATE TABLE quarantine (
partition_tag integer DEFAULT 0, -- see $partition_tag
mail_id bytea NOT NULL, -- long-term unique mail id
chunk_ind integer NOT NULL CHECK (chunk_ind >= 0), -- chunk number, 1..
mail_text bytea NOT NULL, -- store mail as chunks of octects
PRIMARY KEY (partition_tag,mail_id,chunk_ind)
--FOREIGN KEY (mail_id) REFERENCES msgs(mail_id) ON DELETE CASCADE
);

View File

@@ -2,6 +2,9 @@
provider = %domain
domains = *
#debug=yes
#logfile = /srv/automx/automx.log
# Protect against DoS
memcache = 127.0.0.1:11211
memcache_ttl = 600
@@ -16,6 +19,8 @@ host = %sql_dsn
query = %sql_query
result_attrs = display_name, email
display_name = ${display_name}
smtp = yes
smtp_server = %hostname
smtp_port = 587
@@ -32,10 +37,3 @@ imap_encryption = starttls
imap_auth = plaintext
imap_auth_identity = ${email}
imap_refresh_ttl = 6
pop = yes
pop_server = %hostname
pop_port = 110
pop_encryption = starttls
pop_auth = plaintext
pop_auth_identity = ${email}

View File

@@ -96,7 +96,7 @@ auth_master_user_separator = *
# plain login digest-md5 cram-md5 ntlm rpa apop anonymous gssapi otp skey
# gss-spnego
# NOTE: See also disable_plaintext_auth setting.
auth_mechanisms = plain login
auth_mechanisms = plain login oauthbearer xoauth2
##
## Password and user databases
@@ -120,6 +120,7 @@ auth_mechanisms = plain login
#!include auth-system.conf.ext
!include auth-sql.conf.ext
!include auth-oauth2.conf.ext
#!include auth-ldap.conf.ext
#!include auth-passwdfile.conf.ext
#!include auth-checkpassword.conf.ext

View File

@@ -92,14 +92,14 @@ service postlogin {
service stats {
# To allow modoboa to access available cipher list.
unix_listener stats-reader {
user = vmail
group = vmail
user = %{mailboxes_owner}
group = %{mailboxes_owner}
mode = 0660
}
unix_listener stats-writer {
user = vmail
group = vmail
user = %{mailboxes_owner}
group = %{mailboxes_owner}
mode = 0660
}
}
@@ -120,7 +120,7 @@ service auth {
# permissions (e.g. 0777 allows everyone full permissions).
unix_listener auth-userdb {
#mode = 0666
user = vmail
user = %{mailboxes_owner}
#group =
}
@@ -131,13 +131,6 @@ service auth {
group = postfix
}
# Radicale auth
%{radicale_enabled}unix_listener %{radicale_auth_socket_path} {
%{radicale_enabled} mode = 0666
%{radicale_enabled} user = %{radicale_user}
%{radicale_enabled} group = %{radicale_user}
%{radicale_enabled}}
# Auth process is run as this user.
#user = $default_internal_user
}
@@ -154,7 +147,7 @@ service dict {
# For example: mode=0660, group=vmail and global mail_access_groups=vmail
unix_listener dict {
mode = 0600
user = vmail
user = %{mailboxes_owner}
#group =
}
}

View File

@@ -0,0 +1,6 @@
# PEM encoded X.509 SSL/TLS certificate and private key. They're opened before
# dropping root privileges, so keep the key file unreadable by anyone but
# root. Included doc/mkcert.sh can be used to easily generate self-signed
# certificate, just make sure to update the domains in dovecot-openssl.cnf
ssl_cert = <%tls_cert_file
ssl_key = <%tls_key_file

View File

@@ -5,12 +5,11 @@
# SSL/TLS support: yes, no, required. <doc/wiki/SSL.txt>
#ssl = yes
# PEM encoded X.509 SSL/TLS certificate and private key. They're opened before
# dropping root privileges, so keep the key file unreadable by anyone but
# root. Included doc/mkcert.sh can be used to easily generate self-signed
# certificate, just make sure to update the domains in dovecot-openssl.cnf
ssl_cert = <%tls_cert_file
ssl_key = <%tls_key_file
# Workarround https://github.com/modoboa/modoboa/issues/2570
# We try to load the key and pass if it fails
# Keys require root permissions, standard commands would be blocked
# because dovecot can't load these cert
!include_try /etc/dovecot/conf.d/10-ssl-keys.try
# If key file is password protected, give the password here. Alternatively
# give it when starting dovecot with -p parameter. Since this file is often
@@ -41,7 +40,7 @@ ssl_key = <%tls_key_file
#ssl_parameters_regenerate = 168
# SSL protocols to use
ssl_protocols = %ssl_protocols
%ssl_protocol_parameter = %ssl_protocols
# SSL ciphers to use

View File

@@ -38,7 +38,7 @@ plugin {
# Identical to sieve_before, only the specified scripts are executed after the
# user's script (only when keep is still in effect!). Multiple script file or
# directory paths can be specified by appending an increasing number.
#sieve_after =
%{do_move_spam_to_junk}sieve_after = /etc/dovecot/conf.d/custom_after_sieve
#sieve_after2 =
#sieve_after2 = (etc...)

View File

@@ -0,0 +1,5 @@
passdb {
driver = oauth2
mechanisms = xoauth2 oauthbearer
args = /etc/dovecot/conf.d/dovecot-oauth2.conf.ext
}

View File

@@ -0,0 +1,4 @@
require "fileinto";
if header :contains "X-Spam-Status" "Yes" {
fileinto "Junk";
}

View File

@@ -0,0 +1,6 @@
introspection_mode = post
introspection_url = %{oauth2_introspection_url}
username_attribute = username
tls_ca_cert_file = /etc/ssl/certs/ca-certificates.crt
active_attribute = active
active_value = true

View File

@@ -123,7 +123,8 @@ connect = host=%dbhost port=%dbport dbname=%modoboa_dbname user=%modoboa_dbuser
#user_query = \
# SELECT home, uid, gid \
# FROM users WHERE username = '%%n' AND domain = '%%d'
user_query = SELECT '%{home_dir}/%%d/%%n' AS home, %mailboxes_owner_uid as uid, %mailboxes_owner_gid as gid, CONCAT('*:bytes=', mb.quota, 'M') AS quota_rule FROM admin_mailbox mb INNER JOIN admin_domain dom ON mb.domain_id=dom.id INNER JOIN core_user u ON u.id=mb.user_id WHERE mb.address='%%n' AND dom.name='%%d'
%{not_modoboa_2_2_or_greater}user_query = SELECT '%{home_dir}/%%d/%%n' AS home, %mailboxes_owner_uid as uid, %mailboxes_owner_gid as gid, CONCAT('*:bytes=', mb.quota, 'M') AS quota_rule FROM admin_mailbox mb INNER JOIN admin_domain dom ON mb.domain_id=dom.id INNER JOIN core_user u ON u.id=mb.user_id WHERE mb.address='%%n' AND dom.name='%%d'
%{modoboa_2_2_or_greater}user_query = SELECT '%{home_dir}/%%d/%%n' AS home, %mailboxes_owner_uid as uid, %mailboxes_owner_gid as gid, CONCAT('*:bytes=', mb.quota, 'M') AS quota_rule FROM admin_mailbox mb INNER JOIN admin_domain dom ON mb.domain_id=dom.id INNER JOIN core_user u ON u.id=mb.user_id WHERE (mb.is_send_only=0 OR '%%s' NOT IN ('imap', 'pop3', 'lmtp')) AND mb.address='%%n' AND dom.name='%%d'
# If you wish to avoid two SQL lookups (passdb + userdb), you can use
# userdb prefetch instead of userdb sql in dovecot.conf. In that case you'll
@@ -133,7 +134,8 @@ user_query = SELECT '%{home_dir}/%%d/%%n' AS home, %mailboxes_owner_uid as uid,
# SELECT userid AS user, password, \
# home AS userdb_home, uid AS userdb_uid, gid AS userdb_gid \
# FROM users WHERE userid = '%%u'
password_query = SELECT email AS user, password, '%{home_dir}/%%d/%%n' AS userdb_home, %mailboxes_owner_uid AS userdb_uid, %mailboxes_owner_gid AS userdb_gid, CONCAT('*:bytes=', mb.quota, 'M') AS userdb_quota_rule FROM core_user u INNER JOIN admin_mailbox mb ON u.id=mb.user_id INNER JOIN admin_domain dom ON mb.domain_id=dom.id WHERE u.email='%%u' AND u.is_active=1 AND dom.enabled=1
%{not_modoboa_2_2_or_greater}password_query = SELECT email AS user, password, '%{home_dir}/%%d/%%n' AS userdb_home, %mailboxes_owner_uid AS userdb_uid, %mailboxes_owner_gid AS userdb_gid, CONCAT('*:bytes=', mb.quota, 'M') AS userdb_quota_rule FROM core_user u INNER JOIN admin_mailbox mb ON u.id=mb.user_id INNER JOIN admin_domain dom ON mb.domain_id=dom.id WHERE u.email='%%u' AND u.is_active=1 AND dom.enabled=1
%{modoboa_2_2_or_greater}password_query = SELECT email AS user, password, '%{home_dir}/%%d/%%n' AS userdb_home, %mailboxes_owner_uid AS userdb_uid, %mailboxes_owner_gid AS userdb_gid, CONCAT('*:bytes=', mb.quota, 'M') AS userdb_quota_rule FROM core_user u INNER JOIN admin_mailbox mb ON u.id=mb.user_id INNER JOIN admin_domain dom ON mb.domain_id=dom.id WHERE (mb.is_send_only=0 OR '%%s' NOT IN ('imap', 'pop3')) AND u.email='%%u' AND u.is_active=1 AND dom.enabled=1
# Query to get a list of all usernames.
#iterate_query = SELECT username AS user FROM users

View File

@@ -123,7 +123,8 @@ connect = host=%dbhost port=%dbport dbname=%modoboa_dbname user=%modoboa_dbuser
#user_query = \
# SELECT home, uid, gid \
# FROM users WHERE username = '%%n' AND domain = '%%d'
user_query = SELECT '%{home_dir}/%%d/%%n' AS home, %mailboxes_owner_uid as uid, %mailboxes_owner_gid as gid, '*:bytes=' || mb.quota || 'M' AS quota_rule FROM admin_mailbox mb INNER JOIN admin_domain dom ON mb.domain_id=dom.id INNER JOIN core_user u ON u.id=mb.user_id WHERE mb.address='%%n' AND dom.name='%%d'
%{not_modoboa_2_2_or_greater}user_query = SELECT '%{home_dir}/%%d/%%n' AS home, %mailboxes_owner_uid as uid, %mailboxes_owner_gid as gid, '*:bytes=' || mb.quota || 'M' AS quota_rule FROM admin_mailbox mb INNER JOIN admin_domain dom ON mb.domain_id=dom.id INNER JOIN core_user u ON u.id=mb.user_id WHERE mb.address='%%n' AND dom.name='%%d'
%{modoboa_2_2_or_greater}user_query = SELECT '%{home_dir}/%%d/%%n' AS home, %mailboxes_owner_uid as uid, %mailboxes_owner_gid as gid, '*:bytes=' || mb.quota || 'M' AS quota_rule FROM admin_mailbox mb INNER JOIN admin_domain dom ON mb.domain_id=dom.id INNER JOIN core_user u ON u.id=mb.user_id WHERE (mb.is_send_only IS NOT TRUE OR '%%s' NOT IN ('imap', 'pop3', 'lmtp')) AND mb.address='%%n' AND dom.name='%%d'
# If you wish to avoid two SQL lookups (passdb + userdb), you can use
# userdb prefetch instead of userdb sql in dovecot.conf. In that case you'll
@@ -133,7 +134,8 @@ user_query = SELECT '%{home_dir}/%%d/%%n' AS home, %mailboxes_owner_uid as uid,
# SELECT userid AS user, password, \
# home AS userdb_home, uid AS userdb_uid, gid AS userdb_gid \
# FROM users WHERE userid = '%%u'
password_query = SELECT email AS user, password, '%{home_dir}/%%d/%%n' AS userdb_home, %mailboxes_owner_uid AS userdb_uid, %mailboxes_owner_gid AS userdb_gid, CONCAT('*:bytes=', mb.quota, 'M') AS userdb_quota_rule FROM core_user u INNER JOIN admin_mailbox mb ON u.id=mb.user_id INNER JOIN admin_domain dom ON mb.domain_id=dom.id WHERE email='%%u' AND is_active AND dom.enabled
%{not_modoboa_2_2_or_greater}password_query = SELECT email AS user, password, '%{home_dir}/%%d/%%n' AS userdb_home, %mailboxes_owner_uid AS userdb_uid, %mailboxes_owner_gid AS userdb_gid, CONCAT('*:bytes=', mb.quota, 'M') AS userdb_quota_rule FROM core_user u INNER JOIN admin_mailbox mb ON u.id=mb.user_id INNER JOIN admin_domain dom ON mb.domain_id=dom.id WHERE email='%%u' AND is_active AND dom.enabled
%{modoboa_2_2_or_greater}password_query = SELECT email AS user, password, '%{home_dir}/%%d/%%n' AS userdb_home, %mailboxes_owner_uid AS userdb_uid, %mailboxes_owner_gid AS userdb_gid, CONCAT('*:bytes=', mb.quota, 'M') AS userdb_quota_rule FROM core_user u INNER JOIN admin_mailbox mb ON u.id=mb.user_id INNER JOIN admin_domain dom ON mb.domain_id=dom.id WHERE (mb.is_send_only IS NOT TRUE OR '%%s' NOT IN ('imap', 'pop3')) AND email='%%u' AND is_active AND dom.enabled
# Query to get a list of all usernames.
#iterate_query = SELECT username AS user FROM users

View File

@@ -0,0 +1,9 @@
# Fail2Ban filter Modoboa authentication
[INCLUDES]
before = common.conf
[Definition]
failregex = modoboa\.auth: WARNING Failed connection attempt from \'<HOST>\' as user \'.*?\'$

View File

@@ -0,0 +1,9 @@
[modoboa]
enabled = true
port = http,https
protocol = tcp
filter = modoboa-auth
maxretry = %max_retry
bantime = %ban_time
findtime = %find_time
logpath = /var/log/auth.log

View File

@@ -3,6 +3,7 @@
#
PYTHON=%{venv_path}/bin/python
INSTANCE=%{instance_path}
MAILTO=%{cron_error_recipient}
# Operations on mailboxes
%{dovecot_enabled}* * * * * %{dovecot_mailboxes_owner} $PYTHON $INSTANCE/manage.py handle_mailbox_operations
@@ -30,7 +31,7 @@ INSTANCE=%{instance_path}
*/30 * * * * root $PYTHON $INSTANCE/manage.py modo check_mx
# Public API communication
0 * * * * root $PYTHON $INSTANCE/manage.py communicate_with_public_api
%{minutes} %{hours} * * * root $PYTHON $INSTANCE/manage.py communicate_with_public_api
# Generate DKIM keys (they will belong to the user running this job)
%{opendkim_enabled}* * * * * %{opendkim_user} umask 077 && $PYTHON $INSTANCE/manage.py modo manage_dkim_keys
%{dkim_cron_enabled}* * * * * %{opendkim_user} umask 077 && $PYTHON $INSTANCE/manage.py modo manage_dkim_keys

View File

@@ -0,0 +1,9 @@
[program:modoboa-base-worker]
autostart=true
autorestart=true
command=%{venv_path}/bin/python %{home_dir}/instance/manage.py rqworker modoboa
directory=%{home_dir}
user=%{user}
redirect_stderr=true
numprocs=1
stopsignal=TERM

View File

@@ -0,0 +1,9 @@
[program:modoboa-dkim-worker]
autostart=true
autorestart=true
command=%{venv_path}/bin/python %{home_dir}/instance/manage.py rqworker dkim
directory=%{home_dir}
user=%{dkim_user}
redirect_stderr=true
numprocs=1
stopsignal=TERM

View File

@@ -6,3 +6,4 @@ directory=%{home_dir}
redirect_stderr=true
user=%{user}
numprocs=1

View File

@@ -10,8 +10,8 @@ server {
}
server {
listen 443 ssl;
listen [::]:443 ssl;
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name %hostname;
root %app_instance_path;
@@ -37,7 +37,20 @@ server {
try_files $uri $uri/ =404;
}
location ^~ /new-admin {
%{rspamd_enabled} location /rspamd/ {
%{rspamd_enabled} proxy_pass http://localhost:11334/;
%{rspamd_enabled}
%{rspamd_enabled} proxy_set_header Host $host;
%{rspamd_enabled} proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
%{rspamd_enabled} }
location ~ ^/(api|accounts) {
include uwsgi_params;
uwsgi_param UWSGI_SCRIPT instance.wsgi:application;
uwsgi_pass modoboa;
}
location / {
alias %{app_instance_path}/frontend/;
index index.html;
@@ -48,10 +61,5 @@ server {
try_files $uri $uri/ /index.html = 404;
}
location / {
include uwsgi_params;
uwsgi_param UWSGI_SCRIPT instance.wsgi:application;
uwsgi_pass modoboa;
}
%{extra_config}
}

View File

@@ -0,0 +1,11 @@
if /^\s*Received:.*Authenticated sender.*\(Postfix\)/
/^Received: from .*? \([\w\-.]* \[.*?\]\)(.*|\n.*)\(Authenticated sender: (.+)\)\s+by.+\(Postfix\) with (.*)/
REPLACE Received: from [127.0.0.1] (localhost [127.0.0.1]) by localhost (Mailerdaemon) with $3
endif
if /^\s*Received: from .*rspamd.localhost .*\(Postfix\)/
/^Received: from.* (.*|\n.*)\((.+) (.+)\)\s+by (.+) \(Postfix\) with (.*)/
REPLACE Received: from rspamd (rspamd $3) by $4 (Postfix) with $5
endif
/^\s*X-Enigmail/ IGNORE
/^\s*X-Originating-IP/ IGNORE
/^\s*X-Forward/ IGNORE

View File

@@ -41,22 +41,29 @@ smtpd_tls_auth_only = no
smtpd_tls_CApath = /etc/ssl/certs
smtpd_tls_key_file = %tls_key_file
smtpd_tls_cert_file = %tls_cert_file
smtpd_tls_dh1024_param_file = ${config_directory}/dh2048.pem
smtpd_tls_dh1024_param_file = ${config_directory}/ffdhe%{dhe_group}.pem
smtpd_tls_loglevel = 1
smtpd_tls_session_cache_database = btree:$data_directory/smtpd_tls_session_cache
smtpd_tls_security_level = may
smtpd_tls_received_header = yes
# Disallow SSLv2 and SSLv3, only accept secure ciphers
smtpd_tls_protocols = !SSLv2, !SSLv3
smtpd_tls_mandatory_protocols = !SSLv2, !SSLv3
smtpd_tls_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1
smtpd_tls_mandatory_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1
smtpd_tls_mandatory_ciphers = high
smtpd_tls_mandatory_exclude_ciphers = aNULL, MD5 , DES, ADH, RC4, PSD, SRP, 3DES, eNULL
smtpd_tls_exclude_ciphers = aNULL, MD5 , DES, ADH, RC4, PSD, SRP, 3DES, eNULL
smtpd_tls_mandatory_exclude_ciphers = aNULL, eNULL, EXPORT, DES, RC4, MD5, PSK, aECDH, EDH-DSS-DES-CBC3-SHA, EDH-RSA-DES-CBC3-SHA, KRB5-DES, CBC3-SHA, CAMELLIA, SEED-SHA, AES256-SHA, AES256-SHA256, AES256-GCM-SHA384, AES128-SHA, AES128-SHA256, AES128-GCM-SHA256, DHE-RSA-AES128-GCM-SHA256, DHE-RSA-AES128-SHA, DHE-RSA-AES128-SHA256, DHE-RSA-AES256-GCM-SHA384, DHE-RSA-AES256-SHA, DHE-RSA-AES256-SHA256, DHE-RSA-CHACHA20-POLY1305, ECDHE-RSA-AES128-SHA, ECDHE-RSA-AES256-SHA
smtpd_tls_exclude_ciphers = aNULL, eNULL, EXPORT, DES, RC4, MD5, PSK, aECDH, EDH-DSS-DES-CBC3-SHA, EDH-RSA-DES-CBC3-SHA, KRB5-DES, CBC3-SHA, CAMELLIA, SEED-SHA, AES256-SHA, AES256-SHA256, AES256-GCM-SHA384, AES128-SHA, AES128-SHA256, AES128-GCM-SHA256, DHE-RSA-AES128-GCM-SHA256, DHE-RSA-AES128-SHA, DHE-RSA-AES128-SHA256, DHE-RSA-AES256-GCM-SHA384, DHE-RSA-AES256-SHA, DHE-RSA-AES256-SHA256, DHE-RSA-CHACHA20-POLY1305, ECDHE-RSA-AES128-SHA, ECDHE-RSA-AES256-SHA
tls_preempt_cipherlist = yes
tls_ssl_options = NO_COMPRESSION
# Enable elliptic curve cryptography
smtpd_tls_eecdh_grade = strong
# SMTP Smuggling prevention
# See https://www.postfix.org/smtp-smuggling.html
smtpd_data_restrictions = reject_unauth_pipelining
smtpd_forbid_unauth_pipelining = yes
# Use TLS if this is supported by the remote SMTP server, otherwise use plaintext.
smtp_tls_CApath = /etc/ssl/certs
smtp_tls_security_level = may
@@ -67,10 +74,10 @@ smtp_tls_exclude_ciphers = EXPORT, LOW
#
%{dovecot_enabled}virtual_transport = lmtp:unix:private/dovecot-lmtp
virtual_mailbox_domains = proxy:%{db_driver}:/etc/postfix/sql-domains.cf
virtual_alias_domains = proxy:%{db_driver}:/etc/postfix/sql-domain-aliases.cf
virtual_alias_maps =
proxy:%{db_driver}:/etc/postfix/sql-aliases.cf
%{dovecot_enabled}virtual_mailbox_domains = proxy:%{db_driver}:/etc/postfix/sql-domains.cf
%{dovecot_enabled}virtual_alias_domains = proxy:%{db_driver}:/etc/postfix/sql-domain-aliases.cf
%{dovecot_enabled}virtual_alias_maps =
%{dovecot_enabled} proxy:%{db_driver}:/etc/postfix/sql-aliases.cf
## Relay domains
#
@@ -115,10 +122,19 @@ strict_rfc821_envelopes = yes
%{opendkim_enabled}milter_default_action = accept
%{opendkim_enabled}milter_content_timeout = 30s
# Rspamd setup
%{rspamd_enabled}smtpd_milters = inet:localhost:11332
%{rspamd_enabled}non_smtpd_milters = inet:localhost:11332
%{rspamd_enabled}milter_default_action = accept
%{rspamd_enabled}milter_protocol = 6
# List of authorized senders
smtpd_sender_login_maps =
proxy:%{db_driver}:/etc/postfix/sql-sender-login-map.cf
# Add authenticated header to hide public client IP
smtpd_sasl_authenticated_header = yes
# Recipient restriction rules
smtpd_recipient_restrictions =
check_policy_service inet:127.0.0.1:9999
@@ -135,28 +151,27 @@ smtpd_recipient_restrictions =
## Postcreen settings
#
postscreen_access_list =
permit_mynetworks
cidr:/etc/postfix/postscreen_spf_whitelist.cidr
postscreen_blacklist_action = enforce
%{rspamd_disabled}postscreen_access_list =
%{rspamd_disabled} permit_mynetworks
%{rspamd_disabled} cidr:/etc/postfix/postscreen_spf_whitelist.cidr
%{rspamd_disabled}postscreen_blacklist_action = enforce
# Use some DNSBL
postscreen_dnsbl_sites =
zen.spamhaus.org=127.0.0.[2..11]*3
bl.spameatingmonkey.net=127.0.0.2*2
bl.spamcop.net=127.0.0.2
dnsbl.sorbs.net=127.0.0.[2..15]
postscreen_dnsbl_threshold = 3
postscreen_dnsbl_action = enforce
%{rspamd_disabled}postscreen_dnsbl_sites =
%{rspamd_disabled} zen.spamhaus.org=127.0.0.[2..11]*3
%{rspamd_disabled} bl.spameatingmonkey.net=127.0.0.2*2
%{rspamd_disabled} bl.spamcop.net=127.0.0.2
%{rspamd_disabled}postscreen_dnsbl_threshold = 3
%{rspamd_disabled}postscreen_dnsbl_action = enforce
postscreen_greet_banner = Welcome, please wait...
postscreen_greet_action = enforce
%{rspamd_disabled}postscreen_greet_banner = Welcome, please wait...
%{rspamd_disabled}postscreen_greet_action = enforce
postscreen_pipelining_enable = yes
postscreen_pipelining_action = enforce
%{rspamd_disabled}postscreen_pipelining_enable = yes
%{rspamd_disabled}postscreen_pipelining_action = enforce
postscreen_non_smtp_command_enable = yes
postscreen_non_smtp_command_action = enforce
%{rspamd_disabled}postscreen_non_smtp_command_enable = yes
%{rspamd_disabled}postscreen_non_smtp_command_action = enforce
postscreen_bare_newline_enable = yes
postscreen_bare_newline_action = enforce
%{rspamd_disabled}postscreen_bare_newline_enable = yes
%{rspamd_disabled}postscreen_bare_newline_action = enforce

View File

@@ -9,7 +9,8 @@
# service type private unpriv chroot wakeup maxproc command + args
# (yes) (yes) (yes) (never) (100)
# ==========================================================================
smtp inet n - - - 1 postscreen
%{rspamd_disabled}smtp inet n - - - 1 postscreen
%{rspamd_enabled}smtp inet n - - - - smtpd
smtpd pass - - - - - smtpd
%{amavis_enabled} -o smtpd_proxy_filter=inet:[127.0.0.1]:10024
%{amavis_enabled} -o smtpd_proxy_options=speed_adjust
@@ -26,6 +27,7 @@ submission inet n - - - - smtpd
-o smtpd_helo_restrictions=
-o smtpd_sender_restrictions=reject_sender_login_mismatch
-o milter_macro_daemon_name=ORIGINATING
-o cleanup_service_name=ascleanup
%{amavis_enabled} -o smtpd_proxy_filter=inet:[127.0.0.1]:10026
#smtps inet n - - - - smtpd
# -o syslog_name=postfix/smtps
@@ -41,6 +43,8 @@ submission inet n - - - - smtpd
#628 inet n - - - - qmqpd
pickup unix n - - 60 1 pickup
cleanup unix n - - - 0 cleanup
ascleanup unix n - - - 0 cleanup
-o header_checks=pcre:/etc/postfix/anonymize_headers.pcre
qmgr unix n - n 300 1 qmgr
#qmgr unix n - n 300 1 oqmgr
tlsmgr unix - - - 1000? 1 tlsmgr
@@ -78,7 +82,7 @@ scache unix - - - - 1 scache
# Also specify in main.cf: maildrop_destination_recipient_limit=1
#
maildrop unix - n n - - pipe
flags=DRhu user=vmail argv=/usr/bin/maildrop -d ${recipient}
flags=DRhu user=%{dovecot_mailboxes_owner} argv=/usr/bin/maildrop -d ${recipient}
#
# ====================================================================
#
@@ -124,11 +128,6 @@ mailman unix - n n - - pipe
flags=FR user=list argv=/usr/lib/mailman/bin/postfix-to-mailman.py
${nexthop} ${user}
# Modoboa autoreply service
#
autoreply unix - n n - - pipe
flags= user=%{dovecot_mailboxes_owner}:%{dovecot_mailboxes_owner} argv=%{modoboa_venv_path}/bin/python %{modoboa_instance_path}/manage.py autoreply $sender $mailbox
# Amavis return path
#
%{amavis_enabled}127.0.0.1:10025 inet n - n - - smtpd
@@ -149,4 +148,4 @@ autoreply unix - n n - - pipe
%{amavis_enabled} -o smtpd_client_connection_count_limit=0
%{amavis_enabled} -o smtpd_client_connection_rate_limit=0
%{amavis_enabled} -o receive_override_options=no_header_body_checks,no_unknown_recipient_checks
%{amavis_enabled} -o local_header_rewrite_clients=
%{amavis_enabled} -o local_header_rewrite_clients=permit_mynetworks,permit_sasl_authenticated

View File

@@ -71,7 +71,7 @@
# Authentication method
# Value: none | htpasswd | remote_user | http_x_remote_user
type = radicale_dovecot_auth
type = radicale_modoboa_auth_oauth2
# Htpasswd filename
# htpasswd_filename = users
@@ -85,7 +85,7 @@ type = radicale_dovecot_auth
# Incorrect authentication delay (seconds)
#delay = 1
auth_socket = %{auth_socket_path}
oauth2_introspection_endpoint = %{oauth2_introspection_url}
[rights]

View File

@@ -0,0 +1,14 @@
clamav {
scan_mime_parts = true;
scan_text_mime = true;
scan_image_mime = true;
retransmits = 2;
timeout = 30;
symbol = "CLAM_VIRUS";
type = "clamav";
servers = "127.0.0.1:3310"
patterns {
# symbol_name = "pattern";
JUST_EICAR = "Test.EICAR";
}
}

View File

@@ -0,0 +1,3 @@
try_fallback = false;
selector_map = "%selector_map_path";
path_map = "%key_map_path";

View File

@@ -0,0 +1,3 @@
try_fallback = false;
selector_map = "%selector_map_path";
path_map = "%key_map_path";

View File

@@ -0,0 +1,21 @@
reporting {
# Required attributes
enabled = true; # Enable reports in general
email = 'postmaster@%hostname'; # Source of DMARC reports
domain = '%hostname'; # Domain to serve
org_name = '%hostname'; # Organisation
# Optional parameters
#bcc_addrs = ["postmaster@example.com"]; # additional addresses to copy on reports
report_local_controller = false; # Store reports for local/controller scans (for testing only)
#helo = 'rspamd.localhost'; # Helo used in SMTP dialog
#smtp = '127.0.0.1'; # SMTP server IP
#smtp_port = 25; # SMTP server port
from_name = '%hostname DMARC REPORT'; # SMTP FROM
msgid_from = 'rspamd'; # Msgid format
#max_entries = 1k; # Maxiumum amount of entries per domain
#keys_expire = 2d; # Expire date for Redis keys
#only_domains = '/path/to/map'; # Only store reports from domains or eSLDs listed in this map
# Available from 3.3
#exclude_domains = '/path/to/map'; # Exclude reports from domains or eSLDs listed in this map
#exclude_domains = ["example.com", "another.com"]; # Alternative, use array to exclude reports from domains or eSLDs
}

View File

@@ -0,0 +1,5 @@
rules {
DMARC_POLICY_QUARANTINE {
action = "add header";
}
}

View File

@@ -0,0 +1,2 @@
%{greylisting_disabled}enabled = false;
servers = "127.0.0.1:6379";

View File

@@ -0,0 +1,5 @@
symbols {
"WHITELIST_AUTHENTICATED" {
weight = %whitelist_auth_weigth;
}
}

View File

@@ -0,0 +1,20 @@
actions {
reject = 15; # normal value is 15, 150 so it will never be rejected
add_header = 6; # set to 0.1 for testing, 6 for normal operation.
rewrite_subject = 8; # Default: 8
greylist = 4; # Default: 4
}
group "antivirus" {
symbol "JUST_EICAR" {
weight = 10;
description = "Eicar test signature";
}
symbol "CLAM_VIRUS_FAIL" {
weight = 0;
}
symbol "CLAM_VIRUS" {
weight = 10;
description = "ClamAV found a Virus";
}
}

View File

@@ -0,0 +1,16 @@
use = ["x-spam-status","x-virus","authentication-results" ];
extended_spam_headers = false;
skip_local = false;
skip_authenticated = false;
routines {
x-virus {
header = "X-Virus";
remove = 1;
symbols = ["CLAM_VIRUS", "JUST_EICAR"];
}
}

View File

@@ -0,0 +1 @@
enabled = true;

View File

@@ -0,0 +1,6 @@
# to disable all predefined rules if the user doesn't want dnsbl
url_whitelist = [];
rbls {
}

View File

@@ -0,0 +1,2 @@
write_servers = "localhost";
read_servers = "localhost";

View File

@@ -0,0 +1,8 @@
authenticated {
priority = high;
authenticated = yes;
apply {
groups_disabled = ["rbl", "spf"];
}
%{whitelist_auth_enabled} symbols ["WHITELIST_AUTHENTICATED"];
}

View File

@@ -0,0 +1,6 @@
spf_cache_size = 1k;
spf_cache_expire = 1d;
max_dns_nesting = 10;
max_dns_requests = 30;
min_cache_ttl = 5m;
disable_ipv6 = false;

View File

@@ -0,0 +1 @@
enable_password = %controller_password

View File

@@ -0,0 +1 @@
enabled = false;

View File

@@ -0,0 +1,3 @@
upstream "local" {
self_scan = yes;
}

View File

@@ -13,3 +13,5 @@ socket = %uwsgi_socket_path
chmod-socket = 660
vacuum = true
single-interpreter = True
max-requests = 5000
buffer-size = 8192

View File

@@ -3,6 +3,7 @@
import json
import os
import pwd
import random
import shutil
import stat
import sys
@@ -25,7 +26,8 @@ class Modoboa(base.Installer):
"deb": [
"build-essential", "python3-dev", "libxml2-dev", "libxslt-dev",
"libjpeg-dev", "librrd-dev", "rrdtool", "libffi-dev", "cron",
"libssl-dev", "redis-server", "supervisor"
"libssl-dev", "redis-server", "supervisor", "pkg-config",
"libcairo2-dev"
],
"rpm": [
"gcc", "gcc-c++", "python3-devel", "libxml2-devel", "libxslt-devel",
@@ -42,80 +44,73 @@ class Modoboa(base.Installer):
def __init__(self, *args, **kwargs):
"""Get configuration."""
super(Modoboa, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
self.venv_path = self.config.get("modoboa", "venv_path")
self.instance_path = self.config.get("modoboa", "instance_path")
self.extensions = self.config.get("modoboa", "extensions").split()
self.devmode = self.config.getboolean("modoboa", "devmode")
# Sanity check for amavis
self.amavis_enabled = False
if "modoboa-amavis" in self.extensions:
if self.config.getboolean("amavis", "enabled"):
self.amavis_enabled = True
else:
self.extensions.remove("modoboa-amavis")
if "modoboa-radicale" in self.extensions:
if not self.config.getboolean("radicale", "enabled"):
self.extensions.remove("modoboa-radicale")
self.amavis_enabled = self.config.getboolean("amavis", "enabled")
self.dovecot_enabled = self.config.getboolean("dovecot", "enabled")
self.opendkim_enabled = self.config.getboolean("opendkim", "enabled")
self.dkim_cron_enabled = False
def is_extension_ok_for_version(self, extension, version):
"""Check if extension can be installed with this modo version."""
if extension not in compatibility_matrix.EXTENSIONS_AVAILABILITY:
return True
version = utils.convert_version_to_int(version)
min_version = compatibility_matrix.EXTENSIONS_AVAILABILITY[extension]
min_version = utils.convert_version_to_int(min_version)
return version >= min_version
if extension in compatibility_matrix.EXTENSIONS_AVAILABILITY:
min_version = compatibility_matrix.EXTENSIONS_AVAILABILITY[extension]
min_version = utils.convert_version_to_int(min_version)
return version >= min_version
if extension in compatibility_matrix.REMOVED_EXTENSIONS:
max_version = compatibility_matrix.REMOVED_EXTENSIONS[extension]
max_version = utils.convert_version_to_int(max_version)
return version < max_version
return True
def _setup_venv(self):
"""Prepare a dedicated virtualenv."""
python.setup_virtualenv(
self.venv_path, sudo_user=self.user, python_version=3)
python.setup_virtualenv(self.venv_path, sudo_user=self.user)
packages = ["rrdtool"]
version = self.config.get("modoboa", "version")
extras = "postgresql"
if self.dbengine != "postgres":
extras = "mysql"
if self.devmode:
extras += ",dev"
if version == "latest":
packages += ["modoboa"] + self.extensions
packages += [f"modoboa[{extras}]"] + self.extensions
for extension in list(self.extensions):
if extension in compatibility_matrix.REMOVED_EXTENSIONS.keys():
self.extensions.remove(extension)
self.extensions = [
extension for extension in self.extensions
if extension not in compatibility_matrix.REMOVED_EXTENSIONS
]
else:
matrix = compatibility_matrix.COMPATIBILITY_MATRIX[version]
packages.append("modoboa=={}".format(version))
packages.append(f"modoboa[{extras}]=={version}")
for extension in list(self.extensions):
if not self.is_extension_ok_for_version(extension, version):
self.extensions.remove(extension)
continue
if extension in matrix:
req_version = matrix[extension]
req_version = req_version.replace("<", "\<")
req_version = req_version.replace(">", "\>")
if req_version is None:
continue
req_version = req_version.replace("<", "\\<")
req_version = req_version.replace(">", "\\>")
packages.append("{}{}".format(extension, req_version))
else:
packages.append(extension)
# Temp fix for django-braces
python.install_package(
"django-braces", self.venv_path, upgrade=self.upgrade,
sudo_user=self.user
)
if self.dbengine == "postgres":
packages.append("psycopg2-binary\<2.9")
else:
packages.append("mysqlclient")
if sys.version_info.major == 2 and sys.version_info.micro < 9:
# Add extra packages to fix the SNI issue
packages += ["pyOpenSSL"]
# Temp fix for https://github.com/modoboa/modoboa/issues/2247
packages.append("django-webpack-loader==0.7.0")
python.install_packages(
packages, self.venv_path,
upgrade=self.upgrade,
sudo_user=self.user,
beta=self.config.getboolean("modoboa", "install_beta")
)
if self.devmode:
# FIXME: use dev-requirements instead
python.install_packages(
["django-bower", "django-debug-toolbar"], self.venv_path,
upgrade=self.upgrade, sudo_user=self.user)
def _deploy_instance(self):
"""Deploy Modoboa."""
@@ -176,7 +171,7 @@ class Modoboa(base.Installer):
if self.upgrade and self.opendkim_enabled and self.dbengine == "postgres":
# Restore view previously deleted
self.backend.load_sql_file(
self.dbname, self.dbuser, self.dbpassword,
self.dbname, self.dbuser, self.dbpasswd,
self.get_file_path("dkim_view_{}.sql".format(self.dbengine))
)
self.backend.grant_right_on_table(
@@ -186,7 +181,7 @@ class Modoboa(base.Installer):
def setup_database(self):
"""Additional config."""
super(Modoboa, self).setup_database()
super().setup_database()
if not self.amavis_enabled:
return
self.backend.grant_access(
@@ -194,7 +189,7 @@ class Modoboa(base.Installer):
def get_packages(self):
"""Include extra packages if needed."""
packages = super(Modoboa, self).get_packages()
packages = super().get_packages()
condition = (
package.backend.FORMAT == "rpm" and
sys.version_info.major == 2 and
@@ -204,6 +199,10 @@ class Modoboa(base.Installer):
packages += ["openssl-devel"]
return packages
def setup_user(self):
super().setup_user()
self._setup_venv()
def get_config_files(self):
"""Return appropriate path."""
config_files = super().get_config_files()
@@ -212,6 +211,13 @@ class Modoboa(base.Installer):
else:
path = "supervisor=/etc/supervisord.d/policyd.ini"
config_files.append(path)
# Add worker for dkim if needed
if self.modoboa_2_2_or_greater:
config_files.append(
"supervisor-rq-dkim=/etc/supervisor/conf.d/modoboa-dkim-worker.conf")
config_files.append(
"supervisor-rq-base=/etc/supervisor/conf.d/modoboa-base-worker.conf")
return config_files
def get_template_context(self):
@@ -219,15 +225,21 @@ class Modoboa(base.Installer):
context = super(Modoboa, self).get_template_context()
extensions = self.config.get("modoboa", "extensions")
extensions = extensions.split()
random_hour = random.randint(0, 6)
self.dkim_cron_enabled = (not self.modoboa_2_2_or_greater and
self.opendkim_enabled)
context.update({
"sudo_user": (
"uwsgi" if package.backend.FORMAT == "rpm" else context["user"]
),
"dovecot_mailboxes_owner": (
self.config.get("dovecot", "mailboxes_owner")),
"radicale_enabled": (
"" if "modoboa-radicale" in extensions else "#"),
"opendkim_user": self.config.get("opendkim", "user"),
"dkim_user": "_rspamd" if self.config.getboolean("rspamd", "enabled") else self.config.get("opendkim", "user"),
"minutes": random.randint(1, 59),
"hours": f"{random_hour},{random_hour+12}",
"modoboa_2_2_or_greater": "" if self.modoboa_2_2_or_greater else "#",
"dkim_cron_enabled": "" if self.dkim_cron_enabled else "#"
})
return context
@@ -239,7 +251,7 @@ class Modoboa(base.Installer):
self.instance_path, "media", "webmail")
pw = pwd.getpwnam(self.user)
for d in [rrd_root_dir, pdf_storage_dir, webmail_media_dir]:
utils.mkdir(d, stat.S_IRWXU | stat.S_IRWXG, pw[2], pw[3])
utils.mkdir_safe(d, stat.S_IRWXU | stat.S_IRWXG, pw[2], pw[3])
settings = {
"admin": {
"handle_mailboxes": True,
@@ -251,10 +263,10 @@ class Modoboa(base.Installer):
"maillog": {
"rrd_rootdir": rrd_root_dir,
},
"modoboa_pdfcredentials": {
"pdfcredentials": {
"storage_dir": pdf_storage_dir
},
"modoboa_radicale": {
"calendars": {
"server_location": "https://{}/radicale/".format(
self.config.get("general", "hostname")),
"rights_file_path": "{}/rights".format(
@@ -267,6 +279,15 @@ class Modoboa(base.Installer):
if self.config.getboolean("opendkim", "enabled"):
settings["admin"]["dkim_keys_storage_dir"] = (
self.config.get("opendkim", "keys_storage_dir"))
if self.config.getboolean("rspamd", "enabled"):
settings["admin"]["dkim_keys_storage_dir"] = (
self.config.get("rspamd", "dkim_keys_storage_dir"))
settings["modoboa_rspamd"] = {
"key_map_path": self.config.get("rspamd", "key_map_path"),
"selector_map_path": self.config.get("rspamd", "selector_map_path")
}
settings = json.dumps(settings)
query = (
"UPDATE core_localconfig SET _parameters='{}'"
@@ -277,17 +298,18 @@ class Modoboa(base.Installer):
def post_run(self):
"""Additional tasks."""
self._setup_venv()
if 'centos' in utils.dist_name():
system.enable_and_start_service("redis")
else:
system.enable_and_start_service("redis-server")
self._deploy_instance()
if not self.upgrade:
self.apply_settings()
if 'centos' in utils.dist_name():
supervisor = "supervisord"
system.enable_and_start_service("redis")
else:
supervisor = "supervisor"
system.enable_and_start_service("redis-server")
# Restart supervisor
system.enable_service(supervisor)
utils.exec_cmd("service {} stop".format(supervisor))

View File

@@ -21,12 +21,12 @@ class Nginx(base.Installer):
def get_template_context(self, app):
"""Additionnal variables."""
context = super(Nginx, self).get_template_context()
context = super().get_template_context()
context.update({
"app_instance_path": (
self.config.get(app, "instance_path")),
"uwsgi_socket_path": (
Uwsgi(self.config, self.upgrade).get_socket_path(app))
Uwsgi(self.config, self.upgrade, self.restore).get_socket_path(app))
})
return context

View File

@@ -2,6 +2,7 @@
import os
import pwd
import shutil
import stat
from .. import database
@@ -46,7 +47,7 @@ class Opendkim(base.Installer):
stat.S_IROTH | stat.S_IXOTH,
target[1], target[2]
)
super(Opendkim, self).install_config_files()
super().install_config_files()
def get_template_context(self):
"""Additional variables."""
@@ -109,3 +110,25 @@ class Opendkim(base.Installer):
"s/^After=(.*)$/After=$1 {}/".format(dbservice))
utils.exec_cmd(
"perl -pi -e '{}' /lib/systemd/system/opendkim.service".format(pattern))
def restore(self):
"""Restore keys."""
dkim_keys_backup = os.path.join(
self.archive_path, "custom/dkim")
keys_storage_dir = self.app_config["keys_storage_dir"]
if os.path.isdir(dkim_keys_backup):
for file in os.listdir(dkim_keys_backup):
file_path = os.path.join(dkim_keys_backup, file)
if os.path.isfile(file_path):
utils.copy_file(file_path, keys_storage_dir)
utils.success("DKIM keys restored from backup")
# Setup permissions
user = self.config.get("opendkim", "user")
utils.exec_cmd(f"chown -R {user}:{user} {keys_storage_dir}")
def custom_backup(self, path):
"""Backup DKIM keys."""
if os.path.isdir(self.app_config["keys_storage_dir"]):
shutil.copytree(self.app_config["keys_storage_dir"], os.path.join(path, "dkim"))
utils.printcolor(
"DKIM keys saved!", utils.GREEN)

View File

@@ -10,19 +10,17 @@ from .. import package
from .. import utils
from . import base
from . import install
from . import backup, install
class Postfix(base.Installer):
"""Postfix installer."""
appname = "postfix"
packages = {
"deb": ["postfix"],
"rpm": ["postfix"],
"deb": ["postfix", "postfix-pcre"],
}
config_files = ["main.cf", "master.cf"]
config_files = ["main.cf", "master.cf", "anonymize_headers.pcre"]
def get_packages(self):
"""Additional packages."""
@@ -30,7 +28,7 @@ class Postfix(base.Installer):
packages = ["postfix-{}".format(self.db_driver)]
else:
packages = []
return super(Postfix, self).get_packages() + packages
return super().get_packages() + packages
def install_packages(self):
"""Preconfigure postfix package installation."""
@@ -47,11 +45,11 @@ class Postfix(base.Installer):
package.backend.preconfigure(
"postfix", "main_mailer_type", "select", "No configuration")
super(Postfix, self).install_packages()
super().install_packages()
def get_template_context(self):
"""Additional variables."""
context = super(Postfix, self).get_template_context()
context = super().get_template_context()
context.update({
"db_driver": self.db_driver,
"dovecot_mailboxes_owner": self.config.get(
@@ -61,10 +59,19 @@ class Postfix(base.Installer):
"modoboa_instance_path": self.config.get(
"modoboa", "instance_path"),
"opendkim_port": self.config.get(
"opendkim", "port")
"opendkim", "port"),
"rspamd_disabled": "" if not self.config.getboolean(
"rspamd", "enabled") else "#"
})
return context
def check_dhe_group_file(self):
group = self.config.get(self.appname, "dhe_group")
file_name = f"ffdhe{group}.pem"
if not os.path.exists(f"{self.config_dir}/{file_name}"):
url = f"https://raw.githubusercontent.com/internetstandards/dhe_groups/main/{file_name}"
utils.exec_cmd(f"wget {url}", cwd=self.config_dir)
def post_run(self):
"""Additional tasks."""
venv_path = self.config.get("modoboa", "venv_path")
@@ -86,10 +93,8 @@ class Postfix(base.Installer):
if not os.path.exists(path):
utils.copy_file(os.path.join("/etc", f), path)
# Generate EDH parameters
if not os.path.exists("{}/dh2048.pem".format(self.config_dir)):
cmd = "openssl dhparam -dsaparam -out dh2048.pem 2048"
utils.exec_cmd(cmd, cwd=self.config_dir)
# Generate DHE group
self.check_dhe_group_file()
# Generate /etc/aliases.db file to avoid warnings
aliases_file = "/etc/aliases"
@@ -97,4 +102,18 @@ class Postfix(base.Installer):
utils.exec_cmd("postalias {}".format(aliases_file))
# Postwhite
install("postwhite", self.config, self.upgrade)
condition = (
not self.config.getboolean("rspamd", "enabled") and
self.config.getboolean("postwhite", "enabled")
)
if condition:
install("postwhite", self.config, self.upgrade, self.archive_path)
def backup(self, path):
"""Launch postwhite backup."""
condition = (
not self.config.getboolean("rspamd", "enabled") and
self.config.getboolean("postwhite", "enabled")
)
if condition:
backup("postwhite", self.config, path)

View File

@@ -45,8 +45,25 @@ class Postwhite(base.Installer):
"""Additionnal tasks."""
install_dir = "/usr/local/bin"
self.install_from_archive(SPF_TOOLS_REPOSITORY, install_dir)
postw_dir = self.install_from_archive(
self.postw_dir = self.install_from_archive(
POSTWHITE_REPOSITORY, install_dir)
utils.copy_file(os.path.join(postw_dir, "postwhite.conf"), "/etc")
postw_bin = os.path.join(postw_dir, "postwhite")
utils.exec_cmd("{} /etc/postwhite.conf".format(postw_bin))
utils.copy_file(
os.path.join(self.postw_dir, "postwhite.conf"), self.config_dir)
self.postw_bin = os.path.join(self.postw_dir, "postwhite")
utils.exec_cmd("{} /etc/postwhite.conf".format(self.postw_bin))
def custom_backup(self, path):
"""Backup custom configuration if any."""
postswhite_custom = "/etc/postwhite.conf"
if os.path.isfile(postswhite_custom):
utils.copy_file(postswhite_custom, path)
utils.printcolor(
"Postwhite configuration saved!", utils.GREEN)
def restore(self):
"""Restore config files."""
postwhite_backup_configuration = os.path.join(
self.archive_path, "custom/postwhite.conf")
if os.path.isfile(postwhite_backup_configuration):
utils.copy_file(postwhite_backup_configuration, self.config_dir)
utils.success("postwhite.conf restored from backup")

View File

@@ -1,6 +1,7 @@
"""Radicale related tasks."""
import os
import shutil
import stat
from .. import package
@@ -25,15 +26,14 @@ class Radicale(base.Installer):
def __init__(self, *args, **kwargs):
"""Get configuration."""
super(Radicale, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
self.venv_path = self.config.get("radicale", "venv_path")
def _setup_venv(self):
"""Prepare a dedicated virtualenv."""
python.setup_virtualenv(
self.venv_path, sudo_user=self.user, python_version=3)
python.setup_virtualenv(self.venv_path, sudo_user=self.user)
packages = [
"Radicale", "radicale-dovecot-auth", "pytz"
"Radicale", "pytz", "radicale-modoboa-auth-oauth2"
]
python.install_packages(packages, self.venv_path, sudo_user=self.user)
python.install_package_from_repository(
@@ -43,17 +43,22 @@ class Radicale(base.Installer):
def get_template_context(self):
"""Additional variables."""
context = super(Radicale, self).get_template_context()
radicale_auth_socket_path = self.config.get(
"dovecot", "radicale_auth_socket_path")
context = super().get_template_context()
oauth2_client_id, oauth2_client_secret = utils.create_oauth2_app(
"Radicale", "radicale", self.config)
hostname = self.config.get("general", "hostname")
oauth2_introspection_url = (
f"https://{oauth2_client_id}:{oauth2_client_secret}"
f"@{hostname}/api/o/introspect/"
)
context.update({
"auth_socket_path": radicale_auth_socket_path
"oauth2_introspection_url": oauth2_introspection_url,
})
return context
def get_config_files(self):
"""Return appropriate path."""
config_files = super(Radicale, self).get_config_files()
config_files = super().get_config_files()
if package.backend.FORMAT == "deb":
path = "supervisor=/etc/supervisor/conf.d/radicale.conf"
else:
@@ -70,7 +75,18 @@ class Radicale(base.Installer):
stat.S_IROTH | stat.S_IXOTH,
0, 0
)
super(Radicale, self).install_config_files()
super().install_config_files()
def restore(self):
"""Restore collections."""
radicale_backup = os.path.join(
self.archive_path, "custom/radicale")
if os.path.isdir(radicale_backup):
restore_target = os.path.join(self.home_dir, "collections")
if os.path.isdir(restore_target):
shutil.rmtree(restore_target)
shutil.copytree(radicale_backup, restore_target)
utils.success("Radicale collections restored from backup")
def post_run(self):
"""Additional tasks."""
@@ -81,3 +97,12 @@ class Radicale(base.Installer):
system.enable_service(daemon_name)
utils.exec_cmd("service {} stop".format(daemon_name))
utils.exec_cmd("service {} start".format(daemon_name))
def custom_backup(self, path):
"""Backup collections."""
radicale_backup = os.path.join(self.config.get(
"radicale", "home_dir", fallback="/srv/radicale"), "collections")
if os.path.isdir(radicale_backup):
shutil.copytree(radicale_backup, os.path.join(
path, "radicale"))
utils.printcolor("Radicale files saved", utils.GREEN)

View File

@@ -0,0 +1,26 @@
import os
import sys
from .. import utils
class Restore:
def __init__(self, restore):
"""
Restoring pre-check (backup integriety)
REQUIRED : modoboa.sql
OPTIONAL : mails/, custom/, amavis.sql, spamassassin.sql
Only checking required
"""
if not os.path.isdir(restore):
utils.error(
"Provided path is not a directory !")
sys.exit(1)
modoba_sql_file = os.path.join(restore, "databases/modoboa.sql")
if not os.path.isfile(modoba_sql_file):
utils.error(
modoba_sql_file + " not found, please check your backup")
sys.exit(1)
# Everything seems alright here, proceeding...

View File

@@ -0,0 +1,154 @@
"""Rspamd related functions."""
import os
import pwd
import stat
from .. import package
from .. import utils
from .. import system
from . import base
from . import install
class Rspamd(base.Installer):
"""Rspamd installer."""
appname = "rspamd"
packages = {
"deb": [
"rspamd", "redis"
]
}
config_files = [
"local.d/arc.conf",
"local.d/dkim_signing.conf",
"local.d/dmarc.conf",
"local.d/force_actions.conf",
"local.d/greylist.conf",
"local.d/metrics.conf",
"local.d/milter_headers.conf",
"local.d/mx_check.conf",
"local.d/redis.conf",
"local.d/settings.conf",
"local.d/spf.conf",
"local.d/worker-normal.inc",
"local.d/worker-proxy.inc",
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.generate_password_condition = (
not self.upgrade or utils.user_input(
"Do you want to (re)generate rspamd password ? (y/N)").lower().startswith("y")
)
@property
def config_dir(self):
"""Return appropriate config dir."""
return "/etc/rspamd"
def install_packages(self):
debian_based_dist, codename = utils.is_dist_debian_based()
if debian_based_dist:
utils.mkdir_safe(
"/etc/apt/keyrings",
stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP |
stat.S_IROTH | stat.S_IXOTH,
0, 0
)
package.backend.add_custom_repository(
"rspamd",
"http://rspamd.com/apt-stable/",
"https://rspamd.com/apt-stable/gpg.key",
codename
)
package.backend.update()
return super().install_packages()
def install_config_files(self):
"""Make sure config directory exists."""
user = self.config.get(self.appname, "user")
pw = pwd.getpwnam(user)
targets = [
[self.app_config["dkim_keys_storage_dir"], pw[2], pw[3]]
]
for target in targets:
if not os.path.exists(target[0]):
utils.mkdir(
target[0],
stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP |
stat.S_IROTH | stat.S_IXOTH,
target[1], target[2]
)
super().install_config_files()
def get_config_files(self):
"""Return appropriate config files."""
_config_files = self.config_files
if self.config.getboolean("clamav", "enabled"):
_config_files.append("local.d/antivirus.conf")
if self.app_config["dnsbl"].lower() == "true":
_config_files.append("local.d/rbl.conf")
if self.app_config["whitelist_auth"].lower() == "true":
_config_files.append("local.d/groups.conf")
if self.generate_password_condition:
_config_files.append("local.d/worker-controller.inc")
return _config_files
def get_template_context(self):
_context = super().get_template_context()
_context["greylisting_disabled"] = "" if not self.app_config["greylisting"].lower() == "true" else "#"
_context["whitelist_auth_enabled"] = "" if self.app_config["whitelist_auth"].lower() == "true" else "#"
if self.generate_password_condition:
code, controller_password = utils.exec_cmd(
r"rspamadm pw -p {}".format(self.app_config["password"]))
if code != 0:
utils.error("Error setting rspamd password. "
"Please make sure it is not 'q1' or 'q2'."
"Storing the password in plain. See"
"https://rspamd.com/doc/quickstart.html#setting-the-controller-password")
_context["controller_password"] = self.app_config["password"]
else:
controller_password = controller_password.decode().replace("\n", "")
_context["controller_password"] = controller_password
return _context
def post_run(self):
"""Additional tasks."""
user = self.config.get(self.appname, "user")
system.add_user_to_group(
self.config.get("modoboa", "user"),
user
)
if self.config.getboolean("clamav", "enabled"):
install("clamav", self.config, self.upgrade, self.archive_path)
def custom_backup(self, path):
"""Backup custom configuration if any."""
custom_config_dir = os.path.join(self.config_dir,
"/local.d/")
custom_backup_dir = os.path.join(path, "/rspamd/")
local_files = [f for f in os.listdir(custom_config_dir)
if os.path.isfile(custom_config_dir, f)
]
for file in local_files:
utils.copy_file(file, custom_backup_dir)
if len(local_files) != 0:
utils.success("Rspamd custom configuration saved!")
def restore(self):
"""Restore custom config files."""
custom_config_dir = os.path.join(self.config_dir,
"/local.d/")
custom_backup_dir = os.path.join(self.archive_path, "/rspamd/")
backed_up_files = [
f for f in os.listdir(custom_backup_dir)
if os.path.isfile(custom_backup_dir, f)
]
for f in backed_up_files:
utils.copy_file(f, custom_config_dir)
utils.success("Custom Rspamd configuration restored.")

View File

@@ -57,13 +57,7 @@ class Spamassassin(base.Installer):
def post_run(self):
"""Additional tasks."""
amavis_user = self.config.get("amavis", "user")
pw = pwd.getpwnam(amavis_user)
utils.exec_cmd(
"pyzor --homedir {} discover".format(pw[5]),
sudo_user=amavis_user, login=False
)
install("razor", self.config, self.upgrade)
install("razor", self.config, self.upgrade, self.restore)
if utils.dist_name() in ["debian", "ubuntu"]:
utils.exec_cmd(
"perl -pi -e 's/^CRON=0/CRON=1/' /etc/cron.daily/spamassassin")

View File

@@ -7,7 +7,7 @@ from . import package
from . import utils
class CertificateBackend(object):
class CertificateBackend:
"""Base class."""
def __init__(self, config):
@@ -24,13 +24,44 @@ class CertificateBackend(object):
return False
return True
def generate_cert(self):
"""Create a certificate."""
pass
class ManualCertificate(CertificateBackend):
"""Use certificate provided."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
path_correct = True
self.tls_cert_file_path = self.config.get("certificate",
"tls_cert_file_path")
self.tls_key_file_path = self.config.get("certificate",
"tls_key_file_path")
if not os.path.exists(self.tls_key_file_path):
utils.error("'tls_key_file_path' path is not accessible")
path_correct = False
if not os.path.exists(self.tls_cert_file_path):
utils.error("'tls_cert_file_path' path is not accessible")
path_correct = False
if not path_correct:
sys.exit(1)
self.config.set("general", "tls_key_file",
self.tls_key_file_path)
self.config.set("general", "tls_cert_file",
self.tls_cert_file_path)
class SelfSignedCertificate(CertificateBackend):
"""Create a self signed certificate."""
def __init__(self, *args, **kwargs):
"""Sanity checks."""
super(SelfSignedCertificate, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
if self.config.has_option("general", "tls_key_file"):
# Compatibility
return
@@ -65,7 +96,7 @@ class LetsEncryptCertificate(CertificateBackend):
def __init__(self, *args, **kwargs):
"""Update config."""
super(LetsEncryptCertificate, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
self.hostname = self.config.get("general", "hostname")
self.config.set("general", "tls_cert_file", (
"/etc/letsencrypt/live/{}/fullchain.pem".format(self.hostname)))
@@ -90,7 +121,7 @@ class LetsEncryptCertificate(CertificateBackend):
elif "centos" in name:
package.backend.install("certbot")
else:
utils.printcolor("Failed to install certbot, aborting.", utils.RED)
utils.printcolor("Failed to install certbot, aborting.")
sys.exit(1)
# Nginx plugin certbot
if (
@@ -115,12 +146,24 @@ class LetsEncryptCertificate(CertificateBackend):
cfg_file = "/etc/letsencrypt/renewal/{}.conf".format(self.hostname)
pattern = "s/authenticator = standalone/authenticator = nginx/"
utils.exec_cmd("perl -pi -e '{}' {}".format(pattern, cfg_file))
with open("/etc/letsencrypt/renewal-hooks/deploy/reload-services.sh", "w") as fp:
fp.write(f"""#!/bin/bash
HOSTNAME=$(basename $RENEWED_LINEAGE)
if [ "$HOSTNAME" = "{self.hostname}" ]
then
systemctl reload dovecot
systemctl reload postfix
fi
""")
def get_backend(config):
"""Return the appropriate backend."""
if not config.getboolean("certificate", "generate"):
return None
if config.get("certificate", "type") == "letsencrypt":
cert_type = config.get("certificate", "type")
if cert_type == "letsencrypt":
return LetsEncryptCertificate(config)
if cert_type == "manual":
return ManualCertificate(config)
return SelfSignedCertificate(config)

View File

@@ -1,20 +1,22 @@
"""Utility functions."""
import configparser
import contextlib
import datetime
import getpass
import glob
import os
import pwd
import random
import shutil
import stat
import string
import subprocess
import sys
try:
import configparser
except ImportError:
import ConfigParser as configparser
import uuid
from . import config_dict_template
from .compatibility_matrix import APP_INCOMPATIBILITY
ENV = {}
@@ -30,22 +32,19 @@ class FatalError(Exception):
def user_input(message):
"""Ask something to the user."""
try:
from builtins import input
except ImportError:
answer = raw_input(message)
else:
answer = input(message)
answer = input(message)
return answer
def exec_cmd(cmd, sudo_user=None, pinput=None, login=True, **kwargs):
"""Execute a shell command.
def exec_cmd(cmd, sudo_user=None, login=True, **kwargs):
"""
Execute a shell command.
Run a command using the current user. Set :keyword:`sudo_user` if
you need different privileges.
:param str cmd: the command to execute
:param str sudo_user: a valid system username
:param str pinput: data to send to process's stdin
:rtype: tuple
:return: return code, command output
"""
@@ -54,23 +53,21 @@ def exec_cmd(cmd, sudo_user=None, pinput=None, login=True, **kwargs):
cmd = "sudo {}-u {} {}".format("-i " if login else "", sudo_user, cmd)
if "shell" not in kwargs:
kwargs["shell"] = True
if pinput is not None:
kwargs["stdin"] = subprocess.PIPE
capture_output = False
capture_output = True
if "capture_output" in kwargs:
capture_output = kwargs.pop("capture_output")
elif not ENV.get("debug"):
capture_output = True
if capture_output:
kwargs.update(stdout=subprocess.PIPE, stderr=subprocess.PIPE)
output = None
process = subprocess.Popen(cmd, **kwargs)
if pinput or capture_output:
c_args = [pinput] if pinput is not None else []
output = process.communicate(*c_args)[0]
else:
process.wait()
return process.returncode, output
kwargs.update(stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
kwargs["universal_newlines"] = True
output: str = ""
with subprocess.Popen(cmd, **kwargs) as process:
if capture_output:
for line in process.stdout:
output += line
if ENV.get("debug"):
sys.stdout.write(line)
return process.returncode, output.encode()
def dist_info():
@@ -98,6 +95,17 @@ def dist_name():
return dist_info()[0].lower()
def is_dist_debian_based() -> (bool, str):
"""Check if current OS is Debian based or not."""
status, codename = exec_cmd("lsb_release -c -s")
codename = codename.decode().strip().lower()
return codename in [
"bionic", "bookworm", "bullseye", "buster",
"focal", "jammy", "jessie", "sid", "stretch",
"trusty", "wheezy", "xenial"
], codename
def mkdir(path, mode, uid, gid):
"""Create a directory."""
if not os.path.exists(path):
@@ -107,6 +115,13 @@ def mkdir(path, mode, uid, gid):
os.chown(path, uid, gid)
def mkdir_safe(path, mode, uid, gid):
"""Create a directory. Safe way (-p)"""
if not os.path.exists(path):
os.makedirs(os.path.abspath(path), mode)
mkdir(path, mode, uid, gid)
def make_password(length=16):
"""Create a random password."""
return "".join(
@@ -125,7 +140,6 @@ def settings(**kwargs):
class ConfigFileTemplate(string.Template):
"""Custom class for configuration files."""
delimiter = "%"
@@ -163,19 +177,36 @@ def copy_from_template(template, dest, context):
fp.write(ConfigFileTemplate(buf).substitute(context))
def check_config_file(dest, interactive=False, upgrade=False):
def check_config_file(dest,
interactive=False,
upgrade=False,
backup=False,
restore=False):
"""Create a new installer config file if needed."""
is_present = True
if os.path.exists(dest):
return
return is_present, update_config(dest, False)
if upgrade:
printcolor(
error(
"You cannot upgrade an existing installation without a "
"configuration file.", RED)
"configuration file.")
sys.exit(1)
elif backup:
is_present = False
error(
"Your configuration file hasn't been found. A new one will be generated. "
"Please edit it with correct password for the databases !")
elif restore:
error(
"You cannot restore an existing installation without a "
f"configuration file. (file : {dest} has not been found...")
sys.exit(1)
printcolor(
"Configuration file {} not found, creating new one."
.format(dest), YELLOW)
gen_config(dest, interactive)
return is_present, None
def has_colours(stream):
@@ -203,6 +234,16 @@ def printcolor(message, color):
print(message)
def error(message):
"""Print error message."""
printcolor(message, RED)
def success(message):
"""Print success message."""
printcolor(message, GREEN)
def convert_version_to_int(version):
"""Convert a version string to an integer."""
number_bits = (8, 8, 16)
@@ -244,6 +285,16 @@ def random_key(l=16):
return key
def check_if_condition(config, entry):
"""Check if the "if" directive is present and computes it"""
section_if = True
for condition in entry:
config_key, value = condition.split("=")
section_name, option = config_key.split(".")
section_if = config.get(section_name, option) == value
return section_if
def validate(value, config_entry):
if value is None:
return False
@@ -264,11 +315,14 @@ def validate(value, config_entry):
return True
def get_entry_value(entry, interactive):
if callable(entry["default"]):
def get_entry_value(entry: dict, interactive: bool, config: configparser.ConfigParser) -> string:
default_entry = entry["default"]
if type(default_entry) is type(list()):
default_value = str(check_if_condition(config, default_entry)).lower()
elif callable(default_entry):
default_value = entry["default"]()
else:
default_value = entry["default"]
default_value = default_entry
user_value = None
if entry.get("customizable") and interactive:
while (user_value != '' and not validate(user_value, entry)):
@@ -284,26 +338,208 @@ def get_entry_value(entry, interactive):
if entry.get("values") and user_value != "":
user_value = values[int(user_value)]
non_interactive_values = entry.get("non_interactive_values", [])
if user_value in non_interactive_values:
error(
f"{user_value} cannot be set interactively. "
"Please configure installer.cfg manually by running "
"'python3 run.py --stop-after-configfile-check domain'. "
"Check modoboa-installer README for more information."
)
sys.exit(1)
return user_value if user_value else default_value
def gen_config(dest, interactive=False):
"""Create config file from dict template"""
def load_config_template(interactive):
"""Instantiate a configParser object with the predefined template."""
tpl_dict = config_dict_template.ConfigDictTemplate
config = configparser.ConfigParser()
# only ask about options we need, else still generate default
for section in tpl_dict:
interactive_section = interactive
if "if" in section:
config_key, value = section.get("if").split("=")
section_name, option = config_key.split(".")
interactive_section = (
config.get(section_name, option) == value and interactive)
else:
interactive_section = interactive
condition = check_if_condition(config, section["if"])
interactive_section = condition and interactive
config.add_section(section["name"])
for config_entry in section["values"]:
value = get_entry_value(config_entry, interactive_section)
if config_entry.get("if") is not None:
interactive_section = (interactive_section and
check_if_condition(
config, config_entry["if"]
)
)
value = get_entry_value(config_entry,
interactive_section,
config)
config.set(section["name"], config_entry["option"], value)
return config
def update_config(path, apply_update=True):
"""Update an existing config file."""
config = configparser.ConfigParser()
with open(path) as fp:
config.read_file(fp)
new_config = load_config_template(False)
old_sections = config.sections()
new_sections = new_config.sections()
update = False
dropped_sections = list(set(old_sections) - set(new_sections))
added_sections = list(set(new_sections) - set(old_sections))
if len(dropped_sections) > 0 and apply_update:
printcolor("Following section(s) will not be ported "
"due to being deleted or renamed: " +
', '.join(dropped_sections),
RED)
if len(dropped_sections) + len(added_sections) > 0:
update = True
for section in new_sections:
if section in old_sections:
new_options = new_config.options(section)
old_options = config.options(section)
dropped_options = list(set(old_options) - set(new_options))
added_options = list(set(new_options) - set(old_options))
if len(dropped_options) > 0 and apply_update:
printcolor(f"Following option(s) from section: {section}, "
"will not be ported due to being "
"deleted or renamed: " +
', '.join(dropped_options),
RED)
if len(dropped_options) + len(added_options) > 0:
update = True
if apply_update:
for option in new_options:
if option in old_options:
value = config.get(section, option, raw=True)
if value != new_config.get(section, option, raw=True):
update = True
new_config.set(section, option, value)
if apply_update:
if update:
# Backing up old config file
date = datetime.datetime.now().strftime("%Y_%m_%d_%H_%M_%S")
dest = f"{os.path.splitext(path)[0]}_{date}.old"
shutil.copy(path, dest)
# Overwritting old config file
with open(path, "w") as configfile:
new_config.write(configfile)
# Set file owner to running u+g, and set config file permission to 600
current_username = getpass.getuser()
current_user = pwd.getpwnam(current_username)
os.chown(dest, current_user[2], current_user[3])
os.chmod(dest, stat.S_IRUSR | stat.S_IWUSR)
return dest
return None
else:
# Simply check if current config file is outdated
return update
def gen_config(dest, interactive=False):
"""Create config file from dict template"""
config = load_config_template(interactive)
with open(dest, "w") as configfile:
config.write(configfile)
# Set file owner to running user and group, and set config file permission to 600
current_username = getpass.getuser()
current_user = pwd.getpwnam(current_username)
os.chown(dest, current_user[2], current_user[3])
os.chmod(dest, stat.S_IRUSR | stat.S_IWUSR)
def validate_backup_path(path: str, silent_mode: bool):
"""Check if provided backup path is valid or not."""
path_exists = os.path.exists(path)
if path_exists and os.path.isfile(path):
printcolor(
"Error, you provided a file instead of a directory!", RED)
return None
if not path_exists:
if not silent_mode:
create_dir = input(
f"\"{path}\" doesn't exist, would you like to create it? [y/N]\n"
).lower()
if silent_mode or (not silent_mode and create_dir.startswith("y")):
pw = pwd.getpwnam("root")
mkdir_safe(path, stat.S_IRWXU | stat.S_IRWXG, pw[2], pw[3])
else:
printcolor(
"Error, backup directory not present.", RED
)
return None
if len(os.listdir(path)) != 0:
if not silent_mode:
delete_dir = input(
"Warning: backup directory is not empty, it will be purged if you continue... [y/N]\n").lower()
if silent_mode or (not silent_mode and delete_dir.startswith("y")):
try:
os.remove(os.path.join(path, "installer.cfg"))
except FileNotFoundError:
pass
shutil.rmtree(os.path.join(path, "custom"),
ignore_errors=False)
shutil.rmtree(os.path.join(path, "mails"), ignore_errors=False)
shutil.rmtree(os.path.join(path, "databases"),
ignore_errors=False)
else:
printcolor(
"Error: backup directory not clean.", RED
)
return None
backup_path = path
pw = pwd.getpwnam("root")
for dir in ["custom/", "databases/"]:
mkdir_safe(os.path.join(backup_path, dir),
stat.S_IRWXU | stat.S_IRWXG, pw[2], pw[3])
return backup_path
def create_oauth2_app(app_name: str, client_id: str, config) -> tuple[str, str]:
"""Create a application for Oauth2 authentication."""
# FIXME: how can we check that application already exists ?
venv_path = config.get("modoboa", "venv_path")
python_path = os.path.join(venv_path, "bin", "python")
instance_path = config.get("modoboa", "instance_path")
script_path = os.path.join(instance_path, "manage.py")
client_secret = str(uuid.uuid4())
cmd = (
f"{python_path} {script_path} createapplication "
f"--name={app_name} --skip-authorization "
f"--client-id={client_id} --client-secret={client_secret} "
f"confidential client-credentials"
)
exec_cmd(cmd)
return client_id, client_secret
def check_app_compatibility(section, config):
"""Check that the app can be installed in regards to other enabled apps."""
incompatible_app = []
if section in APP_INCOMPATIBILITY.keys():
for app in APP_INCOMPATIBILITY[section]:
if config.getboolean(app, "enabled"):
error(f"{section} cannot be installed if {app} is enabled. "
"Please disable one of them.")
incompatible_app.append(app)
return len(incompatible_app) == 0

246
run.py
View File

@@ -3,54 +3,89 @@
"""An installer for Modoboa."""
import argparse
try:
import configparser
except ImportError:
import ConfigParser as configparser
import configparser
import datetime
import os
import sys
from modoboa_installer import checks
from modoboa_installer import compatibility_matrix
from modoboa_installer import constants
from modoboa_installer import package
from modoboa_installer import scripts
from modoboa_installer import ssl
from modoboa_installer import system
from modoboa_installer import utils
from modoboa_installer import disclaimers
def installation_disclaimer(args, config):
"""Display installation disclaimer."""
hostname = config.get("general", "hostname")
utils.printcolor(
"Warning:\n"
"Before you start the installation, please make sure the following "
"DNS records exist for domain '{}':\n"
" {} IN A <IP ADDRESS OF YOUR SERVER>\n"
" IN MX {}.\n".format(
args.domain,
hostname.replace(".{}".format(args.domain), ""),
hostname
),
utils.CYAN
)
utils.printcolor(
"Your mail server will be installed with the following components:",
utils.BLUE)
PRIMARY_APPS = [
"fail2ban",
"modoboa",
"automx",
"radicale",
"uwsgi",
"nginx",
"postfix",
"dovecot"
]
def upgrade_disclaimer(config):
"""Display upgrade disclaimer."""
utils.printcolor(
"Your mail server is about to be upgraded and the following components"
" will be impacted:", utils.BLUE
)
def backup_system(config, args):
"""Launch backup procedure."""
disclaimers.backup_disclaimer()
backup_path = None
if args.silent_backup:
if not args.backup_path:
if config.has_option("backup", "default_path"):
path = config.get("backup", "default_path")
else:
path = constants.DEFAULT_BACKUP_DIRECTORY
date = datetime.datetime.now().strftime("%m_%d_%Y_%H_%M")
path = os.path.join(path, f"backup_{date}")
else:
path = args.backup_path
backup_path = utils.validate_backup_path(path, args.silent_backup)
if not backup_path:
utils.printcolor(f"Path provided: {path}", utils.BLUE)
return
else:
user_value = None
while not user_value or not backup_path:
utils.printcolor(
"Enter backup path (it must be an empty directory)",
utils.MAGENTA
)
utils.printcolor("CTRL+C to cancel", utils.MAGENTA)
user_value = utils.user_input("-> ")
if not user_value:
continue
backup_path = utils.validate_backup_path(user_value, args.silent_backup)
# Backup configuration file
utils.copy_file(args.configfile, backup_path)
# Backup applications
for app in PRIMARY_APPS:
if app == "dovecot" and args.no_mail:
utils.printcolor("Skipping mail backup", utils.BLUE)
continue
scripts.backup(app, config, backup_path)
def main(input_args):
"""Install process."""
def config_file_update_complete(backup_location):
utils.printcolor("Update complete. It seems successful.",
utils.BLUE)
if backup_location is not None:
utils.printcolor("You will find your old config file "
f"here: {backup_location}",
utils.BLUE)
def parser_setup(input_args):
parser = argparse.ArgumentParser()
versions = (
["latest"] + list(compatibility_matrix.COMPATIBILITY_MATRIX.keys())
)
)
parser.add_argument("--debug", action="store_true", default=False,
help="Enable debug output")
parser.add_argument("--force", action="store_true", default=False,
@@ -72,16 +107,85 @@ def main(input_args):
parser.add_argument(
"--beta", action="store_true", default=False,
help="Install latest beta release of Modoboa instead of the stable one")
parser.add_argument(
"--backup-path", type=str, metavar="path",
help="To use with --silent-backup, you must provide a valid path")
parser.add_argument(
"--backup", action="store_true", default=False,
help="Backing up interactively previously installed instance"
)
parser.add_argument(
"--silent-backup", action="store_true", default=False,
help="For script usage, do not require user interaction "
"backup will be saved at ./modoboa_backup/Backup_M_Y_d_H_M "
"if --backup-path is not provided")
parser.add_argument(
"--no-mail", action="store_true", default=False,
help="Disable mail backup (save space)")
parser.add_argument(
"--restore", type=str, metavar="path",
help="Restore a previously backup up modoboa instance on a NEW machine. "
"You MUST provide backup directory"
)
parser.add_argument(
"--skip-checks", action="store_true", default=False,
help="Skip the checks the installer performs initially")
parser.add_argument("domain", type=str,
help="The main domain of your future mail server")
args = parser.parse_args(input_args)
return parser.parse_args(input_args)
def main(input_args):
"""Install process."""
args = parser_setup(input_args)
if args.debug:
utils.ENV["debug"] = True
utils.printcolor("Welcome to Modoboa installer!\n", utils.GREEN)
utils.check_config_file(args.configfile, args.interactive, args.upgrade)
# Restore prep
is_restoring = False
if args.restore is not None:
is_restoring = True
args.configfile = os.path.join(args.restore, args.configfile)
if not os.path.exists(args.configfile):
utils.error(
"Installer configuration file not found in backup!"
)
sys.exit(1)
utils.success("Welcome to Modoboa installer!\n")
# Checks
if not args.skip_checks:
utils.printcolor("Checking the installer...", utils.BLUE)
checks.handle()
utils.success("Checks complete\n")
is_config_file_available, outdate_config = utils.check_config_file(
args.configfile, args.interactive, args.upgrade, args.backup, is_restoring)
if not is_config_file_available and (
args.upgrade or args.backup or args.silent_backup):
utils.error("No config file found.")
return
# Check if config is outdated and ask user if it needs to be updated
if is_config_file_available and outdate_config:
answer = utils.user_input("It seems that your config file is outdated. "
"Would you like to update it? (Y/n) ")
if not answer or answer.lower().startswith("y"):
config_file_update_complete(utils.update_config(args.configfile))
if not args.stop_after_configfile_check:
answer = utils.user_input("Would you like to stop to review the updated config? (Y/n)")
if not answer or answer.lower().startswith("y"):
return
else:
utils.error("You might encounter unexpected errors ! "
"Make sure to update your config before opening an issue!")
if args.stop_after_configfile_check:
return
config = configparser.ConfigParser()
with open(args.configfile) as fp:
config.read_file(fp)
@@ -91,20 +195,37 @@ def main(input_args):
config.set("dovecot", "domain", args.domain)
config.set("modoboa", "version", args.version)
config.set("modoboa", "install_beta", str(args.beta))
# Display disclaimerpython 3 linux distribution
if not args.upgrade:
installation_disclaimer(args, config)
if config.get("antispam", "type") == "amavis":
antispam_apps = ["amavis", "opendkim"]
else:
upgrade_disclaimer(config)
antispam_apps = ["rspamd"]
if args.backup or args.silent_backup:
backup_system(config, args)
return
# Display disclaimer python 3 linux distribution
if args.upgrade:
disclaimers.upgrade_disclaimer(config)
elif args.restore:
disclaimers.restore_disclaimer()
scripts.restore_prep(args.restore)
else:
disclaimers.installation_disclaimer(args, config)
# Show concerned components
components = []
for section in config.sections():
if section in ["general", "database", "mysql", "postgres",
"certificate", "letsencrypt"]:
if section in ["general", "antispam", "database", "mysql", "postgres",
"certificate", "letsencrypt", "backup"]:
continue
if (config.has_option(section, "enabled") and
not config.getboolean(section, "enabled")):
continue
incompatible_app_detected = not utils.check_app_compatibility(section, config)
if incompatible_app_detected:
sys.exit(0)
components.append(section)
utils.printcolor(" ".join(components), utils.YELLOW)
if not args.force:
@@ -115,27 +236,46 @@ def main(input_args):
utils.printcolor(
"The process can be long, feel free to take a coffee "
"and come back later ;)", utils.BLUE)
utils.printcolor("Starting...", utils.GREEN)
utils.success("Starting...")
package.backend.prepare_system()
package.backend.install_many(["sudo", "wget"])
ssl_backend = ssl.get_backend(config)
if ssl_backend and not args.upgrade:
ssl_backend.generate_cert()
scripts.install("amavis", config, args.upgrade)
scripts.install("modoboa", config, args.upgrade)
scripts.install("automx", config, args.upgrade)
scripts.install("radicale", config, args.upgrade)
scripts.install("uwsgi", config, args.upgrade)
scripts.install("nginx", config, args.upgrade)
scripts.install("opendkim", config, args.upgrade)
scripts.install("postfix", config, args.upgrade)
scripts.install("dovecot", config, args.upgrade)
for appname in PRIMARY_APPS + antispam_apps:
scripts.install(appname, config, args.upgrade, args.restore)
system.restart_service("cron")
package.backend.restore_system()
hostname = config.get("general", "hostname")
if not args.restore:
utils.success(
f"Congratulations! You can enjoy Modoboa at https://{hostname} "
"(admin:password)"
)
else:
utils.success(
f"Restore complete! You can enjoy Modoboa at https://{hostname} "
"(same credentials as before)"
)
if config.getboolean("rspamd", "enabled"):
rspamd_password = config.get("rspamd", "password")
utils.success(
f"You can also enjoy rspamd at https://{hostname}/rspamd "
f"(password: {rspamd_password})"
)
utils.success(
"\n"
"Modoboa is a free software maintained by volunteers.\n"
"You like the project and want it to be sustainable?\n"
"Then don't wait anymore and go sponsor it here:\n"
)
utils.printcolor(
"Congratulations! You can enjoy Modoboa at https://{} (admin:password)"
.format(config.get("general", "hostname")),
utils.GREEN)
"https://github.com/sponsors/modoboa\n",
utils.YELLOW
)
utils.success(
"Thank you for your help :-)\n"
)
if __name__ == "__main__":

View File

@@ -1,3 +1,2 @@
codecov
mock
six

View File

@@ -6,8 +6,13 @@ import sys
import tempfile
import unittest
from six import StringIO
from six.moves import configparser
from io import StringIO
from pathlib import Path
try:
import configparser
except ImportError:
import ConfigParser as configparser
try:
from unittest.mock import patch
except ImportError:
@@ -42,7 +47,7 @@ class ConfigFileTestCase(unittest.TestCase):
def test_interactive_mode(self, mock_user_input):
"""Check interactive mode."""
mock_user_input.side_effect = [
"0", "0", "", "", "", "", ""
"0", "0", "", "", "", "", "", ""
]
with open(os.devnull, "w") as fp:
sys.stdout = fp
@@ -57,11 +62,44 @@ class ConfigFileTestCase(unittest.TestCase):
self.assertEqual(config.get("certificate", "type"), "self-signed")
self.assertEqual(config.get("database", "engine"), "postgres")
@patch("modoboa_installer.utils.user_input")
def test_updating_configfile(self, mock_user_input):
"""Check configfile update mechanism."""
cfgfile_temp = os.path.join(self.workdir, "installer_old.cfg")
out = StringIO()
sys.stdout = out
run.main([
"--stop-after-configfile-check",
"--configfile", cfgfile_temp,
"example.test"])
self.assertTrue(os.path.exists(cfgfile_temp))
# Adding a dummy section
with open(cfgfile_temp, "a") as fp:
fp.write(
"""
[dummy]
weird_old_option = "hey
""")
mock_user_input.side_effect = ["y"]
out = StringIO()
sys.stdout = out
run.main([
"--stop-after-configfile-check",
"--configfile", cfgfile_temp,
"example.test"])
self.assertIn("dummy", out.getvalue())
self.assertTrue(Path(self.workdir).glob("*.old"))
self.assertIn("Update complete",
out.getvalue()
)
@patch("modoboa_installer.utils.user_input")
def test_interactive_mode_letsencrypt(self, mock_user_input):
"""Check interactive mode."""
mock_user_input.side_effect = [
"1", "admin@example.test", "0", "", "", "", "", ""
"0", "0", "1", "admin@example.test", "0", "", "", "", ""
]
with open(os.devnull, "w") as fp:
sys.stdout = fp
@@ -88,8 +126,12 @@ class ConfigFileTestCase(unittest.TestCase):
"example.test"])
self.assertTrue(os.path.exists(self.cfgfile))
self.assertIn(
"modoboa automx amavis clamav dovecot nginx razor postfix"
" postwhite spamassassin uwsgi",
"fail2ban modoboa automx amavis clamav dovecot nginx "
"postfix postwhite spamassassin uwsgi radicale opendkim",
out.getvalue()
)
self.assertNotIn(
"It seems that your config file is outdated.",
out.getvalue()
)

1
version.txt Normal file
View File

@@ -0,0 +1 @@
53669b48de7ce85341a547ed2583380fcb06841b