1
0
mirror of https://github.com/mail-in-a-box/mailinabox.git synced 2025-07-09 00:00:54 +00:00

Compare commits

..

258 Commits
v60.1 ... main

Author SHA1 Message Date
Joshua Tauberer
fbf95271f4 Add --allow-releaseinfo-change to the first main apt-get update because ppa:ondrej/php changed its Label
I got:

```
Updating system packages...

FAILED: apt-get update
-----------------------------------------
Hit:1 http://archive.ubuntu.com/ubuntu jammy InRelease
Hit:2 http://archive.ubuntu.com/ubuntu jammy-updates InRelease
Hit:3 http://archive.ubuntu.com/ubuntu jammy-backports InRelease
Hit:4 http://security.ubuntu.com/ubuntu jammy-security InRelease
Hit:5 https://ppa.launchpadcontent.net/duplicity-team/duplicity-release-git/ubuntu jammy InRelease
Get:6 https://ppa.launchpadcontent.net/ondrej/php/ubuntu jammy InRelease [24.6 kB]
Reading package lists...
E: Repository 'https://ppa.launchpadcontent.net/ondrej/php/ubuntu jammy InRelease' changed its 'Label' value from '***** The main PPA for supported PHP versions with many PECL extensions *****' to 'PPA for PHP'
```
2025-07-08 14:15:29 -04:00
Joshua Tauberer
49d183afbb Use utils.shell("check_call", ...) rather than subprocess.call directly 2025-07-08 14:15:29 -04:00
Joshua Tauberer
061e74b623 Add disabled code to log failed commands to stderr 2025-07-08 14:15:29 -04:00
Joshua Tauberer
3bfd4be982 Add management/dns_update.py --update as an alternative to tools/dns_update.py that runs without the backend 2025-07-08 14:15:29 -04:00
KiekerJan
e3ee800359
fix SOA record check against secondary dns (#2507) 2025-07-08 14:12:09 -04:00
KiekerJan
aee653a7d9
Remove ssl stapling from nginx configuration (#2520) 2025-07-02 17:21:55 -04:00
KiekerJan
dc79ad5bd9
Add check on backup to status checks (#2508) 2025-06-20 06:43:40 -04:00
MrWinux
ae8da06571
Add Configuration to Handle AWS SDK Checksum Changes for Third-Party S3-Compatible Services (#2490)
fix: MissingContentLength error in boto3 version 1.36.1 and up
2025-06-20 06:38:54 -04:00
Teal Dulcet
b86c5a10d5
Updated autoconfig file to include POP3 and CardDAV/CalDAV (#2499)
Co-authored-by: Ben Bucksch <1907525+benbucksch@users.noreply.github.com>
2025-06-20 06:37:08 -04:00
Jeff Tickle
bb4c45b0bf
Remove extraneous use of sudo from /etc/cron.d/mailinabox-nextcloud (#2500) 2025-06-20 06:34:46 -04:00
Joshua Tauberer
b9ce7cb65c
Merge pull request #2473 from tdulcet/python-linting
Added config file for the Ruff Python linter and fixed additional errors
2025-06-20 06:33:24 -04:00
Teal Dulcet
00280123ab Fixed RUF005 (collection-literal-concatenation): Consider iterable unpacking instead of concatenation 2025-06-20 02:40:09 -07:00
Teal Dulcet
a568c6ff74 Fixed RET505 (superfluous-else-return): Unnecessary elif after return statement 2025-06-20 02:40:08 -07:00
Teal Dulcet
d15170b18c Fixed RUF010 (explicit-f-string-type-conversion): Use explicit conversion flag 2025-06-20 02:40:08 -07:00
Teal Dulcet
bf27ac07ed Fixed EM102 (f-string-in-exception): Exception must not use an f-string literal, assign to variable first 2025-06-20 02:40:07 -07:00
Teal Dulcet
54750b1763 Fixed RET506 (superfluous-else-raise): Unnecessary elif after raise statement 2025-06-20 02:40:07 -07:00
Teal Dulcet
5c9c1705d0 Fixed RET504 (unnecessary-assign): Unnecessary assignment to v before return statement 2025-06-20 02:40:06 -07:00
Teal Dulcet
529c7e6dd5 Fixed RUF055 (unnecessary-regular-expression): Plain string pattern passed to re function 2025-06-20 02:40:06 -07:00
Teal Dulcet
ed1579a5c6 Fixed Q003 (avoidable-escaped-quote): Change outer quotes to avoid escaping inner quotes 2025-06-20 02:40:06 -07:00
Teal Dulcet
8aef7aef64 Fixed W605 (invalid-escape-sequence) 2025-06-20 02:40:05 -07:00
Teal Dulcet
560677085e Fixed F841 (unused-variable): Local variable conffile is assigned to but never used 2025-06-20 02:40:04 -07:00
Teal Dulcet
89e4adcfb5 Fixed PLR6201 (literal-membership): Use a set literal when testing for membership 2025-06-20 02:40:04 -07:00
Teal Dulcet
5c30299461 Fixed FURB122 (for-loop-writes): Use of f.write in a for loop 2025-06-20 02:40:03 -07:00
Teal Dulcet
b546ccd162 Fixed FURB110 (if-exp-instead-of-or-operator): Replace ternary if expression with or operator 2025-06-20 02:40:03 -07:00
Teal Dulcet
562f76e61f Fixed UP032 (f-string): Use f-string instead of format call 2025-06-20 02:40:03 -07:00
Teal Dulcet
04ed752948 Fixed FURB142 (for-loop-set-mutations): Use of set.add() in a for loop 2025-06-20 02:40:02 -07:00
Teal Dulcet
c3826e45aa Fixed SIM101 (duplicate-isinstance-call): Multiple isinstance calls for pem, merge into a single call 2025-06-20 02:40:02 -07:00
Teal Dulcet
fd2696a42c Fixed RUF059 (unused-unpacked-variable) 2025-06-20 02:40:01 -07:00
Teal Dulcet
213e449dfe Fixed FURB129 (readlines-in-for): Instead of calling readlines(), iterate over file object directly 2025-06-20 02:40:01 -07:00
Teal Dulcet
ee11f3849b Fixed UP015 (redundant-open-modes): Unnecessary mode argument 2025-06-20 02:40:00 -07:00
Teal Dulcet
498e92dc95 Fixed PLW1514 (unspecified-encoding): open in text mode without explicit encoding argument 2025-06-20 02:40:00 -07:00
Teal Dulcet
66f140a8cf Fixed PGH004 (blanket-noqa): Use a colon when specifying noqa rule codes 2025-06-20 02:40:00 -07:00
Teal Dulcet
717e806427 Fixed RUF031 (incorrectly-parenthesized-tuple-in-subscript): Avoid parentheses for tuples in subscripts 2025-06-20 02:39:59 -07:00
Teal Dulcet
eae0db9df1 Fixed RUF039 (unraw-re-pattern) 2025-06-20 02:39:59 -07:00
Teal Dulcet
e73771be5f Fixed RET507 (superfluous-else-continue): Unnecessary elif after continue statement 2025-06-20 02:39:58 -07:00
Teal Dulcet
0635e89b6e Fixed F401 (unused-import): contextlib imported but unused 2025-06-20 02:39:58 -07:00
Teal Dulcet
e3ef6d726b Fixed SIM103 (needless-bool): Return the condition "admin" in privs directly 2025-06-20 02:39:58 -07:00
Teal Dulcet
3fa0819e04 Fixed G004 (logging-f-string): Logging statement uses f-string 2025-06-20 02:39:57 -07:00
Teal Dulcet
d5d4ba0bf1 Fixed RUF051 (if-key-in-dict-del): Use pop instead of key in dict followed by del dict[key] 2025-06-20 02:39:57 -07:00
Teal Dulcet
a83db1aebc Fixed FURB188 (slice-to-remove-prefix-or-suffix): Prefer str.removeprefix() over conditionally replacing with slice. 2025-06-18 05:00:46 -07:00
Teal Dulcet
ddee3c6bfd Fixed PLR6104 (non-augmented-assignment): Use += to perform an augmented assignment directly 2025-06-18 05:00:45 -07:00
Teal Dulcet
dbabd69218 Fixed RET505 (superfluous-else-return) 2025-06-18 05:00:45 -07:00
Teal Dulcet
3008dfa28f Fixed EM101 (raw-string-in-exception): Exception must not use a string literal, assign to variable first 2025-06-18 05:00:45 -07:00
Teal Dulcet
3a1280d292 Fixed PLW0120 (useless-else-on-loop): else clause on loop without a break statement; remove the else and dedent its contents 2025-06-18 05:00:22 -07:00
Teal Dulcet
68fd3dc535 Fixed FURB118 (reimplemented-operator) 2025-06-18 04:17:27 -07:00
Teal Dulcet
c64a24e870 Fixed UP031 (printf-string-formatting): Use format specifiers instead of percent format 2025-06-18 04:17:03 -07:00
Teal Dulcet
698e8ffc72 Added Ruff config for Python code. 2025-06-18 04:13:33 -07:00
Teal Dulcet
544cce3cdc Fixed syntax in readable_bash.py. 2025-06-18 04:13:33 -07:00
Fabrício Dultra
40d3f0f193
Fix broken Z-Push compatibility list link in mail guide (#2515)
The previous link (wiki.z-hub.io) was no longer accessible. It has been replaced with the current official GitHub link: https://github.com/Z-Hub/Z-Push/wiki/Compatibility.

Co-authored-by: fsdultra <eu@fsdultra.com.br>
2025-06-17 06:37:17 -04:00
Joshua Tauberer
4d5421ed7b Merge release branch for v72 2025-06-03 20:42:45 -04:00
Joshua Tauberer
58dca6e4ab v72 2025-06-03 20:41:46 -04:00
KiekerJan
1a8a50e4ae Update roundcube to 1.6.11
Merges #2511.
2025-06-03 20:41:38 -04:00
Lyle Keeton
05c2f3c9a2
Fix missing PUBLIC_IPV6 in test for whether custom AAAA record blocks SSL certificate generation (#2491)
On new installation, if you create AAAA record for mydomain.tld then mydomain.tld isn't available for SSL certificate or website.

It erroneously reports that it's hosted elsewhere.
2025-05-14 11:58:17 -04:00
MVDW
3efd4257b5
Change distro version check from lsb_release to os-release (#2436) 2025-02-17 16:50:15 -05:00
Victor
a81c18666f
Clear credentials and reset menu after receiving 403 (#2477) 2025-02-16 17:01:51 -05:00
Michael Meidlinger
01996141ad
Allow boto to get S3 credentials for backups from environment variables if access key is blank (#2260)
In case that no static AWS credentials are specified, we try to create the boto3 client without explicitly passing static credentials. This way, we can benedit from dynamic credentials in AWS environments (e.g. using EC2 instance roles)
2025-02-16 16:51:48 -05:00
Joshua Tauberer
c0103045be
Add configurable mailbox quotas (#2387) 2025-02-16 15:18:32 -05:00
Tomasz Stanczak
41cbf0ba8e
Handle no existence of expired certificates before trying to move them into ssl.expired subdirectory (#2480)
Shell option 'nullglob' to prevent the following 'for' loop from being entered even when no matching files are present.
2025-02-15 14:31:58 -05:00
KiekerJan
5ef85f3d02
Update roundcube to 1.6.10 (#2483) 2025-02-15 14:29:15 -05:00
Joshua Tauberer
e6c354c312 v71a 2025-01-06 07:08:06 -05:00
Paul
432b470d29
New & improved Disable MOTD advertisements (#2470)
Checks if /etc/default/motd-news exists before running commands.
2025-01-06 07:06:01 -05:00
Joshua Tauberer
d58dd0c91d v71 2025-01-04 14:39:25 -05:00
Joshua Tauberer
f73da3db60 Fix likely merge mistake in 564ed59bb4
Fixes #2466
2025-01-04 14:28:36 -05:00
Chad Furman
626bced707 % is a special character 2024-12-28 17:10:50 -05:00
Chad Furman
7f9a348d64 removing 'quota' from user output 2024-12-28 17:10:50 -05:00
Chad Furman
ac383ced4d cli.py user now prints '0' rather than 'unlimited' for quota 2024-12-28 17:10:50 -05:00
Chad Furman
450c1924d8 cli script fixes were broken 2024-12-28 17:10:50 -05:00
Chad Furman
c9d37be530 Revert "fixing cli commands"
This reverts commit a4a08980f84360abcd009de9dc7ef8c6fcb529c4.
2024-12-28 17:10:50 -05:00
Chad Furman
08e69ca459 fixed missing column heading 2024-12-28 17:10:50 -05:00
Chad Furman
bd5ba78a99 removing box count / message count feature 2024-12-28 17:10:50 -05:00
Chad Furman
654f5614af removing the ability to configure the default quota -- default quota is always unlimited. 2024-12-28 17:10:50 -05:00
Chad Furman
8bb68d60a5 fixing cli commands 2024-12-28 17:10:50 -05:00
Chad Furman
27c510319f using migrations for alter table command 2024-12-28 17:10:50 -05:00
Chad Furman
67c502e97b removing duplicate conf 2024-12-28 17:10:50 -05:00
Chad Furman
55bb35e3ef fixing imap sed script 2024-12-28 17:10:50 -05:00
Chad Furman
4259033121 fixing parens 2024-12-28 17:10:50 -05:00
Chad Furman
b4170e4095 fixing imap sed script 2024-12-28 17:10:50 -05:00
Chad Furman
d8ab444d59 fixing subprocess import 2024-12-28 17:10:50 -05:00
Chad Furman
ce45217ab8 bringing in quota changes 2024-12-28 17:10:49 -05:00
yeah
18721e42d1
Cronjob for cleaning up expired SSL certificates in order to improve page load times with many domains (#2410)
Fixes #2316.
2024-12-22 08:07:04 -05:00
yeah
e0b93718a3
Revert "increase timeout for the nginx proxy that provides access to the Mail…" (#2411)
Reverts #2407 - as per #2316

This reverts commit 2803d88894.
2024-12-22 08:02:49 -05:00
KiekerJan
2e0482e181
Exclude the owncloud-backup folder from the nightly backup (#2413) 2024-12-22 08:01:02 -05:00
Tomasz Stanczak
0d7388899c
Allow DSA end EllipticCurve private keys to be used additionally to RSA for HTTPS certificates (#2416)
Co-authored-by: Tomasz Stanczak <tomasz@cocoturtle.com>
2024-12-22 07:59:58 -05:00
zoof
4f094f7859
Change hour of daily tasks to run at 1am and only run full backups on weekends (#2424)
* Change hour of daily tasks to run at 1am
* Change to only do full backup on weekends
2024-12-22 07:57:59 -05:00
KiekerJan
564ed59bb4
Add check on ipv6 for spamhaus (#2428) 2024-12-22 07:48:36 -05:00
KiekerJan
9f87b36ba1
add check on SOA record to determine up to date synchronization of secondary nameserver (#2429) 2024-12-22 07:45:45 -05:00
matidau
e36c17fc72
Fixstates only after Z-Push upgrade (#2432) 2024-12-22 07:42:56 -05:00
KiekerJan
3d59f2d7e0
Update roundcube to 1.6.9 (#2440) 2024-12-22 07:28:39 -05:00
Harm Berntsen
ee0d750b85
Add missing php-xml package for Roundcube without Nextcloud (#2441)
When the Nextcloud installation is skipped, php8.0-xml will also not be installed. This causes issues for Roundcube because it won't load: `PHP Fatal error:  Uncaught Error: Class "DOMDocument" not found in /usr/local/lib/roundcubemail/program/lib/Roundcube/html.php:367`. Installing the package on the Roundcube side as well fixes it for me.
2024-12-22 07:28:04 -05:00
Paul
d8563be38b
Disable MOTD advertisements (#2457)
Disables MOTD advertisements which use a script to send server information in `wget` headers to Canonical.
2024-12-22 07:27:36 -05:00
Nicholas Wilson
81b0e0a64f
Updated CHANGELOG.md, fix typo(s) (#2459) 2024-12-22 07:26:59 -05:00
matidau
7ef859ce96
Update zpush.sh to version 2.7.5 (#2463) 2024-12-13 09:28:45 -05:00
Downtown Allday
a8d13b84b4
fix: NameError: name 'subprocess' is not defined (#2425) 2024-11-27 08:22:45 -05:00
matidau
1699ab8c02
Update zpush.sh to version 2.7.4 (#2423) 2024-09-17 14:51:26 -04:00
Downtown Allday
ca123515aa
fix variable (#2439) 2024-09-02 21:30:01 -04:00
matidau
3b8f4a2fe8
Z-Push remove config lines no longer supported (#2433) 2024-08-30 14:27:44 -04:00
darren
f453c44d52
Update setup to handle multiple SSH ports (#2437)
This PR addresses an issue reported in the mailinabox
Slack channel where a system had sshd configured to listen
on two ports.

Co-authored-by: Darren Sanders <darren@dms00.com>
2024-08-30 14:26:05 -04:00
Joshua Tauberer
41870d22b0 v70 2024-08-15 08:53:27 -04:00
matidau
b9c5cd248f
Update Roundcube to 1.6.8 (#2422) 2024-08-15 08:49:52 -04:00
Joshua Tauberer
162e509b8b v69b 2024-07-23 06:20:34 -04:00
Joshua Tauberer
60a2b58e57 Revert "Fixed SC2091: Remove surrounding $() to avoid executing output." and fix it another way
This reverts commit 67bcaea71e. The sub-shell was required to prevent the updated umask from affecting later steps. It broke the permissions of the fetched assets for the control panel: https://discourse.mailinabox.email/t/admin-panel-broken-after-restore-upgrade/12112/24

Instead, the `$()` is replaced with just `()` to create a subshell without executing its output.
2024-07-23 06:16:24 -04:00
Joshua Tauberer
2ae8cd5713 v69a 2024-07-21 08:02:38 -04:00
Teal Dulcet
bc14e80b12
Fix no password prompt. Fixes #2408 (#2409) 2024-07-21 08:00:20 -04:00
Viktor Szépe
cd959bc522
Fix typos (#2406) 2024-07-21 07:01:25 -04:00
KiekerJan
2803d88894
increase timeout for the nginx proxy that provides access to the Mailinabox management daemon (#2407) 2024-07-20 16:44:24 -04:00
Joshua Tauberer
1b3e5e818c v69 2024-07-20 07:33:07 -04:00
Joshua Tauberer
2f5e736fa0 Clean up Nextcloud email settings for calendar invitations
I don't think anything is actually changed here but I think my box was missing some of these settings.
2024-07-20 07:27:46 -04:00
Michael Heuberger
f118a6c0bf
Apply small Nextcloud upgrade to 26.0.13 (#2401) 2024-07-08 08:21:12 -04:00
jvolkenant
de0fc796d4
Fix chown during Nexcloud upgrades (#2377) 2024-06-18 08:37:01 -04:00
Matt
4dd1e75ee7
Allow for Union[None, List[datetime.datetime]] values when printing user table in weekly mail logs (#2378)
* Fix - Allow for `Union[None, List[datetime.datetime]]` when printing user
tables for the weekly mail logs.

* Add - ruff suppressions.
2024-06-18 08:35:54 -04:00
John James Jacoby
8b9f0489c8
Add custom.yaml support for WebSockets (#2385)
Fixes #1956.
2024-06-18 08:32:11 -04:00
matidau
6321ce6ef0
Add php8.0-intl package to z-push setup (#2389) 2024-06-18 08:29:51 -04:00
matidau
30d78cd35a
Update zpush.sh to version 2.7.3 (#2390) 2024-06-18 08:29:22 -04:00
Joshua Tauberer
a332be6a7b
Fixed bugs found by the ShellCheck linter (#1457) 2024-04-03 09:25:32 -04:00
Teal Dulcet
c7faccf1fa Fixed SC2244: Prefer explicit -n to check non-empty string. 2024-04-03 09:22:50 -04:00
Teal Dulcet
ec497efa69 Quote echo commands to preserve whitespace. 2024-04-03 09:22:50 -04:00
Teal Dulcet
55a8be4aa9 Removed unnecessary bc commands. 2024-04-03 09:22:50 -04:00
Teal Dulcet
3399b25084 Replaced the pwd command with Bash's $PWD variable. 2024-04-03 09:22:50 -04:00
Teal Dulcet
2afd0451c1 Fixed SC2007: Use $((..)) instead of deprecated $[..]. 2024-04-03 09:22:50 -04:00
Teal Dulcet
27cf11d8ec Fixed SC2005: Useless echo. 2024-04-03 09:22:50 -04:00
Teal Dulcet
44d9f6eebd Fixed SC2236: Use -n instead of ! -z. 2024-04-03 09:22:50 -04:00
Teal Dulcet
4b7d4ba0a6 Fixed SC2166: Prefer [ p ] && [ q ] as [ p -a q ] is not well defined. 2024-04-03 09:22:50 -04:00
Teal Dulcet
67bcaea71e Fixed SC2091: Remove surrounding $() to avoid executing output. 2024-04-03 09:22:50 -04:00
Teal Dulcet
bdf4155bed Fixed SC2046: Quote to prevent word splitting. 2024-04-03 09:21:34 -04:00
Teal Dulcet
f1888f2043 Fixed SC2148: Add a shebang. 2024-04-03 09:21:34 -04:00
Teal Dulcet
33559bb844 Fixed SC2164: Use 'cd ... || exit' in case cd fails. 2024-04-03 09:21:34 -04:00
Teal Dulcet
30c4681e80 Fixed SC2086: Double quote to prevent globbing and word splitting. 2024-04-03 09:20:20 -04:00
Teal Dulcet
133bae1300 Fixed SC2006: Use $(...) notation instead of legacy backticks .... 2024-04-03 05:17:25 -07:00
Joshua Tauberer
830c83daa1 v68 2024-04-01 10:55:52 -04:00
Joshua Tauberer
7382c18e8f CHANGELOG entries 2024-04-01 10:54:21 -04:00
Joshua Tauberer
fa72e015ee Update SMTP Smuggling protection to the 'long-term fix'
* Revert "Guard against SMTP smuggling", commit faf23f150c, by restoring the setting to its default.
* Revert "[security] SMTP smuggling: update short term fix (#2346)", commmit e931e103fe, by restoring the setting to its default.
* Set smtpd_forbid_bare_newline=normalize.
2024-03-23 13:15:32 -04:00
KiekerJan
1a239c55bb
More robust reading of sshd configuration (#2330)
Use sshd -T instead of directly reading the configuration files
2024-03-23 11:16:40 -04:00
Gio
9b450469eb
Mail guide: OS X -> macOS (#2306) 2024-03-23 09:04:43 -04:00
jvolkenant
163b1a297e
Silence "wal" output on setup using hide_output (#2368) 2024-03-23 08:49:24 -04:00
Joshua Tauberer
18b8f9ab4b Revert "Allow customizations to Roundcube settings to persist between updates by including a configuration override file, if it exists (#2333)"
This reverts commit 1b8cdeb644.

It didn't execute. I should have tried it first.
2024-03-10 08:25:34 -04:00
KiekerJan
0b1d92388a
Take spamhaus return codes into account in status check and postfix config (#2332) 2024-03-10 08:09:36 -04:00
Crag-Monkey
1b8cdeb644
Allow customizations to Roundcube settings to persist between updates by including a configuration override file, if it exists (#2333) 2024-03-10 08:02:16 -04:00
Bastian Bittorf
1053340124
setup/preflight.sh: fix some minor shellcheck complaints (#2342)
This file passes shellcheck now without errors.
This paritally fixes #1457 - the former errors where:

$ shellcheck setup/preflight.sh

In setup/preflight.sh line 1:
^-- SC2148 (error): Tips depend on target shell and yours is unknown. Add a shebang or a 'shell' directive.

In setup/preflight.sh line 29:
if [ $TOTAL_PHYSICAL_MEM -lt 490000 ]; then
     ^-----------------^ SC2086 (info): Double quote to prevent globbing and word splitting.

Did you mean:
if [ "$TOTAL_PHYSICAL_MEM" -lt 490000 ]; then

In setup/preflight.sh line 31:
	TOTAL_PHYSICAL_MEM=$(expr \( \( $TOTAL_PHYSICAL_MEM \* 1024 \) / 1000 \) / 1000)
                             ^--^ SC2003 (style): expr is antiquated. Consider rewriting this using $((..)), ${} or [[ ]].
                                        ^-----------------^ SC2086 (info): Double quote to prevent globbing and word splitting.

Did you mean:
	TOTAL_PHYSICAL_MEM=$(expr \( \( "$TOTAL_PHYSICAL_MEM" \* 1024 \) / 1000 \) / 1000)

In setup/preflight.sh line 38:
if [ $TOTAL_PHYSICAL_MEM -lt 750000 ]; then
     ^-----------------^ SC2086 (info): Double quote to prevent globbing and word splitting.

Did you mean:
if [ "$TOTAL_PHYSICAL_MEM" -lt 750000 ]; then

For more information:
  https://www.shellcheck.net/wiki/SC2148 -- Tips depend on target shell and y...
  https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ...
  https://www.shellcheck.net/wiki/SC2003 -- expr is antiquated. Consider rewr...
2024-03-10 08:01:13 -04:00
Joshua Tauberer
315d2cf691
Fixed errors found by the Ruff Python linter (#2343) 2024-03-10 07:57:19 -04:00
Teal Dulcet
dbc2b5eee0 Fixed ISC003 (explicit-string-concatenation): Explicitly concatenated string should be implicitly concatenated 2024-03-10 07:56:49 -04:00
Teal Dulcet
775a4223de Fixed F821 (undefined-name): Undefined name e 2024-03-10 07:56:49 -04:00
Teal Dulcet
618c466b84 Fixed SIM114 (if-with-same-arms): Combine if branches using logical or operator 2024-03-10 07:56:49 -04:00
Teal Dulcet
a32354fd91 Fixed PLR5501 (collapsible-else-if): Use elif instead of else then if, to reduce indentation 2024-03-10 07:56:49 -04:00
Teal Dulcet
1d79f9bb2b Fixed PERF401 (manual-list-comprehension): Use a list comprehension to create a transformed list 2024-03-10 07:56:49 -04:00
Teal Dulcet
cacf6d2006 Fixed E721 (type-comparison): Use is and is not for type comparisons, or isinstance() for isinstance checks 2024-03-10 07:56:49 -04:00
Teal Dulcet
f0377dd59e Fixed SIM105 (suppressible-exception) 2024-03-10 07:56:49 -04:00
Teal Dulcet
6a47133e3f Fixed F811 (redefined-while-unused): Redefinition of unused sys from line 10 2024-03-10 07:56:49 -04:00
Teal Dulcet
7f456d8e8b Fixed ISC002 (multi-line-implicit-string-concatenation): Implicitly concatenated string literals over multiple lines 2024-03-10 07:56:49 -04:00
Teal Dulcet
e466b9bb53 Fixed RUF005 (collection-literal-concatenation) 2024-03-10 07:56:49 -04:00
Teal Dulcet
0e9193651d Fixed PLW1514 (unspecified-encoding): open in text mode without explicit encoding argument 2024-03-10 07:56:49 -04:00
Teal Dulcet
a02b59d4e4 Fixed F401 (unused-import): socket.timeout imported but unused 2024-03-10 07:56:49 -04:00
Teal Dulcet
15bddcbc39 Fixed RUF010 (explicit-f-string-type-conversion): Use explicit conversion flag 2024-03-10 07:56:49 -04:00
Teal Dulcet
c719fce40a Fixed UP032 (f-string): Use f-string instead of format call 2024-03-10 07:56:49 -04:00
Teal Dulcet
3111cf56de Fixed EM102 (f-string-in-exception): Exception must not use an f-string literal, assign to variable first 2024-03-10 07:56:49 -04:00
Teal Dulcet
6508d47da1 Fixed C405 (unnecessary-literal-set): Unnecessary list literal (rewrite as a set literal) 2024-03-10 07:56:49 -04:00
Teal Dulcet
9b961b7ba0 Fixed UP024 (os-error-alias): Replace aliased errors with OSError 2024-03-10 07:56:49 -04:00
Teal Dulcet
b13cef9b1d Fixed PIE790 (unnecessary-placeholder): Unnecessary pass statement 2024-03-10 07:56:49 -04:00
Teal Dulcet
8b9d3ec094 Fixed W292 (missing-newline-at-end-of-file): No newline at end of file 2024-03-10 07:56:49 -04:00
Teal Dulcet
d1d3d08d70 Fixed B006 (mutable-argument-default): Do not use mutable data structures for argument defaults 2024-03-10 07:56:49 -04:00
Teal Dulcet
922c59ddaf Fixed SIM212 (if-expr-with-twisted-arms): Use with_lines if with_lines else [] instead of [] if not with_lines else with_lines 2024-03-10 07:56:49 -04:00
Teal Dulcet
20a99c0ab8 Fixed UP041 (timeout-error-alias): Replace aliased errors with TimeoutError 2024-03-10 07:56:49 -04:00
Teal Dulcet
54af4725f9 Fixed C404 (unnecessary-list-comprehension-dict): Unnecessary list comprehension (rewrite as a dict comprehension) 2024-03-10 07:56:49 -04:00
Teal Dulcet
fd4fcdaf53 Fixed E712 (true-false-comparison): Comparison to False should be cond is False or if not cond: 2024-03-10 07:56:49 -04:00
Teal Dulcet
d661d623dc Fixed RUF017 (quadratic-list-summation): Avoid quadratic list summation 2024-03-10 07:56:49 -04:00
Teal Dulcet
f621789298 Fixed SIM118 (in-dict-keys): Use key in dict instead of key in dict.keys() 2024-03-10 07:56:49 -04:00
Teal Dulcet
ec32e1d578 Fixed E703 (useless-semicolon): Statement ends with an unnecessary semicolon 2024-03-10 07:56:49 -04:00
Teal Dulcet
57dcd4bb51 Fixed E713 (not-in-test): Test for membership should be not in 2024-03-10 07:56:49 -04:00
Teal Dulcet
845393b6e0 Fixed RET503 (implicit-return): Missing explicit return at the end of function able to return non-None value 2024-03-10 07:56:49 -04:00
Teal Dulcet
c585c1ecf6 Fixed W291 (trailing-whitespace): Trailing whitespace 2024-03-10 07:56:49 -04:00
Teal Dulcet
e0e6f1081b Fixed C414 (unnecessary-double-cast-or-process): Unnecessary list call within sorted() 2024-03-10 07:56:49 -04:00
Teal Dulcet
4999ed7b1c Fixed Q003 (avoidable-escaped-quote): Change outer quotes to avoid escaping inner quotes 2024-03-10 07:54:51 -04:00
Teal Dulcet
ca8f06d590 Fixed PLR1711 (useless-return): Useless return statement at end of function 2024-03-10 07:54:51 -04:00
Teal Dulcet
57d05c1ab2 Fixed B007 (unused-loop-control-variable) 2024-03-10 07:54:51 -04:00
Teal Dulcet
c953e5784d Fixed C401 (unnecessary-generator-set): Unnecessary generator (rewrite as a set comprehension) 2024-03-10 07:54:51 -04:00
Teal Dulcet
81a4da0181 Fixed SIM110 (reimplemented-builtin) 2024-03-10 07:54:51 -04:00
Teal Dulcet
99d3929f99 Fixed E711 (none-comparison) 2024-03-10 07:54:51 -04:00
Teal Dulcet
541f31b1ba Fixed FURB113 (repeated-append) 2024-03-10 07:54:51 -04:00
Teal Dulcet
e8d1c037cb Fixed SIM102 (collapsible-if): Use a single if statement instead of nested if statements 2024-03-10 07:54:51 -04:00
Teal Dulcet
67b9d0b279 Fixed PLW0108 (unnecessary-lambda): Lambda may be unnecessary; consider inlining inner function 2024-03-10 07:54:51 -04:00
Teal Dulcet
3d72c32b1d Fixed W605 (invalid-escape-sequence) 2024-03-10 07:54:51 -04:00
Teal Dulcet
14a5613dc8 Fixed UP031 (printf-string-formatting): Use format specifiers instead of percent format 2024-03-10 07:54:51 -04:00
Teal Dulcet
64540fbb44 Fixed UP034 (extraneous-parentheses): Avoid extraneous parentheses 2024-03-10 07:54:51 -04:00
Teal Dulcet
eefc0514b2 Fixed UP030 (format-literals): Use implicit references for positional format fields 2024-03-10 07:54:51 -04:00
Teal Dulcet
fba92de051 Fixed SIM108 (if-else-block-instead-of-if-exp) 2024-03-10 07:54:51 -04:00
Teal Dulcet
51dc7615f7 Fixed RSE102 (unnecessary-paren-on-raise-exception): Unnecessary parentheses on raised exception 2024-03-10 07:54:51 -04:00
Teal Dulcet
13b38cc04d Fixed F841 (unused-variable) 2024-03-10 07:54:51 -04:00
Teal Dulcet
2b426851f9 Fixed UP032 (f-string): Use f-string instead of format call 2024-03-10 07:54:51 -04:00
Teal Dulcet
b7f70b17ac Fixed RET504 (unnecessary-assign) 2024-03-10 07:54:51 -04:00
Teal Dulcet
6bfd1e5140 Fixed W293 (blank-line-with-whitespace): Blank line contains whitespace 2024-03-10 07:54:51 -04:00
Teal Dulcet
555ecc1ebb Fixed PIE810 (multiple-starts-ends-with): Call startswith once with a tuple 2024-03-10 07:54:51 -04:00
Teal Dulcet
dd61844ced Fixed EM101 (raw-string-in-exception): Exception must not use a string literal, assign to variable first 2024-03-10 07:54:51 -04:00
Teal Dulcet
49124cc9ca Fixed PLR6201 (literal-membership): Use a set literal when testing for membership 2024-03-10 07:54:51 -04:00
Teal Dulcet
cb922ec286 Fixed UP015 (redundant-open-modes): Unnecessary open mode parameters 2024-03-10 07:54:49 -04:00
Teal Dulcet
0ee64f2fe8 Fixed F401 (unused-import) 2024-03-10 07:54:21 -04:00
KiekerJan
785c337fb3
Make reading of previous status check result more robust (#2347) 2024-03-10 07:27:04 -04:00
KiekerJan
293d56c781
Update javascript libraries used by control panel (#2351) 2024-03-10 07:26:33 -04:00
KiekerJan
040d0cbb7c
Update roundcube to 1.6.6 (#2360) 2024-03-10 07:24:29 -04:00
Michael Heuberger
111468efb9
Bump Nextcloud to v26.0.12 (#2310)
Also
- bumps calendar and contacts apps
- reformats some comments (line-breaking)
- adds extra comments for the next developer
2024-03-10 07:22:51 -04:00
John James Jacoby
4ad679da47
Issue-2354: Silence "wal" output on setup (#2356)
Silence "wal" output from RoundCube Sqlite customization, inside of webmail.sh.

Co-authored-by: solomon-s-b

Fixes #2354.
2024-03-10 07:16:03 -04:00
solomon-s-b
84919fefa4
Fix miab-munin.conf filter not capturing HTTP/2.0 (#2359) 2024-03-10 07:15:25 -04:00
KiekerJan
e931e103fe
[security] SMTP smuggling: update short term fix (#2346)
Update short term fix according to postfix advisory at https://www.postfix.org/smtp-smuggling.html.
2024-01-10 09:34:06 -05:00
Joshua Tauberer
7646095b94 v67 2023-12-22 08:56:43 -05:00
Joshua Tauberer
faf23f150c Guard against SMTP smuggling
This short-term workaround is recommended at https://www.postfix.org/smtp-smuggling.html:

    smtpd_data_restrictions=reject_unauth_pipelining
2023-12-22 08:54:15 -05:00
Joshua Tauberer
8e4e9add78 Version 66 2023-12-17 16:31:18 -05:00
KiekerJan
fa8c7ddef5
Upgrade roundcube to 1.6.5 (#2329) 2023-12-04 09:23:36 -05:00
bilogic
6d6ce25e03
Allow specifying another repo to install from in bootstrap.sh (#2334) 2023-12-04 09:22:54 -05:00
Joshua Tauberer
371f5bc1b2 Fix virtualenv creation reported in #2335 2023-11-28 07:25:50 -05:00
Joshua Tauberer
0314554207 Version 65 2023-10-27 06:02:22 -04:00
matidau
46d55f7866
Update zpush.sh to version 2.7.1 (#2315)
Updating to latest release, bugfixes no new features.
2023-10-26 09:04:13 -04:00
KiekerJan
2bbc317873
Update Roundcube to 1.6.4 (#2317) 2023-10-26 09:03:29 -04:00
clpo13
28f929dc13
Fix typo in system-backup.html: Amazone -> Amazon (#2311) 2023-10-10 13:22:19 -04:00
Joshua Tauberer
e419b62034 Version 64 2023-09-02 19:46:24 -04:00
Joshua Tauberer
a966913963
Fix command line arguments for duplicity 2.1 (#2301) 2023-09-02 15:54:16 -04:00
Joshua Tauberer
08defb12be Add a new backup.py command to print the duplicity command to the console to help debugging 2023-09-02 07:49:41 -04:00
Jeff Volkenant
7be687e601 Move source and target positional arguments to the end, required for Duplicity 2.1.0
(Modified by JT.)
2023-09-02 07:28:48 -04:00
Aaron Ten Clay
62efe985f1
Disable OpenDMARC sending reports (#2299)
OpenDMARC report messages, while potentially useful for peer operators of mail servers, are abusable and should not be enabled by default. This change prioritizes the safety of the Box's reputation.
2023-09-02 07:10:04 -04:00
Alex
df44056bae
Fix checksums in nextcloud.sh (#2293) 2023-09-02 07:07:12 -04:00
Dmytro Kyrychuk
3148c621d2
Fix issue with slash (/) characters in B2 Application Key (#2281)
Urlencode B2 Application Key when saving configuration, urldecode it
back when reading. Duplicity accepts urlencoded target directly, no
decoding is necessary when backup is performed.

Resolve #1964
2023-09-02 07:03:24 -04:00
Michael Heuberger
81866de229
Amend --always option to all git describe commands (#2275) 2023-09-02 06:59:39 -04:00
matidau
674ce92e92
Fix z-push-admin broken in v60 (#2263)
Update zpush.sh to create two sbin bash scripts for z-push-admin and z-push-top using PHP_VER.
2023-09-02 06:55:15 -04:00
Darren Sanders
c034b0f789 Fix how the value is being passed for the gpg-options parameter
Duplicity v2.1.0 backups are failing with the error:
"... --gpg-options expected one argument".

The issue is that duplicity v2.1.0 began using the argparse Python
library and the parse_known_args function. This function
interprets the argument being passed, "--cipher-algo=AES256",
as an argument name (because of the leading '-') and not as an
argument value. Because of that it exits with an error and
reports that the --gpg-options arg is missing its value.

Adding an extra set of quotes around this string causes
parse_known_args to interpret the string as an argument
value.
2023-08-30 16:34:17 -07:00
Joshua Tauberer
cd45d08409 Version 63 2023-07-29 12:11:29 -04:00
Michael Heuberger
98628622c7
Bump Nextcloud to v25.0.7 (#2268)
Also
- bumps calendar and contacts apps
- adds extra migration steps between these versions
- adds cron job for Calendar updates
- rotates nextloud log file after upgrading
- adds primary key indices migrations
- adjusts configs slightly
- adds more well-known entries in nginx to improve service discovery
- reformats some comments (line-breaking)
2023-06-16 11:49:55 -04:00
Joshua Tauberer
8b19d15735 Version 62 2023-05-20 08:57:32 -04:00
matidau
93380b243f
Update zpush.sh to version 2.7.0 (#2236) 2023-05-13 10:27:42 -04:00
Joshua Tauberer
fb0a3b0489
Restore Roundcube's password reset tool by removing PRAGMA journal_mode = WAL from Roundcube source (#2199) 2023-05-13 10:26:41 -04:00
Joshua Tauberer
3bc9d07aeb Roundcube 1.6.1 2023-05-13 07:00:54 -04:00
Joshua Tauberer
51ed030917 Allow setting the S3 region name in backup settings to pass to duplicity
It's stuffed inside the username portion of the target URL. We already mangle the target before passing it to duplicity so there wasn't a need for a new field.

Fixes the issue raised in #2200, #2216.
2023-05-13 07:00:29 -04:00
Joshua Tauberer
e828d63a85 Allow secondary DNS xfr: items to be hostnames that are resolved to IP addresses when generating the nsd configuration 2023-05-13 07:00:10 -04:00
Joshua Tauberer
0ee0784bde Changelog entries 2023-05-13 06:59:49 -04:00
Peter Tóth
6d43d24552
Improve control panel panel switching behaviour by using the URL fragment (#2252) 2023-05-13 06:49:34 -04:00
Peter Tóth
963fb9f2e6
email_administrator.py: fix report formatting (#2249) 2023-05-13 06:40:31 -04:00
KiekerJan
c9584148a0
Fix issue where sshkeygen fails when ipv6 is disabled (#2248) 2023-05-13 06:39:46 -04:00
Tomas P
9a33f9c5ff
Fix dynazoom due to change in handling su (#2247)
Seems that in Ubuntu 22.04 the behavior in su changed, making - ( alias for -l, --login ) mutually exclusive with --preserve-environment which is required for passing enviroment variables for cgi to work for dynazoom in munin.dropping - fixes the issue
2023-05-13 06:38:00 -04:00
Michael Heuberger
95530affbf
Bump Nextcloud to v23.0.12 and its apps (#2244) 2023-05-13 06:37:24 -04:00
Hugh Secker-Walker
f72be0be7c
feat(rsync-backup-ui): Add a Copy button to put public key on clipboard in rsync UI (#2227) 2023-05-13 06:36:31 -04:00
KiekerJan
8aa98b25b5 Update configuration of Roundcube password plugin for Roundcube 1.6 2023-05-13 06:22:28 -04:00
KiekerJan
3c15081673 Remove journal PRAGMA from Roundcube source which broke the database for postfix
See #2185.
2023-05-13 06:20:13 -04:00
Joshua Tauberer
01d8e9f3b4 Revert "Disable Roundcube password plugin since it was corrupting the user database (#2198)"
This reverts commit 1587248762.

See subsequent commits.
2023-05-13 06:20:13 -04:00
Adam Elaoumari
88260bb610
Fixed year in changelog (#2241)
Fixed year of version 61.1 (2022 -> 2023)
2023-03-08 10:29:02 -05:00
Joshua Tauberer
6f94412204 v61.1 2023-01-28 11:25:21 -05:00
Joshua Tauberer
c77d1697a7 Revert "Improve error messages in the management tools when external command-line tools are run"
Command line arguments have user secrets in some cases which should not be included in error messages.

This reverts commit 26709a3c1d.

Reported by AK.
2023-01-28 11:24:38 -05:00
Hugh Secker-Walker
31bbef3401
chore(setup): Make sed fingerprint patterns in start.sh be case insensitive (#2201) 2023-01-28 11:12:40 -05:00
Hugh Secker-Walker
7af713592a
feat(status page): Add summary of ok/error/warning counts (#2204)
* feat(status page): Add summary of ok/error/warning counts

* simplify a bit

---------

Co-authored-by: Hugh Secker-Walker <hsw+miac@hodain.net>
Co-authored-by: Joshua Tauberer <jt@occams.info>
2023-01-28 11:11:17 -05:00
Hugh Secker-Walker
4408cb1fba
fix(rsync-backup): Provide default port 22 for rsync usage in backup.py (#2226)
Co-authored-by: Hugh Secker-Walker <hsw+miac@hodain.net>
2023-01-28 11:04:46 -05:00
Joshua Tauberer
5e3e4a2161 v61 2023-01-21 08:20:48 -05:00
Joshua Tauberer
61d1ea1ea7 Changelog entries 2023-01-15 10:17:10 -05:00
Joshua Tauberer
b3743a31e9 Add a status checks check that fail2ban is running using fail2ban-client 2023-01-15 10:17:10 -05:00
Joshua Tauberer
26709a3c1d Improve error messages in the management tools when external command-line tools are run 2023-01-15 10:17:10 -05:00
jcm-shove-it
20ec6c2080
Updated security.md to reflect the support of ubuntu 22.04 (#2219) 2023-01-15 10:05:36 -05:00
Steven Conaway
7a79153afe
Remove old darkmode background color (#2218)
Removing this old background color solves the problem of the bottom of short pages (like `/admin`'s login page) being white. The background was being set to black, which would be inverted, so it'd appear white. Since the `filter:` css has [~97% support](https://caniuse.com/?search=filter), I think that this change should be made. Tested on latest versions of Chrome (mac and iOS), Firefox, and Safari (mac and iOS).
2023-01-15 10:05:13 -05:00
Hugh Secker-Walker
a2565227f2
feat(rsync-port): Add support for non-standard ssh port for rsync backup (#2208) 2023-01-15 10:03:05 -05:00
Hugh Secker-Walker
02b34ce699
fix(backup-display): Fix parsing of rsync target in system-backup.html, fixes #2206 (#2207) 2023-01-15 10:01:07 -05:00
Hugh Secker-Walker
820a39b865
chore(python open): Refactor open and gzip.open to use context manager (#2203)
Co-authored-by: Hugh Secker-Walker <hsw+miac@hodain.net>
2023-01-15 08:28:43 -05:00
Hugh Secker-Walker
57047d96e9
chore(setup): Update obsolete chown group syntax (#2202)
Co-authored-by: Hugh Secker-Walker <hsw+miac@hodain.net>
2023-01-15 08:25:36 -05:00
KiekerJan
1587248762
Disable Roundcube password plugin since it was corrupting the user database (#2198) 2023-01-15 08:22:43 -05:00
KiekerJan
0fc5105da5
Fixes to DNS lookups during status checks when there are timeouts, enforce timeouts better (#2191)
* add dns query handling changes

* replace exception pass with error message

* simplify dns exception catching

* Add not set case to blacklist lookup result handling
2023-01-15 08:20:08 -05:00
KiekerJan
c29593b5ef
explicitly enable fail2ban which didn't start (#2190) 2023-01-15 08:10:04 -05:00
74 changed files with 2182 additions and 1258 deletions

1
.gitignore vendored
View File

@ -6,3 +6,4 @@ externals/
.env .env
.vagrant .vagrant
api/docs/api-docs.html api/docs/api-docs.html
*.code-workspace

View File

@ -1,6 +1,178 @@
CHANGELOG CHANGELOG
========= =========
Version 72 (June 3, 2025)
-------------------------
Upgrades
* Roundcube upgraded to version 1.6.11, fixing a security vulnerability.
Control Panel
* A warning during daily tasks related to no TLS certificates being expired is fixed.
Version 71 (January 4, 2025)
----------------------------
(Version 71a was posted on January 6, 2025 and fixes a setup regression.)
Upgrades
* Roundcube upgraded to version 1.6.9.
* Z-Push upgraded to version 2.7.5.
Automated Maintenance
* Daily automated tasks are now run at 1am in the box's timezone and full backups are now restricted to running only on Saturdays and Sundays at that time.
* Backups now exclude the owncloud-backup folder so that we're not backing up backups.
* Old TLS certificates are now automatically deleted to improve control panel performance.
Setup
* Fixed broken setup if SSH was configured to listen on multiple ports.
* Ubuntu MOTD advertisements are now disabled.
* Fixed missing Roundcube dependency package if NextCloud isn't installed.
Control Panel
* Improved status checks for secondary nameservers.
* Spamhaus is now queried for the box's IPv6 address also.
* DSA and EC private keys are now accepted for TLS certificates.
* Timeouts for loading slow control panel pages are reduced.
And other minor fixes.
Version 70 (August 15, 2024)
----------------------------
* Roundcube is updated to version 1.6.8 fixing security vulnerabilities.
Version 69 (July 20, 2024)
--------------------------
Package updates:
* Nextcloud is updated to 26.0.13.
* Z-Push is updated to 2.7.3.
Other updates:
* Fixed an error generating the weekly statistics.
* Fixed file permissions when setting up Nextcloud.
* Added an undocumented option to proxy websockets.
* Internal improvements to the code to make it more reliable and readable.
Version 69a (July 21, 2024) and 69b (July 23, 2024) correct setup failures.
Version 68 (April 1, 2024)
--------------------------
Package updates:
* Roundcube updated to version 1.6.6.
* Nextcloud is updated to version 26.0.12.
Mail:
* Updated postfix's configuration to guard against SMTP smuggling to the long-term fix (https://www.postfix.org/smtp-smuggling.html).
Control Panel:
* Improved reporting of Spamhaus response codes.
* Improved detection of SSH port.
* Fixed an error if last saved status check results were corrupted.
* Other minor fixes.
Other:
* fail2ban is updated to see "HTTP/2.0" requests to munin also.
* Internal improvements to the code to make it more reliable and readable.
Version 67 (December 22, 2023)
------------------------------
* Guard against a newly published vulnerability called SMTP Smuggling. See https://sec-consult.com/blog/detail/smtp-smuggling-spoofing-e-mails-worldwide/.
Version 66 (December 17, 2023)
------------------------------
* Some users reported an error installing Mail-in-a-Box related to the virtualenv command. This is hopefully fixed.
* Roundcube is updated to 1.6.5 fixing a security vulnerability.
* For Mail-in-a-Box developers, a new setup variable is added to pull the source code from a different repository.
Version 65 (October 27, 2023)
-----------------------------
* Roundcube updated to 1.6.4 fixing a security vulnerability.
* zpush.sh updated to version 2.7.1.
* Fixed a typo in the control panel.
Version 64 (September 2, 2023)
------------------------------
* Fixed broken installation when upgrading from Mail-in-a-Box version 56 (Nextcloud 22) and earlier because of an upstream packaging issue.
* Fixed backups to work with the latest duplicity package which was not backwards compatible.
* Fixed setting B2 as a backup target with a slash in the application key.
* Turned off OpenDMARC diagnostic reports sent in response to incoming mail.
* Fixed some crashes when using an unreleased version of Mail-in-a-Box.
* Added z-push administration scripts.
Version 63 (July 27, 2023)
--------------------------
* Nextcloud updated to 25.0.7.
Version 62 (May 20, 2023)
-------------------------
Package updates:
* Nextcloud updated to 23.0.12 (and its apps also updated).
* Roundcube updated to 1.6.1.
* Z-Push to 2.7.0, which has compatibility for Ubuntu 22.04, so it works again.
Mail:
* Roundcube's password change page is now working again.
Control panel:
* Allow setting the backup location's S3 region name for non-AWS S3-compatible backup hosts.
* Control panel pages can be opened in a new tab/window and bookmarked and browser history navigation now works.
* Add a Copy button to put the rsync backup public key on clipboard.
* Allow secondary DNS xfr: items added in the control panel to be hostnames too.
* Fixed issue where sshkeygen fails when IPv6 is disabled.
* Fixed issue opening munin reports.
* Fixed report formatting in status emails sent to the administrator.
Version 61.1 (January 28, 2023)
-------------------------------
* Fixed rsync backups not working with the default port.
* Reverted "Improve error messages in the management tools when external command-line tools are run." because of the possibility of user secrets being included in error messages.
* Fix for TLS certificate SHA fingerprint not being displayed during setup.
Version 61 (January 21, 2023)
-----------------------------
System:
* fail2ban didn't start after setup.
Mail:
* Disable Roundcube password plugin since it was corrupting the user database.
Control panel:
* Fix changing existing backup settings when the rsync type is used.
* Allow setting a custom port for rsync backups.
* Fixes to DNS lookups during status checks when there are timeouts, enforce timeouts better.
* A new check is added to ensure fail2ban is running.
* Fixed a color.
* Improve error messages in the management tools when external command-line tools are run.
Version 60.1 (October 30, 2022) Version 60.1 (October 30, 2022)
------------------------------- -------------------------------
@ -79,7 +251,7 @@ Other:
* Set systemd journald log retention to 10 days (from no limit) to reduce disk usage. * Set systemd journald log retention to 10 days (from no limit) to reduce disk usage.
* Fixed log processing for submission lines that have a sasl_sender or other extra information. * Fixed log processing for submission lines that have a sasl_sender or other extra information.
* Fix DNS secondary nameserver refesh failure retry period. * Fix DNS secondary nameserver refresh failure retry period.
Version 55 (October 18, 2021) Version 55 (October 18, 2021)
----------------------------- -----------------------------
@ -104,7 +276,7 @@ Control panel:
Other: Other:
* Fail2ban's IPv6 support is enabled. * Fail2ban's IPv6 support is enabled.
* The mail log tool now doesn't crash if there are email addresess in log messages with invalid UTF-8 characters. * The mail log tool now doesn't crash if there are email addresses in log messages with invalid UTF-8 characters.
* Additional nsd.conf files can be placed in /etc/nsd.conf.d. * Additional nsd.conf files can be placed in /etc/nsd.conf.d.
v0.54 (June 20, 2021) v0.54 (June 20, 2021)
@ -137,7 +309,7 @@ Setup:
v0.53a (May 8, 2021) v0.53a (May 8, 2021)
-------------------- --------------------
The download URL for Z-Push has been revised becaue the old URL stopped working. The download URL for Z-Push has been revised because the old URL stopped working.
v0.53 (April 12, 2021) v0.53 (April 12, 2021)
---------------------- ----------------------
@ -356,7 +528,7 @@ Changes:
* Added support for S3-compatible backup services besides Amazon S3. * Added support for S3-compatible backup services besides Amazon S3.
* Fixed the control panel login page to let LastPass save passwords. * Fixed the control panel login page to let LastPass save passwords.
* Fixed an error in the user privileges API. * Fixed an error in the user privileges API.
* Silenced some spurrious messages. * Silenced some spurious messages.
Software updates: Software updates:
@ -420,7 +592,7 @@ Setup:
Control Panel: Control Panel:
* The users page now documents that passwords should only have ASCII characters to prevent character encoding mismaches between clients and the server. * The users page now documents that passwords should only have ASCII characters to prevent character encoding mismatches between clients and the server.
* The users page no longer shows user mailbox sizes because this was extremely slow for very large mailboxes. * The users page no longer shows user mailbox sizes because this was extremely slow for very large mailboxes.
* The Mail-in-a-Box version is now shown in the system status checks even when the new-version check is disabled. * The Mail-in-a-Box version is now shown in the system status checks even when the new-version check is disabled.
* The alises page now warns that alises should not be used to forward mail off of the box. Mail filters within Roundcube are better for that. * The alises page now warns that alises should not be used to forward mail off of the box. Mail filters within Roundcube are better for that.
@ -748,7 +920,7 @@ v0.17c (April 1, 2016)
This update addresses some minor security concerns and some installation issues. This update addresses some minor security concerns and some installation issues.
ownCoud: ownCloud:
* Block web access to the configuration parameters (config.php). There is no immediate impact (see [#776](https://github.com/mail-in-a-box/mailinabox/pull/776)), although advanced users may want to take note. * Block web access to the configuration parameters (config.php). There is no immediate impact (see [#776](https://github.com/mail-in-a-box/mailinabox/pull/776)), although advanced users may want to take note.
@ -764,7 +936,7 @@ Control panel:
Setup: Setup:
* Setup dialogs did not appear correctly when connecting to SSH using Putty on Windows. * Setup dialogs did not appear correctly when connecting to SSH using Putty on Windows.
* We now install Roundcube from our own mirror because Sourceforge's downloads experience frequent intermittant unavailability. * We now install Roundcube from our own mirror because Sourceforge's downloads experience frequent intermittent unavailability.
v0.17b (March 1, 2016) v0.17b (March 1, 2016)
---------------------- ----------------------
@ -807,7 +979,7 @@ This update primarily adds automatic SSL (now "TLS") certificate provisioning fr
Control Panel: Control Panel:
* The SSL certificates (now referred to as "TLS ccertificates") page now supports provisioning free certificates from Let's Encrypt. * The SSL certificates (now referred to as "TLS certificates") page now supports provisioning free certificates from Let's Encrypt.
* Report free memory usage. * Report free memory usage.
* Fix a crash when the git directory is not checked out to a tag. * Fix a crash when the git directory is not checked out to a tag.
* When IPv6 is enabled, check that all domains (besides the system hostname) resolve over IPv6. * When IPv6 is enabled, check that all domains (besides the system hostname) resolve over IPv6.
@ -900,7 +1072,7 @@ Control panel:
System: System:
* Tweaks to fail2ban settings. * Tweaks to fail2ban settings.
* Fixed a spurrious warning while installing munin. * Fixed a spurious warning while installing munin.
v0.13b (August 30, 2015) v0.13b (August 30, 2015)
------------------------ ------------------------
@ -914,7 +1086,7 @@ Note: v0.13 (no 'a', August 19, 2015) was pulled immediately due to an ownCloud
Mail: Mail:
* Outbound mail headers (the Recieved: header) are tweaked to possibly improve deliverability. * Outbound mail headers (the Received: header) are tweaked to possibly improve deliverability.
* Some MIME messages would hang Roundcube due to a missing package. * Some MIME messages would hang Roundcube due to a missing package.
* The users permitted to send as an alias can now be different from where an alias forwards to. * The users permitted to send as an alias can now be different from where an alias forwards to.
@ -946,7 +1118,7 @@ v0.12c was posted to work around the current Sourceforge.net outage: pyzor's rem
v0.12b (July 4, 2015) v0.12b (July 4, 2015)
--------------------- ---------------------
This version corrects a minor regression in v0.12 related to creating aliases targetting multiple addresses. This version corrects a minor regression in v0.12 related to creating aliases targeting multiple addresses.
v0.12 (July 3, 2015) v0.12 (July 3, 2015)
-------------------- --------------------
@ -999,7 +1171,7 @@ Control panel:
System: System:
* The munin system monitoring tool is now installed and accessible at /admin/munin. * The munin system monitoring tool is now installed and accessible at /admin/munin.
* ownCloud updated to version 8.0.4. The ownCloud installation step now is reslient to download problems. The ownCloud configuration file is now stored in STORAGE_ROOT to fix loss of data when moving STORAGE_ROOT to a new machine. * ownCloud updated to version 8.0.4. The ownCloud installation step now is resilient to download problems. The ownCloud configuration file is now stored in STORAGE_ROOT to fix loss of data when moving STORAGE_ROOT to a new machine.
* The setup scripts now run `apt-get update` prior to installing anything to ensure the apt database is in sync with the packages actually available. * The setup scripts now run `apt-get update` prior to installing anything to ensure the apt database is in sync with the packages actually available.
@ -1037,7 +1209,7 @@ DNS:
* Internationalized Domain Names (IDNs) should now work in email. If you had custom DNS or custom web settings for internationalized domains, check that they are still working. * Internationalized Domain Names (IDNs) should now work in email. If you had custom DNS or custom web settings for internationalized domains, check that they are still working.
* It is now possible to set multiple TXT and other types of records on the same domain in the control panel. * It is now possible to set multiple TXT and other types of records on the same domain in the control panel.
* The custom DNS API was completely rewritten to support setting multiple records of the same type on a domain. Any existing client code using the DNS API will have to be rewritten. (Existing code will just get 404s back.) * The custom DNS API was completely rewritten to support setting multiple records of the same type on a domain. Any existing client code using the DNS API will have to be rewritten. (Existing code will just get 404s back.)
* On some systems the `nsd` service failed to start if network inferfaces were not ready. * On some systems the `nsd` service failed to start if network interfaces were not ready.
System / Control Panel: System / Control Panel:

View File

@ -2,13 +2,13 @@
Mail-in-a-Box is an open source community project about working, as a group, to empower ourselves and others to have control over our own digital communications. Just as we hope to increase technological diversity on the Internet through decentralization, we also believe that diverse viewpoints and voices among our community members foster innovation and creative solutions to the challenges we face. Mail-in-a-Box is an open source community project about working, as a group, to empower ourselves and others to have control over our own digital communications. Just as we hope to increase technological diversity on the Internet through decentralization, we also believe that diverse viewpoints and voices among our community members foster innovation and creative solutions to the challenges we face.
We are committed to providing a safe, welcoming, and harrassment-free space for collaboration, for everyone, without regard to age, disability, economic situation, ethnicity, gender identity and expression, language fluency, level of knowledge or experience, nationality, personal appearance, race, religion, sexual identity and orientation, or any other attribute. Community comes first. This policy supersedes all other project goals. We are committed to providing a safe, welcoming, and harassment-free space for collaboration, for everyone, without regard to age, disability, economic situation, ethnicity, gender identity and expression, language fluency, level of knowledge or experience, nationality, personal appearance, race, religion, sexual identity and orientation, or any other attribute. Community comes first. This policy supersedes all other project goals.
The maintainers of Mail-in-a-Box share the dual responsibility of leading by example and enforcing these policies as necessary to maintain an open and welcoming environment. All community members should be excellent to each other. The maintainers of Mail-in-a-Box share the dual responsibility of leading by example and enforcing these policies as necessary to maintain an open and welcoming environment. All community members should be excellent to each other.
## Scope ## Scope
This Code of Conduct applies to all places where Mail-in-a-Box community activity is ocurring, including on GitHub, in discussion forums, on Slack, on social media, and in real life. The Code of Conduct applies not only on websites/at events run by the Mail-in-a-Box community (e.g. our GitHub organization, our Slack team) but also at any other location where the Mail-in-a-Box community is present (e.g. in issues of other GitHub organizations where Mail-in-a-Box community members are discussing problems related to Mail-in-a-Box, or real-life professional conferences), or whenever a Mail-in-a-Box community member is representing Mail-in-a-Box to the public at large or acting on behalf of Mail-in-a-Box. This Code of Conduct applies to all places where Mail-in-a-Box community activity is occurring, including on GitHub, in discussion forums, on Slack, on social media, and in real life. The Code of Conduct applies not only on websites/at events run by the Mail-in-a-Box community (e.g. our GitHub organization, our Slack team) but also at any other location where the Mail-in-a-Box community is present (e.g. in issues of other GitHub organizations where Mail-in-a-Box community members are discussing problems related to Mail-in-a-Box, or real-life professional conferences), or whenever a Mail-in-a-Box community member is representing Mail-in-a-Box to the public at large or acting on behalf of Mail-in-a-Box.
This code does not apply to activity on a server running Mail-in-a-Box software, unless your server is hosting a service for the Mail-in-a-Box community at large. This code does not apply to activity on a server running Mail-in-a-Box software, unless your server is hosting a service for the Mail-in-a-Box community at large.

View File

@ -56,11 +56,11 @@ See the [setup guide](https://mailinabox.email/guide.html) for detailed, user-fr
For experts, start with a completely fresh (really, I mean it) Ubuntu 22.04 LTS 64-bit machine. On the machine... For experts, start with a completely fresh (really, I mean it) Ubuntu 22.04 LTS 64-bit machine. On the machine...
Clone this repository and checkout the tag corresponding to the most recent release: Clone this repository and checkout the tag corresponding to the most recent release (which you can find in the tags or releases lists on GitHub):
$ git clone https://github.com/mail-in-a-box/mailinabox $ git clone https://github.com/mail-in-a-box/mailinabox
$ cd mailinabox $ cd mailinabox
$ git checkout v60.1 $ git checkout TAGNAME
Begin the installation. Begin the installation.

View File

@ -52,7 +52,7 @@ namespace inbox {
# dovevot's standard mailboxes configuration file marks two sent folders # dovevot's standard mailboxes configuration file marks two sent folders
# with the \Sent attribute, just in case clients don't agree about which # with the \Sent attribute, just in case clients don't agree about which
# they're using. We'll keep that, plus add Junk as an alterative for Spam. # they're using. We'll keep that, plus add Junk as an alternative for Spam.
# These are not auto-created. # These are not auto-created.
mailbox "Sent Messages" { mailbox "Sent Messages" {
special_use = \Sent special_use = \Sent

View File

@ -3,5 +3,5 @@
before = common.conf before = common.conf
[Definition] [Definition]
failregex=<HOST> - .*GET /admin/munin/.* HTTP/1.1\" 401.* failregex=<HOST> - .*GET /admin/munin/.* HTTP/\d+\.\d+\" 401.*
ignoreregex = ignoreregex =

View File

@ -74,7 +74,7 @@ action = iptables-allports[name=recidive]
# The last line on the action will sent an email to the configured address. This mail will # The last line on the action will sent an email to the configured address. This mail will
# notify the administrator that someone has been repeatedly triggering one of the other jails. # notify the administrator that someone has been repeatedly triggering one of the other jails.
# By default we don't configure this address and no action is required from the admin anyway. # By default we don't configure this address and no action is required from the admin anyway.
# So the notification is ommited. This will prevent message appearing in the mail.log that mail # So the notification is omitted. This will prevent message appearing in the mail.log that mail
# can't be delivered to fail2ban@$HOSTNAME. # can't be delivered to fail2ban@$HOSTNAME.
[postfix-sasl] [postfix-sasl]

View File

@ -1,7 +1,7 @@
<?xml version="1.0"?> <?xml version="1.0"?>
<clientConfig version="1.1"> <clientConfig version="1.1">
<emailProvider id="PRIMARY_HOSTNAME"> <emailProvider id="PRIMARY_HOSTNAME">
<domain>PRIMARY_HOSTNAME</domain> <domain purpose="mx">PRIMARY_HOSTNAME</domain>
<displayName>PRIMARY_HOSTNAME (Mail-in-a-Box)</displayName> <displayName>PRIMARY_HOSTNAME (Mail-in-a-Box)</displayName>
<displayShortName>PRIMARY_HOSTNAME</displayShortName> <displayShortName>PRIMARY_HOSTNAME</displayShortName>
@ -14,6 +14,14 @@
<authentication>password-cleartext</authentication> <authentication>password-cleartext</authentication>
</incomingServer> </incomingServer>
<incomingServer type="pop3">
<hostname>PRIMARY_HOSTNAME</hostname>
<port>995</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>password-cleartext</authentication>
</incomingServer>
<outgoingServer type="smtp"> <outgoingServer type="smtp">
<hostname>PRIMARY_HOSTNAME</hostname> <hostname>PRIMARY_HOSTNAME</hostname>
<port>465</port> <port>465</port>
@ -29,6 +37,20 @@
</documentation> </documentation>
</emailProvider> </emailProvider>
<addressbook type="carddav">
<username>%EMAILADDRESS%</username>
<authentication system="http">basic</authentication>
<!-- Redirects to: https://PRIMARY_HOSTNAME/cloud/remote.php/carddav/ -->
<url>https://PRIMARY_HOSTNAME/.well-known/carddav</url>
</addressbook>
<calendar type="caldav">
<username>%EMAILADDRESS%</username>
<authentication system="http">basic</authentication>
<!-- Redirects to: https://PRIMARY_HOSTNAME/cloud/remote.php/caldav/ -->
<url>https://PRIMARY_HOSTNAME/.well-known/caldav</url>
</calendar>
<webMail> <webMail>
<loginPage url="https://PRIMARY_HOSTNAME/mail/" /> <loginPage url="https://PRIMARY_HOSTNAME/mail/" />
<loginPageInfo url="https://PRIMARY_HOSTNAME/mail/" > <loginPageInfo url="https://PRIMARY_HOSTNAME/mail/" >

View File

@ -37,7 +37,7 @@
return 403; return 403;
} }
location ~ /mail/.*\.php { location ~ /mail/.*\.php {
# note: ~ has precendence over a regular location block # note: ~ has precedence over a regular location block
include fastcgi_params; include fastcgi_params;
fastcgi_split_path_info ^/mail(/.*)()$; fastcgi_split_path_info ^/mail(/.*)()$;
fastcgi_index index.php; fastcgi_index index.php;

View File

@ -38,7 +38,7 @@
} }
} }
location ~ ^(/cloud)((?:/ocs)?/[^/]+\.php)(/.*)?$ { location ~ ^(/cloud)((?:/ocs)?/[^/]+\.php)(/.*)?$ {
# note: ~ has precendence over a regular location block # note: ~ has precedence over a regular location block
# Accept URLs like: # Accept URLs like:
# /cloud/index.php/apps/files/ # /cloud/index.php/apps/files/
# /cloud/index.php/apps/files/ajax/scan.php (it's really index.php; see 6fdef379adfdeac86cc2220209bdf4eb9562268d) # /cloud/index.php/apps/files/ajax/scan.php (it's really index.php; see 6fdef379adfdeac86cc2220209bdf4eb9562268d)
@ -73,4 +73,9 @@
rewrite ^/.well-known/carddav /cloud/remote.php/carddav/ redirect; rewrite ^/.well-known/carddav /cloud/remote.php/carddav/ redirect;
rewrite ^/.well-known/caldav /cloud/remote.php/caldav/ redirect; rewrite ^/.well-known/caldav /cloud/remote.php/caldav/ redirect;
# This addresses those service discovery issues mentioned in:
# https://docs.nextcloud.com/server/23/admin_manual/issues/general_troubleshooting.html#service-discovery
rewrite ^/.well-known/webfinger /cloud/index.php/.well-known/webfinger redirect;
rewrite ^/.well-known/nodeinfo /cloud/index.php/.well-known/nodeinfo redirect;
# ADDITIONAL DIRECTIVES HERE # ADDITIONAL DIRECTIVES HERE

View File

@ -12,8 +12,6 @@ ssl_session_timeout 1d;
# nginx 1.5.9+ ONLY # nginx 1.5.9+ ONLY
ssl_buffer_size 1400; ssl_buffer_size 1400;
ssl_stapling on;
ssl_stapling_verify on;
resolver 127.0.0.1 valid=86400; resolver 127.0.0.1 valid=86400;
resolver_timeout 10; resolver_timeout 10;

View File

@ -1,4 +1,4 @@
import base64, os, os.path, hmac, json, secrets import base64, hmac, json, secrets
from datetime import timedelta from datetime import timedelta
from expiringdict import ExpiringDict from expiringdict import ExpiringDict
@ -22,7 +22,7 @@ class AuthService:
def init_system_api_key(self): def init_system_api_key(self):
"""Write an API key to a local file so local processes can use the API""" """Write an API key to a local file so local processes can use the API"""
with open(self.key_path, 'r') as file: with open(self.key_path, encoding='utf-8') as file:
self.key = file.read() self.key = file.read()
def authenticate(self, request, env, login_only=False, logout=False): def authenticate(self, request, env, login_only=False, logout=False):
@ -48,11 +48,13 @@ class AuthService:
return username, password return username, password
username, password = parse_http_authorization_basic(request.headers.get('Authorization', '')) username, password = parse_http_authorization_basic(request.headers.get('Authorization', ''))
if username in (None, ""): if username in {None, ""}:
raise ValueError("Authorization header invalid.") msg = "Authorization header invalid."
raise ValueError(msg)
if username.strip() == "" and password.strip() == "": if username.strip() == "" and password.strip() == "":
raise ValueError("No email address, password, session key, or API key provided.") msg = "No email address, password, session key, or API key provided."
raise ValueError(msg)
# If user passed the system API key, grant administrative privs. This key # If user passed the system API key, grant administrative privs. This key
# is not associated with a user. # is not associated with a user.
@ -72,7 +74,8 @@ class AuthService:
# If no password was given, but a username was given, we're missing some information. # If no password was given, but a username was given, we're missing some information.
elif password.strip() == "": elif password.strip() == "":
raise ValueError("Enter a password.") msg = "Enter a password."
raise ValueError(msg)
else: else:
# The user is trying to log in with a username and a password # The user is trying to log in with a username and a password
@ -114,7 +117,8 @@ class AuthService:
]) ])
except: except:
# Login failed. # Login failed.
raise ValueError("Incorrect email address or password.") msg = "Incorrect email address or password."
raise ValueError(msg)
# If MFA is enabled, check that MFA passes. # If MFA is enabled, check that MFA passes.
status, hints = validate_auth_mfa(email, request, env) status, hints = validate_auth_mfa(email, request, env)

View File

@ -7,15 +7,17 @@
# 4) The stopped services are restarted. # 4) The stopped services are restarted.
# 5) STORAGE_ROOT/backup/after-backup is executed if it exists. # 5) STORAGE_ROOT/backup/after-backup is executed if it exists.
import os, os.path, shutil, glob, re, datetime, sys import os, os.path, re, datetime, sys
import dateutil.parser, dateutil.relativedelta, dateutil.tz import dateutil.parser, dateutil.relativedelta, dateutil.tz
from datetime import date
import rtyaml import rtyaml
from exclusiveprocess import Lock from exclusiveprocess import Lock
from utils import load_environment, shell, wait_for_service from utils import load_environment, shell, wait_for_service
import operator
def backup_status(env): def backup_status(env):
# If backups are dissbled, return no status. # If backups are disabled, return no status.
config = get_backup_config(env) config = get_backup_config(env)
if config["target"] == "off": if config["target"] == "off":
return { } return { }
@ -57,10 +59,11 @@ def backup_status(env):
"/usr/bin/duplicity", "/usr/bin/duplicity",
"collection-status", "collection-status",
"--archive-dir", backup_cache_dir, "--archive-dir", backup_cache_dir,
"--gpg-options", "--cipher-algo=AES256", "--gpg-options", "'--cipher-algo=AES256'",
"--log-fd", "1", "--log-fd", "1",
get_duplicity_target_url(config), *get_duplicity_additional_args(env),
] + get_duplicity_additional_args(env), get_duplicity_target_url(config)
],
get_duplicity_env_vars(env), get_duplicity_env_vars(env),
trap=True) trap=True)
if code != 0: if code != 0:
@ -68,7 +71,7 @@ def backup_status(env):
# destination for the backups or the last backup job terminated unexpectedly. # destination for the backups or the last backup job terminated unexpectedly.
raise Exception("Something is wrong with the backup: " + collection_status) raise Exception("Something is wrong with the backup: " + collection_status)
for line in collection_status.split('\n'): for line in collection_status.split('\n'):
if line.startswith(" full") or line.startswith(" inc"): if line.startswith((" full", " inc")):
backup = parse_line(line) backup = parse_line(line)
backups[backup["date"]] = backup backups[backup["date"]] = backup
@ -89,7 +92,7 @@ def backup_status(env):
# Ensure the rows are sorted reverse chronologically. # Ensure the rows are sorted reverse chronologically.
# This is relied on by should_force_full() and the next step. # This is relied on by should_force_full() and the next step.
backups = sorted(backups.values(), key = lambda b : b["date"], reverse=True) backups = sorted(backups.values(), key = operator.itemgetter("date"), reverse=True)
# Get the average size of incremental backups, the size of the # Get the average size of incremental backups, the size of the
# most recent full backup, and the date of the most recent # most recent full backup, and the date of the most recent
@ -156,6 +159,8 @@ def should_force_full(config, env):
# since the last full backup is greater than half the size # since the last full backup is greater than half the size
# of that full backup. # of that full backup.
inc_size = 0 inc_size = 0
# Check if day of week is a weekend day
weekend = date.today().weekday()>=5
for bak in backup_status(env)["backups"]: for bak in backup_status(env)["backups"]:
if not bak["full"]: if not bak["full"]:
# Scan through the incremental backups cumulating # Scan through the incremental backups cumulating
@ -164,16 +169,16 @@ def should_force_full(config, env):
else: else:
# ...until we reach the most recent full backup. # ...until we reach the most recent full backup.
# Return if we should to a full backup, which is based # Return if we should to a full backup, which is based
# on the size of the increments relative to the full # on whether it is a weekend day, the size of the
# backup, as well as the age of the full backup. # increments relative to the full backup, as well as
# the age of the full backup.
if weekend:
if inc_size > .5*bak["size"]: if inc_size > .5*bak["size"]:
return True return True
if dateutil.parser.parse(bak["date"]) + datetime.timedelta(days=config["min_age_in_days"]*10+1) < datetime.datetime.now(dateutil.tz.tzlocal()): if dateutil.parser.parse(bak["date"]) + datetime.timedelta(days=config["min_age_in_days"]*10+1) < datetime.datetime.now(dateutil.tz.tzlocal()):
return True return True
return False return False
else:
# If we got here there are no (full) backups, so make one. # If we got here there are no (full) backups, so make one.
# (I love for/else blocks. Here it's just to show off.)
return True return True
def get_passphrase(env): def get_passphrase(env):
@ -184,7 +189,7 @@ def get_passphrase(env):
# only needs to be 43 base64-characters to match AES256's key # only needs to be 43 base64-characters to match AES256's key
# length of 32 bytes. # length of 32 bytes.
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup') backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
with open(os.path.join(backup_root, 'secret_key.txt')) as f: with open(os.path.join(backup_root, 'secret_key.txt'), encoding="utf-8") as f:
passphrase = f.readline().strip() passphrase = f.readline().strip()
if len(passphrase) < 43: raise Exception("secret_key.txt's first line is too short!") if len(passphrase) < 43: raise Exception("secret_key.txt's first line is too short!")
@ -202,7 +207,9 @@ def get_duplicity_target_url(config):
# the target URL must be the bucket name. The hostname is passed # the target URL must be the bucket name. The hostname is passed
# via get_duplicity_additional_args. Move the first part of the # via get_duplicity_additional_args. Move the first part of the
# path (the bucket name) into the hostname URL component, and leave # path (the bucket name) into the hostname URL component, and leave
# the rest for the path. # the rest for the path. (The S3 region name is also stored in the
# hostname part of the URL, in the username portion, which we also
# have to drop here).
target[1], target[2] = target[2].lstrip('/').split('/', 1) target[1], target[2] = target[2].lstrip('/').split('/', 1)
target = urlunsplit(target) target = urlunsplit(target)
@ -213,16 +220,32 @@ def get_duplicity_additional_args(env):
config = get_backup_config(env) config = get_backup_config(env)
if get_target_type(config) == 'rsync': if get_target_type(config) == 'rsync':
# Extract a port number for the ssh transport. Duplicity accepts the
# optional port number syntax in the target, but it doesn't appear to act
# on it, so we set the ssh port explicitly via the duplicity options.
from urllib.parse import urlsplit
try:
port = urlsplit(config["target"]).port
except ValueError:
port = 22
if port is None:
port = 22
return [ return [
"--ssh-options= -i /root/.ssh/id_rsa_miab", f"--ssh-options='-i /root/.ssh/id_rsa_miab -p {port}'",
"--rsync-options= -e \"/usr/bin/ssh -oStrictHostKeyChecking=no -oBatchMode=yes -p 22 -i /root/.ssh/id_rsa_miab\"", f"--rsync-options='-e \"/usr/bin/ssh -oStrictHostKeyChecking=no -oBatchMode=yes -p {port} -i /root/.ssh/id_rsa_miab\"'",
] ]
elif get_target_type(config) == 's3': if get_target_type(config) == 's3':
# See note about hostname in get_duplicity_target_url. # See note about hostname in get_duplicity_target_url.
# The region name, which is required by some non-AWS endpoints,
# is saved inside the username portion of the URL.
from urllib.parse import urlsplit, urlunsplit from urllib.parse import urlsplit, urlunsplit
target = urlsplit(config["target"]) target = urlsplit(config["target"])
endpoint_url = urlunsplit(("https", target.netloc, '', '', '')) endpoint_url = urlunsplit(("https", target.hostname, '', '', ''))
return ["--s3-endpoint-url", endpoint_url] args = ["--s3-endpoint-url", endpoint_url]
if target.username: # region name is stuffed here
args += ["--s3-region-name", target.username]
return args
return [] return []
@ -234,12 +257,13 @@ def get_duplicity_env_vars(env):
if get_target_type(config) == 's3': if get_target_type(config) == 's3':
env["AWS_ACCESS_KEY_ID"] = config["target_user"] env["AWS_ACCESS_KEY_ID"] = config["target_user"]
env["AWS_SECRET_ACCESS_KEY"] = config["target_pass"] env["AWS_SECRET_ACCESS_KEY"] = config["target_pass"]
env["AWS_REQUEST_CHECKSUM_CALCULATION"] = "WHEN_REQUIRED"
env["AWS_RESPONSE_CHECKSUM_VALIDATION"] = "WHEN_REQUIRED"
return env return env
def get_target_type(config): def get_target_type(config):
protocol = config["target"].split(":")[0] return config["target"].split(":")[0]
return protocol
def perform_backup(full_backup): def perform_backup(full_backup):
env = load_environment() env = load_environment()
@ -302,12 +326,14 @@ def perform_backup(full_backup):
"--verbosity", "warning", "--no-print-statistics", "--verbosity", "warning", "--no-print-statistics",
"--archive-dir", backup_cache_dir, "--archive-dir", backup_cache_dir,
"--exclude", backup_root, "--exclude", backup_root,
"--exclude", os.path.join(env["STORAGE_ROOT"], "owncloud-backup"),
"--volsize", "250", "--volsize", "250",
"--gpg-options", "--cipher-algo=AES256", "--gpg-options", "'--cipher-algo=AES256'",
"--allow-source-mismatch",
*get_duplicity_additional_args(env),
env["STORAGE_ROOT"], env["STORAGE_ROOT"],
get_duplicity_target_url(config), get_duplicity_target_url(config),
"--allow-source-mismatch" ],
] + get_duplicity_additional_args(env),
get_duplicity_env_vars(env)) get_duplicity_env_vars(env))
finally: finally:
# Start services again. # Start services again.
@ -325,8 +351,9 @@ def perform_backup(full_backup):
"--verbosity", "error", "--verbosity", "error",
"--archive-dir", backup_cache_dir, "--archive-dir", backup_cache_dir,
"--force", "--force",
*get_duplicity_additional_args(env),
get_duplicity_target_url(config) get_duplicity_target_url(config)
] + get_duplicity_additional_args(env), ],
get_duplicity_env_vars(env)) get_duplicity_env_vars(env))
# From duplicity's manual: # From duplicity's manual:
@ -340,8 +367,9 @@ def perform_backup(full_backup):
"--verbosity", "error", "--verbosity", "error",
"--archive-dir", backup_cache_dir, "--archive-dir", backup_cache_dir,
"--force", "--force",
*get_duplicity_additional_args(env),
get_duplicity_target_url(config) get_duplicity_target_url(config)
] + get_duplicity_additional_args(env), ],
get_duplicity_env_vars(env)) get_duplicity_env_vars(env))
# Change ownership of backups to the user-data user, so that the after-bcakup # Change ownership of backups to the user-data user, so that the after-bcakup
@ -378,9 +406,11 @@ def run_duplicity_verification():
"--compare-data", "--compare-data",
"--archive-dir", backup_cache_dir, "--archive-dir", backup_cache_dir,
"--exclude", backup_root, "--exclude", backup_root,
"--exclude", os.path.join(env["STORAGE_ROOT"], "owncloud-backup"),
*get_duplicity_additional_args(env),
get_duplicity_target_url(config), get_duplicity_target_url(config),
env["STORAGE_ROOT"], env["STORAGE_ROOT"],
] + get_duplicity_additional_args(env), get_duplicity_env_vars(env)) ], get_duplicity_env_vars(env))
def run_duplicity_restore(args): def run_duplicity_restore(args):
env = load_environment() env = load_environment()
@ -390,10 +420,24 @@ def run_duplicity_restore(args):
"/usr/bin/duplicity", "/usr/bin/duplicity",
"restore", "restore",
"--archive-dir", backup_cache_dir, "--archive-dir", backup_cache_dir,
*get_duplicity_additional_args(env),
get_duplicity_target_url(config), get_duplicity_target_url(config),
] + get_duplicity_additional_args(env) + args, *args],
get_duplicity_env_vars(env)) get_duplicity_env_vars(env))
def print_duplicity_command():
import shlex
env = load_environment()
config = get_backup_config(env)
backup_cache_dir = os.path.join(env["STORAGE_ROOT"], 'backup', 'cache')
for k, v in get_duplicity_env_vars(env).items():
print(f"export {k}={shlex.quote(v)}")
print("duplicity", "{command}", shlex.join([
"--archive-dir", backup_cache_dir,
*get_duplicity_additional_args(env),
get_duplicity_target_url(config)
]))
def list_target_files(config): def list_target_files(config):
import urllib.parse import urllib.parse
try: try:
@ -404,23 +448,32 @@ def list_target_files(config):
if target.scheme == "file": if target.scheme == "file":
return [(fn, os.path.getsize(os.path.join(target.path, fn))) for fn in os.listdir(target.path)] return [(fn, os.path.getsize(os.path.join(target.path, fn))) for fn in os.listdir(target.path)]
elif target.scheme == "rsync": if target.scheme == "rsync":
rsync_fn_size_re = re.compile(r'.* ([^ ]*) [^ ]* [^ ]* (.*)') rsync_fn_size_re = re.compile(r'.* ([^ ]*) [^ ]* [^ ]* (.*)')
rsync_target = '{host}:{path}' rsync_target = '{host}:{path}'
# Strip off any trailing port specifier because it's not valid in rsync's
# DEST syntax. Explicitly set the port number for the ssh transport.
user_host, *_ = target.netloc.rsplit(':', 1)
try:
port = target.port
except ValueError:
port = 22
if port is None:
port = 22
target_path = target.path target_path = target.path
if not target_path.endswith('/'): if not target_path.endswith('/'):
target_path = target_path + '/' target_path += '/'
if target_path.startswith('/'): target_path = target_path.removeprefix('/')
target_path = target_path[1:]
rsync_command = [ 'rsync', rsync_command = [ 'rsync',
'-e', '-e',
'/usr/bin/ssh -i /root/.ssh/id_rsa_miab -oStrictHostKeyChecking=no -oBatchMode=yes', f'/usr/bin/ssh -i /root/.ssh/id_rsa_miab -oStrictHostKeyChecking=no -oBatchMode=yes -p {port}',
'--list-only', '--list-only',
'-r', '-r',
rsync_target.format( rsync_target.format(
host=target.netloc, host=user_host,
path=target_path) path=target_path)
] ]
@ -432,22 +485,22 @@ def list_target_files(config):
if match: if match:
ret.append( (match.groups()[1], int(match.groups()[0].replace(',',''))) ) ret.append( (match.groups()[1], int(match.groups()[0].replace(',',''))) )
return ret return ret
else:
if 'Permission denied (publickey).' in listing: if 'Permission denied (publickey).' in listing:
reason = "Invalid user or check you correctly copied the SSH key." reason = "Invalid user or check you correctly copied the SSH key."
elif 'No such file or directory' in listing: elif 'No such file or directory' in listing:
reason = "Provided path {} is invalid.".format(target_path) reason = f"Provided path {target_path} is invalid."
elif 'Network is unreachable' in listing: elif 'Network is unreachable' in listing:
reason = "The IP address {} is unreachable.".format(target.hostname) reason = f"The IP address {target.hostname} is unreachable."
elif 'Could not resolve hostname' in listing: elif 'Could not resolve hostname' in listing:
reason = "The hostname {} cannot be resolved.".format(target.hostname) reason = f"The hostname {target.hostname} cannot be resolved."
else: else:
reason = "Unknown error." \ reason = ("Unknown error."
"Please check running 'management/backup.py --verify'" \ "Please check running 'management/backup.py --verify'"
"from mailinabox sources to debug the issue." "from mailinabox sources to debug the issue.")
raise ValueError("Connection to rsync host failed: {}".format(reason)) msg = f"Connection to rsync host failed: {reason}"
raise ValueError(msg)
elif target.scheme == "s3": if target.scheme == "s3":
import boto3.s3 import boto3.s3
from botocore.exceptions import ClientError from botocore.exceptions import ClientError
@ -460,10 +513,14 @@ def list_target_files(config):
path = '' path = ''
if bucket == "": if bucket == "":
raise ValueError("Enter an S3 bucket name.") msg = "Enter an S3 bucket name."
raise ValueError(msg)
# connect to the region & bucket # connect to the region & bucket
try: try:
if config['target_user'] == "" and config['target_pass'] == "":
s3 = boto3.client('s3', endpoint_url=f'https://{target.hostname}')
else:
s3 = boto3.client('s3', \ s3 = boto3.client('s3', \
endpoint_url=f'https://{target.hostname}', \ endpoint_url=f'https://{target.hostname}', \
aws_access_key_id=config['target_user'], \ aws_access_key_id=config['target_user'], \
@ -473,7 +530,7 @@ def list_target_files(config):
except ClientError as e: except ClientError as e:
raise ValueError(e) raise ValueError(e)
return backup_list return backup_list
elif target.scheme == 'b2': if target.scheme == 'b2':
from b2sdk.v1 import InMemoryAccountInfo, B2Api from b2sdk.v1 import InMemoryAccountInfo, B2Api
from b2sdk.v1.exception import NonExistentBucket from b2sdk.v1.exception import NonExistentBucket
info = InMemoryAccountInfo() info = InMemoryAccountInfo()
@ -481,17 +538,17 @@ def list_target_files(config):
# Extract information from target # Extract information from target
b2_application_keyid = target.netloc[:target.netloc.index(':')] b2_application_keyid = target.netloc[:target.netloc.index(':')]
b2_application_key = target.netloc[target.netloc.index(':')+1:target.netloc.index('@')] b2_application_key = urllib.parse.unquote(target.netloc[target.netloc.index(':')+1:target.netloc.index('@')])
b2_bucket = target.netloc[target.netloc.index('@')+1:] b2_bucket = target.netloc[target.netloc.index('@')+1:]
try: try:
b2_api.authorize_account("production", b2_application_keyid, b2_application_key) b2_api.authorize_account("production", b2_application_keyid, b2_application_key)
bucket = b2_api.get_bucket_by_name(b2_bucket) bucket = b2_api.get_bucket_by_name(b2_bucket)
except NonExistentBucket as e: except NonExistentBucket:
raise ValueError("B2 Bucket does not exist. Please double check your information!") msg = "B2 Bucket does not exist. Please double check your information!"
raise ValueError(msg)
return [(key.file_name, key.size) for key, _ in bucket.ls()] return [(key.file_name, key.size) for key, _ in bucket.ls()]
else:
raise ValueError(config["target"]) raise ValueError(config["target"])
@ -509,7 +566,7 @@ def backup_set_custom(env, target, target_user, target_pass, min_age):
# Validate. # Validate.
try: try:
if config["target"] not in ("off", "local"): if config["target"] not in {"off", "local"}:
# these aren't supported by the following function, which expects a full url in the target key, # these aren't supported by the following function, which expects a full url in the target key,
# which is what is there except when loading the config prior to saving # which is what is there except when loading the config prior to saving
list_target_files(config) list_target_files(config)
@ -531,8 +588,9 @@ def get_backup_config(env, for_save=False, for_ui=False):
# Merge in anything written to custom.yaml. # Merge in anything written to custom.yaml.
try: try:
custom_config = rtyaml.load(open(os.path.join(backup_root, 'custom.yaml'))) with open(os.path.join(backup_root, 'custom.yaml'), encoding="utf-8") as f:
if not isinstance(custom_config, dict): raise ValueError() # caught below custom_config = rtyaml.load(f)
if not isinstance(custom_config, dict): raise ValueError # caught below
config.update(custom_config) config.update(custom_config)
except: except:
pass pass
@ -545,8 +603,7 @@ def get_backup_config(env, for_save=False, for_ui=False):
# authentication details. The user will have to re-enter it. # authentication details. The user will have to re-enter it.
if for_ui: if for_ui:
for field in ("target_user", "target_pass"): for field in ("target_user", "target_pass"):
if field in config: config.pop(field, None)
del config[field]
# helper fields for the admin # helper fields for the admin
config["file_target_directory"] = os.path.join(backup_root, 'encrypted') config["file_target_directory"] = os.path.join(backup_root, 'encrypted')
@ -556,17 +613,17 @@ def get_backup_config(env, for_save=False, for_ui=False):
config["target"] = "file://" + config["file_target_directory"] config["target"] = "file://" + config["file_target_directory"]
ssh_pub_key = os.path.join('/root', '.ssh', 'id_rsa_miab.pub') ssh_pub_key = os.path.join('/root', '.ssh', 'id_rsa_miab.pub')
if os.path.exists(ssh_pub_key): if os.path.exists(ssh_pub_key):
config["ssh_pub_key"] = open(ssh_pub_key, 'r').read() with open(ssh_pub_key, encoding="utf-8") as f:
config["ssh_pub_key"] = f.read()
return config return config
def write_backup_config(env, newconfig): def write_backup_config(env, newconfig):
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup') backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
with open(os.path.join(backup_root, 'custom.yaml'), "w") as f: with open(os.path.join(backup_root, 'custom.yaml'), "w", encoding="utf-8") as f:
f.write(rtyaml.dump(newconfig)) f.write(rtyaml.dump(newconfig))
if __name__ == "__main__": if __name__ == "__main__":
import sys
if sys.argv[-1] == "--verify": if sys.argv[-1] == "--verify":
# Run duplicity's verification command to check a) the backup files # Run duplicity's verification command to check a) the backup files
# are readable, and b) report if they are up to date. # are readable, and b) report if they are up to date.
@ -575,7 +632,7 @@ if __name__ == "__main__":
elif sys.argv[-1] == "--list": elif sys.argv[-1] == "--list":
# List the saved backup files. # List the saved backup files.
for fn, size in list_target_files(get_backup_config(load_environment())): for fn, size in list_target_files(get_backup_config(load_environment())):
print("{}\t{}".format(fn, size)) print(f"{fn}\t{size}")
elif sys.argv[-1] == "--status": elif sys.argv[-1] == "--status":
# Show backup status. # Show backup status.
@ -588,6 +645,9 @@ if __name__ == "__main__":
# to duplicity. The restore path should be specified. # to duplicity. The restore path should be specified.
run_duplicity_restore(sys.argv[2:]) run_duplicity_restore(sys.argv[2:])
elif sys.argv[-1] == "--duplicity-command":
print_duplicity_command()
else: else:
# Perform a backup. Add --full to force a full backup rather than # Perform a backup. Add --full to force a full backup rather than
# possibly performing an incremental backup. # possibly performing an incremental backup.

View File

@ -6,7 +6,8 @@
# root API key. This file is readable only by root, so this # root API key. This file is readable only by root, so this
# tool can only be used as root. # tool can only be used as root.
import sys, getpass, urllib.request, urllib.error, json, re, csv import sys, getpass, urllib.request, urllib.error, json, csv
import contextlib
def mgmt(cmd, data=None, is_json=False): def mgmt(cmd, data=None, is_json=False):
# The base URL for the management daemon. (Listens on IPv4 only.) # The base URL for the management daemon. (Listens on IPv4 only.)
@ -19,10 +20,8 @@ def mgmt(cmd, data=None, is_json=False):
response = urllib.request.urlopen(req) response = urllib.request.urlopen(req)
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
if e.code == 401: if e.code == 401:
try: with contextlib.suppress(Exception):
print(e.read().decode("utf8")) print(e.read().decode("utf8"))
except:
pass
print("The management daemon refused access. The API key file may be out of sync. Try 'service mailinabox restart'.", file=sys.stderr) print("The management daemon refused access. The API key file may be out of sync. Try 'service mailinabox restart'.", file=sys.stderr)
elif hasattr(e, 'read'): elif hasattr(e, 'read'):
print(e.read().decode('utf8'), file=sys.stderr) print(e.read().decode('utf8'), file=sys.stderr)
@ -47,7 +46,8 @@ def read_password():
return first return first
def setup_key_auth(mgmt_uri): def setup_key_auth(mgmt_uri):
key = open('/var/lib/mailinabox/api.key').read().strip() with open('/var/lib/mailinabox/api.key', encoding='utf-8') as f:
key = f.read().strip()
auth_handler = urllib.request.HTTPBasicAuthHandler() auth_handler = urllib.request.HTTPBasicAuthHandler()
auth_handler.add_password( auth_handler.add_password(
@ -65,6 +65,7 @@ if len(sys.argv) < 2:
{cli} user password user@domain.com [password] {cli} user password user@domain.com [password]
{cli} user remove user@domain.com {cli} user remove user@domain.com
{cli} user make-admin user@domain.com {cli} user make-admin user@domain.com
{cli} user quota user@domain [new-quota] (get or set user quota)
{cli} user remove-admin user@domain.com {cli} user remove-admin user@domain.com
{cli} user admins (lists admins) {cli} user admins (lists admins)
{cli} user mfa show user@domain.com (shows MFA devices for user, if any) {cli} user mfa show user@domain.com (shows MFA devices for user, if any)
@ -90,12 +91,9 @@ elif sys.argv[1] == "user" and len(sys.argv) == 2:
print("*", end='') print("*", end='')
print() print()
elif sys.argv[1] == "user" and sys.argv[2] in ("add", "password"): elif sys.argv[1] == "user" and sys.argv[2] in {"add", "password"}:
if len(sys.argv) < 5: if len(sys.argv) < 5:
if len(sys.argv) < 4: email = input('email: ') if len(sys.argv) < 4 else sys.argv[3]
email = input("email: ")
else:
email = sys.argv[3]
pw = read_password() pw = read_password()
else: else:
email, pw = sys.argv[3:5] email, pw = sys.argv[3:5]
@ -108,11 +106,8 @@ elif sys.argv[1] == "user" and sys.argv[2] in ("add", "password"):
elif sys.argv[1] == "user" and sys.argv[2] == "remove" and len(sys.argv) == 4: elif sys.argv[1] == "user" and sys.argv[2] == "remove" and len(sys.argv) == 4:
print(mgmt("/mail/users/remove", { "email": sys.argv[3] })) print(mgmt("/mail/users/remove", { "email": sys.argv[3] }))
elif sys.argv[1] == "user" and sys.argv[2] in ("make-admin", "remove-admin") and len(sys.argv) == 4: elif sys.argv[1] == "user" and sys.argv[2] in {"make-admin", "remove-admin"} and len(sys.argv) == 4:
if sys.argv[2] == "make-admin": action = 'add' if sys.argv[2] == 'make-admin' else 'remove'
action = "add"
else:
action = "remove"
print(mgmt("/mail/users/privileges/" + action, { "email": sys.argv[3], "privilege": "admin" })) print(mgmt("/mail/users/privileges/" + action, { "email": sys.argv[3], "privilege": "admin" }))
elif sys.argv[1] == "user" and sys.argv[2] == "admins": elif sys.argv[1] == "user" and sys.argv[2] == "admins":
@ -123,6 +118,14 @@ elif sys.argv[1] == "user" and sys.argv[2] == "admins":
if "admin" in user['privileges']: if "admin" in user['privileges']:
print(user['email']) print(user['email'])
elif sys.argv[1] == "user" and sys.argv[2] == "quota" and len(sys.argv) == 4:
# Get a user's quota
print(mgmt(f"/mail/users/quota?text=1&email={sys.argv[3]}"))
elif sys.argv[1] == "user" and sys.argv[2] == "quota" and len(sys.argv) == 5:
# Set a user's quota
users = mgmt("/mail/users/quota", { "email": sys.argv[3], "quota": sys.argv[4] })
elif sys.argv[1] == "user" and len(sys.argv) == 5 and sys.argv[2:4] == ["mfa", "show"]: elif sys.argv[1] == "user" and len(sys.argv) == 5 and sys.argv[2:4] == ["mfa", "show"]:
# Show MFA status for a user. # Show MFA status for a user.
status = mgmt("/mfa/status", { "user": sys.argv[4] }, is_json=True) status = mgmt("/mfa/status", { "user": sys.argv[4] }, is_json=True)
@ -131,7 +134,7 @@ elif sys.argv[1] == "user" and len(sys.argv) == 5 and sys.argv[2:4] == ["mfa", "
for mfa in status["enabled_mfa"]: for mfa in status["enabled_mfa"]:
W.writerow([mfa["id"], mfa["type"], mfa["label"]]) W.writerow([mfa["id"], mfa["type"], mfa["label"]])
elif sys.argv[1] == "user" and len(sys.argv) in (5, 6) and sys.argv[2:4] == ["mfa", "disable"]: elif sys.argv[1] == "user" and len(sys.argv) in {5, 6} and sys.argv[2:4] == ["mfa", "disable"]:
# Disable MFA (all or a particular device) for a user. # Disable MFA (all or a particular device) for a user.
print(mgmt("/mfa/disable", { "user": sys.argv[4], "mfa-id": sys.argv[5] if len(sys.argv) == 6 else None })) print(mgmt("/mfa/disable", { "user": sys.argv[4], "mfa-id": sys.argv[5] if len(sys.argv) == 6 else None }))
@ -147,4 +150,3 @@ elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4:
else: else:
print("Invalid command-line arguments.") print("Invalid command-line arguments.")
sys.exit(1) sys.exit(1)

View File

@ -11,17 +11,19 @@
# service mailinabox start # when done debugging, start it up again # service mailinabox start # when done debugging, start it up again
import os, os.path, re, json, time import os, os.path, re, json, time
import multiprocessing.pool, subprocess import multiprocessing.pool
from functools import wraps from functools import wraps
from flask import Flask, request, render_template, abort, Response, send_from_directory, make_response from flask import Flask, request, render_template, Response, send_from_directory, make_response
import auth, utils import auth, utils
from mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, remove_mail_user from mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, remove_mail_user
from mailconfig import get_mail_user_privileges, add_remove_mail_user_privilege from mailconfig import get_mail_user_privileges, add_remove_mail_user_privilege
from mailconfig import get_mail_aliases, get_mail_aliases_ex, get_mail_domains, add_mail_alias, remove_mail_alias from mailconfig import get_mail_aliases, get_mail_aliases_ex, get_mail_domains, add_mail_alias, remove_mail_alias
from mailconfig import get_mail_quota, set_mail_quota
from mfa import get_public_mfa_state, provision_totp, validate_totp_secret, enable_mfa, disable_mfa from mfa import get_public_mfa_state, provision_totp, validate_totp_secret, enable_mfa, disable_mfa
import contextlib
env = utils.load_environment() env = utils.load_environment()
@ -29,14 +31,12 @@ auth_service = auth.AuthService()
# We may deploy via a symbolic link, which confuses flask's template finding. # We may deploy via a symbolic link, which confuses flask's template finding.
me = __file__ me = __file__
try: with contextlib.suppress(OSError):
me = os.readlink(__file__) me = os.readlink(__file__)
except OSError:
pass
# for generating CSRs we need a list of country codes # for generating CSRs we need a list of country codes
csr_country_codes = [] csr_country_codes = []
with open(os.path.join(os.path.dirname(me), "csr_country_codes.tsv")) as f: with open(os.path.join(os.path.dirname(me), "csr_country_codes.tsv"), encoding="utf-8") as f:
for line in f: for line in f:
if line.strip() == "" or line.startswith("#"): continue if line.strip() == "" or line.startswith("#"): continue
code, name = line.strip().split("\t")[0:2] code, name = line.strip().split("\t")[0:2]
@ -80,7 +80,7 @@ def authorized_personnel_only(viewfunc):
# Not authorized. Return a 401 (send auth) and a prompt to authorize by default. # Not authorized. Return a 401 (send auth) and a prompt to authorize by default.
status = 401 status = 401
headers = { headers = {
'WWW-Authenticate': 'Basic realm="{0}"'.format(auth_service.auth_realm), 'WWW-Authenticate': f'Basic realm="{auth_service.auth_realm}"',
'X-Reason': error, 'X-Reason': error,
} }
@ -90,10 +90,9 @@ def authorized_personnel_only(viewfunc):
status = 403 status = 403
headers = None headers = None
if request.headers.get('Accept') in (None, "", "*/*"): if request.headers.get('Accept') in {None, "", "*/*"}:
# Return plain text output. # Return plain text output.
return Response(error+"\n", status=status, mimetype='text/plain', headers=headers) return Response(error+"\n", status=status, mimetype='text/plain', headers=headers)
else:
# Return JSON output. # Return JSON output.
return Response(json.dumps({ return Response(json.dumps({
"status": "error", "status": "error",
@ -148,7 +147,6 @@ def login():
"status": "missing-totp-token", "status": "missing-totp-token",
"reason": str(e), "reason": str(e),
}) })
else:
# Log the failed login # Log the failed login
log_failed_login(request) log_failed_login(request)
return json_response({ return json_response({
@ -164,7 +162,7 @@ def login():
"api_key": auth_service.create_session_key(email, env, type='login'), "api_key": auth_service.create_session_key(email, env, type='login'),
} }
app.logger.info("New login session created for {}".format(email)) app.logger.info("New login session created for %s", email)
# Return. # Return.
return json_response(resp) return json_response(resp)
@ -173,8 +171,8 @@ def login():
def logout(): def logout():
try: try:
email, _ = auth_service.authenticate(request, env, logout=True) email, _ = auth_service.authenticate(request, env, logout=True)
app.logger.info("{} logged out".format(email)) app.logger.info("%s logged out", email)
except ValueError as e: except ValueError:
pass pass
finally: finally:
return json_response({ "status": "ok" }) return json_response({ "status": "ok" })
@ -186,14 +184,36 @@ def logout():
def mail_users(): def mail_users():
if request.args.get("format", "") == "json": if request.args.get("format", "") == "json":
return json_response(get_mail_users_ex(env, with_archived=True)) return json_response(get_mail_users_ex(env, with_archived=True))
else:
return "".join(x+"\n" for x in get_mail_users(env)) return "".join(x+"\n" for x in get_mail_users(env))
@app.route('/mail/users/add', methods=['POST']) @app.route('/mail/users/add', methods=['POST'])
@authorized_personnel_only @authorized_personnel_only
def mail_users_add(): def mail_users_add():
quota = request.form.get('quota', '0')
try: try:
return add_mail_user(request.form.get('email', ''), request.form.get('password', ''), request.form.get('privileges', ''), env) return add_mail_user(request.form.get('email', ''), request.form.get('password', ''), request.form.get('privileges', ''), quota, env)
except ValueError as e:
return (str(e), 400)
@app.route('/mail/users/quota', methods=['GET'])
@authorized_personnel_only
def get_mail_users_quota():
email = request.values.get('email', '')
quota = get_mail_quota(email, env)
if request.values.get('text'):
return quota
return json_response({
"email": email,
"quota": quota
})
@app.route('/mail/users/quota', methods=['POST'])
@authorized_personnel_only
def mail_users_quota():
try:
return set_mail_quota(request.form.get('email', ''), request.form.get('quota'), env)
except ValueError as e: except ValueError as e:
return (str(e), 400) return (str(e), 400)
@ -234,7 +254,6 @@ def mail_user_privs_remove():
def mail_aliases(): def mail_aliases():
if request.args.get("format", "") == "json": if request.args.get("format", "") == "json":
return json_response(get_mail_aliases_ex(env)) return json_response(get_mail_aliases_ex(env))
else:
return "".join(address+"\t"+receivers+"\t"+(senders or "")+"\n" for address, receivers, senders, auto in get_mail_aliases(env)) return "".join(address+"\t"+receivers+"\t"+(senders or "")+"\n" for address, receivers, senders, auto in get_mail_aliases(env))
@app.route('/mail/aliases/add', methods=['POST']) @app.route('/mail/aliases/add', methods=['POST'])
@ -355,9 +374,9 @@ def dns_set_record(qname, rtype="A"):
# Get the existing records matching the qname and rtype. # Get the existing records matching the qname and rtype.
return dns_get_records(qname, rtype) return dns_get_records(qname, rtype)
elif request.method in ("POST", "PUT"): if request.method in {"POST", "PUT"}:
# There is a default value for A/AAAA records. # There is a default value for A/AAAA records.
if rtype in ("A", "AAAA") and value == "": if rtype in {"A", "AAAA"} and value == "":
value = request.environ.get("HTTP_X_FORWARDED_FOR") # normally REMOTE_ADDR but we're behind nginx as a reverse proxy value = request.environ.get("HTTP_X_FORWARDED_FOR") # normally REMOTE_ADDR but we're behind nginx as a reverse proxy
# Cannot add empty records. # Cannot add empty records.
@ -419,7 +438,7 @@ def ssl_get_status():
{ {
"domain": d["domain"], "domain": d["domain"],
"status": d["ssl_certificate"][0], "status": d["ssl_certificate"][0],
"text": d["ssl_certificate"][1] + ((" " + cant_provision[d["domain"]] if d["domain"] in cant_provision else "")) "text": d["ssl_certificate"][1] + (" " + cant_provision[d["domain"]] if d["domain"] in cant_provision else "")
} for d in domains_status ] } for d in domains_status ]
# Warn the user about domain names not hosted here because of other settings. # Warn the user about domain names not hosted here because of other settings.
@ -491,7 +510,7 @@ def totp_post_enable():
secret = request.form.get('secret') secret = request.form.get('secret')
token = request.form.get('token') token = request.form.get('token')
label = request.form.get('label') label = request.form.get('label')
if type(token) != str: if not isinstance(token, str):
return ("Bad Input", 400) return ("Bad Input", 400)
try: try:
validate_totp_secret(secret) validate_totp_secret(secret)
@ -513,7 +532,7 @@ def totp_post_disable():
return (str(e), 400) return (str(e), 400)
if result: # success if result: # success
return "OK" return "OK"
else: # error # error
return ("Invalid user or MFA id.", 400) return ("Invalid user or MFA id.", 400)
# WEB # WEB
@ -580,8 +599,7 @@ def system_status():
def show_updates(): def show_updates():
from status_checks import list_apt_updates from status_checks import list_apt_updates
return "".join( return "".join(
"%s (%s)\n" "{} ({})\n".format(p["package"], p["version"])
% (p["package"], p["version"])
for p in list_apt_updates()) for p in list_apt_updates())
@app.route('/system/update-packages', methods=["POST"]) @app.route('/system/update-packages', methods=["POST"])
@ -599,7 +617,6 @@ def needs_reboot():
from status_checks import is_reboot_needed_due_to_package_installation from status_checks import is_reboot_needed_due_to_package_installation
if is_reboot_needed_due_to_package_installation(): if is_reboot_needed_due_to_package_installation():
return json_response(True) return json_response(True)
else:
return json_response(False) return json_response(False)
@app.route('/system/reboot', methods=["POST"]) @app.route('/system/reboot', methods=["POST"])
@ -609,7 +626,6 @@ def do_reboot():
from status_checks import is_reboot_needed_due_to_package_installation from status_checks import is_reboot_needed_due_to_package_installation
if is_reboot_needed_due_to_package_installation(): if is_reboot_needed_due_to_package_installation():
return utils.shell("check_output", ["/sbin/shutdown", "-r", "now"], capture_stderr=True) return utils.shell("check_output", ["/sbin/shutdown", "-r", "now"], capture_stderr=True)
else:
return "No reboot is required, so it is not allowed." return "No reboot is required, so it is not allowed."
@ -672,8 +688,7 @@ def check_request_cookie_for_admin_access():
if not session: return False if not session: return False
privs = get_mail_user_privileges(session["email"], env) privs = get_mail_user_privileges(session["email"], env)
if not isinstance(privs, list): return False if not isinstance(privs, list): return False
if "admin" not in privs: return False return "admin" in privs
return True
def authorized_personnel_only_via_cookie(f): def authorized_personnel_only_via_cookie(f):
@wraps(f) @wraps(f)
@ -709,7 +724,7 @@ def munin_cgi(filename):
support infrastructure like spawn-fcgi. support infrastructure like spawn-fcgi.
""" """
COMMAND = 'su - munin --preserve-environment --shell=/bin/bash -c /usr/lib/munin/cgi/munin-cgi-graph' COMMAND = 'su munin --preserve-environment --shell=/bin/bash -c /usr/lib/munin/cgi/munin-cgi-graph'
# su changes user, we use the munin user here # su changes user, we use the munin user here
# --preserve-environment retains the environment, which is where Popen's `env` data is # --preserve-environment retains the environment, which is where Popen's `env` data is
# --shell=/bin/bash ensures the shell used is bash # --shell=/bin/bash ensures the shell used is bash
@ -721,7 +736,7 @@ def munin_cgi(filename):
query_str = request.query_string.decode("utf-8", 'ignore') query_str = request.query_string.decode("utf-8", 'ignore')
env = {'PATH_INFO': '/%s/' % filename, 'REQUEST_METHOD': 'GET', 'QUERY_STRING': query_str} env = {'PATH_INFO': f'/{filename}/', 'REQUEST_METHOD': 'GET', 'QUERY_STRING': query_str}
code, binout = utils.shell('check_output', code, binout = utils.shell('check_output',
COMMAND.split(" ", 5), COMMAND.split(" ", 5),
# Using a maxsplit of 5 keeps the last arguments together # Using a maxsplit of 5 keeps the last arguments together
@ -751,14 +766,11 @@ def log_failed_login(request):
# During setup we call the management interface directly to determine the user # During setup we call the management interface directly to determine the user
# status. So we can't always use X-Forwarded-For because during setup that header # status. So we can't always use X-Forwarded-For because during setup that header
# will not be present. # will not be present.
if request.headers.getlist("X-Forwarded-For"): ip = request.headers.getlist("X-Forwarded-For")[0] if request.headers.getlist("X-Forwarded-For") else request.remote_addr
ip = request.headers.getlist("X-Forwarded-For")[0]
else:
ip = request.remote_addr
# We need to add a timestamp to the log message, otherwise /dev/log will eat the "duplicate" # We need to add a timestamp to the log message, otherwise /dev/log will eat the "duplicate"
# message. # message.
app.logger.warning( "Mail-in-a-Box Management Daemon: Failed login attempt from ip %s - timestamp %s" % (ip, time.time())) app.logger.warning("Mail-in-a-Box Management Daemon: Failed login attempt from ip %s - timestamp %s", ip, time.time())
# APP # APP

View File

@ -11,7 +11,7 @@ export LC_TYPE=en_US.UTF-8
# On Mondays, i.e. once a week, send the administrator a report of total emails # On Mondays, i.e. once a week, send the administrator a report of total emails
# sent and received so the admin might notice server abuse. # sent and received so the admin might notice server abuse.
if [ `date "+%u"` -eq 1 ]; then if [ "$(date "+%u")" -eq 1 ]; then
management/mail_log.py -t week | management/email_administrator.py "Mail-in-a-Box Usage Report" management/mail_log.py -t week | management/email_administrator.py "Mail-in-a-Box Usage Report"
fi fi

View File

@ -4,19 +4,19 @@
# and mail aliases and restarts nsd. # and mail aliases and restarts nsd.
######################################################################## ########################################################################
import sys, os, os.path, urllib.parse, datetime, re, hashlib, base64 import sys, os, os.path, datetime, re, hashlib, base64
import ipaddress import ipaddress
import rtyaml import rtyaml
import dns.resolver import dns.resolver
from utils import shell, load_env_vars_from_file, safe_domain_name, sort_domains from utils import shell, load_env_vars_from_file, safe_domain_name, sort_domains, get_ssh_port
from ssl_certificates import get_ssl_certificates, check_certificate from ssl_certificates import get_ssl_certificates, check_certificate
# From https://stackoverflow.com/questions/3026957/how-to-validate-a-domain-name-using-regex-php/16491074#16491074 # From https://stackoverflow.com/questions/3026957/how-to-validate-a-domain-name-using-regex-php/16491074#16491074
# This regular expression matches domain names according to RFCs, it also accepts fqdn with an leading dot, # This regular expression matches domain names according to RFCs, it also accepts fqdn with an leading dot,
# underscores, as well as asteriks which are allowed in domain names but not hostnames (i.e. allowed in # underscores, as well as asterisks which are allowed in domain names but not hostnames (i.e. allowed in
# DNS but not in URLs), which are common in certain record types like for DKIM. # DNS but not in URLs), which are common in certain record types like for DKIM.
DOMAIN_RE = "^(?!\-)(?:[*][.])?(?:[a-zA-Z\d\-_]{0,62}[a-zA-Z\d_]\.){1,126}(?!\d+)[a-zA-Z\d_]{1,63}(\.?)$" DOMAIN_RE = r"^(?!\-)(?:[*][.])?(?:[a-zA-Z\d\-_]{0,62}[a-zA-Z\d_]\.){1,126}(?!\d+)[a-zA-Z\d_]{1,63}(\.?)$"
def get_dns_domains(env): def get_dns_domains(env):
# Add all domain names in use by email users and mail aliases, any # Add all domain names in use by email users and mail aliases, any
@ -38,7 +38,7 @@ def get_dns_zones(env):
# Exclude domains that are subdomains of other domains we know. Proceed # Exclude domains that are subdomains of other domains we know. Proceed
# by looking at shorter domains first. # by looking at shorter domains first.
zone_domains = set() zone_domains = set()
for domain in sorted(domains, key=lambda d : len(d)): for domain in sorted(domains, key=len):
for d in zone_domains: for d in zone_domains:
if domain.endswith("." + d): if domain.endswith("." + d):
# We found a parent domain already in the list. # We found a parent domain already in the list.
@ -48,9 +48,7 @@ def get_dns_zones(env):
zone_domains.add(domain) zone_domains.add(domain)
# Make a nice and safe filename for each domain. # Make a nice and safe filename for each domain.
zonefiles = [] zonefiles = [[domain, safe_domain_name(domain) + ".txt"] for domain in zone_domains]
for domain in zone_domains:
zonefiles.append([domain, safe_domain_name(domain) + ".txt"])
# Sort the list so that the order is nice and so that nsd.conf has a # Sort the list so that the order is nice and so that nsd.conf has a
# stable order so we don't rewrite the file & restart the service # stable order so we don't rewrite the file & restart the service
@ -125,7 +123,6 @@ def do_dns_update(env, force=False):
if len(updated_domains) == 0: if len(updated_domains) == 0:
# if nothing was updated (except maybe OpenDKIM's files), don't show any output # if nothing was updated (except maybe OpenDKIM's files), don't show any output
return "" return ""
else:
return "updated DNS: " + ",".join(updated_domains) + "\n" return "updated DNS: " + ",".join(updated_domains) + "\n"
######################################################################## ########################################################################
@ -188,14 +185,13 @@ def build_zone(domain, domain_properties, additional_records, env, is_zone=True)
# is managed outside of the box. # is managed outside of the box.
if is_zone: if is_zone:
# Obligatory NS record to ns1.PRIMARY_HOSTNAME. # Obligatory NS record to ns1.PRIMARY_HOSTNAME.
records.append((None, "NS", "ns1.%s." % env["PRIMARY_HOSTNAME"], False)) records.append((None, "NS", "ns1.{}.".format(env["PRIMARY_HOSTNAME"]), False))
# NS record to ns2.PRIMARY_HOSTNAME or whatever the user overrides. # NS record to ns2.PRIMARY_HOSTNAME or whatever the user overrides.
# User may provide one or more additional nameservers # User may provide one or more additional nameservers
secondary_ns_list = get_secondary_dns(additional_records, mode="NS") \ secondary_ns_list = get_secondary_dns(additional_records, mode="NS") \
or ["ns2." + env["PRIMARY_HOSTNAME"]] or ["ns2." + env["PRIMARY_HOSTNAME"]]
for secondary_ns in secondary_ns_list: records.extend((None, "NS", secondary_ns+'.', False) for secondary_ns in secondary_ns_list)
records.append((None, "NS", secondary_ns+'.', False))
# In PRIMARY_HOSTNAME... # In PRIMARY_HOSTNAME...
@ -212,8 +208,7 @@ def build_zone(domain, domain_properties, additional_records, env, is_zone=True)
records.append(("_443._tcp", "TLSA", build_tlsa_record(env), "Optional. When DNSSEC is enabled, provides out-of-band HTTPS certificate validation for a few web clients that support it.")) records.append(("_443._tcp", "TLSA", build_tlsa_record(env), "Optional. When DNSSEC is enabled, provides out-of-band HTTPS certificate validation for a few web clients that support it."))
# Add a SSHFP records to help SSH key validation. One per available SSH key on this system. # Add a SSHFP records to help SSH key validation. One per available SSH key on this system.
for value in build_sshfp_records(): records.extend((None, "SSHFP", value, "Optional. Provides an out-of-band method for verifying an SSH key before connecting. Use 'VerifyHostKeyDNS yes' (or 'VerifyHostKeyDNS ask') when connecting with ssh.") for value in build_sshfp_records())
records.append((None, "SSHFP", value, "Optional. Provides an out-of-band method for verifying an SSH key before connecting. Use 'VerifyHostKeyDNS yes' (or 'VerifyHostKeyDNS ask') when connecting with ssh."))
# Add DNS records for any subdomains of this domain. We should not have a zone for # Add DNS records for any subdomains of this domain. We should not have a zone for
# both a domain and one of its subdomains. # both a domain and one of its subdomains.
@ -223,7 +218,7 @@ def build_zone(domain, domain_properties, additional_records, env, is_zone=True)
subdomain_qname = subdomain[0:-len("." + domain)] subdomain_qname = subdomain[0:-len("." + domain)]
subzone = build_zone(subdomain, domain_properties, additional_records, env, is_zone=False) subzone = build_zone(subdomain, domain_properties, additional_records, env, is_zone=False)
for child_qname, child_rtype, child_value, child_explanation in subzone: for child_qname, child_rtype, child_value, child_explanation in subzone:
if child_qname == None: if child_qname is None:
child_qname = subdomain_qname child_qname = subdomain_qname
else: else:
child_qname += "." + subdomain_qname child_qname += "." + subdomain_qname
@ -231,10 +226,7 @@ def build_zone(domain, domain_properties, additional_records, env, is_zone=True)
has_rec_base = list(records) # clone current state has_rec_base = list(records) # clone current state
def has_rec(qname, rtype, prefix=None): def has_rec(qname, rtype, prefix=None):
for rec in has_rec_base: return any(rec[0] == qname and rec[1] == rtype and (prefix is None or rec[2].startswith(prefix)) for rec in has_rec_base)
if rec[0] == qname and rec[1] == rtype and (prefix is None or rec[2].startswith(prefix)):
return True
return False
# The user may set other records that don't conflict with our settings. # The user may set other records that don't conflict with our settings.
# Don't put any TXT records above this line, or it'll prevent any custom TXT records. # Don't put any TXT records above this line, or it'll prevent any custom TXT records.
@ -260,16 +252,16 @@ def build_zone(domain, domain_properties, additional_records, env, is_zone=True)
# was set. So set has_rec_base to a clone of the current set of DNS settings, and don't update # was set. So set has_rec_base to a clone of the current set of DNS settings, and don't update
# during this process. # during this process.
has_rec_base = list(records) has_rec_base = list(records)
a_expl = "Required. May have a different value. Sets the IP address that %s resolves to for web hosting and other services besides mail. The A record must be present but its value does not affect mail delivery." % domain a_expl = f"Required. May have a different value. Sets the IP address that {domain} resolves to for web hosting and other services besides mail. The A record must be present but its value does not affect mail delivery."
if domain_properties[domain]["auto"]: if domain_properties[domain]["auto"]:
if domain.startswith("ns1.") or domain.startswith("ns2."): a_expl = False # omit from 'External DNS' page since this only applies if box is its own DNS server if domain.startswith(("ns1.", "ns2.")): a_expl = False # omit from 'External DNS' page since this only applies if box is its own DNS server
if domain.startswith("www."): a_expl = "Optional. Sets the IP address that %s resolves to so that the box can provide a redirect to the parent domain." % domain if domain.startswith("www."): a_expl = f"Optional. Sets the IP address that {domain} resolves to so that the box can provide a redirect to the parent domain."
if domain.startswith("mta-sts."): a_expl = "Optional. MTA-STS Policy Host serving /.well-known/mta-sts.txt." if domain.startswith("mta-sts."): a_expl = "Optional. MTA-STS Policy Host serving /.well-known/mta-sts.txt."
if domain.startswith("autoconfig."): a_expl = "Provides email configuration autodiscovery support for Thunderbird Autoconfig." if domain.startswith("autoconfig."): a_expl = "Provides email configuration autodiscovery support for Thunderbird Autoconfig."
if domain.startswith("autodiscover."): a_expl = "Provides email configuration autodiscovery support for Z-Push ActiveSync Autodiscover." if domain.startswith("autodiscover."): a_expl = "Provides email configuration autodiscovery support for Z-Push ActiveSync Autodiscover."
defaults = [ defaults = [
(None, "A", env["PUBLIC_IP"], a_expl), (None, "A", env["PUBLIC_IP"], a_expl),
(None, "AAAA", env.get('PUBLIC_IPV6'), "Optional. Sets the IPv6 address that %s resolves to, e.g. for web hosting. (It is not necessary for receiving mail on this domain.)" % domain), (None, "AAAA", env.get('PUBLIC_IPV6'), f"Optional. Sets the IPv6 address that {domain} resolves to, e.g. for web hosting. (It is not necessary for receiving mail on this domain.)"),
] ]
for qname, rtype, value, explanation in defaults: for qname, rtype, value, explanation in defaults:
if value is None or value.strip() == "": continue # skip IPV6 if not set if value is None or value.strip() == "": continue # skip IPV6 if not set
@ -287,27 +279,27 @@ def build_zone(domain, domain_properties, additional_records, env, is_zone=True)
if domain_properties[domain]["mail"]: if domain_properties[domain]["mail"]:
# The MX record says where email for the domain should be delivered: Here! # The MX record says where email for the domain should be delivered: Here!
if not has_rec(None, "MX", prefix="10 "): if not has_rec(None, "MX", prefix="10 "):
records.append((None, "MX", "10 %s." % env["PRIMARY_HOSTNAME"], "Required. Specifies the hostname (and priority) of the machine that handles @%s mail." % domain)) records.append((None, "MX", "10 {}.".format(env["PRIMARY_HOSTNAME"]), f"Required. Specifies the hostname (and priority) of the machine that handles @{domain} mail."))
# SPF record: Permit the box ('mx', see above) to send mail on behalf of # SPF record: Permit the box ('mx', see above) to send mail on behalf of
# the domain, and no one else. # the domain, and no one else.
# Skip if the user has set a custom SPF record. # Skip if the user has set a custom SPF record.
if not has_rec(None, "TXT", prefix="v=spf1 "): if not has_rec(None, "TXT", prefix="v=spf1 "):
records.append((None, "TXT", 'v=spf1 mx -all', "Recommended. Specifies that only the box is permitted to send @%s mail." % domain)) records.append((None, "TXT", 'v=spf1 mx -all', f"Recommended. Specifies that only the box is permitted to send @{domain} mail."))
# Append the DKIM TXT record to the zone as generated by OpenDKIM. # Append the DKIM TXT record to the zone as generated by OpenDKIM.
# Skip if the user has set a DKIM record already. # Skip if the user has set a DKIM record already.
opendkim_record_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.txt') opendkim_record_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.txt')
with open(opendkim_record_file) as orf: with open(opendkim_record_file, encoding="utf-8") as orf:
m = re.match(r'(\S+)\s+IN\s+TXT\s+\( ((?:"[^"]+"\s+)+)\)', orf.read(), re.S) m = re.match(r'(\S+)\s+IN\s+TXT\s+\( ((?:"[^"]+"\s+)+)\)', orf.read(), re.S)
val = "".join(re.findall(r'"([^"]+)"', m.group(2))) val = "".join(re.findall(r'"([^"]+)"', m.group(2)))
if not has_rec(m.group(1), "TXT", prefix="v=DKIM1; "): if not has_rec(m.group(1), "TXT", prefix="v=DKIM1; "):
records.append((m.group(1), "TXT", val, "Recommended. Provides a way for recipients to verify that this machine sent @%s mail." % domain)) records.append((m.group(1), "TXT", val, f"Recommended. Provides a way for recipients to verify that this machine sent @{domain} mail."))
# Append a DMARC record. # Append a DMARC record.
# Skip if the user has set a DMARC record already. # Skip if the user has set a DMARC record already.
if not has_rec("_dmarc", "TXT", prefix="v=DMARC1; "): if not has_rec("_dmarc", "TXT", prefix="v=DMARC1; "):
records.append(("_dmarc", "TXT", 'v=DMARC1; p=quarantine;', "Recommended. Specifies that mail that does not originate from the box but claims to be from @%s or which does not have a valid DKIM signature is suspect and should be quarantined by the recipient's mail system." % domain)) records.append(("_dmarc", "TXT", 'v=DMARC1; p=quarantine;', f"Recommended. Specifies that mail that does not originate from the box but claims to be from @{domain} or which does not have a valid DKIM signature is suspect and should be quarantined by the recipient's mail system."))
if domain_properties[domain]["user"]: if domain_properties[domain]["user"]:
# Add CardDAV/CalDAV SRV records on the non-primary hostname that points to the primary hostname # Add CardDAV/CalDAV SRV records on the non-primary hostname that points to the primary hostname
@ -364,15 +356,15 @@ def build_zone(domain, domain_properties, additional_records, env, is_zone=True)
# non-mail domain and also may include qnames from custom DNS records. # non-mail domain and also may include qnames from custom DNS records.
# Do this once at the end of generating a zone. # Do this once at the end of generating a zone.
if is_zone: if is_zone:
qnames_with_a = set(qname for (qname, rtype, value, explanation) in records if rtype in ("A", "AAAA")) qnames_with_a = {qname for (qname, rtype, value, explanation) in records if rtype in {"A", "AAAA"}}
qnames_with_mx = set(qname for (qname, rtype, value, explanation) in records if rtype == "MX") qnames_with_mx = {qname for (qname, rtype, value, explanation) in records if rtype == "MX"}
for qname in qnames_with_a - qnames_with_mx: for qname in qnames_with_a - qnames_with_mx:
# Mark this domain as not sending mail with hard-fail SPF and DMARC records. # Mark this domain as not sending mail with hard-fail SPF and DMARC records.
d = (qname+"." if qname else "") + domain d = (qname+"." if qname else "") + domain
if not has_rec(qname, "TXT", prefix="v=spf1 "): if not has_rec(qname, "TXT", prefix="v=spf1 "):
records.append((qname, "TXT", 'v=spf1 -all', "Recommended. Prevents use of this domain name for outbound mail by specifying that no servers are valid sources for mail from @%s. If you do send email from this domain name you should either override this record such that the SPF rule does allow the originating server, or, take the recommended approach and have the box handle mail for this domain (simply add any receiving alias at this domain name to make this machine treat the domain name as one of its mail domains)." % d)) records.append((qname, "TXT", 'v=spf1 -all', f"Recommended. Prevents use of this domain name for outbound mail by specifying that no servers are valid sources for mail from @{d}. If you do send email from this domain name you should either override this record such that the SPF rule does allow the originating server, or, take the recommended approach and have the box handle mail for this domain (simply add any receiving alias at this domain name to make this machine treat the domain name as one of its mail domains)."))
if not has_rec("_dmarc" + ("."+qname if qname else ""), "TXT", prefix="v=DMARC1; "): if not has_rec("_dmarc" + ("."+qname if qname else ""), "TXT", prefix="v=DMARC1; "):
records.append(("_dmarc" + ("."+qname if qname else ""), "TXT", 'v=DMARC1; p=reject;', "Recommended. Prevents use of this domain name for outbound mail by specifying that the SPF rule should be honoured for mail from @%s." % d)) records.append(("_dmarc" + ("."+qname if qname else ""), "TXT", 'v=DMARC1; p=reject;', f"Recommended. Prevents use of this domain name for outbound mail by specifying that the SPF rule should be honoured for mail from @{d}."))
# And with a null MX record (https://explained-from-first-principles.com/email/#null-mx-record) # And with a null MX record (https://explained-from-first-principles.com/email/#null-mx-record)
if not has_rec(qname, "MX"): if not has_rec(qname, "MX"):
@ -449,29 +441,24 @@ def build_sshfp_records():
# Get our local fingerprints by running ssh-keyscan. The output looks # Get our local fingerprints by running ssh-keyscan. The output looks
# like the known_hosts file: hostname, keytype, fingerprint. The order # like the known_hosts file: hostname, keytype, fingerprint. The order
# of the output is arbitrary, so sort it to prevent spurrious updates # of the output is arbitrary, so sort it to prevent spurious updates
# to the zone file (that trigger bumping the serial number). However, # to the zone file (that trigger bumping the serial number). However,
# if SSH has been configured to listen on a nonstandard port, we must # if SSH has been configured to listen on a nonstandard port, we must
# specify that port to sshkeyscan. # specify that port to sshkeyscan.
port = 22 port = get_ssh_port()
with open('/etc/ssh/sshd_config', 'r') as f:
for line in f:
s = line.rstrip().split()
if len(s) == 2 and s[0] == 'Port':
try:
port = int(s[1])
except ValueError:
pass
break
keys = shell("check_output", ["ssh-keyscan", "-t", "rsa,dsa,ecdsa,ed25519", "-p", str(port), "localhost"]) # If nothing returned, SSH is probably not installed.
if not port:
return
keys = shell("check_output", ["ssh-keyscan", "-4", "-t", "rsa,dsa,ecdsa,ed25519", "-p", str(port), "localhost"])
keys = sorted(keys.split("\n")) keys = sorted(keys.split("\n"))
for key in keys: for key in keys:
if key.strip() == "" or key[0] == "#": continue if key.strip() == "" or key[0] == "#": continue
try: try:
host, keytype, pubkey = key.split(" ") _host, keytype, pubkey = key.split(" ")
yield "%d %d ( %s )" % ( yield "%d %d ( %s )" % (
algorithm_number[keytype], algorithm_number[keytype],
2, # specifies we are using SHA-256 on next line 2, # specifies we are using SHA-256 on next line
@ -516,7 +503,7 @@ $TTL 86400 ; default time to live
zone = zone.format(domain=domain, primary_domain=env["PRIMARY_HOSTNAME"]) zone = zone.format(domain=domain, primary_domain=env["PRIMARY_HOSTNAME"])
# Add records. # Add records.
for subdomain, querytype, value, explanation in records: for subdomain, querytype, value, _explanation in records:
if subdomain: if subdomain:
zone += subdomain zone += subdomain
zone += "\tIN\t" + querytype + "\t" zone += "\tIN\t" + querytype + "\t"
@ -534,7 +521,7 @@ $TTL 86400 ; default time to live
zone += value + "\n" zone += value + "\n"
# Append a stable hash of DNSSEC signing keys in a comment. # Append a stable hash of DNSSEC signing keys in a comment.
zone += "\n; DNSSEC signing keys hash: {}\n".format(hash_dnssec_keys(domain, env)) zone += f"\n; DNSSEC signing keys hash: {hash_dnssec_keys(domain, env)}\n"
# DNSSEC requires re-signing a zone periodically. That requires # DNSSEC requires re-signing a zone periodically. That requires
# bumping the serial number even if no other records have changed. # bumping the serial number even if no other records have changed.
@ -550,7 +537,7 @@ $TTL 86400 ; default time to live
# We've signed the domain. Check if we are close to the expiration # We've signed the domain. Check if we are close to the expiration
# time of the signature. If so, we'll force a bump of the serial # time of the signature. If so, we'll force a bump of the serial
# number so we can re-sign it. # number so we can re-sign it.
with open(zonefile + ".signed") as f: with open(zonefile + ".signed", encoding="utf-8") as f:
signed_zone = f.read() signed_zone = f.read()
expiration_times = re.findall(r"\sRRSIG\s+SOA\s+\d+\s+\d+\s\d+\s+(\d{14})", signed_zone) expiration_times = re.findall(r"\sRRSIG\s+SOA\s+\d+\s+\d+\s\d+\s+(\d{14})", signed_zone)
if len(expiration_times) == 0: if len(expiration_times) == 0:
@ -569,7 +556,7 @@ $TTL 86400 ; default time to live
if os.path.exists(zonefile): if os.path.exists(zonefile):
# If the zone already exists, is different, and has a later serial number, # If the zone already exists, is different, and has a later serial number,
# increment the number. # increment the number.
with open(zonefile) as f: with open(zonefile, encoding="utf-8") as f:
existing_zone = f.read() existing_zone = f.read()
m = re.search(r"(\d+)\s*;\s*serial number", existing_zone) m = re.search(r"(\d+)\s*;\s*serial number", existing_zone)
if m: if m:
@ -593,7 +580,7 @@ $TTL 86400 ; default time to live
zone = zone.replace("__SERIAL__", serial) zone = zone.replace("__SERIAL__", serial)
# Write the zone file. # Write the zone file.
with open(zonefile, "w") as f: with open(zonefile, "w", encoding="utf-8") as f:
f.write(zone) f.write(zone)
return True # file is updated return True # file is updated
@ -603,10 +590,11 @@ def get_dns_zonefile(zone, env):
if zone == domain: if zone == domain:
break break
else: else:
raise ValueError("%s is not a domain name that corresponds to a zone." % zone) msg = f"{zone} is not a domain name that corresponds to a zone."
raise ValueError(msg)
nsd_zonefile = "/etc/nsd/zones/" + fn nsd_zonefile = "/etc/nsd/zones/" + fn
with open(nsd_zonefile, "r") as f: with open(nsd_zonefile, encoding="utf-8") as f:
return f.read() return f.read()
######################################################################## ########################################################################
@ -618,29 +606,29 @@ def write_nsd_conf(zonefiles, additional_records, env):
# Append the zones. # Append the zones.
for domain, zonefile in zonefiles: for domain, zonefile in zonefiles:
nsdconf += """ nsdconf += f"""
zone: zone:
name: %s name: {domain}
zonefile: %s zonefile: {zonefile}
""" % (domain, zonefile) """
# If custom secondary nameservers have been set, allow zone transfers # If custom secondary nameservers have been set, allow zone transfers
# and, if not a subnet, notifies to them. # and, if not a subnet, notifies to them.
for ipaddr in get_secondary_dns(additional_records, mode="xfr"): for ipaddr in get_secondary_dns(additional_records, mode="xfr"):
if "/" not in ipaddr: if "/" not in ipaddr:
nsdconf += "\n\tnotify: %s NOKEY" % (ipaddr) nsdconf += f"\n\tnotify: {ipaddr} NOKEY"
nsdconf += "\n\tprovide-xfr: %s NOKEY\n" % (ipaddr) nsdconf += f"\n\tprovide-xfr: {ipaddr} NOKEY\n"
# Check if the file is changing. If it isn't changing, # Check if the file is changing. If it isn't changing,
# return False to flag that no change was made. # return False to flag that no change was made.
if os.path.exists(nsd_conf_file): if os.path.exists(nsd_conf_file):
with open(nsd_conf_file) as f: with open(nsd_conf_file, encoding="utf-8") as f:
if f.read() == nsdconf: if f.read() == nsdconf:
return False return False
# Write out new contents and return True to signal that # Write out new contents and return True to signal that
# configuration changed. # configuration changed.
with open(nsd_conf_file, "w") as f: with open(nsd_conf_file, "w", encoding="utf-8") as f:
f.write(nsdconf) f.write(nsdconf)
return True return True
@ -674,9 +662,8 @@ def hash_dnssec_keys(domain, env):
keydata = [] keydata = []
for keytype, keyfn in sorted(find_dnssec_signing_keys(domain, env)): for keytype, keyfn in sorted(find_dnssec_signing_keys(domain, env)):
oldkeyfn = os.path.join(env['STORAGE_ROOT'], 'dns/dnssec', keyfn + ".private") oldkeyfn = os.path.join(env['STORAGE_ROOT'], 'dns/dnssec', keyfn + ".private")
keydata.append(keytype) keydata.extend((keytype, keyfn))
keydata.append(keyfn) with open(oldkeyfn, encoding="utf-8") as fr:
with open(oldkeyfn, "r") as fr:
keydata.append( fr.read() ) keydata.append( fr.read() )
keydata = "".join(keydata).encode("utf8") keydata = "".join(keydata).encode("utf8")
return hashlib.sha1(keydata).hexdigest() return hashlib.sha1(keydata).hexdigest()
@ -704,12 +691,12 @@ def sign_zone(domain, zonefile, env):
# Use os.umask and open().write() to securely create a copy that only # Use os.umask and open().write() to securely create a copy that only
# we (root) can read. # we (root) can read.
oldkeyfn = os.path.join(env['STORAGE_ROOT'], 'dns/dnssec', keyfn + ext) oldkeyfn = os.path.join(env['STORAGE_ROOT'], 'dns/dnssec', keyfn + ext)
with open(oldkeyfn, "r") as fr: with open(oldkeyfn, encoding="utf-8") as fr:
keydata = fr.read() keydata = fr.read()
keydata = keydata.replace("_domain_", domain) keydata = keydata.replace("_domain_", domain)
prev_umask = os.umask(0o77) # ensure written file is not world-readable prev_umask = os.umask(0o77) # ensure written file is not world-readable
try: try:
with open(newkeyfn + ext, "w") as fw: with open(newkeyfn + ext, "w", encoding="utf-8") as fw:
fw.write(keydata) fw.write(keydata)
finally: finally:
os.umask(prev_umask) # other files we write should be world-readable os.umask(prev_umask) # other files we write should be world-readable
@ -729,9 +716,9 @@ def sign_zone(domain, zonefile, env):
# zonefile to sign # zonefile to sign
"/etc/nsd/zones/" + zonefile, "/etc/nsd/zones/" + zonefile,
]
# keys to sign with (order doesn't matter -- it'll figure it out) # keys to sign with (order doesn't matter -- it'll figure it out)
+ all_keys *all_keys
]
) )
# Create a DS record based on the patched-up key files. The DS record is specific to the # Create a DS record based on the patched-up key files. The DS record is specific to the
@ -743,7 +730,7 @@ def sign_zone(domain, zonefile, env):
# be used, so we'll pre-generate all for each key. One DS record per line. Only one # be used, so we'll pre-generate all for each key. One DS record per line. Only one
# needs to actually be deployed at the registrar. We'll select the preferred one # needs to actually be deployed at the registrar. We'll select the preferred one
# in the status checks. # in the status checks.
with open("/etc/nsd/zones/" + zonefile + ".ds", "w") as f: with open("/etc/nsd/zones/" + zonefile + ".ds", "w", encoding="utf-8") as f:
for key in ksk_keys: for key in ksk_keys:
for digest_type in ('1', '2', '4'): for digest_type in ('1', '2', '4'):
rr_ds = shell('check_output', ["/usr/bin/ldns-key2ds", rr_ds = shell('check_output', ["/usr/bin/ldns-key2ds",
@ -780,7 +767,7 @@ def write_opendkim_tables(domains, env):
# So we must have a separate KeyTable entry for each domain. # So we must have a separate KeyTable entry for each domain.
"SigningTable": "SigningTable":
"".join( "".join(
"*@{domain} {domain}\n".format(domain=domain) f"*@{domain} {domain}\n"
for domain in domains for domain in domains
), ),
@ -789,7 +776,7 @@ def write_opendkim_tables(domains, env):
# signing domain must match the sender's From: domain. # signing domain must match the sender's From: domain.
"KeyTable": "KeyTable":
"".join( "".join(
"{domain} {domain}:mail:{key_file}\n".format(domain=domain, key_file=opendkim_key_file) f"{domain} {domain}:mail:{opendkim_key_file}\n"
for domain in domains for domain in domains
), ),
} }
@ -798,12 +785,12 @@ def write_opendkim_tables(domains, env):
for filename, content in config.items(): for filename, content in config.items():
# Don't write the file if it doesn't need an update. # Don't write the file if it doesn't need an update.
if os.path.exists("/etc/opendkim/" + filename): if os.path.exists("/etc/opendkim/" + filename):
with open("/etc/opendkim/" + filename) as f: with open("/etc/opendkim/" + filename, encoding="utf-8") as f:
if f.read() == content: if f.read() == content:
continue continue
# The contents needs to change. # The contents needs to change.
with open("/etc/opendkim/" + filename, "w") as f: with open("/etc/opendkim/" + filename, "w", encoding="utf-8") as f:
f.write(content) f.write(content)
did_update = True did_update = True
@ -815,8 +802,9 @@ def write_opendkim_tables(domains, env):
def get_custom_dns_config(env, only_real_records=False): def get_custom_dns_config(env, only_real_records=False):
try: try:
custom_dns = rtyaml.load(open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml'))) with open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml'), encoding="utf-8") as f:
if not isinstance(custom_dns, dict): raise ValueError() # caught below custom_dns = rtyaml.load(f)
if not isinstance(custom_dns, dict): raise ValueError # caught below
except: except:
return [ ] return [ ]
@ -834,7 +822,7 @@ def get_custom_dns_config(env, only_real_records=False):
# No other type of data is allowed. # No other type of data is allowed.
else: else:
raise ValueError() raise ValueError
for rtype, value2 in values: for rtype, value2 in values:
if isinstance(value2, str): if isinstance(value2, str):
@ -844,7 +832,7 @@ def get_custom_dns_config(env, only_real_records=False):
yield (qname, rtype, value3) yield (qname, rtype, value3)
# No other type of data is allowed. # No other type of data is allowed.
else: else:
raise ValueError() raise ValueError
def filter_custom_records(domain, custom_dns_iter): def filter_custom_records(domain, custom_dns_iter):
for qname, rtype, value in custom_dns_iter: for qname, rtype, value in custom_dns_iter:
@ -860,10 +848,7 @@ def filter_custom_records(domain, custom_dns_iter):
# our short form (None => domain, or a relative QNAME) if # our short form (None => domain, or a relative QNAME) if
# domain is not None. # domain is not None.
if domain is not None: if domain is not None:
if qname == domain: qname = None if qname == domain else qname[0:len(qname) - len("." + domain)]
qname = None
else:
qname = qname[0:len(qname)-len("." + domain)]
yield (qname, rtype, value) yield (qname, rtype, value)
@ -899,12 +884,12 @@ def write_custom_dns_config(config, env):
# Write. # Write.
config_yaml = rtyaml.dump(dns) config_yaml = rtyaml.dump(dns)
with open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml'), "w") as f: with open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml'), "w", encoding="utf-8") as f:
f.write(config_yaml) f.write(config_yaml)
def set_custom_dns_record(qname, rtype, value, action, env): def set_custom_dns_record(qname, rtype, value, action, env):
# validate qname # validate qname
for zone, fn in get_dns_zones(env): for zone, _fn in get_dns_zones(env):
# It must match a zone apex or be a subdomain of a zone # It must match a zone apex or be a subdomain of a zone
# that we are otherwise hosting. # that we are otherwise hosting.
if qname == zone or qname.endswith("."+zone): if qname == zone or qname.endswith("."+zone):
@ -912,34 +897,39 @@ def set_custom_dns_record(qname, rtype, value, action, env):
else: else:
# No match. # No match.
if qname != "_secondary_nameserver": if qname != "_secondary_nameserver":
raise ValueError("%s is not a domain name or a subdomain of a domain name managed by this box." % qname) msg = f"{qname} is not a domain name or a subdomain of a domain name managed by this box."
raise ValueError(msg)
# validate rtype # validate rtype
rtype = rtype.upper() rtype = rtype.upper()
if value is not None and qname != "_secondary_nameserver": if value is not None and qname != "_secondary_nameserver":
if not re.search(DOMAIN_RE, qname): if not re.search(DOMAIN_RE, qname):
raise ValueError("Invalid name.") msg = "Invalid name."
raise ValueError(msg)
if rtype in ("A", "AAAA"): if rtype in {"A", "AAAA"}:
if value != "local": # "local" is a special flag for us if value != "local": # "local" is a special flag for us
v = ipaddress.ip_address(value) # raises a ValueError if there's a problem v = ipaddress.ip_address(value) # raises a ValueError if there's a problem
if rtype == "A" and not isinstance(v, ipaddress.IPv4Address): raise ValueError("That's an IPv6 address.") if rtype == "A" and not isinstance(v, ipaddress.IPv4Address): raise ValueError("That's an IPv6 address.")
if rtype == "AAAA" and not isinstance(v, ipaddress.IPv6Address): raise ValueError("That's an IPv4 address.") if rtype == "AAAA" and not isinstance(v, ipaddress.IPv6Address): raise ValueError("That's an IPv4 address.")
elif rtype in ("CNAME", "NS"): elif rtype in {"CNAME", "NS"}:
if rtype == "NS" and qname == zone: if rtype == "NS" and qname == zone:
raise ValueError("NS records can only be set for subdomains.") msg = "NS records can only be set for subdomains."
raise ValueError(msg)
# ensure value has a trailing dot # ensure value has a trailing dot
if not value.endswith("."): if not value.endswith("."):
value = value + "." value += "."
if not re.search(DOMAIN_RE, value): if not re.search(DOMAIN_RE, value):
raise ValueError("Invalid value.") msg = "Invalid value."
elif rtype in ("CNAME", "TXT", "SRV", "MX", "SSHFP", "CAA"): raise ValueError(msg)
elif rtype in {"CNAME", "TXT", "SRV", "MX", "SSHFP", "CAA"}:
# anything goes # anything goes
pass pass
else: else:
raise ValueError("Unknown record type '%s'." % rtype) msg = f"Unknown record type '{rtype}'."
raise ValueError(msg)
# load existing config # load existing config
config = list(get_custom_dns_config(env)) config = list(get_custom_dns_config(env))
@ -968,7 +958,7 @@ def set_custom_dns_record(qname, rtype, value, action, env):
# Drop this record. # Drop this record.
made_change = True made_change = True
continue continue
if value == None and (_qname, _rtype) == (qname, rtype): if value is None and (_qname, _rtype) == (qname, rtype):
# Drop all qname-rtype records. # Drop all qname-rtype records.
made_change = True made_change = True
continue continue
@ -978,7 +968,7 @@ def set_custom_dns_record(qname, rtype, value, action, env):
# Preserve this record. # Preserve this record.
newconfig.append((_qname, _rtype, _value)) newconfig.append((_qname, _rtype, _value))
if action in ("add", "set") and needs_add and value is not None: if action in {"add", "set"} and needs_add and value is not None:
newconfig.append((qname, rtype, value)) newconfig.append((qname, rtype, value))
made_change = True made_change = True
@ -992,36 +982,45 @@ def set_custom_dns_record(qname, rtype, value, action, env):
def get_secondary_dns(custom_dns, mode=None): def get_secondary_dns(custom_dns, mode=None):
resolver = dns.resolver.get_default_resolver() resolver = dns.resolver.get_default_resolver()
resolver.timeout = 10 resolver.timeout = 10
resolver.lifetime = 10
values = [] values = []
for qname, rtype, value in custom_dns: for qname, _rtype, value in custom_dns:
if qname != '_secondary_nameserver': continue if qname != '_secondary_nameserver': continue
for hostname in value.split(" "): for hostname in value.split(" "):
hostname = hostname.strip() hostname = hostname.strip()
if mode == None: if mode is None:
# Just return the setting. # Just return the setting.
values.append(hostname) values.append(hostname)
continue continue
# This is a hostname. Before including in zone xfr lines, # If the entry starts with "xfr:" only include it in the zone transfer settings.
# resolve to an IP address. Otherwise just return the hostname. if hostname.startswith("xfr:"):
if mode != "xfr": continue
hostname = hostname[4:]
# If is a hostname, before including in zone xfr lines,
# resolve to an IP address.
# It may not resolve to IPv6, so don't throw an exception if it # It may not resolve to IPv6, so don't throw an exception if it
# doesn't. # doesn't. Skip the entry if there is a DNS error.
if not hostname.startswith("xfr:"):
if mode == "xfr": if mode == "xfr":
try:
ipaddress.ip_interface(hostname) # test if it's an IP address or CIDR notation
values.append(hostname)
except ValueError:
try:
response = dns.resolver.resolve(hostname+'.', "A", raise_on_no_answer=False) response = dns.resolver.resolve(hostname+'.', "A", raise_on_no_answer=False)
values.extend(map(str, response)) values.extend(map(str, response))
except dns.exception.DNSException:
pass
try:
response = dns.resolver.resolve(hostname+'.', "AAAA", raise_on_no_answer=False) response = dns.resolver.resolve(hostname+'.', "AAAA", raise_on_no_answer=False)
values.extend(map(str, response)) values.extend(map(str, response))
continue except dns.exception.DNSException:
values.append(hostname) pass
# This is a zone-xfer-only IP address. Do not return if else:
# we're querying for NS record hostnames. Only return if values.append(hostname)
# we're querying for zone xfer IP addresses - return the
# IP address.
elif mode == "xfr":
values.append(hostname[4:])
return values return values
@ -1030,25 +1029,29 @@ def set_secondary_dns(hostnames, env):
# Validate that all hostnames are valid and that all zone-xfer IP addresses are valid. # Validate that all hostnames are valid and that all zone-xfer IP addresses are valid.
resolver = dns.resolver.get_default_resolver() resolver = dns.resolver.get_default_resolver()
resolver.timeout = 5 resolver.timeout = 5
resolver.lifetime = 5
for item in hostnames: for item in hostnames:
if not item.startswith("xfr:"): if not item.startswith("xfr:"):
# Resolve hostname. # Resolve hostname.
try: try:
response = resolver.resolve(item, "A") resolver.resolve(item, "A")
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.Timeout):
try: try:
response = resolver.resolve(item, "AAAA") resolver.resolve(item, "AAAA")
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.Timeout):
raise ValueError("Could not resolve the IP address of %s." % item) msg = f"Could not resolve the IP address of {item}."
raise ValueError(msg)
else: else:
# Validate IP address. # Validate IP address.
try: try:
if "/" in item[4:]: if "/" in item[4:]:
v = ipaddress.ip_network(item[4:]) # raises a ValueError if there's a problem ipaddress.ip_network(item[4:]) # raises a ValueError if there's a problem
else: else:
v = ipaddress.ip_address(item[4:]) # raises a ValueError if there's a problem ipaddress.ip_address(item[4:]) # raises a ValueError if there's a problem
except ValueError: except ValueError:
raise ValueError("'%s' is not an IPv4 or IPv6 address or subnet." % item[4:]) msg = f"'{item[4:]}' is not an IPv4 or IPv6 address or subnet."
raise ValueError(msg)
# Set. # Set.
set_custom_dns_record("_secondary_nameserver", "A", " ".join(hostnames), "set", env) set_custom_dns_record("_secondary_nameserver", "A", " ".join(hostnames), "set", env)
@ -1064,14 +1067,13 @@ def get_custom_dns_records(custom_dns, qname, rtype):
for qname1, rtype1, value in custom_dns: for qname1, rtype1, value in custom_dns:
if qname1 == qname and rtype1 == rtype: if qname1 == qname and rtype1 == rtype:
yield value yield value
return None
######################################################################## ########################################################################
def build_recommended_dns(env): def build_recommended_dns(env):
ret = [] ret = []
for (domain, zonefile, records) in build_zones(env): for (domain, _zonefile, records) in build_zones(env):
# remove records that we don't dislay # remove records that we don't display
records = [r for r in records if r[3] is not False] records = [r for r in records if r[3] is not False]
# put Required at the top, then Recommended, then everythiing else # put Required at the top, then Recommended, then everythiing else
@ -1079,10 +1081,7 @@ def build_recommended_dns(env):
# expand qnames # expand qnames
for i in range(len(records)): for i in range(len(records)):
if records[i][0] == None: qname = domain if records[i][0] is None else records[i][0] + "." + domain
qname = domain
else:
qname = records[i][0] + "." + domain
records[i] = { records[i] = {
"qname": qname, "qname": qname,
@ -1100,8 +1099,10 @@ if __name__ == "__main__":
env = load_environment() env = load_environment()
if sys.argv[-1] == "--lint": if sys.argv[-1] == "--lint":
write_custom_dns_config(get_custom_dns_config(env), env) write_custom_dns_config(get_custom_dns_config(env), env)
elif sys.argv[-1] == "--update":
do_dns_update(env, force=True)
else: else:
for zone, records in build_recommended_dns(env): for _zone, records in build_recommended_dns(env):
for record in records: for record in records:
print("; " + record['explanation']) print("; " + record['explanation'])
print(record['qname'], record['rtype'], record['value'], sep="\t") print(record['qname'], record['rtype'], record['value'], sep="\t")

View File

@ -37,11 +37,11 @@ msg = MIMEMultipart('alternative')
# In Python 3.6: # In Python 3.6:
#msg = Message() #msg = Message()
msg['From'] = "\"%s\" <%s>" % (env['PRIMARY_HOSTNAME'], admin_addr) msg['From'] = '"{}" <{}>'.format(env['PRIMARY_HOSTNAME'], admin_addr)
msg['To'] = admin_addr msg['To'] = admin_addr
msg['Subject'] = "[%s] %s" % (env['PRIMARY_HOSTNAME'], subject) msg['Subject'] = "[{}] {}".format(env['PRIMARY_HOSTNAME'], subject)
content_html = "<html><body><pre>{}</pre></body></html>".format(html.escape(content)) content_html = f'<html><body><pre style="overflow-x: scroll; white-space: pre;">{html.escape(content)}</pre></body></html>'
msg.attach(MIMEText(content, 'plain')) msg.attach(MIMEText(content, 'plain'))
msg.attach(MIMEText(content_html, 'html')) msg.attach(MIMEText(content_html, 'html'))

View File

@ -71,9 +71,10 @@ def scan_files(collector):
if not os.path.exists(fn): if not os.path.exists(fn):
continue continue
elif fn[-3:] == '.gz': if fn[-3:] == '.gz':
tmp_file = tempfile.NamedTemporaryFile() tmp_file = tempfile.NamedTemporaryFile()
shutil.copyfileobj(gzip.open(fn), tmp_file) with gzip.open(fn, 'rb') as f:
shutil.copyfileobj(f, tmp_file)
if VERBOSE: if VERBOSE:
print("Processing file", fn, "...") print("Processing file", fn, "...")
@ -115,12 +116,11 @@ def scan_mail_log(env):
try: try:
import mailconfig import mailconfig
collector["known_addresses"] = (set(mailconfig.get_mail_users(env)) | collector["known_addresses"] = (set(mailconfig.get_mail_users(env)) |
set(alias[0] for alias in mailconfig.get_mail_aliases(env))) {alias[0] for alias in mailconfig.get_mail_aliases(env)})
except ImportError: except ImportError:
pass pass
print("Scanning logs from {:%Y-%m-%d %H:%M:%S} to {:%Y-%m-%d %H:%M:%S}".format( print(f"Scanning logs from {START_DATE:%Y-%m-%d %H:%M:%S} to {END_DATE:%Y-%m-%d %H:%M:%S}"
START_DATE, END_DATE)
) )
# Scan the lines in the log files until the date goes out of range # Scan the lines in the log files until the date goes out of range
@ -226,7 +226,7 @@ def scan_mail_log(env):
], ],
sub_data=[ sub_data=[
("Protocol and Source", [[ ("Protocol and Source", [[
"{} {}: {} times".format(protocol_name, host, count) f"{protocol_name} {host}: {count} times"
for (protocol_name, host), count for (protocol_name, host), count
in sorted(u["totals_by_protocol_and_host"].items(), key=lambda kv:-kv[1]) in sorted(u["totals_by_protocol_and_host"].items(), key=lambda kv:-kv[1])
] for u in data.values()]) ] for u in data.values()])
@ -302,8 +302,7 @@ def scan_mail_log(env):
for date, sender, message in user_data["blocked"]: for date, sender, message in user_data["blocked"]:
if len(sender) > 64: if len(sender) > 64:
sender = sender[:32] + "" + sender[-32:] sender = sender[:32] + "" + sender[-32:]
user_rejects.append("%s - %s " % (date, sender)) user_rejects.extend((f'{date} - {sender} ', f' {message}'))
user_rejects.append(" %s" % message)
rejects.append(user_rejects) rejects.append(user_rejects)
print_user_table( print_user_table(
@ -320,8 +319,8 @@ def scan_mail_log(env):
if collector["other-services"] and VERBOSE and False: if collector["other-services"] and VERBOSE and False:
print_header("Other services") print_header("Other services")
print("The following unkown services were found in the log file.") print("The following unknown services were found in the log file.")
print(" ", *sorted(list(collector["other-services"])), sep='\n') print(" ", *sorted(collector["other-services"]), sep='\n')
def scan_mail_log_line(line, collector): def scan_mail_log_line(line, collector):
@ -332,7 +331,7 @@ def scan_mail_log_line(line, collector):
if not m: if not m:
return True return True
date, system, service, log = m.groups() date, _system, service, log = m.groups()
collector["scan_count"] += 1 collector["scan_count"] += 1
# print() # print()
@ -356,7 +355,7 @@ def scan_mail_log_line(line, collector):
if date > END_DATE: if date > END_DATE:
# Don't process, and halt # Don't process, and halt
return False return False
elif date < START_DATE: if date < START_DATE:
# Don't process, but continue # Don't process, but continue
return True return True
@ -375,9 +374,9 @@ def scan_mail_log_line(line, collector):
elif service == "postfix/smtpd": elif service == "postfix/smtpd":
if SCAN_BLOCKED: if SCAN_BLOCKED:
scan_postfix_smtpd_line(date, log, collector) scan_postfix_smtpd_line(date, log, collector)
elif service in ("postfix/qmgr", "postfix/pickup", "postfix/cleanup", "postfix/scache", elif service in {"postfix/qmgr", "postfix/pickup", "postfix/cleanup", "postfix/scache",
"spampd", "postfix/anvil", "postfix/master", "opendkim", "postfix/lmtp", "spampd", "postfix/anvil", "postfix/master", "opendkim", "postfix/lmtp",
"postfix/tlsmgr", "anvil"): "postfix/tlsmgr", "anvil"}:
# nothing to look at # nothing to look at
return True return True
else: else:
@ -391,8 +390,8 @@ def scan_mail_log_line(line, collector):
def scan_postgrey_line(date, log, collector): def scan_postgrey_line(date, log, collector):
""" Scan a postgrey log line and extract interesting data """ """ Scan a postgrey log line and extract interesting data """
m = re.match("action=(greylist|pass), reason=(.*?), (?:delay=\d+, )?client_name=(.*), " m = re.match(r"action=(greylist|pass), reason=(.*?), (?:delay=\d+, )?client_name=(.*), "
"client_address=(.*), sender=(.*), recipient=(.*)", r"client_address=(.*), sender=(.*), recipient=(.*)",
log) log)
if m: if m:
@ -424,7 +423,7 @@ def scan_postfix_smtpd_line(date, log, collector):
# Check if the incoming mail was rejected # Check if the incoming mail was rejected
m = re.match("NOQUEUE: reject: RCPT from .*?: (.*?); from=<(.*?)> to=<(.*?)>", log) m = re.match(r"NOQUEUE: reject: RCPT from .*?: (.*?); from=<(.*?)> to=<(.*?)>", log)
if m: if m:
message, sender, user = m.groups() message, sender, user = m.groups()
@ -434,8 +433,7 @@ def scan_postfix_smtpd_line(date, log, collector):
return return
# only log mail to known recipients # only log mail to known recipients
if user_match(user): if user_match(user) and (collector["known_addresses"] is None or user in collector["known_addresses"]):
if collector["known_addresses"] is None or user in collector["known_addresses"]:
data = collector["rejected"].get( data = collector["rejected"].get(
user, user,
{ {
@ -469,7 +467,7 @@ def scan_postfix_smtpd_line(date, log, collector):
def scan_dovecot_login_line(date, log, collector, protocol_name): def scan_dovecot_login_line(date, log, collector, protocol_name):
""" Scan a dovecot login log line and extract interesting data """ """ Scan a dovecot login log line and extract interesting data """
m = re.match("Info: Login: user=<(.*?)>, method=PLAIN, rip=(.*?),", log) m = re.match(r"Info: Login: user=<(.*?)>, method=PLAIN, rip=(.*?),", log)
if m: if m:
# TODO: CHECK DIT # TODO: CHECK DIT
@ -497,9 +495,9 @@ def add_login(user, date, protocol_name, host, collector):
data["latest"] = date data["latest"] = date
data["totals_by_protocol"][protocol_name] += 1 data["totals_by_protocol"][protocol_name] += 1
data["totals_by_protocol_and_host"][(protocol_name, host)] += 1 data["totals_by_protocol_and_host"][protocol_name, host] += 1
if host not in ("127.0.0.1", "::1") or True: if host not in {"127.0.0.1", "::1"} or True:
data["activity-by-hour"][protocol_name][date.hour] += 1 data["activity-by-hour"][protocol_name][date.hour] += 1
collector["logins"][user] = data collector["logins"][user] = data
@ -513,7 +511,7 @@ def scan_postfix_lmtp_line(date, log, collector):
""" """
m = re.match("([A-Z0-9]+): to=<(\S+)>, .* Saved", log) m = re.match(r"([A-Z0-9]+): to=<(\S+)>, .* Saved", log)
if m: if m:
_, user = m.groups() _, user = m.groups()
@ -551,10 +549,10 @@ def scan_postfix_submission_line(date, log, collector):
# Match both the 'plain' and 'login' sasl methods, since both authentication methods are # Match both the 'plain' and 'login' sasl methods, since both authentication methods are
# allowed by Dovecot. Exclude trailing comma after the username when additional fields # allowed by Dovecot. Exclude trailing comma after the username when additional fields
# follow after. # follow after.
m = re.match("([A-Z0-9]+): client=(\S+), sasl_method=(PLAIN|LOGIN), sasl_username=(\S+)(?<!,)", log) m = re.match(r"([A-Z0-9]+): client=(\S+), sasl_method=(PLAIN|LOGIN), sasl_username=(\S+)(?<!,)", log)
if m: if m:
_, client, method, user = m.groups() _, client, _method, user = m.groups()
if user_match(user): if user_match(user):
# Get the user data, or create it if the user is new # Get the user data, or create it if the user is new
@ -587,7 +585,7 @@ def scan_postfix_submission_line(date, log, collector):
def readline(filename): def readline(filename):
""" A generator that returns the lines of a file """ A generator that returns the lines of a file
""" """
with open(filename, errors='replace') as file: with open(filename, errors='replace', encoding='utf-8') as file:
while True: while True:
line = file.readline() line = file.readline()
if not line: if not line:
@ -610,7 +608,8 @@ def valid_date(string):
try: try:
date = dateutil.parser.parse(string) date = dateutil.parser.parse(string)
except ValueError: except ValueError:
raise argparse.ArgumentTypeError("Unrecognized date and/or time '%s'" % string) msg = f"Unrecognized date and/or time '{string}'"
raise argparse.ArgumentTypeError(msg)
return date return date
@ -621,10 +620,7 @@ def print_time_table(labels, data, do_print=True):
data.insert(0, [str(h) for h in range(24)]) data.insert(0, [str(h) for h in range(24)])
temp = "{:<%d} " % max(len(l) for l in labels) temp = "{:<%d} " % max(len(l) for l in labels)
lines = [] lines = [temp.format(label) for label in labels]
for label in labels:
lines.append(temp.format(label))
for h in range(24): for h in range(24):
max_len = max(len(str(d[h])) for d in data) max_len = max(len(str(d[h])) for d in data)
@ -638,7 +634,7 @@ def print_time_table(labels, data, do_print=True):
if do_print: if do_print:
print("\n".join(lines)) print("\n".join(lines))
else: return None
return lines return lines
@ -671,10 +667,10 @@ def print_user_table(users, data=None, sub_data=None, activity=None, latest=None
col_str = str_temp.format(d[row][:31] + "" if len(d[row]) > 32 else d[row]) col_str = str_temp.format(d[row][:31] + "" if len(d[row]) > 32 else d[row])
col_left[col] = True col_left[col] = True
elif isinstance(d[row], datetime.datetime): elif isinstance(d[row], datetime.datetime):
col_str = "{:<20}".format(str(d[row])) col_str = f"{d[row]!s:<20}"
col_left[col] = True col_left[col] = True
else: else:
temp = "{:>%s}" % max(5, len(l) + 1, len(str(d[row])) + 1) temp = f"{{:>{max(5, len(l) + 1, len(str(d[row])) + 1)}}}"
col_str = temp.format(str(d[row])) col_str = temp.format(str(d[row]))
col_widths[col] = max(col_widths[col], len(col_str)) col_widths[col] = max(col_widths[col], len(col_str))
line += col_str line += col_str
@ -683,7 +679,7 @@ def print_user_table(users, data=None, sub_data=None, activity=None, latest=None
data_accum[col] += d[row] data_accum[col] += d[row]
try: try:
if None not in [latest, earliest]: if None not in [latest, earliest]: # noqa: PLR6201
vert_pos = len(line) vert_pos = len(line)
e = earliest[row] e = earliest[row]
l = latest[row] l = latest[row]
@ -711,13 +707,10 @@ def print_user_table(users, data=None, sub_data=None, activity=None, latest=None
if sub_data is not None: if sub_data is not None:
for l, d in sub_data: for l, d in sub_data:
if d[row]: if d[row]:
lines.append("") lines.extend(('', f'{l}', '├─%s' % (len(l) * ''), ''))
lines.append("%s" % l)
lines.append("├─%s" % (len(l) * ""))
lines.append("")
max_len = 0 max_len = 0
for v in list(d[row]): for v in list(d[row]):
lines.append("%s" % v) lines.append(f"{v}")
max_len = max(max_len, len(v)) max_len = max(max_len, len(v))
lines.append("" + (max_len + 1) * "") lines.append("" + (max_len + 1) * "")
@ -739,7 +732,7 @@ def print_user_table(users, data=None, sub_data=None, activity=None, latest=None
else: else:
header += l.rjust(max(5, len(l) + 1, col_widths[col])) header += l.rjust(max(5, len(l) + 1, col_widths[col]))
if None not in (latest, earliest): if None not in [latest, earliest]: # noqa: PLR6201
header += " │ timespan " header += " │ timespan "
lines.insert(0, header.rstrip()) lines.insert(0, header.rstrip())
@ -764,7 +757,7 @@ def print_user_table(users, data=None, sub_data=None, activity=None, latest=None
footer += temp.format(data_accum[row]) footer += temp.format(data_accum[row])
try: try:
if None not in [latest, earliest]: if None not in [latest, earliest]: # noqa: PLR6201
max_l = max(latest) max_l = max(latest)
min_e = min(earliest) min_e = min(earliest)
timespan = relativedelta(max_l, min_e) timespan = relativedelta(max_l, min_e)
@ -843,7 +836,7 @@ if __name__ == "__main__":
END_DATE = args.enddate END_DATE = args.enddate
if args.timespan == 'today': if args.timespan == 'today':
args.timespan = 'day' args.timespan = 'day'
print("Setting end date to {}".format(END_DATE)) print(f"Setting end date to {END_DATE}")
START_DATE = END_DATE - TIME_DELTAS[args.timespan] START_DATE = END_DATE - TIME_DELTAS[args.timespan]

View File

@ -9,10 +9,12 @@
# Python 3 in setup/questions.sh to validate the email # Python 3 in setup/questions.sh to validate the email
# address entered by the user. # address entered by the user.
import subprocess, shutil, os, sqlite3, re import os, sqlite3, re
import utils import utils
from email_validator import validate_email as validate_email_, EmailNotValidError from email_validator import validate_email as validate_email_, EmailNotValidError
import idna import idna
import operator
def validate_email(email, mode=None): def validate_email(email, mode=None):
# Checks that an email address is syntactically valid. Returns True/False. # Checks that an email address is syntactically valid. Returns True/False.
@ -86,16 +88,12 @@ def prettify_idn_email_address(email):
def is_dcv_address(email): def is_dcv_address(email):
email = email.lower() email = email.lower()
for localpart in ("admin", "administrator", "postmaster", "hostmaster", "webmaster", "abuse"): return any(email.startswith((localpart + "@", localpart + "+")) for localpart in ("admin", "administrator", "postmaster", "hostmaster", "webmaster", "abuse"))
if email.startswith(localpart+"@") or email.startswith(localpart+"+"):
return True
return False
def open_database(env, with_connection=False): def open_database(env, with_connection=False):
conn = sqlite3.connect(env["STORAGE_ROOT"] + "/mail/users.sqlite") conn = sqlite3.connect(env["STORAGE_ROOT"] + "/mail/users.sqlite")
if not with_connection: if not with_connection:
return conn.cursor() return conn.cursor()
else:
return conn, conn.cursor() return conn, conn.cursor()
def get_mail_users(env): def get_mail_users(env):
@ -105,6 +103,17 @@ def get_mail_users(env):
users = [ row[0] for row in c.fetchall() ] users = [ row[0] for row in c.fetchall() ]
return utils.sort_email_addresses(users, env) return utils.sort_email_addresses(users, env)
def sizeof_fmt(num):
for unit in ['','K','M','G','T']:
if abs(num) < 1024.0:
if abs(num) > 99:
return f"{num:3.0f}{unit}"
return f"{num:2.1f}{unit}"
num /= 1024.0
return str(num)
def get_mail_users_ex(env, with_archived=False): def get_mail_users_ex(env, with_archived=False):
# Returns a complex data structure of all user accounts, optionally # Returns a complex data structure of all user accounts, optionally
# including archived (status="inactive") accounts. # including archived (status="inactive") accounts.
@ -128,13 +137,42 @@ def get_mail_users_ex(env, with_archived=False):
users = [] users = []
active_accounts = set() active_accounts = set()
c = open_database(env) c = open_database(env)
c.execute('SELECT email, privileges FROM users') c.execute('SELECT email, privileges, quota FROM users')
for email, privileges in c.fetchall(): for email, privileges, quota in c.fetchall():
active_accounts.add(email) active_accounts.add(email)
(user, domain) = email.split('@')
box_size = 0
box_quota = 0
percent = ''
try:
dirsize_file = os.path.join(env['STORAGE_ROOT'], f'mail/mailboxes/{domain}/{user}/maildirsize')
with open(dirsize_file, encoding="utf-8") as f:
box_quota = int(f.readline().split('S')[0])
for line in f:
(size, _count) = line.split(' ')
box_size += int(size)
try:
percent = (box_size / box_quota) * 100
except:
percent = 'Error'
except:
box_size = '?'
box_quota = '?'
percent = '?'
if quota == '0':
percent = ''
user = { user = {
"email": email, "email": email,
"privileges": parse_privs(privileges), "privileges": parse_privs(privileges),
"quota": quota,
"box_quota": box_quota,
"box_size": sizeof_fmt(box_size) if box_size != '?' else box_size,
"percent": f'{percent:3.0f}%' if type(percent) != str else percent,
"status": "active", "status": "active",
} }
users.append(user) users.append(user)
@ -153,6 +191,9 @@ def get_mail_users_ex(env, with_archived=False):
"privileges": [], "privileges": [],
"status": "inactive", "status": "inactive",
"mailbox": mbox, "mailbox": mbox,
"box_size": '?',
"box_quota": '?',
"percent": '?',
} }
users.append(user) users.append(user)
@ -192,8 +233,7 @@ def get_mail_aliases(env):
aliases = { row[0]: row for row in c.fetchall() } # make dict aliases = { row[0]: row for row in c.fetchall() } # make dict
# put in a canonical order: sort by domain, then by email address lexicographically # put in a canonical order: sort by domain, then by email address lexicographically
aliases = [ aliases[address] for address in utils.sort_email_addresses(aliases.keys(), env) ] return [ aliases[address] for address in utils.sort_email_addresses(aliases.keys(), env) ]
return aliases
def get_mail_aliases_ex(env): def get_mail_aliases_ex(env):
# Returns a complex data structure of all mail aliases, similar # Returns a complex data structure of all mail aliases, similar
@ -225,7 +265,7 @@ def get_mail_aliases_ex(env):
domain = get_domain(address) domain = get_domain(address)
# add to list # add to list
if not domain in domains: if domain not in domains:
domains[domain] = { domains[domain] = {
"domain": domain, "domain": domain,
"aliases": [], "aliases": [],
@ -243,7 +283,7 @@ def get_mail_aliases_ex(env):
# Sort aliases within each domain first by required-ness then lexicographically by address. # Sort aliases within each domain first by required-ness then lexicographically by address.
for domain in domains: for domain in domains:
domain["aliases"].sort(key = lambda alias : (alias["auto"], alias["address"])) domain["aliases"].sort(key = operator.itemgetter("auto", "address"))
return domains return domains
def get_domain(emailaddr, as_unicode=True): def get_domain(emailaddr, as_unicode=True):
@ -270,15 +310,15 @@ def get_mail_domains(env, filter_aliases=lambda alias : True, users_only=False):
domains.extend([get_domain(address, as_unicode=False) for address, _, _, auto in get_mail_aliases(env) if filter_aliases(address) and not auto ]) domains.extend([get_domain(address, as_unicode=False) for address, _, _, auto in get_mail_aliases(env) if filter_aliases(address) and not auto ])
return set(domains) return set(domains)
def add_mail_user(email, pw, privs, env): def add_mail_user(email, pw, privs, quota, env):
# validate email # validate email
if email.strip() == "": if email.strip() == "":
return ("No email address provided.", 400) return ("No email address provided.", 400)
elif not validate_email(email): if not validate_email(email):
return ("Invalid email address.", 400) return ("Invalid email address.", 400)
elif not validate_email(email, mode='user'): if not validate_email(email, mode='user'):
return ("User account email addresses may only use the lowercase ASCII letters a-z, the digits 0-9, underscore (_), hyphen (-), and period (.).", 400) return ("User account email addresses may only use the lowercase ASCII letters a-z, the digits 0-9, underscore (_), hyphen (-), and period (.).", 400)
elif is_dcv_address(email) and len(get_mail_users(env)) > 0: if is_dcv_address(email) and len(get_mail_users(env)) > 0:
# Make domain control validation hijacking a little harder to mess up by preventing the usual # Make domain control validation hijacking a little harder to mess up by preventing the usual
# addresses used for DCV from being user accounts. Except let it be the first account because # addresses used for DCV from being user accounts. Except let it be the first account because
# during box setup the user won't know the rules. # during box setup the user won't know the rules.
@ -296,6 +336,14 @@ def add_mail_user(email, pw, privs, env):
validation = validate_privilege(p) validation = validate_privilege(p)
if validation: return validation if validation: return validation
if quota is None:
quota = '0'
try:
quota = validate_quota(quota)
except ValueError as e:
return (str(e), 400)
# get the database # get the database
conn, c = open_database(env, with_connection=True) conn, c = open_database(env, with_connection=True)
@ -304,14 +352,16 @@ def add_mail_user(email, pw, privs, env):
# add the user to the database # add the user to the database
try: try:
c.execute("INSERT INTO users (email, password, privileges) VALUES (?, ?, ?)", c.execute("INSERT INTO users (email, password, privileges, quota) VALUES (?, ?, ?, ?)",
(email, pw, "\n".join(privs))) (email, pw, "\n".join(privs), quota))
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
return ("User already exists.", 400) return ("User already exists.", 400)
# write databasebefore next step # write databasebefore next step
conn.commit() conn.commit()
dovecot_quota_recalc(email)
# Update things in case any new domains are added. # Update things in case any new domains are added.
return kick(env, "mail user added") return kick(env, "mail user added")
@ -326,7 +376,7 @@ def set_mail_password(email, pw, env):
conn, c = open_database(env, with_connection=True) conn, c = open_database(env, with_connection=True)
c.execute("UPDATE users SET password=? WHERE email=?", (pw, email)) c.execute("UPDATE users SET password=? WHERE email=?", (pw, email))
if c.rowcount != 1: if c.rowcount != 1:
return ("That's not a user (%s)." % email, 400) return (f"That's not a user ({email}).", 400)
conn.commit() conn.commit()
return "OK" return "OK"
@ -336,6 +386,58 @@ def hash_password(pw):
# http://wiki2.dovecot.org/Authentication/PasswordSchemes # http://wiki2.dovecot.org/Authentication/PasswordSchemes
return utils.shell('check_output', ["/usr/bin/doveadm", "pw", "-s", "SHA512-CRYPT", "-p", pw]).strip() return utils.shell('check_output', ["/usr/bin/doveadm", "pw", "-s", "SHA512-CRYPT", "-p", pw]).strip()
def get_mail_quota(email, env):
_conn, c = open_database(env, with_connection=True)
c.execute("SELECT quota FROM users WHERE email=?", (email,))
rows = c.fetchall()
if len(rows) != 1:
return (f"That's not a user ({email}).", 400)
return rows[0][0]
def set_mail_quota(email, quota, env):
# validate that password is acceptable
quota = validate_quota(quota)
# update the database
conn, c = open_database(env, with_connection=True)
c.execute("UPDATE users SET quota=? WHERE email=?", (quota, email))
if c.rowcount != 1:
return (f"That's not a user ({email}).", 400)
conn.commit()
dovecot_quota_recalc(email)
return "OK"
def dovecot_quota_recalc(email):
# dovecot processes running for the user will not recognize the new quota setting
# a reload is necessary to reread the quota setting, but it will also shut down
# running dovecot processes. Email clients generally log back in when they lose
# a connection.
# subprocess.call(['doveadm', 'reload'])
# force dovecot to recalculate the quota info for the user.
utils.shell("check_call", ["doveadm", "quota", "recalc", "-u", email])
def validate_quota(quota):
# validate quota
quota = quota.strip().upper()
if quota == "":
msg = "No quota provided."
raise ValueError(msg)
if re.search(r"[\s,.]", quota):
msg = "Quotas cannot contain spaces, commas, or decimal points."
raise ValueError(msg)
if not re.match(r'^[\d]+[GM]?$', quota):
msg = "Invalid quota."
raise ValueError(msg)
return quota
def get_mail_password(email, env): def get_mail_password(email, env):
# Gets the hashed password for a user. Passwords are stored in Dovecot's # Gets the hashed password for a user. Passwords are stored in Dovecot's
# password format, with a prefixed scheme. # password format, with a prefixed scheme.
@ -345,7 +447,8 @@ def get_mail_password(email, env):
c.execute('SELECT password FROM users WHERE email=?', (email,)) c.execute('SELECT password FROM users WHERE email=?', (email,))
rows = c.fetchall() rows = c.fetchall()
if len(rows) != 1: if len(rows) != 1:
raise ValueError("That's not a user (%s)." % email) msg = f"That's not a user ({email})."
raise ValueError(msg)
return rows[0][0] return rows[0][0]
def remove_mail_user(email, env): def remove_mail_user(email, env):
@ -353,7 +456,7 @@ def remove_mail_user(email, env):
conn, c = open_database(env, with_connection=True) conn, c = open_database(env, with_connection=True)
c.execute("DELETE FROM users WHERE email=?", (email,)) c.execute("DELETE FROM users WHERE email=?", (email,))
if c.rowcount != 1: if c.rowcount != 1:
return ("That's not a user (%s)." % email, 400) return (f"That's not a user ({email}).", 400)
conn.commit() conn.commit()
# Update things in case any domains are removed. # Update things in case any domains are removed.
@ -369,12 +472,12 @@ def get_mail_user_privileges(email, env, empty_on_error=False):
rows = c.fetchall() rows = c.fetchall()
if len(rows) != 1: if len(rows) != 1:
if empty_on_error: return [] if empty_on_error: return []
return ("That's not a user (%s)." % email, 400) return (f"That's not a user ({email}).", 400)
return parse_privs(rows[0][0]) return parse_privs(rows[0][0])
def validate_privilege(priv): def validate_privilege(priv):
if "\n" in priv or priv.strip() == "": if "\n" in priv or priv.strip() == "":
return ("That's not a valid privilege (%s)." % priv, 400) return (f"That's not a valid privilege ({priv}).", 400)
return None return None
def add_remove_mail_user_privilege(email, priv, action, env): def add_remove_mail_user_privilege(email, priv, action, env):
@ -417,7 +520,7 @@ def add_mail_alias(address, forwards_to, permitted_senders, env, update_if_exist
if address == "": if address == "":
return ("No email address provided.", 400) return ("No email address provided.", 400)
if not validate_email(address, mode='alias'): if not validate_email(address, mode='alias'):
return ("Invalid email address (%s)." % address, 400) return (f"Invalid email address ({address}).", 400)
# validate forwards_to # validate forwards_to
validated_forwards_to = [] validated_forwards_to = []
@ -446,7 +549,7 @@ def add_mail_alias(address, forwards_to, permitted_senders, env, update_if_exist
# Strip any +tag from email alias and check privileges # Strip any +tag from email alias and check privileges
privileged_email = re.sub(r"(?=\+)[^@]*(?=@)",'',email) privileged_email = re.sub(r"(?=\+)[^@]*(?=@)",'',email)
if not validate_email(email): if not validate_email(email):
return ("Invalid receiver email address (%s)." % email, 400) return (f"Invalid receiver email address ({email}).", 400)
if is_dcv_source and not is_dcv_address(email) and "admin" not in get_mail_user_privileges(privileged_email, env, empty_on_error=True): if is_dcv_source and not is_dcv_address(email) and "admin" not in get_mail_user_privileges(privileged_email, env, empty_on_error=True):
# Make domain control validation hijacking a little harder to mess up by # Make domain control validation hijacking a little harder to mess up by
# requiring aliases for email addresses typically used in DCV to forward # requiring aliases for email addresses typically used in DCV to forward
@ -466,7 +569,7 @@ def add_mail_alias(address, forwards_to, permitted_senders, env, update_if_exist
login = login.strip() login = login.strip()
if login == "": continue if login == "": continue
if login not in valid_logins: if login not in valid_logins:
return ("Invalid permitted sender: %s is not a user on this system." % login, 400) return (f"Invalid permitted sender: {login} is not a user on this system.", 400)
validated_permitted_senders.append(login) validated_permitted_senders.append(login)
# Make sure the alias has either a forwards_to or a permitted_sender. # Make sure the alias has either a forwards_to or a permitted_sender.
@ -477,10 +580,7 @@ def add_mail_alias(address, forwards_to, permitted_senders, env, update_if_exist
forwards_to = ",".join(validated_forwards_to) forwards_to = ",".join(validated_forwards_to)
if len(validated_permitted_senders) == 0: permitted_senders = None if len(validated_permitted_senders) == 0 else ",".join(validated_permitted_senders)
permitted_senders = None
else:
permitted_senders = ",".join(validated_permitted_senders)
conn, c = open_database(env, with_connection=True) conn, c = open_database(env, with_connection=True)
try: try:
@ -488,8 +588,7 @@ def add_mail_alias(address, forwards_to, permitted_senders, env, update_if_exist
return_status = "alias added" return_status = "alias added"
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
if not update_if_exists: if not update_if_exists:
return ("Alias already exists (%s)." % address, 400) return (f"Alias already exists ({address}).", 400)
else:
c.execute("UPDATE aliases SET destination = ?, permitted_senders = ? WHERE source = ?", (forwards_to, permitted_senders, address)) c.execute("UPDATE aliases SET destination = ?, permitted_senders = ? WHERE source = ?", (forwards_to, permitted_senders, address))
return_status = "alias updated" return_status = "alias updated"
@ -498,6 +597,7 @@ def add_mail_alias(address, forwards_to, permitted_senders, env, update_if_exist
if do_kick: if do_kick:
# Update things in case any new domains are added. # Update things in case any new domains are added.
return kick(env, return_status) return kick(env, return_status)
return None
def remove_mail_alias(address, env, do_kick=True): def remove_mail_alias(address, env, do_kick=True):
# convert Unicode domain to IDNA # convert Unicode domain to IDNA
@ -507,16 +607,17 @@ def remove_mail_alias(address, env, do_kick=True):
conn, c = open_database(env, with_connection=True) conn, c = open_database(env, with_connection=True)
c.execute("DELETE FROM aliases WHERE source=?", (address,)) c.execute("DELETE FROM aliases WHERE source=?", (address,))
if c.rowcount != 1: if c.rowcount != 1:
return ("That's not an alias (%s)." % address, 400) return (f"That's not an alias ({address}).", 400)
conn.commit() conn.commit()
if do_kick: if do_kick:
# Update things in case any domains are removed. # Update things in case any domains are removed.
return kick(env, "alias removed") return kick(env, "alias removed")
return None
def add_auto_aliases(aliases, env): def add_auto_aliases(aliases, env):
conn, c = open_database(env, with_connection=True) conn, c = open_database(env, with_connection=True)
c.execute("DELETE FROM auto_aliases"); c.execute("DELETE FROM auto_aliases")
for source, destination in aliases.items(): for source, destination in aliases.items():
c.execute("INSERT INTO auto_aliases (source, destination) VALUES (?, ?)", (source, destination)) c.execute("INSERT INTO auto_aliases (source, destination) VALUES (?, ?)", (source, destination))
conn.commit() conn.commit()
@ -566,7 +667,7 @@ def kick(env, mail_result=None):
auto_aliases = { } auto_aliases = { }
# Mape required aliases to the administrator alias (which should be created manually). # Map required aliases to the administrator alias (which should be created manually).
administrator = get_system_administrator(env) administrator = get_system_administrator(env)
required_aliases = get_required_aliases(env) required_aliases = get_required_aliases(env)
for alias in required_aliases: for alias in required_aliases:
@ -586,14 +687,14 @@ def kick(env, mail_result=None):
# Remove auto-generated postmaster/admin/abuse alises from the main aliases table. # Remove auto-generated postmaster/admin/abuse alises from the main aliases table.
# They are now stored in the auto_aliases table. # They are now stored in the auto_aliases table.
for address, forwards_to, permitted_senders, auto in get_mail_aliases(env): for address, forwards_to, _permitted_senders, auto in get_mail_aliases(env):
user, domain = address.split("@") user, domain = address.split("@")
if user in ("postmaster", "admin", "abuse") \ if user in {"postmaster", "admin", "abuse"} \
and address not in required_aliases \ and address not in required_aliases \
and forwards_to == get_system_administrator(env) \ and forwards_to == get_system_administrator(env) \
and not auto: and not auto:
remove_mail_alias(address, env, do_kick=False) remove_mail_alias(address, env, do_kick=False)
results.append("removed alias %s (was to %s; domain no longer used for email)\n" % (address, forwards_to)) results.append(f"removed alias {address} (was to {forwards_to}; domain no longer used for email)\n")
# Update DNS and nginx in case any domains are added/removed. # Update DNS and nginx in case any domains are added/removed.
@ -608,9 +709,11 @@ def kick(env, mail_result=None):
def validate_password(pw): def validate_password(pw):
# validate password # validate password
if pw.strip() == "": if pw.strip() == "":
raise ValueError("No password provided.") msg = "No password provided."
raise ValueError(msg)
if len(pw) < 8: if len(pw) < 8:
raise ValueError("Passwords must be at least eight characters.") msg = "Passwords must be at least eight characters."
raise ValueError(msg)
if __name__ == "__main__": if __name__ == "__main__":
import sys import sys

View File

@ -41,9 +41,11 @@ def enable_mfa(email, type, secret, token, label, env):
# Sanity check with the provide current token. # Sanity check with the provide current token.
totp = pyotp.TOTP(secret) totp = pyotp.TOTP(secret)
if not totp.verify(token, valid_window=1): if not totp.verify(token, valid_window=1):
raise ValueError("Invalid token.") msg = "Invalid token."
raise ValueError(msg)
else: else:
raise ValueError("Invalid MFA type.") msg = "Invalid MFA type."
raise ValueError(msg)
conn, c = open_database(env, with_connection=True) conn, c = open_database(env, with_connection=True)
c.execute('INSERT INTO mfa (user_id, type, secret, label) VALUES (?, ?, ?, ?)', (get_user_id(email, c), type, secret, label)) c.execute('INSERT INTO mfa (user_id, type, secret, label) VALUES (?, ?, ?, ?)', (get_user_id(email, c), type, secret, label))
@ -66,10 +68,12 @@ def disable_mfa(email, mfa_id, env):
return c.rowcount > 0 return c.rowcount > 0
def validate_totp_secret(secret): def validate_totp_secret(secret):
if type(secret) != str or secret.strip() == "": if not isinstance(secret, str) or secret.strip() == "":
raise ValueError("No secret provided.") msg = "No secret provided."
raise ValueError(msg)
if len(secret) != 32: if len(secret) != 32:
raise ValueError("Secret should be a 32 characters base32 string") msg = "Secret should be a 32 characters base32 string"
raise ValueError(msg)
def provision_totp(email, env): def provision_totp(email, env):
# Make a new secret. # Make a new secret.

View File

@ -4,7 +4,8 @@
import os, os.path, re, shutil, subprocess, tempfile import os, os.path, re, shutil, subprocess, tempfile
from utils import shell, safe_domain_name, sort_domains from utils import shell, safe_domain_name, sort_domains
import idna import functools
import operator
# SELECTING SSL CERTIFICATES FOR USE IN WEB # SELECTING SSL CERTIFICATES FOR USE IN WEB
@ -13,7 +14,7 @@ def get_ssl_certificates(env):
# that the certificates are good for to the best certificate for # that the certificates are good for to the best certificate for
# the domain. # the domain.
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey from cryptography.hazmat.primitives.asymmetric import dsa, rsa, ec
from cryptography.x509 import Certificate from cryptography.x509 import Certificate
# The certificates are all stored here: # The certificates are all stored here:
@ -58,13 +59,13 @@ def get_ssl_certificates(env):
# Not a valid PEM format for a PEM type we care about. # Not a valid PEM format for a PEM type we care about.
continue continue
# Is it a private key?
if isinstance(pem, RSAPrivateKey):
private_keys[pem.public_key().public_numbers()] = { "filename": fn, "key": pem }
# Is it a certificate? # Is it a certificate?
if isinstance(pem, Certificate): if isinstance(pem, Certificate):
certificates.append({ "filename": fn, "cert": pem }) certificates.append({ "filename": fn, "cert": pem })
# It is a private key
elif (isinstance(pem, (rsa.RSAPrivateKey, dsa.DSAPrivateKey, ec.EllipticCurvePrivateKey))):
private_keys[pem.public_key().public_numbers()] = { "filename": fn, "key": pem }
# Process the certificates. # Process the certificates.
domains = { } domains = { }
@ -83,8 +84,7 @@ def get_ssl_certificates(env):
for domain in cert_domains: for domain in cert_domains:
# The primary hostname can only use a certificate mapped # The primary hostname can only use a certificate mapped
# to the system private key. # to the system private key.
if domain == env['PRIMARY_HOSTNAME']: if domain == env['PRIMARY_HOSTNAME'] and cert["private_key"]["filename"] != os.path.join(env['STORAGE_ROOT'], 'ssl', 'ssl_private_key.pem'):
if cert["private_key"]["filename"] != os.path.join(env['STORAGE_ROOT'], 'ssl', 'ssl_private_key.pem'):
continue continue
domains.setdefault(domain, []).append(cert) domains.setdefault(domain, []).append(cert)
@ -150,21 +150,19 @@ def get_domain_ssl_files(domain, ssl_certificates, env, allow_missing_cert=False
"certificate_object": load_pem(load_cert_chain(ssl_certificate)[0]), "certificate_object": load_pem(load_cert_chain(ssl_certificate)[0]),
} }
if use_main_cert: if use_main_cert and domain == env['PRIMARY_HOSTNAME']:
if domain == env['PRIMARY_HOSTNAME']:
# The primary domain must use the server certificate because # The primary domain must use the server certificate because
# it is hard-coded in some service configuration files. # it is hard-coded in some service configuration files.
return system_certificate return system_certificate
wildcard_domain = re.sub("^[^\.]+", "*", domain) wildcard_domain = re.sub(r"^[^\.]+", "*", domain)
if domain in ssl_certificates: if domain in ssl_certificates:
return ssl_certificates[domain] return ssl_certificates[domain]
elif wildcard_domain in ssl_certificates: if wildcard_domain in ssl_certificates:
return ssl_certificates[wildcard_domain] return ssl_certificates[wildcard_domain]
elif not allow_missing_cert: if not allow_missing_cert:
# No valid certificate is available for this domain! Return default files. # No valid certificate is available for this domain! Return default files.
return system_certificate return system_certificate
else:
# No valid certificate is available for this domain. # No valid certificate is available for this domain.
return None return None
@ -212,7 +210,7 @@ def get_certificates_to_provision(env, limit_domains=None, show_valid_certs=True
if not value: continue # IPv6 is not configured if not value: continue # IPv6 is not configured
response = query_dns(domain, rtype) response = query_dns(domain, rtype)
if response != normalize_ip(value): if response != normalize_ip(value):
bad_dns.append("%s (%s)" % (response, rtype)) bad_dns.append(f"{response} ({rtype})")
if bad_dns: if bad_dns:
domains_cant_provision[domain] = "The domain name does not resolve to this machine: " \ domains_cant_provision[domain] = "The domain name does not resolve to this machine: " \
@ -265,11 +263,11 @@ def provision_certificates(env, limit_domains):
# primary domain listed in each certificate. # primary domain listed in each certificate.
from dns_update import get_dns_zones from dns_update import get_dns_zones
certs = { } certs = { }
for zone, zonefile in get_dns_zones(env): for zone, _zonefile in get_dns_zones(env):
certs[zone] = [[]] certs[zone] = [[]]
for domain in sort_domains(domains, env): for domain in sort_domains(domains, env):
# Does the domain end with any domain we've seen so far. # Does the domain end with any domain we've seen so far.
for parent in certs.keys(): for parent in certs:
if domain.endswith("." + parent): if domain.endswith("." + parent):
# Add this to the parent's list of domains. # Add this to the parent's list of domains.
# Start a new group if the list already has # Start a new group if the list already has
@ -286,7 +284,7 @@ def provision_certificates(env, limit_domains):
# Flatten to a list of lists of domains (from a mapping). Remove empty # Flatten to a list of lists of domains (from a mapping). Remove empty
# lists (zones with no domains that need certs). # lists (zones with no domains that need certs).
certs = sum(certs.values(), []) certs = functools.reduce(operator.iadd, certs.values(), [])
certs = [_ for _ in certs if len(_) > 0] certs = [_ for _ in certs if len(_) > 0]
# Prepare to provision. # Prepare to provision.
@ -414,7 +412,7 @@ def create_csr(domain, ssl_key, country_code, env):
"openssl", "req", "-new", "openssl", "req", "-new",
"-key", ssl_key, "-key", ssl_key,
"-sha256", "-sha256",
"-subj", "/C=%s/CN=%s" % (country_code, domain)]) "-subj", f"/C={country_code}/CN={domain}"])
def install_cert(domain, ssl_cert, ssl_chain, env, raw=False): def install_cert(domain, ssl_cert, ssl_chain, env, raw=False):
# Write the combined cert+chain to a temporary path and validate that it is OK. # Write the combined cert+chain to a temporary path and validate that it is OK.
@ -435,7 +433,7 @@ def install_cert(domain, ssl_cert, ssl_chain, env, raw=False):
cert_status += " " + cert_status_details cert_status += " " + cert_status_details
return cert_status return cert_status
# Copy certifiate into ssl directory. # Copy certificate into ssl directory.
install_cert_copy_file(fn, env) install_cert_copy_file(fn, env)
# Run post-install steps. # Run post-install steps.
@ -450,8 +448,8 @@ def install_cert_copy_file(fn, env):
from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives import hashes
from binascii import hexlify from binascii import hexlify
cert = load_pem(load_cert_chain(fn)[0]) cert = load_pem(load_cert_chain(fn)[0])
all_domains, cn = get_certificate_domains(cert) _all_domains, cn = get_certificate_domains(cert)
path = "%s-%s-%s.pem" % ( path = "{}-{}-{}.pem".format(
safe_domain_name(cn), # common name, which should be filename safe because it is IDNA-encoded, but in case of a malformed cert make sure it's ok to use as a filename safe_domain_name(cn), # common name, which should be filename safe because it is IDNA-encoded, but in case of a malformed cert make sure it's ok to use as a filename
cert.not_valid_after.date().isoformat().replace("-", ""), # expiration date cert.not_valid_after.date().isoformat().replace("-", ""), # expiration date
hexlify(cert.fingerprint(hashes.SHA256())).decode("ascii")[0:8], # fingerprint prefix hexlify(cert.fingerprint(hashes.SHA256())).decode("ascii")[0:8], # fingerprint prefix
@ -506,7 +504,7 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
# Check that the ssl_certificate & ssl_private_key files are good # Check that the ssl_certificate & ssl_private_key files are good
# for the provided domain. # for the provided domain.
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey from cryptography.hazmat.primitives.asymmetric import rsa, dsa, ec
from cryptography.x509 import Certificate from cryptography.x509 import Certificate
# The ssl_certificate file may contain a chain of certificates. We'll # The ssl_certificate file may contain a chain of certificates. We'll
@ -517,33 +515,35 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
cert = load_pem(ssl_cert_chain[0]) cert = load_pem(ssl_cert_chain[0])
if not isinstance(cert, Certificate): raise ValueError("This is not a certificate file.") if not isinstance(cert, Certificate): raise ValueError("This is not a certificate file.")
except ValueError as e: except ValueError as e:
return ("There is a problem with the certificate file: %s" % str(e), None) return (f"There is a problem with the certificate file: {e!s}", None)
# First check that the domain name is one of the names allowed by # First check that the domain name is one of the names allowed by
# the certificate. # the certificate.
if domain is not None: if domain is not None:
certificate_names, cert_primary_name = get_certificate_domains(cert) certificate_names, _cert_primary_name = get_certificate_domains(cert)
# Check that the domain appears among the acceptable names, or a wildcard # Check that the domain appears among the acceptable names, or a wildcard
# form of the domain name (which is a stricter check than the specs but # form of the domain name (which is a stricter check than the specs but
# should work in normal cases). # should work in normal cases).
wildcard_domain = re.sub("^[^\.]+", "*", domain) wildcard_domain = re.sub(r"^[^\.]+", "*", domain)
if domain not in certificate_names and wildcard_domain not in certificate_names: if domain not in certificate_names and wildcard_domain not in certificate_names:
return ("The certificate is for the wrong domain name. It is for %s." return ("The certificate is for the wrong domain name. It is for {}.".format(", ".join(sorted(certificate_names))), None)
% ", ".join(sorted(certificate_names)), None)
# Second, check that the certificate matches the private key. # Second, check that the certificate matches the private key.
if ssl_private_key is not None: if ssl_private_key is not None:
try: try:
priv_key = load_pem(open(ssl_private_key, 'rb').read()) with open(ssl_private_key, 'rb') as f:
priv_key = load_pem(f.read())
except ValueError as e: except ValueError as e:
return ("The private key file %s is not a private key file: %s" % (ssl_private_key, str(e)), None) return (f"The private key file {ssl_private_key} is not a private key file: {e!s}", None)
if not isinstance(priv_key, RSAPrivateKey): if (not isinstance(priv_key, rsa.RSAPrivateKey)
return ("The private key file %s is not a private key file." % ssl_private_key, None) and not isinstance(priv_key, dsa.DSAPrivateKey)
and not isinstance(priv_key, ec.EllipticCurvePrivateKey)):
return (f"The private key file {ssl_private_key} is not a private key file.", None)
if priv_key.public_key().public_numbers() != cert.public_key().public_numbers(): if priv_key.public_key().public_numbers() != cert.public_key().public_numbers():
return ("The certificate does not correspond to the private key at %s." % ssl_private_key, None) return (f"The certificate does not correspond to the private key at {ssl_private_key}.", None)
# We could also use the openssl command line tool to get the modulus # We could also use the openssl command line tool to get the modulus
# listed in each file. The output of each command below looks like "Modulus=XXXXX". # listed in each file. The output of each command below looks like "Modulus=XXXXX".
@ -565,7 +565,7 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
import datetime import datetime
now = datetime.datetime.utcnow() now = datetime.datetime.utcnow()
if not(cert.not_valid_before <= now <= cert.not_valid_after): if not(cert.not_valid_before <= now <= cert.not_valid_after):
return ("The certificate has expired or is not yet valid. It is valid from %s to %s." % (cert.not_valid_before, cert.not_valid_after), None) return (f"The certificate has expired or is not yet valid. It is valid from {cert.not_valid_before} to {cert.not_valid_after}.", None)
# Next validate that the certificate is valid. This checks whether the certificate # Next validate that the certificate is valid. This checks whether the certificate
# is self-signed, that the chain of trust makes sense, that it is signed by a CA # is self-signed, that the chain of trust makes sense, that it is signed by a CA
@ -587,14 +587,13 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
# Certificate is self-signed. Probably we detected this above. # Certificate is self-signed. Probably we detected this above.
return ("SELF-SIGNED", None) return ("SELF-SIGNED", None)
elif retcode != 0: if retcode != 0:
if "unable to get local issuer certificate" in verifyoutput: if "unable to get local issuer certificate" in verifyoutput:
return ("The certificate is missing an intermediate chain or the intermediate chain is incorrect or incomplete. (%s)" % verifyoutput, None) return (f"The certificate is missing an intermediate chain or the intermediate chain is incorrect or incomplete. ({verifyoutput})", None)
# There is some unknown problem. Return the `openssl verify` raw output. # There is some unknown problem. Return the `openssl verify` raw output.
return ("There is a problem with the certificate.", verifyoutput.strip()) return ("There is a problem with the certificate.", verifyoutput.strip())
else:
# `openssl verify` returned a zero exit status so the cert is currently # `openssl verify` returned a zero exit status so the cert is currently
# good. # good.
@ -606,7 +605,7 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
expiry_info = "The certificate expires in %d days on %s." % (ndays, cert_expiration_date.date().isoformat()) expiry_info = "The certificate expires in %d days on %s." % (ndays, cert_expiration_date.date().isoformat())
else: else:
# We'll renew it with Lets Encrypt. # We'll renew it with Lets Encrypt.
expiry_info = "The certificate expires on %s." % cert_expiration_date.date().isoformat() expiry_info = f"The certificate expires on {cert_expiration_date.date().isoformat()}."
if warn_if_expiring_soon and ndays <= warn_if_expiring_soon: if warn_if_expiring_soon and ndays <= warn_if_expiring_soon:
# Warn on day 10 to give 4 days for us to automatically renew the # Warn on day 10 to give 4 days for us to automatically renew the
@ -624,7 +623,8 @@ def load_cert_chain(pemfile):
pem = f.read() + b"\n" # ensure trailing newline pem = f.read() + b"\n" # ensure trailing newline
pemblocks = re.findall(re_pem, pem) pemblocks = re.findall(re_pem, pem)
if len(pemblocks) == 0: if len(pemblocks) == 0:
raise ValueError("File does not contain valid PEM data.") msg = "File does not contain valid PEM data."
raise ValueError(msg)
return pemblocks return pemblocks
def load_pem(pem): def load_pem(pem):
@ -635,9 +635,10 @@ def load_pem(pem):
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
pem_type = re.match(b"-+BEGIN (.*?)-+[\r\n]", pem) pem_type = re.match(b"-+BEGIN (.*?)-+[\r\n]", pem)
if pem_type is None: if pem_type is None:
raise ValueError("File is not a valid PEM-formatted file.") msg = "File is not a valid PEM-formatted file."
raise ValueError(msg)
pem_type = pem_type.group(1) pem_type = pem_type.group(1)
if pem_type in (b"RSA PRIVATE KEY", b"PRIVATE KEY"): if pem_type.endswith(b"PRIVATE KEY"):
return serialization.load_pem_private_key(pem, password=None, backend=default_backend()) return serialization.load_pem_private_key(pem, password=None, backend=default_backend())
if pem_type == b"CERTIFICATE": if pem_type == b"CERTIFICATE":
return load_pem_x509_certificate(pem, default_backend()) return load_pem_x509_certificate(pem, default_backend())
@ -666,13 +667,11 @@ def get_certificate_domains(cert):
def idna_decode_dns_name(dns_name): def idna_decode_dns_name(dns_name):
if dns_name.startswith("*."): if dns_name.startswith("*."):
return "*." + idna.encode(dns_name[2:]).decode('ascii') return "*." + idna.encode(dns_name[2:]).decode('ascii')
else:
return idna.encode(dns_name).decode('ascii') return idna.encode(dns_name).decode('ascii')
try: try:
sans = cert.extensions.get_extension_for_oid(OID_SUBJECT_ALTERNATIVE_NAME).value.get_values_for_type(DNSName) sans = cert.extensions.get_extension_for_oid(OID_SUBJECT_ALTERNATIVE_NAME).value.get_values_for_type(DNSName)
for san in sans: names.update(idna_decode_dns_name(san) for san in sans)
names.add(idna_decode_dns_name(san))
except ExtensionNotFound: except ExtensionNotFound:
pass pass

View File

@ -4,11 +4,11 @@
# TLS certificates have been signed, etc., and if not tells the user # TLS certificates have been signed, etc., and if not tells the user
# what to do next. # what to do next.
import sys, os, os.path, re, subprocess, datetime, multiprocessing.pool import sys, os, os.path, re, datetime, multiprocessing.pool
import asyncio import asyncio
import dateutil.parser, dateutil.relativedelta, dateutil.tz
import dns.reversename, dns.resolver import dns.reversename, dns.resolver
import dateutil.parser, dateutil.tz
import idna import idna
import psutil import psutil
import postfix_mta_sts_resolver.resolver import postfix_mta_sts_resolver.resolver
@ -18,7 +18,8 @@ from web_update import get_web_domains, get_domains_with_a_records
from ssl_certificates import get_ssl_certificates, get_domain_ssl_files, check_certificate from ssl_certificates import get_ssl_certificates, get_domain_ssl_files, check_certificate
from mailconfig import get_mail_domains, get_mail_aliases from mailconfig import get_mail_domains, get_mail_aliases
from utils import shell, sort_domains, load_env_vars_from_file, load_settings from utils import shell, sort_domains, load_env_vars_from_file, load_settings, get_ssh_port, get_ssh_config_value
from backup import get_backup_config, backup_status
def get_services(): def get_services():
return [ return [
@ -66,35 +67,23 @@ def run_checks(rounded_values, env, output, pool, domains_to_check=None):
run_network_checks(env, output) run_network_checks(env, output)
run_domain_checks(rounded_values, env, output, pool, domains_to_check=domains_to_check) run_domain_checks(rounded_values, env, output, pool, domains_to_check=domains_to_check)
def get_ssh_port():
# Returns ssh port
try:
output = shell('check_output', ['sshd', '-T'])
except FileNotFoundError:
# sshd is not installed. That's ok.
return None
returnNext = False
for e in output.split():
if returnNext:
return int(e)
if e == "port":
returnNext = True
# Did not find port!
return None
def run_services_checks(env, output, pool): def run_services_checks(env, output, pool):
# Check that system services are running. # Check that system services are running.
all_running = True all_running = True
fatal = False fatal = False
ret = pool.starmap(check_service, ((i, service, env) for i, service in enumerate(get_services())), chunksize=1) ret = pool.starmap(check_service, ((i, service, env) for i, service in enumerate(get_services())), chunksize=1)
for i, running, fatal2, output2 in sorted(ret): for _i, running, fatal2, output2 in sorted(ret):
if output2 is None: continue # skip check (e.g. no port was set, e.g. no sshd) if output2 is None: continue # skip check (e.g. no port was set, e.g. no sshd)
all_running = all_running and running all_running = all_running and running
fatal = fatal or fatal2 fatal = fatal or fatal2
output2.playback(output) output2.playback(output)
# Check fail2ban.
code, ret = shell('check_output', ["fail2ban-client", "status"], capture_stderr=True, trap=True)
if code != 0:
output.print_error("fail2ban is not running.")
all_running = False
if all_running: if all_running:
output.print_ok("All system services are running.") output.print_ok("All system services are running.")
@ -119,7 +108,7 @@ def check_service(i, service, env):
try: try:
s.connect((ip, service["port"])) s.connect((ip, service["port"]))
return True return True
except OSError as e: except OSError:
# timed out or some other odd error # timed out or some other odd error
return False return False
finally: finally:
@ -146,18 +135,17 @@ def check_service(i, service, env):
output.print_error("%s is not running (port %d)." % (service['name'], service['port'])) output.print_error("%s is not running (port %d)." % (service['name'], service['port']))
# Why is nginx not running? # Why is nginx not running?
if not running and service["port"] in (80, 443): if not running and service["port"] in {80, 443}:
output.print_line(shell('check_output', ['nginx', '-t'], capture_stderr=True, trap=True)[1].strip()) output.print_line(shell('check_output', ['nginx', '-t'], capture_stderr=True, trap=True)[1].strip())
else:
# Service should be running locally. # Service should be running locally.
if try_connect("127.0.0.1"): elif try_connect("127.0.0.1"):
running = True running = True
else: else:
output.print_error("%s is not running (port %d)." % (service['name'], service['port'])) output.print_error("%s is not running (port %d)." % (service['name'], service['port']))
# Flag if local DNS is not running. # Flag if local DNS is not running.
if not running and service["port"] == 53 and service["public"] == False: if not running and service["port"] == 53 and service["public"] is False:
fatal = True fatal = True
return (i, running, fatal, output) return (i, running, fatal, output)
@ -169,6 +157,7 @@ def run_system_checks(rounded_values, env, output):
check_system_aliases(env, output) check_system_aliases(env, output)
check_free_disk_space(rounded_values, env, output) check_free_disk_space(rounded_values, env, output)
check_free_memory(rounded_values, env, output) check_free_memory(rounded_values, env, output)
check_backup(rounded_values, env, output)
def check_ufw(env, output): def check_ufw(env, output):
if not os.path.isfile('/usr/sbin/ufw'): if not os.path.isfile('/usr/sbin/ufw'):
@ -189,7 +178,7 @@ def check_ufw(env, output):
for service in get_services(): for service in get_services():
if service["public"] and not is_port_allowed(ufw, service["port"]): if service["public"] and not is_port_allowed(ufw, service["port"]):
not_allowed_ports += 1 not_allowed_ports += 1
output.print_error("Port %s (%s) should be allowed in the firewall, please re-run the setup." % (service["port"], service["name"])) output.print_error("Port {} ({}) should be allowed in the firewall, please re-run the setup.".format(service["port"], service["name"]))
if not_allowed_ports == 0: if not_allowed_ports == 0:
output.print_ok("Firewall is active.") output.print_ok("Firewall is active.")
@ -202,20 +191,15 @@ def is_port_allowed(ufw, port):
return any(re.match(str(port) +"[/ \t].*", item) for item in ufw) return any(re.match(str(port) +"[/ \t].*", item) for item in ufw)
def check_ssh_password(env, output): def check_ssh_password(env, output):
# Check that SSH login with password is disabled. The openssh-server config_value = get_ssh_config_value("passwordauthentication")
# package may not be installed so check that before trying to access if config_value:
# the configuration file. if config_value == "no":
if not os.path.exists("/etc/ssh/sshd_config"): output.print_ok("SSH disallows password-based login.")
return else:
sshd = open("/etc/ssh/sshd_config").read()
if re.search("\nPasswordAuthentication\s+yes", sshd) \
or not re.search("\nPasswordAuthentication\s+no", sshd):
output.print_error("""The SSH server on this machine permits password-based login. A more secure output.print_error("""The SSH server on this machine permits password-based login. A more secure
way to log in is using a public key. Add your SSH public key to $HOME/.ssh/authorized_keys, check way to log in is using a public key. Add your SSH public key to $HOME/.ssh/authorized_keys, check
that you can log in without a password, set the option 'PasswordAuthentication no' in that you can log in without a password, set the option 'PasswordAuthentication no' in
/etc/ssh/sshd_config, and then restart the openssh via 'sudo service ssh restart'.""") /etc/ssh/sshd_config, and then restart the openssh via 'sudo service ssh restart'.""")
else:
output.print_ok("SSH disallows password-based login.")
def is_reboot_needed_due_to_package_installation(): def is_reboot_needed_due_to_package_installation():
return os.path.exists("/var/run/reboot-required") return os.path.exists("/var/run/reboot-required")
@ -230,7 +214,7 @@ def check_software_updates(env, output):
else: else:
output.print_error("There are %d software packages that can be updated." % len(pkgs)) output.print_error("There are %d software packages that can be updated." % len(pkgs))
for p in pkgs: for p in pkgs:
output.print_line("%s (%s)" % (p["package"], p["version"])) output.print_line("{} ({})".format(p["package"], p["version"]))
def check_system_aliases(env, output): def check_system_aliases(env, output):
# Check that the administrator alias exists since that's where all # Check that the administrator alias exists since that's where all
@ -262,13 +246,12 @@ def check_free_disk_space(rounded_values, env, output):
except: except:
backup_cache_count = 0 backup_cache_count = 0
if backup_cache_count > 1: if backup_cache_count > 1:
output.print_warning("The backup cache directory {} has more than one backup target cache. Consider clearing this directory to save disk space." output.print_warning(f"The backup cache directory {backup_cache_path} has more than one backup target cache. Consider clearing this directory to save disk space.")
.format(backup_cache_path))
def check_free_memory(rounded_values, env, output): def check_free_memory(rounded_values, env, output):
# Check free memory. # Check free memory.
percent_free = 100 - psutil.virtual_memory().percent percent_free = 100 - psutil.virtual_memory().percent
memory_msg = "System memory is %s%% free." % str(round(percent_free)) memory_msg = f"System memory is {round(percent_free)!s}% free."
if percent_free >= 20: if percent_free >= 20:
if rounded_values: memory_msg = "System free memory is at least 20%." if rounded_values: memory_msg = "System free memory is at least 20%."
output.print_ok(memory_msg) output.print_ok(memory_msg)
@ -279,6 +262,37 @@ def check_free_memory(rounded_values, env, output):
if rounded_values: memory_msg = "System free memory is below 10%." if rounded_values: memory_msg = "System free memory is below 10%."
output.print_error(memory_msg) output.print_error(memory_msg)
def check_backup(rounded_values, env, output):
# Check backups
backup_config = get_backup_config(env, for_ui=True)
# Is the backup enabled?
if backup_config.get("target", "off") == "off":
output.print_warning("Backups are disabled. It is recommended to enable a backup for your box.")
return
else:
output.print_ok("Backups are enabled")
# Get the age of the most recent backup
backup_stat = backup_status(env)
backups = backup_stat.get("backups", {})
if backups and len(backups) > 0:
most_recent = backups[0]["date"]
# Calculate time between most recent backup and current time
now = datetime.datetime.now(dateutil.tz.tzlocal())
bk_date = dateutil.parser.parse(most_recent).astimezone(dateutil.tz.tzlocal())
bk_age = dateutil.relativedelta.relativedelta(now, bk_date)
if bk_age.days > 7:
output.print_error("Backup is more than a week old")
else:
output.print_error("Could not obtain backup status or no backup has been made (yet). "
"This could happen if you have just enabled backups. In that case, check back tomorrow.")
def run_network_checks(env, output): def run_network_checks(env, output):
# Also see setup/network-checks.sh. # Also see setup/network-checks.sh.
@ -289,7 +303,7 @@ def run_network_checks(env, output):
# Stop if we cannot make an outbound connection on port 25. Many residential # Stop if we cannot make an outbound connection on port 25. Many residential
# networks block outbound port 25 to prevent their network from sending spam. # networks block outbound port 25 to prevent their network from sending spam.
# See if we can reach one of Google's MTAs with a 5-second timeout. # See if we can reach one of Google's MTAs with a 5-second timeout.
code, ret = shell("check_call", ["/bin/nc", "-z", "-w5", "aspmx.l.google.com", "25"], trap=True) _code, ret = shell("check_call", ["/bin/nc", "-z", "-w5", "aspmx.l.google.com", "25"], trap=True)
if ret == 0: if ret == 0:
output.print_ok("Outbound mail (SMTP port 25) is not blocked.") output.print_ok("Outbound mail (SMTP port 25) is not blocked.")
else: else:
@ -304,14 +318,43 @@ def run_network_checks(env, output):
# will not be able to reliably send mail in these cases. # will not be able to reliably send mail in these cases.
rev_ip4 = ".".join(reversed(env['PUBLIC_IP'].split('.'))) rev_ip4 = ".".join(reversed(env['PUBLIC_IP'].split('.')))
zen = query_dns(rev_ip4+'.zen.spamhaus.org', 'A', nxdomain=None) zen = query_dns(rev_ip4+'.zen.spamhaus.org', 'A', nxdomain=None)
evaluate_spamhaus_lookup(env['PUBLIC_IP'], 'IPv4', rev_ip4, output, zen)
if not env['PUBLIC_IPV6']:
return
from ipaddress import IPv6Address
rev_ip6 = ".".join(reversed(IPv6Address(env['PUBLIC_IPV6']).exploded.split(':')))
zen = query_dns(rev_ip6+'.zen.spamhaus.org', 'A', nxdomain=None)
evaluate_spamhaus_lookup(env['PUBLIC_IPV6'], 'IPv6', rev_ip6, output, zen)
def evaluate_spamhaus_lookup(lookupaddress, lookuptype, lookupdomain, output, zen):
# See https://www.spamhaus.org/news/article/807/using-our-public-mirrors-check-your-return-codes-now. for
# information on spamhaus return codes
if zen is None: if zen is None:
output.print_ok("IP address is not blacklisted by zen.spamhaus.org.") output.print_ok(f"{lookuptype} address is not blacklisted by zen.spamhaus.org.")
elif zen == "[timeout]": elif zen == "[timeout]":
output.print_warning("Connection to zen.spamhaus.org timed out. We could not determine whether your server's IP address is blacklisted. Please try again later.") output.print_warning(f"""Connection to zen.spamhaus.org timed out. Could not determine whether this box's
{lookuptype} address is blacklisted. Please try again later.""")
elif zen == "[Not Set]":
output.print_warning(f"""Could not connect to zen.spamhaus.org. Could not determine whether this box's
{lookuptype} address is blacklisted. Please try again later.""")
elif zen == "127.255.255.252":
output.print_warning(f"""Incorrect spamhaus query: {lookupdomain + '.zen.spamhaus.org'}. Could not determine whether
this box's {lookuptype} address is blacklisted.""")
elif zen == "127.255.255.254":
output.print_warning(f"""Mail-in-a-Box is configured to use a public DNS server. This is not supported by
spamhaus. Could not determine whether this box's {lookuptype} address is blacklisted.""")
elif zen == "127.255.255.255":
output.print_warning(f"""Too many queries have been performed on the spamhaus server. Could not determine
whether this box's {lookuptype} address is blacklisted.""")
else: else:
output.print_error("""The IP address of this machine %s is listed in the Spamhaus Block List (code %s), output.print_error(f"""The {lookuptype} address of this machine {lookupaddress} is listed in the Spamhaus Block
which may prevent recipients from receiving your email. See http://www.spamhaus.org/query/ip/%s.""" List (code {zen}), which may prevent recipients from receiving your email. See
% (env['PUBLIC_IP'], zen, env['PUBLIC_IP'])) http://www.spamhaus.org/query/ip/{lookupaddress}.""")
def run_domain_checks(rounded_time, env, output, pool, domains_to_check=None): def run_domain_checks(rounded_time, env, output, pool, domains_to_check=None):
# Get the list of domains we handle mail for. # Get the list of domains we handle mail for.
@ -332,7 +375,7 @@ def run_domain_checks(rounded_time, env, output, pool, domains_to_check=None):
domains_to_check = [ domains_to_check = [
d for d in domains_to_check d for d in domains_to_check
if not ( if not (
d.split(".", 1)[0] in ("www", "autoconfig", "autodiscover", "mta-sts") d.split(".", 1)[0] in {"www", "autoconfig", "autodiscover", "mta-sts"}
and len(d.split(".", 1)) == 2 and len(d.split(".", 1)) == 2
and d.split(".", 1)[1] in domains_to_check and d.split(".", 1)[1] in domains_to_check
) )
@ -414,8 +457,7 @@ def check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles):
# If a DS record is set on the zone containing this domain, check DNSSEC now. # If a DS record is set on the zone containing this domain, check DNSSEC now.
has_dnssec = False has_dnssec = False
for zone in dns_domains: for zone in dns_domains:
if zone == domain or domain.endswith("." + zone): if (zone == domain or domain.endswith("." + zone)) and query_dns(zone, "DS", nxdomain=None) is not None:
if query_dns(zone, "DS", nxdomain=None) is not None:
has_dnssec = True has_dnssec = True
check_dnssec(zone, env, output, dns_zonefiles, is_checking_primary=True) check_dnssec(zone, env, output, dns_zonefiles, is_checking_primary=True)
@ -429,73 +471,69 @@ def check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles):
# the nameserver, are reporting the right info --- but if the glue is incorrect this # the nameserver, are reporting the right info --- but if the glue is incorrect this
# will probably fail. # will probably fail.
if ns_ips == env['PUBLIC_IP'] + '/' + env['PUBLIC_IP']: if ns_ips == env['PUBLIC_IP'] + '/' + env['PUBLIC_IP']:
output.print_ok("Nameserver glue records are correct at registrar. [ns1/ns2.%s%s]" % (env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'])) output.print_ok("Nameserver glue records are correct at registrar. [ns1/ns2.{}{}]".format(env['PRIMARY_HOSTNAME'], env['PUBLIC_IP']))
elif ip == env['PUBLIC_IP']: elif ip == env['PUBLIC_IP']:
# The NS records are not what we expect, but the domain resolves correctly, so # The NS records are not what we expect, but the domain resolves correctly, so
# the user may have set up external DNS. List this discrepancy as a warning. # the user may have set up external DNS. List this discrepancy as a warning.
output.print_warning("""Nameserver glue records (ns1.%s and ns2.%s) should be configured at your domain name output.print_warning("""Nameserver glue records (ns1.{} and ns2.{}) should be configured at your domain name
registrar as having the IP address of this box (%s). They currently report addresses of %s. If you have set up External DNS, this may be OK.""" registrar as having the IP address of this box ({}). They currently report addresses of {}. If you have set up External DNS, this may be OK.""".format(env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'], ns_ips))
% (env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'], ns_ips))
else: else:
output.print_error("""Nameserver glue records are incorrect. The ns1.%s and ns2.%s nameservers must be configured at your domain name output.print_error("""Nameserver glue records are incorrect. The ns1.{} and ns2.{} nameservers must be configured at your domain name
registrar as having the IP address %s. They currently report addresses of %s. It may take several hours for registrar as having the IP address {}. They currently report addresses of {}. It may take several hours for
public DNS to update after a change.""" public DNS to update after a change.""".format(env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'], ns_ips))
% (env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'], ns_ips))
# Check that PRIMARY_HOSTNAME resolves to PUBLIC_IP[V6] in public DNS. # Check that PRIMARY_HOSTNAME resolves to PUBLIC_IP[V6] in public DNS.
ipv6 = query_dns(domain, "AAAA") if env.get("PUBLIC_IPV6") else None ipv6 = query_dns(domain, "AAAA") if env.get("PUBLIC_IPV6") else None
if ip == env['PUBLIC_IP'] and not (ipv6 and env['PUBLIC_IPV6'] and ipv6 != normalize_ip(env['PUBLIC_IPV6'])): if ip == env['PUBLIC_IP'] and not (ipv6 and env['PUBLIC_IPV6'] and ipv6 != normalize_ip(env['PUBLIC_IPV6'])):
output.print_ok("Domain resolves to box's IP address. [%s%s]" % (env['PRIMARY_HOSTNAME'], my_ips)) output.print_ok("Domain resolves to box's IP address. [{}{}]".format(env['PRIMARY_HOSTNAME'], my_ips))
else: else:
output.print_error("""This domain must resolve to your box's IP address (%s) in public DNS but it currently resolves output.print_error("""This domain must resolve to this box's IP address ({}) in public DNS but it currently resolves
to %s. It may take several hours for public DNS to update after a change. This problem may result from other to {}. It may take several hours for public DNS to update after a change. This problem may result from other
issues listed above.""" issues listed above.""".format(my_ips, ip + ((" / " + ipv6) if ipv6 is not None else "")))
% (my_ips, ip + ((" / " + ipv6) if ipv6 is not None else "")))
# Check reverse DNS matches the PRIMARY_HOSTNAME. Note that it might not be # Check reverse DNS matches the PRIMARY_HOSTNAME. Note that it might not be
# a DNS zone if it is a subdomain of another domain we have a zone for. # a DNS zone if it is a subdomain of another domain we have a zone for.
existing_rdns_v4 = query_dns(dns.reversename.from_address(env['PUBLIC_IP']), "PTR") existing_rdns_v4 = query_dns(dns.reversename.from_address(env['PUBLIC_IP']), "PTR")
existing_rdns_v6 = query_dns(dns.reversename.from_address(env['PUBLIC_IPV6']), "PTR") if env.get("PUBLIC_IPV6") else None existing_rdns_v6 = query_dns(dns.reversename.from_address(env['PUBLIC_IPV6']), "PTR") if env.get("PUBLIC_IPV6") else None
if existing_rdns_v4 == domain and existing_rdns_v6 in (None, domain): if existing_rdns_v4 == domain and existing_rdns_v6 in {None, domain}:
output.print_ok("Reverse DNS is set correctly at ISP. [%s%s]" % (my_ips, env['PRIMARY_HOSTNAME'])) output.print_ok("Reverse DNS is set correctly at ISP. [{}{}]".format(my_ips, env['PRIMARY_HOSTNAME']))
elif existing_rdns_v4 == existing_rdns_v6 or existing_rdns_v6 is None: elif existing_rdns_v4 == existing_rdns_v6 or existing_rdns_v6 is None:
output.print_error("""Your box's reverse DNS is currently %s, but it should be %s. Your ISP or cloud provider will have instructions output.print_error(f"""This box's reverse DNS is currently {existing_rdns_v4}, but it should be {domain}. Your ISP or cloud provider will have instructions
on setting up reverse DNS for your box.""" % (existing_rdns_v4, domain) ) on setting up reverse DNS for this box.""" )
else: else:
output.print_error("""Your box's reverse DNS is currently %s (IPv4) and %s (IPv6), but it should be %s. Your ISP or cloud provider will have instructions output.print_error(f"""This box's reverse DNS is currently {existing_rdns_v4} (IPv4) and {existing_rdns_v6} (IPv6), but it should be {domain}. Your ISP or cloud provider will have instructions
on setting up reverse DNS for your box.""" % (existing_rdns_v4, existing_rdns_v6, domain) ) on setting up reverse DNS for this box.""" )
# Check the TLSA record. # Check the TLSA record.
tlsa_qname = "_25._tcp." + domain tlsa_qname = "_25._tcp." + domain
tlsa25 = query_dns(tlsa_qname, "TLSA", nxdomain=None) tlsa25 = query_dns(tlsa_qname, "TLSA", nxdomain=None)
tlsa25_expected = build_tlsa_record(env) tlsa25_expected = build_tlsa_record(env)
if tlsa25 == tlsa25_expected: if tlsa25 == tlsa25_expected:
output.print_ok("""The DANE TLSA record for incoming mail is correct (%s).""" % tlsa_qname,) output.print_ok(f"""The DANE TLSA record for incoming mail is correct ({tlsa_qname}).""",)
elif tlsa25 is None: elif tlsa25 is None:
if has_dnssec: if has_dnssec:
# Omit a warning about it not being set if DNSSEC isn't enabled, # Omit a warning about it not being set if DNSSEC isn't enabled,
# since TLSA shouldn't be used without DNSSEC. # since TLSA shouldn't be used without DNSSEC.
output.print_warning("""The DANE TLSA record for incoming mail is not set. This is optional.""") output.print_warning("""The DANE TLSA record for incoming mail is not set. This is optional.""")
else: else:
output.print_error("""The DANE TLSA record for incoming mail (%s) is not correct. It is '%s' but it should be '%s'. output.print_error(f"""The DANE TLSA record for incoming mail ({tlsa_qname}) is not correct. It is '{tlsa25}' but it should be '{tlsa25_expected}'.
It may take several hours for public DNS to update after a change.""" It may take several hours for public DNS to update after a change.""")
% (tlsa_qname, tlsa25, tlsa25_expected))
# Check that the hostmaster@ email address exists. # Check that the hostmaster@ email address exists.
check_alias_exists("Hostmaster contact address", "hostmaster@" + domain, env, output) check_alias_exists("Hostmaster contact address", "hostmaster@" + domain, env, output)
def check_alias_exists(alias_name, alias, env, output): def check_alias_exists(alias_name, alias, env, output):
mail_aliases = dict([(address, receivers) for address, receivers, *_ in get_mail_aliases(env)]) mail_aliases = {address: receivers for address, receivers, *_ in get_mail_aliases(env)}
if alias in mail_aliases: if alias in mail_aliases:
if mail_aliases[alias]: if mail_aliases[alias]:
output.print_ok("%s exists as a mail alias. [%s%s]" % (alias_name, alias, mail_aliases[alias])) output.print_ok(f"{alias_name} exists as a mail alias. [{alias}{mail_aliases[alias]}]")
else: else:
output.print_error("""You must set the destination of the mail alias for %s to direct email to you or another administrator.""" % alias) output.print_error(f"""You must set the destination of the mail alias for {alias} to direct email to you or another administrator.""")
else: else:
output.print_error("""You must add a mail alias for %s which directs email to you or another administrator.""" % alias) output.print_error(f"""You must add a mail alias for {alias} which directs email to you or another administrator.""")
def check_dns_zone(domain, env, output, dns_zonefiles): def check_dns_zone(domain, env, output, dns_zonefiles):
# If a DS record is set at the registrar, check DNSSEC first because it will affect the NS query. # If a DS record is set at the registrar, check DNSSEC first because it will affect the NS query.
@ -517,44 +555,69 @@ def check_dns_zone(domain, env, output, dns_zonefiles):
secondary_ns = custom_secondary_ns or ["ns2." + env['PRIMARY_HOSTNAME']] secondary_ns = custom_secondary_ns or ["ns2." + env['PRIMARY_HOSTNAME']]
existing_ns = query_dns(domain, "NS") existing_ns = query_dns(domain, "NS")
correct_ns = "; ".join(sorted(["ns1." + env['PRIMARY_HOSTNAME']] + secondary_ns)) correct_ns = "; ".join(sorted(["ns1." + env["PRIMARY_HOSTNAME"], *secondary_ns]))
ip = query_dns(domain, "A") ip = query_dns(domain, "A")
probably_external_dns = False probably_external_dns = False
if existing_ns.lower() == correct_ns.lower(): if existing_ns.lower() == correct_ns.lower():
output.print_ok("Nameservers are set correctly at registrar. [%s]" % correct_ns) output.print_ok(f"Nameservers are set correctly at registrar. [{correct_ns}]")
elif ip == correct_ip: elif ip == correct_ip:
# The domain resolves correctly, so maybe the user is using External DNS. # The domain resolves correctly, so maybe the user is using External DNS.
output.print_warning("""The nameservers set on this domain at your domain name registrar should be %s. They are currently %s. output.print_warning(f"""The nameservers set on this domain at your domain name registrar should be {correct_ns}. They are currently {existing_ns}.
If you are using External DNS, this may be OK.""" If you are using External DNS, this may be OK.""" )
% (correct_ns, existing_ns) )
probably_external_dns = True probably_external_dns = True
else: else:
output.print_error("""The nameservers set on this domain are incorrect. They are currently %s. Use your domain name registrar's output.print_error(f"""The nameservers set on this domain are incorrect. They are currently {existing_ns}. Use your domain name registrar's
control panel to set the nameservers to %s.""" control panel to set the nameservers to {correct_ns}.""" )
% (existing_ns, correct_ns) )
# Check that each custom secondary nameserver resolves the IP address. # Check that each custom secondary nameserver resolves the IP address.
if custom_secondary_ns and not probably_external_dns: if custom_secondary_ns and not probably_external_dns:
SOARecord = query_dns(domain, "SOA", at=env['PUBLIC_IP'])# Explicitly ask the local dns server.
for ns in custom_secondary_ns: for ns in custom_secondary_ns:
# We must first resolve the nameserver to an IP address so we can query it. # We must first resolve the nameserver to an IP address so we can query it.
ns_ips = query_dns(ns, "A") ns_ips = query_dns(ns, "A")
if not ns_ips: if not ns_ips or ns_ips in {'[Not Set]', '[timeout]'}:
output.print_error("Secondary nameserver %s is not valid (it doesn't resolve to an IP address)." % ns) output.print_error(f"Secondary nameserver {ns} is not valid (it doesn't resolve to an IP address).")
continue continue
# Choose the first IP if nameserver returns multiple # Choose the first IP if nameserver returns multiple
ns_ip = ns_ips.split('; ')[0] ns_ip = ns_ips.split('; ')[0]
# No need to check if we could not obtain the SOA record
if SOARecord == '[timeout]':
checkSOA = False
else:
checkSOA = True
# Now query it to see what it says about this domain. # Now query it to see what it says about this domain.
ip = query_dns(domain, "A", at=ns_ip, nxdomain=None) ip = query_dns(domain, "A", at=ns_ip, nxdomain=None)
if ip == correct_ip: if ip == correct_ip:
output.print_ok("Secondary nameserver %s resolved the domain correctly." % ns) output.print_ok(f"Secondary nameserver {ns} resolved the domain correctly.")
elif ip is None: elif ip is None:
output.print_error("Secondary nameserver %s is not configured to resolve this domain." % ns) output.print_error(f"Secondary nameserver {ns} is not configured to resolve this domain.")
# No need to check SOA record if not configured as nameserver
checkSOA = False
elif ip == '[timeout]':
output.print_error(f"Secondary nameserver {ns} did not resolve this domain, result: {ip}")
checkSOA = False
else: else:
output.print_error("Secondary nameserver %s is not configured correctly. (It resolved this domain as %s. It should be %s.)" % (ns, ip, correct_ip)) output.print_error(f"Secondary nameserver {ns} is not configured correctly. (It resolved this domain as {ip}. It should be {correct_ip}.)")
if checkSOA:
# Check that secondary DNS server is synchronized with our primary DNS server. Simplified by checking the SOA record which has a version number
SOASecondary = query_dns(domain, "SOA", at=ns_ip)
if SOARecord == SOASecondary:
output.print_ok(f"Secondary nameserver {ns} has consistent SOA record.")
elif SOASecondary == '[Not Set]':
output.print_error(f"Secondary nameserver {ns} has no SOA record configured.")
elif SOASecondary == '[timeout]':
output.print_warning(f"Secondary nameserver {ns} timed out on checking SOA record.")
else:
output.print_error(f"""Secondary nameserver {ns} has inconsistent SOA record (primary: {SOARecord} versus secondary: {SOASecondary}).
Check that synchronization between secondary and primary DNS servers is properly set-up.""")
def check_dns_zone_suggestions(domain, env, output, dns_zonefiles, domains_with_a_records): def check_dns_zone_suggestions(domain, env, output, dns_zonefiles, domains_with_a_records):
# Warn if a custom DNS record is preventing this or the automatic www redirect from # Warn if a custom DNS record is preventing this or the automatic www redirect from
@ -562,7 +625,7 @@ def check_dns_zone_suggestions(domain, env, output, dns_zonefiles, domains_with_
if domain in domains_with_a_records: if domain in domains_with_a_records:
output.print_warning("""Web has been disabled for this domain because you have set a custom DNS record.""") output.print_warning("""Web has been disabled for this domain because you have set a custom DNS record.""")
if "www." + domain in domains_with_a_records: if "www." + domain in domains_with_a_records:
output.print_warning("""A redirect from 'www.%s' has been disabled for this domain because you have set a custom DNS record on the www subdomain.""" % domain) output.print_warning(f"""A redirect from 'www.{domain}' has been disabled for this domain because you have set a custom DNS record on the www subdomain.""")
# Since DNSSEC is optional, if a DS record is NOT set at the registrar suggest it. # Since DNSSEC is optional, if a DS record is NOT set at the registrar suggest it.
# (If it was set, we did the check earlier.) # (If it was set, we did the check earlier.)
@ -583,7 +646,7 @@ def check_dnssec(domain, env, output, dns_zonefiles, is_checking_primary=False):
expected_ds_records = { } expected_ds_records = { }
ds_file = '/etc/nsd/zones/' + dns_zonefiles[domain] + '.ds' ds_file = '/etc/nsd/zones/' + dns_zonefiles[domain] + '.ds'
if not os.path.exists(ds_file): return # Domain is in our database but DNS has not yet been updated. if not os.path.exists(ds_file): return # Domain is in our database but DNS has not yet been updated.
with open(ds_file) as f: with open(ds_file, encoding="utf-8") as f:
for rr_ds in f: for rr_ds in f:
rr_ds = rr_ds.rstrip() rr_ds = rr_ds.rstrip()
ds_keytag, ds_alg, ds_digalg, ds_digest = rr_ds.split("\t")[4].split(" ") ds_keytag, ds_alg, ds_digalg, ds_digest = rr_ds.split("\t")[4].split(" ")
@ -591,10 +654,11 @@ def check_dnssec(domain, env, output, dns_zonefiles, is_checking_primary=False):
# Some registrars may want the public key so they can compute the digest. The DS # Some registrars may want the public key so they can compute the digest. The DS
# record that we suggest using is for the KSK (and that's how the DS records were generated). # record that we suggest using is for the KSK (and that's how the DS records were generated).
# We'll also give the nice name for the key algorithm. # We'll also give the nice name for the key algorithm.
dnssec_keys = load_env_vars_from_file(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/%s.conf' % alg_name_map[ds_alg])) dnssec_keys = load_env_vars_from_file(os.path.join(env['STORAGE_ROOT'], f'dns/dnssec/{alg_name_map[ds_alg]}.conf'))
dnsssec_pubkey = open(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/' + dnssec_keys['KSK'] + '.key')).read().split("\t")[3].split(" ")[3] with open(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/' + dnssec_keys['KSK'] + '.key'), encoding="utf-8") as f:
dnsssec_pubkey = f.read().split("\t")[3].split(" ")[3]
expected_ds_records[ (ds_keytag, ds_alg, ds_digalg, ds_digest) ] = { expected_ds_records[ ds_keytag, ds_alg, ds_digalg, ds_digest ] = {
"record": rr_ds, "record": rr_ds,
"keytag": ds_keytag, "keytag": ds_keytag,
"alg": ds_alg, "alg": ds_alg,
@ -624,19 +688,19 @@ def check_dnssec(domain, env, output, dns_zonefiles, is_checking_primary=False):
# #
# But it may not be preferred. Only algorithm 13 is preferred. Warn if any of the # But it may not be preferred. Only algorithm 13 is preferred. Warn if any of the
# matched zones uses a different algorithm. # matched zones uses a different algorithm.
if set(r[1] for r in matched_ds) == { '13' } and set(r[2] for r in matched_ds) <= { '2', '4' }: # all are alg 13 and digest type 2 or 4 if {r[1] for r in matched_ds} == { '13' } and {r[2] for r in matched_ds} <= { '2', '4' }: # all are alg 13 and digest type 2 or 4
output.print_ok("DNSSEC 'DS' record is set correctly at registrar.") output.print_ok("DNSSEC 'DS' record is set correctly at registrar.")
return return
elif len([r for r in matched_ds if r[1] == '13' and r[2] in ( '2', '4' )]) > 0: # some but not all are alg 13 if len([r for r in matched_ds if r[1] == '13' and r[2] in { '2', '4' }]) > 0: # some but not all are alg 13
output.print_ok("DNSSEC 'DS' record is set correctly at registrar. (Records using algorithm other than ECDSAP256SHA256 and digest types other than SHA-256/384 should be removed.)") output.print_ok("DNSSEC 'DS' record is set correctly at registrar. (Records using algorithm other than ECDSAP256SHA256 and digest types other than SHA-256/384 should be removed.)")
return return
else: # no record uses alg 13 # no record uses alg 13
output.print_warning("""DNSSEC 'DS' record set at registrar is valid but should be updated to ECDSAP256SHA256 and SHA-256 (see below). output.print_warning("""DNSSEC 'DS' record set at registrar is valid but should be updated to ECDSAP256SHA256 and SHA-256 (see below).
IMPORTANT: Do not delete existing DNSSEC 'DS' records for this domain until confirmation that the new DNSSEC 'DS' record IMPORTANT: Do not delete existing DNSSEC 'DS' records for this domain until confirmation that the new DNSSEC 'DS' record
for this domain is valid.""") for this domain is valid.""")
else: else:
if is_checking_primary: if is_checking_primary:
output.print_error("""The DNSSEC 'DS' record for %s is incorrect. See further details below.""" % domain) output.print_error(f"""The DNSSEC 'DS' record for {domain} is incorrect. See further details below.""")
return return
output.print_error("""This domain's DNSSEC DS record is incorrect. The chain of trust is broken between the public DNS system output.print_error("""This domain's DNSSEC DS record is incorrect. The chain of trust is broken between the public DNS system
and this machine's DNS server. It may take several hours for public DNS to update after a change. If you did not recently and this machine's DNS server. It may take several hours for public DNS to update after a change. If you did not recently
@ -659,8 +723,8 @@ def check_dnssec(domain, env, output, dns_zonefiles, is_checking_primary=False):
output.print_line("----------") output.print_line("----------")
output.print_line("Key Tag: " + ds_suggestion['keytag']) output.print_line("Key Tag: " + ds_suggestion['keytag'])
output.print_line("Key Flags: KSK / 257") output.print_line("Key Flags: KSK / 257")
output.print_line("Algorithm: %s / %s" % (ds_suggestion['alg'], ds_suggestion['alg_name'])) output.print_line("Algorithm: {} / {}".format(ds_suggestion['alg'], ds_suggestion['alg_name']))
output.print_line("Digest Type: %s / %s" % (ds_suggestion['digalg'], ds_suggestion['digalg_name'])) output.print_line("Digest Type: {} / {}".format(ds_suggestion['digalg'], ds_suggestion['digalg_name']))
output.print_line("Digest: " + ds_suggestion['digest']) output.print_line("Digest: " + ds_suggestion['digest'])
output.print_line("Public Key: ") output.print_line("Public Key: ")
output.print_line(ds_suggestion['pubkey'], monospace=True) output.print_line(ds_suggestion['pubkey'], monospace=True)
@ -671,7 +735,7 @@ def check_dnssec(domain, env, output, dns_zonefiles, is_checking_primary=False):
output.print_line("") output.print_line("")
output.print_line("The DS record is currently set to:") output.print_line("The DS record is currently set to:")
for rr in sorted(ds): for rr in sorted(ds):
output.print_line("Key Tag: {0}, Algorithm: {1}, Digest Type: {2}, Digest: {3}".format(*rr)) output.print_line("Key Tag: {}, Algorithm: {}, Digest Type: {}, Digest: {}".format(*rr))
def check_mail_domain(domain, env, output): def check_mail_domain(domain, env, output):
# Check the MX record. # Check the MX record.
@ -679,21 +743,19 @@ def check_mail_domain(domain, env, output):
recommended_mx = "10 " + env['PRIMARY_HOSTNAME'] recommended_mx = "10 " + env['PRIMARY_HOSTNAME']
mx = query_dns(domain, "MX", nxdomain=None) mx = query_dns(domain, "MX", nxdomain=None)
if mx is None: if mx is None or mx == "[timeout]":
mxhost = None
elif mx == "[timeout]":
mxhost = None mxhost = None
else: else:
# query_dns returns a semicolon-delimited list # query_dns returns a semicolon-delimited list
# of priority-host pairs. # of priority-host pairs.
mxhost = mx.split('; ')[0].split(' ')[1] mxhost = mx.split('; ')[0].split(' ')[1]
if mxhost == None: if mxhost is None:
# A missing MX record is okay on the primary hostname because # A missing MX record is okay on the primary hostname because
# the primary hostname's A record (the MX fallback) is... itself, # the primary hostname's A record (the MX fallback) is... itself,
# which is what we want the MX to be. # which is what we want the MX to be.
if domain == env['PRIMARY_HOSTNAME']: if domain == env['PRIMARY_HOSTNAME']:
output.print_ok("Domain's email is directed to this domain. [%s has no MX record, which is ok]" % (domain,)) output.print_ok(f"Domain's email is directed to this domain. [{domain} has no MX record, which is ok]")
# And a missing MX record is okay on other domains if the A record # And a missing MX record is okay on other domains if the A record
# matches the A record of the PRIMARY_HOSTNAME. Actually this will # matches the A record of the PRIMARY_HOSTNAME. Actually this will
@ -701,17 +763,17 @@ def check_mail_domain(domain, env, output):
else: else:
domain_a = query_dns(domain, "A", nxdomain=None) domain_a = query_dns(domain, "A", nxdomain=None)
primary_a = query_dns(env['PRIMARY_HOSTNAME'], "A", nxdomain=None) primary_a = query_dns(env['PRIMARY_HOSTNAME'], "A", nxdomain=None)
if domain_a != None and domain_a == primary_a: if domain_a is not None and domain_a == primary_a:
output.print_ok("Domain's email is directed to this domain. [%s has no MX record but its A record is OK]" % (domain,)) output.print_ok(f"Domain's email is directed to this domain. [{domain} has no MX record but its A record is OK]")
else: else:
output.print_error("""This domain's DNS MX record is not set. It should be '%s'. Mail will not output.print_error(f"""This domain's DNS MX record is not set. It should be '{recommended_mx}'. Mail will not
be delivered to this box. It may take several hours for public DNS to update after a be delivered to this box. It may take several hours for public DNS to update after a
change. This problem may result from other issues listed here.""" % (recommended_mx,)) change. This problem may result from other issues listed here.""")
elif mxhost == env['PRIMARY_HOSTNAME']: elif mxhost == env['PRIMARY_HOSTNAME']:
good_news = "Domain's email is directed to this domain. [%s%s]" % (domain, mx) good_news = f"Domain's email is directed to this domain. [{domain}{mx}]"
if mx != recommended_mx: if mx != recommended_mx:
good_news += " This configuration is non-standard. The recommended configuration is '%s'." % (recommended_mx,) good_news += f" This configuration is non-standard. The recommended configuration is '{recommended_mx}'."
output.print_ok(good_news) output.print_ok(good_news)
# Check MTA-STS policy. # Check MTA-STS policy.
@ -722,14 +784,14 @@ def check_mail_domain(domain, env, output):
if policy[1].get("mx") == [env['PRIMARY_HOSTNAME']] and policy[1].get("mode") == "enforce": # policy[0] is the policyid if policy[1].get("mx") == [env['PRIMARY_HOSTNAME']] and policy[1].get("mode") == "enforce": # policy[0] is the policyid
output.print_ok("MTA-STS policy is present.") output.print_ok("MTA-STS policy is present.")
else: else:
output.print_error("MTA-STS policy is present but has unexpected settings. [{}]".format(policy[1])) output.print_error(f"MTA-STS policy is present but has unexpected settings. [{policy[1]}]")
else: else:
output.print_error("MTA-STS policy is missing: {}".format(valid)) output.print_error(f"MTA-STS policy is missing: {valid}")
else: else:
output.print_error("""This domain's DNS MX record is incorrect. It is currently set to '%s' but should be '%s'. Mail will not output.print_error(f"""This domain's DNS MX record is incorrect. It is currently set to '{mx}' but should be '{recommended_mx}'. Mail will not
be delivered to this box. It may take several hours for public DNS to update after a change. This problem may result from be delivered to this box. It may take several hours for public DNS to update after a change. This problem may result from
other issues listed here.""" % (mx, recommended_mx)) other issues listed here.""")
# Check that the postmaster@ email address exists. Not required if the domain has a # Check that the postmaster@ email address exists. Not required if the domain has a
# catch-all address or domain alias. # catch-all address or domain alias.
@ -739,15 +801,26 @@ def check_mail_domain(domain, env, output):
# Stop if the domain is listed in the Spamhaus Domain Block List. # Stop if the domain is listed in the Spamhaus Domain Block List.
# The user might have chosen a domain that was previously in use by a spammer # The user might have chosen a domain that was previously in use by a spammer
# and will not be able to reliably send mail. # and will not be able to reliably send mail.
# See https://www.spamhaus.org/news/article/807/using-our-public-mirrors-check-your-return-codes-now. for
# information on spamhaus return codes
dbl = query_dns(domain+'.dbl.spamhaus.org', "A", nxdomain=None) dbl = query_dns(domain+'.dbl.spamhaus.org', "A", nxdomain=None)
if dbl is None: if dbl is None:
output.print_ok("Domain is not blacklisted by dbl.spamhaus.org.") output.print_ok("Domain is not blacklisted by dbl.spamhaus.org.")
elif dbl == "[timeout]": elif dbl == "[timeout]":
output.print_warning("Connection to dbl.spamhaus.org timed out. We could not determine whether the domain {} is blacklisted. Please try again later.".format(domain)) output.print_warning(f"Connection to dbl.spamhaus.org timed out. Could not determine whether the domain {domain} is blacklisted. Please try again later.")
elif dbl == "[Not Set]":
output.print_warning(f"Could not connect to dbl.spamhaus.org. Could not determine whether the domain {domain} is blacklisted. Please try again later.")
elif dbl == "127.255.255.252":
output.print_warning("Incorrect spamhaus query: {}. Could not determine whether the domain {} is blacklisted.".format(domain+'.dbl.spamhaus.org', domain))
elif dbl == "127.255.255.254":
output.print_warning(f"Mail-in-a-Box is configured to use a public DNS server. This is not supported by spamhaus. Could not determine whether the domain {domain} is blacklisted.")
elif dbl == "127.255.255.255":
output.print_warning(f"Too many queries have been performed on the spamhaus server. Could not determine whether the domain {domain} is blacklisted.")
else: else:
output.print_error("""This domain is listed in the Spamhaus Domain Block List (code %s), output.print_error(f"""This domain is listed in the Spamhaus Domain Block List (code {dbl}),
which may prevent recipients from receiving your mail. which may prevent recipients from receiving your mail.
See http://www.spamhaus.org/dbl/ and http://www.spamhaus.org/query/domain/%s.""" % (dbl, domain)) See http://www.spamhaus.org/dbl/ and http://www.spamhaus.org/query/domain/{domain}.""")
def check_web_domain(domain, rounded_time, ssl_certificates, env, output): def check_web_domain(domain, rounded_time, ssl_certificates, env, output):
# See if the domain's A record resolves to our PUBLIC_IP. This is already checked # See if the domain's A record resolves to our PUBLIC_IP. This is already checked
@ -761,13 +834,13 @@ def check_web_domain(domain, rounded_time, ssl_certificates, env, output):
if value == normalize_ip(expected): if value == normalize_ip(expected):
ok_values.append(value) ok_values.append(value)
else: else:
output.print_error("""This domain should resolve to your box's IP address (%s %s) if you would like the box to serve output.print_error(f"""This domain should resolve to this box's IP address ({rtype} {expected}) if you would like the box to serve
webmail or a website on this domain. The domain currently resolves to %s in public DNS. It may take several hours for webmail or a website on this domain. The domain currently resolves to {value} in public DNS. It may take several hours for
public DNS to update after a change. This problem may result from other issues listed here.""" % (rtype, expected, value)) public DNS to update after a change. This problem may result from other issues listed here.""")
return return
# If both A and AAAA are correct... # If both A and AAAA are correct...
output.print_ok("Domain resolves to this box's IP address. [%s%s]" % (domain, '; '.join(ok_values))) output.print_ok("Domain resolves to this box's IP address. [{}{}]".format(domain, '; '.join(ok_values)))
# We need a TLS certificate for PRIMARY_HOSTNAME because that's where the # We need a TLS certificate for PRIMARY_HOSTNAME because that's where the
@ -788,12 +861,17 @@ def query_dns(qname, rtype, nxdomain='[Not Set]', at=None, as_list=False):
# running bind server), or if the 'at' argument is specified, use that host # running bind server), or if the 'at' argument is specified, use that host
# as the nameserver. # as the nameserver.
resolver = dns.resolver.get_default_resolver() resolver = dns.resolver.get_default_resolver()
if at:
# Make sure at is not a string that cannot be used as a nameserver
if at and at not in {'[Not set]', '[timeout]'}:
resolver = dns.resolver.Resolver() resolver = dns.resolver.Resolver()
resolver.nameservers = [at] resolver.nameservers = [at]
# Set a timeout so that a non-responsive server doesn't hold us back. # Set a timeout so that a non-responsive server doesn't hold us back.
resolver.timeout = 5 resolver.timeout = 5
# The number of seconds to spend trying to get an answer to the question. If the
# lifetime expires a dns.exception.Timeout exception will be raised.
resolver.lifetime = 5
# Do the query. # Do the query.
try: try:
@ -809,7 +887,7 @@ def query_dns(qname, rtype, nxdomain='[Not Set]', at=None, as_list=False):
# be expressed in equivalent string forms. Canonicalize the form before # be expressed in equivalent string forms. Canonicalize the form before
# returning them. The caller should normalize any IP addresses the result # returning them. The caller should normalize any IP addresses the result
# of this method is compared with. # of this method is compared with.
if rtype in ("A", "AAAA"): if rtype in {"A", "AAAA"}:
response = [normalize_ip(str(r)) for r in response] response = [normalize_ip(str(r)) for r in response]
if as_list: if as_list:
@ -825,7 +903,7 @@ def check_ssl_cert(domain, rounded_time, ssl_certificates, env, output):
# Check that TLS certificate is signed. # Check that TLS certificate is signed.
# Skip the check if the A record is not pointed here. # Skip the check if the A record is not pointed here.
if query_dns(domain, "A", None) not in (env['PUBLIC_IP'], None): return if query_dns(domain, "A", None) not in {env['PUBLIC_IP'], None}: return
# Where is the certificate file stored? # Where is the certificate file stored?
tls_cert = get_domain_ssl_files(domain, ssl_certificates, env, allow_missing_cert=True) tls_cert = get_domain_ssl_files(domain, ssl_certificates, env, allow_missing_cert=True)
@ -895,22 +973,20 @@ def list_apt_updates(apt_update=True):
return pkgs return pkgs
def what_version_is_this(env): def what_version_is_this(env):
# This function runs `git describe --abbrev=0` on the Mail-in-a-Box installation directory. # This function runs `git describe --always --abbrev=0` on the Mail-in-a-Box installation directory.
# Git may not be installed and Mail-in-a-Box may not have been cloned from github, # Git may not be installed and Mail-in-a-Box may not have been cloned from github,
# so this function may raise all sorts of exceptions. # so this function may raise all sorts of exceptions.
miab_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) miab_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
tag = shell("check_output", ["/usr/bin/git", "describe", "--abbrev=0"], env={"GIT_DIR": os.path.join(miab_dir, '.git')}).strip() return shell("check_output", ["/usr/bin/git", "describe", "--always", "--abbrev=0"], env={"GIT_DIR": os.path.join(miab_dir, '.git')}).strip()
return tag
def get_latest_miab_version(): def get_latest_miab_version():
# This pings https://mailinabox.email/setup.sh and extracts the tag named in # This pings https://mailinabox.email/setup.sh and extracts the tag named in
# the script to determine the current product version. # the script to determine the current product version.
from urllib.request import urlopen, HTTPError, URLError from urllib.request import urlopen, HTTPError, URLError
from socket import timeout
try: try:
return re.search(b'TAG=(.*)', urlopen("https://mailinabox.email/setup.sh?ping=1", timeout=5).read()).group(1).decode("utf8") return re.search(b'TAG=(.*)', urlopen("https://mailinabox.email/setup.sh?ping=1", timeout=5).read()).group(1).decode("utf8")
except (HTTPError, URLError, timeout): except (TimeoutError, HTTPError, URLError):
return None return None
def check_miab_version(env, output): def check_miab_version(env, output):
@ -922,17 +998,16 @@ def check_miab_version(env, output):
this_ver = "Unknown" this_ver = "Unknown"
if config.get("privacy", True): if config.get("privacy", True):
output.print_warning("You are running version Mail-in-a-Box %s. Mail-in-a-Box version check disabled by privacy setting." % this_ver) output.print_warning(f"You are running version Mail-in-a-Box {this_ver}. Mail-in-a-Box version check disabled by privacy setting.")
else: else:
latest_ver = get_latest_miab_version() latest_ver = get_latest_miab_version()
if this_ver == latest_ver: if this_ver == latest_ver:
output.print_ok("Mail-in-a-Box is up to date. You are running version %s." % this_ver) output.print_ok(f"Mail-in-a-Box is up to date. You are running version {this_ver}.")
elif latest_ver is None: elif latest_ver is None:
output.print_error("Latest Mail-in-a-Box version could not be determined. You are running version %s." % this_ver) output.print_error(f"Latest Mail-in-a-Box version could not be determined. You are running version {this_ver}.")
else: else:
output.print_error("A new version of Mail-in-a-Box is available. You are running version %s. The latest version is %s. For upgrade instructions, see https://mailinabox.email. " output.print_error(f"A new version of Mail-in-a-Box is available. You are running version {this_ver}. The latest version is {latest_ver}. For upgrade instructions, see https://mailinabox.email. ")
% (this_ver, latest_ver))
def run_and_output_changes(env, pool): def run_and_output_changes(env, pool):
import json import json
@ -947,7 +1022,11 @@ def run_and_output_changes(env, pool):
# Load previously saved status checks. # Load previously saved status checks.
cache_fn = "/var/cache/mailinabox/status_checks.json" cache_fn = "/var/cache/mailinabox/status_checks.json"
if os.path.exists(cache_fn): if os.path.exists(cache_fn):
prev = json.load(open(cache_fn)) with open(cache_fn, encoding="utf-8") as f:
try:
prev = json.load(f)
except json.JSONDecodeError:
prev = []
# Group the serial output into categories by the headings. # Group the serial output into categories by the headings.
def group_by_heading(lines): def group_by_heading(lines):
@ -982,14 +1061,14 @@ def run_and_output_changes(env, pool):
out.add_heading(category + " -- Previously:") out.add_heading(category + " -- Previously:")
elif op == "delete": elif op == "delete":
out.add_heading(category + " -- Removed") out.add_heading(category + " -- Removed")
if op in ("replace", "delete"): if op in {"replace", "delete"}:
BufferedOutput(with_lines=prev_lines[i1:i2]).playback(out) BufferedOutput(with_lines=prev_lines[i1:i2]).playback(out)
if op == "replace": if op == "replace":
out.add_heading(category + " -- Currently:") out.add_heading(category + " -- Currently:")
elif op == "insert": elif op == "insert":
out.add_heading(category + " -- Added") out.add_heading(category + " -- Added")
if op in ("replace", "insert"): if op in {"replace", "insert"}:
BufferedOutput(with_lines=cur_lines[j1:j2]).playback(out) BufferedOutput(with_lines=cur_lines[j1:j2]).playback(out)
for category, prev_lines in prev_status.items(): for category, prev_lines in prev_status.items():
@ -999,7 +1078,7 @@ def run_and_output_changes(env, pool):
# Store the current status checks output for next time. # Store the current status checks output for next time.
os.makedirs(os.path.dirname(cache_fn), exist_ok=True) os.makedirs(os.path.dirname(cache_fn), exist_ok=True)
with open(cache_fn, "w") as f: with open(cache_fn, "w", encoding="utf-8") as f:
json.dump(cur.buf, f, indent=True) json.dump(cur.buf, f, indent=True)
def normalize_ip(ip): def normalize_ip(ip):
@ -1033,8 +1112,8 @@ class FileOutput:
def print_block(self, message, first_line=" "): def print_block(self, message, first_line=" "):
print(first_line, end='', file=self.buf) print(first_line, end='', file=self.buf)
message = re.sub("\n\s*", " ", message) message = re.sub("\n\\s*", " ", message)
words = re.split("(\s+)", message) words = re.split(r"(\s+)", message)
linelen = 0 linelen = 0
for w in words: for w in words:
if self.width and (linelen + len(w) > self.width-1-len(first_line)): if self.width and (linelen + len(w) > self.width-1-len(first_line)):
@ -1073,9 +1152,9 @@ class ConsoleOutput(FileOutput):
class BufferedOutput: class BufferedOutput:
# Record all of the instance method calls so we can play them back later. # Record all of the instance method calls so we can play them back later.
def __init__(self, with_lines=None): def __init__(self, with_lines=None):
self.buf = [] if not with_lines else with_lines self.buf = with_lines or []
def __getattr__(self, attr): def __getattr__(self, attr):
if attr not in ("add_heading", "print_ok", "print_error", "print_warning", "print_block", "print_line"): if attr not in {"add_heading", "print_ok", "print_error", "print_warning", "print_block", "print_line"}:
raise AttributeError raise AttributeError
# Return a function that just records the call & arguments to our buffer. # Return a function that just records the call & arguments to our buffer.
def w(*args, **kwargs): def w(*args, **kwargs):

View File

@ -7,7 +7,7 @@
<h3>Add a mail alias</h3> <h3>Add a mail alias</h3>
<p>Aliases are email forwarders. An alias can forward email to a <a href="#" onclick="return show_panel('users')">mail user</a> or to any email address.</p> <p>Aliases are email forwarders. An alias can forward email to a <a href="#users">mail user</a> or to any email address.</p>
<p>To use an alias or any address besides your own login username in outbound mail, the sending user must be included as a permitted sender for the alias.</p> <p>To use an alias or any address besides your own login username in outbound mail, the sending user must be included as a permitted sender for the alias.</p>

View File

@ -77,7 +77,7 @@
<h3>Using a secondary nameserver</h3> <h3>Using a secondary nameserver</h3>
<p>If your TLD requires you to have two separate nameservers, you can either set up <a href="#" onclick="return show_panel('external_dns')">external DNS</a> and ignore the DNS server on this box entirely, or use the DNS server on this box but add a secondary (aka &ldquo;slave&rdquo;) nameserver.</p> <p>If your TLD requires you to have two separate nameservers, you can either set up <a href="#external_dns">external DNS</a> and ignore the DNS server on this box entirely, or use the DNS server on this box but add a secondary (aka &ldquo;slave&rdquo;) nameserver.</p>
<p>If you choose to use a secondary nameserver, you must find a secondary nameserver service provider. Your domain name registrar or virtual cloud provider may provide this service for you. Once you set up the secondary nameserver service, enter the hostname (not the IP address) of <em>their</em> secondary nameserver in the box below.</p> <p>If you choose to use a secondary nameserver, you must find a secondary nameserver service provider. Your domain name registrar or virtual cloud provider may provide this service for you. Once you set up the secondary nameserver service, enter the hostname (not the IP address) of <em>their</em> secondary nameserver in the box below.</p>
<form class="form-horizontal" role="form" onsubmit="do_set_secondary_dns(); return false;"> <form class="form-horizontal" role="form" onsubmit="do_set_secondary_dns(); return false;">
@ -96,7 +96,7 @@
<div class="col-sm-offset-1 col-sm-11"> <div class="col-sm-offset-1 col-sm-11">
<p class="small"> <p class="small">
Multiple secondary servers can be separated with commas or spaces (i.e., <code>ns2.hostingcompany.com ns3.hostingcompany.com</code>). Multiple secondary servers can be separated with commas or spaces (i.e., <code>ns2.hostingcompany.com ns3.hostingcompany.com</code>).
To enable zone transfers to additional servers without listing them as secondary nameservers, add an IP address or subnet using <code>xfr:10.20.30.40</code> or <code>xfr:10.0.0.0/8</code>. To enable zone transfers to additional servers without listing them as secondary nameservers, prefix a hostname, IP address, or subnet with <code>xfr:</code>, e.g. <code>xfr:10.20.30.40</code> or <code>xfr:10.0.0.0/8</code>.
</p> </p>
<p id="secondarydns-clear-instructions" style="display: none" class="small"> <p id="secondarydns-clear-instructions" style="display: none" class="small">
Clear the input field above and click Update to use this machine itself as secondary DNS, which is the default/normal setup. Clear the input field above and click Update to use this machine itself as secondary DNS, which is the default/normal setup.

View File

@ -73,12 +73,7 @@
filter: invert(100%) hue-rotate(180deg); filter: invert(100%) hue-rotate(180deg);
} }
/* Set explicit background color (necessary for Firefox) */ /* Override Bootstrap theme here to give more contrast. The black turns to white by the filter. */
html {
background-color: #111;
}
/* Override Boostrap theme here to give more contrast. The black turns to white by the filter. */
.form-control { .form-control {
color: black !important; color: black !important;
} }
@ -117,30 +112,30 @@
<li class="dropdown if-logged-in-admin"> <li class="dropdown if-logged-in-admin">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">System <b class="caret"></b></a> <a href="#" class="dropdown-toggle" data-toggle="dropdown">System <b class="caret"></b></a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li><a href="#system_status" onclick="return show_panel(this);">Status Checks</a></li> <li><a href="#system_status">Status Checks</a></li>
<li><a href="#tls" onclick="return show_panel(this);">TLS (SSL) Certificates</a></li> <li><a href="#tls">TLS (SSL) Certificates</a></li>
<li><a href="#system_backup" onclick="return show_panel(this);">Backup Status</a></li> <li><a href="#system_backup">Backup Status</a></li>
<li class="divider"></li> <li class="divider"></li>
<li class="dropdown-header">Advanced Pages</li> <li class="dropdown-header">Advanced Pages</li>
<li><a href="#custom_dns" onclick="return show_panel(this);">Custom DNS</a></li> <li><a href="#custom_dns">Custom DNS</a></li>
<li><a href="#external_dns" onclick="return show_panel(this);">External DNS</a></li> <li><a href="#external_dns">External DNS</a></li>
<li><a href="#munin" onclick="return show_panel(this);">Munin Monitoring</a></li> <li><a href="#munin">Munin Monitoring</a></li>
</ul> </ul>
</li> </li>
<li><a href="#mail-guide" onclick="return show_panel(this);" class="if-logged-in-not-admin">Mail</a></li> <li><a href="#mail-guide" class="if-logged-in-not-admin">Mail</a></li>
<li class="dropdown if-logged-in-admin"> <li class="dropdown if-logged-in-admin">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">Mail &amp; Users <b class="caret"></b></a> <a href="#" class="dropdown-toggle" data-toggle="dropdown">Mail &amp; Users <b class="caret"></b></a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li><a href="#mail-guide" onclick="return show_panel(this);">Instructions</a></li> <li><a href="#mail-guide">Instructions</a></li>
<li><a href="#users" onclick="return show_panel(this);">Users</a></li> <li><a href="#users">Users</a></li>
<li><a href="#aliases" onclick="return show_panel(this);">Aliases</a></li> <li><a href="#aliases">Aliases</a></li>
<li class="divider"></li> <li class="divider"></li>
<li class="dropdown-header">Your Account</li> <li class="dropdown-header">Your Account</li>
<li><a href="#mfa" onclick="return show_panel(this);">Two-Factor Authentication</a></li> <li><a href="#mfa">Two-Factor Authentication</a></li>
</ul> </ul>
</li> </li>
<li><a href="#sync_guide" onclick="return show_panel(this);" class="if-logged-in">Contacts/Calendar</a></li> <li><a href="#sync_guide" class="if-logged-in">Contacts/Calendar</a></li>
<li><a href="#web" onclick="return show_panel(this);" class="if-logged-in-admin">Web</a></li> <li><a href="#web" class="if-logged-in-admin">Web</a></li>
</ul> </ul>
<ul class="nav navbar-nav navbar-right"> <ul class="nav navbar-nav navbar-right">
<li class="if-logged-in"><a href="#" onclick="do_logout(); return false;" style="color: white">Log out</a></li> <li class="if-logged-in"><a href="#" onclick="do_logout(); return false;" style="color: white">Log out</a></li>
@ -397,7 +392,9 @@ function api(url, method, data, callback, callback_error, headers) {
403: function(xhr) { 403: function(xhr) {
// Credentials are no longer valid. Try to login again. // Credentials are no longer valid. Try to login again.
var p = current_panel; var p = current_panel;
clear_credentials();
show_panel('login'); show_panel('login');
show_hide_menus();
switch_back_to_panel = p; switch_back_to_panel = p;
} }
} }
@ -407,16 +404,21 @@ function api(url, method, data, callback, callback_error, headers) {
var current_panel = null; var current_panel = null;
var switch_back_to_panel = null; var switch_back_to_panel = null;
function do_logout() { function clear_credentials() {
// Clear the session from the backend.
api("/logout", "POST");
// Forget the token. // Forget the token.
api_credentials = null; api_credentials = null;
if (typeof localStorage != 'undefined') if (typeof localStorage != 'undefined')
localStorage.removeItem("miab-cp-credentials"); localStorage.removeItem("miab-cp-credentials");
if (typeof sessionStorage != 'undefined') if (typeof sessionStorage != 'undefined')
sessionStorage.removeItem("miab-cp-credentials"); sessionStorage.removeItem("miab-cp-credentials");
}
function do_logout() {
// Clear the session from the backend.
api("/logout", "POST");
// Remove locally stored credentials
clear_credentials();
// Return to the start. // Return to the start.
show_panel('login'); show_panel('login');
@ -426,23 +428,25 @@ function do_logout() {
} }
function show_panel(panelid) { function show_panel(panelid) {
if (panelid.getAttribute) if (panelid.getAttribute) {
// we might be passed an HTMLElement <a>. // we might be passed an HTMLElement <a>.
panelid = panelid.getAttribute('href').substring(1); panelid = panelid.getAttribute('href').substring(1);
}
$('.admin_panel').hide(); $('.admin_panel').hide();
$('#panel_' + panelid).show(); $('#panel_' + panelid).show();
if (typeof localStorage != 'undefined')
localStorage.setItem("miab-cp-lastpanel", panelid);
if (window["show_" + panelid]) if (window["show_" + panelid])
window["show_" + panelid](); window["show_" + panelid]();
current_panel = panelid; current_panel = panelid;
switch_back_to_panel = null; switch_back_to_panel = null;
return false; // when called from onclick, cancel navigation
} }
window.onhashchange = function() {
var panelid = window.location.hash.substring(1);
show_panel(panelid);
};
$(function() { $(function() {
// Recall saved user credentials. // Recall saved user credentials.
try { try {
@ -457,8 +461,9 @@ $(function() {
show_hide_menus(); show_hide_menus();
// Recall what the user was last looking at. // Recall what the user was last looking at.
if (api_credentials != null && typeof localStorage != 'undefined' && localStorage.getItem("miab-cp-lastpanel")) { if (api_credentials != null && window.location.hash) {
show_panel(localStorage.getItem("miab-cp-lastpanel")); var panelid = window.location.hash.substring(1);
show_panel(panelid);
} else if (api_credentials != null) { } else if (api_credentials != null) {
show_panel('welcome'); show_panel('welcome');
} else { } else {

View File

@ -168,7 +168,18 @@ function do_login() {
// Open the next panel the user wants to go to. Do this after the XHR response // Open the next panel the user wants to go to. Do this after the XHR response
// is over so that we don't start a new XHR request while this one is finishing, // is over so that we don't start a new XHR request while this one is finishing,
// which confuses the loading indicator. // which confuses the loading indicator.
setTimeout(function() { show_panel(!switch_back_to_panel || switch_back_to_panel == "login" ? 'welcome' : switch_back_to_panel) }, 300); setTimeout(function() {
if (window.location.hash) {
var panelid = window.location.hash.substring(1);
show_panel(panelid);
} else {
show_panel(
!switch_back_to_panel || switch_back_to_panel == "login"
? 'welcome'
: switch_back_to_panel)
}
}, 300);
} }
}, },
undefined, undefined,

View File

@ -16,7 +16,7 @@
<h4>Automatic configuration</h4> <h4>Automatic configuration</h4>
<p>iOS and OS X only: Open <a style="font-weight: bold" href="https://{{hostname}}/mailinabox.mobileconfig">this configuration link</a> on your iOS device or on your Mac desktop to easily set up mail (IMAP/SMTP), Contacts, and Calendar. Your username is your whole email address.</p> <p>iOS and macOS only: Open <a style="font-weight: bold" href="https://{{hostname}}/mailinabox.mobileconfig">this configuration link</a> on your iOS device or on your Mac desktop to easily set up mail (IMAP/SMTP), Contacts, and Calendar. Your username is your whole email address.</p>
<h4>Manual configuration</h4> <h4>Manual configuration</h4>
@ -36,13 +36,13 @@
<tr><th>Password:</th> <td>Your mail password.</td></tr> <tr><th>Password:</th> <td>Your mail password.</td></tr>
</table> </table>
<p>In addition to setting up your email, you&rsquo;ll also need to set up <a href="#sync_guide" onclick="return show_panel(this);">contacts and calendar synchronization</a> separately.</p> <p>In addition to setting up your email, you&rsquo;ll also need to set up <a href="#sync_guide">contacts and calendar synchronization</a> separately.</p>
<p>As an alternative to IMAP you can also use the POP protocol: choose POP as the protocol, port 995, and SSL or TLS security in your mail client. The SMTP settings and usernames and passwords remain the same. However, we recommend you use IMAP instead.</p> <p>As an alternative to IMAP you can also use the POP protocol: choose POP as the protocol, port 995, and SSL or TLS security in your mail client. The SMTP settings and usernames and passwords remain the same. However, we recommend you use IMAP instead.</p>
<h4>Exchange/ActiveSync settings</h4> <h4>Exchange/ActiveSync settings</h4>
<p>On iOS devices, devices on this <a href="https://wiki.z-hub.io/display/ZP/Compatibility">compatibility list</a>, or using Outlook 2007 or later on Windows 7 and later, you may set up your mail as an Exchange or ActiveSync server. However, we&rsquo;ve found this to be more buggy than using IMAP as described above. If you encounter any problems, please use the manual settings above.</p> <p>On iOS devices, devices on this <a href="https://github.com/Z-Hub/Z-Push/wiki/Compatibility">compatibility list</a>, or using Outlook 2007 or later on Windows 7 and later, you may set up your mail as an Exchange or ActiveSync server. However, we&rsquo;ve found this to be more buggy than using IMAP as described above. If you encounter any problems, please use the manual settings above.</p>
<table class="table"> <table class="table">
<tr><th>Server</th> <td>{{hostname}}</td></tr> <tr><th>Server</th> <td>{{hostname}}</td></tr>

View File

@ -17,14 +17,14 @@
<tr><th>Calendar</td> <td><a href="https://{{hostname}}/cloud/calendar">https://{{hostname}}/cloud/calendar</a></td></tr> <tr><th>Calendar</td> <td><a href="https://{{hostname}}/cloud/calendar">https://{{hostname}}/cloud/calendar</a></td></tr>
</table> </table>
<p>Log in settings are the same as with <a href="#mail-guide" onclick="return show_panel(this);">mail</a>: your <p>Log in settings are the same as with <a href="#mail-guide">mail</a>: your
complete email address and your mail password.</p> complete email address and your mail password.</p>
</div> </div>
<div class="col-sm-6"> <div class="col-sm-6">
<h4>On your mobile device</h4> <h4>On your mobile device</h4>
<p>If you set up your <a href="#mail-guide" onclick="return show_panel(this);">mail</a> using Exchange/ActiveSync, <p>If you set up your <a href="#mail-guide">mail</a> using Exchange/ActiveSync,
your contacts and calendar may already appear on your device.</p> your contacts and calendar may already appear on your device.</p>
<p>Otherwise, here are some apps that can synchronize your contacts and calendar to your Android phone.</p> <p>Otherwise, here are some apps that can synchronize your contacts and calendar to your Android phone.</p>

View File

@ -5,7 +5,7 @@
<h2>Backup Status</h2> <h2>Backup Status</h2>
<p>The box makes an incremental backup each night. By default the backup is stored on the machine itself, but you can also store it on S3-compatible services like Amazon Web Services (AWS).</p> <p>The box makes an incremental backup each night. You can store the backup on any Amazon Web Services S3-compatible service, or other options.</p>
<h3>Configuration</h3> <h3>Configuration</h3>
@ -45,6 +45,10 @@
<label for="backup-target-rsync-host" class="col-sm-2 control-label">Hostname</label> <label for="backup-target-rsync-host" class="col-sm-2 control-label">Hostname</label>
<div class="col-sm-8"> <div class="col-sm-8">
<input type="text" placeholder="hostname.local" class="form-control" rows="1" id="backup-target-rsync-host"> <input type="text" placeholder="hostname.local" class="form-control" rows="1" id="backup-target-rsync-host">
<div class="small" style="margin-top: 2px">
The hostname at your rsync provider, e.g. <tt>da2327.rsync.net</tt>. Optionally includes a colon
and the provider's non-standard ssh port number, e.g. <tt>u215843.your-storagebox.de:23</tt>.
</div>
</div> </div>
</div> </div>
<div class="form-group backup-target-rsync"> <div class="form-group backup-target-rsync">
@ -66,9 +70,12 @@
<div class="small" style="margin-top: 2px"> <div class="small" style="margin-top: 2px">
Copy the Public SSH Key above, and paste it within the <tt>~/.ssh/authorized_keys</tt> Copy the Public SSH Key above, and paste it within the <tt>~/.ssh/authorized_keys</tt>
of target user on the backup server specified above. That way you'll enable secure and of target user on the backup server specified above. That way you'll enable secure and
passwordless authentication from your mail-in-a-box server and your backup server. passwordless authentication from your Mail-in-a-Box server and your backup server.
</div> </div>
</div> </div>
<div id="copy_pub_key_div" class="col-sm">
<button type="button" class="btn btn-small" onclick="copy_pub_key_to_clipboard()">Copy</button>
</div>
</div> </div>
<!-- S3 BACKUP --> <!-- S3 BACKUP -->
<div class="form-group backup-target-s3"> <div class="form-group backup-target-s3">
@ -91,13 +98,19 @@
<div class="form-group backup-target-s3"> <div class="form-group backup-target-s3">
<label for="backup-target-s3-host" class="col-sm-2 control-label">S3 Host / Endpoint</label> <label for="backup-target-s3-host" class="col-sm-2 control-label">S3 Host / Endpoint</label>
<div class="col-sm-8"> <div class="col-sm-8">
<input type="text" placeholder="Endpoint" class="form-control" rows="1" id="backup-target-s3-host"> <input type="text" placeholder="https://s3.backuphost.com" class="form-control" rows="1" id="backup-target-s3-host">
</div> </div>
</div> </div>
<div class="form-group backup-target-s3"> <div class="form-group backup-target-s3">
<label for="backup-target-s3-path" class="col-sm-2 control-label">S3 Path</label> <label for="backup-target-s3-region-name" class="col-sm-2 control-label">S3 Region Name <span style="font-weight: normal">(if required)</span></label>
<div class="col-sm-8"> <div class="col-sm-8">
<input type="text" placeholder="your-bucket-name/backup-directory" class="form-control" rows="1" id="backup-target-s3-path"> <input type="text" placeholder="region.name" class="form-control" rows="1" id="backup-target-s3-region-name">
</div>
</div>
<div class="form-group backup-target-s3">
<label for="backup-target-s3-path" class="col-sm-2 control-label">S3 Bucket &amp; Path</label>
<div class="col-sm-8">
<input type="text" placeholder="bucket-name/backup-directory" class="form-control" rows="1" id="backup-target-s3-path">
</div> </div>
</div> </div>
<div class="form-group backup-target-s3"> <div class="form-group backup-target-s3">
@ -259,19 +272,18 @@ function show_custom_backup() {
} else if (r.target == "off") { } else if (r.target == "off") {
$("#backup-target-type").val("off"); $("#backup-target-type").val("off");
} else if (r.target.substring(0, 8) == "rsync://") { } else if (r.target.substring(0, 8) == "rsync://") {
$("#backup-target-type").val("rsync"); const spec = url_split(r.target);
var path = r.target.substring(8).split('//'); $("#backup-target-type").val(spec.scheme);
var host_parts = path.shift().split('@'); $("#backup-target-rsync-user").val(spec.user);
$("#backup-target-rsync-user").val(host_parts[0]); $("#backup-target-rsync-host").val(spec.host);
$("#backup-target-rsync-host").val(host_parts[1]); $("#backup-target-rsync-path").val(spec.path);
$("#backup-target-rsync-path").val('/'+path[0]);
} else if (r.target.substring(0, 5) == "s3://") { } else if (r.target.substring(0, 5) == "s3://") {
const spec = url_split(r.target);
$("#backup-target-type").val("s3"); $("#backup-target-type").val("s3");
var hostpath = r.target.substring(5).split('/'); $("#backup-target-s3-host-select").val(spec.host);
var host = hostpath.shift(); $("#backup-target-s3-host").val(spec.host);
$("#backup-target-s3-host-select").val(host); $("#backup-target-s3-region-name").val(spec.user); // stuffing the region name in the username
$("#backup-target-s3-host").val(host); $("#backup-target-s3-path").val(spec.path);
$("#backup-target-s3-path").val(hostpath.join('/'));
} else if (r.target.substring(0, 5) == "b2://") { } else if (r.target.substring(0, 5) == "b2://") {
$("#backup-target-type").val("b2"); $("#backup-target-type").val("b2");
var targetPath = r.target.substring(5); var targetPath = r.target.substring(5);
@ -279,7 +291,7 @@ function show_custom_backup() {
var b2_applicationkey = targetPath.split(':')[1].split('@')[0]; var b2_applicationkey = targetPath.split(':')[1].split('@')[0];
var b2_bucket = targetPath.split('@')[1]; var b2_bucket = targetPath.split('@')[1];
$("#backup-target-b2-user").val(b2_application_keyid); $("#backup-target-b2-user").val(b2_application_keyid);
$("#backup-target-b2-pass").val(b2_applicationkey); $("#backup-target-b2-pass").val(decodeURIComponent(b2_applicationkey));
$("#backup-target-b2-bucket").val(b2_bucket); $("#backup-target-b2-bucket").val(b2_bucket);
} }
toggle_form() toggle_form()
@ -295,13 +307,16 @@ function set_custom_backup() {
if (target_type == "local" || target_type == "off") if (target_type == "local" || target_type == "off")
target = target_type; target = target_type;
else if (target_type == "s3") else if (target_type == "s3")
target = "s3://" + $("#backup-target-s3-host").val() + "/" + $("#backup-target-s3-path").val(); target = "s3://"
+ ($("#backup-target-s3-region-name").val() ? ($("#backup-target-s3-region-name").val() + "@") : "")
+ $("#backup-target-s3-host").val()
+ "/" + $("#backup-target-s3-path").val();
else if (target_type == "rsync") { else if (target_type == "rsync") {
target = "rsync://" + $("#backup-target-rsync-user").val() + "@" + $("#backup-target-rsync-host").val() target = "rsync://" + $("#backup-target-rsync-user").val() + "@" + $("#backup-target-rsync-host").val()
+ "/" + $("#backup-target-rsync-path").val(); + "/" + $("#backup-target-rsync-path").val();
target_user = ''; target_user = '';
} else if (target_type == "b2") { } else if (target_type == "b2") {
target = 'b2://' + $('#backup-target-b2-user').val() + ':' + $('#backup-target-b2-pass').val() target = 'b2://' + $('#backup-target-b2-user').val() + ':' + encodeURIComponent($('#backup-target-b2-pass').val())
+ '@' + $('#backup-target-b2-bucket').val() + '@' + $('#backup-target-b2-bucket').val()
target_user = ''; target_user = '';
target_pass = ''; target_pass = '';
@ -344,4 +359,42 @@ function init_inputs(target_type) {
set_host($('#backup-target-s3-host-select').val()); set_host($('#backup-target-s3-host-select').val());
} }
} }
// Return a two-element array of the substring preceding and the substring following
// the first occurrence of separator in string. Return [undefined, string] if the
// separator does not appear in string.
const split1_rest = (string, separator) => {
const index = string.indexOf(separator);
return (index >= 0) ? [string.substring(0, index), string.substring(index + separator.length)] : [undefined, string];
};
// Note: The manifest JS URL class does not work in some security-conscious
// settings, e.g. Brave browser, so we roll our own that handles only what we need.
//
// Use greedy separator parsing to get parts of a MIAB backup target url.
// Note: path will not include a leading forward slash '/'
const url_split = url => {
const [ scheme, scheme_rest ] = split1_rest(url, '://');
const [ user, user_rest ] = split1_rest(scheme_rest, '@');
const [ host, path ] = split1_rest(user_rest, '/');
return {
scheme,
user,
host,
path,
}
};
// Hide Copy button if not in a modern clipboard-supporting environment.
// Using document API because jQuery is not necessarily available in this script scope.
if (!(navigator && navigator.clipboard && navigator.clipboard.writeText)) {
document.getElementById('copy_pub_key_div').hidden = true;
}
function copy_pub_key_to_clipboard() {
const ssh_pub_key = $("#ssh-pub-key").val();
navigator.clipboard.writeText(ssh_pub_key);
}
</script> </script>

View File

@ -10,13 +10,13 @@
border-top: none; border-top: none;
padding-top: 0; padding-top: 0;
} }
#system-checks .status-error td { #system-checks .status-error td, .summary-error {
color: #733; color: #733;
} }
#system-checks .status-warning td { #system-checks .status-warning td, .summary-warning {
color: #770; color: #770;
} }
#system-checks .status-ok td { #system-checks .status-ok td, .summary-ok {
color: #040; color: #040;
} }
#system-checks div.extra { #system-checks div.extra {
@ -52,6 +52,9 @@
</div> <!-- /col --> </div> <!-- /col -->
<div class="col-md-pull-3 col-md-8"> <div class="col-md-pull-3 col-md-8">
<div id="system-checks-summary">
</div>
<table id="system-checks" class="table" style="max-width: 60em"> <table id="system-checks" class="table" style="max-width: 60em">
<thead> <thead>
</thead> </thead>
@ -64,6 +67,9 @@
<script> <script>
function show_system_status() { function show_system_status() {
const summary = $('#system-checks-summary');
summary.html("");
$('#system-checks tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>") $('#system-checks tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
api( api(
@ -93,6 +99,12 @@ function show_system_status() {
{ }, { },
function(r) { function(r) {
$('#system-checks tbody').html(""); $('#system-checks tbody').html("");
const ok_symbol = "✓";
const error_symbol = "✖";
const warning_symbol = "?";
let count_by_status = { ok: 0, error: 0, warning: 0 };
for (var i = 0; i < r.length; i++) { for (var i = 0; i < r.length; i++) {
var n = $("<tr><td class='status'/><td class='message'><p style='margin: 0'/><div class='extra'/><a class='showhide' href='#'/></tr>"); var n = $("<tr><td class='status'/><td class='message'><p style='margin: 0'/><div class='extra'/><a class='showhide' href='#'/></tr>");
if (i == 0) n.addClass('first') if (i == 0) n.addClass('first')
@ -100,9 +112,12 @@ function show_system_status() {
n.addClass(r[i].type) n.addClass(r[i].type)
else else
n.addClass("status-" + r[i].type) n.addClass("status-" + r[i].type)
if (r[i].type == "ok") n.find('td.status').text("✓")
if (r[i].type == "error") n.find('td.status').text("✖") if (r[i].type == "ok") n.find('td.status').text(ok_symbol);
if (r[i].type == "warning") n.find('td.status').text("?") if (r[i].type == "error") n.find('td.status').text(error_symbol);
if (r[i].type == "warning") n.find('td.status').text(warning_symbol);
count_by_status[r[i].type]++;
n.find('td.message p').text(r[i].text) n.find('td.message p').text(r[i].text)
$('#system-checks tbody').append(n); $('#system-checks tbody').append(n);
@ -122,8 +137,17 @@ function show_system_status() {
n.find('> td.message > div').append(m); n.find('> td.message > div').append(m);
} }
} }
})
// Summary counts
summary.html("Summary: ");
if (count_by_status['error'] + count_by_status['warning'] == 0) {
summary.append($('<span class="summary-ok"/>').text(`All ${count_by_status['ok']} ${ok_symbol} OK`));
} else {
summary.append($('<span class="summary-ok"/>').text(`${count_by_status['ok']} ${ok_symbol} OK, `));
summary.append($('<span class="summary-error"/>').text(`${count_by_status['error']} ${error_symbol} Error, `));
summary.append($('<span class="summary-warning"/>').text(`${count_by_status['warning']} ${warning_symbol} Warning`));
}
})
} }
var current_privacy_setting = null; var current_privacy_setting = null;

View File

@ -6,6 +6,7 @@
#user_table .account_inactive .if_active { display: none; } #user_table .account_inactive .if_active { display: none; }
#user_table .account_active .if_inactive { display: none; } #user_table .account_active .if_inactive { display: none; }
#user_table .account_active.if_inactive { display: none; } #user_table .account_active.if_inactive { display: none; }
.row-center { text-align: center; }
</style> </style>
<h3>Add a mail user</h3> <h3>Add a mail user</h3>
@ -27,20 +28,28 @@
<option value="admin">Administrator</option> <option value="admin">Administrator</option>
</select> </select>
</div> </div>
<div class="form-group">
<label class="sr-only" for="adduserQuota">Quota</label>
<input type="text" class="form-control" id="adduserQuota" placeholder="Quota" style="width:5em;" value="0">
</div>
<button type="submit" class="btn btn-primary">Add User</button> <button type="submit" class="btn btn-primary">Add User</button>
</form> </form>
<ul style="margin-top: 1em; padding-left: 1.5em; font-size: 90%;"> <ul style="margin-top: 1em; padding-left: 1.5em; font-size: 90%;">
<li>Passwords must be at least eight characters consisting of English letters and numbers only. For best results, <a href="#" onclick="return generate_random_password()">generate a random password</a>.</li> <li>Passwords must be at least eight characters consisting of English letters and numbers only. For best results, <a href="#" onclick="return generate_random_password()">generate a random password</a>.</li>
<li>Use <a href="#" onclick="return show_panel('aliases')">aliases</a> to create email addresses that forward to existing accounts.</li> <li>Use <a href="#aliases">aliases</a> to create email addresses that forward to existing accounts.</li>
<li>Administrators get access to this control panel.</li> <li>Administrators get access to this control panel.</li>
<li>User accounts cannot contain any international (non-ASCII) characters, but <a href="#" onclick="return show_panel('aliases');">aliases</a> can.</li> <li>User accounts cannot contain any international (non-ASCII) characters, but <a href="#aliases">aliases</a> can.</li>
<li>Quotas may not contain any spaces, commas or decimal points. Suffixes of G (gigabytes) and M (megabytes) are allowed. For unlimited storage enter 0 (zero)</li>
</ul> </ul>
<h3>Existing mail users</h3> <h3>Existing mail users</h3>
<table id="user_table" class="table" style="width: auto"> <table id="user_table" class="table" style="width: auto">
<thead> <thead>
<tr> <tr>
<th width="50%">Email Address</th> <th width="35%">Email Address</th>
<th class="row-center">Size</th>
<th class="row-center">Used</th>
<th class="row-center">Quota</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@ -53,10 +62,21 @@
<tr id="user-template"> <tr id="user-template">
<td class='address'> <td class='address'>
</td> </td>
<td class="box-size row-center"></td>
<td class="percent row-center"></td>
<td class="quota row-center">
</td>
<td class='actions'> <td class='actions'>
<span class='privs'> <span class='privs'>
</span> </span>
<span class="if_active">
<a href="#" onclick="users_set_quota(this); return false;" class='setquota' title="Set Quota">
set quota
</a>
|
</span>
<span class="if_active"> <span class="if_active">
<a href="#" onclick="users_set_password(this); return false;" class='setpw' title="Set Password"> <a href="#" onclick="users_set_password(this); return false;" class='setpw' title="Set Password">
set password set password
@ -97,10 +117,28 @@
<table class="table" style="margin-top: .5em"> <table class="table" style="margin-top: .5em">
<thead><th>Verb</th> <th>Action</th><th></th></thead> <thead><th>Verb</th> <th>Action</th><th></th></thead>
<tr><td>GET</td><td><i>(none)</i></td> <td>Returns a list of existing mail users. Adding <code>?format=json</code> to the URL will give JSON-encoded results.</td></tr> <tr><td>GET</td><td><i>(none)</i></td> <td>Returns a list of existing mail users. Adding <code>?format=json</code> to the URL will give JSON-encoded results.</td></tr>
<tr><td>POST</td><td>/add</td> <td>Adds a new mail user. Required POST-body parameters are <code>email</code> and <code>password</code>.</td></tr> <tr>
<tr><td>POST</td><td>/remove</td> <td>Removes a mail user. Required POST-body parameter is <code>email</code>.</td></tr> <td>POST</td>
<td>/add</td>
<td>Adds a new mail user. Required POST-body parameters are <code>email</code> and <code>password</code>. Optional parameters: <code>privilege=admin</code> and <code>quota</code></td>
</tr>
<tr>
<td>POST</td>
<td>/remove</td>
<td>Removes a mail user. Required POST-by parameter is <code>email</code>.</td>
</tr>
<tr><td>POST</td><td>/privileges/add</td> <td>Used to make a mail user an admin. Required POST-body parameters are <code>email</code> and <code>privilege=admin</code>.</td></tr> <tr><td>POST</td><td>/privileges/add</td> <td>Used to make a mail user an admin. Required POST-body parameters are <code>email</code> and <code>privilege=admin</code>.</td></tr>
<tr><td>POST</td><td>/privileges/remove</td> <td>Used to remove the admin privilege from a mail user. Required POST-body parameter is <code>email</code>.</td></tr> <tr><td>POST</td><td>/privileges/remove</td> <td>Used to remove the admin privilege from a mail user. Required POST-body parameter is <code>email</code>.</td></tr>
<tr>
<td>GET</td>
<td>/quota</td>
<td>Get the quota for a mail user. Required POST-body parameters are <code>email</code> and will return JSON result</td>
</tr>
<tr>
<td>POST</td>
<td>/quota</td>
<td>Set the quota for a mail user. Required POST-body parameters are <code>email</code> and <code>quota</code>.</td>
</tr>
</table> </table>
<h4>Examples:</h4> <h4>Examples:</h4>
@ -133,7 +171,7 @@ function show_users() {
function(r) { function(r) {
$('#user_table tbody').html(""); $('#user_table tbody').html("");
for (var i = 0; i < r.length; i++) { for (var i = 0; i < r.length; i++) {
var hdr = $("<tr><th colspan='2' style='background-color: #EEE'></th></tr>"); var hdr = $("<tr><th colspan='6' style='background-color: #EEE'></th></tr>");
hdr.find('th').text(r[i].domain); hdr.find('th').text(r[i].domain);
$('#user_table tbody').append(hdr); $('#user_table tbody').append(hdr);
@ -151,7 +189,14 @@ function show_users() {
n2.addClass("account_" + user.status); n2.addClass("account_" + user.status);
n.attr('data-email', user.email); n.attr('data-email', user.email);
n.find('.address').text(user.email) n.attr('data-quota', user.quota);
n.find('.address').text(user.email);
n.find('.box-size').text(user.box_size);
if (user.box_size == '?') {
n.find('.box-size').attr('title', 'Mailbox size is unkown')
}
n.find('.percent').text(user.percent);
n.find('.quota').text((user.quota == '0') ? 'unlimited' : user.quota);
n2.find('.restore_info tt').text(user.mailbox); n2.find('.restore_info tt').text(user.mailbox);
if (user.status == 'inactive') continue; if (user.status == 'inactive') continue;
@ -180,13 +225,15 @@ function do_add_user() {
var email = $("#adduserEmail").val(); var email = $("#adduserEmail").val();
var pw = $("#adduserPassword").val(); var pw = $("#adduserPassword").val();
var privs = $("#adduserPrivs").val(); var privs = $("#adduserPrivs").val();
var quota = $("#adduserQuota").val();
api( api(
"/mail/users/add", "/mail/users/add",
"POST", "POST",
{ {
email: email, email: email,
password: pw, password: pw,
privileges: privs privileges: privs,
quota: quota
}, },
function(r) { function(r) {
// Responses are multiple lines of pre-formatted text. // Responses are multiple lines of pre-formatted text.
@ -228,6 +275,36 @@ function users_set_password(elem) {
}); });
} }
function users_set_quota(elem) {
var email = $(elem).parents('tr').attr('data-email');
var quota = $(elem).parents('tr').attr('data-quota');
show_modal_confirm(
"Set Quota",
$("<p>Set quota for <b>" + email + "</b>?</p>" +
"<p>" +
"<label for='users_set_quota' style='display: block; font-weight: normal'>Quota:</label>" +
"<input type='text' id='users_set_quota' value='" + quota + "'></p>" +
"<p><small>Quotas may not contain any spaces or commas. Suffixes of G (gigabytes) and M (megabytes) are allowed.</small></p>" +
"<p><small>For unlimited storage enter 0 (zero)</small></p>"),
"Set Quota",
function() {
api(
"/mail/users/quota",
"POST",
{
email: email,
quota: $('#users_set_quota').val()
},
function(r) {
show_users();
},
function(r) {
show_modal_error("Set Quota", r);
});
});
}
function users_remove(elem) { function users_remove(elem) {
var email = $(elem).parents('tr').attr('data-email'); var email = $(elem).parents('tr').attr('data-email');
@ -293,7 +370,7 @@ function generate_random_password() {
var charset = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"; // confusable characters skipped var charset = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"; // confusable characters skipped
for (var i = 0; i < 12; i++) for (var i = 0; i < 12; i++)
pw += charset.charAt(Math.floor(Math.random() * charset.length)); pw += charset.charAt(Math.floor(Math.random() * charset.length));
show_modal_error("Random Password", "<p>Here, try this:</p> <p><code style='font-size: 110%'>" + pw + "</code></pr"); show_modal_error("Random Password", "<p>Here, try this:</p> <p><code style='font-size: 110%'>" + pw + "</code></p>");
return false; // cancel click return false; // cancel click
} }
</script> </script>

View File

@ -10,7 +10,7 @@
<p>You can replace the default website with your own HTML pages and other static files. This control panel won&rsquo;t help you design a website, but once you have <tt>.html</tt> files you can upload them following these instructions:</p> <p>You can replace the default website with your own HTML pages and other static files. This control panel won&rsquo;t help you design a website, but once you have <tt>.html</tt> files you can upload them following these instructions:</p>
<ol> <ol>
<li>Ensure that any domains you are publishing a website for have no problems on the <a href="#system_status" onclick="return show_panel(this);">Status Checks</a> page.</li> <li>Ensure that any domains you are publishing a website for have no problems on the <a href="#system_status">Status Checks</a> page.</li>
<li>On your personal computer, install an SSH file transfer program such as <a href="https://filezilla-project.org/">FileZilla</a> or <a href="http://linuxcommand.org/man_pages/scp1.html">scp</a>.</li> <li>On your personal computer, install an SSH file transfer program such as <a href="https://filezilla-project.org/">FileZilla</a> or <a href="http://linuxcommand.org/man_pages/scp1.html">scp</a>.</li>
@ -32,7 +32,7 @@
</tbody> </tbody>
</table> </table>
<p>To add a domain to this table, create a dummy <a href="#users" onclick="return show_panel(this);">mail user</a> or <a href="#aliases" onclick="return show_panel(this);">alias</a> on the domain first and see the <a href="https://mailinabox.email/guide.html#domain-name-configuration">setup guide</a> for adding nameserver records to the new domain at your registrar (but <i>not</i> glue records).</p> <p>To add a domain to this table, create a dummy <a href="#users">mail user</a> or <a href="#aliases">alias</a> on the domain first and see the <a href="https://mailinabox.email/guide.html#domain-name-configuration">setup guide</a> for adding nameserver records to the new domain at your registrar (but <i>not</i> glue records).</p>
</ol> </ol>

View File

@ -14,28 +14,30 @@ def load_env_vars_from_file(fn):
# Load settings from a KEY=VALUE file. # Load settings from a KEY=VALUE file.
import collections import collections
env = collections.OrderedDict() env = collections.OrderedDict()
for line in open(fn): env.setdefault(*line.strip().split("=", 1)) with open(fn, encoding="utf-8") as f:
for line in f:
env.setdefault(*line.strip().split("=", 1))
return env return env
def save_environment(env): def save_environment(env):
with open("/etc/mailinabox.conf", "w") as f: with open("/etc/mailinabox.conf", "w", encoding="utf-8") as f:
for k, v in env.items(): f.writelines(f"{k}={v}\n" for k, v in env.items())
f.write("%s=%s\n" % (k, v))
# THE SETTINGS FILE AT STORAGE_ROOT/settings.yaml. # THE SETTINGS FILE AT STORAGE_ROOT/settings.yaml.
def write_settings(config, env): def write_settings(config, env):
import rtyaml import rtyaml
fn = os.path.join(env['STORAGE_ROOT'], 'settings.yaml') fn = os.path.join(env['STORAGE_ROOT'], 'settings.yaml')
with open(fn, "w") as f: with open(fn, "w", encoding="utf-8") as f:
f.write(rtyaml.dump(config)) f.write(rtyaml.dump(config))
def load_settings(env): def load_settings(env):
import rtyaml import rtyaml
fn = os.path.join(env['STORAGE_ROOT'], 'settings.yaml') fn = os.path.join(env['STORAGE_ROOT'], 'settings.yaml')
try: try:
config = rtyaml.load(open(fn, "r")) with open(fn, encoding="utf-8") as f:
if not isinstance(config, dict): raise ValueError() # caught below config = rtyaml.load(f)
if not isinstance(config, dict): raise ValueError # caught below
return config return config
except: except:
return { } return { }
@ -56,7 +58,7 @@ def sort_domains(domain_names, env):
# from shortest to longest since zones are always shorter than their # from shortest to longest since zones are always shorter than their
# subdomains. # subdomains.
zones = { } zones = { }
for domain in sorted(domain_names, key=lambda d : len(d)): for domain in sorted(domain_names, key=len):
for z in zones.values(): for z in zones.values():
if domain.endswith("." + z): if domain.endswith("." + z):
# We found a parent domain already in the list. # We found a parent domain already in the list.
@ -78,7 +80,7 @@ def sort_domains(domain_names, env):
)) ))
# Now sort the domain names that fall within each zone. # Now sort the domain names that fall within each zone.
domain_names = sorted(domain_names, return sorted(domain_names,
key = lambda d : ( key = lambda d : (
# First by zone. # First by zone.
zone_domains.index(zones[d]), zone_domains.index(zones[d]),
@ -93,24 +95,25 @@ def sort_domains(domain_names, env):
list(reversed(d.split("."))), list(reversed(d.split("."))),
)) ))
return domain_names
def sort_email_addresses(email_addresses, env): def sort_email_addresses(email_addresses, env):
email_addresses = set(email_addresses) email_addresses = set(email_addresses)
domains = set(email.split("@", 1)[1] for email in email_addresses if "@" in email) domains = {email.split("@", 1)[1] for email in email_addresses if "@" in email}
ret = [] ret = []
for domain in sort_domains(domains, env): for domain in sort_domains(domains, env):
domain_emails = set(email for email in email_addresses if email.endswith("@" + domain)) domain_emails = {email for email in email_addresses if email.endswith("@" + domain)}
ret.extend(sorted(domain_emails)) ret.extend(sorted(domain_emails))
email_addresses -= domain_emails email_addresses -= domain_emails
ret.extend(sorted(email_addresses)) # whatever is left ret.extend(sorted(email_addresses)) # whatever is left
return ret return ret
def shell(method, cmd_args, env={}, capture_stderr=False, return_bytes=False, trap=False, input=None): def shell(method, cmd_args, env=None, capture_stderr=False, return_bytes=False, trap=False, input=None):
# A safe way to execute processes. # A safe way to execute processes.
# Some processes like apt-get require being given a sane PATH. # Some processes like apt-get require being given a sane PATH.
import subprocess import subprocess
if env is None:
env = {}
env.update({ "PATH": "/sbin:/bin:/usr/sbin:/usr/bin" }) env.update({ "PATH": "/sbin:/bin:/usr/sbin:/usr/bin" })
kwargs = { kwargs = {
'env': env, 'env': env,
@ -119,19 +122,21 @@ def shell(method, cmd_args, env={}, capture_stderr=False, return_bytes=False, tr
if method == "check_output" and input is not None: if method == "check_output" and input is not None:
kwargs['input'] = input kwargs['input'] = input
if not trap:
ret = getattr(subprocess, method)(cmd_args, **kwargs)
else:
try: try:
ret = getattr(subprocess, method)(cmd_args, **kwargs) ret = getattr(subprocess, method)(cmd_args, **kwargs)
code = 0 code = 0
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
if not trap:
if False:
import sys, shlex
print(shlex.join(cmd_args), file=sys.stderr)
raise
raise
ret = e.output ret = e.output
code = e.returncode code = e.returncode
if not return_bytes and isinstance(ret, bytes): ret = ret.decode("utf8") if not return_bytes and isinstance(ret, bytes): ret = ret.decode("utf8")
if not trap: if not trap:
return ret return ret
else:
return code, ret return code, ret
def create_syslog_handler(): def create_syslog_handler():
@ -146,7 +151,7 @@ def du(path):
# soft and hard links. # soft and hard links.
total_size = 0 total_size = 0
seen = set() seen = set()
for dirpath, dirnames, filenames in os.walk(path): for dirpath, _dirnames, filenames in os.walk(path):
for f in filenames: for f in filenames:
fp = os.path.join(dirpath, f) fp = os.path.join(dirpath, f)
try: try:
@ -175,6 +180,35 @@ def wait_for_service(port, public, env, timeout):
return False return False
time.sleep(min(timeout/4, 1)) time.sleep(min(timeout/4, 1))
def get_ssh_port():
port_value = get_ssh_config_value("port")
if port_value:
return int(port_value)
return None
def get_ssh_config_value(parameter_name):
# Returns ssh configuration value for the provided parameter
import subprocess
try:
output = shell('check_output', ['sshd', '-T'])
except FileNotFoundError:
# sshd is not installed. That's ok.
return None
except subprocess.CalledProcessError:
# error while calling shell command
return None
for line in output.split("\n"):
if " " not in line: continue # there's a blank line at the end
key, values = line.split(" ", 1)
if key == parameter_name:
return values # space-delimited if there are multiple values
# Did not find the parameter!
return None
if __name__ == "__main__": if __name__ == "__main__":
from web_update import get_web_domains from web_update import get_web_domains
env = load_environment() env = load_environment()

View File

@ -22,17 +22,17 @@ def get_web_domains(env, include_www_redirects=True, include_auto=True, exclude_
# Add 'www.' subdomains that we want to provide default redirects # Add 'www.' subdomains that we want to provide default redirects
# to the main domain for. We'll add 'www.' to any DNS zones, i.e. # to the main domain for. We'll add 'www.' to any DNS zones, i.e.
# the topmost of each domain we serve. # the topmost of each domain we serve.
domains |= set('www.' + zone for zone, zonefile in get_dns_zones(env)) domains |= {'www.' + zone for zone, zonefile in get_dns_zones(env)}
if include_auto: if include_auto:
# Add Autoconfiguration domains for domains that there are user accounts at: # Add Autoconfiguration domains for domains that there are user accounts at:
# 'autoconfig.' for Mozilla Thunderbird auto setup. # 'autoconfig.' for Mozilla Thunderbird auto setup.
# 'autodiscover.' for ActiveSync autodiscovery (Z-Push). # 'autodiscover.' for ActiveSync autodiscovery (Z-Push).
domains |= set('autoconfig.' + maildomain for maildomain in get_mail_domains(env, users_only=True)) domains |= {'autoconfig.' + maildomain for maildomain in get_mail_domains(env, users_only=True)}
domains |= set('autodiscover.' + maildomain for maildomain in get_mail_domains(env, users_only=True)) domains |= {'autodiscover.' + maildomain for maildomain in get_mail_domains(env, users_only=True)}
# 'mta-sts.' for MTA-STS support for all domains that have email addresses. # 'mta-sts.' for MTA-STS support for all domains that have email addresses.
domains |= set('mta-sts.' + maildomain for maildomain in get_mail_domains(env)) domains |= {'mta-sts.' + maildomain for maildomain in get_mail_domains(env)}
if exclude_dns_elsewhere: if exclude_dns_elsewhere:
# ...Unless the domain has an A/AAAA record that maps it to a different # ...Unless the domain has an A/AAAA record that maps it to a different
@ -45,15 +45,14 @@ def get_web_domains(env, include_www_redirects=True, include_auto=True, exclude_
domains.add(env['PRIMARY_HOSTNAME']) domains.add(env['PRIMARY_HOSTNAME'])
# Sort the list so the nginx conf gets written in a stable order. # Sort the list so the nginx conf gets written in a stable order.
domains = sort_domains(domains, env) return sort_domains(domains, env)
return domains
def get_domains_with_a_records(env): def get_domains_with_a_records(env):
domains = set() domains = set()
dns = get_custom_dns_config(env) dns = get_custom_dns_config(env)
for domain, rtype, value in dns: for domain, rtype, value in dns:
if rtype == "CNAME" or (rtype in ("A", "AAAA") and value not in ("local", env['PUBLIC_IP'])): if rtype == "CNAME" or (rtype in {"A", "AAAA"} and value not in {"local", env['PUBLIC_IP'], env['PUBLIC_IPV6']}):
domains.add(domain) domains.add(domain)
return domains return domains
@ -63,7 +62,8 @@ def get_web_domains_with_root_overrides(env):
root_overrides = { } root_overrides = { }
nginx_conf_custom_fn = os.path.join(env["STORAGE_ROOT"], "www/custom.yaml") nginx_conf_custom_fn = os.path.join(env["STORAGE_ROOT"], "www/custom.yaml")
if os.path.exists(nginx_conf_custom_fn): if os.path.exists(nginx_conf_custom_fn):
custom_settings = rtyaml.load(open(nginx_conf_custom_fn)) with open(nginx_conf_custom_fn, encoding='utf-8') as f:
custom_settings = rtyaml.load(f)
for domain, settings in custom_settings.items(): for domain, settings in custom_settings.items():
for type, value in [('redirect', settings.get('redirects', {}).get('/')), for type, value in [('redirect', settings.get('redirects', {}).get('/')),
('proxy', settings.get('proxies', {}).get('/'))]: ('proxy', settings.get('proxies', {}).get('/'))]:
@ -75,13 +75,18 @@ def do_web_update(env):
# Pre-load what SSL certificates we will use for each domain. # Pre-load what SSL certificates we will use for each domain.
ssl_certificates = get_ssl_certificates(env) ssl_certificates = get_ssl_certificates(env)
# Helper for reading config files and templates
def read_conf(conf_fn):
with open(os.path.join(os.path.dirname(__file__), "../conf", conf_fn), encoding='utf-8') as f:
return f.read()
# Build an nginx configuration file. # Build an nginx configuration file.
nginx_conf = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-top.conf")).read() nginx_conf = read_conf("nginx-top.conf")
# Load the templates. # Load the templates.
template0 = open(os.path.join(os.path.dirname(__file__), "../conf/nginx.conf")).read() template0 = read_conf("nginx.conf")
template1 = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-alldomains.conf")).read() template1 = read_conf("nginx-alldomains.conf")
template2 = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-primaryonly.conf")).read() template2 = read_conf("nginx-primaryonly.conf")
template3 = "\trewrite ^(.*) https://$REDIRECT_DOMAIN$1 permanent;\n" template3 = "\trewrite ^(.*) https://$REDIRECT_DOMAIN$1 permanent;\n"
# Add the PRIMARY_HOST configuration first so it becomes nginx's default server. # Add the PRIMARY_HOST configuration first so it becomes nginx's default server.
@ -107,12 +112,12 @@ def do_web_update(env):
# Did the file change? If not, don't bother writing & restarting nginx. # Did the file change? If not, don't bother writing & restarting nginx.
nginx_conf_fn = "/etc/nginx/conf.d/local.conf" nginx_conf_fn = "/etc/nginx/conf.d/local.conf"
if os.path.exists(nginx_conf_fn): if os.path.exists(nginx_conf_fn):
with open(nginx_conf_fn) as f: with open(nginx_conf_fn, encoding='utf-8') as f:
if f.read() == nginx_conf: if f.read() == nginx_conf:
return "" return ""
# Save the file. # Save the file.
with open(nginx_conf_fn, "w") as f: with open(nginx_conf_fn, "w", encoding='utf-8') as f:
f.write(nginx_conf) f.write(nginx_conf)
# Kick nginx. Since this might be called from the web admin # Kick nginx. Since this might be called from the web admin
@ -141,19 +146,17 @@ def make_domain_config(domain, templates, ssl_certificates, env):
def hashfile(filepath): def hashfile(filepath):
import hashlib import hashlib
sha1 = hashlib.sha1() sha1 = hashlib.sha1()
f = open(filepath, 'rb') with open(filepath, 'rb') as f:
try:
sha1.update(f.read()) sha1.update(f.read())
finally:
f.close()
return sha1.hexdigest() return sha1.hexdigest()
nginx_conf_extra += "\t# ssl files sha1: %s / %s\n" % (hashfile(tls_cert["private-key"]), hashfile(tls_cert["certificate"])) nginx_conf_extra += "\t# ssl files sha1: {} / {}\n".format(hashfile(tls_cert["private-key"]), hashfile(tls_cert["certificate"]))
# Add in any user customizations in YAML format. # Add in any user customizations in YAML format.
hsts = "yes" hsts = "yes"
nginx_conf_custom_fn = os.path.join(env["STORAGE_ROOT"], "www/custom.yaml") nginx_conf_custom_fn = os.path.join(env["STORAGE_ROOT"], "www/custom.yaml")
if os.path.exists(nginx_conf_custom_fn): if os.path.exists(nginx_conf_custom_fn):
yaml = rtyaml.load(open(nginx_conf_custom_fn)) with open(nginx_conf_custom_fn, encoding='utf-8') as f:
yaml = rtyaml.load(f)
if domain in yaml: if domain in yaml:
yaml = yaml[domain] yaml = yaml[domain]
@ -163,7 +166,8 @@ def make_domain_config(domain, templates, ssl_certificates, env):
pass_http_host_header = False pass_http_host_header = False
proxy_redirect_off = False proxy_redirect_off = False
frame_options_header_sameorigin = False frame_options_header_sameorigin = False
m = re.search("#(.*)$", url) web_sockets = False
m = re.search(r"#(.*)$", url)
if m: if m:
for flag in m.group(1).split(","): for flag in m.group(1).split(","):
if flag == "pass-http-host": if flag == "pass-http-host":
@ -172,47 +176,53 @@ def make_domain_config(domain, templates, ssl_certificates, env):
proxy_redirect_off = True proxy_redirect_off = True
elif flag == "frame-options-sameorigin": elif flag == "frame-options-sameorigin":
frame_options_header_sameorigin = True frame_options_header_sameorigin = True
url = re.sub("#(.*)$", "", url) elif flag == "web-sockets":
web_sockets = True
url = re.sub(r"#(.*)$", "", url)
nginx_conf_extra += "\tlocation %s {" % path nginx_conf_extra += f"\tlocation {path} {{"
nginx_conf_extra += "\n\t\tproxy_pass %s;" % url nginx_conf_extra += f"\n\t\tproxy_pass {url};"
if proxy_redirect_off: if proxy_redirect_off:
nginx_conf_extra += "\n\t\tproxy_redirect off;" nginx_conf_extra += "\n\t\tproxy_redirect off;"
if pass_http_host_header: if pass_http_host_header:
nginx_conf_extra += "\n\t\tproxy_set_header Host $http_host;" nginx_conf_extra += "\n\t\tproxy_set_header Host $http_host;"
if frame_options_header_sameorigin: if frame_options_header_sameorigin:
nginx_conf_extra += "\n\t\tproxy_set_header X-Frame-Options SAMEORIGIN;" nginx_conf_extra += "\n\t\tproxy_set_header X-Frame-Options SAMEORIGIN;"
if web_sockets:
nginx_conf_extra += "\n\t\tproxy_http_version 1.1;"
nginx_conf_extra += "\n\t\tproxy_set_header Upgrade $http_upgrade;"
nginx_conf_extra += "\n\t\tproxy_set_header Connection 'Upgrade';"
nginx_conf_extra += "\n\t\tproxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;" nginx_conf_extra += "\n\t\tproxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;"
nginx_conf_extra += "\n\t\tproxy_set_header X-Forwarded-Host $http_host;" nginx_conf_extra += "\n\t\tproxy_set_header X-Forwarded-Host $http_host;"
nginx_conf_extra += "\n\t\tproxy_set_header X-Forwarded-Proto $scheme;" nginx_conf_extra += "\n\t\tproxy_set_header X-Forwarded-Proto $scheme;"
nginx_conf_extra += "\n\t\tproxy_set_header X-Real-IP $remote_addr;" nginx_conf_extra += "\n\t\tproxy_set_header X-Real-IP $remote_addr;"
nginx_conf_extra += "\n\t}\n" nginx_conf_extra += "\n\t}\n"
for path, alias in yaml.get("aliases", {}).items(): for path, alias in yaml.get("aliases", {}).items():
nginx_conf_extra += "\tlocation %s {" % path nginx_conf_extra += f"\tlocation {path} {{"
nginx_conf_extra += "\n\t\talias %s;" % alias nginx_conf_extra += f"\n\t\talias {alias};"
nginx_conf_extra += "\n\t}\n" nginx_conf_extra += "\n\t}\n"
for path, url in yaml.get("redirects", {}).items(): for path, url in yaml.get("redirects", {}).items():
nginx_conf_extra += "\trewrite %s %s permanent;\n" % (path, url) nginx_conf_extra += f"\trewrite {path} {url} permanent;\n"
# override the HSTS directive type # override the HSTS directive type
hsts = yaml.get("hsts", hsts) hsts = yaml.get("hsts", hsts)
# Add the HSTS header. # Add the HSTS header.
if hsts == "yes": if hsts == "yes":
nginx_conf_extra += "\tadd_header Strict-Transport-Security \"max-age=15768000\" always;\n" nginx_conf_extra += '\tadd_header Strict-Transport-Security "max-age=15768000" always;\n'
elif hsts == "preload": elif hsts == "preload":
nginx_conf_extra += "\tadd_header Strict-Transport-Security \"max-age=15768000; includeSubDomains; preload\" always;\n" nginx_conf_extra += '\tadd_header Strict-Transport-Security "max-age=15768000; includeSubDomains; preload" always;\n'
# Add in any user customizations in the includes/ folder. # Add in any user customizations in the includes/ folder.
nginx_conf_custom_include = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(domain) + ".conf") nginx_conf_custom_include = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(domain) + ".conf")
if os.path.exists(nginx_conf_custom_include): if os.path.exists(nginx_conf_custom_include):
nginx_conf_extra += "\tinclude %s;\n" % (nginx_conf_custom_include) nginx_conf_extra += f"\tinclude {nginx_conf_custom_include};\n"
# PUT IT ALL TOGETHER # PUT IT ALL TOGETHER
# Combine the pieces. Iteratively place each template into the "# ADDITIONAL DIRECTIVES HERE" placeholder # Combine the pieces. Iteratively place each template into the "# ADDITIONAL DIRECTIVES HERE" placeholder
# of the previous template. # of the previous template.
nginx_conf = "# ADDITIONAL DIRECTIVES HERE\n" nginx_conf = "# ADDITIONAL DIRECTIVES HERE\n"
for t in templates + [nginx_conf_extra]: for t in [*templates, nginx_conf_extra]:
nginx_conf = re.sub("[ \t]*# ADDITIONAL DIRECTIVES HERE *\n", t, nginx_conf) nginx_conf = re.sub("[ \t]*# ADDITIONAL DIRECTIVES HERE *\n", t, nginx_conf)
# Replace substitution strings in the template & return. # Replace substitution strings in the template & return.
@ -221,9 +231,8 @@ def make_domain_config(domain, templates, ssl_certificates, env):
nginx_conf = nginx_conf.replace("$ROOT", root) nginx_conf = nginx_conf.replace("$ROOT", root)
nginx_conf = nginx_conf.replace("$SSL_KEY", tls_cert["private-key"]) nginx_conf = nginx_conf.replace("$SSL_KEY", tls_cert["private-key"])
nginx_conf = nginx_conf.replace("$SSL_CERTIFICATE", tls_cert["certificate"]) nginx_conf = nginx_conf.replace("$SSL_CERTIFICATE", tls_cert["certificate"])
nginx_conf = nginx_conf.replace("$REDIRECT_DOMAIN", re.sub(r"^www\.", "", domain)) # for default www redirects to parent domain return nginx_conf.replace("$REDIRECT_DOMAIN", re.sub(r"^www\.", "", domain)) # for default www redirects to parent domain
return nginx_conf
def get_web_root(domain, env, test_exists=True): def get_web_root(domain, env, test_exists=True):
# Try STORAGE_ROOT/web/domain_name if it exists, but fall back to STORAGE_ROOT/web/default. # Try STORAGE_ROOT/web/domain_name if it exists, but fall back to STORAGE_ROOT/web/default.
@ -247,9 +256,8 @@ def get_web_domains_info(env):
cert_status, cert_status_details = check_certificate(domain, tls_cert["certificate"], tls_cert["private-key"]) cert_status, cert_status_details = check_certificate(domain, tls_cert["certificate"], tls_cert["private-key"])
if cert_status == "OK": if cert_status == "OK":
return ("success", "Signed & valid. " + cert_status_details) return ("success", "Signed & valid. " + cert_status_details)
elif cert_status == "SELF-SIGNED": if cert_status == "SELF-SIGNED":
return ("warning", "Self-signed. Get a signed certificate to stop warnings.") return ("warning", "Self-signed. Get a signed certificate to stop warnings.")
else:
return ("danger", "Certificate has a problem: " + cert_status) return ("danger", "Certificate has a problem: " + cert_status)
return [ return [

View File

@ -1,5 +1,5 @@
from daemon import app from daemon import app
import auth, utils import utils
app.logger.addHandler(utils.create_syslog_handler()) app.logger.addHandler(utils.create_syslog_handler())

69
pyproject.toml Normal file
View File

@ -0,0 +1,69 @@
[tool.ruff]
line-length = 320 # https://github.com/astral-sh/ruff/issues/8106
indent-width = 4
target-version = "py310"
preview = true
output-format = "concise"
extend-exclude = ["tools/mail.py"]
[tool.ruff.lint]
select = [
"F",
"E4",
"E7",
"E9",
"W",
"UP",
"YTT",
"S",
"BLE",
"B",
"A",
"C4",
"T10",
"DJ",
"EM",
"EXE",
"ISC",
"ICN",
"G",
"PIE",
"PYI",
"Q003",
"Q004",
"RSE",
"RET",
"SLF",
"SLOT",
"SIM",
"TID",
"TC",
"ARG",
"PGH",
"PL",
"TRY",
"FLY",
"PERF",
"FURB",
"LOG",
"RUF"
]
ignore = [
"W191",
"PLR09",
"PLR1702",
"PLR2004",
"RUF001",
"RUF002",
"RUF003",
"RUF023"
]
[tool.ruff.format]
quote-style = "preserve"
indent-style = "tab"

View File

@ -1,7 +1,7 @@
Mail-in-a-Box Security Guide Mail-in-a-Box Security Guide
============================ ============================
Mail-in-a-Box turns a fresh Ubuntu 18.04 LTS 64-bit machine into a mail server appliance by installing and configuring various components. Mail-in-a-Box turns a fresh Ubuntu 22.04 LTS 64-bit machine into a mail server appliance by installing and configuring various components.
This page documents the security posture of Mail-in-a-Box. The term “box” is used below to mean a configured Mail-in-a-Box. This page documents the security posture of Mail-in-a-Box. The term “box” is used below to mean a configured Mail-in-a-Box.

View File

@ -23,7 +23,7 @@ if [ -z "$TAG" ]; then
if [ "$UBUNTU_VERSION" == "Ubuntu 22.04 LTS" ]; then if [ "$UBUNTU_VERSION" == "Ubuntu 22.04 LTS" ]; then
# This machine is running Ubuntu 22.04, which is supported by # This machine is running Ubuntu 22.04, which is supported by
# Mail-in-a-Box versions 60 and later. # Mail-in-a-Box versions 60 and later.
TAG=v60.1 TAG=v72
elif [ "$UBUNTU_VERSION" == "Ubuntu 18.04 LTS" ]; then elif [ "$UBUNTU_VERSION" == "Ubuntu 18.04 LTS" ]; then
# This machine is running Ubuntu 18.04, which is supported by # This machine is running Ubuntu 18.04, which is supported by
# Mail-in-a-Box versions 0.40 through 5x. # Mail-in-a-Box versions 0.40 through 5x.
@ -51,33 +51,37 @@ if [[ $EUID -ne 0 ]]; then
fi fi
# Clone the Mail-in-a-Box repository if it doesn't exist. # Clone the Mail-in-a-Box repository if it doesn't exist.
if [ ! -d $HOME/mailinabox ]; then if [ ! -d "$HOME/mailinabox" ]; then
if [ ! -f /usr/bin/git ]; then if [ ! -f /usr/bin/git ]; then
echo Installing git . . . echo "Installing git . . ."
apt-get -q -q update apt-get -q -q update
DEBIAN_FRONTEND=noninteractive apt-get -q -q install -y git < /dev/null DEBIAN_FRONTEND=noninteractive apt-get -q -q install -y git < /dev/null
echo echo
fi fi
echo Downloading Mail-in-a-Box $TAG. . . if [ "$SOURCE" == "" ]; then
SOURCE=https://github.com/mail-in-a-box/mailinabox
fi
echo "Downloading Mail-in-a-Box $TAG. . ."
git clone \ git clone \
-b $TAG --depth 1 \ -b "$TAG" --depth 1 \
https://github.com/mail-in-a-box/mailinabox \ "$SOURCE" \
$HOME/mailinabox \ "$HOME/mailinabox" \
< /dev/null 2> /dev/null < /dev/null 2> /dev/null
echo echo
fi fi
# Change directory to it. # Change directory to it.
cd $HOME/mailinabox cd "$HOME/mailinabox" || exit
# Update it. # Update it.
if [ "$TAG" != $(git describe) ]; then if [ "$TAG" != "$(git describe --always)" ]; then
echo Updating Mail-in-a-Box to $TAG . . . echo "Updating Mail-in-a-Box to $TAG . . ."
git fetch --depth 1 --force --prune origin tag $TAG git fetch --depth 1 --force --prune origin tag "$TAG"
if ! git checkout -q $TAG; then if ! git checkout -q "$TAG"; then
echo "Update failed. Did you modify something in $(pwd)?" echo "Update failed. Did you modify something in $PWD?"
exit 1 exit 1
fi fi
echo echo
@ -85,4 +89,3 @@ fi
# Start setup script. # Start setup script.
setup/start.sh setup/start.sh

View File

@ -10,12 +10,12 @@ source setup/functions.sh # load our functions
source /etc/mailinabox.conf # load global vars source /etc/mailinabox.conf # load global vars
# Install DKIM... # Install DKIM...
echo Installing OpenDKIM/OpenDMARC... echo "Installing OpenDKIM/OpenDMARC..."
apt_install opendkim opendkim-tools opendmarc apt_install opendkim opendkim-tools opendmarc
# Make sure configuration directories exist. # Make sure configuration directories exist.
mkdir -p /etc/opendkim; mkdir -p /etc/opendkim;
mkdir -p $STORAGE_ROOT/mail/dkim mkdir -p "$STORAGE_ROOT/mail/dkim"
# Used in InternalHosts and ExternalIgnoreList configuration directives. # Used in InternalHosts and ExternalIgnoreList configuration directives.
# Not quite sure why. # Not quite sure why.
@ -53,20 +53,20 @@ fi
# such as Google. But they and others use a 2048 bit key, so we'll # such as Google. But they and others use a 2048 bit key, so we'll
# do the same. Keys beyond 2048 bits may exceed DNS record limits. # do the same. Keys beyond 2048 bits may exceed DNS record limits.
if [ ! -f "$STORAGE_ROOT/mail/dkim/mail.private" ]; then if [ ! -f "$STORAGE_ROOT/mail/dkim/mail.private" ]; then
opendkim-genkey -b 2048 -r -s mail -D $STORAGE_ROOT/mail/dkim opendkim-genkey -b 2048 -r -s mail -D "$STORAGE_ROOT/mail/dkim"
fi fi
# Ensure files are owned by the opendkim user and are private otherwise. # Ensure files are owned by the opendkim user and are private otherwise.
chown -R opendkim:opendkim $STORAGE_ROOT/mail/dkim chown -R opendkim:opendkim "$STORAGE_ROOT/mail/dkim"
chmod go-rwx $STORAGE_ROOT/mail/dkim chmod go-rwx "$STORAGE_ROOT/mail/dkim"
tools/editconf.py /etc/opendmarc.conf -s \ tools/editconf.py /etc/opendmarc.conf -s \
"Syslog=true" \ "Syslog=true" \
"Socket=inet:8893@[127.0.0.1]" \ "Socket=inet:8893@[127.0.0.1]" \
"FailureReports=true" "FailureReports=false"
# SPFIgnoreResults causes the filter to ignore any SPF results in the header # SPFIgnoreResults causes the filter to ignore any SPF results in the header
# of the message. This is useful if you want the filter to perfrom SPF checks # of the message. This is useful if you want the filter to perform SPF checks
# itself, or because you don't trust the arriving header. This added header is # itself, or because you don't trust the arriving header. This added header is
# used by spamassassin to evaluate the mail for spamminess. # used by spamassassin to evaluate the mail for spamminess.
@ -82,11 +82,11 @@ tools/editconf.py /etc/opendmarc.conf -s \
tools/editconf.py /etc/opendmarc.conf -s \ tools/editconf.py /etc/opendmarc.conf -s \
"SPFSelfValidate=true" "SPFSelfValidate=true"
# Enables generation of failure reports for sending domains that publish a # Disables generation of failure reports for sending domains that publish a
# "none" policy. # "none" policy.
tools/editconf.py /etc/opendmarc.conf -s \ tools/editconf.py /etc/opendmarc.conf -s \
"FailureReportsOnNone=true" "FailureReportsOnNone=false"
# AlwaysAddARHeader Adds an "Authentication-Results:" header field even to # AlwaysAddARHeader Adds an "Authentication-Results:" header field even to
# unsigned messages from domains with no "signs all" policy. The reported DKIM # unsigned messages from domains with no "signs all" policy. The reported DKIM

View File

@ -101,12 +101,12 @@ if [ ! -f "$STORAGE_ROOT/dns/dnssec/$algo.conf" ]; then
# we're capturing into the `KSK` variable. # we're capturing into the `KSK` variable.
# #
# ldns-keygen uses /dev/random for generating random numbers by default. # ldns-keygen uses /dev/random for generating random numbers by default.
# This is slow and unecessary if we ensure /dev/urandom is seeded properly, # This is slow and unnecessary if we ensure /dev/urandom is seeded properly,
# so we use /dev/urandom. See system.sh for an explanation. See #596, #115. # so we use /dev/urandom. See system.sh for an explanation. See #596, #115.
# (This previously used -b 2048 but it's unclear if this setting makes sense # (This previously used -b 2048 but it's unclear if this setting makes sense
# for non-RSA keys, so it's removed. The RSA-based keys are not recommended # for non-RSA keys, so it's removed. The RSA-based keys are not recommended
# anymore anyway.) # anymore anyway.)
KSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -r /dev/urandom -a $algo -k _domain_); KSK=$(umask 077; cd "$STORAGE_ROOT/dns/dnssec"; ldns-keygen -r /dev/urandom -a $algo -k _domain_);
# Now create a Zone-Signing Key (ZSK) which is expected to be # Now create a Zone-Signing Key (ZSK) which is expected to be
# rotated more often than a KSK, although we have no plans to # rotated more often than a KSK, although we have no plans to
@ -114,7 +114,7 @@ if [ ! -f "$STORAGE_ROOT/dns/dnssec/$algo.conf" ]; then
# disturbing DNS availability.) Omit `-k`. # disturbing DNS availability.) Omit `-k`.
# (This previously used -b 1024 but it's unclear if this setting makes sense # (This previously used -b 1024 but it's unclear if this setting makes sense
# for non-RSA keys, so it's removed.) # for non-RSA keys, so it's removed.)
ZSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -r /dev/urandom -a $algo _domain_); ZSK=$(umask 077; cd "$STORAGE_ROOT/dns/dnssec"; ldns-keygen -r /dev/urandom -a $algo _domain_);
# These generate two sets of files like: # These generate two sets of files like:
# #
@ -126,7 +126,7 @@ if [ ! -f "$STORAGE_ROOT/dns/dnssec/$algo.conf" ]; then
# options. So we'll store the names of the files we just generated. # options. So we'll store the names of the files we just generated.
# We might have multiple keys down the road. This will identify # We might have multiple keys down the road. This will identify
# what keys are the current keys. # what keys are the current keys.
cat > $STORAGE_ROOT/dns/dnssec/$algo.conf << EOF; cat > "$STORAGE_ROOT/dns/dnssec/$algo.conf" << EOF;
KSK=$KSK KSK=$KSK
ZSK=$ZSK ZSK=$ZSK
EOF EOF
@ -142,7 +142,7 @@ cat > /etc/cron.daily/mailinabox-dnssec << EOF;
#!/bin/bash #!/bin/bash
# Mail-in-a-Box # Mail-in-a-Box
# Re-sign any DNS zones with DNSSEC because the signatures expire periodically. # Re-sign any DNS zones with DNSSEC because the signatures expire periodically.
$(pwd)/tools/dns_update $PWD/tools/dns_update
EOF EOF
chmod +x /etc/cron.daily/mailinabox-dnssec chmod +x /etc/cron.daily/mailinabox-dnssec

View File

@ -1,6 +1,7 @@
#!/bin/bash
# If there aren't any mail users yet, create one. # If there aren't any mail users yet, create one.
if [ -z "$(management/cli.py user)" ]; then if [ -z "$(management/cli.py user)" ]; then
# The outut of "management/cli.py user" is a list of mail users. If there # The output of "management/cli.py user" is a list of mail users. If there
# aren't any yet, it'll be empty. # aren't any yet, it'll be empty.
# If we didn't ask for an email address at the start, do so now. # If we didn't ask for an email address at the start, do so now.
@ -10,7 +11,7 @@ if [ -z "$(management/cli.py user)" ]; then
input_box "Mail Account" \ input_box "Mail Account" \
"Let's create your first mail account. "Let's create your first mail account.
\n\nWhat email address do you want?" \ \n\nWhat email address do you want?" \
me@$(get_default_hostname) \ "me@$(get_default_hostname)" \
EMAIL_ADDR EMAIL_ADDR
if [ -z "$EMAIL_ADDR" ]; then if [ -z "$EMAIL_ADDR" ]; then
@ -22,7 +23,7 @@ if [ -z "$(management/cli.py user)" ]; then
input_box "Mail Account" \ input_box "Mail Account" \
"That's not a valid email address. "That's not a valid email address.
\n\nWhat email address do you want?" \ \n\nWhat email address do you want?" \
$EMAIL_ADDR \ "$EMAIL_ADDR" \
EMAIL_ADDR EMAIL_ADDR
if [ -z "$EMAIL_ADDR" ]; then if [ -z "$EMAIL_ADDR" ]; then
# user hit ESC/cancel # user hit ESC/cancel
@ -47,11 +48,11 @@ if [ -z "$(management/cli.py user)" ]; then
fi fi
# Create the user's mail account. This will ask for a password if none was given above. # Create the user's mail account. This will ask for a password if none was given above.
management/cli.py user add $EMAIL_ADDR ${EMAIL_PW:-} management/cli.py user add "$EMAIL_ADDR" ${EMAIL_PW:+"$EMAIL_PW"}
# Make it an admin. # Make it an admin.
hide_output management/cli.py user make-admin $EMAIL_ADDR hide_output management/cli.py user make-admin "$EMAIL_ADDR"
# Create an alias to which we'll direct all automatically-created administrative aliases. # Create an alias to which we'll direct all automatically-created administrative aliases.
management/cli.py alias add administrator@$PRIMARY_HOSTNAME $EMAIL_ADDR > /dev/null management/cli.py alias add "administrator@$PRIMARY_HOSTNAME" "$EMAIL_ADDR" > /dev/null
fi fi

View File

@ -1,3 +1,4 @@
#!/bin/bash
# Turn on "strict mode." See http://redsymbol.net/articles/unofficial-bash-strict-mode/. # Turn on "strict mode." See http://redsymbol.net/articles/unofficial-bash-strict-mode/.
# -e: exit if any command unexpectedly fails. # -e: exit if any command unexpectedly fails.
# -u: exit if we have a variable typo. # -u: exit if we have a variable typo.
@ -16,7 +17,7 @@ function hide_output {
# Execute command, redirecting stderr/stdout to the temporary file. Since we # Execute command, redirecting stderr/stdout to the temporary file. Since we
# check the return code ourselves, disable 'set -e' temporarily. # check the return code ourselves, disable 'set -e' temporarily.
set +e set +e
"$@" &> $OUTPUT "$@" &> "$OUTPUT"
E=$? E=$?
set -e set -e
@ -24,15 +25,15 @@ function hide_output {
if [ $E != 0 ]; then if [ $E != 0 ]; then
# Something failed. # Something failed.
echo echo
echo FAILED: "$@" echo "FAILED: $*"
echo ----------------------------------------- echo -----------------------------------------
cat $OUTPUT cat "$OUTPUT"
echo ----------------------------------------- echo -----------------------------------------
exit $E exit $E
fi fi
# Remove temporary file. # Remove temporary file.
rm -f $OUTPUT rm -f "$OUTPUT"
} }
function apt_get_quiet { function apt_get_quiet {
@ -62,9 +63,9 @@ function get_default_hostname {
# Guess the machine's hostname. It should be a fully qualified # Guess the machine's hostname. It should be a fully qualified
# domain name suitable for DNS. None of these calls may provide # domain name suitable for DNS. None of these calls may provide
# the right value, but it's the best guess we can make. # the right value, but it's the best guess we can make.
set -- $(hostname --fqdn 2>/dev/null || set -- "$(hostname --fqdn 2>/dev/null ||
hostname --all-fqdns 2>/dev/null || hostname --all-fqdns 2>/dev/null ||
hostname 2>/dev/null) hostname 2>/dev/null)"
printf '%s\n' "$1" # return this value printf '%s\n' "$1" # return this value
} }
@ -76,7 +77,7 @@ function get_publicip_from_web_service {
# #
# Pass '4' or '6' as an argument to this function to specify # Pass '4' or '6' as an argument to this function to specify
# what type of address to get (IPv4, IPv6). # what type of address to get (IPv4, IPv6).
curl -$1 --fail --silent --max-time 15 icanhazip.com 2>/dev/null || /bin/true curl -"$1" --fail --silent --max-time 15 icanhazip.com 2>/dev/null || /bin/true
} }
function get_default_privateip { function get_default_privateip {
@ -119,19 +120,19 @@ function get_default_privateip {
if [ "$1" == "6" ]; then target=2001:4860:4860::8888; fi if [ "$1" == "6" ]; then target=2001:4860:4860::8888; fi
# Get the route information. # Get the route information.
route=$(ip -$1 -o route get $target 2>/dev/null | grep -v unreachable) route=$(ip -"$1" -o route get $target 2>/dev/null | grep -v unreachable)
# Parse the address out of the route information. # Parse the address out of the route information.
address=$(echo $route | sed "s/.* src \([^ ]*\).*/\1/") address=$(echo "$route" | sed "s/.* src \([^ ]*\).*/\1/")
if [[ "$1" == "6" && $address == fe80:* ]]; then if [[ "$1" == "6" && $address == fe80:* ]]; then
# For IPv6 link-local addresses, parse the interface out # For IPv6 link-local addresses, parse the interface out
# of the route information and append it with a '%'. # of the route information and append it with a '%'.
interface=$(echo $route | sed "s/.* dev \([^ ]*\).*/\1/") interface=$(echo "$route" | sed "s/.* dev \([^ ]*\).*/\1/")
address=$address%$interface address=$address%$interface
fi fi
echo $address echo "$address"
} }
function ufw_allow { function ufw_allow {
@ -149,7 +150,7 @@ function ufw_limit {
} }
function restart_service { function restart_service {
hide_output service $1 restart hide_output service "$1" restart
} }
## Dialog Functions ## ## Dialog Functions ##
@ -178,7 +179,7 @@ function input_menu {
declare -n result_code=$4_EXITCODE declare -n result_code=$4_EXITCODE
local IFS=^$'\n' local IFS=^$'\n'
set +e set +e
result=$(dialog --stdout --title "$1" --menu "$2" 0 0 0 $3) result=$(dialog --stdout --title "$1" --menu "$2" 0 0 0 "$3")
result_code=$? result_code=$?
set -e set -e
} }
@ -190,17 +191,17 @@ function wget_verify {
HASH=$2 HASH=$2
DEST=$3 DEST=$3
CHECKSUM="$HASH $DEST" CHECKSUM="$HASH $DEST"
rm -f $DEST rm -f "$DEST"
hide_output wget -O $DEST $URL hide_output wget -O "$DEST" "$URL"
if ! echo "$CHECKSUM" | sha1sum --check --strict > /dev/null; then if ! echo "$CHECKSUM" | sha1sum --check --strict > /dev/null; then
echo "------------------------------------------------------------" echo "------------------------------------------------------------"
echo "Download of $URL did not match expected checksum." echo "Download of $URL did not match expected checksum."
echo "Found:" echo "Found:"
sha1sum $DEST sha1sum "$DEST"
echo echo
echo "Expected:" echo "Expected:"
echo "$CHECKSUM" echo "$CHECKSUM"
rm -f $DEST rm -f "$DEST"
exit 1 exit 1
fi fi
} }
@ -216,9 +217,9 @@ function git_clone {
SUBDIR=$3 SUBDIR=$3
TARGETPATH=$4 TARGETPATH=$4
TMPPATH=/tmp/git-clone-$$ TMPPATH=/tmp/git-clone-$$
rm -rf $TMPPATH $TARGETPATH rm -rf $TMPPATH "$TARGETPATH"
git clone -q $REPO $TMPPATH || exit 1 git clone -q "$REPO" $TMPPATH || exit 1
(cd $TMPPATH; git checkout -q $TREEISH;) || exit 1 (cd $TMPPATH; git checkout -q "$TREEISH";) || exit 1
mv $TMPPATH/$SUBDIR $TARGETPATH mv $TMPPATH/"$SUBDIR" "$TARGETPATH"
rm -rf $TMPPATH rm -rf $TMPPATH
} }

View File

@ -45,8 +45,8 @@ apt_install \
# - https://www.dovecot.org/list/dovecot/2012-August/137569.html # - https://www.dovecot.org/list/dovecot/2012-August/137569.html
# - https://www.dovecot.org/list/dovecot/2011-December/132455.html # - https://www.dovecot.org/list/dovecot/2011-December/132455.html
tools/editconf.py /etc/dovecot/conf.d/10-master.conf \ tools/editconf.py /etc/dovecot/conf.d/10-master.conf \
default_process_limit=$(echo "$(nproc) * 250" | bc) \ default_process_limit="$(($(nproc) * 250))" \
default_vsz_limit=$(echo "$(free -tm | tail -1 | awk '{print $2}') / 3" | bc)M \ default_vsz_limit="$(($(free -tm | tail -1 | awk '{print $2}') / 3))M" \
log_path=/var/log/mail.log log_path=/var/log/mail.log
# The inotify `max_user_instances` default is 128, which constrains # The inotify `max_user_instances` default is 128, which constrains
@ -61,12 +61,38 @@ tools/editconf.py /etc/sysctl.conf \
# username part of the user's email address. We'll ensure that no bad domains or email addresses # username part of the user's email address. We'll ensure that no bad domains or email addresses
# are created within the management daemon. # are created within the management daemon.
tools/editconf.py /etc/dovecot/conf.d/10-mail.conf \ tools/editconf.py /etc/dovecot/conf.d/10-mail.conf \
mail_location=maildir:$STORAGE_ROOT/mail/mailboxes/%d/%n \ mail_location="maildir:$STORAGE_ROOT/mail/mailboxes/%d/%n" \
mail_privileged_group=mail \ mail_privileged_group=mail \
first_valid_uid=0 first_valid_uid=0
# Create, subscribe, and mark as special folders: INBOX, Drafts, Sent, Trash, Spam and Archive. # Create, subscribe, and mark as special folders: INBOX, Drafts, Sent, Trash, Spam and Archive.
cp conf/dovecot-mailboxes.conf /etc/dovecot/conf.d/15-mailboxes.conf cp conf/dovecot-mailboxes.conf /etc/dovecot/conf.d/15-mailboxes.conf
sed -i "s/#mail_plugins =\(.*\)/mail_plugins =\1 \$mail_plugins quota/" /etc/dovecot/conf.d/10-mail.conf
if ! grep -q "mail_plugins.* imap_quota" /etc/dovecot/conf.d/20-imap.conf; then
sed -i "s/\(mail_plugins =.*\)/\1\n mail_plugins = \$mail_plugins imap_quota/" /etc/dovecot/conf.d/20-imap.conf
fi
# configure stuff for quota support
if ! grep -q "quota_status_success = DUNNO" /etc/dovecot/conf.d/90-quota.conf; then
cat > /etc/dovecot/conf.d/90-quota.conf << EOF;
plugin {
quota = maildir
quota_grace = 10%%
quota_status_success = DUNNO
quota_status_nouser = DUNNO
quota_status_overquota = "522 5.2.2 Mailbox is full"
}
service quota-status {
executable = quota-status -p postfix
inet_listener {
port = 12340
}
}
EOF
fi
# ### IMAP/POP # ### IMAP/POP
@ -152,7 +178,7 @@ EOF
# Setting a `postmaster_address` is required or LMTP won't start. An alias # Setting a `postmaster_address` is required or LMTP won't start. An alias
# will be created automatically by our management daemon. # will be created automatically by our management daemon.
tools/editconf.py /etc/dovecot/conf.d/15-lda.conf \ tools/editconf.py /etc/dovecot/conf.d/15-lda.conf \
postmaster_address=postmaster@$PRIMARY_HOSTNAME "postmaster_address=postmaster@$PRIMARY_HOSTNAME"
# ### Sieve # ### Sieve
@ -201,14 +227,14 @@ chown -R mail:dovecot /etc/dovecot
chmod -R o-rwx /etc/dovecot chmod -R o-rwx /etc/dovecot
# Ensure mailbox files have a directory that exists and are owned by the mail user. # Ensure mailbox files have a directory that exists and are owned by the mail user.
mkdir -p $STORAGE_ROOT/mail/mailboxes mkdir -p "$STORAGE_ROOT/mail/mailboxes"
chown -R mail.mail $STORAGE_ROOT/mail/mailboxes chown -R mail:mail "$STORAGE_ROOT/mail/mailboxes"
# Same for the sieve scripts. # Same for the sieve scripts.
mkdir -p $STORAGE_ROOT/mail/sieve mkdir -p "$STORAGE_ROOT/mail/sieve"
mkdir -p $STORAGE_ROOT/mail/sieve/global_before mkdir -p "$STORAGE_ROOT/mail/sieve/global_before"
mkdir -p $STORAGE_ROOT/mail/sieve/global_after mkdir -p "$STORAGE_ROOT/mail/sieve/global_after"
chown -R mail.mail $STORAGE_ROOT/mail/sieve chown -R mail:mail "$STORAGE_ROOT/mail/sieve"
# Allow the IMAP/POP ports in the firewall. # Allow the IMAP/POP ports in the firewall.
ufw_allow imaps ufw_allow imaps

View File

@ -37,7 +37,7 @@ source /etc/mailinabox.conf # load global vars
# * `postfix`: The SMTP server. # * `postfix`: The SMTP server.
# * `postfix-pcre`: Enables header filtering. # * `postfix-pcre`: Enables header filtering.
# * `postgrey`: A mail policy service that soft-rejects mail the first time # * `postgrey`: A mail policy service that soft-rejects mail the first time
# it is received. Spammers don't usually try agian. Legitimate mail # it is received. Spammers don't usually try again. Legitimate mail
# always will. # always will.
# * `ca-certificates`: A trust store used to squelch postfix warnings about # * `ca-certificates`: A trust store used to squelch postfix warnings about
# untrusted opportunistically-encrypted connections. # untrusted opportunistically-encrypted connections.
@ -55,9 +55,9 @@ apt_install postfix postfix-sqlite postfix-pcre postgrey ca-certificates
# * Set the SMTP banner (which must have the hostname first, then anything). # * Set the SMTP banner (which must have the hostname first, then anything).
tools/editconf.py /etc/postfix/main.cf \ tools/editconf.py /etc/postfix/main.cf \
inet_interfaces=all \ inet_interfaces=all \
smtp_bind_address=$PRIVATE_IP \ smtp_bind_address="$PRIVATE_IP" \
smtp_bind_address6=$PRIVATE_IPV6 \ smtp_bind_address6="$PRIVATE_IPV6" \
myhostname=$PRIMARY_HOSTNAME\ myhostname="$PRIMARY_HOSTNAME"\
smtpd_banner="\$myhostname ESMTP Hi, I'm a Mail-in-a-Box (Ubuntu/Postfix; see https://mailinabox.email/)" \ smtpd_banner="\$myhostname ESMTP Hi, I'm a Mail-in-a-Box (Ubuntu/Postfix; see https://mailinabox.email/)" \
mydestination=localhost mydestination=localhost
@ -69,6 +69,18 @@ tools/editconf.py /etc/postfix/main.cf \
maximal_queue_lifetime=2d \ maximal_queue_lifetime=2d \
bounce_queue_lifetime=1d bounce_queue_lifetime=1d
# Guard against SMTP smuggling
# This "long-term" fix is recommended at https://www.postfix.org/smtp-smuggling.html.
# This beecame supported in a backported fix in package version 3.6.4-1ubuntu1.3. It is
# unnecessary in Postfix 3.9+ where this is the default. The "short-term" workarounds
# that we previously had are reverted to postfix defaults (though smtpd_discard_ehlo_keywords
# was never included in a released version of Mail-in-a-Box).
tools/editconf.py /etc/postfix/main.cf -e \
smtpd_data_restrictions= \
smtpd_discard_ehlo_keywords=
tools/editconf.py /etc/postfix/main.cf \
smtpd_forbid_bare_newline=normalize
# ### Outgoing Mail # ### Outgoing Mail
# Enable the 'submission' ports 465 and 587 and tweak their settings. # Enable the 'submission' ports 465 and 587 and tweak their settings.
@ -126,9 +138,9 @@ sed -i "s/PUBLIC_IP/$PUBLIC_IP/" /etc/postfix/outgoing_mail_header_filters
tools/editconf.py /etc/postfix/main.cf \ tools/editconf.py /etc/postfix/main.cf \
smtpd_tls_security_level=may\ smtpd_tls_security_level=may\
smtpd_tls_auth_only=yes \ smtpd_tls_auth_only=yes \
smtpd_tls_cert_file=$STORAGE_ROOT/ssl/ssl_certificate.pem \ smtpd_tls_cert_file="$STORAGE_ROOT/ssl/ssl_certificate.pem" \
smtpd_tls_key_file=$STORAGE_ROOT/ssl/ssl_private_key.pem \ smtpd_tls_key_file="$STORAGE_ROOT/ssl/ssl_private_key.pem" \
smtpd_tls_dh1024_param_file=$STORAGE_ROOT/ssl/dh2048.pem \ smtpd_tls_dh1024_param_file="$STORAGE_ROOT/ssl/dh2048.pem" \
smtpd_tls_protocols="!SSLv2,!SSLv3" \ smtpd_tls_protocols="!SSLv2,!SSLv3" \
smtpd_tls_ciphers=medium \ smtpd_tls_ciphers=medium \
tls_medium_cipherlist=ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA256:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA \ tls_medium_cipherlist=ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA256:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA \
@ -160,7 +172,7 @@ tools/editconf.py /etc/postfix/main.cf \
# When connecting to remote SMTP servers, prefer TLS and use DANE if available. # When connecting to remote SMTP servers, prefer TLS and use DANE if available.
# #
# Prefering ("opportunistic") TLS means Postfix will use TLS if the remote end # Preferring ("opportunistic") TLS means Postfix will use TLS if the remote end
# offers it, otherwise it will transmit the message in the clear. Postfix will # offers it, otherwise it will transmit the message in the clear. Postfix will
# accept whatever SSL certificate the remote end provides. Opportunistic TLS # accept whatever SSL certificate the remote end provides. Opportunistic TLS
# protects against passive easvesdropping (but not man-in-the-middle attacks). # protects against passive easvesdropping (but not man-in-the-middle attacks).
@ -176,7 +188,7 @@ tools/editconf.py /etc/postfix/main.cf \
# itself but assumes the system's nameserver does and reports DNSSEC status. Thus this also # itself but assumes the system's nameserver does and reports DNSSEC status. Thus this also
# relies on our local DNS server (see system.sh) and `smtp_dns_support_level=dnssec`. # relies on our local DNS server (see system.sh) and `smtp_dns_support_level=dnssec`.
# #
# The `smtp_tls_CAfile` is superflous, but it eliminates warnings in the logs about untrusted certs, # The `smtp_tls_CAfile` is superfluous, but it eliminates warnings in the logs about untrusted certs,
# which we don't care about seeing because Postfix is doing opportunistic TLS anyway. Better to encrypt, # which we don't care about seeing because Postfix is doing opportunistic TLS anyway. Better to encrypt,
# even if we don't know if it's to the right party, than to not encrypt at all. Instead we'll # even if we don't know if it's to the right party, than to not encrypt at all. Instead we'll
# now see notices about trusted certs. The CA file is provided by the package `ca-certificates`. # now see notices about trusted certs. The CA file is provided by the package `ca-certificates`.
@ -218,14 +230,15 @@ tools/editconf.py /etc/postfix/main.cf -e lmtp_destination_recipient_limit=
# * `reject_unlisted_recipient`: Although Postfix will reject mail to unknown recipients, it's nicer to reject such mail ahead of greylisting rather than after. # * `reject_unlisted_recipient`: Although Postfix will reject mail to unknown recipients, it's nicer to reject such mail ahead of greylisting rather than after.
# * `check_policy_service`: Apply greylisting using postgrey. # * `check_policy_service`: Apply greylisting using postgrey.
# #
# Note the spamhaus rbl return codes are taken into account as advised here: https://docs.spamhaus.com/datasets/docs/source/40-real-world-usage/PublicMirrors/MTAs/020-Postfix.html
# Notes: #NODOC # Notes: #NODOC
# permit_dnswl_client can pass through mail from whitelisted IP addresses, which would be good to put before greylisting #NODOC # permit_dnswl_client can pass through mail from whitelisted IP addresses, which would be good to put before greylisting #NODOC
# so these IPs get mail delivered quickly. But when an IP is not listed in the permit_dnswl_client list (i.e. it is not #NODOC # so these IPs get mail delivered quickly. But when an IP is not listed in the permit_dnswl_client list (i.e. it is not #NODOC
# whitelisted) then postfix does a DEFER_IF_REJECT, which results in all "unknown user" sorts of messages turning into #NODOC # whitelisted) then postfix does a DEFER_IF_REJECT, which results in all "unknown user" sorts of messages turning into #NODOC
# "450 4.7.1 Client host rejected: Service unavailable". This is a retry code, so the mail doesn't properly bounce. #NODOC # "450 4.7.1 Client host rejected: Service unavailable". This is a retry code, so the mail doesn't properly bounce. #NODOC
tools/editconf.py /etc/postfix/main.cf \ tools/editconf.py /etc/postfix/main.cf \
smtpd_sender_restrictions="reject_non_fqdn_sender,reject_unknown_sender_domain,reject_authenticated_sender_login_mismatch,reject_rhsbl_sender dbl.spamhaus.org" \ smtpd_sender_restrictions="reject_non_fqdn_sender,reject_unknown_sender_domain,reject_authenticated_sender_login_mismatch,reject_rhsbl_sender dbl.spamhaus.org=127.0.1.[2..99]" \
smtpd_recipient_restrictions=permit_sasl_authenticated,permit_mynetworks,"reject_rbl_client zen.spamhaus.org",reject_unlisted_recipient,"check_policy_service inet:127.0.0.1:10023" smtpd_recipient_restrictions="permit_sasl_authenticated,permit_mynetworks,reject_rbl_client zen.spamhaus.org=127.0.0.[2..11],reject_unlisted_recipient,check_policy_service inet:127.0.0.1:10023,check_policy_service inet:127.0.0.1:12340"
# Postfix connects to Postgrey on the 127.0.0.1 interface specifically. Ensure that # Postfix connects to Postgrey on the 127.0.0.1 interface specifically. Ensure that
# Postgrey listens on the same interface (and not IPv6, for instance). # Postgrey listens on the same interface (and not IPv6, for instance).
@ -234,7 +247,7 @@ tools/editconf.py /etc/postfix/main.cf \
# other MTA have their own intervals. To fix the problem of receiving # other MTA have their own intervals. To fix the problem of receiving
# e-mails really latter, delay of greylisting has been set to # e-mails really latter, delay of greylisting has been set to
# 180 seconds (default is 300 seconds). We will move the postgrey database # 180 seconds (default is 300 seconds). We will move the postgrey database
# under $STORAGE_ROOT. This prevents a "warming up" that would have occured # under $STORAGE_ROOT. This prevents a "warming up" that would have occurred
# previously with a migrated or reinstalled OS. We will specify this new path # previously with a migrated or reinstalled OS. We will specify this new path
# with the --dbdir=... option. Arguments within POSTGREY_OPTS can not have spaces, # with the --dbdir=... option. Arguments within POSTGREY_OPTS can not have spaces,
# including dbdir. This is due to the way the init script sources the # including dbdir. This is due to the way the init script sources the
@ -247,17 +260,17 @@ tools/editconf.py /etc/default/postgrey \
# If the $STORAGE_ROOT/mail/postgrey is empty, copy the postgrey database over from the old location # If the $STORAGE_ROOT/mail/postgrey is empty, copy the postgrey database over from the old location
if [ ! -d $STORAGE_ROOT/mail/postgrey/db ]; then if [ ! -d "$STORAGE_ROOT/mail/postgrey/db" ]; then
# Stop the service # Stop the service
service postgrey stop service postgrey stop
# Ensure the new paths for postgrey db exists # Ensure the new paths for postgrey db exists
mkdir -p $STORAGE_ROOT/mail/postgrey/db mkdir -p "$STORAGE_ROOT/mail/postgrey/db"
# Move over database files # Move over database files
mv /var/lib/postgrey/* $STORAGE_ROOT/mail/postgrey/db/ || true mv /var/lib/postgrey/* "$STORAGE_ROOT/mail/postgrey/db/" || true
fi fi
# Ensure permissions are set # Ensure permissions are set
chown -R postgrey:postgrey $STORAGE_ROOT/mail/postgrey/ chown -R postgrey:postgrey "$STORAGE_ROOT/mail/postgrey/"
chmod 700 $STORAGE_ROOT/mail/postgrey/{,db} chmod 700 "$STORAGE_ROOT/mail/postgrey/"{,db}
# We are going to setup a newer whitelist for postgrey, the version included in the distribution is old # We are going to setup a newer whitelist for postgrey, the version included in the distribution is old
cat > /etc/cron.daily/mailinabox-postgrey-whitelist << EOF; cat > /etc/cron.daily/mailinabox-postgrey-whitelist << EOF;

View File

@ -5,7 +5,7 @@
# #
# This script configures user authentication for Dovecot # This script configures user authentication for Dovecot
# and Postfix (which relies on Dovecot) and destination # and Postfix (which relies on Dovecot) and destination
# validation by quering an Sqlite3 database of mail users. # validation by querying an Sqlite3 database of mail users.
source setup/functions.sh # load our functions source setup/functions.sh # load our functions
source /etc/mailinabox.conf # load global vars source /etc/mailinabox.conf # load global vars
@ -18,12 +18,12 @@ source /etc/mailinabox.conf # load global vars
db_path=$STORAGE_ROOT/mail/users.sqlite db_path=$STORAGE_ROOT/mail/users.sqlite
# Create an empty database if it doesn't yet exist. # Create an empty database if it doesn't yet exist.
if [ ! -f $db_path ]; then if [ ! -f "$db_path" ]; then
echo Creating new user database: $db_path; echo "Creating new user database: $db_path";
echo "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra, privileges TEXT NOT NULL DEFAULT '');" | sqlite3 $db_path; echo "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra, privileges TEXT NOT NULL DEFAULT '', quota TEXT NOT NULL DEFAULT '0');" | sqlite3 "$db_path";
echo "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);" | sqlite3 $db_path; echo "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);" | sqlite3 "$db_path";
echo "CREATE TABLE mfa (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, type TEXT NOT NULL, secret TEXT NOT NULL, mru_token TEXT, label TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);" | sqlite3 $db_path; echo "CREATE TABLE mfa (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, type TEXT NOT NULL, secret TEXT NOT NULL, mru_token TEXT, label TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);" | sqlite3 "$db_path";
echo "CREATE TABLE auto_aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);" | sqlite3 $db_path; echo "CREATE TABLE auto_aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);" | sqlite3 "$db_path";
fi fi
# ### User Authentication # ### User Authentication
@ -51,7 +51,7 @@ driver = sqlite
connect = $db_path connect = $db_path
default_pass_scheme = SHA512-CRYPT default_pass_scheme = SHA512-CRYPT
password_query = SELECT email as user, password FROM users WHERE email='%u'; password_query = SELECT email as user, password FROM users WHERE email='%u';
user_query = SELECT email AS user, "mail" as uid, "mail" as gid, "$STORAGE_ROOT/mail/mailboxes/%d/%n" as home FROM users WHERE email='%u'; user_query = SELECT email AS user, "mail" as uid, "mail" as gid, "$STORAGE_ROOT/mail/mailboxes/%d/%n" as home, '*:bytes=' || quota AS quota_rule FROM users WHERE email='%u';
iterate_query = SELECT email AS user FROM users; iterate_query = SELECT email AS user FROM users;
EOF EOF
chmod 0600 /etc/dovecot/dovecot-sql.conf.ext # per Dovecot instructions chmod 0600 /etc/dovecot/dovecot-sql.conf.ext # per Dovecot instructions
@ -159,4 +159,5 @@ EOF
restart_service postfix restart_service postfix
restart_service dovecot restart_service dovecot
# force a recalculation of all user quotas
doveadm quota recalc -A

View File

@ -27,6 +27,12 @@ inst_dir=/usr/local/lib/mailinabox
mkdir -p $inst_dir mkdir -p $inst_dir
venv=$inst_dir/env venv=$inst_dir/env
if [ ! -d $venv ]; then if [ ! -d $venv ]; then
# A bug specific to Ubuntu 22.04 and Python 3.10 requires
# forcing a virtualenv directory layout option (see #2335
# and https://github.com/pypa/virtualenv/pull/2415). In
# our issue, reportedly installing python3-distutils didn't
# fix the problem.)
export DEB_PYTHON_INSTALL_LAYOUT='deb'
hide_output virtualenv -ppython3 $venv hide_output virtualenv -ppython3 $venv
fi fi
@ -46,9 +52,9 @@ hide_output $venv/bin/pip install --upgrade \
# CONFIGURATION # CONFIGURATION
# Create a backup directory and a random key for encrypting backups. # Create a backup directory and a random key for encrypting backups.
mkdir -p $STORAGE_ROOT/backup mkdir -p "$STORAGE_ROOT/backup"
if [ ! -f $STORAGE_ROOT/backup/secret_key.txt ]; then if [ ! -f "$STORAGE_ROOT/backup/secret_key.txt" ]; then
$(umask 077; openssl rand -base64 2048 > $STORAGE_ROOT/backup/secret_key.txt) (umask 077; openssl rand -base64 2048 > "$STORAGE_ROOT/backup/secret_key.txt")
fi fi
@ -60,18 +66,18 @@ rm -rf $assets_dir
mkdir -p $assets_dir mkdir -p $assets_dir
# jQuery CDN URL # jQuery CDN URL
jquery_version=2.1.4 jquery_version=2.2.4
jquery_url=https://code.jquery.com jquery_url=https://code.jquery.com
# Get jQuery # Get jQuery
wget_verify $jquery_url/jquery-$jquery_version.min.js 43dc554608df885a59ddeece1598c6ace434d747 $assets_dir/jquery.min.js wget_verify $jquery_url/jquery-$jquery_version.min.js 69bb69e25ca7d5ef0935317584e6153f3fd9a88c $assets_dir/jquery.min.js
# Bootstrap CDN URL # Bootstrap CDN URL
bootstrap_version=3.3.7 bootstrap_version=3.4.1
bootstrap_url=https://github.com/twbs/bootstrap/releases/download/v$bootstrap_version/bootstrap-$bootstrap_version-dist.zip bootstrap_url=https://github.com/twbs/bootstrap/releases/download/v$bootstrap_version/bootstrap-$bootstrap_version-dist.zip
# Get Bootstrap # Get Bootstrap
wget_verify $bootstrap_url e6b1000b94e835ffd37f4c6dcbdad43f4b48a02a /tmp/bootstrap.zip wget_verify $bootstrap_url 0bb64c67c2552014d48ab4db81c2e8c01781f580 /tmp/bootstrap.zip
unzip -q /tmp/bootstrap.zip -d $assets_dir unzip -q /tmp/bootstrap.zip -d $assets_dir
mv $assets_dir/bootstrap-$bootstrap_version-dist $assets_dir/bootstrap mv $assets_dir/bootstrap-$bootstrap_version-dist $assets_dir/bootstrap
rm -f /tmp/bootstrap.zip rm -f /tmp/bootstrap.zip
@ -94,7 +100,7 @@ tr -cd '[:xdigit:]' < /dev/urandom | head -c 32 > /var/lib/mailinabox/api.key
chmod 640 /var/lib/mailinabox/api.key chmod 640 /var/lib/mailinabox/api.key
source $venv/bin/activate source $venv/bin/activate
export PYTHONPATH=$(pwd)/management export PYTHONPATH=$PWD/management
exec gunicorn -b localhost:10222 -w 1 --timeout 630 wsgi:app exec gunicorn -b localhost:10222 -w 1 --timeout 630 wsgi:app
EOF EOF
chmod +x $inst_dir/start chmod +x $inst_dir/start
@ -110,7 +116,7 @@ minute=$((RANDOM % 60)) # avoid overloading mailinabox.email
cat > /etc/cron.d/mailinabox-nightly << EOF; cat > /etc/cron.d/mailinabox-nightly << EOF;
# Mail-in-a-Box --- Do not edit / will be overwritten on update. # Mail-in-a-Box --- Do not edit / will be overwritten on update.
# Run nightly tasks: backup, status checks. # Run nightly tasks: backup, status checks.
$minute 3 * * * root (cd $(pwd) && management/daily_tasks.sh) $minute 1 * * * root (cd $PWD && management/daily_tasks.sh)
EOF EOF
# Start the management server. # Start the management server.

View File

@ -9,6 +9,7 @@ import sys, os, os.path, glob, re, shutil
sys.path.insert(0, 'management') sys.path.insert(0, 'management')
from utils import load_environment, save_environment, shell from utils import load_environment, save_environment, shell
import contextlib
def migration_1(env): def migration_1(env):
# Re-arrange where we store SSL certificates. There was a typo also. # Re-arrange where we store SSL certificates. There was a typo also.
@ -22,7 +23,7 @@ def migration_1(env):
# Migrate the 'domains' directory. # Migrate the 'domains' directory.
for sslfn in glob.glob(os.path.join( env["STORAGE_ROOT"], 'ssl/domains/*' )): for sslfn in glob.glob(os.path.join( env["STORAGE_ROOT"], 'ssl/domains/*' )):
fn = os.path.basename(sslfn) fn = os.path.basename(sslfn)
m = re.match("(.*)_(certifiate.pem|cert_sign_req.csr|private_key.pem)$", fn) m = re.match(r"(.*)_(certifiate.pem|cert_sign_req.csr|private_key.pem)$", fn)
if m: if m:
# get the new name for the file # get the new name for the file
domain_name, file_type = m.groups() domain_name, file_type = m.groups()
@ -31,10 +32,8 @@ def migration_1(env):
move_file(sslfn, domain_name, file_type) move_file(sslfn, domain_name, file_type)
# Move the old domains directory if it is now empty. # Move the old domains directory if it is now empty.
try: with contextlib.suppress(Exception):
os.rmdir(os.path.join( env["STORAGE_ROOT"], 'ssl/domains')) os.rmdir(os.path.join( env["STORAGE_ROOT"], 'ssl/domains'))
except:
pass
def migration_2(env): def migration_2(env):
# Delete the .dovecot_sieve script everywhere. This was formerly a copy of our spam -> Spam # Delete the .dovecot_sieve script everywhere. This was formerly a copy of our spam -> Spam
@ -165,15 +164,15 @@ def migration_12(env):
try: try:
table = table[0] table = table[0]
c = conn.cursor() c = conn.cursor()
dropcmd = "DROP TABLE %s" % table dropcmd = f"DROP TABLE {table}"
c.execute(dropcmd) c.execute(dropcmd)
except: except:
print("Failed to drop table", table, e) print("Failed to drop table", table)
# Save. # Save.
conn.commit() conn.commit()
conn.close() conn.close()
# Delete all sessions, requring users to login again to recreate carddav_* # Delete all sessions, requiring users to login again to recreate carddav_*
# databases # databases
conn = sqlite3.connect(os.path.join(env["STORAGE_ROOT"], "mail/roundcube/roundcube.sqlite")) conn = sqlite3.connect(os.path.join(env["STORAGE_ROOT"], "mail/roundcube/roundcube.sqlite"))
c = conn.cursor() c = conn.cursor()
@ -191,6 +190,12 @@ def migration_14(env):
db = os.path.join(env["STORAGE_ROOT"], 'mail/users.sqlite') db = os.path.join(env["STORAGE_ROOT"], 'mail/users.sqlite')
shell("check_call", ["sqlite3", db, "CREATE TABLE auto_aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);"]) shell("check_call", ["sqlite3", db, "CREATE TABLE auto_aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);"])
def migration_15(env):
# Add a column to the users table to store their quota limit. Default to '0' for unlimited.
db = os.path.join(env["STORAGE_ROOT"], 'mail/users.sqlite')
shell("check_call", ["sqlite3", db, "ALTER TABLE users ADD COLUMN quota TEXT NOT NULL DEFAULT '0';"])
########################################################### ###########################################################
def get_current_migration(): def get_current_migration():
@ -212,8 +217,8 @@ def run_migrations():
migration_id_file = os.path.join(env['STORAGE_ROOT'], 'mailinabox.version') migration_id_file = os.path.join(env['STORAGE_ROOT'], 'mailinabox.version')
migration_id = None migration_id = None
if os.path.exists(migration_id_file): if os.path.exists(migration_id_file):
with open(migration_id_file) as f: with open(migration_id_file, encoding='utf-8') as f:
migration_id = f.read().strip(); migration_id = f.read().strip()
if migration_id is None: if migration_id is None:
# Load the legacy location of the migration ID. We'll drop support # Load the legacy location of the migration ID. We'll drop support
@ -222,7 +227,7 @@ def run_migrations():
if migration_id is None: if migration_id is None:
print() print()
print("%s file doesn't exists. Skipping migration..." % (migration_id_file,)) print(f"{migration_id_file} file doesn't exists. Skipping migration...")
return return
ourver = int(migration_id) ourver = int(migration_id)
@ -253,7 +258,7 @@ def run_migrations():
# Write out our current version now. Do this sooner rather than later # Write out our current version now. Do this sooner rather than later
# in case of any problems. # in case of any problems.
with open(migration_id_file, "w") as f: with open(migration_id_file, "w", encoding='utf-8') as f:
f.write(str(ourver) + "\n") f.write(str(ourver) + "\n")
# Delete the legacy location of this field. # Delete the legacy location of this field.

View File

@ -34,13 +34,13 @@ contact.admin.always_send warning critical
EOF EOF
# The Debian installer touches these files and chowns them to www-data:adm for use with spawn-fcgi # The Debian installer touches these files and chowns them to www-data:adm for use with spawn-fcgi
chown munin. /var/log/munin/munin-cgi-html.log chown munin /var/log/munin/munin-cgi-html.log
chown munin. /var/log/munin/munin-cgi-graph.log chown munin /var/log/munin/munin-cgi-graph.log
# ensure munin-node knows the name of this machine # ensure munin-node knows the name of this machine
# and reduce logging level to warning # and reduce logging level to warning
tools/editconf.py /etc/munin/munin-node.conf -s \ tools/editconf.py /etc/munin/munin-node.conf -s \
host_name=$PRIMARY_HOSTNAME \ host_name="$PRIMARY_HOSTNAME" \
log_level=1 log_level=1
# Update the activated plugins through munin's autoconfiguration. # Update the activated plugins through munin's autoconfiguration.
@ -52,9 +52,9 @@ find /etc/munin/plugins/ -lname /usr/share/munin/plugins/ntp_ -print0 | xargs -0
# Deactivate monitoring of network interfaces that are not up. Otherwise we can get a lot of empty charts. # Deactivate monitoring of network interfaces that are not up. Otherwise we can get a lot of empty charts.
for f in $(find /etc/munin/plugins/ \( -lname /usr/share/munin/plugins/if_ -o -lname /usr/share/munin/plugins/if_err_ -o -lname /usr/share/munin/plugins/bonding_err_ \)); do for f in $(find /etc/munin/plugins/ \( -lname /usr/share/munin/plugins/if_ -o -lname /usr/share/munin/plugins/if_err_ -o -lname /usr/share/munin/plugins/bonding_err_ \)); do
IF=$(echo $f | sed s/.*_//); IF=$(echo "$f" | sed s/.*_//);
if ! grep -qFx up /sys/class/net/$IF/operstate 2>/dev/null; then if ! grep -qFx up "/sys/class/net/$IF/operstate" 2>/dev/null; then
rm $f; rm "$f";
fi; fi;
done done
@ -62,7 +62,7 @@ done
mkdir -p /var/lib/munin-node/plugin-state/ mkdir -p /var/lib/munin-node/plugin-state/
# Create a systemd service for munin. # Create a systemd service for munin.
ln -sf $(pwd)/management/munin_start.sh /usr/local/lib/mailinabox/munin_start.sh ln -sf "$PWD/management/munin_start.sh" /usr/local/lib/mailinabox/munin_start.sh
chmod 0744 /usr/local/lib/mailinabox/munin_start.sh chmod 0744 /usr/local/lib/mailinabox/munin_start.sh
cp --remove-destination conf/munin.service /lib/systemd/system/munin.service # target was previously a symlink so remove first cp --remove-destination conf/munin.service /lib/systemd/system/munin.service # target was previously a symlink so remove first
hide_output systemctl link -f /lib/systemd/system/munin.service hide_output systemctl link -f /lib/systemd/system/munin.service

View File

@ -1,3 +1,4 @@
#!/bin/bash
# Install the 'host', 'sed', and and 'nc' tools. This script is run before # Install the 'host', 'sed', and and 'nc' tools. This script is run before
# the rest of the system setup so we may not yet have things installed. # the rest of the system setup so we may not yet have things installed.
apt_get_quiet install bind9-host sed netcat-openbsd apt_get_quiet install bind9-host sed netcat-openbsd
@ -6,7 +7,7 @@ apt_get_quiet install bind9-host sed netcat-openbsd
# The user might have chosen a name that was previously in use by a spammer # The user might have chosen a name that was previously in use by a spammer
# and will not be able to reliably send mail. Do this after any automatic # and will not be able to reliably send mail. Do this after any automatic
# choices made above. # choices made above.
if host $PRIMARY_HOSTNAME.dbl.spamhaus.org > /dev/null; then if host "$PRIMARY_HOSTNAME.dbl.spamhaus.org" > /dev/null; then
echo echo
echo "The hostname you chose '$PRIMARY_HOSTNAME' is listed in the" echo "The hostname you chose '$PRIMARY_HOSTNAME' is listed in the"
echo "Spamhaus Domain Block List. See http://www.spamhaus.org/dbl/" echo "Spamhaus Domain Block List. See http://www.spamhaus.org/dbl/"
@ -22,8 +23,8 @@ fi
# The user might have ended up on an IP address that was previously in use # The user might have ended up on an IP address that was previously in use
# by a spammer, or the user may be deploying on a residential network. We # by a spammer, or the user may be deploying on a residential network. We
# will not be able to reliably send mail in these cases. # will not be able to reliably send mail in these cases.
REVERSED_IPV4=$(echo $PUBLIC_IP | sed "s/\([0-9]*\).\([0-9]*\).\([0-9]*\).\([0-9]*\)/\4.\3.\2.\1/") REVERSED_IPV4=$(echo "$PUBLIC_IP" | sed "s/\([0-9]*\).\([0-9]*\).\([0-9]*\).\([0-9]*\)/\4.\3.\2.\1/")
if host $REVERSED_IPV4.zen.spamhaus.org > /dev/null; then if host "$REVERSED_IPV4.zen.spamhaus.org" > /dev/null; then
echo echo
echo "The IP address $PUBLIC_IP is listed in the Spamhaus Block List." echo "The IP address $PUBLIC_IP is listed in the Spamhaus Block List."
echo "See http://www.spamhaus.org/query/ip/$PUBLIC_IP." echo "See http://www.spamhaus.org/query/ip/$PUBLIC_IP."

View File

@ -21,36 +21,57 @@ echo "Installing Nextcloud (contacts/calendar)..."
# we automatically install intermediate versions as needed. # we automatically install intermediate versions as needed.
# * The hash is the SHA1 hash of the ZIP package, which you can find by just running this script and # * The hash is the SHA1 hash of the ZIP package, which you can find by just running this script and
# copying it from the error message when it doesn't match what is below. # copying it from the error message when it doesn't match what is below.
nextcloud_ver=23.0.10 nextcloud_ver=26.0.13
nextcloud_hash=8831c7862e39460fbb789bacac8729fab0ba02dd nextcloud_hash=d5c10b650e5396d5045131c6d22c02a90572527c
# Nextcloud apps # Nextcloud apps
# -------------- # --------------
# * Find the most recent tag that is compatible with the Nextcloud version above by # * Find the most recent tag that is compatible with the Nextcloud version above by:
# consulting the <dependencies>...<nextcloud> node at: # https://github.com/nextcloud-releases/contacts/tags
# https://github.com/nextcloud-releases/contacts/blob/main/appinfo/info.xml # https://github.com/nextcloud-releases/calendar/tags
# https://github.com/nextcloud-releases/calendar/blob/main/appinfo/info.xml # https://github.com/nextcloud/user_external/tags
# https://github.com/nextcloud/user_external/blob/master/appinfo/info.xml #
# * The hash is the SHA1 hash of the ZIP package, which you can find by just running this script and # * For these three packages, contact, calendar and user_external, the hash is the SHA1 hash of
# copying it from the error message when it doesn't match what is below. # the ZIP package, which you can find by just running this script and copying it from
contacts_ver=4.2.2 # the error message when it doesn't match what is below:
contacts_hash=ca13d608ed8955aa374cb4f31b6026b57ef88887
calendar_ver=3.5.1 # Always ensure the versions are supported, see https://apps.nextcloud.com/apps/contacts
calendar_hash=c8136a3deb872a3ef73ce1155b58f3ab27ec7110 contacts_ver=5.5.3
user_external_ver=3.0.0 contacts_hash=799550f38e46764d90fa32ca1a6535dccd8316e5
user_external_hash=0df781b261f55bbde73d8c92da3f99397000972f
# Always ensure the versions are supported, see https://apps.nextcloud.com/apps/calendar
calendar_ver=4.7.6
calendar_hash=a995bca4effeecb2cab25f3bbeac9bfe05fee766
# Always ensure the versions are supported, see https://apps.nextcloud.com/apps/user_external
user_external_ver=3.3.0
user_external_hash=280d24eb2a6cb56b4590af8847f925c28d8d853e
# Developer advice (test plan)
# ----------------------------
# When upgrading above versions, how to test?
#
# 1. Enter your server instance (or on the Vagrant image)
# 1. Git clone <your fork>
# 2. Git checkout <your fork>
# 3. Run `sudo ./setup/nextcloud.sh`
# 4. Ensure the installation completes. If any hashes mismatch, correct them.
# 5. Enter nextcloud web, run following tests:
# 5.1 You still can create, edit and delete contacts
# 5.2 You still can create, edit and delete calendar events
# 5.3 You still can create, edit and delete users
# 5.4 Go to Administration > Logs and ensure no new errors are shown
# Clear prior packages and install dependencies from apt. # Clear prior packages and install dependencies from apt.
apt-get purge -qq -y owncloud* # we used to use the package manager apt-get purge -qq -y owncloud* # we used to use the package manager
apt_install curl php${PHP_VER} php${PHP_VER}-fpm \ apt_install curl php"${PHP_VER}" php"${PHP_VER}"-fpm \
php${PHP_VER}-cli php${PHP_VER}-sqlite3 php${PHP_VER}-gd php${PHP_VER}-imap php${PHP_VER}-curl \ php"${PHP_VER}"-cli php"${PHP_VER}"-sqlite3 php"${PHP_VER}"-gd php"${PHP_VER}"-imap php"${PHP_VER}"-curl \
php${PHP_VER}-dev php${PHP_VER}-gd php${PHP_VER}-xml php${PHP_VER}-mbstring php${PHP_VER}-zip php${PHP_VER}-apcu \ php"${PHP_VER}"-dev php"${PHP_VER}"-gd php"${PHP_VER}"-xml php"${PHP_VER}"-mbstring php"${PHP_VER}"-zip php"${PHP_VER}"-apcu \
php${PHP_VER}-intl php${PHP_VER}-imagick php${PHP_VER}-gmp php${PHP_VER}-bcmath php"${PHP_VER}"-intl php"${PHP_VER}"-imagick php"${PHP_VER}"-gmp php"${PHP_VER}"-bcmath
# Enable APC before Nextcloud tools are run. # Enable APC before Nextcloud tools are run.
tools/editconf.py /etc/php/$PHP_VER/mods-available/apcu.ini -c ';' \ tools/editconf.py /etc/php/"$PHP_VER"/mods-available/apcu.ini -c ';' \
apc.enabled=1 \ apc.enabled=1 \
apc.enable_cli=1 apc.enable_cli=1
@ -70,7 +91,7 @@ InstallNextcloud() {
echo echo
# Download and verify # Download and verify
wget_verify https://download.nextcloud.com/server/releases/nextcloud-$version.zip $hash /tmp/nextcloud.zip wget_verify "https://download.nextcloud.com/server/releases/nextcloud-$version.zip" "$hash" /tmp/nextcloud.zip
# Remove the current owncloud/Nextcloud # Remove the current owncloud/Nextcloud
rm -rf /usr/local/lib/owncloud rm -rf /usr/local/lib/owncloud
@ -84,18 +105,18 @@ InstallNextcloud() {
# their github repositories. # their github repositories.
mkdir -p /usr/local/lib/owncloud/apps mkdir -p /usr/local/lib/owncloud/apps
wget_verify https://github.com/nextcloud-releases/contacts/archive/refs/tags/v$version_contacts.tar.gz $hash_contacts /tmp/contacts.tgz wget_verify "https://github.com/nextcloud-releases/contacts/archive/refs/tags/v$version_contacts.tar.gz" "$hash_contacts" /tmp/contacts.tgz
tar xf /tmp/contacts.tgz -C /usr/local/lib/owncloud/apps/ tar xf /tmp/contacts.tgz -C /usr/local/lib/owncloud/apps/
rm /tmp/contacts.tgz rm /tmp/contacts.tgz
wget_verify https://github.com/nextcloud-releases/calendar/archive/refs/tags/v$version_calendar.tar.gz $hash_calendar /tmp/calendar.tgz wget_verify "https://github.com/nextcloud-releases/calendar/archive/refs/tags/v$version_calendar.tar.gz" "$hash_calendar" /tmp/calendar.tgz
tar xf /tmp/calendar.tgz -C /usr/local/lib/owncloud/apps/ tar xf /tmp/calendar.tgz -C /usr/local/lib/owncloud/apps/
rm /tmp/calendar.tgz rm /tmp/calendar.tgz
# Starting with Nextcloud 15, the app user_external is no longer included in Nextcloud core, # Starting with Nextcloud 15, the app user_external is no longer included in Nextcloud core,
# we will install from their github repository. # we will install from their github repository.
if [ -n "$version_user_external" ]; then if [ -n "$version_user_external" ]; then
wget_verify https://github.com/nextcloud-releases/user_external/releases/download/v$version_user_external/user_external-v$version_user_external.tar.gz $hash_user_external /tmp/user_external.tgz wget_verify "https://github.com/nextcloud-releases/user_external/releases/download/v$version_user_external/user_external-v$version_user_external.tar.gz" "$hash_user_external" /tmp/user_external.tgz
tar -xf /tmp/user_external.tgz -C /usr/local/lib/owncloud/apps/ tar -xf /tmp/user_external.tgz -C /usr/local/lib/owncloud/apps/
rm /tmp/user_external.tgz rm /tmp/user_external.tgz
fi fi
@ -105,32 +126,35 @@ InstallNextcloud() {
# Create a symlink to the config.php in STORAGE_ROOT (for upgrades we're restoring the symlink we previously # Create a symlink to the config.php in STORAGE_ROOT (for upgrades we're restoring the symlink we previously
# put in, and in new installs we're creating a symlink and will create the actual config later). # put in, and in new installs we're creating a symlink and will create the actual config later).
ln -sf $STORAGE_ROOT/owncloud/config.php /usr/local/lib/owncloud/config/config.php ln -sf "$STORAGE_ROOT/owncloud/config.php" /usr/local/lib/owncloud/config/config.php
# Make sure permissions are correct or the upgrade step won't run. # Make sure permissions are correct or the upgrade step won't run.
# $STORAGE_ROOT/owncloud may not yet exist, so use -f to suppress # $STORAGE_ROOT/owncloud may not yet exist, so use -f to suppress
# that error. # that error.
chown -f -R www-data.www-data $STORAGE_ROOT/owncloud /usr/local/lib/owncloud || /bin/true chown -f -R www-data:www-data "$STORAGE_ROOT/owncloud" /usr/local/lib/owncloud || /bin/true
# If this isn't a new installation, immediately run the upgrade script. # If this isn't a new installation, immediately run the upgrade script.
# Then check for success (0=ok and 3=no upgrade needed, both are success). # Then check for success (0=ok and 3=no upgrade needed, both are success).
if [ -e $STORAGE_ROOT/owncloud/owncloud.db ]; then if [ -e "$STORAGE_ROOT/owncloud/owncloud.db" ]; then
# ownCloud 8.1.1 broke upgrades. It may fail on the first attempt, but # ownCloud 8.1.1 broke upgrades. It may fail on the first attempt, but
# that can be OK. # that can be OK.
sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/occ upgrade sudo -u www-data php"$PHP_VER" /usr/local/lib/owncloud/occ upgrade
if [ \( $? -ne 0 \) -a \( $? -ne 3 \) ]; then E=$?
if [ $E -ne 0 ] && [ $E -ne 3 ]; then
echo "Trying ownCloud upgrade again to work around ownCloud upgrade bug..." echo "Trying ownCloud upgrade again to work around ownCloud upgrade bug..."
sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/occ upgrade sudo -u www-data php"$PHP_VER" /usr/local/lib/owncloud/occ upgrade
if [ \( $? -ne 0 \) -a \( $? -ne 3 \) ]; then exit 1; fi E=$?
sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/occ maintenance:mode --off if [ $E -ne 0 ] && [ $E -ne 3 ]; then exit 1; fi
sudo -u www-data php"$PHP_VER" /usr/local/lib/owncloud/occ maintenance:mode --off
echo "...which seemed to work." echo "...which seemed to work."
fi fi
# Add missing indices. NextCloud didn't include this in the normal upgrade because it might take some time. # Add missing indices. NextCloud didn't include this in the normal upgrade because it might take some time.
sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/occ db:add-missing-indices sudo -u www-data php"$PHP_VER" /usr/local/lib/owncloud/occ db:add-missing-indices
sudo -u www-data php"$PHP_VER" /usr/local/lib/owncloud/occ db:add-missing-primary-keys
# Run conversion to BigInt identifiers, this process may take some time on large tables. # Run conversion to BigInt identifiers, this process may take some time on large tables.
sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/occ db:convert-filecache-bigint --no-interaction sudo -u www-data php"$PHP_VER" /usr/local/lib/owncloud/occ db:convert-filecache-bigint --no-interaction
fi fi
} }
@ -142,7 +166,7 @@ InstallNextcloud() {
# If config.php exists, get version number, otherwise CURRENT_NEXTCLOUD_VER is empty. # If config.php exists, get version number, otherwise CURRENT_NEXTCLOUD_VER is empty.
if [ -f "$STORAGE_ROOT/owncloud/config.php" ]; then if [ -f "$STORAGE_ROOT/owncloud/config.php" ]; then
CURRENT_NEXTCLOUD_VER=$(php$PHP_VER -r "include(\"$STORAGE_ROOT/owncloud/config.php\"); echo(\$CONFIG['version']);") CURRENT_NEXTCLOUD_VER=$(php"$PHP_VER" -r "include(\"$STORAGE_ROOT/owncloud/config.php\"); echo(\$CONFIG['version']);")
else else
CURRENT_NEXTCLOUD_VER="" CURRENT_NEXTCLOUD_VER=""
fi fi
@ -152,7 +176,7 @@ fi
if [ ! -d /usr/local/lib/owncloud/ ] || [[ ! ${CURRENT_NEXTCLOUD_VER} =~ ^$nextcloud_ver ]]; then if [ ! -d /usr/local/lib/owncloud/ ] || [[ ! ${CURRENT_NEXTCLOUD_VER} =~ ^$nextcloud_ver ]]; then
# Stop php-fpm if running. If they are not running (which happens on a previously failed install), dont bail. # Stop php-fpm if running. If they are not running (which happens on a previously failed install), dont bail.
service php$PHP_VER-fpm stop &> /dev/null || /bin/true service php"$PHP_VER"-fpm stop &> /dev/null || /bin/true
# Backup the existing ownCloud/Nextcloud. # Backup the existing ownCloud/Nextcloud.
# Create a backup directory to store the current installation and database to # Create a backup directory to store the current installation and database to
@ -162,17 +186,23 @@ if [ ! -d /usr/local/lib/owncloud/ ] || [[ ! ${CURRENT_NEXTCLOUD_VER} =~ ^$nextc
echo "Upgrading Nextcloud --- backing up existing installation, configuration, and database to directory to $BACKUP_DIRECTORY..." echo "Upgrading Nextcloud --- backing up existing installation, configuration, and database to directory to $BACKUP_DIRECTORY..."
cp -r /usr/local/lib/owncloud "$BACKUP_DIRECTORY/owncloud-install" cp -r /usr/local/lib/owncloud "$BACKUP_DIRECTORY/owncloud-install"
fi fi
if [ -e $STORAGE_ROOT/owncloud/owncloud.db ]; then if [ -e "$STORAGE_ROOT/owncloud/owncloud.db" ]; then
cp $STORAGE_ROOT/owncloud/owncloud.db $BACKUP_DIRECTORY cp "$STORAGE_ROOT/owncloud/owncloud.db" "$BACKUP_DIRECTORY"
fi fi
if [ -e $STORAGE_ROOT/owncloud/config.php ]; then if [ -e "$STORAGE_ROOT/owncloud/config.php" ]; then
cp $STORAGE_ROOT/owncloud/config.php $BACKUP_DIRECTORY cp "$STORAGE_ROOT/owncloud/config.php" "$BACKUP_DIRECTORY"
fi fi
# If ownCloud or Nextcloud was previously installed.... # If ownCloud or Nextcloud was previously installed....
if [ ! -z ${CURRENT_NEXTCLOUD_VER} ]; then if [ -n "${CURRENT_NEXTCLOUD_VER}" ]; then
# Database migrations from ownCloud are no longer possible because ownCloud cannot be run under # Database migrations from ownCloud are no longer possible because ownCloud cannot be run under
# PHP 7. # PHP 7.
if [ -e "$STORAGE_ROOT/owncloud/config.php" ]; then
# Remove the read-onlyness of the config, which is needed for migrations, especially for v24
sed -i -e '/config_is_read_only/d' "$STORAGE_ROOT/owncloud/config.php"
fi
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^[89] ]]; then if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^[89] ]]; then
echo "Upgrades from Mail-in-a-Box prior to v0.28 (dated July 30, 2018) with Nextcloud < 13.0.6 (you have ownCloud 8 or 9) are not supported. Upgrade to Mail-in-a-Box version v0.30 first. Setup will continue, but skip the Nextcloud migration." echo "Upgrades from Mail-in-a-Box prior to v0.28 (dated July 30, 2018) with Nextcloud < 13.0.6 (you have ownCloud 8 or 9) are not supported. Upgrade to Mail-in-a-Box version v0.30 first. Setup will continue, but skip the Nextcloud migration."
return 0 return 0
@ -183,6 +213,12 @@ if [ ! -d /usr/local/lib/owncloud/ ] || [[ ! ${CURRENT_NEXTCLOUD_VER} =~ ^$nextc
echo "Upgrades from Mail-in-a-Box prior to v60 with Nextcloud 19 or earlier are not supported. Upgrade to the latest Mail-in-a-Box version supported on your machine first. Setup will continue, but skip the Nextcloud migration." echo "Upgrades from Mail-in-a-Box prior to v60 with Nextcloud 19 or earlier are not supported. Upgrade to the latest Mail-in-a-Box version supported on your machine first. Setup will continue, but skip the Nextcloud migration."
return 0 return 0
fi fi
# Hint: whenever you bump, remember this:
# - Run a server with the previous version
# - On a new if-else block, copy the versions/hashes from the previous version
# - Run sudo ./setup/start.sh on the new machine. Upon completion, test its basic functionalities.
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^20 ]]; then if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^20 ]]; then
InstallNextcloud 21.0.7 f5c7079c5b56ce1e301c6a27c0d975d608bb01c9 4.0.7 45e7cf4bfe99cd8d03625cf9e5a1bb2e90549136 3.0.4 d0284b68135777ec9ca713c307216165b294d0fe InstallNextcloud 21.0.7 f5c7079c5b56ce1e301c6a27c0d975d608bb01c9 4.0.7 45e7cf4bfe99cd8d03625cf9e5a1bb2e90549136 3.0.4 d0284b68135777ec9ca713c307216165b294d0fe
CURRENT_NEXTCLOUD_VER="21.0.7" CURRENT_NEXTCLOUD_VER="21.0.7"
@ -191,6 +227,18 @@ if [ ! -d /usr/local/lib/owncloud/ ] || [[ ! ${CURRENT_NEXTCLOUD_VER} =~ ^$nextc
InstallNextcloud 22.2.6 9d39741f051a8da42ff7df46ceef2653a1dc70d9 4.1.0 697f6b4a664e928d72414ea2731cb2c9d1dc3077 3.2.2 ce4030ab57f523f33d5396c6a81396d440756f5f 3.0.0 0df781b261f55bbde73d8c92da3f99397000972f InstallNextcloud 22.2.6 9d39741f051a8da42ff7df46ceef2653a1dc70d9 4.1.0 697f6b4a664e928d72414ea2731cb2c9d1dc3077 3.2.2 ce4030ab57f523f33d5396c6a81396d440756f5f 3.0.0 0df781b261f55bbde73d8c92da3f99397000972f
CURRENT_NEXTCLOUD_VER="22.2.6" CURRENT_NEXTCLOUD_VER="22.2.6"
fi fi
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^22 ]]; then
InstallNextcloud 23.0.12 d138641b8e7aabebe69bb3ec7c79a714d122f729 4.1.0 697f6b4a664e928d72414ea2731cb2c9d1dc3077 3.2.2 ce4030ab57f523f33d5396c6a81396d440756f5f 3.0.0 0df781b261f55bbde73d8c92da3f99397000972f
CURRENT_NEXTCLOUD_VER="23.0.12"
fi
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^23 ]]; then
InstallNextcloud 24.0.12 7aa5d61632c1ccf4ca3ff00fb6b295d318c05599 4.1.0 697f6b4a664e928d72414ea2731cb2c9d1dc3077 3.2.2 ce4030ab57f523f33d5396c6a81396d440756f5f 3.0.0 0df781b261f55bbde73d8c92da3f99397000972f
CURRENT_NEXTCLOUD_VER="24.0.12"
fi
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^24 ]]; then
InstallNextcloud 25.0.7 a5a565c916355005c7b408dd41a1e53505e1a080 5.3.0 4b0a6666374e3b55cfd2ae9b72e1d458b87d4c8c 4.4.2 21a42e15806adc9b2618760ef94f1797ef399e2f 3.2.0 a494073dcdecbbbc79a9c77f72524ac9994d2eec
CURRENT_NEXTCLOUD_VER="25.0.7"
fi
fi fi
InstallNextcloud $nextcloud_ver $nextcloud_hash $contacts_ver $contacts_hash $calendar_ver $calendar_hash $user_external_ver $user_external_hash InstallNextcloud $nextcloud_ver $nextcloud_hash $contacts_ver $contacts_hash $calendar_ver $calendar_hash $user_external_ver $user_external_hash
@ -200,13 +248,13 @@ fi
# Setup Nextcloud if the Nextcloud database does not yet exist. Running setup when # Setup Nextcloud if the Nextcloud database does not yet exist. Running setup when
# the database does exist wipes the database and user data. # the database does exist wipes the database and user data.
if [ ! -f $STORAGE_ROOT/owncloud/owncloud.db ]; then if [ ! -f "$STORAGE_ROOT/owncloud/owncloud.db" ]; then
# Create user data directory # Create user data directory
mkdir -p $STORAGE_ROOT/owncloud mkdir -p "$STORAGE_ROOT/owncloud"
# Create an initial configuration file. # Create an initial configuration file.
instanceid=oc$(echo $PRIMARY_HOSTNAME | sha1sum | fold -w 10 | head -n 1) instanceid=oc$(echo "$PRIMARY_HOSTNAME" | sha1sum | fold -w 10 | head -n 1)
cat > $STORAGE_ROOT/owncloud/config.php <<EOF; cat > "$STORAGE_ROOT/owncloud/config.php" <<EOF;
<?php <?php
\$CONFIG = array ( \$CONFIG = array (
'datadirectory' => '$STORAGE_ROOT/owncloud', 'datadirectory' => '$STORAGE_ROOT/owncloud',
@ -226,15 +274,6 @@ if [ ! -f $STORAGE_ROOT/owncloud/owncloud.db ]; then
), ),
), ),
'memcache.local' => '\OC\Memcache\APCu', 'memcache.local' => '\OC\Memcache\APCu',
'mail_smtpmode' => 'sendmail',
'mail_smtpsecure' => '',
'mail_smtpauthtype' => 'LOGIN',
'mail_smtpauth' => false,
'mail_smtphost' => '',
'mail_smtpport' => '',
'mail_smtpname' => '',
'mail_smtppassword' => '',
'mail_from_address' => 'owncloud',
); );
?> ?>
EOF EOF
@ -259,12 +298,12 @@ EOF
EOF EOF
# Set permissions # Set permissions
chown -R www-data.www-data $STORAGE_ROOT/owncloud /usr/local/lib/owncloud chown -R www-data:www-data "$STORAGE_ROOT/owncloud" /usr/local/lib/owncloud
# Execute Nextcloud's setup step, which creates the Nextcloud sqlite database. # Execute Nextcloud's setup step, which creates the Nextcloud sqlite database.
# It also wipes it if it exists. And it updates config.php with database # It also wipes it if it exists. And it updates config.php with database
# settings and deletes the autoconfig.php file. # settings and deletes the autoconfig.php file.
(cd /usr/local/lib/owncloud; sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/index.php;) (cd /usr/local/lib/owncloud || exit; sudo -u www-data php"$PHP_VER" /usr/local/lib/owncloud/index.php;)
fi fi
# Update config.php. # Update config.php.
@ -280,23 +319,20 @@ fi
# Use PHP to read the settings file, modify it, and write out the new settings array. # Use PHP to read the settings file, modify it, and write out the new settings array.
TIMEZONE=$(cat /etc/timezone) TIMEZONE=$(cat /etc/timezone)
CONFIG_TEMP=$(/bin/mktemp) CONFIG_TEMP=$(/bin/mktemp)
php$PHP_VER <<EOF > $CONFIG_TEMP && mv $CONFIG_TEMP $STORAGE_ROOT/owncloud/config.php; php"$PHP_VER" <<EOF > "$CONFIG_TEMP" && mv "$CONFIG_TEMP" "$STORAGE_ROOT/owncloud/config.php";
<?php <?php
include("$STORAGE_ROOT/owncloud/config.php"); include("$STORAGE_ROOT/owncloud/config.php");
\$CONFIG['config_is_read_only'] = true; \$CONFIG['config_is_read_only'] = false;
\$CONFIG['trusted_domains'] = array('$PRIMARY_HOSTNAME'); \$CONFIG['trusted_domains'] = array('$PRIMARY_HOSTNAME');
\$CONFIG['memcache.local'] = '\OC\Memcache\APCu'; \$CONFIG['memcache.local'] = '\OC\Memcache\APCu';
\$CONFIG['overwrite.cli.url'] = '/cloud'; \$CONFIG['overwrite.cli.url'] = 'https://${PRIMARY_HOSTNAME}/cloud';
\$CONFIG['mail_from_address'] = 'administrator'; # just the local part, matches our master administrator address
\$CONFIG['logtimezone'] = '$TIMEZONE'; \$CONFIG['logtimezone'] = '$TIMEZONE';
\$CONFIG['logdateformat'] = 'Y-m-d H:i:s'; \$CONFIG['logdateformat'] = 'Y-m-d H:i:s';
\$CONFIG['mail_domain'] = '$PRIMARY_HOSTNAME';
\$CONFIG['user_backends'] = array( \$CONFIG['user_backends'] = array(
array( array(
'class' => '\OCA\UserExternal\IMAP', 'class' => '\OCA\UserExternal\IMAP',
@ -306,36 +342,47 @@ include("$STORAGE_ROOT/owncloud/config.php");
), ),
); );
\$CONFIG['mail_domain'] = '$PRIMARY_HOSTNAME';
\$CONFIG['mail_from_address'] = 'administrator'; # just the local part, matches the required administrator alias on mail_domain/$PRIMARY_HOSTNAME
\$CONFIG['mail_smtpmode'] = 'sendmail';
\$CONFIG['mail_smtpauth'] = true; # if smtpmode is smtp
\$CONFIG['mail_smtphost'] = '127.0.0.1'; # if smtpmode is smtp
\$CONFIG['mail_smtpport'] = '587'; # if smtpmode is smtp
\$CONFIG['mail_smtpsecure'] = ''; # if smtpmode is smtp, must be empty string
\$CONFIG['mail_smtpname'] = ''; # if smtpmode is smtp, set this to a mail user
\$CONFIG['mail_smtppassword'] = ''; # if smtpmode is smtp, set this to the user's password
echo "<?php\n\\\$CONFIG = "; echo "<?php\n\\\$CONFIG = ";
var_export(\$CONFIG); var_export(\$CONFIG);
echo ";"; echo ";";
?> ?>
EOF EOF
chown www-data.www-data $STORAGE_ROOT/owncloud/config.php chown www-data:www-data "$STORAGE_ROOT/owncloud/config.php"
# Enable/disable apps. Note that this must be done after the Nextcloud setup. # Enable/disable apps. Note that this must be done after the Nextcloud setup.
# The firstrunwizard gave Josh all sorts of problems, so disabling that. # The firstrunwizard gave Josh all sorts of problems, so disabling that.
# user_external is what allows Nextcloud to use IMAP for login. The contacts # user_external is what allows Nextcloud to use IMAP for login. The contacts
# and calendar apps are the extensions we really care about here. # and calendar apps are the extensions we really care about here.
hide_output sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/console.php app:disable firstrunwizard hide_output sudo -u www-data php"$PHP_VER" /usr/local/lib/owncloud/console.php app:disable firstrunwizard
hide_output sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/console.php app:enable user_external hide_output sudo -u www-data php"$PHP_VER" /usr/local/lib/owncloud/console.php app:enable user_external
hide_output sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/console.php app:enable contacts hide_output sudo -u www-data php"$PHP_VER" /usr/local/lib/owncloud/console.php app:enable contacts
hide_output sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/console.php app:enable calendar hide_output sudo -u www-data php"$PHP_VER" /usr/local/lib/owncloud/console.php app:enable calendar
# When upgrading, run the upgrade script again now that apps are enabled. It seems like # When upgrading, run the upgrade script again now that apps are enabled. It seems like
# the first upgrade at the top won't work because apps may be disabled during upgrade? # the first upgrade at the top won't work because apps may be disabled during upgrade?
# Check for success (0=ok, 3=no upgrade needed). # Check for success (0=ok, 3=no upgrade needed).
sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/occ upgrade sudo -u www-data php"$PHP_VER" /usr/local/lib/owncloud/occ upgrade
if [ \( $? -ne 0 \) -a \( $? -ne 3 \) ]; then exit 1; fi E=$?
if [ $E -ne 0 ] && [ $E -ne 3 ]; then exit 1; fi
# Disable default apps that we don't support # Disable default apps that we don't support
sudo -u www-data \ sudo -u www-data \
php$PHP_VER /usr/local/lib/owncloud/occ app:disable photos dashboard activity \ php"$PHP_VER" /usr/local/lib/owncloud/occ app:disable photos dashboard activity \
| (grep -v "No such app enabled" || /bin/true) | (grep -v "No such app enabled" || /bin/true)
# Set PHP FPM values to support large file uploads # Set PHP FPM values to support large file uploads
# (semicolon is the comment character in this file, hashes produce deprecation warnings) # (semicolon is the comment character in this file, hashes produce deprecation warnings)
tools/editconf.py /etc/php/$PHP_VER/fpm/php.ini -c ';' \ tools/editconf.py /etc/php/"$PHP_VER"/fpm/php.ini -c ';' \
upload_max_filesize=16G \ upload_max_filesize=16G \
post_max_size=16G \ post_max_size=16G \
output_buffering=16384 \ output_buffering=16384 \
@ -344,7 +391,7 @@ tools/editconf.py /etc/php/$PHP_VER/fpm/php.ini -c ';' \
short_open_tag=On short_open_tag=On
# Set Nextcloud recommended opcache settings # Set Nextcloud recommended opcache settings
tools/editconf.py /etc/php/$PHP_VER/cli/conf.d/10-opcache.ini -c ';' \ tools/editconf.py /etc/php/"$PHP_VER"/cli/conf.d/10-opcache.ini -c ';' \
opcache.enable=1 \ opcache.enable=1 \
opcache.enable_cli=1 \ opcache.enable_cli=1 \
opcache.interned_strings_buffer=8 \ opcache.interned_strings_buffer=8 \
@ -353,20 +400,45 @@ tools/editconf.py /etc/php/$PHP_VER/cli/conf.d/10-opcache.ini -c ';' \
opcache.save_comments=1 \ opcache.save_comments=1 \
opcache.revalidate_freq=1 opcache.revalidate_freq=1
# Migrate users_external data from <0.6.0 to version 3.0.0 (see https://github.com/nextcloud/user_external). # Migrate users_external data from <0.6.0 to version 3.0.0
# (see https://github.com/nextcloud/user_external).
# This version was probably in use in Mail-in-a-Box v0.41 (February 26, 2019) and earlier. # This version was probably in use in Mail-in-a-Box v0.41 (February 26, 2019) and earlier.
# We moved to v0.6.3 in 193763f8. Ignore errors - maybe there are duplicated users with the # We moved to v0.6.3 in 193763f8. Ignore errors - maybe there are duplicated users with the
# correct backend already. # correct backend already.
sqlite3 $STORAGE_ROOT/owncloud/owncloud.db "UPDATE oc_users_external SET backend='127.0.0.1';" || /bin/true sqlite3 "$STORAGE_ROOT/owncloud/owncloud.db" "UPDATE oc_users_external SET backend='127.0.0.1';" || /bin/true
# Set up a cron job for Nextcloud. # Set up a general cron job for Nextcloud.
# Also add another job for Calendar updates, per advice in the Nextcloud docs
# https://docs.nextcloud.com/server/24/admin_manual/groupware/calendar.html#background-jobs
cat > /etc/cron.d/mailinabox-nextcloud << EOF; cat > /etc/cron.d/mailinabox-nextcloud << EOF;
#!/bin/bash #!/bin/bash
# Mail-in-a-Box # Mail-in-a-Box
*/5 * * * * root sudo -u www-data php$PHP_VER -f /usr/local/lib/owncloud/cron.php */5 * * * * www-data php$PHP_VER -f /usr/local/lib/owncloud/cron.php
*/5 * * * * www-data php$PHP_VER -f /usr/local/lib/owncloud/occ dav:send-event-reminders
EOF EOF
chmod +x /etc/cron.d/mailinabox-nextcloud chmod +x /etc/cron.d/mailinabox-nextcloud
# We also need to change the sending mode from background-job to occ.
# Or else the reminders will just be sent as soon as possible when the background jobs run.
hide_output sudo -u www-data php"$PHP_VER" -f /usr/local/lib/owncloud/occ config:app:set dav sendEventRemindersMode --value occ
# Now set the config to read-only.
# Do this only at the very bottom when no further occ commands are needed.
sed -i'' "s/'config_is_read_only'\s*=>\s*false/'config_is_read_only' => true/" "$STORAGE_ROOT/owncloud/config.php"
# Rotate the nextcloud.log file
cat > /etc/logrotate.d/nextcloud <<EOF
# Nextcloud logs
$STORAGE_ROOT/owncloud/nextcloud.log {
size 10M
create 640 www-data www-data
rotate 30
copytruncate
missingok
compress
}
EOF
# There's nothing much of interest that a user could do as an admin for Nextcloud, # There's nothing much of interest that a user could do as an admin for Nextcloud,
# and there's a lot they could mess up, so we don't make any users admins of Nextcloud. # and there's a lot they could mess up, so we don't make any users admins of Nextcloud.
# But if we wanted to, we would do this: # But if we wanted to, we would do this:
@ -377,4 +449,4 @@ chmod +x /etc/cron.d/mailinabox-nextcloud
# ``` # ```
# Enable PHP modules and restart PHP. # Enable PHP modules and restart PHP.
restart_service php$PHP_VER-fpm restart_service php"$PHP_VER"-fpm

View File

@ -1,3 +1,4 @@
#!/bin/bash
# Are we running as root? # Are we running as root?
if [[ $EUID -ne 0 ]]; then if [[ $EUID -ne 0 ]]; then
echo "This script must be run as root. Please re-run like this:" echo "This script must be run as root. Please re-run like this:"
@ -7,11 +8,14 @@ if [[ $EUID -ne 0 ]]; then
exit 1 exit 1
fi fi
# Check that we are running on Ubuntu 20.04 LTS (or 20.04.xx). # Check that we are running on Ubuntu 22.04 LTS (or 22.04.xx).
if [ "$( lsb_release --id --short )" != "Ubuntu" ] || [ "$( lsb_release --release --short )" != "22.04" ]; then # Pull in the variables defined in /etc/os-release but in a
# namespace to avoid polluting our variables.
source <(cat /etc/os-release | sed s/^/OS_RELEASE_/)
if [ "${OS_RELEASE_ID:-}" != "ubuntu" ] || [ "${OS_RELEASE_VERSION_ID:-}" != "22.04" ]; then
echo "Mail-in-a-Box only supports being installed on Ubuntu 22.04, sorry. You are running:" echo "Mail-in-a-Box only supports being installed on Ubuntu 22.04, sorry. You are running:"
echo echo
lsb_release --description --short echo "${OS_RELEASE_ID:-"Unknown linux distribution"} ${OS_RELEASE_VERSION_ID:-}"
echo echo
echo "We can't write scripts that run on every possible setup, sorry." echo "We can't write scripts that run on every possible setup, sorry."
exit 1 exit 1
@ -26,16 +30,16 @@ fi
# #
# Skip the check if we appear to be running inside of Vagrant, because that's really just for testing. # Skip the check if we appear to be running inside of Vagrant, because that's really just for testing.
TOTAL_PHYSICAL_MEM=$(head -n 1 /proc/meminfo | awk '{print $2}') TOTAL_PHYSICAL_MEM=$(head -n 1 /proc/meminfo | awk '{print $2}')
if [ $TOTAL_PHYSICAL_MEM -lt 490000 ]; then if [ "$TOTAL_PHYSICAL_MEM" -lt 490000 ]; then
if [ ! -d /vagrant ]; then if [ ! -d /vagrant ]; then
TOTAL_PHYSICAL_MEM=$(expr \( \( $TOTAL_PHYSICAL_MEM \* 1024 \) / 1000 \) / 1000) TOTAL_PHYSICAL_MEM=$(( TOTAL_PHYSICAL_MEM * 1024 / 1000 / 1000 ))
echo "Your Mail-in-a-Box needs more memory (RAM) to function properly." echo "Your Mail-in-a-Box needs more memory (RAM) to function properly."
echo "Please provision a machine with at least 512 MB, 1 GB recommended." echo "Please provision a machine with at least 512 MB, 1 GB recommended."
echo "This machine has $TOTAL_PHYSICAL_MEM MB memory." echo "This machine has $TOTAL_PHYSICAL_MEM MB memory."
exit exit
fi fi
fi fi
if [ $TOTAL_PHYSICAL_MEM -lt 750000 ]; then if [ "$TOTAL_PHYSICAL_MEM" -lt 750000 ]; then
echo "WARNING: Your Mail-in-a-Box has less than 768 MB of memory." echo "WARNING: Your Mail-in-a-Box has less than 768 MB of memory."
echo " It might run unreliably when under heavy load." echo " It might run unreliably when under heavy load."
fi fi

View File

@ -1,3 +1,4 @@
#!/bin/bash
if [ -z "${NONINTERACTIVE:-}" ]; then if [ -z "${NONINTERACTIVE:-}" ]; then
# Install 'dialog' so we can ask the user questions. The original motivation for # Install 'dialog' so we can ask the user questions. The original motivation for
# this was being able to ask the user for input even if stdin has been redirected, # this was being able to ask the user for input even if stdin has been redirected,
@ -7,7 +8,7 @@ if [ -z "${NONINTERACTIVE:-}" ]; then
# #
# Also install dependencies needed to validate the email address. # Also install dependencies needed to validate the email address.
if [ ! -f /usr/bin/dialog ] || [ ! -f /usr/bin/python3 ] || [ ! -f /usr/bin/pip3 ]; then if [ ! -f /usr/bin/dialog ] || [ ! -f /usr/bin/python3 ] || [ ! -f /usr/bin/pip3 ]; then
echo Installing packages needed for setup... echo "Installing packages needed for setup..."
apt-get -q -q update apt-get -q -q update
apt_get_quiet install dialog python3 python3-pip || exit 1 apt_get_quiet install dialog python3 python3-pip || exit 1
fi fi
@ -31,7 +32,7 @@ if [ -z "${PRIMARY_HOSTNAME:-}" ]; then
# domain the user possibly wants to use is example.com then. # domain the user possibly wants to use is example.com then.
# We strip the string "box." from the hostname to get the mail # We strip the string "box." from the hostname to get the mail
# domain. If the hostname differs, nothing happens here. # domain. If the hostname differs, nothing happens here.
DEFAULT_DOMAIN_GUESS=$(echo $(get_default_hostname) | sed -e 's/^box\.//') DEFAULT_DOMAIN_GUESS=$(get_default_hostname | sed -e 's/^box\.//')
# This is the first run. Ask the user for his email address so we can # This is the first run. Ask the user for his email address so we can
# provide the best default for the box's hostname. # provide the best default for the box's hostname.
@ -55,7 +56,7 @@ you really want.
do do
input_box "Your Email Address" \ input_box "Your Email Address" \
"That's not a valid email address.\n\nWhat email address are you setting this box up to manage?" \ "That's not a valid email address.\n\nWhat email address are you setting this box up to manage?" \
$EMAIL_ADDR \ "$EMAIL_ADDR" \
EMAIL_ADDR EMAIL_ADDR
if [ -z "$EMAIL_ADDR" ]; then if [ -z "$EMAIL_ADDR" ]; then
# user hit ESC/cancel # user hit ESC/cancel
@ -65,7 +66,7 @@ you really want.
# Take the part after the @-sign as the user's domain name, and add # Take the part after the @-sign as the user's domain name, and add
# 'box.' to the beginning to create a default hostname for this machine. # 'box.' to the beginning to create a default hostname for this machine.
DEFAULT_PRIMARY_HOSTNAME=box.$(echo $EMAIL_ADDR | sed 's/.*@//') DEFAULT_PRIMARY_HOSTNAME=box.$(echo "$EMAIL_ADDR" | sed 's/.*@//')
fi fi
input_box "Hostname" \ input_box "Hostname" \
@ -74,7 +75,7 @@ you really want.
address, so we're suggesting $DEFAULT_PRIMARY_HOSTNAME. address, so we're suggesting $DEFAULT_PRIMARY_HOSTNAME.
\n\nYou can change it, but we recommend you don't. \n\nYou can change it, but we recommend you don't.
\n\nHostname:" \ \n\nHostname:" \
$DEFAULT_PRIMARY_HOSTNAME \ "$DEFAULT_PRIMARY_HOSTNAME" \
PRIMARY_HOSTNAME PRIMARY_HOSTNAME
if [ -z "$PRIMARY_HOSTNAME" ]; then if [ -z "$PRIMARY_HOSTNAME" ]; then
@ -92,7 +93,7 @@ if [ -z "${PUBLIC_IP:-}" ]; then
# On the first run, if we got an answer from the Internet then don't # On the first run, if we got an answer from the Internet then don't
# ask the user. # ask the user.
if [[ -z "${DEFAULT_PUBLIC_IP:-}" && ! -z "$GUESSED_IP" ]]; then if [[ -z "${DEFAULT_PUBLIC_IP:-}" && -n "$GUESSED_IP" ]]; then
PUBLIC_IP=$GUESSED_IP PUBLIC_IP=$GUESSED_IP
# Otherwise on the first run at least provide a default. # Otherwise on the first run at least provide a default.
@ -109,7 +110,7 @@ if [ -z "${PUBLIC_IP:-}" ]; then
input_box "Public IP Address" \ input_box "Public IP Address" \
"Enter the public IP address of this machine, as given to you by your ISP. "Enter the public IP address of this machine, as given to you by your ISP.
\n\nPublic IP address:" \ \n\nPublic IP address:" \
${DEFAULT_PUBLIC_IP:-} \ "${DEFAULT_PUBLIC_IP:-}" \
PUBLIC_IP PUBLIC_IP
if [ -z "$PUBLIC_IP" ]; then if [ -z "$PUBLIC_IP" ]; then
@ -125,7 +126,7 @@ if [ -z "${PUBLIC_IPV6:-}" ]; then
# Ask the Internet. # Ask the Internet.
GUESSED_IP=$(get_publicip_from_web_service 6) GUESSED_IP=$(get_publicip_from_web_service 6)
MATCHED=0 MATCHED=0
if [[ -z "${DEFAULT_PUBLIC_IPV6:-}" && ! -z "$GUESSED_IP" ]]; then if [[ -z "${DEFAULT_PUBLIC_IPV6:-}" && -n "$GUESSED_IP" ]]; then
PUBLIC_IPV6=$GUESSED_IP PUBLIC_IPV6=$GUESSED_IP
elif [[ "${DEFAULT_PUBLIC_IPV6:-}" == "$GUESSED_IP" ]]; then elif [[ "${DEFAULT_PUBLIC_IPV6:-}" == "$GUESSED_IP" ]]; then
# No IPv6 entered and machine seems to have none, or what # No IPv6 entered and machine seems to have none, or what
@ -141,10 +142,10 @@ if [ -z "${PUBLIC_IPV6:-}" ]; then
"Enter the public IPv6 address of this machine, as given to you by your ISP. "Enter the public IPv6 address of this machine, as given to you by your ISP.
\n\nLeave blank if the machine does not have an IPv6 address. \n\nLeave blank if the machine does not have an IPv6 address.
\n\nPublic IPv6 address:" \ \n\nPublic IPv6 address:" \
${DEFAULT_PUBLIC_IPV6:-} \ "${DEFAULT_PUBLIC_IPV6:-}" \
PUBLIC_IPV6 PUBLIC_IPV6
if [ ! $PUBLIC_IPV6_EXITCODE ]; then if [ ! -n "$PUBLIC_IPV6_EXITCODE" ]; then
# user hit ESC/cancel # user hit ESC/cancel
exit exit
fi fi
@ -162,7 +163,7 @@ if [ -z "${PRIVATE_IPV6:-}" ]; then
fi fi
if [[ -z "$PRIVATE_IP" && -z "$PRIVATE_IPV6" ]]; then if [[ -z "$PRIVATE_IP" && -z "$PRIVATE_IPV6" ]]; then
echo echo
echo "I could not determine the IP or IPv6 address of the network inteface" echo "I could not determine the IP or IPv6 address of the network interface"
echo "for connecting to the Internet. Setup must stop." echo "for connecting to the Internet. Setup must stop."
echo echo
hostname -I hostname -I
@ -197,7 +198,7 @@ fi
echo echo
echo "Primary Hostname: $PRIMARY_HOSTNAME" echo "Primary Hostname: $PRIMARY_HOSTNAME"
echo "Public IP Address: $PUBLIC_IP" echo "Public IP Address: $PUBLIC_IP"
if [ ! -z "$PUBLIC_IPV6" ]; then if [ -n "$PUBLIC_IPV6" ]; then
echo "Public IPv6 Address: $PUBLIC_IPV6" echo "Public IPv6 Address: $PUBLIC_IPV6"
fi fi
if [ "$PRIVATE_IP" != "$PUBLIC_IP" ]; then if [ "$PRIVATE_IP" != "$PUBLIC_IP" ]; then
@ -207,6 +208,6 @@ if [ "$PRIVATE_IPV6" != "$PUBLIC_IPV6" ]; then
echo "Private IPv6 Address: $PRIVATE_IPV6" echo "Private IPv6 Address: $PRIVATE_IPV6"
fi fi
if [ -f /usr/bin/git ] && [ -d .git ]; then if [ -f /usr/bin/git ] && [ -d .git ]; then
echo "Mail-in-a-Box Version: " $(git describe) echo "Mail-in-a-Box Version: $(git describe --always)"
fi fi
echo echo

View File

@ -53,7 +53,7 @@ tools/editconf.py /etc/default/spampd \
# Spamassassin normally wraps spam as an attachment inside a fresh # Spamassassin normally wraps spam as an attachment inside a fresh
# email with a report about the message. This also protects the user # email with a report about the message. This also protects the user
# from accidentally openening a message with embedded malware. # from accidentally opening a message with embedded malware.
# #
# It's nice to see what rules caused the message to be marked as spam, # It's nice to see what rules caused the message to be marked as spam,
# but it's also annoying to get to the original message when it is an # but it's also annoying to get to the original message when it is an
@ -135,11 +135,11 @@ EOF
# the filemode in the config file. # the filemode in the config file.
tools/editconf.py /etc/spamassassin/local.cf -s \ tools/editconf.py /etc/spamassassin/local.cf -s \
bayes_path=$STORAGE_ROOT/mail/spamassassin/bayes \ bayes_path="$STORAGE_ROOT/mail/spamassassin/bayes" \
bayes_file_mode=0666 bayes_file_mode=0666
mkdir -p $STORAGE_ROOT/mail/spamassassin mkdir -p "$STORAGE_ROOT/mail/spamassassin"
chown -R spampd:spampd $STORAGE_ROOT/mail/spamassassin chown -R spampd:spampd "$STORAGE_ROOT/mail/spamassassin"
# To mark mail as spam or ham, just drag it in or out of the Spam folder. We'll # To mark mail as spam or ham, just drag it in or out of the Spam folder. We'll
# use the Dovecot antispam plugin to detect the message move operation and execute # use the Dovecot antispam plugin to detect the message move operation and execute
@ -184,8 +184,8 @@ chmod a+x /usr/local/bin/sa-learn-pipe.sh
# Create empty bayes training data (if it doesn't exist). Once the files exist, # Create empty bayes training data (if it doesn't exist). Once the files exist,
# ensure they are group-writable so that the Dovecot process has access. # ensure they are group-writable so that the Dovecot process has access.
sudo -u spampd /usr/bin/sa-learn --sync 2>/dev/null sudo -u spampd /usr/bin/sa-learn --sync 2>/dev/null
chmod -R 660 $STORAGE_ROOT/mail/spamassassin chmod -R 660 "$STORAGE_ROOT/mail/spamassassin"
chmod 770 $STORAGE_ROOT/mail/spamassassin chmod 770 "$STORAGE_ROOT/mail/spamassassin"
# Initial training? # Initial training?
# sa-learn --ham storage/mail/mailboxes/*/*/cur/ # sa-learn --ham storage/mail/mailboxes/*/*/cur/

View File

@ -26,9 +26,9 @@ source /etc/mailinabox.conf # load global vars
# Show a status line if we are going to take any action in this file. # Show a status line if we are going to take any action in this file.
if [ ! -f /usr/bin/openssl ] \ if [ ! -f /usr/bin/openssl ] \
|| [ ! -f $STORAGE_ROOT/ssl/ssl_private_key.pem ] \ || [ ! -f "$STORAGE_ROOT/ssl/ssl_private_key.pem" ] \
|| [ ! -f $STORAGE_ROOT/ssl/ssl_certificate.pem ] \ || [ ! -f "$STORAGE_ROOT/ssl/ssl_certificate.pem" ] \
|| [ ! -f $STORAGE_ROOT/ssl/dh2048.pem ]; then || [ ! -f "$STORAGE_ROOT/ssl/dh2048.pem" ]; then
echo "Creating initial SSL certificate and perfect forward secrecy Diffie-Hellman parameters..." echo "Creating initial SSL certificate and perfect forward secrecy Diffie-Hellman parameters..."
fi fi
@ -38,7 +38,7 @@ apt_install openssl
# Create a directory to store TLS-related things like "SSL" certificates. # Create a directory to store TLS-related things like "SSL" certificates.
mkdir -p $STORAGE_ROOT/ssl mkdir -p "$STORAGE_ROOT/ssl"
# Generate a new private key. # Generate a new private key.
# #
@ -60,39 +60,48 @@ mkdir -p $STORAGE_ROOT/ssl
# #
# Since we properly seed /dev/urandom in system.sh we should be fine, but I leave # Since we properly seed /dev/urandom in system.sh we should be fine, but I leave
# in the rest of the notes in case that ever changes. # in the rest of the notes in case that ever changes.
if [ ! -f $STORAGE_ROOT/ssl/ssl_private_key.pem ]; then if [ ! -f "$STORAGE_ROOT/ssl/ssl_private_key.pem" ]; then
# Set the umask so the key file is never world-readable. # Set the umask so the key file is never world-readable.
(umask 077; hide_output \ (umask 077; hide_output \
openssl genrsa -out $STORAGE_ROOT/ssl/ssl_private_key.pem 2048) openssl genrsa -out "$STORAGE_ROOT/ssl/ssl_private_key.pem" 2048)
fi fi
# Generate a self-signed SSL certificate because things like nginx, dovecot, # Generate a self-signed SSL certificate because things like nginx, dovecot,
# etc. won't even start without some certificate in place, and we need nginx # etc. won't even start without some certificate in place, and we need nginx
# so we can offer the user a control panel to install a better certificate. # so we can offer the user a control panel to install a better certificate.
if [ ! -f $STORAGE_ROOT/ssl/ssl_certificate.pem ]; then if [ ! -f "$STORAGE_ROOT/ssl/ssl_certificate.pem" ]; then
# Generate a certificate signing request. # Generate a certificate signing request.
CSR=/tmp/ssl_cert_sign_req-$$.csr CSR=/tmp/ssl_cert_sign_req-$$.csr
hide_output \ hide_output \
openssl req -new -key $STORAGE_ROOT/ssl/ssl_private_key.pem -out $CSR \ openssl req -new -key "$STORAGE_ROOT/ssl/ssl_private_key.pem" -out $CSR \
-sha256 -subj "/CN=$PRIMARY_HOSTNAME" -sha256 -subj "/CN=$PRIMARY_HOSTNAME"
# Generate the self-signed certificate. # Generate the self-signed certificate.
CERT=$STORAGE_ROOT/ssl/$PRIMARY_HOSTNAME-selfsigned-$(date --rfc-3339=date | sed s/-//g).pem CERT=$STORAGE_ROOT/ssl/$PRIMARY_HOSTNAME-selfsigned-$(date --rfc-3339=date | sed s/-//g).pem
hide_output \ hide_output \
openssl x509 -req -days 365 \ openssl x509 -req -days 365 \
-in $CSR -signkey $STORAGE_ROOT/ssl/ssl_private_key.pem -out $CERT -in $CSR -signkey "$STORAGE_ROOT/ssl/ssl_private_key.pem" -out "$CERT"
# Delete the certificate signing request because it has no other purpose. # Delete the certificate signing request because it has no other purpose.
rm -f $CSR rm -f $CSR
# Symlink the certificate into the system certificate path, so system services # Symlink the certificate into the system certificate path, so system services
# can find it. # can find it.
ln -s $CERT $STORAGE_ROOT/ssl/ssl_certificate.pem ln -s "$CERT" "$STORAGE_ROOT/ssl/ssl_certificate.pem"
fi fi
# Generate some Diffie-Hellman cipher bits. # Generate some Diffie-Hellman cipher bits.
# openssl's default bit length for this is 1024 bits, but we'll create # openssl's default bit length for this is 1024 bits, but we'll create
# 2048 bits of bits per the latest recommendations. # 2048 bits of bits per the latest recommendations.
if [ ! -f $STORAGE_ROOT/ssl/dh2048.pem ]; then if [ ! -f "$STORAGE_ROOT/ssl/dh2048.pem" ]; then
openssl dhparam -out $STORAGE_ROOT/ssl/dh2048.pem 2048 openssl dhparam -out "$STORAGE_ROOT/ssl/dh2048.pem" 2048
fi fi
# Cleanup expired SSL certificates from $STORAGE_ROOT/ssl daily
cat > /etc/cron.daily/mailinabox-ssl-cleanup << EOF;
#!/bin/bash
# Mail-in-a-Box
# Cleanup expired SSL certificates
$(pwd)/tools/ssl_cleanup
EOF
chmod +x /etc/cron.daily/mailinabox-ssl-cleanup

View File

@ -46,7 +46,7 @@ fi
# in the first dialog prompt, so we should do this before that starts. # in the first dialog prompt, so we should do this before that starts.
cat > /usr/local/bin/mailinabox << EOF; cat > /usr/local/bin/mailinabox << EOF;
#!/bin/bash #!/bin/bash
cd $(pwd) cd $PWD
source setup/start.sh source setup/start.sh
EOF EOF
chmod +x /usr/local/bin/mailinabox chmod +x /usr/local/bin/mailinabox
@ -75,17 +75,17 @@ fi
# migration (schema) number for the files stored there, assume this is a fresh # migration (schema) number for the files stored there, assume this is a fresh
# installation to that directory and write the file to contain the current # installation to that directory and write the file to contain the current
# migration number for this version of Mail-in-a-Box. # migration number for this version of Mail-in-a-Box.
if ! id -u $STORAGE_USER >/dev/null 2>&1; then if ! id -u "$STORAGE_USER" >/dev/null 2>&1; then
useradd -m $STORAGE_USER useradd -m "$STORAGE_USER"
fi fi
if [ ! -d $STORAGE_ROOT ]; then if [ ! -d "$STORAGE_ROOT" ]; then
mkdir -p $STORAGE_ROOT mkdir -p "$STORAGE_ROOT"
fi fi
f=$STORAGE_ROOT f=$STORAGE_ROOT
while [[ $f != / ]]; do chmod a+rx "$f"; f=$(dirname "$f"); done; while [[ $f != / ]]; do chmod a+rx "$f"; f=$(dirname "$f"); done;
if [ ! -f $STORAGE_ROOT/mailinabox.version ]; then if [ ! -f "$STORAGE_ROOT/mailinabox.version" ]; then
setup/migrate.py --current > $STORAGE_ROOT/mailinabox.version setup/migrate.py --current > "$STORAGE_ROOT/mailinabox.version"
chown $STORAGE_USER.$STORAGE_USER $STORAGE_ROOT/mailinabox.version chown "$STORAGE_USER:$STORAGE_USER" "$STORAGE_ROOT/mailinabox.version"
fi fi
# Save the global options in /etc/mailinabox.conf so that standalone # Save the global options in /etc/mailinabox.conf so that standalone
@ -122,7 +122,7 @@ source setup/munin.sh
# Wait for the management daemon to start... # Wait for the management daemon to start...
until nc -z -w 4 127.0.0.1 10222 until nc -z -w 4 127.0.0.1 10222
do do
echo Waiting for the Mail-in-a-Box management daemon to start... echo "Waiting for the Mail-in-a-Box management daemon to start..."
sleep 2 sleep 2
done done
@ -142,41 +142,41 @@ source setup/firstuser.sh
# We'd let certbot ask the user interactively, but when this script is # We'd let certbot ask the user interactively, but when this script is
# run in the recommended curl-pipe-to-bash method there is no TTY and # run in the recommended curl-pipe-to-bash method there is no TTY and
# certbot will fail if it tries to ask. # certbot will fail if it tries to ask.
if [ ! -d $STORAGE_ROOT/ssl/lets_encrypt/accounts/acme-v02.api.letsencrypt.org/ ]; then if [ ! -d "$STORAGE_ROOT/ssl/lets_encrypt/accounts/acme-v02.api.letsencrypt.org/" ]; then
echo echo
echo "-----------------------------------------------" echo "-----------------------------------------------"
echo "Mail-in-a-Box uses Let's Encrypt to provision free SSL/TLS certificates" echo "Mail-in-a-Box uses Let's Encrypt to provision free SSL/TLS certificates"
echo "to enable HTTPS connections to your box. We're automatically" echo "to enable HTTPS connections to your box. We're automatically"
echo "agreeing you to their subscriber agreement. See https://letsencrypt.org." echo "agreeing you to their subscriber agreement. See https://letsencrypt.org."
echo echo
certbot register --register-unsafely-without-email --agree-tos --config-dir $STORAGE_ROOT/ssl/lets_encrypt certbot register --register-unsafely-without-email --agree-tos --config-dir "$STORAGE_ROOT/ssl/lets_encrypt"
fi fi
# Done. # Done.
echo echo
echo "-----------------------------------------------" echo "-----------------------------------------------"
echo echo
echo Your Mail-in-a-Box is running. echo "Your Mail-in-a-Box is running."
echo echo
echo Please log in to the control panel for further instructions at: echo "Please log in to the control panel for further instructions at:"
echo echo
if management/status_checks.py --check-primary-hostname; then if management/status_checks.py --check-primary-hostname; then
# Show the nice URL if it appears to be resolving and has a valid certificate. # Show the nice URL if it appears to be resolving and has a valid certificate.
echo https://$PRIMARY_HOSTNAME/admin echo "https://$PRIMARY_HOSTNAME/admin"
echo echo
echo "If you have a DNS problem put the box's IP address in the URL" echo "If you have a DNS problem put the box's IP address in the URL"
echo "(https://$PUBLIC_IP/admin) but then check the TLS fingerprint:" echo "(https://$PUBLIC_IP/admin) but then check the TLS fingerprint:"
openssl x509 -in $STORAGE_ROOT/ssl/ssl_certificate.pem -noout -fingerprint -sha256\ openssl x509 -in "$STORAGE_ROOT/ssl/ssl_certificate.pem" -noout -fingerprint -sha256\
| sed "s/SHA256 Fingerprint=//" | sed "s/SHA256 Fingerprint=//i"
else else
echo https://$PUBLIC_IP/admin echo "https://$PUBLIC_IP/admin"
echo echo
echo You will be alerted that the website has an invalid certificate. Check that echo "You will be alerted that the website has an invalid certificate. Check that"
echo the certificate fingerprint matches: echo "the certificate fingerprint matches:"
echo echo
openssl x509 -in $STORAGE_ROOT/ssl/ssl_certificate.pem -noout -fingerprint -sha256\ openssl x509 -in "$STORAGE_ROOT/ssl/ssl_certificate.pem" -noout -fingerprint -sha256\
| sed "s/SHA256 Fingerprint=//" | sed "s/SHA256 Fingerprint=//i"
echo echo
echo Then you can confirm the security exception and continue. echo "Then you can confirm the security exception and continue."
echo echo
fi fi

View File

@ -1,3 +1,4 @@
#!/bin/bash
source /etc/mailinabox.conf source /etc/mailinabox.conf
source setup/functions.sh # load our functions source setup/functions.sh # load our functions
@ -11,8 +12,8 @@ source setup/functions.sh # load our functions
# #
# First set the hostname in the configuration file, then activate the setting # First set the hostname in the configuration file, then activate the setting
echo $PRIMARY_HOSTNAME > /etc/hostname echo "$PRIMARY_HOSTNAME" > /etc/hostname
hostname $PRIMARY_HOSTNAME hostname "$PRIMARY_HOSTNAME"
# ### Fix permissions # ### Fix permissions
@ -36,7 +37,7 @@ chmod g-w /etc /etc/default /usr
# - Check if the user intents to activate swap on next boot by checking fstab entries. # - Check if the user intents to activate swap on next boot by checking fstab entries.
# - Check if a swapfile already exists # - Check if a swapfile already exists
# - Check if the root file system is not btrfs, might be an incompatible version with # - Check if the root file system is not btrfs, might be an incompatible version with
# swapfiles. User should hanle it them selves. # swapfiles. User should handle it them selves.
# - Check the memory requirements # - Check the memory requirements
# - Check available diskspace # - Check available diskspace
@ -53,14 +54,14 @@ if
[ -z "$SWAP_IN_FSTAB" ] && [ -z "$SWAP_IN_FSTAB" ] &&
[ ! -e /swapfile ] && [ ! -e /swapfile ] &&
[ -z "$ROOT_IS_BTRFS" ] && [ -z "$ROOT_IS_BTRFS" ] &&
[ $TOTAL_PHYSICAL_MEM -lt 1900000 ] && [ "$TOTAL_PHYSICAL_MEM" -lt 1900000 ] &&
[ $AVAILABLE_DISK_SPACE -gt 5242880 ] [ "$AVAILABLE_DISK_SPACE" -gt 5242880 ]
then then
echo "Adding a swap file to the system..." echo "Adding a swap file to the system..."
# Allocate and activate the swap file. Allocate in 1KB chuncks # Allocate and activate the swap file. Allocate in 1KB chunks
# doing it in one go, could fail on low memory systems # doing it in one go, could fail on low memory systems
dd if=/dev/zero of=/swapfile bs=1024 count=$[1024*1024] status=none dd if=/dev/zero of=/swapfile bs=1024 count=$((1024*1024)) status=none
if [ -e /swapfile ]; then if [ -e /swapfile ]; then
chmod 600 /swapfile chmod 600 /swapfile
hide_output mkswap /swapfile hide_output mkswap /swapfile
@ -82,6 +83,15 @@ fi
# (See https://discourse.mailinabox.email/t/journalctl-reclaim-space-on-small-mailinabox/6728/11.) # (See https://discourse.mailinabox.email/t/journalctl-reclaim-space-on-small-mailinabox/6728/11.)
tools/editconf.py /etc/systemd/journald.conf MaxRetentionSec=10day tools/editconf.py /etc/systemd/journald.conf MaxRetentionSec=10day
# ### Improve server privacy
# Disable MOTD adverts to prevent revealing server information in MOTD request headers
# See https://ma.ttias.be/what-exactly-being-sent-ubuntu-motd/
if [ -f /etc/default/motd-news ]; then
tools/editconf.py /etc/default/motd-news ENABLED=0
rm -f /var/cache/motd-news
fi
# ### Add PPAs. # ### Add PPAs.
# We install some non-standard Ubuntu packages maintained by other # We install some non-standard Ubuntu packages maintained by other
@ -109,9 +119,10 @@ hide_output add-apt-repository --y ppa:ondrej/php
# Update system packages to make sure we have the latest upstream versions # Update system packages to make sure we have the latest upstream versions
# of things from Ubuntu, as well as the directory of packages provide by the # of things from Ubuntu, as well as the directory of packages provide by the
# PPAs so we can install those packages later. # PPAs so we can install those packages later.
# --allow-releaseinfo-change is added because ppa:ondrej/php changed its Label.
echo Updating system packages... echo "Updating system packages..."
hide_output apt-get update hide_output apt-get update --allow-releaseinfo-change
apt_get_quiet upgrade apt_get_quiet upgrade
# Old kernels pile up over time and take up a lot of disk space, and because of Mail-in-a-Box # Old kernels pile up over time and take up a lot of disk space, and because of Mail-in-a-Box
@ -135,7 +146,7 @@ apt_get_quiet autoremove
# * bc: allows us to do math to compute sane defaults # * bc: allows us to do math to compute sane defaults
# * openssh-client: provides ssh-keygen # * openssh-client: provides ssh-keygen
echo Installing system packages... echo "Installing system packages..."
apt_install python3 python3-dev python3-pip python3-setuptools \ apt_install python3 python3-dev python3-pip python3-setuptools \
netcat-openbsd wget curl git sudo coreutils bc file \ netcat-openbsd wget curl git sudo coreutils bc file \
pollinate openssh-client unzip \ pollinate openssh-client unzip \
@ -164,7 +175,7 @@ fi
# not likely the user will want to change this, so we only ask on first # not likely the user will want to change this, so we only ask on first
# setup. # setup.
if [ -z "${NONINTERACTIVE:-}" ]; then if [ -z "${NONINTERACTIVE:-}" ]; then
if [ ! -f /etc/timezone ] || [ ! -z ${FIRST_TIME_SETUP:-} ]; then if [ ! -f /etc/timezone ] || [ -n "${FIRST_TIME_SETUP:-}" ]; then
# If the file is missing or this is the user's first time running # If the file is missing or this is the user's first time running
# Mail-in-a-Box setup, run the interactive timezone configuration # Mail-in-a-Box setup, run the interactive timezone configuration
# tool. # tool.
@ -217,7 +228,7 @@ fi
# issue any warnings if no entropy is actually available. (http://www.2uo.de/myths-about-urandom/) # issue any warnings if no entropy is actually available. (http://www.2uo.de/myths-about-urandom/)
# Entropy might not be readily available because this machine has no user input # Entropy might not be readily available because this machine has no user input
# devices (common on servers!) and either no hard disk or not enough IO has # devices (common on servers!) and either no hard disk or not enough IO has
# ocurred yet --- although haveged tries to mitigate this. So there's a good chance # occurred yet --- although haveged tries to mitigate this. So there's a good chance
# that accessing /dev/urandom will not be drawing from any hardware entropy and under # that accessing /dev/urandom will not be drawing from any hardware entropy and under
# a perfect-storm circumstance where the other seeds are meaningless, /dev/urandom # a perfect-storm circumstance where the other seeds are meaningless, /dev/urandom
# may not be seeded at all. # may not be seeded at all.
@ -226,7 +237,7 @@ fi
# hardware entropy to get going, by drawing from /dev/random. haveged makes this # hardware entropy to get going, by drawing from /dev/random. haveged makes this
# less likely to stall for very long. # less likely to stall for very long.
echo Initializing system random number generator... echo "Initializing system random number generator..."
dd if=/dev/random of=/dev/urandom bs=1 count=32 2> /dev/null dd if=/dev/random of=/dev/urandom bs=1 count=32 2> /dev/null
# This is supposedly sufficient. But because we're not sure if hardware entropy # This is supposedly sufficient. But because we're not sure if hardware entropy
@ -269,14 +280,14 @@ if [ -z "${DISABLE_FIREWALL:-}" ]; then
# ssh might be running on an alternate port. Use sshd -T to dump sshd's #NODOC # ssh might be running on an alternate port. Use sshd -T to dump sshd's #NODOC
# settings, find the port it is supposedly running on, and open that port #NODOC # settings, find the port it is supposedly running on, and open that port #NODOC
# too. #NODOC # too. #NODOC
SSH_PORT=$(sshd -T 2>/dev/null | grep "^port " | sed "s/port //") #NODOC SSH_PORT=$(sshd -T 2>/dev/null | grep "^port " | sed "s/port //" | tr '\n' ' ') #NODOC
if [ ! -z "$SSH_PORT" ]; then if [ -n "$SSH_PORT" ]; then
if [ "$SSH_PORT" != "22" ]; then for port in $SSH_PORT; do
if [ "$port" != "22" ]; then
echo Opening alternate SSH port $SSH_PORT. #NODOC echo "Opening alternate SSH port $port." #NODOC
ufw_limit $SSH_PORT #NODOC ufw_limit "$port" #NODOC
fi fi
done
fi fi
ufw --force enable; ufw --force enable;
@ -373,3 +384,5 @@ cp -f conf/fail2ban/filter.d/* /etc/fail2ban/filter.d/
# scripts will ensure the files exist and then fail2ban is given another # scripts will ensure the files exist and then fail2ban is given another
# restart at the very end of setup. # restart at the very end of setup.
restart_service fail2ban restart_service fail2ban
systemctl enable fail2ban

View File

@ -6,9 +6,9 @@ source setup/functions.sh # load our functions
source /etc/mailinabox.conf # load global vars source /etc/mailinabox.conf # load global vars
# Some Ubuntu images start off with Apache. Remove it since we # Some Ubuntu images start off with Apache. Remove it since we
# will use nginx. Use autoremove to remove any Apache depenencies. # will use nginx. Use autoremove to remove any Apache dependencies.
if [ -f /usr/sbin/apache2 ]; then if [ -f /usr/sbin/apache2 ]; then
echo Removing apache... echo "Removing apache..."
hide_output apt-get -y purge apache2 apache2-* hide_output apt-get -y purge apache2 apache2-*
hide_output apt-get -y --purge autoremove hide_output apt-get -y --purge autoremove
fi fi
@ -19,7 +19,7 @@ fi
echo "Installing Nginx (web server)..." echo "Installing Nginx (web server)..."
apt_install nginx php${PHP_VER}-cli php${PHP_VER}-fpm idn2 apt_install nginx php"${PHP_VER}"-cli php"${PHP_VER}"-fpm idn2
rm -f /etc/nginx/sites-enabled/default rm -f /etc/nginx/sites-enabled/default
@ -46,15 +46,15 @@ tools/editconf.py /etc/nginx/nginx.conf -s \
ssl_protocols="TLSv1.2 TLSv1.3;" ssl_protocols="TLSv1.2 TLSv1.3;"
# Tell PHP not to expose its version number in the X-Powered-By header. # Tell PHP not to expose its version number in the X-Powered-By header.
tools/editconf.py /etc/php/$PHP_VER/fpm/php.ini -c ';' \ tools/editconf.py /etc/php/"$PHP_VER"/fpm/php.ini -c ';' \
expose_php=Off expose_php=Off
# Set PHPs default charset to UTF-8, since we use it. See #367. # Set PHPs default charset to UTF-8, since we use it. See #367.
tools/editconf.py /etc/php/$PHP_VER/fpm/php.ini -c ';' \ tools/editconf.py /etc/php/"$PHP_VER"/fpm/php.ini -c ';' \
default_charset="UTF-8" default_charset="UTF-8"
# Configure the path environment for php-fpm # Configure the path environment for php-fpm
tools/editconf.py /etc/php/$PHP_VER/fpm/pool.d/www.conf -c ';' \ tools/editconf.py /etc/php/"$PHP_VER"/fpm/pool.d/www.conf -c ';' \
env[PATH]=/usr/local/bin:/usr/bin:/bin \ env[PATH]=/usr/local/bin:/usr/bin:/bin \
# Configure php-fpm based on the amount of memory the machine has # Configure php-fpm based on the amount of memory the machine has
@ -62,32 +62,32 @@ tools/editconf.py /etc/php/$PHP_VER/fpm/pool.d/www.conf -c ';' \
# Some synchronisation issues can occur when many people access the site at once. # Some synchronisation issues can occur when many people access the site at once.
# The pm=ondemand setting is used for memory constrained machines < 2GB, this is copied over from PR: 1216 # The pm=ondemand setting is used for memory constrained machines < 2GB, this is copied over from PR: 1216
TOTAL_PHYSICAL_MEM=$(head -n 1 /proc/meminfo | awk '{print $2}' || /bin/true) TOTAL_PHYSICAL_MEM=$(head -n 1 /proc/meminfo | awk '{print $2}' || /bin/true)
if [ $TOTAL_PHYSICAL_MEM -lt 1000000 ] if [ "$TOTAL_PHYSICAL_MEM" -lt 1000000 ]
then then
tools/editconf.py /etc/php/$PHP_VER/fpm/pool.d/www.conf -c ';' \ tools/editconf.py /etc/php/"$PHP_VER"/fpm/pool.d/www.conf -c ';' \
pm=ondemand \ pm=ondemand \
pm.max_children=8 \ pm.max_children=8 \
pm.start_servers=2 \ pm.start_servers=2 \
pm.min_spare_servers=1 \ pm.min_spare_servers=1 \
pm.max_spare_servers=3 pm.max_spare_servers=3
elif [ $TOTAL_PHYSICAL_MEM -lt 2000000 ] elif [ "$TOTAL_PHYSICAL_MEM" -lt 2000000 ]
then then
tools/editconf.py /etc/php/$PHP_VER/fpm/pool.d/www.conf -c ';' \ tools/editconf.py /etc/php/"$PHP_VER"/fpm/pool.d/www.conf -c ';' \
pm=ondemand \ pm=ondemand \
pm.max_children=16 \ pm.max_children=16 \
pm.start_servers=4 \ pm.start_servers=4 \
pm.min_spare_servers=1 \ pm.min_spare_servers=1 \
pm.max_spare_servers=6 pm.max_spare_servers=6
elif [ $TOTAL_PHYSICAL_MEM -lt 3000000 ] elif [ "$TOTAL_PHYSICAL_MEM" -lt 3000000 ]
then then
tools/editconf.py /etc/php/$PHP_VER/fpm/pool.d/www.conf -c ';' \ tools/editconf.py /etc/php/"$PHP_VER"/fpm/pool.d/www.conf -c ';' \
pm=dynamic \ pm=dynamic \
pm.max_children=60 \ pm.max_children=60 \
pm.start_servers=6 \ pm.start_servers=6 \
pm.min_spare_servers=3 \ pm.min_spare_servers=3 \
pm.max_spare_servers=9 pm.max_spare_servers=9
else else
tools/editconf.py /etc/php/$PHP_VER/fpm/pool.d/www.conf -c ';' \ tools/editconf.py /etc/php/"$PHP_VER"/fpm/pool.d/www.conf -c ';' \
pm=dynamic \ pm=dynamic \
pm.max_children=120 \ pm.max_children=120 \
pm.start_servers=12 \ pm.start_servers=12 \
@ -138,16 +138,16 @@ cat conf/mta-sts.txt \
chmod a+r /var/lib/mailinabox/mta-sts.txt chmod a+r /var/lib/mailinabox/mta-sts.txt
# make a default homepage # make a default homepage
if [ -d $STORAGE_ROOT/www/static ]; then mv $STORAGE_ROOT/www/static $STORAGE_ROOT/www/default; fi # migration #NODOC if [ -d "$STORAGE_ROOT/www/static" ]; then mv "$STORAGE_ROOT/www/static" "$STORAGE_ROOT/www/default"; fi # migration #NODOC
mkdir -p $STORAGE_ROOT/www/default mkdir -p "$STORAGE_ROOT/www/default"
if [ ! -f $STORAGE_ROOT/www/default/index.html ]; then if [ ! -f "$STORAGE_ROOT/www/default/index.html" ]; then
cp conf/www_default.html $STORAGE_ROOT/www/default/index.html cp conf/www_default.html "$STORAGE_ROOT/www/default/index.html"
fi fi
chown -R $STORAGE_USER $STORAGE_ROOT/www chown -R "$STORAGE_USER" "$STORAGE_ROOT/www"
# Start services. # Start services.
restart_service nginx restart_service nginx
restart_service php$PHP_VER-fpm restart_service php"$PHP_VER"-fpm
# Open ports. # Open ports.
ufw_allow http ufw_allow http

55
setup/webmail.sh Executable file → Normal file
View File

@ -22,8 +22,9 @@ source /etc/mailinabox.conf # load global vars
echo "Installing Roundcube (webmail)..." echo "Installing Roundcube (webmail)..."
apt_install \ apt_install \
dbconfig-common \ dbconfig-common \
php${PHP_VER}-cli php${PHP_VER}-sqlite3 php${PHP_VER}-intl php${PHP_VER}-common php${PHP_VER}-curl php${PHP_VER}-imap \ php"${PHP_VER}"-cli php"${PHP_VER}"-sqlite3 php"${PHP_VER}"-intl php"${PHP_VER}"-common php"${PHP_VER}"-curl php"${PHP_VER}"-imap \
php${PHP_VER}-gd php${PHP_VER}-pspell php${PHP_VER}-mbstring libjs-jquery libjs-jquery-mousewheel libmagic1 php"${PHP_VER}"-gd php"${PHP_VER}"-pspell php"${PHP_VER}"-mbstring php"${PHP_VER}"-xml libjs-jquery libjs-jquery-mousewheel libmagic1 \
sqlite3
# Install Roundcube from source if it is not already present or if it is out of date. # Install Roundcube from source if it is not already present or if it is out of date.
# Combine the Roundcube version number with the commit hash of plugins to track # Combine the Roundcube version number with the commit hash of plugins to track
@ -35,9 +36,9 @@ apt_install \
# https://github.com/mstilkerich/rcmcarddav/releases # https://github.com/mstilkerich/rcmcarddav/releases
# The easiest way to get the package hashes is to run this script and get the hash from # The easiest way to get the package hashes is to run this script and get the hash from
# the error message. # the error message.
VERSION=1.6.0 VERSION=1.6.11
HASH=fd84b4fac74419bb73e7a3bcae1978d5589c52de HASH=d72da06b5f65142dab8b574f7676e0220541a3d4
PERSISTENT_LOGIN_VERSION=bde7b6840c7d91de627ea14e81cf4133cbb3c07a # version 5.2 PERSISTENT_LOGIN_VERSION=bde7b6840c7d91de627ea14e81cf4133cbb3c07a # version 5.3
HTML5_NOTIFIER_VERSION=68d9ca194212e15b3c7225eb6085dbcf02fd13d7 # version 0.6.4+ HTML5_NOTIFIER_VERSION=68d9ca194212e15b3c7225eb6085dbcf02fd13d7 # version 0.6.4+
CARDDAV_VERSION=4.4.3 CARDDAV_VERSION=4.4.3
CARDDAV_HASH=74f8ba7aee33e78beb9de07f7f44b81f6071b644 CARDDAV_HASH=74f8ba7aee33e78beb9de07f7f44b81f6071b644
@ -144,6 +145,7 @@ cat > $RCM_CONFIG <<EOF;
\$config['session_path'] = '/mail/'; \$config['session_path'] = '/mail/';
/* prevent CSRF, requires php 7.3+ */ /* prevent CSRF, requires php 7.3+ */
\$config['session_samesite'] = 'Strict'; \$config['session_samesite'] = 'Strict';
\$config['quota_zero_as_unlimited'] = true;
?> ?>
EOF EOF
@ -169,8 +171,8 @@ cat > ${RCM_PLUGIN_DIR}/carddav/config.inc.php <<EOF;
EOF EOF
# Create writable directories. # Create writable directories.
mkdir -p /var/log/roundcubemail /var/tmp/roundcubemail $STORAGE_ROOT/mail/roundcube mkdir -p /var/log/roundcubemail /var/tmp/roundcubemail "$STORAGE_ROOT/mail/roundcube"
chown -R www-data.www-data /var/log/roundcubemail /var/tmp/roundcubemail $STORAGE_ROOT/mail/roundcube chown -R www-data:www-data /var/log/roundcubemail /var/tmp/roundcubemail "$STORAGE_ROOT/mail/roundcube"
# Ensure the log file monitored by fail2ban exists, or else fail2ban can't start. # Ensure the log file monitored by fail2ban exists, or else fail2ban can't start.
sudo -u www-data touch /var/log/roundcubemail/errors.log sudo -u www-data touch /var/log/roundcubemail/errors.log
@ -184,31 +186,40 @@ cp ${RCM_PLUGIN_DIR}/password/config.inc.php.dist \
tools/editconf.py ${RCM_PLUGIN_DIR}/password/config.inc.php \ tools/editconf.py ${RCM_PLUGIN_DIR}/password/config.inc.php \
"\$config['password_minimum_length']=8;" \ "\$config['password_minimum_length']=8;" \
"\$config['password_db_dsn']='sqlite:///$STORAGE_ROOT/mail/users.sqlite';" \ "\$config['password_db_dsn']='sqlite:///$STORAGE_ROOT/mail/users.sqlite';" \
"\$config['password_query']='UPDATE users SET password=%D WHERE email=%u';" \ "\$config['password_query']='UPDATE users SET password=%P WHERE email=%u';" \
"\$config['password_dovecotpw']='/usr/bin/doveadm pw';" \ "\$config['password_algorithm']='sha512-crypt';" \
"\$config['password_dovecotpw_method']='SHA512-CRYPT';" \ "\$config['password_algorithm_prefix']='{SHA512-CRYPT}';"
"\$config['password_dovecotpw_with_method']=true;"
# so PHP can use doveadm, for the password changing plugin # so PHP can use doveadm, for the password changing plugin
usermod -a -G dovecot www-data usermod -a -G dovecot www-data
# set permissions so that PHP can use users.sqlite # set permissions so that PHP can use users.sqlite
# could use dovecot instead of www-data, but not sure it matters # could use dovecot instead of www-data, but not sure it matters
chown root.www-data $STORAGE_ROOT/mail chown root:www-data "$STORAGE_ROOT/mail"
chmod 775 $STORAGE_ROOT/mail chmod 775 "$STORAGE_ROOT/mail"
chown root.www-data $STORAGE_ROOT/mail/users.sqlite chown root:www-data "$STORAGE_ROOT/mail/users.sqlite"
chmod 664 $STORAGE_ROOT/mail/users.sqlite chmod 664 "$STORAGE_ROOT/mail/users.sqlite"
# Fix Carddav permissions: # Fix Carddav permissions:
chown -f -R root.www-data ${RCM_PLUGIN_DIR}/carddav chown -f -R root:www-data ${RCM_PLUGIN_DIR}/carddav
# root.www-data need all permissions, others only read # root:www-data need all permissions, others only read
chmod -R 774 ${RCM_PLUGIN_DIR}/carddav chmod -R 774 ${RCM_PLUGIN_DIR}/carddav
# Run Roundcube database migration script (database is created if it does not exist) # Run Roundcube database migration script (database is created if it does not exist)
php$PHP_VER ${RCM_DIR}/bin/updatedb.sh --dir ${RCM_DIR}/SQL --package roundcube php"$PHP_VER" ${RCM_DIR}/bin/updatedb.sh --dir ${RCM_DIR}/SQL --package roundcube
chown www-data:www-data $STORAGE_ROOT/mail/roundcube/roundcube.sqlite chown www-data:www-data "$STORAGE_ROOT/mail/roundcube/roundcube.sqlite"
chmod 664 $STORAGE_ROOT/mail/roundcube/roundcube.sqlite chmod 664 "$STORAGE_ROOT/mail/roundcube/roundcube.sqlite"
# Patch the Roundcube code to eliminate an issue that causes postfix to reject our sqlite
# user database (see https://github.com/mail-in-a-box/mailinabox/issues/2185)
sed -i.miabold 's/^[^#]\+.\+PRAGMA journal_mode = WAL.\+$/#&/' \
/usr/local/lib/roundcubemail/program/lib/Roundcube/db/sqlite.php
# Because Roundcube wants to set the PRAGMA we just deleted from the source, we apply it here
# to the roundcube database (see https://github.com/roundcube/roundcubemail/issues/8035)
# Database should exist, created by migration script
hide_output sqlite3 "$STORAGE_ROOT/mail/roundcube/roundcube.sqlite" 'PRAGMA journal_mode=WAL;'
# Enable PHP modules. # Enable PHP modules.
phpenmod -v $PHP_VER imap phpenmod -v "$PHP_VER" imap
restart_service php$PHP_VER-fpm restart_service php"$PHP_VER"-fpm

View File

@ -17,13 +17,13 @@ source /etc/mailinabox.conf # load global vars
echo "Installing Z-Push (Exchange/ActiveSync server)..." echo "Installing Z-Push (Exchange/ActiveSync server)..."
apt_install \ apt_install \
php${PHP_VER}-soap php${PHP_VER}-imap libawl-php php$PHP_VER-xml php"${PHP_VER}"-soap php"${PHP_VER}"-imap libawl-php php"$PHP_VER"-xml php"${PHP_VER}"-intl
phpenmod -v $PHP_VER imap phpenmod -v "$PHP_VER" imap
# Copy Z-Push into place. # Copy Z-Push into place.
VERSION=2.6.2 VERSION=2.7.5
TARGETHASH=f0e8091a8030e5b851f5ba1f9f0e1a05b8762d80 TARGETHASH=f0b0b06e255f3496173ab9d28a4f2d985184720e
needs_update=0 #NODOC needs_update=0 #NODOC
if [ ! -f /usr/local/lib/z-push/version ]; then if [ ! -f /usr/local/lib/z-push/version ]; then
needs_update=1 #NODOC needs_update=1 #NODOC
@ -41,7 +41,15 @@ if [ $needs_update == 1 ]; then
mv /tmp/z-push/*/src /usr/local/lib/z-push mv /tmp/z-push/*/src /usr/local/lib/z-push
rm -rf /tmp/z-push.zip /tmp/z-push rm -rf /tmp/z-push.zip /tmp/z-push
# Create admin and top scripts with PHP_VER
rm -f /usr/sbin/z-push-{admin,top} rm -f /usr/sbin/z-push-{admin,top}
echo '#!/bin/bash' > /usr/sbin/z-push-admin
echo php"$PHP_VER" /usr/local/lib/z-push/z-push-admin.php '"$@"' >> /usr/sbin/z-push-admin
chmod 755 /usr/sbin/z-push-admin
echo '#!/bin/bash' > /usr/sbin/z-push-top
echo php"$PHP_VER" /usr/local/lib/z-push/z-push-top.php '"$@"' >> /usr/sbin/z-push-top
chmod 755 /usr/sbin/z-push-top
echo $VERSION > /usr/local/lib/z-push/version echo $VERSION > /usr/local/lib/z-push/version
fi fi
@ -49,8 +57,6 @@ fi
sed -i "s^define('TIMEZONE', .*^define('TIMEZONE', '$(cat /etc/timezone)');^" /usr/local/lib/z-push/config.php sed -i "s^define('TIMEZONE', .*^define('TIMEZONE', '$(cat /etc/timezone)');^" /usr/local/lib/z-push/config.php
sed -i "s/define('BACKEND_PROVIDER', .*/define('BACKEND_PROVIDER', 'BackendCombined');/" /usr/local/lib/z-push/config.php sed -i "s/define('BACKEND_PROVIDER', .*/define('BACKEND_PROVIDER', 'BackendCombined');/" /usr/local/lib/z-push/config.php
sed -i "s/define('USE_FULLEMAIL_FOR_LOGIN', .*/define('USE_FULLEMAIL_FOR_LOGIN', true);/" /usr/local/lib/z-push/config.php sed -i "s/define('USE_FULLEMAIL_FOR_LOGIN', .*/define('USE_FULLEMAIL_FOR_LOGIN', true);/" /usr/local/lib/z-push/config.php
sed -i "s/define('LOG_MEMORY_PROFILER', .*/define('LOG_MEMORY_PROFILER', false);/" /usr/local/lib/z-push/config.php
sed -i "s/define('BUG68532FIXED', .*/define('BUG68532FIXED', false);/" /usr/local/lib/z-push/config.php
sed -i "s/define('LOGLEVEL', .*/define('LOGLEVEL', LOGLEVEL_ERROR);/" /usr/local/lib/z-push/config.php sed -i "s/define('LOGLEVEL', .*/define('LOGLEVEL', LOGLEVEL_ERROR);/" /usr/local/lib/z-push/config.php
# Configure BACKEND # Configure BACKEND
@ -100,8 +106,10 @@ EOF
# Restart service. # Restart service.
restart_service php$PHP_VER-fpm restart_service php"$PHP_VER"-fpm
# Fix states after upgrade # Fix states after upgrade
hide_output php$PHP_VER /usr/local/lib/z-push/z-push-admin.php -a fixstates if [ $needs_update == 1 ]; then
hide_output php"$PHP_VER" /usr/local/lib/z-push/z-push-admin.php -a fixstates
fi

View File

@ -6,12 +6,12 @@
# try to log in to. # try to log in to.
###################################################################### ######################################################################
import sys, os, time, functools import sys, os, time
# parse command line # parse command line
if len(sys.argv) != 4: if len(sys.argv) != 4:
print("Usage: tests/fail2ban.py \"ssh user@hostname\" hostname owncloud_user") print('Usage: tests/fail2ban.py "ssh user@hostname" hostname owncloud_user')
sys.exit(1) sys.exit(1)
ssh_command, hostname, owncloud_user = sys.argv[1:4] ssh_command, hostname, owncloud_user = sys.argv[1:4]
@ -24,7 +24,6 @@ socket.setdefaulttimeout(10)
class IsBlocked(Exception): class IsBlocked(Exception):
"""Tests raise this exception when it appears that a fail2ban """Tests raise this exception when it appears that a fail2ban
jail is in effect, i.e. on a connection refused error.""" jail is in effect, i.e. on a connection refused error."""
pass
def smtp_test(): def smtp_test():
import smtplib import smtplib
@ -33,13 +32,14 @@ def smtp_test():
server = smtplib.SMTP(hostname, 587) server = smtplib.SMTP(hostname, 587)
except ConnectionRefusedError: except ConnectionRefusedError:
# looks like fail2ban worked # looks like fail2ban worked
raise IsBlocked() raise IsBlocked
server.starttls() server.starttls()
server.ehlo_or_helo_if_needed() server.ehlo_or_helo_if_needed()
try: try:
server.login("fakeuser", "fakepassword") server.login("fakeuser", "fakepassword")
raise Exception("authentication didn't fail") msg = "authentication didn't fail"
raise Exception(msg)
except smtplib.SMTPAuthenticationError: except smtplib.SMTPAuthenticationError:
# athentication should fail # athentication should fail
pass pass
@ -57,11 +57,12 @@ def imap_test():
M = imaplib.IMAP4_SSL(hostname) M = imaplib.IMAP4_SSL(hostname)
except ConnectionRefusedError: except ConnectionRefusedError:
# looks like fail2ban worked # looks like fail2ban worked
raise IsBlocked() raise IsBlocked
try: try:
M.login("fakeuser", "fakepassword") M.login("fakeuser", "fakepassword")
raise Exception("authentication didn't fail") msg = "authentication didn't fail"
raise Exception(msg)
except imaplib.IMAP4.error: except imaplib.IMAP4.error:
# authentication should fail # authentication should fail
pass pass
@ -75,17 +76,18 @@ def pop_test():
M = poplib.POP3_SSL(hostname) M = poplib.POP3_SSL(hostname)
except ConnectionRefusedError: except ConnectionRefusedError:
# looks like fail2ban worked # looks like fail2ban worked
raise IsBlocked() raise IsBlocked
try: try:
M.user('fakeuser') M.user('fakeuser')
try: try:
M.pass_('fakepassword') M.pass_('fakepassword')
except poplib.error_proto as e: except poplib.error_proto:
# Authentication should fail. # Authentication should fail.
M = None # don't .quit() M = None # don't .quit()
return return
M.list() M.list()
raise Exception("authentication didn't fail") msg = "authentication didn't fail"
raise Exception(msg)
finally: finally:
if M: if M:
M.quit() M.quit()
@ -99,11 +101,12 @@ def managesieve_test():
M = imaplib.IMAP4(hostname, 4190) M = imaplib.IMAP4(hostname, 4190)
except ConnectionRefusedError: except ConnectionRefusedError:
# looks like fail2ban worked # looks like fail2ban worked
raise IsBlocked() raise IsBlocked
try: try:
M.login("fakeuser", "fakepassword") M.login("fakeuser", "fakepassword")
raise Exception("authentication didn't fail") msg = "authentication didn't fail"
raise Exception(msg)
except imaplib.IMAP4.error: except imaplib.IMAP4.error:
# authentication should fail # authentication should fail
pass pass
@ -129,17 +132,18 @@ def http_test(url, expected_status, postdata=None, qsargs=None, auth=None):
headers={'User-Agent': 'Mail-in-a-Box fail2ban tester'}, headers={'User-Agent': 'Mail-in-a-Box fail2ban tester'},
timeout=8, timeout=8,
verify=False) # don't bother with HTTPS validation, it may not be configured yet verify=False) # don't bother with HTTPS validation, it may not be configured yet
except requests.exceptions.ConnectTimeout as e: except requests.exceptions.ConnectTimeout:
raise IsBlocked() raise IsBlocked
except requests.exceptions.ConnectionError as e: except requests.exceptions.ConnectionError as e:
if "Connection refused" in str(e): if "Connection refused" in str(e):
raise IsBlocked() raise IsBlocked
raise # some other unexpected condition raise # some other unexpected condition
# return response status code # return response status code
if r.status_code != expected_status: if r.status_code != expected_status:
r.raise_for_status() # anything but 200 r.raise_for_status() # anything but 200
raise IOError("Got unexpected status code %s." % r.status_code) msg = f"Got unexpected status code {r.status_code}."
raise OSError(msg)
# define how to run a test # define how to run a test
@ -149,7 +153,7 @@ def restart_fail2ban_service(final=False):
if not final: if not final:
# Stop recidive jails during testing. # Stop recidive jails during testing.
command += " && sudo fail2ban-client stop recidive" command += " && sudo fail2ban-client stop recidive"
os.system("%s \"%s\"" % (ssh_command, command)) os.system(f'{ssh_command} "{command}"')
def testfunc_runner(i, testfunc, *args): def testfunc_runner(i, testfunc, *args):
print(i+1, end=" ", flush=True) print(i+1, end=" ", flush=True)
@ -163,7 +167,6 @@ def run_test(testfunc, args, count, within_seconds, parallel):
# run testfunc sequentially and still get to count requests within # run testfunc sequentially and still get to count requests within
# the required time. So we split the requests across threads. # the required time. So we split the requests across threads.
import requests.exceptions
from multiprocessing import Pool from multiprocessing import Pool
restart_fail2ban_service() restart_fail2ban_service()
@ -179,7 +182,7 @@ def run_test(testfunc, args, count, within_seconds, parallel):
# Distribute the requests across the pool. # Distribute the requests across the pool.
asyncresults = [] asyncresults = []
for i in range(count): for i in range(count):
ar = p.apply_async(testfunc_runner, [i, testfunc] + list(args)) ar = p.apply_async(testfunc_runner, [i, testfunc, *list(args)])
asyncresults.append(ar) asyncresults.append(ar)
# Wait for all runs to finish. # Wait for all runs to finish.

View File

@ -7,7 +7,7 @@
# where ipaddr is the IP address of your Mail-in-a-Box # where ipaddr is the IP address of your Mail-in-a-Box
# and hostname is the domain name to check the DNS for. # and hostname is the domain name to check the DNS for.
import sys, re, difflib import sys, re
import dns.reversename, dns.resolver import dns.reversename, dns.resolver
if len(sys.argv) < 3: if len(sys.argv) < 3:
@ -27,10 +27,10 @@ def test(server, description):
("ns2." + primary_hostname, "A", ipaddr), ("ns2." + primary_hostname, "A", ipaddr),
("www." + hostname, "A", ipaddr), ("www." + hostname, "A", ipaddr),
(hostname, "MX", "10 " + primary_hostname + "."), (hostname, "MX", "10 " + primary_hostname + "."),
(hostname, "TXT", "\"v=spf1 mx -all\""), (hostname, "TXT", '"v=spf1 mx -all"'),
("mail._domainkey." + hostname, "TXT", "\"v=DKIM1; k=rsa; s=email; \" \"p=__KEY__\""), ("mail._domainkey." + hostname, "TXT", '"v=DKIM1; k=rsa; s=email; " "p=__KEY__"'),
#("_adsp._domainkey." + hostname, "TXT", "\"dkim=all\""), #("_adsp._domainkey." + hostname, "TXT", "\"dkim=all\""),
("_dmarc." + hostname, "TXT", "\"v=DMARC1; p=quarantine;\""), ("_dmarc." + hostname, "TXT", '"v=DMARC1; p=quarantine;"'),
] ]
return test2(tests, server, description) return test2(tests, server, description)
@ -51,7 +51,7 @@ def test2(tests, server, description):
response = dns.resolver.resolve(qname, rtype) response = dns.resolver.resolve(qname, rtype)
except dns.resolver.NoNameservers: except dns.resolver.NoNameservers:
# host did not have an answer for this query # host did not have an answer for this query
print("Could not connect to %s for DNS query." % server) print(f"Could not connect to {server} for DNS query.")
sys.exit(1) sys.exit(1)
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
# host did not have an answer for this query; not sure what the # host did not have an answer for this query; not sure what the
@ -59,14 +59,14 @@ def test2(tests, server, description):
response = ["[no value]"] response = ["[no value]"]
response = ";".join(str(r) for r in response) response = ";".join(str(r) for r in response)
response = re.sub(r"(\"p=).*(\")", r"\1__KEY__\2", response) # normalize DKIM key response = re.sub(r"(\"p=).*(\")", r"\1__KEY__\2", response) # normalize DKIM key
response = response.replace("\"\" ", "") # normalize TXT records (DNSSEC signing inserts empty text string components) response = response.replace('"" ', "") # normalize TXT records (DNSSEC signing inserts empty text string components)
# is it right? # is it right?
if response == expected_answer: if response == expected_answer:
#print(server, ":", qname, rtype, "?", response) #print(server, ":", qname, rtype, "?", response)
continue continue
# show prolem # show problem
if first: if first:
print("Incorrect DNS Response from", description) print("Incorrect DNS Response from", description)
print() print()
@ -79,7 +79,7 @@ def test2(tests, server, description):
# Test the response from the machine itself. # Test the response from the machine itself.
if not test(ipaddr, "Mail-in-a-Box"): if not test(ipaddr, "Mail-in-a-Box"):
print () print ()
print ("Please run the Mail-in-a-Box setup script on %s again." % hostname) print (f"Please run the Mail-in-a-Box setup script on {hostname} again.")
sys.exit(1) sys.exit(1)
else: else:
print ("The Mail-in-a-Box provided correct DNS answers.") print ("The Mail-in-a-Box provided correct DNS answers.")
@ -89,7 +89,7 @@ else:
# to see if the machine is hooked up to recursive DNS properly. # to see if the machine is hooked up to recursive DNS properly.
if not test("8.8.8.8", "Google Public DNS"): if not test("8.8.8.8", "Google Public DNS"):
print () print ()
print ("Check that the nameserver settings for %s are correct at your domain registrar. It may take a few hours for Google Public DNS to update after changes on your Mail-in-a-Box." % hostname) print (f"Check that the nameserver settings for {hostname} are correct at your domain registrar. It may take a few hours for Google Public DNS to update after changes on your Mail-in-a-Box.")
sys.exit(1) sys.exit(1)
else: else:
print ("Your domain registrar or DNS host appears to be configured correctly as well. Public DNS provides the same answers.") print ("Your domain registrar or DNS host appears to be configured correctly as well. Public DNS provides the same answers.")
@ -98,7 +98,7 @@ else:
# And if that's OK, also check reverse DNS (the PTR record). # And if that's OK, also check reverse DNS (the PTR record).
if not test_ptr("8.8.8.8", "Google Public DNS (Reverse DNS)"): if not test_ptr("8.8.8.8", "Google Public DNS (Reverse DNS)"):
print () print ()
print ("The reverse DNS for %s is not correct. Consult your ISP for how to set the reverse DNS (also called the PTR record) for %s to %s." % (hostname, hostname, ipaddr)) print (f"The reverse DNS for {hostname} is not correct. Consult your ISP for how to set the reverse DNS (also called the PTR record) for {hostname} to {ipaddr}.")
sys.exit(1) sys.exit(1)
else: else:
print ("And the reverse DNS for the domain is correct.") print ("And the reverse DNS for the domain is correct.")

View File

@ -30,15 +30,11 @@ print("IMAP login is OK.")
# Attempt to send a mail to ourself. # Attempt to send a mail to ourself.
mailsubject = "Mail-in-a-Box Automated Test Message " + uuid.uuid4().hex mailsubject = "Mail-in-a-Box Automated Test Message " + uuid.uuid4().hex
emailto = emailaddress emailto = emailaddress
msg = """From: {emailaddress} msg = f"""From: {emailaddress}
To: {emailto} To: {emailto}
Subject: {subject} Subject: {mailsubject}
This is a test message. It should be automatically deleted by the test script.""".format( This is a test message. It should be automatically deleted by the test script."""
emailaddress=emailaddress,
emailto=emailto,
subject=mailsubject,
)
# Connect to the server on the SMTP submission TLS port. # Connect to the server on the SMTP submission TLS port.
server = smtplib.SMTP_SSL(host) server = smtplib.SMTP_SSL(host)
@ -50,7 +46,7 @@ reverse_ip = dns.reversename.from_address(ipaddr) # e.g. "1.0.0.127.in-addr.arpa
try: try:
reverse_dns = dns.resolver.resolve(reverse_ip, 'PTR')[0].target.to_text(omit_final_dot=True) # => hostname reverse_dns = dns.resolver.resolve(reverse_ip, 'PTR')[0].target.to_text(omit_final_dot=True) # => hostname
except dns.resolver.NXDOMAIN: except dns.resolver.NXDOMAIN:
print("Reverse DNS lookup failed for %s. SMTP EHLO name check skipped." % ipaddr) print(f"Reverse DNS lookup failed for {ipaddr}. SMTP EHLO name check skipped.")
reverse_dns = None reverse_dns = None
if reverse_dns is not None: if reverse_dns is not None:
server.ehlo_or_helo_if_needed() # must send EHLO before getting the server's EHLO name server.ehlo_or_helo_if_needed() # must send EHLO before getting the server's EHLO name
@ -58,7 +54,7 @@ if reverse_dns is not None:
if helo_name != reverse_dns: if helo_name != reverse_dns:
print("The server's EHLO name does not match its reverse hostname. Check DNS settings.") print("The server's EHLO name does not match its reverse hostname. Check DNS settings.")
else: else:
print("SMTP EHLO name (%s) is OK." % helo_name) print(f"SMTP EHLO name ({helo_name}) is OK.")
# Login and send a test email. # Login and send a test email.
server.login(emailaddress, pw) server.login(emailaddress, pw)

View File

@ -6,11 +6,11 @@ if len(sys.argv) < 3:
sys.exit(1) sys.exit(1)
host, toaddr, fromaddr = sys.argv[1:4] host, toaddr, fromaddr = sys.argv[1:4]
msg = """From: %s msg = f"""From: {fromaddr}
To: %s To: {toaddr}
Subject: SMTP server test Subject: SMTP server test
This is a test message.""" % (fromaddr, toaddr) This is a test message."""
server = smtplib.SMTP(host, 25) server = smtplib.SMTP(host, 25)
server.set_debuglevel(1) server.set_debuglevel(1)

View File

@ -88,15 +88,15 @@ def sslyze(opts, port, ok_ciphers):
try: try:
# Execute SSLyze. # Execute SSLyze.
out = subprocess.check_output([SSLYZE] + common_opts + opts + [connection_string]) out = subprocess.check_output([SSLYZE, *common_opts, *opts, connection_string])
out = out.decode("utf8") out = out.decode("utf8")
# Trim output to make better for storing in git. # Trim output to make better for storing in git.
if "SCAN RESULTS FOR" not in out: if "SCAN RESULTS FOR" not in out:
# Failed. Just output the error. # Failed. Just output the error.
out = re.sub("[\w\W]*CHECKING HOST\(S\) AVAILABILITY\n\s*-+\n", "", out) # chop off header that shows the host we queried out = re.sub("[\\w\\W]*CHECKING HOST\\(S\\) AVAILABILITY\n\\s*-+\n", "", out) # chop off header that shows the host we queried
out = re.sub("[\w\W]*SCAN RESULTS FOR.*\n\s*-+\n", "", out) # chop off header that shows the host we queried out = re.sub("[\\w\\W]*SCAN RESULTS FOR.*\n\\s*-+\n", "", out) # chop off header that shows the host we queried
out = re.sub("SCAN COMPLETED IN .*", "", out) out = re.sub(r"SCAN COMPLETED IN .*", "", out)
out = out.rstrip(" \n-") + "\n" out = out.rstrip(" \n-") + "\n"
# Print. # Print.
@ -105,8 +105,8 @@ def sslyze(opts, port, ok_ciphers):
# Pull out the accepted ciphers list for each SSL/TLS protocol # Pull out the accepted ciphers list for each SSL/TLS protocol
# version outputted. # version outputted.
accepted_ciphers = set() accepted_ciphers = set()
for ciphers in re.findall(" Accepted:([\w\W]*?)\n *\n", out): for ciphers in re.findall(" Accepted:([\\w\\W]*?)\n *\n", out):
accepted_ciphers |= set(re.findall("\n\s*(\S*)", ciphers)) accepted_ciphers |= set(re.findall("\n\\s*(\\S*)", ciphers))
# Compare to what Mozilla recommends, for a given modernness-level. # Compare to what Mozilla recommends, for a given modernness-level.
print(" Should Not Offer: " + (", ".join(sorted(accepted_ciphers-set(ok_ciphers))) or "(none -- good)")) print(" Should Not Offer: " + (", ".join(sorted(accepted_ciphers-set(ok_ciphers))) or "(none -- good)"))
@ -142,7 +142,7 @@ for cipher in csv.DictReader(io.StringIO(urllib.request.urlopen("https://raw.git
client_compatibility = json.loads(urllib.request.urlopen("https://raw.githubusercontent.com/mail-in-a-box/user-agent-tls-capabilities/master/clients.json").read().decode("utf8")) client_compatibility = json.loads(urllib.request.urlopen("https://raw.githubusercontent.com/mail-in-a-box/user-agent-tls-capabilities/master/clients.json").read().decode("utf8"))
cipher_clients = { } cipher_clients = { }
for client in client_compatibility: for client in client_compatibility:
if len(set(client['protocols']) & set(["TLS 1.0", "TLS 1.1", "TLS 1.2"])) == 0: continue # does not support TLS if len(set(client['protocols']) & {"TLS 1.0", "TLS 1.1", "TLS 1.2"}) == 0: continue # does not support TLS
for cipher in client['ciphers']: for cipher in client['ciphers']:
cipher_clients.setdefault(cipher_names.get(cipher), set()).add("/".join(x for x in [client['client']['name'], client['client']['version'], client['client']['platform']] if x)) cipher_clients.setdefault(cipher_names.get(cipher), set()).add("/".join(x for x in [client['client']['name'], client['client']['version'], client['client']['platform']] if x))

View File

@ -1,9 +1,10 @@
#!/bin/bash
# Use this script to make an archive of the contents of all # Use this script to make an archive of the contents of all
# of the configuration files we edit with editconf.py. # of the configuration files we edit with editconf.py.
for fn in `grep -hr editconf.py setup | sed "s/tools\/editconf.py //" | sed "s/ .*//" | sort | uniq`; do for fn in $(grep -hr editconf.py setup | sed "s/tools\/editconf.py //" | sed "s/ .*//" | sort | uniq); do
echo ====================================================================== echo ======================================================================
echo $fn echo "$fn"
echo ====================================================================== echo ======================================================================
cat $fn cat "$fn"
done done

View File

@ -3,4 +3,4 @@ POSTDATA=dummy
if [ "$1" == "--force" ]; then if [ "$1" == "--force" ]; then
POSTDATA=force=1 POSTDATA=force=1
fi fi
curl -s -d $POSTDATA --user $(</var/lib/mailinabox/api.key): http://127.0.0.1:10222/dns/update curl -s -d $POSTDATA --user "$(</var/lib/mailinabox/api.key):" http://127.0.0.1:10222/dns/update

View File

@ -30,7 +30,7 @@ import sys, re
# sanity check # sanity check
if len(sys.argv) < 3: if len(sys.argv) < 3:
print("usage: python3 editconf.py /etc/file.conf [-s] [-w] [-c <CHARACTER>] [-t] NAME=VAL [NAME=VAL ...]") print("usage: python3 editconf.py /etc/file.conf [-e] [-s] [-w] [-c <CHARACTER>] [-t] NAME=VAL [NAME=VAL ...]")
sys.exit(1) sys.exit(1)
# parse command line arguments # parse command line arguments
@ -76,14 +76,15 @@ for setting in settings:
found = set() found = set()
buf = "" buf = ""
input_lines = list(open(filename)) with open(filename, encoding="utf-8") as f:
input_lines = list(f)
while len(input_lines) > 0: while len(input_lines) > 0:
line = input_lines.pop(0) line = input_lines.pop(0)
# If this configuration file uses folded lines, append any folded lines # If this configuration file uses folded lines, append any folded lines
# into our input buffer. # into our input buffer.
if folded_lines and line[0] not in (comment_char, " ", ""): if folded_lines and line[0] not in {comment_char, " ", ""}:
while len(input_lines) > 0 and input_lines[0][0] in " \t": while len(input_lines) > 0 and input_lines[0][0] in " \t":
line += input_lines.pop(0) line += input_lines.pop(0)
@ -92,9 +93,9 @@ while len(input_lines) > 0:
# Check if this line contain this setting from the command-line arguments. # Check if this line contain this setting from the command-line arguments.
name, val = settings[i].split("=", 1) name, val = settings[i].split("=", 1)
m = re.match( m = re.match(
"(\s*)" r"(\s*)"
+ "(" + re.escape(comment_char) + "\s*)?" "(" + re.escape(comment_char) + r"\s*)?"
+ re.escape(name) + delimiter_re + "(.*?)\s*$", + re.escape(name) + delimiter_re + r"(.*?)\s*$",
line, re.S) line, re.S)
if not m: continue if not m: continue
indent, is_comment, existing_val = m.groups() indent, is_comment, existing_val = m.groups()
@ -143,7 +144,7 @@ for i in range(len(settings)):
if not testing: if not testing:
# Write out the new file. # Write out the new file.
with open(filename, "w") as f: with open(filename, "w", encoding="utf-8") as f:
f.write(buf) f.write(buf)
else: else:
# Just print the new file to stdout. # Just print the new file to stdout.

View File

@ -14,13 +14,13 @@ if [ -z "$1" ]; then
echo echo
echo "Available backups:" echo "Available backups:"
echo echo
find $STORAGE_ROOT/owncloud-backup/* -maxdepth 0 -type d find "$STORAGE_ROOT/owncloud-backup/"* -maxdepth 0 -type d
echo echo
echo "Supply the directory that was created during the last installation as the only commandline argument" echo "Supply the directory that was created during the last installation as the only commandline argument"
exit exit
fi fi
if [ ! -f $1/config.php ]; then if [ ! -f "$1/config.php" ]; then
echo "This isn't a valid backup location" echo "This isn't a valid backup location"
exit 1 exit 1
fi fi
@ -36,14 +36,14 @@ cp -r "$1/owncloud-install" /usr/local/lib/owncloud
# restore access rights # restore access rights
chmod 750 /usr/local/lib/owncloud/{apps,config} chmod 750 /usr/local/lib/owncloud/{apps,config}
cp "$1/owncloud.db" $STORAGE_ROOT/owncloud/ cp "$1/owncloud.db" "$STORAGE_ROOT/owncloud/"
cp "$1/config.php" $STORAGE_ROOT/owncloud/ cp "$1/config.php" "$STORAGE_ROOT/owncloud/"
ln -sf $STORAGE_ROOT/owncloud/config.php /usr/local/lib/owncloud/config/config.php ln -sf "$STORAGE_ROOT/owncloud/config.php" /usr/local/lib/owncloud/config/config.php
chown -f -R www-data.www-data $STORAGE_ROOT/owncloud /usr/local/lib/owncloud chown -f -R www-data:www-data "$STORAGE_ROOT/owncloud" /usr/local/lib/owncloud
chown www-data.www-data $STORAGE_ROOT/owncloud/config.php chown www-data:www-data "$STORAGE_ROOT/owncloud/config.php"
sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/occ maintenance:mode --off sudo -u www-data "php$PHP_VER" /usr/local/lib/owncloud/occ maintenance:mode --off
service php8.0-fpm start service php8.0-fpm start
echo "Done" echo "Done"

View File

@ -11,13 +11,13 @@ source /etc/mailinabox.conf # load global vars
ADMIN=$(./mail.py user admins | head -n 1) ADMIN=$(./mail.py user admins | head -n 1)
test -z "$1" || ADMIN=$1 test -z "$1" || ADMIN=$1
echo I am going to unlock admin features for $ADMIN. echo "I am going to unlock admin features for $ADMIN."
echo You can provide another user to unlock as the first argument of this script. echo "You can provide another user to unlock as the first argument of this script."
echo echo
echo WARNING: you could break mail-in-a-box when fiddling around with Nextcloud\'s admin interface echo "WARNING: you could break mail-in-a-box when fiddling around with Nextcloud's admin interface"
echo If in doubt, press CTRL-C to cancel. echo "If in doubt, press CTRL-C to cancel."
echo echo
echo Press enter to continue. echo "Press enter to continue."
read read
sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/occ group:adduser admin $ADMIN && echo Done. sudo -u www-data "php$PHP_VER" /usr/local/lib/owncloud/occ group:adduser admin "$ADMIN" && echo "Done."

View File

@ -17,13 +17,8 @@ accesses = set()
# Scan the current and rotated access logs. # Scan the current and rotated access logs.
for fn in glob.glob("/var/log/nginx/access.log*"): for fn in glob.glob("/var/log/nginx/access.log*"):
# Gunzip if necessary. # Gunzip if necessary.
if fn.endswith(".gz"):
f = gzip.open(fn)
else:
f = open(fn, "rb")
# Loop through the lines in the access log. # Loop through the lines in the access log.
with f: with (gzip.open if fn.endswith(".gz") else open)(fn, "rb") as f:
for line in f: for line in f:
# Find lines that are GETs on the bootstrap script by either curl or wget. # Find lines that are GETs on the bootstrap script by either curl or wget.
# (Note that we purposely skip ...?ping=1 requests which is the admin panel querying us for updates.) # (Note that we purposely skip ...?ping=1 requests which is the admin panel querying us for updates.)
@ -43,7 +38,8 @@ for date, ip in accesses:
# Since logs are rotated, store the statistics permanently in a JSON file. # Since logs are rotated, store the statistics permanently in a JSON file.
# Load in the stats from an existing file. # Load in the stats from an existing file.
if os.path.exists(outfn): if os.path.exists(outfn):
existing_data = json.load(open(outfn)) with open(outfn, encoding="utf-8") as f:
existing_data = json.load(f)
for date, count in existing_data: for date, count in existing_data:
if date not in by_date: if date not in by_date:
by_date[date] = count by_date[date] = count
@ -55,5 +51,5 @@ by_date = sorted(by_date.items())
by_date.pop(-1) by_date.pop(-1)
# Write out. # Write out.
with open(outfn, "w") as f: with open(outfn, "w", encoding="utf-8") as f:
json.dump(by_date, f, sort_keys=True, indent=True) json.dump(by_date, f, sort_keys=True, indent=True)

View File

@ -124,12 +124,13 @@ def generate_documentation():
""") """)
parser = Source.parser() parser = Source.parser()
for line in open("setup/start.sh"): with open("setup/start.sh", encoding="utf-8") as start_file:
for line in start_file:
try: try:
fn = parser.parse_string(line).filename() fn = parser.parse_string(line).filename()
except: except:
continue continue
if fn in ("setup/start.sh", "setup/preflight.sh", "setup/questions.sh", "setup/firstuser.sh", "setup/management.sh"): if fn in {"setup/start.sh", "setup/preflight.sh", "setup/questions.sh", "setup/firstuser.sh", "setup/management.sh"}:
continue continue
import sys import sys
@ -191,8 +192,7 @@ class CatEOF(Grammar):
def value(self): def value(self):
content = self[9].string content = self[9].string
content = re.sub(r"\\([$])", r"\1", content) # un-escape bash-escaped characters content = re.sub(r"\\([$])", r"\1", content) # un-escape bash-escaped characters
return "<div class='write-to'><div class='filename'>%s <span>(%s)</span></div><pre>%s</pre></div>\n" \ return "<div class='write-to'><div class='filename'>{} <span>({})</span></div><pre>{}</pre></div>\n".format(self[4].string,
% (self[4].string,
"overwrite" if ">>" not in self[2].string else "append to", "overwrite" if ">>" not in self[2].string else "append to",
cgi.escape(content)) cgi.escape(content))
@ -222,14 +222,14 @@ class EditConf(Grammar):
EOL EOL
) )
def value(self): def value(self):
conffile = self[1] # conffile = self[1]
options = [] options = []
eq = "=" eq = "="
if self[3] and "-s" in self[3].string: eq = " " if self[3] and "-s" in self[3].string: eq = " "
for opt in re.split("\s+", self[4].string): for opt in re.split(r"\s+", self[4].string):
k, v = opt.split("=", 1) k, v = opt.split("=", 1)
v = re.sub(r"\n+", "", fixup_tokens(v)) # not sure why newlines are getting doubled v = re.sub(r"\n+", "", fixup_tokens(v)) # not sure why newlines are getting doubled
options.append("%s%s%s" % (k, eq, v)) options.append(f"{k}{eq}{v}")
return "<div class='write-to'><div class='filename'>" + self[1].string + " <span>(change settings)</span></div><pre>" + "\n".join(cgi.escape(s) for s in options) + "</pre></div>\n" return "<div class='write-to'><div class='filename'>" + self[1].string + " <span>(change settings)</span></div><pre>" + "\n".join(cgi.escape(s) for s in options) + "</pre></div>\n"
class CaptureOutput(Grammar): class CaptureOutput(Grammar):
@ -247,8 +247,8 @@ class SedReplace(Grammar):
class EchoPipe(Grammar): class EchoPipe(Grammar):
grammar = OPTIONAL(SPACE), L("echo "), REST_OF_LINE, L(' | '), REST_OF_LINE, EOL grammar = OPTIONAL(SPACE), L("echo "), REST_OF_LINE, L(' | '), REST_OF_LINE, EOL
def value(self): def value(self):
text = " ".join("\"%s\"" % s for s in self[2].string.split(" ")) text = " ".join(f'"{s}"' for s in self[2].string.split(" "))
return "<pre class='shell'><div>echo " + recode_bash(text) + " \<br> | " + recode_bash(self[4].string) + "</div></pre>\n" return "<pre class='shell'><div>echo " + recode_bash(text) + r" \<br> | " + recode_bash(self[4].string) + "</div></pre>\n"
def shell_line(bash): def shell_line(bash):
return "<pre class='shell'><div>" + recode_bash(bash.strip()) + "</div></pre>\n" return "<pre class='shell'><div>" + recode_bash(bash.strip()) + "</div></pre>\n"
@ -323,7 +323,7 @@ def quasitokenize(bashscript):
elif c == "\\": elif c == "\\":
# Escaping next character. # Escaping next character.
escape_next = True escape_next = True
elif quote_mode is None and c in ('"', "'"): elif quote_mode is None and c in {'"', "'"}:
# Starting a quoted word. # Starting a quoted word.
quote_mode = c quote_mode = c
elif c == quote_mode: elif c == quote_mode:
@ -363,7 +363,7 @@ def quasitokenize(bashscript):
newscript += c newscript += c
# "<< EOF" escaping. # "<< EOF" escaping.
if quote_mode is None and re.search("<<\s*EOF\n$", newscript): if quote_mode is None and re.search("<<\\s*EOF\n$", newscript):
quote_mode = "EOF" quote_mode = "EOF"
elif quote_mode == "EOF" and re.search("\nEOF\n$", newscript): elif quote_mode == "EOF" and re.search("\nEOF\n$", newscript):
quote_mode = None quote_mode = None
@ -377,7 +377,7 @@ def recode_bash(s):
tok = tok.replace(c, "\\" + c) tok = tok.replace(c, "\\" + c)
tok = fixup_tokens(tok) tok = fixup_tokens(tok)
if " " in tok or '"' in tok: if " " in tok or '"' in tok:
tok = tok.replace("\"", "\\\"") tok = tok.replace('"', '\\"')
tok = '"' + tok +'"' tok = '"' + tok +'"'
else: else:
tok = tok.replace("'", "\\'") tok = tok.replace("'", "\\'")
@ -400,20 +400,20 @@ class BashScript(Grammar):
@staticmethod @staticmethod
def parse(fn): def parse(fn):
if fn in ("setup/functions.sh", "/etc/mailinabox.conf"): return "" if fn in {"setup/functions.sh", "/etc/mailinabox.conf"}: return ""
string = open(fn).read() with open(fn, encoding="utf-8") as f:
string = f.read()
# tokenize # tokenize
string = re.sub(".* #NODOC\n", "", string) string = re.sub(".* #NODOC\n", "", string)
string = re.sub("\n\s*if .*then.*|\n\s*fi|\n\s*else|\n\s*elif .*", "", string) string = re.sub("\n\\s*if .*then.*|\n\\s*fi|\n\\s*else|\n\\s*elif .*", "", string)
string = quasitokenize(string) string = quasitokenize(string)
string = re.sub("hide_output ", "", string) string = string.replace(r"hide_output ", "")
parser = BashScript.parser() parser = BashScript.parser()
result = parser.parse_string(string) result = parser.parse_string(string)
v = "<div class='row'><div class='col-xs-12 sourcefile'>view the bash source for the following section at <a href=\"%s\">%s</a></div></div>\n" \ v = "<div class='row'><div class='col-xs-12 sourcefile'>view the bash source for the following section at <a href=\"{}\">{}</a></div></div>\n".format("https://github.com/mail-in-a-box/mailinabox/tree/master/" + fn, fn)
% ("https://github.com/mail-in-a-box/mailinabox/tree/master/" + fn, fn)
mode = 0 mode = 0
for item in result.value(): for item in result.value():
@ -427,7 +427,7 @@ class BashScript(Grammar):
mode = 0 mode = 0
clz = "contd" clz = "contd"
if mode == 0: if mode == 0:
v += "<div class='row %s'>\n" % clz v += f"<div class='row {clz}'>\n"
v += "<div class='col-md-6 prose'>\n" v += "<div class='col-md-6 prose'>\n"
v += item v += item
mode = 1 mode = 1
@ -458,17 +458,16 @@ class BashScript(Grammar):
v = fixup_tokens(v) v = fixup_tokens(v)
v = v.replace("</pre>\n<pre class='shell'>", "") v = v.replace("</pre>\n<pre class='shell'>", "")
v = re.sub("<pre>([\w\W]*?)</pre>", lambda m : "<pre>" + strip_indent(m.group(1)) + "</pre>", v) v = re.sub(r"<pre>([\w\W]*?)</pre>", lambda m : "<pre>" + strip_indent(m.group(1)) + "</pre>", v)
v = re.sub(r"(\$?)PRIMARY_HOSTNAME", r"<b>box.yourdomain.com</b>", v) v = re.sub(r"(\$?)PRIMARY_HOSTNAME", r"<b>box.yourdomain.com</b>", v)
v = re.sub(r"\$STORAGE_ROOT", r"<b>$STORE</b>", v) v = re.sub(r"\$STORAGE_ROOT", r"<b>$STORE</b>", v)
v = v.replace("`pwd`", "<code><b>/path/to/mailinabox</b></code>") return v.replace("`pwd`", "<code><b>/path/to/mailinabox</b></code>")
return v
def wrap_lines(text, cols=60): def wrap_lines(text, cols=60):
ret = "" ret = ""
words = re.split("(\s+)", text) words = re.split(r"(\s+)", text)
linelen = 0 linelen = 0
for w in words: for w in words:
if linelen + len(w) > cols-1: if linelen + len(w) > cols-1:

17
tools/ssl_cleanup Executable file
View File

@ -0,0 +1,17 @@
#!/bin/bash
# Cleanup SSL certificates which expired more than 7 days ago from $STORAGE_ROOT/ssl and move them to $STORAGE_ROOT/ssl.expired
source /etc/mailinabox.conf
shopt -s extglob nullglob
retain_after="$(date --date="7 days ago" +%Y%m%d)"
mkdir -p $STORAGE_ROOT/ssl.expired
for file in $STORAGE_ROOT/ssl/*-+([0-9])-+([0-9a-f]).pem; do
pem="$(basename "$file")"
not_valid_after="$(cut -d- -f1 <<< "${pem: -21}")"
if [ "$not_valid_after" -lt "$retain_after" ]; then
mv "$file" "$STORAGE_ROOT/ssl.expired/${pem}"
fi
done

View File

@ -1,2 +1,2 @@
#!/bin/bash #!/bin/bash
curl -s -d POSTDATA --user $(</var/lib/mailinabox/api.key): http://127.0.0.1:10222/web/update curl -s -d POSTDATA --user "$(</var/lib/mailinabox/api.key):" http://127.0.0.1:10222/web/update