1
0
mirror of https://github.com/mail-in-a-box/mailinabox.git synced 2026-03-12 17:07:23 +01:00

Compare commits

..

135 Commits

Author SHA1 Message Date
Joshua Tauberer
3cde9a8893 v73 2025-07-11 05:59:13 -04:00
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
59 changed files with 1284 additions and 684 deletions

View File

@@ -1,6 +1,123 @@
CHANGELOG
=========
Version 73 (July 11, 2025)
--------------------------
Mail:
* Quotas for mail storage can now be set per user in the control panel.
* Autoconfig now includes POP3 and CardDAV/CalDAV.
Backups:
* Fix for S3-compatible backups (other than AWS S3 itself).
Control Panel:
* Backup status is added to the status checks.
* S3 backup credentials can now be stored in environment variables.
* Fix for when an AAAA record is set up the box's own IP address.
* Fix for when logged out of the control panel.
* Fix link to Z-Push client compatibility list.
Setup:
* The Ubuntu version check is updated.
Other:
* Code cleanup using the Ruff Python linter.
* Other minor changes.
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)
------------------------------
@@ -27,7 +144,7 @@ Version 64 (September 2, 2023)
* 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 unrelased version of Mail-in-a-Box.
* Fixed some crashes when using an unreleased version of Mail-in-a-Box.
* Added z-push administration scripts.
Version 63 (July 27, 2023)
@@ -163,7 +280,7 @@ Other:
* 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.
* Fix DNS secondary nameserver refesh failure retry period.
* Fix DNS secondary nameserver refresh failure retry period.
Version 55 (October 18, 2021)
-----------------------------
@@ -188,7 +305,7 @@ Control panel:
Other:
* 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.
v0.54 (June 20, 2021)
@@ -221,7 +338,7 @@ Setup:
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)
----------------------
@@ -440,7 +557,7 @@ Changes:
* Added support for S3-compatible backup services besides Amazon S3.
* Fixed the control panel login page to let LastPass save passwords.
* Fixed an error in the user privileges API.
* Silenced some spurrious messages.
* Silenced some spurious messages.
Software updates:
@@ -504,7 +621,7 @@ Setup:
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 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.
@@ -832,7 +949,7 @@ v0.17c (April 1, 2016)
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.
@@ -848,7 +965,7 @@ Control panel:
Setup:
* 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)
----------------------
@@ -891,7 +1008,7 @@ This update primarily adds automatic SSL (now "TLS") certificate provisioning fr
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.
* 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.
@@ -984,7 +1101,7 @@ Control panel:
System:
* Tweaks to fail2ban settings.
* Fixed a spurrious warning while installing munin.
* Fixed a spurious warning while installing munin.
v0.13b (August 30, 2015)
------------------------
@@ -998,7 +1115,7 @@ Note: v0.13 (no 'a', August 19, 2015) was pulled immediately due to an ownCloud
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.
* The users permitted to send as an alias can now be different from where an alias forwards to.
@@ -1030,7 +1147,7 @@ v0.12c was posted to work around the current Sourceforge.net outage: pyzor's rem
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)
--------------------
@@ -1083,7 +1200,7 @@ Control panel:
System:
* 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.
@@ -1121,7 +1238,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.
* 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.)
* 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:

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.
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.
## 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.

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...
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
$ cd mailinabox
$ git checkout v67
$ git checkout TAGNAME
Begin the installation.

View File

@@ -52,7 +52,7 @@ namespace inbox {
# dovevot's standard mailboxes configuration file marks two sent folders
# 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.
mailbox "Sent Messages" {
special_use = \Sent

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
# 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.
# 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.
[postfix-sasl]

View File

@@ -1,7 +1,7 @@
<?xml version="1.0"?>
<clientConfig version="1.1">
<emailProvider id="PRIMARY_HOSTNAME">
<domain>PRIMARY_HOSTNAME</domain>
<domain purpose="mx">PRIMARY_HOSTNAME</domain>
<displayName>PRIMARY_HOSTNAME (Mail-in-a-Box)</displayName>
<displayShortName>PRIMARY_HOSTNAME</displayShortName>
@@ -14,6 +14,14 @@
<authentication>password-cleartext</authentication>
</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">
<hostname>PRIMARY_HOSTNAME</hostname>
<port>465</port>
@@ -29,6 +37,20 @@
</documentation>
</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>
<loginPage url="https://PRIMARY_HOSTNAME/mail/" />
<loginPageInfo url="https://PRIMARY_HOSTNAME/mail/" >

View File

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

View File

@@ -38,7 +38,7 @@
}
}
location ~ ^(/cloud)((?:/ocs)?/[^/]+\.php)(/.*)?$ {
# note: ~ has precendence over a regular location block
# note: ~ has precedence over a regular location block
# Accept URLs like:
# /cloud/index.php/apps/files/
# /cloud/index.php/apps/files/ajax/scan.php (it's really index.php; see 6fdef379adfdeac86cc2220209bdf4eb9562268d)

View File

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

View File

@@ -9,13 +9,15 @@
import os, os.path, re, datetime, sys
import dateutil.parser, dateutil.relativedelta, dateutil.tz
from datetime import date
import rtyaml
from exclusiveprocess import Lock
from utils import load_environment, shell, wait_for_service
import operator
def backup_status(env):
# If backups are dissbled, return no status.
# If backups are disabled, return no status.
config = get_backup_config(env)
if config["target"] == "off":
return { }
@@ -90,7 +92,7 @@ def backup_status(env):
# Ensure the rows are sorted reverse chronologically.
# 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
# most recent full backup, and the date of the most recent
@@ -157,6 +159,8 @@ def should_force_full(config, env):
# since the last full backup is greater than half the size
# of that full backup.
inc_size = 0
# Check if day of week is a weekend day
weekend = date.today().weekday()>=5
for bak in backup_status(env)["backups"]:
if not bak["full"]:
# Scan through the incremental backups cumulating
@@ -165,17 +169,17 @@ def should_force_full(config, env):
else:
# ...until we reach the most recent full backup.
# Return if we should to a full backup, which is based
# on the size of the increments relative to the full
# backup, as well as the age of the full backup.
if inc_size > .5*bak["size"]:
return True
if dateutil.parser.parse(bak["date"]) + datetime.timedelta(days=config["min_age_in_days"]*10+1) < datetime.datetime.now(dateutil.tz.tzlocal()):
return True
# on whether it is a weekend day, the size of the
# increments relative to the full backup, as well as
# the age of the full backup.
if weekend:
if inc_size > .5*bak["size"]:
return True
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 False
else:
# 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
# If we got here there are no (full) backups, so make one.
return True
def get_passphrase(env):
# Get the encryption passphrase. secret_key.txt is 2048 random
@@ -231,7 +235,7 @@ def get_duplicity_additional_args(env):
f"--ssh-options='-i /root/.ssh/id_rsa_miab -p {port}'",
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.
# The region name, which is required by some non-AWS endpoints,
# is saved inside the username portion of the URL.
@@ -253,6 +257,8 @@ def get_duplicity_env_vars(env):
if get_target_type(config) == 's3':
env["AWS_ACCESS_KEY_ID"] = config["target_user"]
env["AWS_SECRET_ACCESS_KEY"] = config["target_pass"]
env["AWS_REQUEST_CHECKSUM_CALCULATION"] = "WHEN_REQUIRED"
env["AWS_RESPONSE_CHECKSUM_VALIDATION"] = "WHEN_REQUIRED"
return env
@@ -320,6 +326,7 @@ def perform_backup(full_backup):
"--verbosity", "warning", "--no-print-statistics",
"--archive-dir", backup_cache_dir,
"--exclude", backup_root,
"--exclude", os.path.join(env["STORAGE_ROOT"], "owncloud-backup"),
"--volsize", "250",
"--gpg-options", "'--cipher-algo=AES256'",
"--allow-source-mismatch",
@@ -399,6 +406,7 @@ def run_duplicity_verification():
"--compare-data",
"--archive-dir", backup_cache_dir,
"--exclude", backup_root,
"--exclude", os.path.join(env["STORAGE_ROOT"], "owncloud-backup"),
*get_duplicity_additional_args(env),
get_duplicity_target_url(config),
env["STORAGE_ROOT"],
@@ -440,7 +448,7 @@ def list_target_files(config):
if target.scheme == "file":
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_target = '{host}:{path}'
@@ -456,9 +464,8 @@ def list_target_files(config):
target_path = target.path
if not target_path.endswith('/'):
target_path = target_path + '/'
if target_path.startswith('/'):
target_path = target_path[1:]
target_path += '/'
target_path = target_path.removeprefix('/')
rsync_command = [ 'rsync',
'-e',
@@ -478,23 +485,22 @@ def list_target_files(config):
if match:
ret.append( (match.groups()[1], int(match.groups()[0].replace(',',''))) )
return ret
if 'Permission denied (publickey).' in listing:
reason = "Invalid user or check you correctly copied the SSH key."
elif 'No such file or directory' in listing:
reason = f"Provided path {target_path} is invalid."
elif 'Network is unreachable' in listing:
reason = f"The IP address {target.hostname} is unreachable."
elif 'Could not resolve hostname' in listing:
reason = f"The hostname {target.hostname} cannot be resolved."
else:
if 'Permission denied (publickey).' in listing:
reason = "Invalid user or check you correctly copied the SSH key."
elif 'No such file or directory' in listing:
reason = f"Provided path {target_path} is invalid."
elif 'Network is unreachable' in listing:
reason = f"The IP address {target.hostname} is unreachable."
elif 'Could not resolve hostname' in listing:
reason = f"The hostname {target.hostname} cannot be resolved."
else:
reason = ("Unknown error."
"Please check running 'management/backup.py --verify'"
"from mailinabox sources to debug the issue.")
msg = f"Connection to rsync host failed: {reason}"
raise ValueError(msg)
reason = ("Unknown error."
"Please check running 'management/backup.py --verify'"
"from mailinabox sources to debug the issue.")
msg = f"Connection to rsync host failed: {reason}"
raise ValueError(msg)
elif target.scheme == "s3":
if target.scheme == "s3":
import boto3.s3
from botocore.exceptions import ClientError
@@ -512,16 +518,19 @@ def list_target_files(config):
# connect to the region & bucket
try:
s3 = boto3.client('s3', \
endpoint_url=f'https://{target.hostname}', \
aws_access_key_id=config['target_user'], \
aws_secret_access_key=config['target_pass'])
if config['target_user'] == "" and config['target_pass'] == "":
s3 = boto3.client('s3', endpoint_url=f'https://{target.hostname}')
else:
s3 = boto3.client('s3', \
endpoint_url=f'https://{target.hostname}', \
aws_access_key_id=config['target_user'], \
aws_secret_access_key=config['target_pass'])
bucket_objects = s3.list_objects_v2(Bucket=bucket, Prefix=path)['Contents']
backup_list = [(key['Key'][len(path):], key['Size']) for key in bucket_objects]
except ClientError as e:
raise ValueError(e)
return backup_list
elif target.scheme == 'b2':
if target.scheme == 'b2':
from b2sdk.v1 import InMemoryAccountInfo, B2Api
from b2sdk.v1.exception import NonExistentBucket
info = InMemoryAccountInfo()
@@ -540,8 +549,7 @@ def list_target_files(config):
raise ValueError(msg)
return [(key.file_name, key.size) for key, _ in bucket.ls()]
else:
raise ValueError(config["target"])
raise ValueError(config["target"])
def backup_set_custom(env, target, target_user, target_pass, min_age):
@@ -595,8 +603,7 @@ def get_backup_config(env, for_save=False, for_ui=False):
# authentication details. The user will have to re-enter it.
if for_ui:
for field in ("target_user", "target_pass"):
if field in config:
del config[field]
config.pop(field, None)
# helper fields for the admin
config["file_target_directory"] = os.path.join(backup_root, 'encrypted')

View File

@@ -65,6 +65,7 @@ if len(sys.argv) < 2:
{cli} user password user@domain.com [password]
{cli} user remove 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 admins (lists admins)
{cli} user mfa show user@domain.com (shows MFA devices for user, if any)
@@ -117,6 +118,14 @@ elif sys.argv[1] == "user" and sys.argv[2] == "admins":
if "admin" in user['privileges']:
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"]:
# Show MFA status for a user.
status = mgmt("/mfa/status", { "user": sys.argv[4] }, is_json=True)
@@ -141,4 +150,3 @@ elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4:
else:
print("Invalid command-line arguments.")
sys.exit(1)

View File

@@ -21,6 +21,7 @@ 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_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_quota, set_mail_quota
from mfa import get_public_mfa_state, provision_totp, validate_totp_secret, enable_mfa, disable_mfa
import contextlib
@@ -92,12 +93,11 @@ def authorized_personnel_only(viewfunc):
if request.headers.get('Accept') in {None, "", "*/*"}:
# Return plain text output.
return Response(error+"\n", status=status, mimetype='text/plain', headers=headers)
else:
# Return JSON output.
return Response(json.dumps({
"status": "error",
"reason": error,
})+"\n", status=status, mimetype='application/json', headers=headers)
# Return JSON output.
return Response(json.dumps({
"status": "error",
"reason": error,
})+"\n", status=status, mimetype='application/json', headers=headers)
return newview
@@ -147,13 +147,12 @@ def login():
"status": "missing-totp-token",
"reason": str(e),
})
else:
# Log the failed login
log_failed_login(request)
return json_response({
"status": "invalid",
"reason": str(e),
})
# Log the failed login
log_failed_login(request)
return json_response({
"status": "invalid",
"reason": str(e),
})
# Return a new session for the user.
resp = {
@@ -163,7 +162,7 @@ def login():
"api_key": auth_service.create_session_key(email, env, type='login'),
}
app.logger.info(f"New login session created for {email}")
app.logger.info("New login session created for %s", email)
# Return.
return json_response(resp)
@@ -172,7 +171,7 @@ def login():
def logout():
try:
email, _ = auth_service.authenticate(request, env, logout=True)
app.logger.info(f"{email} logged out")
app.logger.info("%s logged out", email)
except ValueError:
pass
finally:
@@ -185,14 +184,36 @@ def logout():
def mail_users():
if request.args.get("format", "") == "json":
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'])
@authorized_personnel_only
def mail_users_add():
quota = request.form.get('quota', '0')
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:
return (str(e), 400)
@@ -233,8 +254,7 @@ def mail_user_privs_remove():
def mail_aliases():
if request.args.get("format", "") == "json":
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'])
@authorized_personnel_only
@@ -354,7 +374,7 @@ def dns_set_record(qname, rtype="A"):
# Get the existing records matching the qname and 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.
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
@@ -512,8 +532,8 @@ def totp_post_disable():
return (str(e), 400)
if result: # success
return "OK"
else: # error
return ("Invalid user or MFA id.", 400)
# error
return ("Invalid user or MFA id.", 400)
# WEB
@@ -597,8 +617,7 @@ def needs_reboot():
from status_checks import is_reboot_needed_due_to_package_installation
if is_reboot_needed_due_to_package_installation():
return json_response(True)
else:
return json_response(False)
return json_response(False)
@app.route('/system/reboot', methods=["POST"])
@authorized_personnel_only
@@ -607,8 +626,7 @@ def do_reboot():
from status_checks import 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)
else:
return "No reboot is required, so it is not allowed."
return "No reboot is required, so it is not allowed."
@app.route('/system/backup/status')
@@ -670,8 +688,7 @@ def check_request_cookie_for_admin_access():
if not session: return False
privs = get_mail_user_privileges(session["email"], env)
if not isinstance(privs, list): return False
if "admin" not in privs: return False
return True
return "admin" in privs
def authorized_personnel_only_via_cookie(f):
@wraps(f)
@@ -719,7 +736,7 @@ def munin_cgi(filename):
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',
COMMAND.split(" ", 5),
# Using a maxsplit of 5 keeps the last arguments together
@@ -753,7 +770,7 @@ def log_failed_login(request):
# We need to add a timestamp to the log message, otherwise /dev/log will eat the "duplicate"
# message.
app.logger.warning( f"Mail-in-a-Box Management Daemon: Failed login attempt from ip {ip} - timestamp {time.time()}")
app.logger.warning("Mail-in-a-Box Management Daemon: Failed login attempt from ip %s - timestamp %s", ip, time.time())
# 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
# 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"
fi

View File

@@ -9,13 +9,12 @@ import ipaddress
import rtyaml
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
import contextlib
# 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,
# 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.
DOMAIN_RE = r"^(?!\-)(?:[*][.])?(?:[a-zA-Z\d\-_]{0,62}[a-zA-Z\d_]\.){1,126}(?!\d+)[a-zA-Z\d_]{1,63}(\.?)$"
@@ -124,8 +123,7 @@ def do_dns_update(env, force=False):
if len(updated_domains) == 0:
# if nothing was updated (except maybe OpenDKIM's files), don't show any output
return ""
else:
return "updated DNS: " + ",".join(updated_domains) + "\n"
return "updated DNS: " + ",".join(updated_domains) + "\n"
########################################################################
@@ -187,7 +185,7 @@ def build_zone(domain, domain_properties, additional_records, env, is_zone=True)
# is managed outside of the box.
if is_zone:
# 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.
# User may provide one or more additional nameservers
@@ -254,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
# during this process.
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.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("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."
defaults = [
(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:
if value is None or value.strip() == "": continue # skip IPV6 if not set
@@ -281,13 +279,13 @@ def build_zone(domain, domain_properties, additional_records, env, is_zone=True)
if domain_properties[domain]["mail"]:
# The MX record says where email for the domain should be delivered: Here!
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
# the domain, and no one else.
# Skip if the user has set a custom SPF record.
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.
# Skip if the user has set a DKIM record already.
@@ -296,12 +294,12 @@ def build_zone(domain, domain_properties, additional_records, env, is_zone=True)
m = re.match(r'(\S+)\s+IN\s+TXT\s+\( ((?:"[^"]+"\s+)+)\)', orf.read(), re.S)
val = "".join(re.findall(r'"([^"]+)"', m.group(2)))
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.
# Skip if the user has set a DMARC record already.
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"]:
# Add CardDAV/CalDAV SRV records on the non-primary hostname that points to the primary hostname
@@ -364,9 +362,9 @@ def build_zone(domain, domain_properties, additional_records, env, is_zone=True)
# Mark this domain as not sending mail with hard-fail SPF and DMARC records.
d = (qname+"." if qname else "") + domain
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; "):
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)
if not has_rec(qname, "MX"):
@@ -443,19 +441,16 @@ def build_sshfp_records():
# Get our local fingerprints by running ssh-keyscan. The output looks
# 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,
# if SSH has been configured to listen on a nonstandard port, we must
# specify that port to sshkeyscan.
port = 22
with open('/etc/ssh/sshd_config', encoding="utf-8") as f:
for line in f:
s = line.rstrip().split()
if len(s) == 2 and s[0] == 'Port':
with contextlib.suppress(ValueError):
port = int(s[1])
break
port = get_ssh_port()
# 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"))
@@ -595,7 +590,8 @@ def get_dns_zonefile(zone, env):
if zone == domain:
break
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
with open(nsd_zonefile, encoding="utf-8") as f:
@@ -620,8 +616,8 @@ zone:
# and, if not a subnet, notifies to them.
for ipaddr in get_secondary_dns(additional_records, mode="xfr"):
if "/" not in ipaddr:
nsdconf += "\n\tnotify: %s NOKEY" % (ipaddr)
nsdconf += "\n\tprovide-xfr: %s NOKEY\n" % (ipaddr)
nsdconf += f"\n\tnotify: {ipaddr} NOKEY"
nsdconf += f"\n\tprovide-xfr: {ipaddr} NOKEY\n"
# Check if the file is changing. If it isn't changing,
# return False to flag that no change was made.
@@ -720,9 +716,9 @@ def sign_zone(domain, zonefile, env):
# zonefile to sign
"/etc/nsd/zones/" + zonefile,
]
# 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
@@ -901,7 +897,8 @@ def set_custom_dns_record(qname, rtype, value, action, env):
else:
# No match.
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
rtype = rtype.upper()
@@ -922,7 +919,7 @@ def set_custom_dns_record(qname, rtype, value, action, env):
# ensure value has a trailing dot
if not value.endswith("."):
value = value + "."
value += "."
if not re.search(DOMAIN_RE, value):
msg = "Invalid value."
@@ -931,7 +928,8 @@ def set_custom_dns_record(qname, rtype, value, action, env):
# anything goes
pass
else:
raise ValueError("Unknown record type '%s'." % rtype)
msg = f"Unknown record type '{rtype}'."
raise ValueError(msg)
# load existing config
config = list(get_custom_dns_config(env))
@@ -1042,7 +1040,8 @@ def set_secondary_dns(hostnames, env):
try:
resolver.resolve(item, "AAAA")
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:
# Validate IP address.
try:
@@ -1051,7 +1050,8 @@ def set_secondary_dns(hostnames, env):
else:
ipaddress.ip_address(item[4:]) # raises a ValueError if there's a problem
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_custom_dns_record("_secondary_nameserver", "A", " ".join(hostnames), "set", env)
@@ -1099,6 +1099,8 @@ if __name__ == "__main__":
env = load_environment()
if sys.argv[-1] == "--lint":
write_custom_dns_config(get_custom_dns_config(env), env)
elif sys.argv[-1] == "--update":
do_dns_update(env, force=True)
else:
for _zone, records in build_recommended_dns(env):
for record in records:

View File

@@ -71,7 +71,7 @@ def scan_files(collector):
if not os.path.exists(fn):
continue
elif fn[-3:] == '.gz':
if fn[-3:] == '.gz':
tmp_file = tempfile.NamedTemporaryFile()
with gzip.open(fn, 'rb') as f:
shutil.copyfileobj(f, tmp_file)
@@ -302,7 +302,7 @@ def scan_mail_log(env):
for date, sender, message in user_data["blocked"]:
if len(sender) > 64:
sender = sender[:32] + "" + sender[-32:]
user_rejects.extend((f'{date} - {sender} ', ' %s' % message))
user_rejects.extend((f'{date} - {sender} ', f' {message}'))
rejects.append(user_rejects)
print_user_table(
@@ -319,7 +319,7 @@ def scan_mail_log(env):
if collector["other-services"] and VERBOSE and False:
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(collector["other-services"]), sep='\n')
@@ -355,7 +355,7 @@ def scan_mail_log_line(line, collector):
if date > END_DATE:
# Don't process, and halt
return False
elif date < START_DATE:
if date < START_DATE:
# Don't process, but continue
return True
@@ -391,7 +391,7 @@ def scan_postgrey_line(date, log, collector):
""" Scan a postgrey log line and extract interesting data """
m = re.match(r"action=(greylist|pass), reason=(.*?), (?:delay=\d+, )?client_name=(.*), "
"client_address=(.*), sender=(.*), recipient=(.*)",
r"client_address=(.*), sender=(.*), recipient=(.*)",
log)
if m:
@@ -423,7 +423,7 @@ def scan_postfix_smtpd_line(date, log, collector):
# 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:
message, sender, user = m.groups()
@@ -467,7 +467,7 @@ def scan_postfix_smtpd_line(date, log, collector):
def scan_dovecot_login_line(date, log, collector, protocol_name):
""" 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:
# TODO: CHECK DIT
@@ -495,7 +495,7 @@ def add_login(user, date, protocol_name, host, collector):
data["latest"] = date
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:
data["activity-by-hour"][protocol_name][date.hour] += 1
@@ -608,7 +608,8 @@ def valid_date(string):
try:
date = dateutil.parser.parse(string)
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
@@ -634,8 +635,7 @@ def print_time_table(labels, data, do_print=True):
if do_print:
print("\n".join(lines))
return None
else:
return lines
return lines
def print_user_table(users, data=None, sub_data=None, activity=None, latest=None, earliest=None,
@@ -670,7 +670,7 @@ def print_user_table(users, data=None, sub_data=None, activity=None, latest=None
col_str = f"{d[row]!s:<20}"
col_left[col] = True
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_widths[col] = max(col_widths[col], len(col_str))
line += col_str
@@ -679,7 +679,7 @@ def print_user_table(users, data=None, sub_data=None, activity=None, latest=None
data_accum[col] += d[row]
try:
if None not in {latest, earliest}:
if None not in [latest, earliest]: # noqa: PLR6201
vert_pos = len(line)
e = earliest[row]
l = latest[row]
@@ -707,10 +707,10 @@ def print_user_table(users, data=None, sub_data=None, activity=None, latest=None
if sub_data is not None:
for l, d in sub_data:
if d[row]:
lines.extend(('', '%s' % l, '├─%s' % (len(l) * ''), ''))
lines.extend(('', f'{l}', '├─%s' % (len(l) * ''), ''))
max_len = 0
for v in list(d[row]):
lines.append("%s" % v)
lines.append(f"{v}")
max_len = max(max_len, len(v))
lines.append("" + (max_len + 1) * "")
@@ -732,7 +732,7 @@ def print_user_table(users, data=None, sub_data=None, activity=None, latest=None
else:
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 "
lines.insert(0, header.rstrip())
@@ -757,7 +757,7 @@ def print_user_table(users, data=None, sub_data=None, activity=None, latest=None
footer += temp.format(data_accum[row])
try:
if None not in {latest, earliest}:
if None not in [latest, earliest]: # noqa: PLR6201
max_l = max(latest)
min_e = min(earliest)
timespan = relativedelta(max_l, min_e)

View File

@@ -10,9 +10,11 @@
# address entered by the user.
import os, sqlite3, re
import utils
from email_validator import validate_email as validate_email_, EmailNotValidError
import idna
import operator
def validate_email(email, mode=None):
# Checks that an email address is syntactically valid. Returns True/False.
@@ -92,8 +94,7 @@ def open_database(env, with_connection=False):
conn = sqlite3.connect(env["STORAGE_ROOT"] + "/mail/users.sqlite")
if not with_connection:
return conn.cursor()
else:
return conn, conn.cursor()
return conn, conn.cursor()
def get_mail_users(env):
# Returns a flat, sorted list of all user accounts.
@@ -102,6 +103,17 @@ def get_mail_users(env):
users = [ row[0] for row in c.fetchall() ]
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):
# Returns a complex data structure of all user accounts, optionally
# including archived (status="inactive") accounts.
@@ -125,13 +137,42 @@ def get_mail_users_ex(env, with_archived=False):
users = []
active_accounts = set()
c = open_database(env)
c.execute('SELECT email, privileges FROM users')
for email, privileges in c.fetchall():
c.execute('SELECT email, privileges, quota FROM users')
for email, privileges, quota in c.fetchall():
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 = {
"email": email,
"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",
}
users.append(user)
@@ -150,6 +191,9 @@ def get_mail_users_ex(env, with_archived=False):
"privileges": [],
"status": "inactive",
"mailbox": mbox,
"box_size": '?',
"box_quota": '?',
"percent": '?',
}
users.append(user)
@@ -239,7 +283,7 @@ def get_mail_aliases_ex(env):
# Sort aliases within each domain first by required-ness then lexicographically by address.
for domain in domains:
domain["aliases"].sort(key = lambda alias : (alias["auto"], alias["address"]))
domain["aliases"].sort(key = operator.itemgetter("auto", "address"))
return domains
def get_domain(emailaddr, as_unicode=True):
@@ -266,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 ])
return set(domains)
def add_mail_user(email, pw, privs, env):
def add_mail_user(email, pw, privs, quota, env):
# validate email
if email.strip() == "":
return ("No email address provided.", 400)
elif not validate_email(email):
if not validate_email(email):
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)
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
# 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.
@@ -292,6 +336,14 @@ def add_mail_user(email, pw, privs, env):
validation = validate_privilege(p)
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
conn, c = open_database(env, with_connection=True)
@@ -300,14 +352,16 @@ def add_mail_user(email, pw, privs, env):
# add the user to the database
try:
c.execute("INSERT INTO users (email, password, privileges) VALUES (?, ?, ?)",
(email, pw, "\n".join(privs)))
c.execute("INSERT INTO users (email, password, privileges, quota) VALUES (?, ?, ?, ?)",
(email, pw, "\n".join(privs), quota))
except sqlite3.IntegrityError:
return ("User already exists.", 400)
# write databasebefore next step
conn.commit()
dovecot_quota_recalc(email)
# Update things in case any new domains are added.
return kick(env, "mail user added")
@@ -322,7 +376,7 @@ def set_mail_password(email, pw, env):
conn, c = open_database(env, with_connection=True)
c.execute("UPDATE users SET password=? WHERE email=?", (pw, email))
if c.rowcount != 1:
return ("That's not a user (%s)." % email, 400)
return (f"That's not a user ({email}).", 400)
conn.commit()
return "OK"
@@ -332,6 +386,58 @@ def hash_password(pw):
# http://wiki2.dovecot.org/Authentication/PasswordSchemes
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):
# Gets the hashed password for a user. Passwords are stored in Dovecot's
# password format, with a prefixed scheme.
@@ -341,7 +447,8 @@ def get_mail_password(email, env):
c.execute('SELECT password FROM users WHERE email=?', (email,))
rows = c.fetchall()
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]
def remove_mail_user(email, env):
@@ -349,7 +456,7 @@ def remove_mail_user(email, env):
conn, c = open_database(env, with_connection=True)
c.execute("DELETE FROM users WHERE email=?", (email,))
if c.rowcount != 1:
return ("That's not a user (%s)." % email, 400)
return (f"That's not a user ({email}).", 400)
conn.commit()
# Update things in case any domains are removed.
@@ -365,12 +472,12 @@ def get_mail_user_privileges(email, env, empty_on_error=False):
rows = c.fetchall()
if len(rows) != 1:
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])
def validate_privilege(priv):
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
def add_remove_mail_user_privilege(email, priv, action, env):
@@ -413,7 +520,7 @@ def add_mail_alias(address, forwards_to, permitted_senders, env, update_if_exist
if address == "":
return ("No email address provided.", 400)
if not validate_email(address, mode='alias'):
return ("Invalid email address (%s)." % address, 400)
return (f"Invalid email address ({address}).", 400)
# validate forwards_to
validated_forwards_to = []
@@ -442,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
privileged_email = re.sub(r"(?=\+)[^@]*(?=@)",'',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):
# Make domain control validation hijacking a little harder to mess up by
# requiring aliases for email addresses typically used in DCV to forward
@@ -462,7 +569,7 @@ def add_mail_alias(address, forwards_to, permitted_senders, env, update_if_exist
login = login.strip()
if login == "": continue
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)
# Make sure the alias has either a forwards_to or a permitted_sender.
@@ -481,10 +588,9 @@ def add_mail_alias(address, forwards_to, permitted_senders, env, update_if_exist
return_status = "alias added"
except sqlite3.IntegrityError:
if not update_if_exists:
return ("Alias already exists (%s)." % address, 400)
else:
c.execute("UPDATE aliases SET destination = ?, permitted_senders = ? WHERE source = ?", (forwards_to, permitted_senders, address))
return_status = "alias updated"
return (f"Alias already exists ({address}).", 400)
c.execute("UPDATE aliases SET destination = ?, permitted_senders = ? WHERE source = ?", (forwards_to, permitted_senders, address))
return_status = "alias updated"
conn.commit()
@@ -501,7 +607,7 @@ def remove_mail_alias(address, env, do_kick=True):
conn, c = open_database(env, with_connection=True)
c.execute("DELETE FROM aliases WHERE source=?", (address,))
if c.rowcount != 1:
return ("That's not an alias (%s)." % address, 400)
return (f"That's not an alias ({address}).", 400)
conn.commit()
if do_kick:
@@ -561,7 +667,7 @@ def kick(env, mail_result=None):
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)
required_aliases = get_required_aliases(env)
for alias in required_aliases:

View File

@@ -14,7 +14,7 @@ def get_ssl_certificates(env):
# that the certificates are good for to the best certificate for
# the domain.
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
from cryptography.hazmat.primitives.asymmetric import dsa, rsa, ec
from cryptography.x509 import Certificate
# The certificates are all stored here:
@@ -59,13 +59,13 @@ def get_ssl_certificates(env):
# Not a valid PEM format for a PEM type we care about.
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?
if isinstance(pem, Certificate):
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.
domains = { }
@@ -158,14 +158,13 @@ def get_domain_ssl_files(domain, ssl_certificates, env, allow_missing_cert=False
wildcard_domain = re.sub(r"^[^\.]+", "*", domain)
if domain in ssl_certificates:
return ssl_certificates[domain]
elif wildcard_domain in ssl_certificates:
if wildcard_domain in ssl_certificates:
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.
return system_certificate
else:
# No valid certificate is available for this domain.
return None
# No valid certificate is available for this domain.
return None
# PROVISIONING CERTIFICATES FROM LETSENCRYPT
@@ -434,7 +433,7 @@ def install_cert(domain, ssl_cert, ssl_chain, env, raw=False):
cert_status += " " + cert_status_details
return cert_status
# Copy certifiate into ssl directory.
# Copy certificate into ssl directory.
install_cert_copy_file(fn, env)
# Run post-install steps.
@@ -505,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
# 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
# The ssl_certificate file may contain a chain of certificates. We'll
@@ -516,7 +515,7 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
cert = load_pem(ssl_cert_chain[0])
if not isinstance(cert, Certificate): raise ValueError("This is not a certificate file.")
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
# the certificate.
@@ -528,8 +527,7 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
# should work in normal cases).
wildcard_domain = re.sub(r"^[^\.]+", "*", domain)
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."
% ", ".join(sorted(certificate_names)), None)
return ("The certificate is for the wrong domain name. It is for {}.".format(", ".join(sorted(certificate_names))), None)
# Second, check that the certificate matches the private key.
if ssl_private_key is not None:
@@ -539,11 +537,13 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
except ValueError as e:
return (f"The private key file {ssl_private_key} is not a private key file: {e!s}", None)
if not isinstance(priv_key, RSAPrivateKey):
return ("The private key file %s is not a private key file." % ssl_private_key, None)
if (not isinstance(priv_key, rsa.RSAPrivateKey)
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():
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
# listed in each file. The output of each command below looks like "Modulus=XXXXX".
@@ -587,34 +587,33 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
# Certificate is self-signed. Probably we detected this above.
return ("SELF-SIGNED", None)
elif retcode != 0:
if retcode != 0:
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.
return ("There is a problem with the certificate.", verifyoutput.strip())
# `openssl verify` returned a zero exit status so the cert is currently
# good.
# But is it expiring soon?
cert_expiration_date = cert.not_valid_after
ndays = (cert_expiration_date-now).days
if not rounded_time or ndays <= 10:
# Yikes better renew soon!
expiry_info = "The certificate expires in %d days on %s." % (ndays, cert_expiration_date.date().isoformat())
else:
# `openssl verify` returned a zero exit status so the cert is currently
# good.
# We'll renew it with Lets Encrypt.
expiry_info = f"The certificate expires on {cert_expiration_date.date().isoformat()}."
# But is it expiring soon?
cert_expiration_date = cert.not_valid_after
ndays = (cert_expiration_date-now).days
if not rounded_time or ndays <= 10:
# Yikes better renew soon!
expiry_info = "The certificate expires in %d days on %s." % (ndays, cert_expiration_date.date().isoformat())
else:
# We'll renew it with Lets Encrypt.
expiry_info = "The certificate expires on %s." % cert_expiration_date.date().isoformat()
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
# certificate, which occurs on day 14.
return ("The certificate is expiring soon: " + expiry_info, None)
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
# certificate, which occurs on day 14.
return ("The certificate is expiring soon: " + expiry_info, None)
# Return the special OK code.
return ("OK", expiry_info)
# Return the special OK code.
return ("OK", expiry_info)
def load_cert_chain(pemfile):
# A certificate .pem file may contain a chain of certificates.
@@ -639,7 +638,7 @@ def load_pem(pem):
msg = "File is not a valid PEM-formatted file."
raise ValueError(msg)
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())
if pem_type == b"CERTIFICATE":
return load_pem_x509_certificate(pem, default_backend())
@@ -668,13 +667,11 @@ def get_certificate_domains(cert):
def idna_decode_dns_name(dns_name):
if dns_name.startswith("*."):
return "*." + idna.encode(dns_name[2:]).decode('ascii')
else:
return idna.encode(dns_name).decode('ascii')
return idna.encode(dns_name).decode('ascii')
try:
sans = cert.extensions.get_extension_for_oid(OID_SUBJECT_ALTERNATIVE_NAME).value.get_values_for_type(DNSName)
for san in sans:
names.add(idna_decode_dns_name(san))
names.update(idna_decode_dns_name(san) for san in sans)
except ExtensionNotFound:
pass

View File

@@ -6,6 +6,7 @@
import sys, os, os.path, re, datetime, multiprocessing.pool
import asyncio
import dateutil.parser, dateutil.relativedelta, dateutil.tz
import dns.reversename, dns.resolver
import idna
@@ -17,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 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():
return [
@@ -65,24 +67,6 @@ def run_checks(rounded_values, env, output, pool, domains_to_check=None):
run_network_checks(env, output)
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):
# Check that system services are running.
all_running = True
@@ -173,6 +157,7 @@ def run_system_checks(rounded_values, env, output):
check_system_aliases(env, output)
check_free_disk_space(rounded_values, env, output)
check_free_memory(rounded_values, env, output)
check_backup(rounded_values, env, output)
def check_ufw(env, output):
if not os.path.isfile('/usr/sbin/ufw'):
@@ -206,21 +191,15 @@ def is_port_allowed(ufw, port):
return any(re.match(str(port) +"[/ \t].*", item) for item in ufw)
def check_ssh_password(env, output):
# Check that SSH login with password is disabled. The openssh-server
# package may not be installed so check that before trying to access
# the configuration file.
if not os.path.exists("/etc/ssh/sshd_config"):
return
with open("/etc/ssh/sshd_config", encoding="utf-8") as f:
sshd = f.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
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
/etc/ssh/sshd_config, and then restart the openssh via 'sudo service ssh restart'.""")
else:
output.print_ok("SSH disallows password-based login.")
config_value = get_ssh_config_value("passwordauthentication")
if config_value:
if config_value == "no":
output.print_ok("SSH disallows password-based login.")
else:
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
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'.""")
def is_reboot_needed_due_to_package_installation():
return os.path.exists("/var/run/reboot-required")
@@ -272,7 +251,7 @@ def check_free_disk_space(rounded_values, env, output):
def check_free_memory(rounded_values, env, output):
# Check free memory.
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 rounded_values: memory_msg = "System free memory is at least 20%."
output.print_ok(memory_msg)
@@ -283,6 +262,37 @@ def check_free_memory(rounded_values, env, output):
if rounded_values: memory_msg = "System free memory is below 10%."
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):
# Also see setup/network-checks.sh.
@@ -306,26 +316,45 @@ def run_network_checks(env, output):
# 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
# will not be able to reliably send mail in these cases.
# See https://www.spamhaus.org/news/article/807/using-our-public-mirrors-check-your-return-codes-now. for
# information on spamhaus return codes
rev_ip4 = ".".join(reversed(env['PUBLIC_IP'].split('.')))
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:
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]":
output.print_warning("Connection to zen.spamhaus.org timed out. Could not determine whether this box'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("Could not connect to zen.spamhaus.org. Could not determine whether this box's IP address is blacklisted. Please try again later.")
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("Incorrect spamhaus query: %s. Could not determine whether this box's IP address is blacklisted." % (rev_ip4+'.zen.spamhaus.org'))
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("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 IP address is blacklisted.")
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("Too many queries have been performed on the spamhaus server. Could not determine whether this box's IP address is blacklisted.")
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:
output.print_error("""The IP address of this machine {} is listed in the Spamhaus Block List (code {}),
which may prevent recipients from receiving your email. See http://www.spamhaus.org/query/ip/{}.""".format(env['PUBLIC_IP'], zen, env['PUBLIC_IP']))
output.print_error(f"""The {lookuptype} address of this machine {lookupaddress} is listed in the Spamhaus Block
List (code {zen}), which may prevent recipients from receiving your email. See
http://www.spamhaus.org/query/ip/{lookupaddress}.""")
def run_domain_checks(rounded_time, env, output, pool, domains_to_check=None):
# Get the list of domains we handle mail for.
@@ -483,7 +512,7 @@ def check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles):
tlsa25 = query_dns(tlsa_qname, "TLSA", nxdomain=None)
tlsa25_expected = build_tlsa_record(env)
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:
if has_dnssec:
# Omit a warning about it not being set if DNSSEC isn't enabled,
@@ -502,9 +531,9 @@ def check_alias_exists(alias_name, alias, env, output):
if mail_aliases[alias]:
output.print_ok(f"{alias_name} exists as a mail alias. [{alias}{mail_aliases[alias]}]")
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:
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):
# If a DS record is set at the registrar, check DNSSEC first because it will affect the NS query.
@@ -532,7 +561,7 @@ def check_dns_zone(domain, env, output, dns_zonefiles):
probably_external_dns = False
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:
# The domain resolves correctly, so maybe the user is using External DNS.
output.print_warning(f"""The nameservers set on this domain at your domain name registrar should be {correct_ns}. They are currently {existing_ns}.
@@ -545,31 +574,58 @@ def check_dns_zone(domain, env, output, dns_zonefiles):
# Check that each custom secondary nameserver resolves the IP address.
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:
# We must first resolve the nameserver to an IP address so we can query it.
ns_ips = query_dns(ns, "A")
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
# Choose the first IP if nameserver returns multiple
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.
ip = query_dns(domain, "A", at=ns_ip, nxdomain=None)
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:
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:
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):
# Warn if a custom DNS record is preventing this or the automatic www redirect from
# being served.
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.""")
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.
# (If it was set, we did the check earlier.)
@@ -598,11 +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
# 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.
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'))
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,
"keytag": ds_keytag,
"alg": ds_alg,
@@ -635,16 +691,16 @@ def check_dnssec(domain, env, output, dns_zonefiles, is_checking_primary=False):
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.")
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.)")
return
else: # 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).
# 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).
IMPORTANT: Do not delete existing DNSSEC 'DS' records for this domain until confirmation that the new DNSSEC 'DS' record
for this domain is valid.""")
else:
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
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
@@ -756,11 +812,11 @@ def check_mail_domain(domain, env, output):
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: %s. Could not determine whether the domain %s is blacklisted." % (domain+'.dbl.spamhaus.org', domain))
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("Mail-in-a-Box is configured to use a public DNS server. This is not supported by spamhaus. Could not determine whether the domain {} is blacklisted.".format(domain))
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("Too many queries have been performed on the spamhaus server. Could not determine whether the domain {} is blacklisted.".format(domain))
output.print_warning(f"Too many queries have been performed on the spamhaus server. Could not determine whether the domain {domain} is blacklisted.")
else:
output.print_error(f"""This domain is listed in the Spamhaus Domain Block List (code {dbl}),
which may prevent recipients from receiving your mail.
@@ -942,14 +998,14 @@ def check_miab_version(env, output):
this_ver = "Unknown"
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:
latest_ver = get_latest_miab_version()
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:
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:
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. ")
@@ -1096,7 +1152,7 @@ class ConsoleOutput(FileOutput):
class BufferedOutput:
# Record all of the instance method calls so we can play them back later.
def __init__(self, with_lines=None):
self.buf = with_lines if with_lines else []
self.buf = with_lines or []
def __getattr__(self, attr):
if attr not in {"add_heading", "print_ok", "print_error", "print_warning", "print_block", "print_line"}:
raise AttributeError

View File

@@ -73,7 +73,7 @@
filter: invert(100%) hue-rotate(180deg);
}
/* Override Boostrap theme here to give more contrast. The black turns to white by the filter. */
/* Override Bootstrap theme here to give more contrast. The black turns to white by the filter. */
.form-control {
color: black !important;
}
@@ -392,7 +392,9 @@ function api(url, method, data, callback, callback_error, headers) {
403: function(xhr) {
// Credentials are no longer valid. Try to login again.
var p = current_panel;
clear_credentials();
show_panel('login');
show_hide_menus();
switch_back_to_panel = p;
}
}
@@ -402,16 +404,21 @@ function api(url, method, data, callback, callback_error, headers) {
var current_panel = null;
var switch_back_to_panel = null;
function do_logout() {
// Clear the session from the backend.
api("/logout", "POST");
function clear_credentials() {
// Forget the token.
api_credentials = null;
if (typeof localStorage != 'undefined')
localStorage.removeItem("miab-cp-credentials");
if (typeof sessionStorage != 'undefined')
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.
show_panel('login');

View File

@@ -16,7 +16,7 @@
<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>
@@ -42,7 +42,7 @@
<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">
<tr><th>Server</th> <td>{{hostname}}</td></tr>

View File

@@ -361,7 +361,7 @@ function init_inputs(target_type) {
}
// Return a two-element array of the substring preceding and the substring following
// the first occurence of separator in string. Return [undefined, string] if the
// 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);

View File

@@ -6,6 +6,7 @@
#user_table .account_inactive .if_active { display: none; }
#user_table .account_active .if_inactive { display: none; }
#user_table .account_active.if_inactive { display: none; }
.row-center { text-align: center; }
</style>
<h3>Add a mail user</h3>
@@ -27,6 +28,10 @@
<option value="admin">Administrator</option>
</select>
</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>
</form>
<ul style="margin-top: 1em; padding-left: 1.5em; font-size: 90%;">
@@ -34,13 +39,17 @@
<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>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>
<h3>Existing mail users</h3>
<table id="user_table" class="table" style="width: auto">
<thead>
<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>
</tr>
</thead>
@@ -53,10 +62,21 @@
<tr id="user-template">
<td class='address'>
</td>
<td class="box-size row-center"></td>
<td class="percent row-center"></td>
<td class="quota row-center">
</td>
<td class='actions'>
<span class='privs'>
</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">
<a href="#" onclick="users_set_password(this); return false;" class='setpw' title="Set Password">
set password
@@ -97,10 +117,28 @@
<table class="table" style="margin-top: .5em">
<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>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><td>POST</td><td>/remove</td> <td>Removes a mail user. Required POST-body parameter is <code>email</code>.</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>. 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/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>
<h4>Examples:</h4>
@@ -133,7 +171,7 @@ function show_users() {
function(r) {
$('#user_table tbody').html("");
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);
$('#user_table tbody').append(hdr);
@@ -151,7 +189,14 @@ function show_users() {
n2.addClass("account_" + user.status);
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);
if (user.status == 'inactive') continue;
@@ -180,13 +225,15 @@ function do_add_user() {
var email = $("#adduserEmail").val();
var pw = $("#adduserPassword").val();
var privs = $("#adduserPrivs").val();
var quota = $("#adduserQuota").val();
api(
"/mail/users/add",
"POST",
{
email: email,
password: pw,
privileges: privs
privileges: privs,
quota: quota
},
function(r) {
// 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) {
var email = $(elem).parents('tr').attr('data-email');
@@ -293,7 +370,7 @@ function generate_random_password() {
var charset = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"; // confusable characters skipped
for (var i = 0; i < 12; i++)
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
}
</script>

View File

@@ -21,8 +21,7 @@ def load_env_vars_from_file(fn):
def save_environment(env):
with open("/etc/mailinabox.conf", "w", encoding="utf-8") as f:
for k, v in env.items():
f.write(f"{k}={v}\n")
f.writelines(f"{k}={v}\n" for k, v in env.items())
# THE SETTINGS FILE AT STORAGE_ROOT/settings.yaml.
@@ -123,20 +122,22 @@ def shell(method, cmd_args, env=None, capture_stderr=False, return_bytes=False,
if method == "check_output" and input is not None:
kwargs['input'] = input
if not trap:
try:
ret = getattr(subprocess, method)(cmd_args, **kwargs)
else:
try:
ret = getattr(subprocess, method)(cmd_args, **kwargs)
code = 0
except subprocess.CalledProcessError as e:
ret = e.output
code = e.returncode
code = 0
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
code = e.returncode
if not return_bytes and isinstance(ret, bytes): ret = ret.decode("utf8")
if not trap:
return ret
else:
return code, ret
return code, ret
def create_syslog_handler():
import logging.handlers
@@ -179,6 +180,35 @@ def wait_for_service(port, public, env, timeout):
return False
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__":
from web_update import get_web_domains
env = load_environment()

View File

@@ -52,7 +52,7 @@ def get_domains_with_a_records(env):
domains = set()
dns = get_custom_dns_config(env)
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)
return domains
@@ -166,7 +166,8 @@ def make_domain_config(domain, templates, ssl_certificates, env):
pass_http_host_header = False
proxy_redirect_off = False
frame_options_header_sameorigin = False
m = re.search("#(.*)$", url)
web_sockets = False
m = re.search(r"#(.*)$", url)
if m:
for flag in m.group(1).split(","):
if flag == "pass-http-host":
@@ -175,24 +176,30 @@ def make_domain_config(domain, templates, ssl_certificates, env):
proxy_redirect_off = True
elif flag == "frame-options-sameorigin":
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 += "\n\t\tproxy_pass %s;" % url
nginx_conf_extra += f"\tlocation {path} {{"
nginx_conf_extra += f"\n\t\tproxy_pass {url};"
if proxy_redirect_off:
nginx_conf_extra += "\n\t\tproxy_redirect off;"
if pass_http_host_header:
nginx_conf_extra += "\n\t\tproxy_set_header Host $http_host;"
if frame_options_header_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-Host $http_host;"
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}\n"
for path, alias in yaml.get("aliases", {}).items():
nginx_conf_extra += "\tlocation %s {" % path
nginx_conf_extra += "\n\t\talias %s;" % alias
nginx_conf_extra += f"\tlocation {path} {{"
nginx_conf_extra += f"\n\t\talias {alias};"
nginx_conf_extra += "\n\t}\n"
for path, url in yaml.get("redirects", {}).items():
nginx_conf_extra += f"\trewrite {path} {url} permanent;\n"
@@ -209,7 +216,7 @@ def make_domain_config(domain, templates, ssl_certificates, env):
# 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")
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
# Combine the pieces. Iteratively place each template into the "# ADDITIONAL DIRECTIVES HERE" placeholder
@@ -249,10 +256,9 @@ def get_web_domains_info(env):
cert_status, cert_status_details = check_certificate(domain, tls_cert["certificate"], tls_cert["private-key"])
if cert_status == "OK":
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.")
else:
return ("danger", "Certificate has a problem: " + cert_status)
return ("danger", "Certificate has a problem: " + cert_status)
return [
{

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

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

View File

@@ -10,12 +10,12 @@ source setup/functions.sh # load our functions
source /etc/mailinabox.conf # load global vars
# Install DKIM...
echo Installing OpenDKIM/OpenDMARC...
echo "Installing OpenDKIM/OpenDMARC..."
apt_install opendkim opendkim-tools opendmarc
# Make sure configuration directories exist.
mkdir -p /etc/opendkim;
mkdir -p $STORAGE_ROOT/mail/dkim
mkdir -p "$STORAGE_ROOT/mail/dkim"
# Used in InternalHosts and ExternalIgnoreList configuration directives.
# Not quite sure why.
@@ -53,12 +53,12 @@ fi
# 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.
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
# Ensure files are owned by the opendkim user and are private otherwise.
chown -R opendkim:opendkim $STORAGE_ROOT/mail/dkim
chmod go-rwx $STORAGE_ROOT/mail/dkim
chown -R opendkim:opendkim "$STORAGE_ROOT/mail/dkim"
chmod go-rwx "$STORAGE_ROOT/mail/dkim"
tools/editconf.py /etc/opendmarc.conf -s \
"Syslog=true" \
@@ -66,7 +66,7 @@ tools/editconf.py /etc/opendmarc.conf -s \
"FailureReports=false"
# 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
# used by spamassassin to evaluate the mail for spamminess.

View File

@@ -101,12 +101,12 @@ if [ ! -f "$STORAGE_ROOT/dns/dnssec/$algo.conf" ]; then
# we're capturing into the `KSK` variable.
#
# 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.
# (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
# 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
# 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`.
# (This previously used -b 1024 but it's unclear if this setting makes sense
# 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:
#
@@ -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.
# We might have multiple keys down the road. This will identify
# what keys are the current keys.
cat > $STORAGE_ROOT/dns/dnssec/$algo.conf << EOF;
cat > "$STORAGE_ROOT/dns/dnssec/$algo.conf" << EOF;
KSK=$KSK
ZSK=$ZSK
EOF
@@ -142,7 +142,7 @@ cat > /etc/cron.daily/mailinabox-dnssec << EOF;
#!/bin/bash
# Mail-in-a-Box
# Re-sign any DNS zones with DNSSEC because the signatures expire periodically.
$(pwd)/tools/dns_update
$PWD/tools/dns_update
EOF
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 [ -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.
# 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" \
"Let's create your first mail account.
\n\nWhat email address do you want?" \
me@$(get_default_hostname) \
"me@$(get_default_hostname)" \
EMAIL_ADDR
if [ -z "$EMAIL_ADDR" ]; then
@@ -22,7 +23,7 @@ if [ -z "$(management/cli.py user)" ]; then
input_box "Mail Account" \
"That's not a valid email address.
\n\nWhat email address do you want?" \
$EMAIL_ADDR \
"$EMAIL_ADDR" \
EMAIL_ADDR
if [ -z "$EMAIL_ADDR" ]; then
# user hit ESC/cancel
@@ -47,11 +48,11 @@ if [ -z "$(management/cli.py user)" ]; then
fi
# 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.
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.
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

View File

@@ -1,3 +1,4 @@
#!/bin/bash
# Turn on "strict mode." See http://redsymbol.net/articles/unofficial-bash-strict-mode/.
# -e: exit if any command unexpectedly fails.
# -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
# check the return code ourselves, disable 'set -e' temporarily.
set +e
"$@" &> $OUTPUT
"$@" &> "$OUTPUT"
E=$?
set -e
@@ -24,15 +25,15 @@ function hide_output {
if [ $E != 0 ]; then
# Something failed.
echo
echo FAILED: "$@"
echo "FAILED: $*"
echo -----------------------------------------
cat $OUTPUT
cat "$OUTPUT"
echo -----------------------------------------
exit $E
fi
# Remove temporary file.
rm -f $OUTPUT
rm -f "$OUTPUT"
}
function apt_get_quiet {
@@ -62,9 +63,9 @@ function get_default_hostname {
# Guess the machine's hostname. It should be a fully qualified
# domain name suitable for DNS. None of these calls may provide
# 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 2>/dev/null)
hostname 2>/dev/null)"
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
# 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 {
@@ -119,19 +120,19 @@ function get_default_privateip {
if [ "$1" == "6" ]; then target=2001:4860:4860::8888; fi
# 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.
address=$(echo $route | sed "s/.* src \([^ ]*\).*/\1/")
address=$(echo "$route" | sed "s/.* src \([^ ]*\).*/\1/")
if [[ "$1" == "6" && $address == fe80:* ]]; then
# For IPv6 link-local addresses, parse the interface out
# 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
fi
echo $address
echo "$address"
}
function ufw_allow {
@@ -149,7 +150,7 @@ function ufw_limit {
}
function restart_service {
hide_output service $1 restart
hide_output service "$1" restart
}
## Dialog Functions ##
@@ -178,7 +179,7 @@ function input_menu {
declare -n result_code=$4_EXITCODE
local IFS=^$'\n'
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=$?
set -e
}
@@ -190,17 +191,17 @@ function wget_verify {
HASH=$2
DEST=$3
CHECKSUM="$HASH $DEST"
rm -f $DEST
hide_output wget -O $DEST $URL
rm -f "$DEST"
hide_output wget -O "$DEST" "$URL"
if ! echo "$CHECKSUM" | sha1sum --check --strict > /dev/null; then
echo "------------------------------------------------------------"
echo "Download of $URL did not match expected checksum."
echo "Found:"
sha1sum $DEST
sha1sum "$DEST"
echo
echo "Expected:"
echo "$CHECKSUM"
rm -f $DEST
rm -f "$DEST"
exit 1
fi
}
@@ -216,9 +217,9 @@ function git_clone {
SUBDIR=$3
TARGETPATH=$4
TMPPATH=/tmp/git-clone-$$
rm -rf $TMPPATH $TARGETPATH
git clone -q $REPO $TMPPATH || exit 1
(cd $TMPPATH; git checkout -q $TREEISH;) || exit 1
mv $TMPPATH/$SUBDIR $TARGETPATH
rm -rf $TMPPATH "$TARGETPATH"
git clone -q "$REPO" $TMPPATH || exit 1
(cd $TMPPATH; git checkout -q "$TREEISH";) || exit 1
mv $TMPPATH/"$SUBDIR" "$TARGETPATH"
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/2011-December/132455.html
tools/editconf.py /etc/dovecot/conf.d/10-master.conf \
default_process_limit=$(echo "$(nproc) * 250" | bc) \
default_vsz_limit=$(echo "$(free -tm | tail -1 | awk '{print $2}') / 3" | bc)M \
default_process_limit="$(($(nproc) * 250))" \
default_vsz_limit="$(($(free -tm | tail -1 | awk '{print $2}') / 3))M" \
log_path=/var/log/mail.log
# 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
# are created within the management daemon.
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 \
first_valid_uid=0
# 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
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
@@ -152,7 +178,7 @@ EOF
# Setting a `postmaster_address` is required or LMTP won't start. An alias
# will be created automatically by our management daemon.
tools/editconf.py /etc/dovecot/conf.d/15-lda.conf \
postmaster_address=postmaster@$PRIMARY_HOSTNAME
"postmaster_address=postmaster@$PRIMARY_HOSTNAME"
# ### Sieve
@@ -201,14 +227,14 @@ chown -R mail:dovecot /etc/dovecot
chmod -R o-rwx /etc/dovecot
# Ensure mailbox files have a directory that exists and are owned by the mail user.
mkdir -p $STORAGE_ROOT/mail/mailboxes
chown -R mail:mail $STORAGE_ROOT/mail/mailboxes
mkdir -p "$STORAGE_ROOT/mail/mailboxes"
chown -R mail:mail "$STORAGE_ROOT/mail/mailboxes"
# Same for the sieve scripts.
mkdir -p $STORAGE_ROOT/mail/sieve
mkdir -p $STORAGE_ROOT/mail/sieve/global_before
mkdir -p $STORAGE_ROOT/mail/sieve/global_after
chown -R mail:mail $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_after"
chown -R mail:mail "$STORAGE_ROOT/mail/sieve"
# Allow the IMAP/POP ports in the firewall.
ufw_allow imaps

View File

@@ -37,7 +37,7 @@ source /etc/mailinabox.conf # load global vars
# * `postfix`: The SMTP server.
# * `postfix-pcre`: Enables header filtering.
# * `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.
# * `ca-certificates`: A trust store used to squelch postfix warnings about
# 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).
tools/editconf.py /etc/postfix/main.cf \
inet_interfaces=all \
smtp_bind_address=$PRIVATE_IP \
smtp_bind_address6=$PRIVATE_IPV6 \
myhostname=$PRIMARY_HOSTNAME\
smtp_bind_address="$PRIVATE_IP" \
smtp_bind_address6="$PRIVATE_IPV6" \
myhostname="$PRIMARY_HOSTNAME"\
smtpd_banner="\$myhostname ESMTP Hi, I'm a Mail-in-a-Box (Ubuntu/Postfix; see https://mailinabox.email/)" \
mydestination=localhost
@@ -70,10 +70,16 @@ tools/editconf.py /etc/postfix/main.cf \
bounce_queue_lifetime=1d
# Guard against SMTP smuggling
# This short-term workaround is recommended at https://www.postfix.org/smtp-smuggling.html
# 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_data_restrictions=reject_unauth_pipelining \
smtpd_discard_ehlo_keywords="chunking, silent-discard"
smtpd_forbid_bare_newline=normalize
# ### Outgoing Mail
@@ -132,9 +138,9 @@ sed -i "s/PUBLIC_IP/$PUBLIC_IP/" /etc/postfix/outgoing_mail_header_filters
tools/editconf.py /etc/postfix/main.cf \
smtpd_tls_security_level=may\
smtpd_tls_auth_only=yes \
smtpd_tls_cert_file=$STORAGE_ROOT/ssl/ssl_certificate.pem \
smtpd_tls_key_file=$STORAGE_ROOT/ssl/ssl_private_key.pem \
smtpd_tls_dh1024_param_file=$STORAGE_ROOT/ssl/dh2048.pem \
smtpd_tls_cert_file="$STORAGE_ROOT/ssl/ssl_certificate.pem" \
smtpd_tls_key_file="$STORAGE_ROOT/ssl/ssl_private_key.pem" \
smtpd_tls_dh1024_param_file="$STORAGE_ROOT/ssl/dh2048.pem" \
smtpd_tls_protocols="!SSLv2,!SSLv3" \
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 \
@@ -166,7 +172,7 @@ tools/editconf.py /etc/postfix/main.cf \
# 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
# accept whatever SSL certificate the remote end provides. Opportunistic TLS
# protects against passive easvesdropping (but not man-in-the-middle attacks).
@@ -182,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
# 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,
# 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`.
@@ -224,7 +230,7 @@ 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.
# * `check_policy_service`: Apply greylisting using postgrey.
#
# Note the spamhaus rbl return codes are taken into account as adviced here: https://docs.spamhaus.com/datasets/docs/source/40-real-world-usage/PublicMirrors/MTAs/020-Postfix.html
# 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
# 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
@@ -232,7 +238,7 @@ tools/editconf.py /etc/postfix/main.cf -e lmtp_destination_recipient_limit=
# "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 \
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=127.0.0.[2..11],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
# Postgrey listens on the same interface (and not IPv6, for instance).
@@ -241,7 +247,7 @@ tools/editconf.py /etc/postfix/main.cf \
# other MTA have their own intervals. To fix the problem of receiving
# e-mails really latter, delay of greylisting has been set to
# 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
# 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
@@ -254,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 [ ! -d $STORAGE_ROOT/mail/postgrey/db ]; then
if [ ! -d "$STORAGE_ROOT/mail/postgrey/db" ]; then
# Stop the service
service postgrey stop
# 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
mv /var/lib/postgrey/* $STORAGE_ROOT/mail/postgrey/db/ || true
mv /var/lib/postgrey/* "$STORAGE_ROOT/mail/postgrey/db/" || true
fi
# Ensure permissions are set
chown -R postgrey:postgrey $STORAGE_ROOT/mail/postgrey/
chmod 700 $STORAGE_ROOT/mail/postgrey/{,db}
chown -R postgrey:postgrey "$STORAGE_ROOT/mail/postgrey/"
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
cat > /etc/cron.daily/mailinabox-postgrey-whitelist << EOF;

View File

@@ -5,7 +5,7 @@
#
# This script configures user authentication for Dovecot
# 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 /etc/mailinabox.conf # load global vars
@@ -18,12 +18,12 @@ source /etc/mailinabox.conf # load global vars
db_path=$STORAGE_ROOT/mail/users.sqlite
# Create an empty database if it doesn't yet exist.
if [ ! -f $db_path ]; then
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 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 auto_aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);" | sqlite3 $db_path;
if [ ! -f "$db_path" ]; then
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 '', 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 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";
fi
# ### User Authentication
@@ -51,7 +51,7 @@ driver = sqlite
connect = $db_path
default_pass_scheme = SHA512-CRYPT
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;
EOF
chmod 0600 /etc/dovecot/dovecot-sql.conf.ext # per Dovecot instructions
@@ -159,4 +159,5 @@ EOF
restart_service postfix
restart_service dovecot
# force a recalculation of all user quotas
doveadm quota recalc -A

View File

@@ -52,9 +52,9 @@ hide_output $venv/bin/pip install --upgrade \
# CONFIGURATION
# Create a backup directory and a random key for encrypting backups.
mkdir -p $STORAGE_ROOT/backup
if [ ! -f $STORAGE_ROOT/backup/secret_key.txt ]; then
$(umask 077; openssl rand -base64 2048 > $STORAGE_ROOT/backup/secret_key.txt)
mkdir -p "$STORAGE_ROOT/backup"
if [ ! -f "$STORAGE_ROOT/backup/secret_key.txt" ]; then
(umask 077; openssl rand -base64 2048 > "$STORAGE_ROOT/backup/secret_key.txt")
fi
@@ -100,7 +100,7 @@ tr -cd '[:xdigit:]' < /dev/urandom | head -c 32 > /var/lib/mailinabox/api.key
chmod 640 /var/lib/mailinabox/api.key
source $venv/bin/activate
export PYTHONPATH=$(pwd)/management
export PYTHONPATH=$PWD/management
exec gunicorn -b localhost:10222 -w 1 --timeout 630 wsgi:app
EOF
chmod +x $inst_dir/start
@@ -116,7 +116,7 @@ minute=$((RANDOM % 60)) # avoid overloading mailinabox.email
cat > /etc/cron.d/mailinabox-nightly << EOF;
# Mail-in-a-Box --- Do not edit / will be overwritten on update.
# 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
# Start the management server.

View File

@@ -23,7 +23,7 @@ def migration_1(env):
# Migrate the 'domains' directory.
for sslfn in glob.glob(os.path.join( env["STORAGE_ROOT"], 'ssl/domains/*' )):
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:
# get the new name for the file
domain_name, file_type = m.groups()
@@ -164,7 +164,7 @@ def migration_12(env):
try:
table = table[0]
c = conn.cursor()
dropcmd = "DROP TABLE %s" % table
dropcmd = f"DROP TABLE {table}"
c.execute(dropcmd)
except:
print("Failed to drop table", table)
@@ -172,7 +172,7 @@ def migration_12(env):
conn.commit()
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
conn = sqlite3.connect(os.path.join(env["STORAGE_ROOT"], "mail/roundcube/roundcube.sqlite"))
c = conn.cursor()
@@ -190,6 +190,12 @@ def migration_14(env):
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);"])
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():

View File

@@ -40,7 +40,7 @@ chown munin /var/log/munin/munin-cgi-graph.log
# ensure munin-node knows the name of this machine
# and reduce logging level to warning
tools/editconf.py /etc/munin/munin-node.conf -s \
host_name=$PRIMARY_HOSTNAME \
host_name="$PRIMARY_HOSTNAME" \
log_level=1
# 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.
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 ! grep -qFx up /sys/class/net/$IF/operstate 2>/dev/null; then
rm $f;
IF=$(echo "$f" | sed s/.*_//);
if ! grep -qFx up "/sys/class/net/$IF/operstate" 2>/dev/null; then
rm "$f";
fi;
done
@@ -62,7 +62,7 @@ done
mkdir -p /var/lib/munin-node/plugin-state/
# 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
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

View File

@@ -1,3 +1,4 @@
#!/bin/bash
# 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.
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
# and will not be able to reliably send mail. Do this after any automatic
# 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 "The hostname you chose '$PRIMARY_HOSTNAME' is listed in the"
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
# 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.
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
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
echo
echo "The IP address $PUBLIC_IP is listed in the Spamhaus Block List."
echo "See http://www.spamhaus.org/query/ip/$PUBLIC_IP."

View File

@@ -21,8 +21,8 @@ echo "Installing Nextcloud (contacts/calendar)..."
# 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
# copying it from the error message when it doesn't match what is below.
nextcloud_ver=26.0.12
nextcloud_hash=b55e9f51171c0a9b9ab3686cf5c8ad1a4292ca15
nextcloud_ver=26.0.13
nextcloud_hash=d5c10b650e5396d5045131c6d22c02a90572527c
# Nextcloud apps
# --------------
@@ -40,12 +40,12 @@ contacts_ver=5.5.3
contacts_hash=799550f38e46764d90fa32ca1a6535dccd8316e5
# Always ensure the versions are supported, see https://apps.nextcloud.com/apps/calendar
calendar_ver=4.6.6
calendar_hash=e34a71669a52d997e319d64a984dcd041389eb22
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.2.0
user_external_hash=a494073dcdecbbbc79a9c77f72524ac9994d2eec
user_external_ver=3.3.0
user_external_hash=280d24eb2a6cb56b4590af8847f925c28d8d853e
# Developer advice (test plan)
# ----------------------------
@@ -65,13 +65,13 @@ user_external_hash=a494073dcdecbbbc79a9c77f72524ac9994d2eec
# Clear prior packages and install dependencies from apt.
apt-get purge -qq -y owncloud* # we used to use the package manager
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}-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
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}"-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
# 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.enable_cli=1
@@ -91,7 +91,7 @@ InstallNextcloud() {
echo
# 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
rm -rf /usr/local/lib/owncloud
@@ -105,18 +105,18 @@ InstallNextcloud() {
# their github repositories.
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/
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/
rm /tmp/calendar.tgz
# Starting with Nextcloud 15, the app user_external is no longer included in Nextcloud core,
# we will install from their github repository.
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/
rm /tmp/user_external.tgz
fi
@@ -126,33 +126,35 @@ InstallNextcloud() {
# 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).
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.
# $STORAGE_ROOT/owncloud may not yet exist, so use -f to suppress
# 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.
# 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
# that can be OK.
sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/occ upgrade
if [ \( $? -ne 0 \) -a \( $? -ne 3 \) ]; then
sudo -u www-data php"$PHP_VER" /usr/local/lib/owncloud/occ upgrade
E=$?
if [ $E -ne 0 ] && [ $E -ne 3 ]; then
echo "Trying ownCloud upgrade again to work around ownCloud upgrade bug..."
sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/occ upgrade
if [ \( $? -ne 0 \) -a \( $? -ne 3 \) ]; then exit 1; fi
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 upgrade
E=$?
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."
fi
# 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-primary-keys
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.
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
}
@@ -164,7 +166,7 @@ InstallNextcloud() {
# If config.php exists, get version number, otherwise CURRENT_NEXTCLOUD_VER is empty.
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
CURRENT_NEXTCLOUD_VER=""
fi
@@ -174,7 +176,7 @@ fi
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.
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.
# Create a backup directory to store the current installation and database to
@@ -184,21 +186,21 @@ 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..."
cp -r /usr/local/lib/owncloud "$BACKUP_DIRECTORY/owncloud-install"
fi
if [ -e $STORAGE_ROOT/owncloud/owncloud.db ]; then
cp $STORAGE_ROOT/owncloud/owncloud.db $BACKUP_DIRECTORY
if [ -e "$STORAGE_ROOT/owncloud/owncloud.db" ]; then
cp "$STORAGE_ROOT/owncloud/owncloud.db" "$BACKUP_DIRECTORY"
fi
if [ -e $STORAGE_ROOT/owncloud/config.php ]; then
cp $STORAGE_ROOT/owncloud/config.php $BACKUP_DIRECTORY
if [ -e "$STORAGE_ROOT/owncloud/config.php" ]; then
cp "$STORAGE_ROOT/owncloud/config.php" "$BACKUP_DIRECTORY"
fi
# 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
# PHP 7.
if [ -e $STORAGE_ROOT/owncloud/config.php ]; then
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
sed -i -e '/config_is_read_only/d' "$STORAGE_ROOT/owncloud/config.php"
fi
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^[89] ]]; then
@@ -246,13 +248,13 @@ fi
# Setup Nextcloud if the Nextcloud database does not yet exist. Running setup when
# 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
mkdir -p $STORAGE_ROOT/owncloud
mkdir -p "$STORAGE_ROOT/owncloud"
# Create an initial configuration file.
instanceid=oc$(echo $PRIMARY_HOSTNAME | sha1sum | fold -w 10 | head -n 1)
cat > $STORAGE_ROOT/owncloud/config.php <<EOF;
instanceid=oc$(echo "$PRIMARY_HOSTNAME" | sha1sum | fold -w 10 | head -n 1)
cat > "$STORAGE_ROOT/owncloud/config.php" <<EOF;
<?php
\$CONFIG = array (
'datadirectory' => '$STORAGE_ROOT/owncloud',
@@ -272,15 +274,6 @@ if [ ! -f $STORAGE_ROOT/owncloud/owncloud.db ]; then
),
),
'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
@@ -305,12 +298,12 @@ EOF
EOF
# 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.
# It also wipes it if it exists. And it updates config.php with database
# 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
# Update config.php.
@@ -326,7 +319,7 @@ fi
# Use PHP to read the settings file, modify it, and write out the new settings array.
TIMEZONE=$(cat /etc/timezone)
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
include("$STORAGE_ROOT/owncloud/config.php");
@@ -336,13 +329,10 @@ include("$STORAGE_ROOT/owncloud/config.php");
\$CONFIG['memcache.local'] = '\OC\Memcache\APCu';
\$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['logdateformat'] = 'Y-m-d H:i:s';
\$CONFIG['mail_domain'] = '$PRIMARY_HOSTNAME';
\$CONFIG['user_backends'] = array(
array(
'class' => '\OCA\UserExternal\IMAP',
@@ -352,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 = ";
var_export(\$CONFIG);
echo ";";
?>
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.
# The firstrunwizard gave Josh all sorts of problems, so disabling that.
# user_external is what allows Nextcloud to use IMAP for login. The contacts
# 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: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 calendar
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 contacts
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
# 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).
sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/occ upgrade
if [ \( $? -ne 0 \) -a \( $? -ne 3 \) ]; then exit 1; fi
sudo -u www-data php"$PHP_VER" /usr/local/lib/owncloud/occ upgrade
E=$?
if [ $E -ne 0 ] && [ $E -ne 3 ]; then exit 1; fi
# Disable default apps that we don't support
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)
# Set PHP FPM values to support large file uploads
# (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 \
post_max_size=16G \
output_buffering=16384 \
@@ -390,7 +391,7 @@ tools/editconf.py /etc/php/$PHP_VER/fpm/php.ini -c ';' \
short_open_tag=On
# 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_cli=1 \
opcache.interned_strings_buffer=8 \
@@ -404,7 +405,7 @@ tools/editconf.py /etc/php/$PHP_VER/cli/conf.d/10-opcache.ini -c ';' \
# 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
# 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 general cron job for Nextcloud.
# Also add another job for Calendar updates, per advice in the Nextcloud docs
@@ -412,18 +413,18 @@ sqlite3 $STORAGE_ROOT/owncloud/owncloud.db "UPDATE oc_users_external SET backend
cat > /etc/cron.d/mailinabox-nextcloud << EOF;
#!/bin/bash
# Mail-in-a-Box
*/5 * * * * root sudo -u www-data php$PHP_VER -f /usr/local/lib/owncloud/cron.php
*/5 * * * * root sudo -u www-data php$PHP_VER -f /usr/local/lib/owncloud/occ dav:send-event-reminders
*/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
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
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
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
@@ -448,4 +449,4 @@ EOF
# ```
# Enable PHP modules and restart PHP.
restart_service php$PHP_VER-fpm
restart_service php"$PHP_VER"-fpm

View File

@@ -8,11 +8,14 @@ if [[ $EUID -ne 0 ]]; then
exit 1
fi
# Check that we are running on Ubuntu 20.04 LTS (or 20.04.xx).
if [ "$( lsb_release --id --short )" != "Ubuntu" ] || [ "$( lsb_release --release --short )" != "22.04" ]; then
# Check that we are running on Ubuntu 22.04 LTS (or 22.04.xx).
# 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
lsb_release --description --short
echo "${OS_RELEASE_ID:-"Unknown linux distribution"} ${OS_RELEASE_VERSION_ID:-}"
echo
echo "We can't write scripts that run on every possible setup, sorry."
exit 1

View File

@@ -1,3 +1,4 @@
#!/bin/bash
if [ -z "${NONINTERACTIVE:-}" ]; then
# 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,
@@ -7,7 +8,7 @@ if [ -z "${NONINTERACTIVE:-}" ]; then
#
# Also install dependencies needed to validate the email address.
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_quiet install dialog python3 python3-pip || exit 1
fi
@@ -31,7 +32,7 @@ if [ -z "${PRIMARY_HOSTNAME:-}" ]; then
# domain the user possibly wants to use is example.com then.
# We strip the string "box." from the hostname to get the mail
# 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
# provide the best default for the box's hostname.
@@ -55,7 +56,7 @@ you really want.
do
input_box "Your Email Address" \
"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
if [ -z "$EMAIL_ADDR" ]; then
# 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
# '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
input_box "Hostname" \
@@ -74,7 +75,7 @@ you really want.
address, so we're suggesting $DEFAULT_PRIMARY_HOSTNAME.
\n\nYou can change it, but we recommend you don't.
\n\nHostname:" \
$DEFAULT_PRIMARY_HOSTNAME \
"$DEFAULT_PRIMARY_HOSTNAME" \
PRIMARY_HOSTNAME
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
# 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
# Otherwise on the first run at least provide a default.
@@ -109,7 +110,7 @@ if [ -z "${PUBLIC_IP:-}" ]; then
input_box "Public IP Address" \
"Enter the public IP address of this machine, as given to you by your ISP.
\n\nPublic IP address:" \
${DEFAULT_PUBLIC_IP:-} \
"${DEFAULT_PUBLIC_IP:-}" \
PUBLIC_IP
if [ -z "$PUBLIC_IP" ]; then
@@ -125,7 +126,7 @@ if [ -z "${PUBLIC_IPV6:-}" ]; then
# Ask the Internet.
GUESSED_IP=$(get_publicip_from_web_service 6)
MATCHED=0
if [[ -z "${DEFAULT_PUBLIC_IPV6:-}" && ! -z "$GUESSED_IP" ]]; then
if [[ -z "${DEFAULT_PUBLIC_IPV6:-}" && -n "$GUESSED_IP" ]]; then
PUBLIC_IPV6=$GUESSED_IP
elif [[ "${DEFAULT_PUBLIC_IPV6:-}" == "$GUESSED_IP" ]]; then
# 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.
\n\nLeave blank if the machine does not have an IPv6 address.
\n\nPublic IPv6 address:" \
${DEFAULT_PUBLIC_IPV6:-} \
"${DEFAULT_PUBLIC_IPV6:-}" \
PUBLIC_IPV6
if [ ! $PUBLIC_IPV6_EXITCODE ]; then
if [ ! -n "$PUBLIC_IPV6_EXITCODE" ]; then
# user hit ESC/cancel
exit
fi
@@ -162,7 +163,7 @@ if [ -z "${PRIVATE_IPV6:-}" ]; then
fi
if [[ -z "$PRIVATE_IP" && -z "$PRIVATE_IPV6" ]]; then
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
hostname -I
@@ -197,7 +198,7 @@ fi
echo
echo "Primary Hostname: $PRIMARY_HOSTNAME"
echo "Public IP Address: $PUBLIC_IP"
if [ ! -z "$PUBLIC_IPV6" ]; then
if [ -n "$PUBLIC_IPV6" ]; then
echo "Public IPv6 Address: $PUBLIC_IPV6"
fi
if [ "$PRIVATE_IP" != "$PUBLIC_IP" ]; then
@@ -207,6 +208,6 @@ if [ "$PRIVATE_IPV6" != "$PUBLIC_IPV6" ]; then
echo "Private IPv6 Address: $PRIVATE_IPV6"
fi
if [ -f /usr/bin/git ] && [ -d .git ]; then
echo "Mail-in-a-Box Version: " $(git describe --always)
echo "Mail-in-a-Box Version: $(git describe --always)"
fi
echo

View File

@@ -53,7 +53,7 @@ tools/editconf.py /etc/default/spampd \
# Spamassassin normally wraps spam as an attachment inside a fresh
# 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,
# 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.
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
mkdir -p $STORAGE_ROOT/mail/spamassassin
chown -R spampd:spampd $STORAGE_ROOT/mail/spamassassin
mkdir -p "$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
# 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,
# ensure they are group-writable so that the Dovecot process has access.
sudo -u spampd /usr/bin/sa-learn --sync 2>/dev/null
chmod -R 660 $STORAGE_ROOT/mail/spamassassin
chmod 770 $STORAGE_ROOT/mail/spamassassin
chmod -R 660 "$STORAGE_ROOT/mail/spamassassin"
chmod 770 "$STORAGE_ROOT/mail/spamassassin"
# Initial training?
# 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.
if [ ! -f /usr/bin/openssl ] \
|| [ ! -f $STORAGE_ROOT/ssl/ssl_private_key.pem ] \
|| [ ! -f $STORAGE_ROOT/ssl/ssl_certificate.pem ] \
|| [ ! -f $STORAGE_ROOT/ssl/dh2048.pem ]; then
|| [ ! -f "$STORAGE_ROOT/ssl/ssl_private_key.pem" ] \
|| [ ! -f "$STORAGE_ROOT/ssl/ssl_certificate.pem" ] \
|| [ ! -f "$STORAGE_ROOT/ssl/dh2048.pem" ]; then
echo "Creating initial SSL certificate and perfect forward secrecy Diffie-Hellman parameters..."
fi
@@ -38,7 +38,7 @@ apt_install openssl
# 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.
#
@@ -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
# 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.
(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
# 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
# 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.
CSR=/tmp/ssl_cert_sign_req-$$.csr
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"
# Generate the self-signed certificate.
CERT=$STORAGE_ROOT/ssl/$PRIMARY_HOSTNAME-selfsigned-$(date --rfc-3339=date | sed s/-//g).pem
hide_output \
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.
rm -f $CSR
# Symlink the certificate into the system certificate path, so system services
# can find it.
ln -s $CERT $STORAGE_ROOT/ssl/ssl_certificate.pem
ln -s "$CERT" "$STORAGE_ROOT/ssl/ssl_certificate.pem"
fi
# Generate some Diffie-Hellman cipher bits.
# openssl's default bit length for this is 1024 bits, but we'll create
# 2048 bits of bits per the latest recommendations.
if [ ! -f $STORAGE_ROOT/ssl/dh2048.pem ]; then
openssl dhparam -out $STORAGE_ROOT/ssl/dh2048.pem 2048
if [ ! -f "$STORAGE_ROOT/ssl/dh2048.pem" ]; then
openssl dhparam -out "$STORAGE_ROOT/ssl/dh2048.pem" 2048
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.
cat > /usr/local/bin/mailinabox << EOF;
#!/bin/bash
cd $(pwd)
cd $PWD
source setup/start.sh
EOF
chmod +x /usr/local/bin/mailinabox
@@ -75,17 +75,17 @@ fi
# 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
# migration number for this version of Mail-in-a-Box.
if ! id -u $STORAGE_USER >/dev/null 2>&1; then
useradd -m $STORAGE_USER
if ! id -u "$STORAGE_USER" >/dev/null 2>&1; then
useradd -m "$STORAGE_USER"
fi
if [ ! -d $STORAGE_ROOT ]; then
mkdir -p $STORAGE_ROOT
if [ ! -d "$STORAGE_ROOT" ]; then
mkdir -p "$STORAGE_ROOT"
fi
f=$STORAGE_ROOT
while [[ $f != / ]]; do chmod a+rx "$f"; f=$(dirname "$f"); done;
if [ ! -f $STORAGE_ROOT/mailinabox.version ]; then
setup/migrate.py --current > $STORAGE_ROOT/mailinabox.version
chown $STORAGE_USER:$STORAGE_USER $STORAGE_ROOT/mailinabox.version
if [ ! -f "$STORAGE_ROOT/mailinabox.version" ]; then
setup/migrate.py --current > "$STORAGE_ROOT/mailinabox.version"
chown "$STORAGE_USER:$STORAGE_USER" "$STORAGE_ROOT/mailinabox.version"
fi
# 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...
until nc -z -w 4 127.0.0.1 10222
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
done
@@ -142,41 +142,41 @@ source setup/firstuser.sh
# 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
# 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 "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 "agreeing you to their subscriber agreement. See https://letsencrypt.org."
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
# Done.
echo
echo "-----------------------------------------------"
echo
echo Your Mail-in-a-Box is running.
echo "Your Mail-in-a-Box is running."
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
if management/status_checks.py --check-primary-hostname; then
# 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 "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:"
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=//i"
else
echo https://$PUBLIC_IP/admin
echo "https://$PUBLIC_IP/admin"
echo
echo You will be alerted that the website has an invalid certificate. Check that
echo the certificate fingerprint matches:
echo "You will be alerted that the website has an invalid certificate. Check that"
echo "the certificate fingerprint matches:"
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=//i"
echo
echo Then you can confirm the security exception and continue.
echo "Then you can confirm the security exception and continue."
echo
fi

View File

@@ -1,3 +1,4 @@
#!/bin/bash
source /etc/mailinabox.conf
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
echo $PRIMARY_HOSTNAME > /etc/hostname
hostname $PRIMARY_HOSTNAME
echo "$PRIMARY_HOSTNAME" > /etc/hostname
hostname "$PRIMARY_HOSTNAME"
# ### 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 a swapfile already exists
# - 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 available diskspace
@@ -53,14 +54,14 @@ if
[ -z "$SWAP_IN_FSTAB" ] &&
[ ! -e /swapfile ] &&
[ -z "$ROOT_IS_BTRFS" ] &&
[ $TOTAL_PHYSICAL_MEM -lt 1900000 ] &&
[ $AVAILABLE_DISK_SPACE -gt 5242880 ]
[ "$TOTAL_PHYSICAL_MEM" -lt 1900000 ] &&
[ "$AVAILABLE_DISK_SPACE" -gt 5242880 ]
then
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
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
chmod 600 /swapfile
hide_output mkswap /swapfile
@@ -82,6 +83,15 @@ fi
# (See https://discourse.mailinabox.email/t/journalctl-reclaim-space-on-small-mailinabox/6728/11.)
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.
# 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
# of things from Ubuntu, as well as the directory of packages provide by the
# PPAs so we can install those packages later.
# --allow-releaseinfo-change is added because ppa:ondrej/php changed its Label.
echo Updating system packages...
hide_output apt-get update
echo "Updating system packages..."
hide_output apt-get update --allow-releaseinfo-change
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
@@ -135,7 +146,7 @@ apt_get_quiet autoremove
# * bc: allows us to do math to compute sane defaults
# * openssh-client: provides ssh-keygen
echo Installing system packages...
echo "Installing system packages..."
apt_install python3 python3-dev python3-pip python3-setuptools \
netcat-openbsd wget curl git sudo coreutils bc file \
pollinate openssh-client unzip \
@@ -164,7 +175,7 @@ fi
# not likely the user will want to change this, so we only ask on first
# setup.
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
# Mail-in-a-Box setup, run the interactive timezone configuration
# tool.
@@ -217,7 +228,7 @@ fi
# 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
# 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
# a perfect-storm circumstance where the other seeds are meaningless, /dev/urandom
# may not be seeded at all.
@@ -226,7 +237,7 @@ fi
# hardware entropy to get going, by drawing from /dev/random. haveged makes this
# 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
# 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
# settings, find the port it is supposedly running on, and open that port #NODOC
# too. #NODOC
SSH_PORT=$(sshd -T 2>/dev/null | grep "^port " | sed "s/port //") #NODOC
if [ ! -z "$SSH_PORT" ]; then
if [ "$SSH_PORT" != "22" ]; then
echo Opening alternate SSH port $SSH_PORT. #NODOC
ufw_limit $SSH_PORT #NODOC
fi
SSH_PORT=$(sshd -T 2>/dev/null | grep "^port " | sed "s/port //" | tr '\n' ' ') #NODOC
if [ -n "$SSH_PORT" ]; then
for port in $SSH_PORT; do
if [ "$port" != "22" ]; then
echo "Opening alternate SSH port $port." #NODOC
ufw_limit "$port" #NODOC
fi
done
fi
ufw --force enable;

View File

@@ -6,9 +6,9 @@ source setup/functions.sh # load our functions
source /etc/mailinabox.conf # load global vars
# 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
echo Removing apache...
echo "Removing apache..."
hide_output apt-get -y purge apache2 apache2-*
hide_output apt-get -y --purge autoremove
fi
@@ -19,7 +19,7 @@ fi
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
@@ -46,15 +46,15 @@ tools/editconf.py /etc/nginx/nginx.conf -s \
ssl_protocols="TLSv1.2 TLSv1.3;"
# 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
# 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"
# 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 \
# 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.
# 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)
if [ $TOTAL_PHYSICAL_MEM -lt 1000000 ]
if [ "$TOTAL_PHYSICAL_MEM" -lt 1000000 ]
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.max_children=8 \
pm.start_servers=2 \
pm.min_spare_servers=1 \
pm.max_spare_servers=3
elif [ $TOTAL_PHYSICAL_MEM -lt 2000000 ]
elif [ "$TOTAL_PHYSICAL_MEM" -lt 2000000 ]
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.max_children=16 \
pm.start_servers=4 \
pm.min_spare_servers=1 \
pm.max_spare_servers=6
elif [ $TOTAL_PHYSICAL_MEM -lt 3000000 ]
elif [ "$TOTAL_PHYSICAL_MEM" -lt 3000000 ]
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.max_children=60 \
pm.start_servers=6 \
pm.min_spare_servers=3 \
pm.max_spare_servers=9
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.max_children=120 \
pm.start_servers=12 \
@@ -138,16 +138,16 @@ cat conf/mta-sts.txt \
chmod a+r /var/lib/mailinabox/mta-sts.txt
# make a default homepage
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
if [ ! -f $STORAGE_ROOT/www/default/index.html ]; then
cp conf/www_default.html $STORAGE_ROOT/www/default/index.html
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"
if [ ! -f "$STORAGE_ROOT/www/default/index.html" ]; then
cp conf/www_default.html "$STORAGE_ROOT/www/default/index.html"
fi
chown -R $STORAGE_USER $STORAGE_ROOT/www
chown -R "$STORAGE_USER" "$STORAGE_ROOT/www"
# Start services.
restart_service nginx
restart_service php$PHP_VER-fpm
restart_service php"$PHP_VER"-fpm
# Open ports.
ufw_allow http

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

@@ -22,8 +22,8 @@ source /etc/mailinabox.conf # load global vars
echo "Installing Roundcube (webmail)..."
apt_install \
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}-gd php${PHP_VER}-pspell php${PHP_VER}-mbstring libjs-jquery libjs-jquery-mousewheel libmagic1 \
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 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.
@@ -36,8 +36,8 @@ apt_install \
# 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 error message.
VERSION=1.6.6
HASH=7705d2736890c49e7ae3ac75e3ae00ba56187056
VERSION=1.6.11
HASH=d72da06b5f65142dab8b574f7676e0220541a3d4
PERSISTENT_LOGIN_VERSION=bde7b6840c7d91de627ea14e81cf4133cbb3c07a # version 5.3
HTML5_NOTIFIER_VERSION=68d9ca194212e15b3c7225eb6085dbcf02fd13d7 # version 0.6.4+
CARDDAV_VERSION=4.4.3
@@ -145,6 +145,7 @@ cat > $RCM_CONFIG <<EOF;
\$config['session_path'] = '/mail/';
/* prevent CSRF, requires php 7.3+ */
\$config['session_samesite'] = 'Strict';
\$config['quota_zero_as_unlimited'] = true;
?>
EOF
@@ -170,8 +171,8 @@ cat > ${RCM_PLUGIN_DIR}/carddav/config.inc.php <<EOF;
EOF
# Create writable directories.
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
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"
# Ensure the log file monitored by fail2ban exists, or else fail2ban can't start.
sudo -u www-data touch /var/log/roundcubemail/errors.log
@@ -194,10 +195,10 @@ usermod -a -G dovecot www-data
# set permissions so that PHP can use users.sqlite
# could use dovecot instead of www-data, but not sure it matters
chown root:www-data $STORAGE_ROOT/mail
chmod 775 $STORAGE_ROOT/mail
chown root:www-data $STORAGE_ROOT/mail/users.sqlite
chmod 664 $STORAGE_ROOT/mail/users.sqlite
chown root:www-data "$STORAGE_ROOT/mail"
chmod 775 "$STORAGE_ROOT/mail"
chown root:www-data "$STORAGE_ROOT/mail/users.sqlite"
chmod 664 "$STORAGE_ROOT/mail/users.sqlite"
# Fix Carddav permissions:
chown -f -R root:www-data ${RCM_PLUGIN_DIR}/carddav
@@ -205,9 +206,9 @@ chown -f -R root:www-data ${RCM_PLUGIN_DIR}/carddav
chmod -R 774 ${RCM_PLUGIN_DIR}/carddav
# 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
chown www-data:www-data $STORAGE_ROOT/mail/roundcube/roundcube.sqlite
chmod 664 $STORAGE_ROOT/mail/roundcube/roundcube.sqlite
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"
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)
@@ -217,8 +218,8 @@ sed -i.miabold 's/^[^#]\+.\+PRAGMA journal_mode = WAL.\+$/#&/' \
# 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
sqlite3 $STORAGE_ROOT/mail/roundcube/roundcube.sqlite 'PRAGMA journal_mode=WAL;' | 2>&1
hide_output sqlite3 "$STORAGE_ROOT/mail/roundcube/roundcube.sqlite" 'PRAGMA journal_mode=WAL;'
# Enable PHP modules.
phpenmod -v $PHP_VER imap
restart_service php$PHP_VER-fpm
phpenmod -v "$PHP_VER" imap
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)..."
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.
VERSION=2.7.1
TARGETHASH=f15c566b1ad50de24f3f08f505f0c3d8155c2d0d
VERSION=2.7.5
TARGETHASH=f0b0b06e255f3496173ab9d28a4f2d985184720e
needs_update=0 #NODOC
if [ ! -f /usr/local/lib/z-push/version ]; then
needs_update=1 #NODOC
@@ -44,10 +44,10 @@ if [ $needs_update == 1 ]; then
# Create admin and top scripts with PHP_VER
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
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
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
@@ -57,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('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('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
# Configure BACKEND
@@ -108,8 +106,10 @@ EOF
# Restart service.
restart_service php$PHP_VER-fpm
restart_service php"$PHP_VER"-fpm
# 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

@@ -142,7 +142,8 @@ def http_test(url, expected_status, postdata=None, qsargs=None, auth=None):
# return response status code
if r.status_code != expected_status:
r.raise_for_status() # anything but 200
raise OSError("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

View File

@@ -51,7 +51,7 @@ def test2(tests, server, description):
response = dns.resolver.resolve(qname, rtype)
except dns.resolver.NoNameservers:
# 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)
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
# host did not have an answer for this query; not sure what the
@@ -66,7 +66,7 @@ def test2(tests, server, description):
#print(server, ":", qname, rtype, "?", response)
continue
# show prolem
# show problem
if first:
print("Incorrect DNS Response from", description)
print()
@@ -79,7 +79,7 @@ def test2(tests, server, description):
# Test the response from the machine itself.
if not test(ipaddr, "Mail-in-a-Box"):
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)
else:
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.
if not test("8.8.8.8", "Google Public DNS"):
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)
else:
print ("Your domain registrar or DNS host appears to be configured correctly as well. Public DNS provides the same answers.")

View File

@@ -46,7 +46,7 @@ reverse_ip = dns.reversename.from_address(ipaddr) # e.g. "1.0.0.127.in-addr.arpa
try:
reverse_dns = dns.resolver.resolve(reverse_ip, 'PTR')[0].target.to_text(omit_final_dot=True) # => hostname
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
if reverse_dns is not None:
server.ehlo_or_helo_if_needed() # must send EHLO before getting the server's EHLO name
@@ -54,7 +54,7 @@ if reverse_dns is not None:
if helo_name != reverse_dns:
print("The server's EHLO name does not match its reverse hostname. Check DNS settings.")
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.
server.login(emailaddress, pw)

View File

@@ -96,7 +96,7 @@ def sslyze(opts, port, ok_ciphers):
# 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]*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"
# Print.

View File

@@ -1,9 +1,10 @@
#!/bin/bash
# Use this script to make an archive of the contents of all
# 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 $fn
echo "$fn"
echo ======================================================================
cat $fn
cat "$fn"
done

View File

@@ -3,4 +3,4 @@ POSTDATA=dummy
if [ "$1" == "--force" ]; then
POSTDATA=force=1
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
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)
# parse command line arguments

View File

@@ -14,13 +14,13 @@ if [ -z "$1" ]; then
echo
echo "Available backups:"
echo
find $STORAGE_ROOT/owncloud-backup/* -maxdepth 0 -type d
find "$STORAGE_ROOT/owncloud-backup/"* -maxdepth 0 -type d
echo
echo "Supply the directory that was created during the last installation as the only commandline argument"
exit
fi
if [ ! -f $1/config.php ]; then
if [ ! -f "$1/config.php" ]; then
echo "This isn't a valid backup location"
exit 1
fi
@@ -36,14 +36,14 @@ cp -r "$1/owncloud-install" /usr/local/lib/owncloud
# restore access rights
chmod 750 /usr/local/lib/owncloud/{apps,config}
cp "$1/owncloud.db" $STORAGE_ROOT/owncloud/
cp "$1/config.php" $STORAGE_ROOT/owncloud/
cp "$1/owncloud.db" "$STORAGE_ROOT/owncloud/"
cp "$1/config.php" "$STORAGE_ROOT/owncloud/"
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 www-data:www-data $STORAGE_ROOT/owncloud/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 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
echo "Done"

View File

@@ -11,13 +11,13 @@ source /etc/mailinabox.conf # load global vars
ADMIN=$(./mail.py user admins | head -n 1)
test -z "$1" || ADMIN=$1
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 "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
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 "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
echo Press enter to continue.
echo "Press enter to continue."
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

@@ -124,14 +124,14 @@ def generate_documentation():
""")
parser = Source.parser()
with open("setup/start.sh", "r") as start_file:
for line in start_file:
try:
fn = parser.parse_string(line).filename()
except:
continue
if fn in ("setup/start.sh", "setup/preflight.sh", "setup/questions.sh", "setup/firstuser.sh", "setup/management.sh"):
continue
with open("setup/start.sh", encoding="utf-8") as start_file:
for line in start_file:
try:
fn = parser.parse_string(line).filename()
except:
continue
if fn in {"setup/start.sh", "setup/preflight.sh", "setup/questions.sh", "setup/firstuser.sh", "setup/management.sh"}:
continue
import sys
print(fn, file=sys.stderr)
@@ -192,8 +192,7 @@ class CatEOF(Grammar):
def value(self):
content = self[9].string
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" \
% (self[4].string,
return "<div class='write-to'><div class='filename'>{} <span>({})</span></div><pre>{}</pre></div>\n".format(self[4].string,
"overwrite" if ">>" not in self[2].string else "append to",
cgi.escape(content))
@@ -223,16 +222,14 @@ class EditConf(Grammar):
EOL
)
def value(self):
conffile = self[1]
# conffile = self[1]
options = []
eq = "="
if self[3] and "-s" in self[3].string: eq = " "
try:
for opt in re.split("\s+", self[4].string):
k, v = opt.split("=", 1) # try except , else err: 1 arg missing
for opt in re.split(r"\s+", self[4].string):
k, v = opt.split("=", 1)
v = re.sub(r"\n+", "", fixup_tokens(v)) # not sure why newlines are getting doubled
options.append("%s%s%s" % (k, eq, v))
except: pass
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"
class CaptureOutput(Grammar):
@@ -250,8 +247,8 @@ class SedReplace(Grammar):
class EchoPipe(Grammar):
grammar = OPTIONAL(SPACE), L("echo "), REST_OF_LINE, L(' | '), REST_OF_LINE, EOL
def value(self):
text = " ".join("\"%s\"" % 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"
text = " ".join(f'"{s}"' for s in self[2].string.split(" "))
return "<pre class='shell'><div>echo " + recode_bash(text) + r" \<br> | " + recode_bash(self[4].string) + "</div></pre>\n"
def shell_line(bash):
return "<pre class='shell'><div>" + recode_bash(bash.strip()) + "</div></pre>\n"
@@ -326,7 +323,7 @@ def quasitokenize(bashscript):
elif c == "\\":
# Escaping next character.
escape_next = True
elif quote_mode is None and c in ('"', "'"):
elif quote_mode is None and c in {'"', "'"}:
# Starting a quoted word.
quote_mode = c
elif c == quote_mode:
@@ -366,7 +363,7 @@ def quasitokenize(bashscript):
newscript += c
# "<< 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"
elif quote_mode == "EOF" and re.search("\nEOF\n$", newscript):
quote_mode = None
@@ -380,7 +377,7 @@ def recode_bash(s):
tok = tok.replace(c, "\\" + c)
tok = fixup_tokens(tok)
if " " in tok or '"' in tok:
tok = tok.replace("\"", "\\\"")
tok = tok.replace('"', '\\"')
tok = '"' + tok +'"'
else:
tok = tok.replace("'", "\\'")
@@ -403,21 +400,20 @@ class BashScript(Grammar):
@staticmethod
def parse(fn):
if fn in ("setup/functions.sh", "/etc/mailinabox.conf"): return ""
with open(fn, "r") as f:
if fn in {"setup/functions.sh", "/etc/mailinabox.conf"}: return ""
with open(fn, encoding="utf-8") as f:
string = f.read()
# tokenize
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 = re.sub("hide_output ", "", string)
string = string.replace(r"hide_output ", "")
parser = BashScript.parser()
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" \
% ("https://github.com/mail-in-a-box/mailinabox/tree/master/" + fn, fn)
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)
mode = 0
for item in result.value():
@@ -431,7 +427,7 @@ class BashScript(Grammar):
mode = 0
clz = "contd"
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 += item
mode = 1
@@ -462,17 +458,16 @@ class BashScript(Grammar):
v = fixup_tokens(v)
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"\$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):
ret = ""
words = re.split("(\s+)", text)
words = re.split(r"(\s+)", text)
linelen = 0
for w in words:
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
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