mirror of
https://github.com/mail-in-a-box/mailinabox.git
synced 2026-03-12 17:07:23 +01:00
Compare commits
152 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92221f9efb | ||
|
|
0bd3977cde | ||
|
|
6a979f4f52 | ||
|
|
545e7a52e4 | ||
|
|
48c233ebe5 | ||
|
|
9a588de754 | ||
|
|
ac9ecc3bd3 | ||
|
|
8b166f3041 | ||
|
|
5509420637 | ||
|
|
7d6c7b6610 | ||
|
|
1f0e493b8c | ||
|
|
ada2167d08 | ||
|
|
be5032ffbe | ||
|
|
00b3a3b0a9 | ||
|
|
6d82c0035a | ||
|
|
4dced10a3f | ||
|
|
b80f225691 | ||
|
|
7f0f28f8e3 | ||
|
|
a8ea456b49 | ||
|
|
03bff5292b | ||
|
|
e891a9a3f3 | ||
|
|
51aedcf6c3 | ||
|
|
853008ddcc | ||
|
|
7d6427904f | ||
|
|
dcb93d071c | ||
|
|
2ea97f0643 | ||
|
|
4791c2fc62 | ||
|
|
49c333221a | ||
|
|
481a333dc0 | ||
|
|
b0df35eba0 | ||
|
|
08ae3d2b7f | ||
|
|
7c4eb0fb70 | ||
|
|
ee01eae55e | ||
|
|
89b301afc7 | ||
|
|
ce70f44c58 | ||
|
|
6594e19a1f | ||
|
|
8597646a12 | ||
|
|
f205c48564 | ||
|
|
3c3683429b | ||
|
|
a7a66929aa | ||
|
|
0d72566c99 | ||
|
|
62db58eaaf | ||
|
|
891de8d6c3 | ||
|
|
62b9b1f15f | ||
|
|
94da7bb088 | ||
|
|
65983b8ac7 | ||
|
|
56d0289ed9 | ||
|
|
f253c40012 | ||
|
|
4bbe4af377 | ||
|
|
2c34a6df2b | ||
|
|
1098e2b48e | ||
|
|
c50170b816 | ||
|
|
cd518e6820 | ||
|
|
967409b157 | ||
|
|
1b2711fc42 | ||
|
|
e6102eacfb | ||
|
|
6fd3195275 | ||
|
|
224242dfde | ||
|
|
049bfb6f7f | ||
|
|
12d60d102b | ||
|
|
9db2fc7f05 | ||
|
|
e03a6541ce | ||
|
|
41642f2f59 | ||
|
|
df9bb263dc | ||
|
|
339c330b4f | ||
|
|
cfc8fb484c | ||
|
|
bc1be9d70a | ||
|
|
3a4b8da8fd | ||
|
|
37dad9d4bb | ||
|
|
b805f8695e | ||
|
|
10bedad3a3 | ||
|
|
afc9f9686a | ||
|
|
7de8fc9bc0 | ||
|
|
c87b62b8c2 | ||
|
|
8fe33da85d | ||
|
|
c202a5cbc6 | ||
|
|
1353949e42 | ||
|
|
c19f8c9ee6 | ||
|
|
40b21c466d | ||
|
|
f52749b403 | ||
|
|
d67e09f334 | ||
|
|
e224fc6656 | ||
|
|
5e47677f7a | ||
|
|
db9637ce4f | ||
|
|
f908bc364e | ||
|
|
30c2c60f59 | ||
|
|
ab5ce01bdd | ||
|
|
ddadb6c28a | ||
|
|
23be1031b8 | ||
|
|
faee29ba8b | ||
|
|
b86bf07d57 | ||
|
|
e6294049bc | ||
|
|
30885bcc8a | ||
|
|
a67f90593d | ||
|
|
385340da46 | ||
|
|
0271e549bb | ||
|
|
f53b18ebb9 | ||
|
|
8567a9b719 | ||
|
|
ad9d732608 | ||
|
|
aa15670dc2 | ||
|
|
81176c8e4b | ||
|
|
960b5d5bbd | ||
|
|
802e7a1f4d | ||
|
|
52c68c6510 | ||
|
|
54b1ee9a3d | ||
|
|
6e3dee8b3b | ||
|
|
cd62fd9826 | ||
|
|
91638c7fe0 | ||
|
|
ff8170d5ab | ||
|
|
f6f75f6fab | ||
|
|
2f54f39f31 | ||
|
|
fa792f664e | ||
|
|
b50dfb7f93 | ||
|
|
cde4e0caca | ||
|
|
df80b9fc71 | ||
|
|
7558ffd4f3 | ||
|
|
50e9e8af30 | ||
|
|
ed02e2106b | ||
|
|
24a567c3be | ||
|
|
70f05e9d52 | ||
|
|
889118aeb6 | ||
|
|
a70ba94b0c | ||
|
|
9e29564f48 | ||
|
|
5aeced5c2e | ||
|
|
46f64e0e0a | ||
|
|
4971b63501 | ||
|
|
3ff9817325 | ||
|
|
d6becddbe5 | ||
|
|
1d6793d124 | ||
|
|
c7377e602d | ||
|
|
08021ea19f | ||
|
|
295d481603 | ||
|
|
c4cb828f65 | ||
|
|
0657f9e875 | ||
|
|
e37768ca86 | ||
|
|
bea5eb0dda | ||
|
|
fd5b11823c | ||
|
|
5fc1944f04 | ||
|
|
39fd4ce16c | ||
|
|
c0f4d5479f | ||
|
|
193763f8f0 | ||
|
|
79759ea5a3 | ||
|
|
6e5ceab0f8 | ||
|
|
c6fa0d23df | ||
|
|
85e59245fd | ||
|
|
4232a1205c | ||
|
|
0d4c693792 | ||
|
|
77b2246010 | ||
|
|
aff80ac58c | ||
|
|
25fec63a03 | ||
|
|
9b46637aff | ||
|
|
fb25013334 |
@@ -13,7 +13,7 @@ trim_trailing_whitespace = true
|
|||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
|
|
||||||
[Makefile]
|
[Makefile]
|
||||||
indent_style = tabs
|
indent_style = tab
|
||||||
indent_size = 4
|
indent_size = 4
|
||||||
|
|
||||||
[Vagrantfile]
|
[Vagrantfile]
|
||||||
@@ -23,7 +23,7 @@ indent_size = 2
|
|||||||
indent_size = 2
|
indent_size = 2
|
||||||
|
|
||||||
[*.py]
|
[*.py]
|
||||||
indent_style = tabs
|
indent_style = tab
|
||||||
|
|
||||||
[*.js]
|
[*.js]
|
||||||
indent_size = 2
|
indent_size = 2
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ tools/__pycache__/
|
|||||||
externals/
|
externals/
|
||||||
.env
|
.env
|
||||||
.vagrant
|
.vagrant
|
||||||
|
api/docs/api-docs.html
|
||||||
176
CHANGELOG.md
176
CHANGELOG.md
@@ -1,6 +1,182 @@
|
|||||||
CHANGELOG
|
CHANGELOG
|
||||||
=========
|
=========
|
||||||
|
|
||||||
|
v0.51 (November 14, 2020)
|
||||||
|
-------------------------
|
||||||
|
|
||||||
|
Software updates:
|
||||||
|
|
||||||
|
* Upgraded Nextcloud from 17.0.6 to 20.0.1 (with Contacts from 3.3.0 to 3.4.1 and Calendar from 2.0.3 to 2.1.2)
|
||||||
|
* Upgraded Roundcube to version 1.4.9.
|
||||||
|
|
||||||
|
Mail:
|
||||||
|
|
||||||
|
* The MTA-STA max_age value was increased to the normal one week.
|
||||||
|
|
||||||
|
Control Panel:
|
||||||
|
|
||||||
|
* Two-factor authentication can now be enabled for logins to the control panel. However, keep in mind that many online services (including domain name registrars, cloud server providers, and TLS certificate providers) may allow an attacker to take over your account or issue a fraudulent TLS certificate with only access to your email address, and this new two-factor authentication does not protect access to your inbox. It therefore remains very important that user accounts with administrative email addresses have strong passwords.
|
||||||
|
* TLS certificate expiry dates are now shown in ISO8601 format for clarity.
|
||||||
|
|
||||||
|
v0.50 (September 25, 2020)
|
||||||
|
--------------------------
|
||||||
|
|
||||||
|
Setup:
|
||||||
|
|
||||||
|
* When upgrading from versions before v0.40, setup will now warn that ownCloud/Nextcloud data cannot be migrated rather than failing the installation.
|
||||||
|
|
||||||
|
Mail:
|
||||||
|
|
||||||
|
* An MTA-STS policy for incoming mail is now published (in DNS and over HTTPS) when the primary hostname and email address domain both have a signed TLS certificate installed, allowing senders to know that an encrypted connection should be enforced.
|
||||||
|
* The per-IP connection limit to the IMAP server has been doubled to allow more devices to connect at once, especially with multiple users behind a NAT.
|
||||||
|
|
||||||
|
DNS:
|
||||||
|
|
||||||
|
* autoconfig and autodiscover subdomains and CalDAV/CardDAV SRV records are no longer generated for domains that don't have user accounts since they are unnecessary.
|
||||||
|
* IPv6 addresses can now be specified for secondary DNS nameservers in the control panel.
|
||||||
|
|
||||||
|
TLS:
|
||||||
|
|
||||||
|
* TLS certificates are now provisioned in groups by parent domain to limit easy domain enumeration and make provisioning more resilient to errors for particular domains.
|
||||||
|
|
||||||
|
Control Panel:
|
||||||
|
|
||||||
|
* The control panel API is now fully documented at https://mailinabox.email/api-docs.html.
|
||||||
|
* User passwords can now have spaces.
|
||||||
|
* Status checks for automatic subdomains have been moved into the section for the parent domain.
|
||||||
|
* Typo fixed.
|
||||||
|
|
||||||
|
Web:
|
||||||
|
|
||||||
|
* The default web page served on fresh installations now adds the `noindex` meta tag.
|
||||||
|
* The HSTS header is revised to also be sent on non-success responses.
|
||||||
|
|
||||||
|
v0.48 (August 26, 2020)
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
Security fixes:
|
||||||
|
|
||||||
|
* Roundcube is updated to version 1.4.8 fixing additional cross-site scripting (XSS) vulnerabilities.
|
||||||
|
|
||||||
|
v0.47 (July 29, 2020)
|
||||||
|
---------------------
|
||||||
|
|
||||||
|
Security fixes:
|
||||||
|
|
||||||
|
* Roundcube is updated to version 1.4.7 fixing a cross-site scripting (XSS) vulnerability with HTML messages with malicious svg/namespace (CVE-2020-15562) (https://roundcube.net/news/2020/07/05/security-updates-1.4.7-1.3.14-and-1.2.11).
|
||||||
|
* SSH connections are now rate-limited at the firewall level (in addition to fail2ban).
|
||||||
|
|
||||||
|
v0.46 (June 11, 2020)
|
||||||
|
---------------------
|
||||||
|
|
||||||
|
Security fixes:
|
||||||
|
|
||||||
|
* Roundcube is updated to version 1.4.6 (https://roundcube.net/news/2020/06/02/security-updates-1.4.5-and-1.3.12).
|
||||||
|
|
||||||
|
v0.45 (May 16, 2020)
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
Security fixes:
|
||||||
|
|
||||||
|
* Fix missing brute force login protection for Roundcube logins.
|
||||||
|
|
||||||
|
Software updates:
|
||||||
|
|
||||||
|
* Upgraded Roundcube from 1.4.2 to 1.4.4.
|
||||||
|
* Upgraded Nextcloud from 17.0.2 to 17.0.6 (with Contacts from 3.1.6 to 3.3.0 and Calendar from 1.7.1 to v2.0.3)
|
||||||
|
* Upgraded Z-Push to 2.5.2.
|
||||||
|
|
||||||
|
System:
|
||||||
|
|
||||||
|
* Nightly backups now occur on a random minute in the 3am hour (in the system time zone). The minute is chosen during Mail-in-a-Box installation/upgrade and remains the same until the next upgrade.
|
||||||
|
* Fix for mail log statistics report on leap days.
|
||||||
|
* Fix Mozilla autoconfig useGlobalPreferredServer setting.
|
||||||
|
|
||||||
|
Web:
|
||||||
|
|
||||||
|
* Add a new hidden feature to set nginx alias in www/custom.yaml.
|
||||||
|
|
||||||
|
Setup:
|
||||||
|
|
||||||
|
* Improved error handling.
|
||||||
|
|
||||||
|
v0.44 (February 15, 2020)
|
||||||
|
-------------------------
|
||||||
|
|
||||||
|
System:
|
||||||
|
|
||||||
|
* TLS settings have been upgraded following Mozilla's recommendations for servers. TLS1.2 and 1.3 are now the only supported protocols for web, IMAP, and SMTP (submission).
|
||||||
|
* Fixed an issue starting services when Mail-in-a-Box isn't on the root filesystem.
|
||||||
|
* Changed some performance options affecting Roundcube and Nextcloud.
|
||||||
|
|
||||||
|
Software updates:
|
||||||
|
|
||||||
|
* Upgraded Nextcloud from 15.0.8 to 17.0.2 (with Contacts from 3.1.1 to 3.1.6 and Calendar from 1.6.5 to 1.7.1)
|
||||||
|
* Upgraded Z-Push to 2.5.1.
|
||||||
|
* Upgraded Roundcube from 1.3.10 to 1.4.2 and changed the default skin (theme) to Elastic.
|
||||||
|
|
||||||
|
Control panel:
|
||||||
|
|
||||||
|
* The Custom DNS list of records is now sorted.
|
||||||
|
* The emails that report TLS provisioning results now has a less scary subject line.
|
||||||
|
|
||||||
|
Mail:
|
||||||
|
|
||||||
|
* Fetching of updated whitelist for greylisting was fetching each day instead of every month.
|
||||||
|
* OpenDKIM signing has been changed to 'relaxed' mode so that some old mail lists that forward mail can do so.
|
||||||
|
|
||||||
|
DNS:
|
||||||
|
|
||||||
|
* Automatic autoconfig.* subdomains can now be suppressed with custom DNS records.
|
||||||
|
* DNS zone transfer now works with IPv6 addresses.
|
||||||
|
|
||||||
|
Setup:
|
||||||
|
|
||||||
|
* An Ubuntu package source was missing on systems where it defaults off.
|
||||||
|
|
||||||
|
v0.43 (September 1, 2019)
|
||||||
|
-------------------------
|
||||||
|
|
||||||
|
Security fixes:
|
||||||
|
|
||||||
|
* A security issue was discovered in rsync backups. If you have enabled rsync backups, the file `id_rsa_miab` may have been copied to your backup destination. This file can be used to access your backup destination. If the file was copied to your backup destination, we recommend that you delete the file on your backup destination, delete `/root/.ssh/id_rsa_miab` on your Mail-in-a-Box, then re-run Mail-in-a-Box setup, and re-configure your SSH public key at your backup destination according to the instructions in the Mail-in-a-Box control panel.
|
||||||
|
* Brute force attack prevention was missing for the managesieve service.
|
||||||
|
|
||||||
|
Setup:
|
||||||
|
|
||||||
|
* Nextcloud was not upgraded properly after restoring Mail-in-a-Box from a backup from v0.40 or earlier.
|
||||||
|
|
||||||
|
Mail:
|
||||||
|
|
||||||
|
* Upgraded Roundcube to 1.3.10.
|
||||||
|
* Fetch an updated whitelist for greylisting on a monthly basis to reduce the number of delayed incoming emails.
|
||||||
|
|
||||||
|
Control panel:
|
||||||
|
|
||||||
|
* When using secondary DNS, it is now possible to specify a subnet range with the `xfr:` option.
|
||||||
|
* Fixed an issue when the secondary DNS option is used and the secondary DNS hostname resolves to multiple IP addresses.
|
||||||
|
* Fix a bug in how a backup configuration error is shown.
|
||||||
|
|
||||||
|
v0.42b (August 3, 2019)
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
|
||||||
|
* Decreased the minimum supported RAM to 502 Mb.
|
||||||
|
* Improved mail client autoconfiguration.
|
||||||
|
* 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.
|
||||||
|
|
||||||
|
Software updates:
|
||||||
|
|
||||||
|
* Upgraded Roundcube from 1.3.8 to 1.3.9.
|
||||||
|
* Upgraded Nextcloud from 14.0.6 to 15.0.8 (with Contacts from 2.1.8 to 3.1.1 and Calendar from 1.6.4 to 1.6.5).
|
||||||
|
* Upgraded Z-Push from 2.4.4 to 2.5.0.
|
||||||
|
|
||||||
|
Note that v0.42 (July 4, 2019) was pulled shortly after it was released to fix a Nextcloud upgrade issue.
|
||||||
|
|
||||||
v0.41 (February 26, 2019)
|
v0.41 (February 26, 2019)
|
||||||
-------------------------
|
-------------------------
|
||||||
|
|
||||||
|
|||||||
37
README.md
37
README.md
@@ -13,33 +13,40 @@ Our goals are to:
|
|||||||
|
|
||||||
* Make deploying a good mail server easy.
|
* Make deploying a good mail server easy.
|
||||||
* Promote [decentralization](http://redecentralize.org/), innovation, and privacy on the web.
|
* Promote [decentralization](http://redecentralize.org/), innovation, and privacy on the web.
|
||||||
* Have automated, auditable, and [idempotent](https://sharknet.us/2014/02/01/automated-configuration-management-challenges-with-idempotency/) configuration.
|
* Have automated, auditable, and [idempotent](https://web.archive.org/web/20190518072631/https://sharknet.us/2014/02/01/automated-configuration-management-challenges-with-idempotency/) configuration.
|
||||||
* **Not** make a totally unhackable, NSA-proof server.
|
* **Not** make a totally unhackable, NSA-proof server.
|
||||||
* **Not** make something customizable by power users.
|
* **Not** make something customizable by power users.
|
||||||
|
|
||||||
Additionally, this project has a [Code of Conduct](CODE_OF_CONDUCT.md), which supersedes the goals above. Please review it when joining our community.
|
Additionally, this project has a [Code of Conduct](CODE_OF_CONDUCT.md), which supersedes the goals above. Please review it when joining our community.
|
||||||
|
|
||||||
The Box
|
|
||||||
-------
|
In The Box
|
||||||
|
----------
|
||||||
|
|
||||||
Mail-in-a-Box turns a fresh Ubuntu 18.04 LTS 64-bit machine into a working mail server by installing and configuring various components.
|
Mail-in-a-Box turns a fresh Ubuntu 18.04 LTS 64-bit machine into a working mail server by installing and configuring various components.
|
||||||
|
|
||||||
It is a one-click email appliance. There are no user-configurable setup options. It "just works".
|
It is a one-click email appliance. There are no user-configurable setup options. It "just works."
|
||||||
|
|
||||||
The components installed are:
|
The components installed are:
|
||||||
|
|
||||||
* SMTP ([postfix](http://www.postfix.org/)), IMAP ([dovecot](http://dovecot.org/)), CardDAV/CalDAV ([Nextcloud](https://nextcloud.com/)), Exchange ActiveSync ([z-push](http://z-push.org/))
|
* SMTP ([postfix](http://www.postfix.org/)), IMAP ([Dovecot](http://dovecot.org/)), CardDAV/CalDAV ([Nextcloud](https://nextcloud.com/)), and Exchange ActiveSync ([z-push](http://z-push.org/)) servers
|
||||||
* Webmail ([Roundcube](http://roundcube.net/)), static website hosting ([nginx](http://nginx.org/))
|
* Webmail ([Roundcube](http://roundcube.net/)), mail filter rules (thanks to Roundcube and Dovecot), and email client autoconfig settings (served by [nginx](http://nginx.org/))
|
||||||
* Spam filtering ([spamassassin](https://spamassassin.apache.org/)), greylisting ([postgrey](http://postgrey.schweikert.ch/))
|
* Spam filtering ([spamassassin](https://spamassassin.apache.org/)) and greylisting ([postgrey](http://postgrey.schweikert.ch/))
|
||||||
* DNS ([nsd4](https://www.nlnetlabs.nl/projects/nsd/)) with [SPF](https://en.wikipedia.org/wiki/Sender_Policy_Framework), DKIM ([OpenDKIM](http://www.opendkim.org/)), [DMARC](https://en.wikipedia.org/wiki/DMARC), [DNSSEC](https://en.wikipedia.org/wiki/DNSSEC), [DANE TLSA](https://en.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities), and [SSHFP](https://tools.ietf.org/html/rfc4255) records automatically set
|
* DNS ([nsd4](https://www.nlnetlabs.nl/projects/nsd/)) with [SPF](https://en.wikipedia.org/wiki/Sender_Policy_Framework), DKIM ([OpenDKIM](http://www.opendkim.org/)), [DMARC](https://en.wikipedia.org/wiki/DMARC), [DNSSEC](https://en.wikipedia.org/wiki/DNSSEC), [DANE TLSA](https://en.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities), [MTA-STS](https://tools.ietf.org/html/rfc8461), and [SSHFP](https://tools.ietf.org/html/rfc4255) policy records automatically set
|
||||||
* Backups ([duplicity](http://duplicity.nongnu.org/)), firewall ([ufw](https://launchpad.net/ufw)), intrusion protection ([fail2ban](http://www.fail2ban.org/wiki/index.php/Main_Page)), system monitoring ([munin](http://munin-monitoring.org/))
|
* TLS certificates are automatically provisioned using [Let's Encrypt](https://letsencrypt.org/) for protecting https and all of the other services on the box
|
||||||
|
* Backups ([duplicity](http://duplicity.nongnu.org/)), firewall ([ufw](https://launchpad.net/ufw)), intrusion protection ([fail2ban](http://www.fail2ban.org/wiki/index.php/Main_Page)), and basic system monitoring ([munin](http://munin-monitoring.org/))
|
||||||
|
|
||||||
It also includes:
|
It also includes system management tools:
|
||||||
|
|
||||||
* A control panel and API for adding/removing mail users, aliases, custom DNS records, etc. and detailed system monitoring.
|
* Comprehensive health monitoring that checks each day that services are running, ports are open, TLS certificates are valid, and DNS records are correct
|
||||||
|
* A control panel for adding/removing mail users, aliases, custom DNS records, configuring backups, etc.
|
||||||
|
* An API for all of the actions on the control panel
|
||||||
|
|
||||||
|
It also supports static website hosting since the box is serving HTTPS anyway. (To serve a website for your domains elsewhere, just add a custom DNS "A" record in you Mail-in-a-Box's control panel to point domains to another server.)
|
||||||
|
|
||||||
For more information on how Mail-in-a-Box handles your privacy, see the [security details page](security.md).
|
For more information on how Mail-in-a-Box handles your privacy, see the [security details page](security.md).
|
||||||
|
|
||||||
|
|
||||||
Installation
|
Installation
|
||||||
------------
|
------------
|
||||||
|
|
||||||
@@ -58,7 +65,7 @@ by him:
|
|||||||
$ curl -s https://keybase.io/joshdata/key.asc | gpg --import
|
$ curl -s https://keybase.io/joshdata/key.asc | gpg --import
|
||||||
gpg: key C10BDD81: public key "Joshua Tauberer <jt@occams.info>" imported
|
gpg: key C10BDD81: public key "Joshua Tauberer <jt@occams.info>" imported
|
||||||
|
|
||||||
$ git verify-tag v0.41
|
$ git verify-tag v0.51
|
||||||
gpg: Signature made ..... using RSA key ID C10BDD81
|
gpg: Signature made ..... using RSA key ID C10BDD81
|
||||||
gpg: Good signature from "Joshua Tauberer <jt@occams.info>"
|
gpg: Good signature from "Joshua Tauberer <jt@occams.info>"
|
||||||
gpg: WARNING: This key is not certified with a trusted signature!
|
gpg: WARNING: This key is not certified with a trusted signature!
|
||||||
@@ -71,7 +78,7 @@ and on his [personal homepage](https://razor.occams.info/). (Of course, if this
|
|||||||
|
|
||||||
Checkout the tag corresponding to the most recent release:
|
Checkout the tag corresponding to the most recent release:
|
||||||
|
|
||||||
$ git checkout v0.41
|
$ git checkout v0.51
|
||||||
|
|
||||||
Begin the installation.
|
Begin the installation.
|
||||||
|
|
||||||
@@ -81,6 +88,9 @@ For help, DO NOT contact Josh directly --- I don't do tech support by email or t
|
|||||||
|
|
||||||
Post your question on the [discussion forum](https://discourse.mailinabox.email/) instead, where maintainers and Mail-in-a-Box users may be able to help you.
|
Post your question on the [discussion forum](https://discourse.mailinabox.email/) instead, where maintainers and Mail-in-a-Box users may be able to help you.
|
||||||
|
|
||||||
|
Note that while we want everything to "just work," we can't control the rest of the Internet. Other mail services might block or spam-filter email sent from your Mail-in-a-Box.
|
||||||
|
This is a challenge faced by everyone who runs their own mail server, with or without Mail-in-a-Box. See our discussion forum for tips about that.
|
||||||
|
|
||||||
Contributing and Development
|
Contributing and Development
|
||||||
----------------------------
|
----------------------------
|
||||||
|
|
||||||
@@ -94,6 +104,7 @@ This project was inspired in part by the ["NSA-proof your email in 2 hours"](htt
|
|||||||
|
|
||||||
Mail-in-a-Box is similar to [iRedMail](http://www.iredmail.org/) and [Modoboa](https://github.com/tonioo/modoboa).
|
Mail-in-a-Box is similar to [iRedMail](http://www.iredmail.org/) and [Modoboa](https://github.com/tonioo/modoboa).
|
||||||
|
|
||||||
|
|
||||||
The History
|
The History
|
||||||
-----------
|
-----------
|
||||||
|
|
||||||
|
|||||||
23
api/docs/generate-docs.sh
Executable file
23
api/docs/generate-docs.sh
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
# Requirements:
|
||||||
|
# - Node.js
|
||||||
|
# - redoc-cli (`npm install redoc-cli -g`)
|
||||||
|
|
||||||
|
redoc-cli bundle ../mailinabox.yml \
|
||||||
|
-t template.hbs \
|
||||||
|
-o api-docs.html \
|
||||||
|
--templateOptions.metaDescription="Mail-in-a-Box HTTP API" \
|
||||||
|
--title="Mail-in-a-Box HTTP API" \
|
||||||
|
--options.expandSingleSchemaField \
|
||||||
|
--options.hideSingleRequestSampleTab \
|
||||||
|
--options.jsonSampleExpandLevel=10 \
|
||||||
|
--options.hideDownloadButton \
|
||||||
|
--options.theme.logo.maxHeight=180px \
|
||||||
|
--options.theme.logo.maxWidth=180px \
|
||||||
|
--options.theme.colors.primary.main="#C52" \
|
||||||
|
--options.theme.typography.fontSize=16px \
|
||||||
|
--options.theme.typography.fontFamily="Raleway, sans-serif" \
|
||||||
|
--options.theme.typography.headings.fontFamily="Ubuntu, Arial, sans-serif" \
|
||||||
|
--options.theme.typography.code.fontSize=15px \
|
||||||
|
--options.theme.typography.code.fontFamily='"Source Code Pro", monospace'
|
||||||
31
api/docs/template.hbs
Normal file
31
api/docs/template.hbs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf8" />
|
||||||
|
<title>{{title}}</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="description" content="{{templateOptions.metaDescription}}" />
|
||||||
|
<link rel="icon" type="image/png" href="https://mailinabox.email/static/logo_small.png">
|
||||||
|
<link rel="apple-touch-icon" type="image/png" href="https://mailinabox.email/static/logo_small.png">
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Raleway:400,700" rel="stylesheet" />
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300" rel="stylesheet" />
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Source+Code+Pro:500" rel="stylesheet" />
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #000 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{{{redocHead}}}
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
{{{redocHTML}}}
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
2680
api/mailinabox.yml
Normal file
2680
api/mailinabox.yml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
# Fail2Ban filter Dovecot authentication and pop3/imap server
|
# Fail2Ban filter Dovecot authentication and pop3/imap/managesieve server
|
||||||
# For Mail-in-a-Box
|
# For Mail-in-a-Box
|
||||||
|
|
||||||
[INCLUDES]
|
[INCLUDES]
|
||||||
@@ -9,7 +9,7 @@ before = common.conf
|
|||||||
|
|
||||||
_daemon = (auth|dovecot(-auth)?|auth-worker)
|
_daemon = (auth|dovecot(-auth)?|auth-worker)
|
||||||
|
|
||||||
failregex = ^%(__prefix_line)s(pop3|imap)-login: (Info: )?(Aborted login|Disconnected)(: Inactivity)? \(((no auth attempts|auth failed, \d+ attempts)( in \d+ secs)?|tried to use (disabled|disallowed) \S+ auth)\):( user=<\S*>,)?( method=\S+,)? rip=<HOST>, lip=(\d{1,3}\.){3}\d{1,3}(, TLS( handshaking)?(: Disconnected)?)?(, session=<\S+>)?\s*$
|
failregex = ^%(__prefix_line)s(pop3|imap|managesieve)-login: (Info: )?(Aborted login|Disconnected)(: Inactivity)? \(((no auth attempts|auth failed, \d+ attempts)( in \d+ secs)?|tried to use (disabled|disallowed) \S+ auth)\):( user=<\S*>,)?( method=\S+,)? rip=<HOST>, lip=(\d{1,3}\.){3}\d{1,3}(, TLS( handshaking)?(: Disconnected)?)?(, session=<\S+>)?\s*$
|
||||||
|
|
||||||
ignoreregex =
|
ignoreregex =
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ findtime = 30
|
|||||||
enabled = true
|
enabled = true
|
||||||
port = http,https
|
port = http,https
|
||||||
filter = miab-roundcube
|
filter = miab-roundcube
|
||||||
logpath = /var/log/roundcubemail/errors
|
logpath = /var/log/roundcubemail/errors.log
|
||||||
maxretry = 20
|
maxretry = 20
|
||||||
findtime = 30
|
findtime = 30
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<username>%EMAILADDRESS%</username>
|
<username>%EMAILADDRESS%</username>
|
||||||
<authentication>password-cleartext</authentication>
|
<authentication>password-cleartext</authentication>
|
||||||
<addThisServer>true</addThisServer>
|
<addThisServer>true</addThisServer>
|
||||||
<useGlobalPreferredServer>true</useGlobalPreferredServer>
|
<useGlobalPreferredServer>false</useGlobalPreferredServer>
|
||||||
</outgoingServer>
|
</outgoingServer>
|
||||||
|
|
||||||
<documentation url="https://PRIMARY_HOSTNAME/">
|
<documentation url="https://PRIMARY_HOSTNAME/">
|
||||||
|
|||||||
4
conf/mta-sts.txt
Normal file
4
conf/mta-sts.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
version: STSv1
|
||||||
|
mode: MODE
|
||||||
|
mx: PRIMARY_HOSTNAME
|
||||||
|
max_age: 604800
|
||||||
@@ -18,6 +18,12 @@
|
|||||||
location = /.well-known/autoconfig/mail/config-v1.1.xml {
|
location = /.well-known/autoconfig/mail/config-v1.1.xml {
|
||||||
alias /var/lib/mailinabox/mozilla-autoconfig.xml;
|
alias /var/lib/mailinabox/mozilla-autoconfig.xml;
|
||||||
}
|
}
|
||||||
|
location = /mail/config-v1.1.xml {
|
||||||
|
alias /var/lib/mailinabox/mozilla-autoconfig.xml;
|
||||||
|
}
|
||||||
|
location = /.well-known/mta-sts.txt {
|
||||||
|
alias /var/lib/mailinabox/mta-sts.txt;
|
||||||
|
}
|
||||||
|
|
||||||
# Roundcube Webmail configuration.
|
# Roundcube Webmail configuration.
|
||||||
rewrite ^/mail$ /mail/ redirect;
|
rewrite ^/mail$ /mail/ redirect;
|
||||||
|
|||||||
@@ -19,14 +19,23 @@
|
|||||||
rewrite ^/cloud/$ /cloud/index.php;
|
rewrite ^/cloud/$ /cloud/index.php;
|
||||||
rewrite ^/cloud/(contacts|calendar|files)$ /cloud/index.php/apps/$1/ redirect;
|
rewrite ^/cloud/(contacts|calendar|files)$ /cloud/index.php/apps/$1/ redirect;
|
||||||
rewrite ^(/cloud/core/doc/[^\/]+/)$ $1/index.html;
|
rewrite ^(/cloud/core/doc/[^\/]+/)$ $1/index.html;
|
||||||
|
rewrite ^(/cloud/oc[sm]-provider)/$ $1/index.php redirect;
|
||||||
location /cloud/ {
|
location /cloud/ {
|
||||||
alias /usr/local/lib/owncloud/;
|
alias /usr/local/lib/owncloud/;
|
||||||
location ~ ^/cloud/(build|tests|config|lib|3rdparty|templates|data|README)/ {
|
location ~ ^/cloud/(build|tests|config|lib|3rdparty|templates|data|README)/ {
|
||||||
deny all;
|
deny all;
|
||||||
}
|
}
|
||||||
location ~ ^/cloud/(?:\.|autotest|occ|issue|indie|db_|console) {
|
location ~ ^/cloud/(?:\.|autotest|occ|issue|indie|db_|console) {
|
||||||
deny all;
|
deny all;
|
||||||
}
|
}
|
||||||
|
# Enable paths for service and cloud federation discovery
|
||||||
|
# Resolves warning in Nextcloud Settings panel
|
||||||
|
location ~ ^/cloud/(oc[sm]-provider)?/([^/]+\.php)$ {
|
||||||
|
index index.php;
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_param SCRIPT_FILENAME /usr/local/lib/owncloud/$1/$2;
|
||||||
|
fastcgi_pass php-fpm;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
location ~ ^(/cloud)((?:/ocs)?/[^/]+\.php)(/.*)?$ {
|
location ~ ^(/cloud)((?:/ocs)?/[^/]+\.php)(/.*)?$ {
|
||||||
# note: ~ has precendence over a regular location block
|
# note: ~ has precendence over a regular location block
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# We track the Mozilla "intermediate" compatibility TLS recommendations.
|
# We track the Mozilla "intermediate" compatibility TLS recommendations.
|
||||||
# Note that these settings are repeated in the SMTP and IMAP configuration.
|
# Note that these settings are repeated in the SMTP and IMAP configuration.
|
||||||
# ssl_protocols has moved to nginx.conf in bionic, check there for enabled protocols.
|
# ssl_protocols has moved to nginx.conf in bionic, check there for enabled protocols.
|
||||||
ssl_ciphers 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA:!DSS';
|
ssl_ciphers 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;
|
||||||
ssl_dhparam STORAGE_ROOT/ssl/dh2048.pem;
|
ssl_dhparam STORAGE_ROOT/ssl/dh2048.pem;
|
||||||
|
|
||||||
# as recommended by http://nginx.org/en/docs/http/configuring_https_servers.html
|
# as recommended by http://nginx.org/en/docs/http/configuring_https_servers.html
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>this is a mail-in-a-box</title>
|
<title>this is a mail-in-a-box</title>
|
||||||
|
<meta name="robots" content="noindex">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>this is a mail-in-a-box</h1>
|
<h1>this is a mail-in-a-box</h1>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import base64, os, os.path, hmac
|
import base64, os, os.path, hmac, json
|
||||||
|
|
||||||
from flask import make_response
|
from flask import make_response
|
||||||
|
|
||||||
import utils
|
import utils
|
||||||
from mailconfig import get_mail_password, get_mail_user_privileges
|
from mailconfig import get_mail_password, get_mail_user_privileges
|
||||||
|
from mfa import get_hash_mfa_state, validate_auth_mfa
|
||||||
|
|
||||||
DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key'
|
DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key'
|
||||||
DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server'
|
DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server'
|
||||||
@@ -72,17 +73,19 @@ class KeyAuthService:
|
|||||||
if username in (None, ""):
|
if username in (None, ""):
|
||||||
raise ValueError("Authorization header invalid.")
|
raise ValueError("Authorization header invalid.")
|
||||||
elif username == self.key:
|
elif username == self.key:
|
||||||
# The user passed the API key which grants administrative privs.
|
# The user passed the master API key which grants administrative privs.
|
||||||
return (None, ["admin"])
|
return (None, ["admin"])
|
||||||
else:
|
else:
|
||||||
# The user is trying to log in with a username and user-specific
|
# The user is trying to log in with a username and either a password
|
||||||
# API key or password. Raises or returns privs.
|
# (and possibly a MFA token) or a user-specific API key.
|
||||||
return (username, self.get_user_credentials(username, password, env))
|
return (username, self.check_user_auth(username, password, request, env))
|
||||||
|
|
||||||
def get_user_credentials(self, email, pw, env):
|
def check_user_auth(self, email, pw, request, env):
|
||||||
# Validate a user's credentials. On success returns a list of
|
# Validate a user's login email address and password. If MFA is enabled,
|
||||||
# privileges (e.g. [] or ['admin']). On failure raises a ValueError
|
# check the MFA token in the X-Auth-Token header.
|
||||||
# with a login error message.
|
#
|
||||||
|
# On success returns a list of privileges (e.g. [] or ['admin']). On login
|
||||||
|
# failure, raises a ValueError with a login error message.
|
||||||
|
|
||||||
# Sanity check.
|
# Sanity check.
|
||||||
if email == "" or pw == "":
|
if email == "" or pw == "":
|
||||||
@@ -112,6 +115,12 @@ class KeyAuthService:
|
|||||||
# Login failed.
|
# Login failed.
|
||||||
raise ValueError("Invalid password.")
|
raise ValueError("Invalid password.")
|
||||||
|
|
||||||
|
# If MFA is enabled, check that MFA passes.
|
||||||
|
status, hints = validate_auth_mfa(email, request, env)
|
||||||
|
if not status:
|
||||||
|
# Login valid. Hints may have more info.
|
||||||
|
raise ValueError(",".join(hints))
|
||||||
|
|
||||||
# Get privileges for authorization. This call should never fail because by this
|
# Get privileges for authorization. This call should never fail because by this
|
||||||
# point we know the email address is a valid user. But on error the call will
|
# point we know the email address is a valid user. But on error the call will
|
||||||
# return a tuple of an error message and an HTTP status code.
|
# return a tuple of an error message and an HTTP status code.
|
||||||
@@ -122,16 +131,27 @@ class KeyAuthService:
|
|||||||
return privs
|
return privs
|
||||||
|
|
||||||
def create_user_key(self, email, env):
|
def create_user_key(self, email, env):
|
||||||
# Store an HMAC with the client. The hashed message of the HMAC will be the user's
|
# Create a user API key, which is a shared secret that we can re-generate from
|
||||||
# email address & hashed password and the key will be the master API key. The user of
|
# static information in our database. The shared secret contains the user's
|
||||||
# course has their own email address and password. We assume they do not have the master
|
# email address, current hashed password, and current MFA state, so that the
|
||||||
# API key (unless they are trusted anyway). The HMAC proves that they authenticated
|
# key becomes invalid if any of that information changes.
|
||||||
# with us in some other way to get the HMAC. Including the password means that when
|
#
|
||||||
# a user's password is reset, the HMAC changes and they will correctly need to log
|
# Use an HMAC to generate the API key using our master API key as a key,
|
||||||
# in to the control panel again. This method raises a ValueError if the user does
|
# which also means that the API key becomes invalid when our master API key
|
||||||
# not exist, due to get_mail_password.
|
# changes --- i.e. when this process is restarted.
|
||||||
|
#
|
||||||
|
# Raises ValueError via get_mail_password if the user doesn't exist.
|
||||||
|
|
||||||
|
# Construct the HMAC message from the user's email address and current password.
|
||||||
msg = b"AUTH:" + email.encode("utf8") + b" " + get_mail_password(email, env).encode("utf8")
|
msg = b"AUTH:" + email.encode("utf8") + b" " + get_mail_password(email, env).encode("utf8")
|
||||||
return hmac.new(self.key.encode('ascii'), msg, digestmod="sha256").hexdigest()
|
|
||||||
|
# Add to the message the current MFA state, which is a list of MFA information.
|
||||||
|
# Turn it into a string stably.
|
||||||
|
msg += b" " + json.dumps(get_hash_mfa_state(email, env), sort_keys=True).encode("utf8")
|
||||||
|
|
||||||
|
# Make the HMAC.
|
||||||
|
hash_key = self.key.encode('ascii')
|
||||||
|
return hmac.new(hash_key, msg, digestmod="sha256").hexdigest()
|
||||||
|
|
||||||
def _generate_key(self):
|
def _generate_key(self):
|
||||||
raw_key = os.urandom(32)
|
raw_key = os.urandom(32)
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ from exclusiveprocess import Lock
|
|||||||
from utils import load_environment, shell, wait_for_service, fix_boto
|
from utils import load_environment, shell, wait_for_service, fix_boto
|
||||||
|
|
||||||
rsync_ssh_options = [
|
rsync_ssh_options = [
|
||||||
"--ssh-options='-i /root/.ssh/id_rsa_miab'",
|
"--ssh-options= -i /root/.ssh/id_rsa_miab",
|
||||||
"--rsync-options=-e \"/usr/bin/ssh -oStrictHostKeyChecking=no -oBatchMode=yes -p 22 -i /root/.ssh/id_rsa_miab\"",
|
"--rsync-options= -e \"/usr/bin/ssh -oStrictHostKeyChecking=no -oBatchMode=yes -p 22 -i /root/.ssh/id_rsa_miab\"",
|
||||||
]
|
]
|
||||||
|
|
||||||
def backup_status(env):
|
def backup_status(env):
|
||||||
@@ -406,11 +406,11 @@ def list_target_files(config):
|
|||||||
reason = "Provided path {} is invalid.".format(target_path)
|
reason = "Provided path {} is invalid.".format(target_path)
|
||||||
elif 'Network is unreachable' in listing:
|
elif 'Network is unreachable' in listing:
|
||||||
reason = "The IP address {} is unreachable.".format(target.hostname)
|
reason = "The IP address {} is unreachable.".format(target.hostname)
|
||||||
elif 'Could not resolve hostname':
|
elif 'Could not resolve hostname' in listing:
|
||||||
reason = "The hostname {} cannot be resolved.".format(target.hostname)
|
reason = "The hostname {} cannot be resolved.".format(target.hostname)
|
||||||
else:
|
else:
|
||||||
reason = "Unknown error." \
|
reason = "Unknown error." \
|
||||||
"Please check running 'python management/backup.py --verify'" \
|
"Please check running 'management/backup.py --verify'" \
|
||||||
"from mailinabox sources to debug the issue."
|
"from mailinabox sources to debug the issue."
|
||||||
raise ValueError("Connection to rsync host failed: {}".format(reason))
|
raise ValueError("Connection to rsync host failed: {}".format(reason))
|
||||||
|
|
||||||
@@ -419,15 +419,22 @@ def list_target_files(config):
|
|||||||
fix_boto() # must call prior to importing boto
|
fix_boto() # must call prior to importing boto
|
||||||
import boto.s3
|
import boto.s3
|
||||||
from boto.exception import BotoServerError
|
from boto.exception import BotoServerError
|
||||||
|
custom_region = False
|
||||||
for region in boto.s3.regions():
|
for region in boto.s3.regions():
|
||||||
if region.endpoint == target.hostname:
|
if region.endpoint == target.hostname:
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
raise ValueError("Invalid S3 region/host.")
|
# If region is not found this is a custom region
|
||||||
|
custom_region = True
|
||||||
|
|
||||||
bucket = target.path[1:].split('/')[0]
|
bucket = target.path[1:].split('/')[0]
|
||||||
path = '/'.join(target.path[1:].split('/')[1:]) + '/'
|
path = '/'.join(target.path[1:].split('/')[1:]) + '/'
|
||||||
|
|
||||||
|
# Create a custom region with custom endpoint
|
||||||
|
if custom_region:
|
||||||
|
from boto.s3.connection import S3Connection
|
||||||
|
region = boto.s3.S3RegionInfo(name=bucket, endpoint=target.hostname, connection_cls=S3Connection)
|
||||||
|
|
||||||
# If no prefix is specified, set the path to '', otherwise boto won't list the files
|
# If no prefix is specified, set the path to '', otherwise boto won't list the files
|
||||||
if path == '/':
|
if path == '/':
|
||||||
path = ''
|
path = ''
|
||||||
|
|||||||
150
management/cli.py
Executable file
150
management/cli.py
Executable file
@@ -0,0 +1,150 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
#
|
||||||
|
# This is a command-line script for calling management APIs
|
||||||
|
# on the Mail-in-a-Box control panel backend. The script
|
||||||
|
# reads /var/lib/mailinabox/api.key for the backend's
|
||||||
|
# root API key. This file is readable only by root, so this
|
||||||
|
# tool can only be used as root.
|
||||||
|
|
||||||
|
import sys, getpass, urllib.request, urllib.error, json, re, csv
|
||||||
|
|
||||||
|
def mgmt(cmd, data=None, is_json=False):
|
||||||
|
# The base URL for the management daemon. (Listens on IPv4 only.)
|
||||||
|
mgmt_uri = 'http://127.0.0.1:10222'
|
||||||
|
|
||||||
|
setup_key_auth(mgmt_uri)
|
||||||
|
|
||||||
|
req = urllib.request.Request(mgmt_uri + cmd, urllib.parse.urlencode(data).encode("utf8") if data else None)
|
||||||
|
try:
|
||||||
|
response = urllib.request.urlopen(req)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 401:
|
||||||
|
try:
|
||||||
|
print(e.read().decode("utf8"))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
print("The management daemon refused access. The API key file may be out of sync. Try 'service mailinabox restart'.", file=sys.stderr)
|
||||||
|
elif hasattr(e, 'read'):
|
||||||
|
print(e.read().decode('utf8'), file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print(e, file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
resp = response.read().decode('utf8')
|
||||||
|
if is_json: resp = json.loads(resp)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
def read_password():
|
||||||
|
while True:
|
||||||
|
first = getpass.getpass('password: ')
|
||||||
|
if len(first) < 8:
|
||||||
|
print("Passwords must be at least eight characters.")
|
||||||
|
continue
|
||||||
|
second = getpass.getpass(' (again): ')
|
||||||
|
if first != second:
|
||||||
|
print("Passwords not the same. Try again.")
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
return first
|
||||||
|
|
||||||
|
def setup_key_auth(mgmt_uri):
|
||||||
|
key = open('/var/lib/mailinabox/api.key').read().strip()
|
||||||
|
|
||||||
|
auth_handler = urllib.request.HTTPBasicAuthHandler()
|
||||||
|
auth_handler.add_password(
|
||||||
|
realm='Mail-in-a-Box Management Server',
|
||||||
|
uri=mgmt_uri,
|
||||||
|
user=key,
|
||||||
|
passwd='')
|
||||||
|
opener = urllib.request.build_opener(auth_handler)
|
||||||
|
urllib.request.install_opener(opener)
|
||||||
|
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("""Usage:
|
||||||
|
{cli} user (lists users)
|
||||||
|
{cli} user add user@domain.com [password]
|
||||||
|
{cli} user password user@domain.com [password]
|
||||||
|
{cli} user remove user@domain.com
|
||||||
|
{cli} user make-admin user@domain.com
|
||||||
|
{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)
|
||||||
|
{cli} user mfa disable user@domain.com [id] (disables MFA for user)
|
||||||
|
{cli} alias (lists aliases)
|
||||||
|
{cli} alias add incoming.name@domain.com sent.to@other.domain.com
|
||||||
|
{cli} alias add incoming.name@domain.com 'sent.to@other.domain.com, multiple.people@other.domain.com'
|
||||||
|
{cli} alias remove incoming.name@domain.com
|
||||||
|
|
||||||
|
Removing a mail user does not delete their mail folders on disk. It only prevents IMAP/SMTP login.
|
||||||
|
""".format(
|
||||||
|
cli="management/cli.py"
|
||||||
|
))
|
||||||
|
|
||||||
|
elif sys.argv[1] == "user" and len(sys.argv) == 2:
|
||||||
|
# Dump a list of users, one per line. Mark admins with an asterisk.
|
||||||
|
users = mgmt("/mail/users?format=json", is_json=True)
|
||||||
|
for domain in users:
|
||||||
|
for user in domain["users"]:
|
||||||
|
if user['status'] == 'inactive': continue
|
||||||
|
print(user['email'], end='')
|
||||||
|
if "admin" in user['privileges']:
|
||||||
|
print("*", end='')
|
||||||
|
print()
|
||||||
|
|
||||||
|
elif sys.argv[1] == "user" and sys.argv[2] in ("add", "password"):
|
||||||
|
if len(sys.argv) < 5:
|
||||||
|
if len(sys.argv) < 4:
|
||||||
|
email = input("email: ")
|
||||||
|
else:
|
||||||
|
email = sys.argv[3]
|
||||||
|
pw = read_password()
|
||||||
|
else:
|
||||||
|
email, pw = sys.argv[3:5]
|
||||||
|
|
||||||
|
if sys.argv[2] == "add":
|
||||||
|
print(mgmt("/mail/users/add", { "email": email, "password": pw }))
|
||||||
|
elif sys.argv[2] == "password":
|
||||||
|
print(mgmt("/mail/users/password", { "email": email, "password": pw }))
|
||||||
|
|
||||||
|
elif sys.argv[1] == "user" and sys.argv[2] == "remove" and len(sys.argv) == 4:
|
||||||
|
print(mgmt("/mail/users/remove", { "email": sys.argv[3] }))
|
||||||
|
|
||||||
|
elif sys.argv[1] == "user" and sys.argv[2] in ("make-admin", "remove-admin") and len(sys.argv) == 4:
|
||||||
|
if sys.argv[2] == "make-admin":
|
||||||
|
action = "add"
|
||||||
|
else:
|
||||||
|
action = "remove"
|
||||||
|
print(mgmt("/mail/users/privileges/" + action, { "email": sys.argv[3], "privilege": "admin" }))
|
||||||
|
|
||||||
|
elif sys.argv[1] == "user" and sys.argv[2] == "admins":
|
||||||
|
# Dump a list of admin users.
|
||||||
|
users = mgmt("/mail/users?format=json", is_json=True)
|
||||||
|
for domain in users:
|
||||||
|
for user in domain["users"]:
|
||||||
|
if "admin" in user['privileges']:
|
||||||
|
print(user['email'])
|
||||||
|
|
||||||
|
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)
|
||||||
|
W = csv.writer(sys.stdout)
|
||||||
|
W.writerow(["id", "type", "label"])
|
||||||
|
for mfa in status["enabled_mfa"]:
|
||||||
|
W.writerow([mfa["id"], mfa["type"], mfa["label"]])
|
||||||
|
|
||||||
|
elif sys.argv[1] == "user" and len(sys.argv) in (5, 6) and sys.argv[2:4] == ["mfa", "disable"]:
|
||||||
|
# Disable MFA (all or a particular device) for a user.
|
||||||
|
print(mgmt("/mfa/disable", { "user": sys.argv[4], "mfa-id": sys.argv[5] if len(sys.argv) == 6 else None }))
|
||||||
|
|
||||||
|
elif sys.argv[1] == "alias" and len(sys.argv) == 2:
|
||||||
|
print(mgmt("/mail/aliases"))
|
||||||
|
|
||||||
|
elif sys.argv[1] == "alias" and sys.argv[2] == "add" and len(sys.argv) == 5:
|
||||||
|
print(mgmt("/mail/aliases/add", { "address": sys.argv[3], "forwards_to": sys.argv[4] }))
|
||||||
|
|
||||||
|
elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4:
|
||||||
|
print(mgmt("/mail/aliases/remove", { "address": sys.argv[3] }))
|
||||||
|
|
||||||
|
else:
|
||||||
|
print("Invalid command-line arguments.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import os, os.path, re, json, time
|
import os, os.path, re, json, time
|
||||||
import subprocess
|
import multiprocessing.pool, subprocess
|
||||||
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from flask import Flask, request, render_template, abort, Response, send_from_directory, make_response
|
from flask import Flask, request, render_template, abort, Response, send_from_directory, make_response
|
||||||
|
|
||||||
import auth, utils, multiprocessing.pool
|
import auth, utils
|
||||||
from mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, remove_mail_user
|
from mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, remove_mail_user
|
||||||
from mailconfig import get_mail_user_privileges, add_remove_mail_user_privilege
|
from mailconfig import get_mail_user_privileges, add_remove_mail_user_privilege
|
||||||
from mailconfig import get_mail_aliases, get_mail_aliases_ex, get_mail_domains, add_mail_alias, remove_mail_alias
|
from mailconfig import get_mail_aliases, get_mail_aliases_ex, get_mail_domains, add_mail_alias, remove_mail_alias
|
||||||
|
from mfa import get_public_mfa_state, provision_totp, validate_totp_secret, enable_mfa, disable_mfa
|
||||||
|
|
||||||
env = utils.load_environment()
|
env = utils.load_environment()
|
||||||
|
|
||||||
@@ -35,23 +36,31 @@ app = Flask(__name__, template_folder=os.path.abspath(os.path.join(os.path.dirna
|
|||||||
def authorized_personnel_only(viewfunc):
|
def authorized_personnel_only(viewfunc):
|
||||||
@wraps(viewfunc)
|
@wraps(viewfunc)
|
||||||
def newview(*args, **kwargs):
|
def newview(*args, **kwargs):
|
||||||
# Authenticate the passed credentials, which is either the API key or a username:password pair.
|
# Authenticate the passed credentials, which is either the API key or a username:password pair
|
||||||
|
# and an optional X-Auth-Token token.
|
||||||
error = None
|
error = None
|
||||||
|
privs = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
email, privs = auth_service.authenticate(request, env)
|
email, privs = auth_service.authenticate(request, env)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
# Authentication failed.
|
|
||||||
privs = []
|
|
||||||
error = "Incorrect username or password"
|
|
||||||
|
|
||||||
# Write a line in the log recording the failed login
|
# Write a line in the log recording the failed login
|
||||||
log_failed_login(request)
|
log_failed_login(request)
|
||||||
|
|
||||||
|
# Authentication failed.
|
||||||
|
error = str(e)
|
||||||
|
|
||||||
# Authorized to access an API view?
|
# Authorized to access an API view?
|
||||||
if "admin" in privs:
|
if "admin" in privs:
|
||||||
|
# Store the email address of the logged in user so it can be accessed
|
||||||
|
# from the API methods that affect the calling user.
|
||||||
|
request.user_email = email
|
||||||
|
request.user_privs = privs
|
||||||
|
|
||||||
# Call view func.
|
# Call view func.
|
||||||
return viewfunc(*args, **kwargs)
|
return viewfunc(*args, **kwargs)
|
||||||
elif not error:
|
|
||||||
|
if not error:
|
||||||
error = "You are not an administrator."
|
error = "You are not an administrator."
|
||||||
|
|
||||||
# Not authorized. Return a 401 (send auth) and a prompt to authorize by default.
|
# Not authorized. Return a 401 (send auth) and a prompt to authorize by default.
|
||||||
@@ -83,8 +92,8 @@ def authorized_personnel_only(viewfunc):
|
|||||||
def unauthorized(error):
|
def unauthorized(error):
|
||||||
return auth_service.make_unauthorized_response()
|
return auth_service.make_unauthorized_response()
|
||||||
|
|
||||||
def json_response(data):
|
def json_response(data, status=200):
|
||||||
return Response(json.dumps(data, indent=2, sort_keys=True)+'\n', status=200, mimetype='application/json')
|
return Response(json.dumps(data, indent=2, sort_keys=True)+'\n', status=status, mimetype='application/json')
|
||||||
|
|
||||||
###################################
|
###################################
|
||||||
|
|
||||||
@@ -119,12 +128,17 @@ def me():
|
|||||||
try:
|
try:
|
||||||
email, privs = auth_service.authenticate(request, env)
|
email, privs = auth_service.authenticate(request, env)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
# Log the failed login
|
if "missing-totp-token" in str(e):
|
||||||
log_failed_login(request)
|
return json_response({
|
||||||
|
"status": "missing-totp-token",
|
||||||
return json_response({
|
"reason": str(e),
|
||||||
"status": "invalid",
|
})
|
||||||
"reason": "Incorrect username or password",
|
else:
|
||||||
|
# Log the failed login
|
||||||
|
log_failed_login(request)
|
||||||
|
return json_response({
|
||||||
|
"status": "invalid",
|
||||||
|
"reason": str(e),
|
||||||
})
|
})
|
||||||
|
|
||||||
resp = {
|
resp = {
|
||||||
@@ -334,7 +348,7 @@ def ssl_get_status():
|
|||||||
|
|
||||||
# What domains can we provision certificates for? What unexpected problems do we have?
|
# What domains can we provision certificates for? What unexpected problems do we have?
|
||||||
provision, cant_provision = get_certificates_to_provision(env, show_valid_certs=False)
|
provision, cant_provision = get_certificates_to_provision(env, show_valid_certs=False)
|
||||||
|
|
||||||
# What's the current status of TLS certificates on all of the domain?
|
# What's the current status of TLS certificates on all of the domain?
|
||||||
domains_status = get_web_domains_info(env)
|
domains_status = get_web_domains_info(env)
|
||||||
domains_status = [
|
domains_status = [
|
||||||
@@ -383,6 +397,60 @@ def ssl_provision_certs():
|
|||||||
requests = provision_certificates(env, limit_domains=None)
|
requests = provision_certificates(env, limit_domains=None)
|
||||||
return json_response({ "requests": requests })
|
return json_response({ "requests": requests })
|
||||||
|
|
||||||
|
# multi-factor auth
|
||||||
|
|
||||||
|
@app.route('/mfa/status', methods=['POST'])
|
||||||
|
@authorized_personnel_only
|
||||||
|
def mfa_get_status():
|
||||||
|
# Anyone accessing this route is an admin, and we permit them to
|
||||||
|
# see the MFA status for any user if they submit a 'user' form
|
||||||
|
# field. But we don't include provisioning info since a user can
|
||||||
|
# only provision for themselves.
|
||||||
|
email = request.form.get('user', request.user_email) # user field if given, otherwise the user making the request
|
||||||
|
try:
|
||||||
|
resp = {
|
||||||
|
"enabled_mfa": get_public_mfa_state(email, env)
|
||||||
|
}
|
||||||
|
if email == request.user_email:
|
||||||
|
resp.update({
|
||||||
|
"new_mfa": {
|
||||||
|
"totp": provision_totp(email, env)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except ValueError as e:
|
||||||
|
return (str(e), 400)
|
||||||
|
return json_response(resp)
|
||||||
|
|
||||||
|
@app.route('/mfa/totp/enable', methods=['POST'])
|
||||||
|
@authorized_personnel_only
|
||||||
|
def totp_post_enable():
|
||||||
|
secret = request.form.get('secret')
|
||||||
|
token = request.form.get('token')
|
||||||
|
label = request.form.get('label')
|
||||||
|
if type(token) != str:
|
||||||
|
return ("Bad Input", 400)
|
||||||
|
try:
|
||||||
|
validate_totp_secret(secret)
|
||||||
|
enable_mfa(request.user_email, "totp", secret, token, label, env)
|
||||||
|
except ValueError as e:
|
||||||
|
return (str(e), 400)
|
||||||
|
return "OK"
|
||||||
|
|
||||||
|
@app.route('/mfa/disable', methods=['POST'])
|
||||||
|
@authorized_personnel_only
|
||||||
|
def totp_post_disable():
|
||||||
|
# Anyone accessing this route is an admin, and we permit them to
|
||||||
|
# disable the MFA status for any user if they submit a 'user' form
|
||||||
|
# field.
|
||||||
|
email = request.form.get('user', request.user_email) # user field if given, otherwise the user making the request
|
||||||
|
try:
|
||||||
|
result = disable_mfa(email, request.form.get('mfa-id') or None, env) # convert empty string to None
|
||||||
|
except ValueError as e:
|
||||||
|
return (str(e), 400)
|
||||||
|
if result: # success
|
||||||
|
return "OK"
|
||||||
|
else: # error
|
||||||
|
return ("Invalid user or MFA id.", 400)
|
||||||
|
|
||||||
# WEB
|
# WEB
|
||||||
|
|
||||||
@@ -437,9 +505,8 @@ def system_status():
|
|||||||
self.items[-1]["extra"].append({ "text": message, "monospace": monospace })
|
self.items[-1]["extra"].append({ "text": message, "monospace": monospace })
|
||||||
output = WebOutput()
|
output = WebOutput()
|
||||||
# Create a temporary pool of processes for the status checks
|
# Create a temporary pool of processes for the status checks
|
||||||
pool = multiprocessing.pool.Pool(processes=5)
|
with multiprocessing.pool.Pool(processes=5) as pool:
|
||||||
run_checks(False, env, output, pool)
|
run_checks(False, env, output, pool)
|
||||||
pool.terminate()
|
|
||||||
return json_response(output.items)
|
return json_response(output.items)
|
||||||
|
|
||||||
@app.route('/system/updates')
|
@app.route('/system/updates')
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ if [ `date "+%u"` -eq 1 ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Take a backup.
|
# Take a backup.
|
||||||
management/backup.py | management/email_administrator.py "Backup Status"
|
management/backup.py 2>&1 | management/email_administrator.py "Backup Status"
|
||||||
|
|
||||||
# Provision any new certificates for new domains or domains with expiring certificates.
|
# Provision any new certificates for new domains or domains with expiring certificates.
|
||||||
management/ssl_certificates.py -q | management/email_administrator.py "Error Provisioning TLS Certificate"
|
management/ssl_certificates.py -q 2>&1 | management/email_administrator.py "TLS Certificate Provisioning Result"
|
||||||
|
|
||||||
# Run status checks and email the administrator if anything changed.
|
# Run status checks and email the administrator if anything changed.
|
||||||
management/status_checks.py --show-changes | management/email_administrator.py "Status Checks Change Notice"
|
management/status_checks.py --show-changes 2>&1 | management/email_administrator.py "Status Checks Change Notice"
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import ipaddress
|
|||||||
import rtyaml
|
import rtyaml
|
||||||
import dns.resolver
|
import dns.resolver
|
||||||
|
|
||||||
from mailconfig import get_mail_domains
|
from mailconfig import get_mail_domains, get_mail_aliases
|
||||||
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
|
||||||
|
from ssl_certificates import get_ssl_certificates, check_certificate
|
||||||
|
|
||||||
# From https://stackoverflow.com/questions/3026957/how-to-validate-a-domain-name-using-regex-php/16491074#16491074
|
# From https://stackoverflow.com/questions/3026957/how-to-validate-a-domain-name-using-regex-php/16491074#16491074
|
||||||
# This regular expression matches domain names according to RFCs, it also accepts fqdn with an leading dot,
|
# This regular expression matches domain names according to RFCs, it also accepts fqdn with an leading dot,
|
||||||
@@ -280,14 +281,85 @@ def build_zone(domain, all_domains, additional_records, www_redirect_domains, en
|
|||||||
if not has_rec(dmarc_qname, "TXT", prefix="v=DMARC1; "):
|
if not has_rec(dmarc_qname, "TXT", prefix="v=DMARC1; "):
|
||||||
records.append((dmarc_qname, "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." % (qname + "." + domain)))
|
records.append((dmarc_qname, "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." % (qname + "." + domain)))
|
||||||
|
|
||||||
# Add CardDAV/CalDAV SRV records on the non-primary hostname that points to the primary hostname.
|
# Add CardDAV/CalDAV SRV records on the non-primary hostname that points to the primary hostname
|
||||||
|
# for autoconfiguration of mail clients (so only domains hosting user accounts need it).
|
||||||
# The SRV record format is priority (0, whatever), weight (0, whatever), port, service provider hostname (w/ trailing dot).
|
# The SRV record format is priority (0, whatever), weight (0, whatever), port, service provider hostname (w/ trailing dot).
|
||||||
if domain != env["PRIMARY_HOSTNAME"]:
|
if domain != env["PRIMARY_HOSTNAME"] and domain in get_mail_domains(env, users_only=True):
|
||||||
for dav in ("card", "cal"):
|
for dav in ("card", "cal"):
|
||||||
qname = "_" + dav + "davs._tcp"
|
qname = "_" + dav + "davs._tcp"
|
||||||
if not has_rec(qname, "SRV"):
|
if not has_rec(qname, "SRV"):
|
||||||
records.append((qname, "SRV", "0 0 443 " + env["PRIMARY_HOSTNAME"] + ".", "Recommended. Specifies the hostname of the server that handles CardDAV/CalDAV services for email addresses on this domain."))
|
records.append((qname, "SRV", "0 0 443 " + env["PRIMARY_HOSTNAME"] + ".", "Recommended. Specifies the hostname of the server that handles CardDAV/CalDAV services for email addresses on this domain."))
|
||||||
|
|
||||||
|
# Adds autoconfiguration A records for all domains that there are user accounts at.
|
||||||
|
# This allows the following clients to automatically configure email addresses in the respective applications.
|
||||||
|
# autodiscover.* - Z-Push ActiveSync Autodiscover
|
||||||
|
# autoconfig.* - Thunderbird Autoconfig
|
||||||
|
if domain in get_mail_domains(env, users_only=True):
|
||||||
|
autodiscover_records = [
|
||||||
|
("autodiscover", "A", env["PUBLIC_IP"], "Provides email configuration autodiscovery support for Z-Push ActiveSync Autodiscover."),
|
||||||
|
("autodiscover", "AAAA", env["PUBLIC_IPV6"], "Provides email configuration autodiscovery support for Z-Push ActiveSync Autodiscover."),
|
||||||
|
("autoconfig", "A", env["PUBLIC_IP"], "Provides email configuration autodiscovery support for Thunderbird Autoconfig."),
|
||||||
|
("autoconfig", "AAAA", env["PUBLIC_IPV6"], "Provides email configuration autodiscovery support for Thunderbird Autoconfig.")
|
||||||
|
]
|
||||||
|
for qname, rtype, value, explanation in autodiscover_records:
|
||||||
|
if value is None or value.strip() == "": continue # skip IPV6 if not set
|
||||||
|
if not has_rec(qname, rtype):
|
||||||
|
records.append((qname, rtype, value, explanation))
|
||||||
|
|
||||||
|
# If this is a domain name that there are email addresses configured for, i.e. "something@"
|
||||||
|
# this domain name, then the domain name is a MTA-STS (https://tools.ietf.org/html/rfc8461)
|
||||||
|
# Policy Domain.
|
||||||
|
#
|
||||||
|
# A "_mta-sts" TXT record signals the presence of a MTA-STS policy. The id field helps clients
|
||||||
|
# cache the policy. It should be stable so we don't update DNS unnecessarily but change when
|
||||||
|
# the policy changes. It must be at most 32 letters and numbers, so we compute a hash of the
|
||||||
|
# policy file.
|
||||||
|
#
|
||||||
|
# The policy itself is served at the "mta-sts" (no underscore) subdomain over HTTPS. Therefore
|
||||||
|
# the TLS certificate used by Postfix for STARTTLS must be a valid certificate for the MX
|
||||||
|
# domain name (PRIMARY_HOSTNAME) *and* the TLS certificate used by nginx for HTTPS on the mta-sts
|
||||||
|
# subdomain must be valid certificate for that domain. Do not set an MTA-STS policy if either
|
||||||
|
# certificate in use is not valid (e.g. because it is self-signed and a valid certificate has not
|
||||||
|
# yet been provisioned). Since we cannot provision a certificate without A/AAAA records, we
|
||||||
|
# always set them --- only the TXT records depend on there being valid certificates.
|
||||||
|
mta_sts_enabled = False
|
||||||
|
mta_sts_records = [
|
||||||
|
("mta-sts", "A", env["PUBLIC_IP"], "Optional. MTA-STS Policy Host serving /.well-known/mta-sts.txt."),
|
||||||
|
("mta-sts", "AAAA", env.get('PUBLIC_IPV6'), "Optional. MTA-STS Policy Host serving /.well-known/mta-sts.txt."),
|
||||||
|
]
|
||||||
|
if domain in get_mail_domains(env):
|
||||||
|
# Check that PRIMARY_HOSTNAME and the mta_sts domain both have valid certificates.
|
||||||
|
for d in (env['PRIMARY_HOSTNAME'], "mta-sts." + domain):
|
||||||
|
cert = get_ssl_certificates(env).get(d)
|
||||||
|
if not cert:
|
||||||
|
break # no certificate provisioned for this domain
|
||||||
|
cert_status = check_certificate(d, cert['certificate'], cert['private-key'])
|
||||||
|
if cert_status[0] != 'OK':
|
||||||
|
break # certificate is not valid
|
||||||
|
else:
|
||||||
|
# 'break' was not encountered above, so both domains are good
|
||||||
|
mta_sts_enabled = True
|
||||||
|
if mta_sts_enabled:
|
||||||
|
# Compute an up-to-32-character hash of the policy file. We'll take a SHA-1 hash of the policy
|
||||||
|
# file (20 bytes) and encode it as base-64 (28 bytes, using alphanumeric alternate characters
|
||||||
|
# instead of '+' and '/' which are not allowed in an MTA-STS policy id) but then just take its
|
||||||
|
# first 20 characters, which is more than sufficient to change whenever the policy file changes
|
||||||
|
# (and ensures any '=' padding at the end of the base64 encoding is dropped).
|
||||||
|
with open("/var/lib/mailinabox/mta-sts.txt", "rb") as f:
|
||||||
|
mta_sts_policy_id = base64.b64encode(hashlib.sha1(f.read()).digest(), altchars=b"AA").decode("ascii")[0:20]
|
||||||
|
mta_sts_records.extend([
|
||||||
|
("_mta-sts", "TXT", "v=STSv1; id=" + mta_sts_policy_id, "Optional. Part of the MTA-STS policy for incoming mail. If set, a MTA-STS policy must also be published.")
|
||||||
|
])
|
||||||
|
|
||||||
|
# Enable SMTP TLS reporting (https://tools.ietf.org/html/rfc8460) if the user has set a config option.
|
||||||
|
# Skip if the rules below if the user has set a custom _smtp._tls record.
|
||||||
|
if env.get("MTA_STS_TLSRPT_RUA") and not has_rec("_smtp._tls", "TXT", prefix="v=TLSRPTv1;"):
|
||||||
|
mta_sts_records.append(("_smtp._tls", "TXT", "v=TLSRPTv1; rua=" + env["MTA_STS_TLSRPT_RUA"], "Optional. Enables MTA-STS reporting."))
|
||||||
|
for qname, rtype, value, explanation in mta_sts_records:
|
||||||
|
if value is None or value.strip() == "": continue # skip IPV6 if not set
|
||||||
|
if not has_rec(qname, rtype):
|
||||||
|
records.append((qname, rtype, value, explanation))
|
||||||
|
|
||||||
# Sort the records. The None records *must* go first in the nsd zone file. Otherwise it doesn't matter.
|
# Sort the records. The None records *must* go first in the nsd zone file. Otherwise it doesn't matter.
|
||||||
records.sort(key = lambda rec : list(reversed(rec[0].split(".")) if rec[0] is not None else ""))
|
records.sort(key = lambda rec : list(reversed(rec[0].split(".")) if rec[0] is not None else ""))
|
||||||
|
|
||||||
@@ -508,9 +580,11 @@ zone:
|
|||||||
""" % (domain, zonefile)
|
""" % (domain, zonefile)
|
||||||
|
|
||||||
# If custom secondary nameservers have been set, allow zone transfers
|
# If custom secondary nameservers have been set, allow zone transfers
|
||||||
# and notifies to them.
|
# and, if not a subnet, notifies to them.
|
||||||
for ipaddr in get_secondary_dns(additional_records, mode="xfr"):
|
for ipaddr in get_secondary_dns(additional_records, mode="xfr"):
|
||||||
nsdconf += "\n\tnotify: %s NOKEY\n\tprovide-xfr: %s NOKEY\n" % (ipaddr, ipaddr)
|
if "/" not in ipaddr:
|
||||||
|
nsdconf += "\n\tnotify: %s NOKEY" % (ipaddr)
|
||||||
|
nsdconf += "\n\tprovide-xfr: %s NOKEY\n" % (ipaddr)
|
||||||
|
|
||||||
# Check if the file is changing. If it isn't changing,
|
# Check if the file is changing. If it isn't changing,
|
||||||
# return False to flag that no change was made.
|
# return False to flag that no change was made.
|
||||||
@@ -858,10 +932,15 @@ def get_secondary_dns(custom_dns, mode=None):
|
|||||||
|
|
||||||
# This is a hostname. Before including in zone xfr lines,
|
# This is a hostname. Before including in zone xfr lines,
|
||||||
# resolve to an IP address. Otherwise just return the hostname.
|
# resolve to an IP address. Otherwise just return the hostname.
|
||||||
|
# It may not resolve to IPv6, so don't throw an exception if it
|
||||||
|
# doesn't.
|
||||||
if not hostname.startswith("xfr:"):
|
if not hostname.startswith("xfr:"):
|
||||||
if mode == "xfr":
|
if mode == "xfr":
|
||||||
response = dns.resolver.query(hostname+'.', "A")
|
response = dns.resolver.query(hostname+'.', "A", raise_on_no_answer=False)
|
||||||
hostname = str(response[0])
|
values.extend(map(str, response))
|
||||||
|
response = dns.resolver.query(hostname+'.', "AAAA", raise_on_no_answer=False)
|
||||||
|
values.extend(map(str, response))
|
||||||
|
continue
|
||||||
values.append(hostname)
|
values.append(hostname)
|
||||||
|
|
||||||
# This is a zone-xfer-only IP address. Do not return if
|
# This is a zone-xfer-only IP address. Do not return if
|
||||||
@@ -884,14 +963,19 @@ def set_secondary_dns(hostnames, env):
|
|||||||
try:
|
try:
|
||||||
response = resolver.query(item, "A")
|
response = resolver.query(item, "A")
|
||||||
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
||||||
raise ValueError("Could not resolve the IP address of %s." % item)
|
try:
|
||||||
|
response = resolver.query(item, "AAAA")
|
||||||
|
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
||||||
|
raise ValueError("Could not resolve the IP address of %s." % item)
|
||||||
else:
|
else:
|
||||||
# Validate IP address.
|
# Validate IP address.
|
||||||
try:
|
try:
|
||||||
v = ipaddress.ip_address(item[4:]) # raises a ValueError if there's a problem
|
if "/" in item[4:]:
|
||||||
if not isinstance(v, ipaddress.IPv4Address): raise ValueError("That's an IPv6 address.")
|
v = ipaddress.ip_network(item[4:]) # raises a ValueError if there's a problem
|
||||||
|
else:
|
||||||
|
v = ipaddress.ip_address(item[4:]) # raises a ValueError if there's a problem
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValueError("'%s' is not an IPv4 address." % item[4:])
|
raise ValueError("'%s' is not an IPv4 or IPv6 address or subnet." % item[4:])
|
||||||
|
|
||||||
# Set.
|
# Set.
|
||||||
set_custom_dns_record("_secondary_nameserver", "A", " ".join(hostnames), "set", env)
|
set_custom_dns_record("_secondary_nameserver", "A", " ".join(hostnames), "set", env)
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ import utils
|
|||||||
|
|
||||||
|
|
||||||
LOG_FILES = (
|
LOG_FILES = (
|
||||||
'/var/log/mail.log',
|
|
||||||
'/var/log/mail.log.1',
|
|
||||||
'/var/log/mail.log.2.gz',
|
|
||||||
'/var/log/mail.log.3.gz',
|
|
||||||
'/var/log/mail.log.4.gz',
|
|
||||||
'/var/log/mail.log.5.gz',
|
|
||||||
'/var/log/mail.log.6.gz',
|
'/var/log/mail.log.6.gz',
|
||||||
|
'/var/log/mail.log.5.gz',
|
||||||
|
'/var/log/mail.log.4.gz',
|
||||||
|
'/var/log/mail.log.3.gz',
|
||||||
|
'/var/log/mail.log.2.gz',
|
||||||
|
'/var/log/mail.log.1',
|
||||||
|
'/var/log/mail.log',
|
||||||
)
|
)
|
||||||
|
|
||||||
TIME_DELTAS = OrderedDict([
|
TIME_DELTAS = OrderedDict([
|
||||||
@@ -80,7 +80,7 @@ def scan_files(collector):
|
|||||||
print("Processing file", fn, "...")
|
print("Processing file", fn, "...")
|
||||||
fn = tmp_file.name if tmp_file else fn
|
fn = tmp_file.name if tmp_file else fn
|
||||||
|
|
||||||
for line in reverse_readline(fn):
|
for line in readline(fn):
|
||||||
if scan_mail_log_line(line.strip(), collector) is False:
|
if scan_mail_log_line(line.strip(), collector) is False:
|
||||||
if stop_scan:
|
if stop_scan:
|
||||||
return
|
return
|
||||||
@@ -344,16 +344,22 @@ def scan_mail_log_line(line, collector):
|
|||||||
|
|
||||||
# Replaced the dateutil parser for a less clever way of parser that is roughly 4 times faster.
|
# Replaced the dateutil parser for a less clever way of parser that is roughly 4 times faster.
|
||||||
# date = dateutil.parser.parse(date)
|
# date = dateutil.parser.parse(date)
|
||||||
date = datetime.datetime.strptime(date, '%b %d %H:%M:%S')
|
|
||||||
date = date.replace(START_DATE.year)
|
# date = datetime.datetime.strptime(date, '%b %d %H:%M:%S')
|
||||||
|
# date = date.replace(START_DATE.year)
|
||||||
|
|
||||||
|
# strptime fails on Feb 29 if correct year is not provided. See https://bugs.python.org/issue26460
|
||||||
|
date = datetime.datetime.strptime(str(START_DATE.year) + ' ' + date, '%Y %b %d %H:%M:%S')
|
||||||
|
# print("date:", date)
|
||||||
|
|
||||||
# Check if the found date is within the time span we are scanning
|
# Check if the found date is within the time span we are scanning
|
||||||
|
# END_DATE < START_DATE
|
||||||
if date > START_DATE:
|
if date > START_DATE:
|
||||||
# Don't process, but continue
|
|
||||||
return True
|
|
||||||
elif date < END_DATE:
|
|
||||||
# Don't process, and halt
|
# Don't process, and halt
|
||||||
return False
|
return False
|
||||||
|
elif date < END_DATE:
|
||||||
|
# Don't process, but continue
|
||||||
|
return True
|
||||||
|
|
||||||
if service == "postfix/submission/smtpd":
|
if service == "postfix/submission/smtpd":
|
||||||
if SCAN_OUT:
|
if SCAN_OUT:
|
||||||
@@ -453,9 +459,9 @@ def scan_postfix_smtpd_line(date, log, collector):
|
|||||||
if m:
|
if m:
|
||||||
message = "domain blocked: " + m.group(2)
|
message = "domain blocked: " + m.group(2)
|
||||||
|
|
||||||
if data["latest"] is None:
|
if data["earliest"] is None:
|
||||||
data["latest"] = date
|
data["earliest"] = date
|
||||||
data["earliest"] = date
|
data["latest"] = date
|
||||||
data["blocked"].append((date, sender, message))
|
data["blocked"].append((date, sender, message))
|
||||||
|
|
||||||
collector["rejected"][user] = data
|
collector["rejected"][user] = data
|
||||||
@@ -487,9 +493,9 @@ def add_login(user, date, protocol_name, host, collector):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if data["latest"] is None:
|
if data["earliest"] is None:
|
||||||
data["latest"] = date
|
data["earliest"] = date
|
||||||
data["earliest"] = date
|
data["latest"] = date
|
||||||
|
|
||||||
data["totals_by_protocol"][protocol_name] += 1
|
data["totals_by_protocol"][protocol_name] += 1
|
||||||
data["totals_by_protocol_and_host"][(protocol_name, host)] += 1
|
data["totals_by_protocol_and_host"][(protocol_name, host)] += 1
|
||||||
@@ -528,9 +534,9 @@ def scan_postfix_lmtp_line(date, log, collector):
|
|||||||
data["received_count"] += 1
|
data["received_count"] += 1
|
||||||
data["activity-by-hour"][date.hour] += 1
|
data["activity-by-hour"][date.hour] += 1
|
||||||
|
|
||||||
if data["latest"] is None:
|
if data["earliest"] is None:
|
||||||
data["latest"] = date
|
data["earliest"] = date
|
||||||
data["earliest"] = date
|
data["latest"] = date
|
||||||
|
|
||||||
collector["received_mail"][user] = data
|
collector["received_mail"][user] = data
|
||||||
|
|
||||||
@@ -567,9 +573,9 @@ def scan_postfix_submission_line(date, log, collector):
|
|||||||
data["hosts"].add(client)
|
data["hosts"].add(client)
|
||||||
data["activity-by-hour"][date.hour] += 1
|
data["activity-by-hour"][date.hour] += 1
|
||||||
|
|
||||||
if data["latest"] is None:
|
if data["earliest"] is None:
|
||||||
data["latest"] = date
|
data["earliest"] = date
|
||||||
data["earliest"] = date
|
data["latest"] = date
|
||||||
|
|
||||||
collector["sent_mail"][user] = data
|
collector["sent_mail"][user] = data
|
||||||
|
|
||||||
@@ -578,42 +584,15 @@ def scan_postfix_submission_line(date, log, collector):
|
|||||||
|
|
||||||
# Utility functions
|
# Utility functions
|
||||||
|
|
||||||
def reverse_readline(filename, buf_size=8192):
|
def readline(filename):
|
||||||
""" A generator that returns the lines of a file in reverse order
|
""" A generator that returns the lines of a file
|
||||||
|
|
||||||
http://stackoverflow.com/a/23646049/801870
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
with open(filename) as file:
|
||||||
with open(filename) as fh:
|
while True:
|
||||||
segment = None
|
line = file.readline()
|
||||||
offset = 0
|
if not line:
|
||||||
fh.seek(0, os.SEEK_END)
|
break
|
||||||
file_size = remaining_size = fh.tell()
|
yield line
|
||||||
while remaining_size > 0:
|
|
||||||
offset = min(file_size, offset + buf_size)
|
|
||||||
fh.seek(file_size - offset)
|
|
||||||
buff = fh.read(min(remaining_size, buf_size))
|
|
||||||
remaining_size -= buf_size
|
|
||||||
lines = buff.split('\n')
|
|
||||||
# the first line of the buffer is probably not a complete line so
|
|
||||||
# we'll save it and append it to the last line of the next buffer
|
|
||||||
# we read
|
|
||||||
if segment is not None:
|
|
||||||
# if the previous chunk starts right from the beginning of line
|
|
||||||
# do not concat the segment to the last line of new chunk
|
|
||||||
# instead, yield the segment first
|
|
||||||
if buff[-1] is not '\n':
|
|
||||||
lines[-1] += segment
|
|
||||||
else:
|
|
||||||
yield segment
|
|
||||||
segment = lines[0]
|
|
||||||
for index in range(len(lines) - 1, 0, -1):
|
|
||||||
if len(lines[index]):
|
|
||||||
yield lines[index]
|
|
||||||
# Don't yield None if the file was empty
|
|
||||||
if segment is not None:
|
|
||||||
yield segment
|
|
||||||
|
|
||||||
|
|
||||||
def user_match(user):
|
def user_match(user):
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ def get_mail_users_ex(env, with_archived=False):
|
|||||||
if email in active_accounts: continue
|
if email in active_accounts: continue
|
||||||
user = {
|
user = {
|
||||||
"email": email,
|
"email": email,
|
||||||
"privileges": "",
|
"privileges": [],
|
||||||
"status": "inactive",
|
"status": "inactive",
|
||||||
"mailbox": mbox,
|
"mailbox": mbox,
|
||||||
}
|
}
|
||||||
@@ -258,13 +258,15 @@ def get_domain(emailaddr, as_unicode=True):
|
|||||||
pass
|
pass
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def get_mail_domains(env, filter_aliases=lambda alias : True):
|
def get_mail_domains(env, filter_aliases=lambda alias : True, users_only=False):
|
||||||
# Returns the domain names (IDNA-encoded) of all of the email addresses
|
# Returns the domain names (IDNA-encoded) of all of the email addresses
|
||||||
# configured on the system.
|
# configured on the system. If users_only is True, only return domains
|
||||||
return set(
|
# with email addresses that correspond to user accounts.
|
||||||
[get_domain(login, as_unicode=False) for login in get_mail_users(env)]
|
domains = []
|
||||||
+ [get_domain(address, as_unicode=False) for address, *_ in get_mail_aliases(env) if filter_aliases(address) ]
|
domains.extend([get_domain(login, as_unicode=False) for login in get_mail_users(env)])
|
||||||
)
|
if not users_only:
|
||||||
|
domains.extend([get_domain(address, as_unicode=False) for address, *_ in get_mail_aliases(env) if filter_aliases(address) ])
|
||||||
|
return set(domains)
|
||||||
|
|
||||||
def add_mail_user(email, pw, privs, env):
|
def add_mail_user(email, pw, privs, env):
|
||||||
# validate email
|
# validate email
|
||||||
@@ -603,12 +605,9 @@ def validate_password(pw):
|
|||||||
# validate password
|
# validate password
|
||||||
if pw.strip() == "":
|
if pw.strip() == "":
|
||||||
raise ValueError("No password provided.")
|
raise ValueError("No password provided.")
|
||||||
if re.search(r"[\s]", pw):
|
|
||||||
raise ValueError("Passwords cannot contain spaces.")
|
|
||||||
if len(pw) < 8:
|
if len(pw) < 8:
|
||||||
raise ValueError("Passwords must be at least eight characters.")
|
raise ValueError("Passwords must be at least eight characters.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import sys
|
import sys
|
||||||
if len(sys.argv) > 2 and sys.argv[1] == "validate-email":
|
if len(sys.argv) > 2 and sys.argv[1] == "validate-email":
|
||||||
|
|||||||
141
management/mfa.py
Normal file
141
management/mfa.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import base64
|
||||||
|
import hmac
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import pyotp
|
||||||
|
import qrcode
|
||||||
|
|
||||||
|
from mailconfig import open_database
|
||||||
|
|
||||||
|
def get_user_id(email, c):
|
||||||
|
c.execute('SELECT id FROM users WHERE email=?', (email,))
|
||||||
|
r = c.fetchone()
|
||||||
|
if not r: raise ValueError("User does not exist.")
|
||||||
|
return r[0]
|
||||||
|
|
||||||
|
def get_mfa_state(email, env):
|
||||||
|
c = open_database(env)
|
||||||
|
c.execute('SELECT id, type, secret, mru_token, label FROM mfa WHERE user_id=?', (get_user_id(email, c),))
|
||||||
|
return [
|
||||||
|
{ "id": r[0], "type": r[1], "secret": r[2], "mru_token": r[3], "label": r[4] }
|
||||||
|
for r in c.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_public_mfa_state(email, env):
|
||||||
|
mfa_state = get_mfa_state(email, env)
|
||||||
|
return [
|
||||||
|
{ "id": s["id"], "type": s["type"], "label": s["label"] }
|
||||||
|
for s in mfa_state
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_hash_mfa_state(email, env):
|
||||||
|
mfa_state = get_mfa_state(email, env)
|
||||||
|
return [
|
||||||
|
{ "id": s["id"], "type": s["type"], "secret": s["secret"] }
|
||||||
|
for s in mfa_state
|
||||||
|
]
|
||||||
|
|
||||||
|
def enable_mfa(email, type, secret, token, label, env):
|
||||||
|
if type == "totp":
|
||||||
|
validate_totp_secret(secret)
|
||||||
|
# Sanity check with the provide current token.
|
||||||
|
totp = pyotp.TOTP(secret)
|
||||||
|
if not totp.verify(token, valid_window=1):
|
||||||
|
raise ValueError("Invalid token.")
|
||||||
|
else:
|
||||||
|
raise ValueError("Invalid MFA type.")
|
||||||
|
|
||||||
|
conn, c = open_database(env, with_connection=True)
|
||||||
|
c.execute('INSERT INTO mfa (user_id, type, secret, label) VALUES (?, ?, ?, ?)', (get_user_id(email, c), type, secret, label))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def set_mru_token(email, mfa_id, token, env):
|
||||||
|
conn, c = open_database(env, with_connection=True)
|
||||||
|
c.execute('UPDATE mfa SET mru_token=? WHERE user_id=? AND id=?', (token, get_user_id(email, c), mfa_id))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def disable_mfa(email, mfa_id, env):
|
||||||
|
conn, c = open_database(env, with_connection=True)
|
||||||
|
if mfa_id is None:
|
||||||
|
# Disable all MFA for a user.
|
||||||
|
c.execute('DELETE FROM mfa WHERE user_id=?', (get_user_id(email, c),))
|
||||||
|
else:
|
||||||
|
# Disable a particular MFA mode for a user.
|
||||||
|
c.execute('DELETE FROM mfa WHERE user_id=? AND id=?', (get_user_id(email, c), mfa_id))
|
||||||
|
conn.commit()
|
||||||
|
return c.rowcount > 0
|
||||||
|
|
||||||
|
def validate_totp_secret(secret):
|
||||||
|
if type(secret) != str or secret.strip() == "":
|
||||||
|
raise ValueError("No secret provided.")
|
||||||
|
if len(secret) != 32:
|
||||||
|
raise ValueError("Secret should be a 32 characters base32 string")
|
||||||
|
|
||||||
|
def provision_totp(email, env):
|
||||||
|
# Make a new secret.
|
||||||
|
secret = base64.b32encode(os.urandom(20)).decode('utf-8')
|
||||||
|
validate_totp_secret(secret) # sanity check
|
||||||
|
|
||||||
|
# Make a URI that we encode within a QR code.
|
||||||
|
uri = pyotp.TOTP(secret).provisioning_uri(
|
||||||
|
name=email,
|
||||||
|
issuer_name=env["PRIMARY_HOSTNAME"] + " Mail-in-a-Box Control Panel"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate a QR code as a base64-encode PNG image.
|
||||||
|
qr = qrcode.make(uri)
|
||||||
|
byte_arr = io.BytesIO()
|
||||||
|
qr.save(byte_arr, format='PNG')
|
||||||
|
png_b64 = base64.b64encode(byte_arr.getvalue()).decode('utf-8')
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "totp",
|
||||||
|
"secret": secret,
|
||||||
|
"qr_code_base64": png_b64
|
||||||
|
}
|
||||||
|
|
||||||
|
def validate_auth_mfa(email, request, env):
|
||||||
|
# Validates that a login request satisfies any MFA modes
|
||||||
|
# that have been enabled for the user's account. Returns
|
||||||
|
# a tuple (status, [hints]). status is True for a successful
|
||||||
|
# MFA login, False for a missing token. If status is False,
|
||||||
|
# hints is an array of codes that indicate what the user
|
||||||
|
# can try. Possible codes are:
|
||||||
|
# "missing-totp-token"
|
||||||
|
# "invalid-totp-token"
|
||||||
|
|
||||||
|
mfa_state = get_mfa_state(email, env)
|
||||||
|
|
||||||
|
# If no MFA modes are added, return True.
|
||||||
|
if len(mfa_state) == 0:
|
||||||
|
return (True, [])
|
||||||
|
|
||||||
|
# Try the enabled MFA modes.
|
||||||
|
hints = set()
|
||||||
|
for mfa_mode in mfa_state:
|
||||||
|
if mfa_mode["type"] == "totp":
|
||||||
|
# Check that a token is present in the X-Auth-Token header.
|
||||||
|
# If not, give a hint that one can be supplied.
|
||||||
|
token = request.headers.get('x-auth-token')
|
||||||
|
if not token:
|
||||||
|
hints.add("missing-totp-token")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for a replay attack.
|
||||||
|
if hmac.compare_digest(token, mfa_mode['mru_token'] or ""):
|
||||||
|
# If the token fails, skip this MFA mode.
|
||||||
|
hints.add("invalid-totp-token")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check the token.
|
||||||
|
totp = pyotp.TOTP(mfa_mode["secret"])
|
||||||
|
if not totp.verify(token, valid_window=1):
|
||||||
|
hints.add("invalid-totp-token")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# On success, record the token to prevent a replay attack.
|
||||||
|
set_mru_token(email, mfa_mode['id'], token, env)
|
||||||
|
return (True, [])
|
||||||
|
|
||||||
|
# On a failed login, indicate failure and any hints for what the user can do instead.
|
||||||
|
return (False, list(hints))
|
||||||
@@ -180,7 +180,7 @@ def get_certificates_to_provision(env, limit_domains=None, show_valid_certs=True
|
|||||||
# for and subtract:
|
# for and subtract:
|
||||||
# * domains not in limit_domains if limit_domains is not empty
|
# * domains not in limit_domains if limit_domains is not empty
|
||||||
# * domains with custom "A" records, i.e. they are hosted elsewhere
|
# * domains with custom "A" records, i.e. they are hosted elsewhere
|
||||||
# * domains with actual "A" records that point elsewhere
|
# * domains with actual "A" records that point elsewhere (misconfiguration)
|
||||||
# * domains that already have certificates that will be valid for a while
|
# * domains that already have certificates that will be valid for a while
|
||||||
|
|
||||||
from web_update import get_web_domains
|
from web_update import get_web_domains
|
||||||
@@ -216,12 +216,12 @@ def get_certificates_to_provision(env, limit_domains=None, show_valid_certs=True
|
|||||||
response = query_dns(domain, rtype)
|
response = query_dns(domain, rtype)
|
||||||
if response != normalize_ip(value):
|
if response != normalize_ip(value):
|
||||||
bad_dns.append("%s (%s)" % (response, rtype))
|
bad_dns.append("%s (%s)" % (response, rtype))
|
||||||
|
|
||||||
if bad_dns:
|
if bad_dns:
|
||||||
domains_cant_provision[domain] = "The domain name does not resolve to this machine: " \
|
domains_cant_provision[domain] = "The domain name does not resolve to this machine: " \
|
||||||
+ (", ".join(bad_dns)) \
|
+ (", ".join(bad_dns)) \
|
||||||
+ "."
|
+ "."
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# DNS is all good.
|
# DNS is all good.
|
||||||
|
|
||||||
@@ -256,15 +256,41 @@ def provision_certificates(env, limit_domains):
|
|||||||
"result": "skipped",
|
"result": "skipped",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Break into groups by DNS zone: Group every domain with its parent domain, if
|
||||||
|
# its parent domain is in the list of domains to request a certificate for.
|
||||||
|
# Start with the zones so that if the zone doesn't need a certificate itself,
|
||||||
|
# its children will still be grouped together. Sort the provision domains to
|
||||||
|
# put parents ahead of children.
|
||||||
|
# Since Let's Encrypt requests are limited to 100 domains at a time,
|
||||||
|
# we'll create a list of lists of domains where the inner lists have
|
||||||
|
# at most 100 items. By sorting we also get the DNS zone domain as the first
|
||||||
|
# entry in each list (unless we overflow beyond 100) which ends up as the
|
||||||
|
# primary domain listed in each certificate.
|
||||||
|
from dns_update import get_dns_zones
|
||||||
|
certs = { }
|
||||||
|
for zone, zonefile in get_dns_zones(env):
|
||||||
|
certs[zone] = [[]]
|
||||||
|
for domain in sort_domains(domains, env):
|
||||||
|
# Does the domain end with any domain we've seen so far.
|
||||||
|
for parent in certs.keys():
|
||||||
|
if domain.endswith("." + parent):
|
||||||
|
# Add this to the parent's list of domains.
|
||||||
|
# Start a new group if the list already has
|
||||||
|
# 100 items.
|
||||||
|
if len(certs[parent][-1]) == 100:
|
||||||
|
certs[parent].append([])
|
||||||
|
certs[parent][-1].append(domain)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# This domain is not a child of any domain we've seen yet, so
|
||||||
|
# start a new group. This shouldn't happen since every zone
|
||||||
|
# was already added.
|
||||||
|
certs[domain] = [[domain]]
|
||||||
|
|
||||||
# Break into groups of up to 100 certificates at a time, which is Let's Encrypt's
|
# Flatten to a list of lists of domains (from a mapping). Remove empty
|
||||||
# limit for a single certificate. We'll sort to put related domains together.
|
# lists (zones with no domains that need certs).
|
||||||
max_domains_per_group = 100
|
certs = sum(certs.values(), [])
|
||||||
domains = sort_domains(domains, env)
|
certs = [_ for _ in certs if len(_) > 0]
|
||||||
certs = []
|
|
||||||
while len(domains) > 0:
|
|
||||||
certs.append( domains[:max_domains_per_group] )
|
|
||||||
domains = domains[max_domains_per_group:]
|
|
||||||
|
|
||||||
# Prepare to provision.
|
# Prepare to provision.
|
||||||
|
|
||||||
@@ -580,10 +606,10 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
|
|||||||
ndays = (cert_expiration_date-now).days
|
ndays = (cert_expiration_date-now).days
|
||||||
if not rounded_time or ndays <= 10:
|
if not rounded_time or ndays <= 10:
|
||||||
# Yikes better renew soon!
|
# Yikes better renew soon!
|
||||||
expiry_info = "The certificate expires in %d days on %s." % (ndays, cert_expiration_date.strftime("%x"))
|
expiry_info = "The certificate expires in %d days on %s." % (ndays, cert_expiration_date.date().isoformat())
|
||||||
else:
|
else:
|
||||||
# We'll renew it with Lets Encrypt.
|
# We'll renew it with Lets Encrypt.
|
||||||
expiry_info = "The certificate expires on %s." % cert_expiration_date.strftime("%x")
|
expiry_info = "The certificate expires on %s." % cert_expiration_date.date().isoformat()
|
||||||
|
|
||||||
if warn_if_expiring_soon and ndays <= warn_if_expiring_soon:
|
if warn_if_expiring_soon and ndays <= warn_if_expiring_soon:
|
||||||
# Warn on day 10 to give 4 days for us to automatically renew the
|
# Warn on day 10 to give 4 days for us to automatically renew the
|
||||||
|
|||||||
@@ -5,11 +5,13 @@
|
|||||||
# what to do next.
|
# what to do next.
|
||||||
|
|
||||||
import sys, os, os.path, re, subprocess, datetime, multiprocessing.pool
|
import sys, os, os.path, re, subprocess, datetime, multiprocessing.pool
|
||||||
|
import asyncio
|
||||||
|
|
||||||
import dns.reversename, dns.resolver
|
import dns.reversename, dns.resolver
|
||||||
import dateutil.parser, dateutil.tz
|
import dateutil.parser, dateutil.tz
|
||||||
import idna
|
import idna
|
||||||
import psutil
|
import psutil
|
||||||
|
import postfix_mta_sts_resolver.resolver
|
||||||
|
|
||||||
from dns_update import get_dns_zones, build_tlsa_record, get_custom_dns_config, get_secondary_dns, get_custom_dns_records
|
from dns_update import get_dns_zones, build_tlsa_record, get_custom_dns_config, get_secondary_dns, get_custom_dns_records
|
||||||
from web_update import get_web_domains, get_domains_with_a_records
|
from web_update import get_web_domains, get_domains_with_a_records
|
||||||
@@ -309,6 +311,17 @@ def run_domain_checks(rounded_time, env, output, pool):
|
|||||||
|
|
||||||
domains_to_check = mail_domains | dns_domains | web_domains
|
domains_to_check = mail_domains | dns_domains | web_domains
|
||||||
|
|
||||||
|
# Remove "www", "autoconfig", "autodiscover", and "mta-sts" subdomains, which we group with their parent,
|
||||||
|
# if their parent is in the domains to check list.
|
||||||
|
domains_to_check = [
|
||||||
|
d for d in domains_to_check
|
||||||
|
if not (
|
||||||
|
d.split(".", 1)[0] in ("www", "autoconfig", "autodiscover", "mta-sts")
|
||||||
|
and len(d.split(".", 1)) == 2
|
||||||
|
and d.split(".", 1)[1] in domains_to_check
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
# Get the list of domains that we don't serve web for because of a custom CNAME/A record.
|
# Get the list of domains that we don't serve web for because of a custom CNAME/A record.
|
||||||
domains_with_a_records = get_domains_with_a_records(env)
|
domains_with_a_records = get_domains_with_a_records(env)
|
||||||
|
|
||||||
@@ -327,6 +340,11 @@ def run_domain_checks(rounded_time, env, output, pool):
|
|||||||
def run_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains, domains_with_a_records):
|
def run_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains, domains_with_a_records):
|
||||||
output = BufferedOutput()
|
output = BufferedOutput()
|
||||||
|
|
||||||
|
# When running inside Flask, the worker threads don't get a thread pool automatically.
|
||||||
|
# Also this method is called in a forked worker pool, so creating a new loop is probably
|
||||||
|
# a good idea.
|
||||||
|
asyncio.set_event_loop(asyncio.new_event_loop())
|
||||||
|
|
||||||
# we'd move this up, but this returns non-pickleable values
|
# we'd move this up, but this returns non-pickleable values
|
||||||
ssl_certificates = get_ssl_certificates(env)
|
ssl_certificates = get_ssl_certificates(env)
|
||||||
|
|
||||||
@@ -354,6 +372,26 @@ def run_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zone
|
|||||||
if domain in dns_domains:
|
if domain in dns_domains:
|
||||||
check_dns_zone_suggestions(domain, env, output, dns_zonefiles, domains_with_a_records)
|
check_dns_zone_suggestions(domain, env, output, dns_zonefiles, domains_with_a_records)
|
||||||
|
|
||||||
|
# Check auto-configured subdomains. See run_domain_checks.
|
||||||
|
# Skip mta-sts because we check the policy directly.
|
||||||
|
for label in ("www", "autoconfig", "autodiscover"):
|
||||||
|
subdomain = label + "." + domain
|
||||||
|
if subdomain in web_domains or subdomain in mail_domains:
|
||||||
|
# Run checks.
|
||||||
|
subdomain_output = run_domain_checks_on_domain(subdomain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains, domains_with_a_records)
|
||||||
|
|
||||||
|
# Prepend the domain name to the start of each check line, and then add to the
|
||||||
|
# checks for this domain.
|
||||||
|
for attr, args, kwargs in subdomain_output[1].buf:
|
||||||
|
if attr == "add_heading":
|
||||||
|
# Drop the heading, but use its text as the subdomain name in
|
||||||
|
# each line since it is in Unicode form.
|
||||||
|
subdomain = args[0]
|
||||||
|
continue
|
||||||
|
if len(args) == 1 and isinstance(args[0], str):
|
||||||
|
args = [ subdomain + ": " + args[0] ]
|
||||||
|
getattr(output, attr)(*args, **kwargs)
|
||||||
|
|
||||||
return (domain, output)
|
return (domain, output)
|
||||||
|
|
||||||
def check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles):
|
def check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles):
|
||||||
@@ -486,10 +524,12 @@ def check_dns_zone(domain, env, output, dns_zonefiles):
|
|||||||
if custom_secondary_ns and not probably_external_dns:
|
if custom_secondary_ns and not probably_external_dns:
|
||||||
for ns in custom_secondary_ns:
|
for ns in custom_secondary_ns:
|
||||||
# We must first resolve the nameserver to an IP address so we can query it.
|
# We must first resolve the nameserver to an IP address so we can query it.
|
||||||
ns_ip = query_dns(ns, "A")
|
ns_ips = query_dns(ns, "A")
|
||||||
if not ns_ip:
|
if not ns_ips:
|
||||||
output.print_error("Secondary nameserver %s is not valid (it doesn't resolve to an IP address)." % ns)
|
output.print_error("Secondary nameserver %s is not valid (it doesn't resolve to an IP address)." % ns)
|
||||||
continue
|
continue
|
||||||
|
# Choose the first IP if nameserver returns multiple
|
||||||
|
ns_ip = ns_ips.split('; ')[0]
|
||||||
|
|
||||||
# Now query it to see what it says about this domain.
|
# Now query it to see what it says about this domain.
|
||||||
ip = query_dns(domain, "A", at=ns_ip, nxdomain=None)
|
ip = query_dns(domain, "A", at=ns_ip, nxdomain=None)
|
||||||
@@ -609,6 +649,19 @@ def check_mail_domain(domain, env, output):
|
|||||||
if mx != recommended_mx:
|
if mx != recommended_mx:
|
||||||
good_news += " This configuration is non-standard. The recommended configuration is '%s'." % (recommended_mx,)
|
good_news += " This configuration is non-standard. The recommended configuration is '%s'." % (recommended_mx,)
|
||||||
output.print_ok(good_news)
|
output.print_ok(good_news)
|
||||||
|
|
||||||
|
# Check MTA-STS policy.
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
sts_resolver = postfix_mta_sts_resolver.resolver.STSResolver(loop=loop)
|
||||||
|
valid, policy = loop.run_until_complete(sts_resolver.resolve(domain))
|
||||||
|
if valid == postfix_mta_sts_resolver.resolver.STSFetchResult.VALID:
|
||||||
|
if policy[1].get("mx") == [env['PRIMARY_HOSTNAME']] and policy[1].get("mode") == "enforce": # policy[0] is the policyid
|
||||||
|
output.print_ok("MTA-STS policy is present.")
|
||||||
|
else:
|
||||||
|
output.print_error("MTA-STS policy is present but has unexpected settings. [{}]".format(policy[1]))
|
||||||
|
else:
|
||||||
|
output.print_error("MTA-STS policy is missing: {}".format(valid))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
output.print_error("""This domain's DNS MX record is incorrect. It is currently set to '%s' but should be '%s'. Mail will not
|
output.print_error("""This domain's DNS MX record is incorrect. It is currently set to '%s' but should be '%s'. Mail will not
|
||||||
be delivered to this box. It may take several hours for public DNS to update after a change. This problem may result from
|
be delivered to this box. It may take several hours for public DNS to update after a change. This problem may result from
|
||||||
@@ -968,13 +1021,14 @@ if __name__ == "__main__":
|
|||||||
from utils import load_environment
|
from utils import load_environment
|
||||||
|
|
||||||
env = load_environment()
|
env = load_environment()
|
||||||
pool = multiprocessing.pool.Pool(processes=10)
|
|
||||||
|
|
||||||
if len(sys.argv) == 1:
|
if len(sys.argv) == 1:
|
||||||
run_checks(False, env, ConsoleOutput(), pool)
|
with multiprocessing.pool.Pool(processes=10) as pool:
|
||||||
|
run_checks(False, env, ConsoleOutput(), pool)
|
||||||
|
|
||||||
elif sys.argv[1] == "--show-changes":
|
elif sys.argv[1] == "--show-changes":
|
||||||
run_and_output_changes(env, pool)
|
with multiprocessing.pool.Pool(processes=10) as pool:
|
||||||
|
run_and_output_changes(env, pool)
|
||||||
|
|
||||||
elif sys.argv[1] == "--check-primary-hostname":
|
elif sys.argv[1] == "--check-primary-hostname":
|
||||||
# See if the primary hostname appears resolvable and has a signed certificate.
|
# See if the primary hostname appears resolvable and has a signed certificate.
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ function aliases_remove(elem) {
|
|||||||
},
|
},
|
||||||
function(r) {
|
function(r) {
|
||||||
// Responses are multiple lines of pre-formatted text.
|
// Responses are multiple lines of pre-formatted text.
|
||||||
show_modal_error("Remove User", $("<pre/>").text(r));
|
show_modal_error("Remove Alias", $("<pre/>").text(r));
|
||||||
show_aliases();
|
show_aliases();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -90,7 +90,7 @@
|
|||||||
<div class="col-sm-offset-1 col-sm-11">
|
<div class="col-sm-offset-1 col-sm-11">
|
||||||
<p class="small">
|
<p class="small">
|
||||||
Multiple secondary servers can be separated with commas or spaces (i.e., <code>ns2.hostingcompany.com ns3.hostingcompany.com</code>).
|
Multiple secondary servers can be separated with commas or spaces (i.e., <code>ns2.hostingcompany.com ns3.hostingcompany.com</code>).
|
||||||
To enable zone transfers to additional servers without listing them as secondary nameservers, add <code>xfr:IPADDRESS</code>.
|
To enable zone transfers to additional servers without listing them as secondary nameservers, add an IP address or subnet using <code>xfr:10.20.30.40</code> or <code>xfr:10.0.0.0/8</code>.
|
||||||
</p>
|
</p>
|
||||||
<p id="secondarydns-clear-instructions" style="display: none" class="small">
|
<p id="secondarydns-clear-instructions" style="display: none" class="small">
|
||||||
Clear the input field above and click Update to use this machine itself as secondary DNS, which is the default/normal setup.
|
Clear the input field above and click Update to use this machine itself as secondary DNS, which is the default/normal setup.
|
||||||
@@ -192,6 +192,22 @@ function show_current_custom_dns() {
|
|||||||
$('#custom-dns-current').fadeIn();
|
$('#custom-dns-current').fadeIn();
|
||||||
else
|
else
|
||||||
$('#custom-dns-current').fadeOut();
|
$('#custom-dns-current').fadeOut();
|
||||||
|
|
||||||
|
var reverse_fqdn = function(el) {
|
||||||
|
el.qname = el.qname.split('.').reverse().join('.');
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
var sort = function(a, b) {
|
||||||
|
if(a.qname === b.qname) {
|
||||||
|
if(a.rtype === b.rtype) {
|
||||||
|
return a.value > b.value ? 1 : -1;
|
||||||
|
}
|
||||||
|
return a.rtype > b.rtype ? 1 : -1;
|
||||||
|
}
|
||||||
|
return a.qname > b.qname ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
data = data.map(reverse_fqdn).sort(sort).map(reverse_fqdn);
|
||||||
|
|
||||||
$('#custom-dns-current').find("tbody").text('');
|
$('#custom-dns-current').find("tbody").text('');
|
||||||
for (var i = 0; i < data.length; i++) {
|
for (var i = 0; i < data.length; i++) {
|
||||||
|
|||||||
@@ -97,11 +97,14 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="dropdown">
|
<li class="dropdown">
|
||||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown">Mail <b class="caret"></b></a>
|
<a href="#" class="dropdown-toggle" data-toggle="dropdown">Mail & Users <b class="caret"></b></a>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
<li><a href="#mail-guide" onclick="return show_panel(this);">Instructions</a></li>
|
<li><a href="#mail-guide" onclick="return show_panel(this);">Instructions</a></li>
|
||||||
<li><a href="#users" onclick="return show_panel(this);">Users</a></li>
|
<li><a href="#users" onclick="return show_panel(this);">Users</a></li>
|
||||||
<li><a href="#aliases" onclick="return show_panel(this);">Aliases</a></li>
|
<li><a href="#aliases" onclick="return show_panel(this);">Aliases</a></li>
|
||||||
|
<li class="divider"></li>
|
||||||
|
<li class="dropdown-header">Your Account</li>
|
||||||
|
<li><a href="#mfa" onclick="return show_panel(this);">Two-Factor Authentication</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li><a href="#sync_guide" onclick="return show_panel(this);">Contacts/Calendar</a></li>
|
<li><a href="#sync_guide" onclick="return show_panel(this);">Contacts/Calendar</a></li>
|
||||||
@@ -131,6 +134,10 @@
|
|||||||
{% include "custom-dns.html" %}
|
{% include "custom-dns.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="panel_mfa" class="admin_panel">
|
||||||
|
{% include "mfa.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="panel_login" class="admin_panel">
|
<div id="panel_login" class="admin_panel">
|
||||||
{% include "login.html" %}
|
{% include "login.html" %}
|
||||||
</div>
|
</div>
|
||||||
@@ -292,7 +299,7 @@ function ajax_with_indicator(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var api_credentials = ["", ""];
|
var api_credentials = ["", ""];
|
||||||
function api(url, method, data, callback, callback_error) {
|
function api(url, method, data, callback, callback_error, headers) {
|
||||||
// from http://www.webtoolkit.info/javascript-base64.html
|
// from http://www.webtoolkit.info/javascript-base64.html
|
||||||
function base64encode(input) {
|
function base64encode(input) {
|
||||||
_keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
|
_keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
|
||||||
@@ -330,7 +337,7 @@ function api(url, method, data, callback, callback_error) {
|
|||||||
method: method,
|
method: method,
|
||||||
cache: false,
|
cache: false,
|
||||||
data: data,
|
data: data,
|
||||||
|
headers: headers,
|
||||||
// the custom DNS api sends raw POST/PUT bodies --- prevent URL-encoding
|
// the custom DNS api sends raw POST/PUT bodies --- prevent URL-encoding
|
||||||
processData: typeof data != "string",
|
processData: typeof data != "string",
|
||||||
mimeType: typeof data == "string" ? "text/plain; charset=ascii" : null,
|
mimeType: typeof data == "string" ? "text/plain; charset=ascii" : null,
|
||||||
@@ -358,6 +365,16 @@ function api(url, method, data, callback, callback_error) {
|
|||||||
|
|
||||||
var current_panel = null;
|
var current_panel = null;
|
||||||
var switch_back_to_panel = null;
|
var switch_back_to_panel = null;
|
||||||
|
|
||||||
|
function do_logout() {
|
||||||
|
api_credentials = ["", ""];
|
||||||
|
if (typeof localStorage != 'undefined')
|
||||||
|
localStorage.removeItem("miab-cp-credentials");
|
||||||
|
if (typeof sessionStorage != 'undefined')
|
||||||
|
sessionStorage.removeItem("miab-cp-credentials");
|
||||||
|
show_panel('login');
|
||||||
|
}
|
||||||
|
|
||||||
function show_panel(panelid) {
|
function show_panel(panelid) {
|
||||||
if (panelid.getAttribute)
|
if (panelid.getAttribute)
|
||||||
// we might be passed an HTMLElement <a>.
|
// we might be passed an HTMLElement <a>.
|
||||||
|
|||||||
@@ -1,4 +1,29 @@
|
|||||||
<h1 style="margin: 1em; text-align: center">{{hostname}}</h1>
|
<style>
|
||||||
|
.title {
|
||||||
|
margin: 1em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 2em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 32em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login #loginOtp {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loginForm.is-twofactor #loginOtp {
|
||||||
|
display: block
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<h1 class="title">{{hostname}}</h1>
|
||||||
|
|
||||||
{% if no_users_exist or no_admins_exist %}
|
{% if no_users_exist or no_admins_exist %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -7,23 +32,23 @@
|
|||||||
<p class="text-danger">There are no users on this system! To make an administrative user,
|
<p class="text-danger">There are no users on this system! To make an administrative user,
|
||||||
log into this machine using SSH (like when you first set it up) and run:</p>
|
log into this machine using SSH (like when you first set it up) and run:</p>
|
||||||
<pre>cd mailinabox
|
<pre>cd mailinabox
|
||||||
sudo tools/mail.py user add me@{{hostname}}
|
sudo management/cli.py user add me@{{hostname}}
|
||||||
sudo tools/mail.py user make-admin me@{{hostname}}</pre>
|
sudo management/cli.py user make-admin me@{{hostname}}</pre>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-danger">There are no administrative users on this system! To make an administrative user,
|
<p class="text-danger">There are no administrative users on this system! To make an administrative user,
|
||||||
log into this machine using SSH (like when you first set it up) and run:</p>
|
log into this machine using SSH (like when you first set it up) and run:</p>
|
||||||
<pre>cd mailinabox
|
<pre>cd mailinabox
|
||||||
sudo tools/mail.py user make-admin me@{{hostname}}</pre>
|
sudo management/cli.py user make-admin me@{{hostname}}</pre>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<hr>
|
<hr>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<p style="margin: 2em; text-align: center;">Log in here for your Mail-in-a-Box control panel.</p>
|
<p class="subtitle">Log in here for your Mail-in-a-Box control panel.</p>
|
||||||
|
|
||||||
<div style="margin: 0 auto; max-width: 32em;">
|
<div class="login">
|
||||||
<form class="form-horizontal" role="form" onsubmit="do_login(); return false;">
|
<form id="loginForm" class="form-horizontal" role="form" onsubmit="do_login(); return false;" method="get">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="inputEmail3" class="col-sm-3 control-label">Email</label>
|
<label for="inputEmail3" class="col-sm-3 control-label">Email</label>
|
||||||
<div class="col-sm-9">
|
<div class="col-sm-9">
|
||||||
@@ -36,6 +61,13 @@ sudo tools/mail.py user make-admin me@{{hostname}}</pre>
|
|||||||
<input name="password" type="password" class="form-control" id="loginPassword" placeholder="Password">
|
<input name="password" type="password" class="form-control" id="loginPassword" placeholder="Password">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group" id="loginOtp">
|
||||||
|
<label for="loginOtpInput" class="col-sm-3 control-label">Code</label>
|
||||||
|
<div class="col-sm-9">
|
||||||
|
<input type="text" class="form-control" id="loginOtpInput" placeholder="6-digit code">
|
||||||
|
<div class="help-block" style="margin-top: 5px; font-size: 90%">Enter the six-digit code generated by your two factor authentication app.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="col-sm-offset-3 col-sm-9">
|
<div class="col-sm-offset-3 col-sm-9">
|
||||||
<div class="checkbox">
|
<div class="checkbox">
|
||||||
@@ -53,15 +85,15 @@ sudo tools/mail.py user make-admin me@{{hostname}}</pre>
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function do_login() {
|
function do_login() {
|
||||||
if ($('#loginEmail').val() == "") {
|
if ($('#loginEmail').val() == "") {
|
||||||
show_modal_error("Login Failed", "Enter your email address.", function() {
|
show_modal_error("Login Failed", "Enter your email address.", function() {
|
||||||
$('#loginEmail').focus();
|
$('#loginEmail').focus();
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($('#loginPassword').val() == "") {
|
if ($('#loginPassword').val() == "") {
|
||||||
show_modal_error("Login Failed", "Enter your email password.", function() {
|
show_modal_error("Login Failed", "Enter your email password.", function() {
|
||||||
$('#loginPassword').focus();
|
$('#loginPassword').focus();
|
||||||
@@ -75,17 +107,29 @@ function do_login() {
|
|||||||
api(
|
api(
|
||||||
"/me",
|
"/me",
|
||||||
"GET",
|
"GET",
|
||||||
{ },
|
{},
|
||||||
function(response){
|
function(response) {
|
||||||
// This API call always succeeds. It returns a JSON object indicating
|
// This API call always succeeds. It returns a JSON object indicating
|
||||||
// whether the request was authenticated or not.
|
// whether the request was authenticated or not.
|
||||||
if (response.status != "ok") {
|
if (response.status != 'ok') {
|
||||||
// Show why the login failed.
|
if (response.status === 'missing-totp-token' || (response.status === 'invalid' && response.reason == 'invalid-totp-token')) {
|
||||||
show_modal_error("Login Failed", response.reason)
|
$('#loginForm').addClass('is-twofactor');
|
||||||
|
if (response.reason === "invalid-totp-token") {
|
||||||
|
show_modal_error("Login Failed", "Incorrect two factor authentication token.");
|
||||||
|
} else {
|
||||||
|
setTimeout(() => {
|
||||||
|
$('#loginOtpInput').focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$('#loginForm').removeClass('is-twofactor');
|
||||||
|
|
||||||
// Reset any saved credentials.
|
// Show why the login failed.
|
||||||
do_logout();
|
show_modal_error("Login Failed", response.reason)
|
||||||
|
|
||||||
|
// Reset any saved credentials.
|
||||||
|
do_logout();
|
||||||
|
}
|
||||||
} else if (!("api_key" in response)) {
|
} else if (!("api_key" in response)) {
|
||||||
// Login succeeded but user might not be authorized!
|
// Login succeeded but user might not be authorized!
|
||||||
show_modal_error("Login Failed", "You are not an administrator on this system.")
|
show_modal_error("Login Failed", "You are not an administrator on this system.")
|
||||||
@@ -102,6 +146,8 @@ function do_login() {
|
|||||||
// Try to wipe the username/password information.
|
// Try to wipe the username/password information.
|
||||||
$('#loginEmail').val('');
|
$('#loginEmail').val('');
|
||||||
$('#loginPassword').val('');
|
$('#loginPassword').val('');
|
||||||
|
$('#loginOtpInput').val('');
|
||||||
|
$('#loginForm').removeClass('is-twofactor');
|
||||||
|
|
||||||
// Remember the credentials.
|
// Remember the credentials.
|
||||||
if (typeof localStorage != 'undefined' && typeof sessionStorage != 'undefined') {
|
if (typeof localStorage != 'undefined' && typeof sessionStorage != 'undefined') {
|
||||||
@@ -119,19 +165,16 @@ function do_login() {
|
|||||||
// which confuses the loading indicator.
|
// which confuses the loading indicator.
|
||||||
setTimeout(function() { show_panel(!switch_back_to_panel || switch_back_to_panel == "login" ? 'system_status' : switch_back_to_panel) }, 300);
|
setTimeout(function() { show_panel(!switch_back_to_panel || switch_back_to_panel == "login" ? 'system_status' : switch_back_to_panel) }, 300);
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
}
|
undefined,
|
||||||
|
{
|
||||||
function do_logout() {
|
'x-auth-token': $('#loginOtpInput').val()
|
||||||
api_credentials = ["", ""];
|
});
|
||||||
if (typeof localStorage != 'undefined')
|
|
||||||
localStorage.removeItem("miab-cp-credentials");
|
|
||||||
if (typeof sessionStorage != 'undefined')
|
|
||||||
sessionStorage.removeItem("miab-cp-credentials");
|
|
||||||
show_panel('login');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function show_login() {
|
function show_login() {
|
||||||
|
$('#loginForm').removeClass('is-twofactor');
|
||||||
|
$('#loginOtpInput').val('');
|
||||||
$('#loginEmail,#loginPassword').each(function() {
|
$('#loginEmail,#loginPassword').each(function() {
|
||||||
var input = $(this);
|
var input = $(this);
|
||||||
if (!$.trim(input.val())) {
|
if (!$.trim(input.val())) {
|
||||||
|
|||||||
242
management/templates/mfa.html
Normal file
242
management/templates/mfa.html
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
<style>
|
||||||
|
.twofactor #totp-setup,
|
||||||
|
.twofactor #disable-2fa,
|
||||||
|
.twofactor #output-2fa {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.twofactor.loaded .loading-indicator {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.twofactor.disabled #disable-2fa,
|
||||||
|
.twofactor.enabled #totp-setup {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.twofactor.disabled #totp-setup,
|
||||||
|
.twofactor.enabled #disable-2fa {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.twofactor #totp-setup-qr img {
|
||||||
|
display: block;
|
||||||
|
width: 256px;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.twofactor #output-2fa.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<h2>Two-Factor Authentication</h2>
|
||||||
|
|
||||||
|
<p>When two-factor authentication is enabled, you will be prompted to enter a six digit code from an
|
||||||
|
authenticator app (usually on your phone) when you log into this control panel.</p>
|
||||||
|
|
||||||
|
<div class="panel panel-danger">
|
||||||
|
<div class="panel-heading">
|
||||||
|
Enabling two-factor authentication does not protect access to your email
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
Enabling two-factor authentication on this page only limits access to this control panel. Remember that most websites allow you to
|
||||||
|
reset your password by checking your email, so anyone with access to your email can typically take over
|
||||||
|
your other accounts. Additionally, if your email address or any alias that forwards to your email
|
||||||
|
address is a typical domain control validation address (e.g admin@, administrator@, postmaster@, hostmaster@,
|
||||||
|
webmaster@, abuse@), extra care should be taken to protect the account. <strong>Always use a strong password,
|
||||||
|
and ensure every administrator account for this control panel does the same.</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="twofactor">
|
||||||
|
<div class="loading-indicator">Loading...</div>
|
||||||
|
|
||||||
|
<form id="totp-setup">
|
||||||
|
<h3>Setup Instructions</h3>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<p>1. Install <a href="https://freeotp.github.io/">FreeOTP</a> or <a href="https://www.pcworld.com/article/3225913/what-is-two-factor-authentication-and-which-2fa-apps-are-best.html">any
|
||||||
|
other two-factor authentication app</a> that supports TOTP.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<p style="margin-bottom: 0">2. Scan the QR code in the app or directly enter the secret into the app:</p>
|
||||||
|
<div id="totp-setup-qr"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="otp-label" style="font-weight: normal">3. Optionally, give your device a label so that you can remember what device you set it up on:</label>
|
||||||
|
<input type="text" id="totp-setup-label" class="form-control" placeholder="my phone" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="otp" style="font-weight: normal">4. Use the app to generate your first six-digit code and enter it here:</label>
|
||||||
|
<input type="text" id="totp-setup-token" class="form-control" placeholder="6-digit code" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" id="totp-setup-secret" />
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<p>When you click Enable Two-Factor Authentication, you will be logged out of the control panel and will have to log in
|
||||||
|
again, now using your two-factor authentication app.</p>
|
||||||
|
<button id="totp-setup-submit" disabled type="submit" class="btn">Enable Two-Factor Authentication</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form id="disable-2fa">
|
||||||
|
<div class="form-group">
|
||||||
|
<p>Two-factor authentication is active for your account<span id="mfa-device-label"></span>.</p>
|
||||||
|
<p>You will have to log into the admin panel again after disabling two-factor authentication.</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<button type="submit" class="btn btn-danger">Disable Two-Factor Authentication</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="output-2fa" class="panel panel-danger">
|
||||||
|
<div class="panel-body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var el = {
|
||||||
|
disableForm: document.getElementById('disable-2fa'),
|
||||||
|
output: document.getElementById('output-2fa'),
|
||||||
|
totpSetupForm: document.getElementById('totp-setup'),
|
||||||
|
totpSetupToken: document.getElementById('totp-setup-token'),
|
||||||
|
totpSetupSecret: document.getElementById('totp-setup-secret'),
|
||||||
|
totpSetupLabel: document.getElementById('totp-setup-label'),
|
||||||
|
totpQr: document.getElementById('totp-setup-qr'),
|
||||||
|
totpSetupSubmit: document.querySelector('#totp-setup-submit'),
|
||||||
|
wrapper: document.querySelector('.twofactor')
|
||||||
|
}
|
||||||
|
|
||||||
|
function update_setup_disabled(evt) {
|
||||||
|
var val = evt.target.value.trim();
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof val !== 'string' ||
|
||||||
|
typeof el.totpSetupSecret.value !== 'string' ||
|
||||||
|
val.length !== 6 ||
|
||||||
|
el.totpSetupSecret.value.length !== 32 ||
|
||||||
|
!(/^\+?\d+$/.test(val))
|
||||||
|
) {
|
||||||
|
el.totpSetupSubmit.setAttribute('disabled', '');
|
||||||
|
} else {
|
||||||
|
el.totpSetupSubmit.removeAttribute('disabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render_totp_setup(provisioned_totp) {
|
||||||
|
var img = document.createElement('img');
|
||||||
|
img.src = "data:image/png;base64," + provisioned_totp.qr_code_base64;
|
||||||
|
|
||||||
|
var code = document.createElement('div');
|
||||||
|
code.innerHTML = `Secret: ${provisioned_totp.secret}`;
|
||||||
|
|
||||||
|
el.totpQr.appendChild(img);
|
||||||
|
el.totpQr.appendChild(code);
|
||||||
|
|
||||||
|
el.totpSetupToken.addEventListener('input', update_setup_disabled);
|
||||||
|
el.totpSetupForm.addEventListener('submit', do_enable_totp);
|
||||||
|
|
||||||
|
el.totpSetupSecret.setAttribute('value', provisioned_totp.secret);
|
||||||
|
|
||||||
|
el.wrapper.classList.add('disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
function render_disable(mfa) {
|
||||||
|
el.disableForm.addEventListener('submit', do_disable);
|
||||||
|
el.wrapper.classList.add('enabled');
|
||||||
|
if (mfa.label)
|
||||||
|
$("#mfa-device-label").text(" on device '" + mfa.label + "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide_error() {
|
||||||
|
el.output.querySelector('.panel-body').innerHTML = '';
|
||||||
|
el.output.classList.remove('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function render_error(msg) {
|
||||||
|
el.output.querySelector('.panel-body').innerHTML = msg;
|
||||||
|
el.output.classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset_view() {
|
||||||
|
el.wrapper.classList.remove('loaded', 'disabled', 'enabled');
|
||||||
|
|
||||||
|
el.disableForm.removeEventListener('submit', do_disable);
|
||||||
|
|
||||||
|
hide_error();
|
||||||
|
|
||||||
|
el.totpSetupForm.reset();
|
||||||
|
el.totpSetupForm.removeEventListener('submit', do_enable_totp);
|
||||||
|
|
||||||
|
el.totpSetupSecret.setAttribute('value', '');
|
||||||
|
el.totpSetupToken.removeEventListener('input', update_setup_disabled);
|
||||||
|
|
||||||
|
el.totpSetupSubmit.setAttribute('disabled', '');
|
||||||
|
el.totpQr.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function show_mfa() {
|
||||||
|
reset_view();
|
||||||
|
|
||||||
|
api(
|
||||||
|
'/mfa/status',
|
||||||
|
'POST',
|
||||||
|
{},
|
||||||
|
function(res) {
|
||||||
|
el.wrapper.classList.add('loaded');
|
||||||
|
|
||||||
|
var has_mfa = false;
|
||||||
|
res.enabled_mfa.forEach(function(mfa) {
|
||||||
|
if (mfa.type == "totp") {
|
||||||
|
render_disable(mfa);
|
||||||
|
has_mfa = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!has_mfa)
|
||||||
|
render_totp_setup(res.new_mfa.totp);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function do_disable(evt) {
|
||||||
|
evt.preventDefault();
|
||||||
|
hide_error();
|
||||||
|
|
||||||
|
api(
|
||||||
|
'/mfa/disable',
|
||||||
|
'POST',
|
||||||
|
{ type: 'totp' },
|
||||||
|
function() {
|
||||||
|
do_logout();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function do_enable_totp(evt) {
|
||||||
|
evt.preventDefault();
|
||||||
|
hide_error();
|
||||||
|
|
||||||
|
api(
|
||||||
|
'/mfa/totp/enable',
|
||||||
|
'POST',
|
||||||
|
{
|
||||||
|
token: $(el.totpSetupToken).val(),
|
||||||
|
secret: $(el.totpSetupSecret).val(),
|
||||||
|
label: $(el.totpSetupLabel).val()
|
||||||
|
},
|
||||||
|
function(res) { do_logout(); },
|
||||||
|
function(res) { render_error(res); }
|
||||||
|
);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -77,15 +77,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group backup-target-s3">
|
<div class="form-group backup-target-s3">
|
||||||
<label for="backup-target-s3-host" class="col-sm-2 control-label">S3 Region</label>
|
<label for="backup-target-s3-host-select" class="col-sm-2 control-label">S3 Region</label>
|
||||||
<div class="col-sm-8">
|
<div class="col-sm-8">
|
||||||
<select class="form-control" rows="1" id="backup-target-s3-host">
|
<select class="form-control" rows="1" id="backup-target-s3-host-select">
|
||||||
{% for name, host in backup_s3_hosts %}
|
{% for name, host in backup_s3_hosts %}
|
||||||
<option value="{{host}}">{{name}}</option>
|
<option value="{{host}}">{{name}}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
<option value="other">Other</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group backup-target-s3">
|
||||||
|
<label for="backup-target-s3-host" class="col-sm-2 control-label">S3 Host / Endpoint</label>
|
||||||
|
<div class="col-sm-8">
|
||||||
|
<input type="text" placeholder="Endpoint" class="form-control" rows="1" id="backup-target-s3-host">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="form-group backup-target-s3">
|
<div class="form-group backup-target-s3">
|
||||||
<label for="backup-target-s3-path" class="col-sm-2 control-label">S3 Path</label>
|
<label for="backup-target-s3-path" class="col-sm-2 control-label">S3 Path</label>
|
||||||
<div class="col-sm-8">
|
<div class="col-sm-8">
|
||||||
@@ -106,10 +113,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Common -->
|
<!-- Common -->
|
||||||
<div class="form-group backup-target-local backup-target-rsync backup-target-s3">
|
<div class="form-group backup-target-local backup-target-rsync backup-target-s3">
|
||||||
<label for="min-age" class="col-sm-2 control-label">Days:</label>
|
<label for="min-age" class="col-sm-2 control-label">Retention Days:</label>
|
||||||
<div class="col-sm-8">
|
<div class="col-sm-8">
|
||||||
<input type="number" class="form-control" rows="1" id="min-age">
|
<input type="number" class="form-control" rows="1" id="min-age">
|
||||||
<div class="small" style="margin-top: 2px">This is the <i>minimum</i> number of days backup data is kept for. The box makes an incremental backup, so backup data is often kept much longer. An incremental backup file that is less than this number of days old requires that all previous increments back to the most recent full backup, plus that full backup, remain available.</div>
|
<div class="small" style="margin-top: 2px">This is the minimum time backup data is kept for. The box makes an incremental backup most nights, which requires that previous backups back to the most recent full backup be preserved, so backup data is often kept much longer than this setting. Full backups are made periodically when the incremental backup data size exceeds a limit.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -139,6 +146,8 @@ function toggle_form() {
|
|||||||
var target_type = $("#backup-target-type").val();
|
var target_type = $("#backup-target-type").val();
|
||||||
$(".backup-target-local, .backup-target-rsync, .backup-target-s3").hide();
|
$(".backup-target-local, .backup-target-rsync, .backup-target-s3").hide();
|
||||||
$(".backup-target-" + target_type).show();
|
$(".backup-target-" + target_type).show();
|
||||||
|
|
||||||
|
init_inputs(target_type);
|
||||||
}
|
}
|
||||||
|
|
||||||
function nice_size(bytes) {
|
function nice_size(bytes) {
|
||||||
@@ -278,4 +287,20 @@ function set_custom_backup() {
|
|||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function init_inputs(target_type) {
|
||||||
|
function set_host(host) {
|
||||||
|
if(host !== 'other') {
|
||||||
|
$("#backup-target-s3-host").val(host);
|
||||||
|
} else {
|
||||||
|
$("#backup-target-s3-host").val('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (target_type == "s3") {
|
||||||
|
$('#backup-target-s3-host-select').off('change').on('change', function() {
|
||||||
|
set_host($('#backup-target-s3-host-select').val());
|
||||||
|
});
|
||||||
|
set_host($('#backup-target-s3-host-select').val());
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
<thead><th>Verb</th> <th>Action</th><th></th></thead>
|
<thead><th>Verb</th> <th>Action</th><th></th></thead>
|
||||||
<tr><td>GET</td><td><i>(none)</i></td> <td>Returns a list of existing mail users. Adding <code>?format=json</code> to the URL will give JSON-encoded results.</td></tr>
|
<tr><td>GET</td><td><i>(none)</i></td> <td>Returns a list of existing mail users. Adding <code>?format=json</code> to the URL will give JSON-encoded results.</td></tr>
|
||||||
<tr><td>POST</td><td>/add</td> <td>Adds a new mail user. Required POST-body parameters are <code>email</code> and <code>password</code>.</td></tr>
|
<tr><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-by parameter is <code>email</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>/privileges/add</td> <td>Used to make a mail user an admin. Required POST-body parameters are <code>email</code> and <code>privilege=admin</code>.</td></tr>
|
<tr><td>POST</td><td>/privileges/add</td> <td>Used to make a mail user an admin. Required POST-body parameters are <code>email</code> and <code>privilege=admin</code>.</td></tr>
|
||||||
<tr><td>POST</td><td>/privileges/remove</td> <td>Used to remove the admin privilege from a mail user. Required POST-body parameter is <code>email</code>.</td></tr>
|
<tr><td>POST</td><td>/privileges/remove</td> <td>Used to remove the admin privilege from a mail user. Required POST-body parameter is <code>email</code>.</td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -23,7 +23,16 @@ def get_web_domains(env, include_www_redirects=True, exclude_dns_elsewhere=True)
|
|||||||
# to the main domain for. We'll add 'www.' to any DNS zones, i.e.
|
# to the main domain for. We'll add 'www.' to any DNS zones, i.e.
|
||||||
# the topmost of each domain we serve.
|
# the topmost of each domain we serve.
|
||||||
domains |= set('www.' + zone for zone, zonefile in get_dns_zones(env))
|
domains |= set('www.' + zone for zone, zonefile in get_dns_zones(env))
|
||||||
|
|
||||||
|
# Add Autoconfiguration domains for domains that there are user accounts at:
|
||||||
|
# 'autoconfig.' for Mozilla Thunderbird auto setup.
|
||||||
|
# 'autodiscover.' for Activesync autodiscovery.
|
||||||
|
domains |= set('autoconfig.' + maildomain for maildomain in get_mail_domains(env, users_only=True))
|
||||||
|
domains |= set('autodiscover.' + maildomain for maildomain in get_mail_domains(env, users_only=True))
|
||||||
|
|
||||||
|
# 'mta-sts.' for MTA-STS support for all domains that have email addresses.
|
||||||
|
domains |= set('mta-sts.' + maildomain for maildomain in get_mail_domains(env))
|
||||||
|
|
||||||
if exclude_dns_elsewhere:
|
if exclude_dns_elsewhere:
|
||||||
# ...Unless the domain has an A/AAAA record that maps it to a different
|
# ...Unless the domain has an A/AAAA record that maps it to a different
|
||||||
# IP address than this box. Remove those domains from our list.
|
# IP address than this box. Remove those domains from our list.
|
||||||
@@ -137,7 +146,7 @@ def make_domain_config(domain, templates, ssl_certificates, env):
|
|||||||
finally:
|
finally:
|
||||||
f.close()
|
f.close()
|
||||||
return sha1.hexdigest()
|
return sha1.hexdigest()
|
||||||
nginx_conf_extra += "# ssl files sha1: %s / %s\n" % (hashfile(tls_cert["private-key"]), hashfile(tls_cert["certificate"]))
|
nginx_conf_extra += "\t# ssl files sha1: %s / %s\n" % (hashfile(tls_cert["private-key"]), hashfile(tls_cert["certificate"]))
|
||||||
|
|
||||||
# Add in any user customizations in YAML format.
|
# Add in any user customizations in YAML format.
|
||||||
hsts = "yes"
|
hsts = "yes"
|
||||||
@@ -149,9 +158,27 @@ def make_domain_config(domain, templates, ssl_certificates, env):
|
|||||||
|
|
||||||
# any proxy or redirect here?
|
# any proxy or redirect here?
|
||||||
for path, url in yaml.get("proxies", {}).items():
|
for path, url in yaml.get("proxies", {}).items():
|
||||||
|
# Parse some flags in the fragment of the URL.
|
||||||
|
pass_http_host_header = False
|
||||||
|
m = re.search("#(.*)$", url)
|
||||||
|
if m:
|
||||||
|
for flag in m.group(1).split(","):
|
||||||
|
if flag == "pass-http-host":
|
||||||
|
pass_http_host_header = True
|
||||||
|
url = re.sub("#(.*)$", "", url)
|
||||||
|
|
||||||
nginx_conf_extra += "\tlocation %s {" % path
|
nginx_conf_extra += "\tlocation %s {" % path
|
||||||
nginx_conf_extra += "\n\t\tproxy_pass %s;" % url
|
nginx_conf_extra += "\n\t\tproxy_pass %s;" % url
|
||||||
|
if pass_http_host_header:
|
||||||
|
nginx_conf_extra += "\n\t\tproxy_set_header Host $http_host;"
|
||||||
nginx_conf_extra += "\n\t\tproxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;"
|
nginx_conf_extra += "\n\t\tproxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;"
|
||||||
|
nginx_conf_extra += "\n\t\tproxy_set_header X-Forwarded-Host $http_host;"
|
||||||
|
nginx_conf_extra += "\n\t\tproxy_set_header X-Forwarded-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 += "\n\t}\n"
|
nginx_conf_extra += "\n\t}\n"
|
||||||
for path, url in yaml.get("redirects", {}).items():
|
for path, url in yaml.get("redirects", {}).items():
|
||||||
nginx_conf_extra += "\trewrite %s %s permanent;\n" % (path, url)
|
nginx_conf_extra += "\trewrite %s %s permanent;\n" % (path, url)
|
||||||
@@ -161,9 +188,9 @@ def make_domain_config(domain, templates, ssl_certificates, env):
|
|||||||
|
|
||||||
# Add the HSTS header.
|
# Add the HSTS header.
|
||||||
if hsts == "yes":
|
if hsts == "yes":
|
||||||
nginx_conf_extra += "add_header Strict-Transport-Security max-age=15768000;\n"
|
nginx_conf_extra += "\tadd_header Strict-Transport-Security \"max-age=15768000\" always;\n"
|
||||||
elif hsts == "preload":
|
elif hsts == "preload":
|
||||||
nginx_conf_extra += "add_header Strict-Transport-Security \"max-age=15768000; includeSubDomains; preload\";\n"
|
nginx_conf_extra += "\tadd_header Strict-Transport-Security \"max-age=15768000; includeSubDomains; preload\" always;\n"
|
||||||
|
|
||||||
# Add in any user customizations in the includes/ folder.
|
# Add in any user customizations in the includes/ folder.
|
||||||
nginx_conf_custom_include = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(domain) + ".conf")
|
nginx_conf_custom_include = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(domain) + ".conf")
|
||||||
|
|||||||
15
security.md
15
security.md
@@ -39,9 +39,8 @@ These services are protected by [TLS](https://en.wikipedia.org/wiki/Transport_La
|
|||||||
The services all follow these rules:
|
The services all follow these rules:
|
||||||
|
|
||||||
* TLS certificates are generated with 2048-bit RSA keys and SHA-256 fingerprints. The box provides a self-signed certificate by default. The [setup guide](https://mailinabox.email/guide.html) explains how to verify the certificate fingerprint on first login. Users are encouraged to replace the certificate with a proper CA-signed one. ([source](setup/ssl.sh))
|
* TLS certificates are generated with 2048-bit RSA keys and SHA-256 fingerprints. The box provides a self-signed certificate by default. The [setup guide](https://mailinabox.email/guide.html) explains how to verify the certificate fingerprint on first login. Users are encouraged to replace the certificate with a proper CA-signed one. ([source](setup/ssl.sh))
|
||||||
* Only TLSv1, TLSv1.1 and TLSv1.2 are offered (the older SSL protocols are not offered).
|
* Only TLSv1.2+ are offered (the older SSL protocols are not offered).
|
||||||
* HTTPS, IMAP, and POP track the [Mozilla Intermediate Ciphers Recommendation](https://wiki.mozilla.org/Security/Server_Side_TLS), balancing security with supporting a wide range of mail clients. Diffie-Hellman ciphers use a 2048-bit key for forward secrecy. For more details, see the [output of SSLyze for these ports](tests/tls_results.txt).
|
* We track the [Mozilla Intermediate Ciphers Recommendation](https://wiki.mozilla.org/Security/Server_Side_TLS), balancing security with supporting a wide range of mail clients. Diffie-Hellman ciphers use a 2048-bit key for forward secrecy. For more details, see the [output of SSLyze for these ports](tests/tls_results.txt).
|
||||||
* SMTP (port 25) uses the Postfix medium grade ciphers and SMTP Submission (port 587) uses the Postfix high grade ciphers ([more info](http://www.postfix.org/postconf.5.html#smtpd_tls_mandatory_ciphers)).
|
|
||||||
|
|
||||||
Additionally:
|
Additionally:
|
||||||
|
|
||||||
@@ -95,16 +94,20 @@ Domain policy records allow recipient MTAs to detect when the _domain_ part of o
|
|||||||
|
|
||||||
### User Policy
|
### User Policy
|
||||||
|
|
||||||
While domain policy records prevent other servers from sending mail with a "From:" header that matches a domain hosted on the box (see above), those policy records do not guarnatee that the user portion of the sender email address matches the actual sender. In enterprise environments where the box may host the mail of untrusted users, it is important to guard against users impersonating other users.
|
While domain policy records prevent other servers from sending mail with a "From:" header that matches a domain hosted on the box (see above), those policy records do not guarantee that the user portion of the sender email address matches the actual sender. In enterprise environments where the box may host the mail of untrusted users, it is important to guard against users impersonating other users.
|
||||||
|
|
||||||
The box restricts the envelope sender address (also called the return path or MAIL FROM address --- this is different from the "From:" header) that users may put into outbound mail. The envelope sender address must be either their own email address (their SMTP login username) or any alias that they are listed as a permitted sender of. (There is currently no restriction on the contents of the "From:" header.)
|
The box restricts the envelope sender address (also called the return path or MAIL FROM address --- this is different from the "From:" header) that users may put into outbound mail. The envelope sender address must be either their own email address (their SMTP login username) or any alias that they are listed as a permitted sender of. (There is currently no restriction on the contents of the "From:" header.)
|
||||||
|
|
||||||
Incoming Mail
|
Incoming Mail
|
||||||
-------------
|
-------------
|
||||||
|
|
||||||
### Encryption
|
### Encryption Settings
|
||||||
|
|
||||||
As discussed above, there is no way to require on-the-wire encryption of mail. When the box receives an incoming email (SMTP on port 25), it offers encryption (STARTTLS) but cannot require that senders use it because some senders may not support STARTTLS at all and other senders may support STARTTLS but not with the latest protocols/ciphers. To give senders the best chance at making use of encryption, the box offers protocols back to TLSv1 and ciphers with key lengths as low as 112 bits. Modern clients (senders) will make use of the 256-bit ciphers and Diffie-Hellman ciphers with a 2048-bit key for perfect forward secrecy, however. ([source](setup/mail-postfix.sh))
|
As with outbound email, there is no way to require on-the-wire encryption of incoming mail from all senders. When the box receives an incoming email (SMTP on port 25), it offers encryption (STARTTLS) but cannot require that senders use it because some senders may not support STARTTLS at all and other senders may support STARTTLS but not with the latest protocols/ciphers. To give senders the best chance at making use of encryption, the box offers protocols back to TLSv1 and ciphers with key lengths as low as 112 bits. Modern clients (senders) will make use of the 256-bit ciphers and Diffie-Hellman ciphers with a 2048-bit key for perfect forward secrecy, however. ([source](setup/mail-postfix.sh))
|
||||||
|
|
||||||
|
### MTA-STS
|
||||||
|
|
||||||
|
The box publishes a SMTP MTA Strict Transport Security ([SMTP MTA-STS](https://en.wikipedia.org/wiki/Simple_Mail_Transfer_Protocol#SMTP_MTA_Strict_Transport_Security)) policy (via DNS and HTTPS) in "enforce" mode. Senders that support MTA-STS will use a secure SMTP connection. (MTA-STS tells senders to connect and expect a signed TLS certificate for the "MX" domain without permitting a fallback to an unencrypted connection.)
|
||||||
|
|
||||||
### DANE
|
### DANE
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ if [ -z "$TAG" ]; then
|
|||||||
# want to display in status checks.
|
# want to display in status checks.
|
||||||
if [ "`lsb_release -d | sed 's/.*:\s*//' | sed 's/18\.04\.[0-9]/18.04/' `" == "Ubuntu 18.04 LTS" ]; then
|
if [ "`lsb_release -d | sed 's/.*:\s*//' | sed 's/18\.04\.[0-9]/18.04/' `" == "Ubuntu 18.04 LTS" ]; then
|
||||||
# This machine is running Ubuntu 18.04.
|
# This machine is running Ubuntu 18.04.
|
||||||
TAG=v0.41
|
TAG=v0.51
|
||||||
|
|
||||||
elif [ "`lsb_release -d | sed 's/.*:\s*//' | sed 's/14\.04\.[0-9]/14.04/' `" == "Ubuntu 14.04 LTS" ]; then
|
elif [ "`lsb_release -d | sed 's/.*:\s*//' | sed 's/14\.04\.[0-9]/14.04/' `" == "Ubuntu 14.04 LTS" ]; then
|
||||||
# This machine is running Ubuntu 14.04.
|
# This machine is running Ubuntu 14.04.
|
||||||
@@ -35,14 +35,14 @@ if [ -z "$TAG" ]; then
|
|||||||
|
|
||||||
else
|
else
|
||||||
echo "This script must be run on a system running Ubuntu 18.04 or Ubuntu 14.04."
|
echo "This script must be run on a system running Ubuntu 18.04 or Ubuntu 14.04."
|
||||||
exit
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Are we running as root?
|
# Are we running as root?
|
||||||
if [[ $EUID -ne 0 ]]; then
|
if [[ $EUID -ne 0 ]]; then
|
||||||
echo "This script must be run as root. Did you leave out sudo?"
|
echo "This script must be run as root. Did you leave out sudo?"
|
||||||
exit
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Clone the Mail-in-a-Box repository if it doesn't exist.
|
# Clone the Mail-in-a-Box repository if it doesn't exist.
|
||||||
@@ -73,7 +73,7 @@ if [ "$TAG" != `git describe` ]; then
|
|||||||
git fetch --depth 1 --force --prune origin tag $TAG
|
git fetch --depth 1 --force --prune origin tag $TAG
|
||||||
if ! git checkout -q $TAG; then
|
if ! git checkout -q $TAG; then
|
||||||
echo "Update failed. Did you modify something in `pwd`?"
|
echo "Update failed. Did you modify something in `pwd`?"
|
||||||
exit
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo
|
echo
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ if grep -q "ExternalIgnoreList" /etc/opendkim.conf; then
|
|||||||
else
|
else
|
||||||
# Add various configuration options to the end of `opendkim.conf`.
|
# Add various configuration options to the end of `opendkim.conf`.
|
||||||
cat >> /etc/opendkim.conf << EOF;
|
cat >> /etc/opendkim.conf << EOF;
|
||||||
|
Canonicalization relaxed/simple
|
||||||
MinimumKeyBits 1024
|
MinimumKeyBits 1024
|
||||||
ExternalIgnoreList refile:/etc/opendkim/TrustedHosts
|
ExternalIgnoreList refile:/etc/opendkim/TrustedHosts
|
||||||
InternalHosts refile:/etc/opendkim/TrustedHosts
|
InternalHosts refile:/etc/opendkim/TrustedHosts
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# If there aren't any mail users yet, create one.
|
# If there aren't any mail users yet, create one.
|
||||||
if [ -z "`tools/mail.py user`" ]; then
|
if [ -z "`management/cli.py user`" ]; then
|
||||||
# The outut of "tools/mail.py user" is a list of mail users. If there
|
# The outut of "management/cli.py user" is a list of mail users. If there
|
||||||
# aren't any yet, it'll be empty.
|
# aren't any yet, it'll be empty.
|
||||||
|
|
||||||
# If we didn't ask for an email address at the start, do so now.
|
# If we didn't ask for an email address at the start, do so now.
|
||||||
@@ -47,11 +47,11 @@ if [ -z "`tools/mail.py user`" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Create the user's mail account. This will ask for a password if none was given above.
|
# Create the user's mail account. This will ask for a password if none was given above.
|
||||||
tools/mail.py user add $EMAIL_ADDR ${EMAIL_PW:-}
|
management/cli.py user add $EMAIL_ADDR ${EMAIL_PW:-}
|
||||||
|
|
||||||
# Make it an admin.
|
# Make it an admin.
|
||||||
hide_output tools/mail.py user make-admin $EMAIL_ADDR
|
hide_output management/cli.py user make-admin $EMAIL_ADDR
|
||||||
|
|
||||||
# Create an alias to which we'll direct all automatically-created administrative aliases.
|
# Create an alias to which we'll direct all automatically-created administrative aliases.
|
||||||
tools/mail.py alias add administrator@$PRIMARY_HOSTNAME $EMAIL_ADDR > /dev/null
|
management/cli.py alias add administrator@$PRIMARY_HOSTNAME $EMAIL_ADDR > /dev/null
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Turn on "strict mode." See http://redsymbol.net/articles/unofficial-bash-strict-mode/.
|
# Turn on "strict mode." See http://redsymbol.net/articles/unofficial-bash-strict-mode/.
|
||||||
# -e: exit if any command unexpectedly fails.
|
# -e: exit if any command unexpectedly fails.
|
||||||
# -u: exit if we have a variable typo.
|
# -u: exit if we have a variable typo.
|
||||||
# -o pipefail: don't ignore errors in the non-last command in a pipeline
|
# -o pipefail: don't ignore errors in the non-last command in a pipeline
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
function hide_output {
|
function hide_output {
|
||||||
@@ -57,15 +57,6 @@ function apt_install {
|
|||||||
apt_get_quiet install $PACKAGES
|
apt_get_quiet install $PACKAGES
|
||||||
}
|
}
|
||||||
|
|
||||||
function apt_add_repository_to_unattended_upgrades {
|
|
||||||
if [ -f /etc/apt/apt.conf.d/50unattended-upgrades ]; then
|
|
||||||
if ! grep -q "$1" /etc/apt/apt.conf.d/50unattended-upgrades; then
|
|
||||||
sed -i "/Allowed-Origins/a \
|
|
||||||
\"$1\";" /etc/apt/apt.conf.d/50unattended-upgrades
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
function get_default_hostname {
|
function get_default_hostname {
|
||||||
# Guess the machine's hostname. It should be a fully qualified
|
# Guess the machine's hostname. It should be a fully qualified
|
||||||
# domain name suitable for DNS. None of these calls may provide
|
# domain name suitable for DNS. None of these calls may provide
|
||||||
@@ -127,7 +118,7 @@ function get_default_privateip {
|
|||||||
if [ "$1" == "6" ]; then target=2001:4860:4860::8888; fi
|
if [ "$1" == "6" ]; then target=2001:4860:4860::8888; fi
|
||||||
|
|
||||||
# Get the route information.
|
# Get the route information.
|
||||||
route=$(ip -$1 -o route get $target | grep -v unreachable)
|
route=$(ip -$1 -o route get $target 2>/dev/null | grep -v unreachable)
|
||||||
|
|
||||||
# Parse the address out of the route information.
|
# Parse the address out of the route information.
|
||||||
address=$(echo $route | sed "s/.* src \([^ ]*\).*/\1/")
|
address=$(echo $route | sed "s/.* src \([^ ]*\).*/\1/")
|
||||||
@@ -145,7 +136,14 @@ function get_default_privateip {
|
|||||||
function ufw_allow {
|
function ufw_allow {
|
||||||
if [ -z "${DISABLE_FIREWALL:-}" ]; then
|
if [ -z "${DISABLE_FIREWALL:-}" ]; then
|
||||||
# ufw has completely unhelpful output
|
# ufw has completely unhelpful output
|
||||||
ufw allow $1 > /dev/null;
|
ufw allow "$1" > /dev/null;
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
function ufw_limit {
|
||||||
|
if [ -z "${DISABLE_FIREWALL:-}" ]; then
|
||||||
|
# ufw has completely unhelpful output
|
||||||
|
ufw limit "$1" > /dev/null;
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,16 +78,16 @@ tools/editconf.py /etc/dovecot/conf.d/10-auth.conf \
|
|||||||
"auth_mechanisms=plain login"
|
"auth_mechanisms=plain login"
|
||||||
|
|
||||||
# Enable SSL, specify the location of the SSL certificate and private key files.
|
# Enable SSL, specify the location of the SSL certificate and private key files.
|
||||||
# Disable obsolete SSL protocols and allow only good ciphers per http://baldric.net/2013/12/07/tls-ciphers-in-postfix-and-dovecot/.
|
# Use Mozilla's "Intermediate" recommendations at https://ssl-config.mozilla.org/#server=dovecot&server-version=2.2.33&config=intermediate&openssl-version=1.1.1,
|
||||||
# Enable strong ssl dh parameters
|
# except that the current version of Dovecot does not have a TLSv1.3 setting, so we only use TLSv1.2.
|
||||||
tools/editconf.py /etc/dovecot/conf.d/10-ssl.conf \
|
tools/editconf.py /etc/dovecot/conf.d/10-ssl.conf \
|
||||||
ssl=required \
|
ssl=required \
|
||||||
"ssl_cert=<$STORAGE_ROOT/ssl/ssl_certificate.pem" \
|
"ssl_cert=<$STORAGE_ROOT/ssl/ssl_certificate.pem" \
|
||||||
"ssl_key=<$STORAGE_ROOT/ssl/ssl_private_key.pem" \
|
"ssl_key=<$STORAGE_ROOT/ssl/ssl_private_key.pem" \
|
||||||
"ssl_protocols=!SSLv3 !SSLv2" \
|
"ssl_protocols=TLSv1.2" \
|
||||||
"ssl_cipher_list=ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA:!DSS" \
|
"ssl_cipher_list=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" \
|
||||||
"ssl_prefer_server_ciphers = yes" \
|
"ssl_prefer_server_ciphers=no" \
|
||||||
"ssl_dh_parameters_length = 2048"
|
"ssl_dh_parameters_length=2048"
|
||||||
|
|
||||||
# Disable in-the-clear IMAP/POP because there is no reason for a user to transmit
|
# Disable in-the-clear IMAP/POP because there is no reason for a user to transmit
|
||||||
# login credentials outside of an encrypted connection. Only the over-TLS versions
|
# login credentials outside of an encrypted connection. Only the over-TLS versions
|
||||||
@@ -135,8 +135,16 @@ service lmtp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Enable imap-login on localhost to allow the user_external plugin
|
||||||
|
# for Nextcloud to do imap authentication. (See #1577)
|
||||||
|
service imap-login {
|
||||||
|
inet_listener imap {
|
||||||
|
address = 127.0.0.1
|
||||||
|
port = 143
|
||||||
|
}
|
||||||
|
}
|
||||||
protocol imap {
|
protocol imap {
|
||||||
mail_max_userip_connections = 20
|
mail_max_userip_connections = 40
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|||||||
@@ -80,8 +80,7 @@ tools/editconf.py /etc/postfix/main.cf \
|
|||||||
# OpenDKIM milter only. See dkim.sh.
|
# OpenDKIM milter only. See dkim.sh.
|
||||||
# * Even though we dont allow auth over non-TLS connections (smtpd_tls_auth_only below, and without auth the client cant
|
# * Even though we dont allow auth over non-TLS connections (smtpd_tls_auth_only below, and without auth the client cant
|
||||||
# send outbound mail), don't allow non-TLS mail submission on this port anyway to prevent accidental misconfiguration.
|
# send outbound mail), don't allow non-TLS mail submission on this port anyway to prevent accidental misconfiguration.
|
||||||
# * Require the best ciphers for incoming connections per http://baldric.net/2013/12/07/tls-ciphers-in-postfix-and-dovecot/.
|
# Setting smtpd_tls_security_level=encrypt also triggers the use of the 'mandatory' settings below.
|
||||||
# By putting this setting here we leave opportunistic TLS on incoming mail at default cipher settings (any cipher is better than none).
|
|
||||||
# * Give it a different name in syslog to distinguish it from the port 25 smtpd server.
|
# * Give it a different name in syslog to distinguish it from the port 25 smtpd server.
|
||||||
# * Add a new cleanup service specific to the submission service ('authclean')
|
# * Add a new cleanup service specific to the submission service ('authclean')
|
||||||
# that filters out privacy-sensitive headers on mail being sent out by
|
# that filters out privacy-sensitive headers on mail being sent out by
|
||||||
@@ -93,7 +92,6 @@ tools/editconf.py /etc/postfix/master.cf -s -w \
|
|||||||
-o syslog_name=postfix/submission
|
-o syslog_name=postfix/submission
|
||||||
-o smtpd_milters=inet:127.0.0.1:8891
|
-o smtpd_milters=inet:127.0.0.1:8891
|
||||||
-o smtpd_tls_security_level=encrypt
|
-o smtpd_tls_security_level=encrypt
|
||||||
-o smtpd_tls_ciphers=high -o smtpd_tls_exclude_ciphers=aNULL,DES,3DES,MD5,DES+MD5,RC4 -o smtpd_tls_mandatory_protocols=!SSLv2,!SSLv3
|
|
||||||
-o cleanup_service_name=authclean" \
|
-o cleanup_service_name=authclean" \
|
||||||
"authclean=unix n - - - 0 cleanup
|
"authclean=unix n - - - 0 cleanup
|
||||||
-o header_checks=pcre:/etc/postfix/outgoing_mail_header_filters
|
-o header_checks=pcre:/etc/postfix/outgoing_mail_header_filters
|
||||||
@@ -108,20 +106,35 @@ cp conf/postfix_outgoing_mail_header_filters /etc/postfix/outgoing_mail_header_f
|
|||||||
sed -i "s/PRIMARY_HOSTNAME/$PRIMARY_HOSTNAME/" /etc/postfix/outgoing_mail_header_filters
|
sed -i "s/PRIMARY_HOSTNAME/$PRIMARY_HOSTNAME/" /etc/postfix/outgoing_mail_header_filters
|
||||||
sed -i "s/PUBLIC_IP/$PUBLIC_IP/" /etc/postfix/outgoing_mail_header_filters
|
sed -i "s/PUBLIC_IP/$PUBLIC_IP/" /etc/postfix/outgoing_mail_header_filters
|
||||||
|
|
||||||
# Enable TLS on these and all other connections (i.e. ports 25 *and* 587) and
|
# Enable TLS on incoming connections. It is not required on port 25, allowing for opportunistic
|
||||||
# require TLS before a user is allowed to authenticate. This also makes
|
# encryption. On port 587 it is mandatory (see above). Shared and non-shared settings are
|
||||||
# opportunistic TLS available on *incoming* mail.
|
# given here. Shared settings include:
|
||||||
# Set stronger DH parameters, which via openssl tend to default to 1024 bits
|
# * Require TLS before a user is allowed to authenticate.
|
||||||
# (see ssl.sh).
|
# * Set the path to the server TLS certificate and 2048-bit DH parameters for old DH ciphers.
|
||||||
|
# For port 25 only:
|
||||||
|
# * Disable extremely old versions of TLS and extremely unsafe ciphers, but some mail servers out in
|
||||||
|
# the world are very far behind and if we disable too much, they may not be able to use TLS and
|
||||||
|
# won't fall back to cleartext. So we don't disable too much. smtpd_tls_exclude_ciphers applies to
|
||||||
|
# both port 25 and port 587, but because we override the cipher list for both, it probably isn't used.
|
||||||
|
# Use Mozilla's "Old" recommendations at https://ssl-config.mozilla.org/#server=postfix&server-version=3.3.0&config=old&openssl-version=1.1.1
|
||||||
|
# For port 587 (via the 'mandatory' settings):
|
||||||
|
# * Use Mozilla's "Intermediate" TLS recommendations from https://ssl-config.mozilla.org/#server=postfix&server-version=3.3.0&config=intermediate&openssl-version=1.1.1
|
||||||
|
# using and overriding the "high" cipher list so we don't conflict with the more permissive settings for port 25.
|
||||||
tools/editconf.py /etc/postfix/main.cf \
|
tools/editconf.py /etc/postfix/main.cf \
|
||||||
smtpd_tls_security_level=may\
|
smtpd_tls_security_level=may\
|
||||||
smtpd_tls_auth_only=yes \
|
smtpd_tls_auth_only=yes \
|
||||||
smtpd_tls_cert_file=$STORAGE_ROOT/ssl/ssl_certificate.pem \
|
smtpd_tls_cert_file=$STORAGE_ROOT/ssl/ssl_certificate.pem \
|
||||||
smtpd_tls_key_file=$STORAGE_ROOT/ssl/ssl_private_key.pem \
|
smtpd_tls_key_file=$STORAGE_ROOT/ssl/ssl_private_key.pem \
|
||||||
smtpd_tls_dh1024_param_file=$STORAGE_ROOT/ssl/dh2048.pem \
|
smtpd_tls_dh1024_param_file=$STORAGE_ROOT/ssl/dh2048.pem \
|
||||||
smtpd_tls_protocols=\!SSLv2,\!SSLv3 \
|
smtpd_tls_protocols="!SSLv2,!SSLv3" \
|
||||||
smtpd_tls_ciphers=medium \
|
smtpd_tls_ciphers=medium \
|
||||||
|
tls_medium_cipherlist=ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA256:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA \
|
||||||
smtpd_tls_exclude_ciphers=aNULL,RC4 \
|
smtpd_tls_exclude_ciphers=aNULL,RC4 \
|
||||||
|
smtpd_tls_mandatory_protocols="!SSLv2,!SSLv3,!TLSv1,!TLSv1.1" \
|
||||||
|
smtpd_tls_mandatory_ciphers=high \
|
||||||
|
tls_high_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 \
|
||||||
|
smtpd_tls_mandatory_exclude_ciphers=aNULL,DES,3DES,MD5,DES+MD5,RC4 \
|
||||||
|
tls_preempt_cipherlist=no \
|
||||||
smtpd_tls_received_header=yes
|
smtpd_tls_received_header=yes
|
||||||
|
|
||||||
# Prevent non-authenticated users from sending mail that requires being
|
# Prevent non-authenticated users from sending mail that requires being
|
||||||
@@ -143,8 +156,12 @@ tools/editconf.py /etc/postfix/main.cf \
|
|||||||
# offers it, otherwise it will transmit the message in the clear. Postfix will
|
# offers it, otherwise it will transmit the message in the clear. Postfix will
|
||||||
# accept whatever SSL certificate the remote end provides. Opportunistic TLS
|
# accept whatever SSL certificate the remote end provides. Opportunistic TLS
|
||||||
# protects against passive easvesdropping (but not man-in-the-middle attacks).
|
# protects against passive easvesdropping (but not man-in-the-middle attacks).
|
||||||
|
# Since we'd rather have poor encryption than none at all, we use Mozilla's
|
||||||
|
# "Old" recommendations at https://ssl-config.mozilla.org/#server=postfix&server-version=3.3.0&config=old&openssl-version=1.1.1
|
||||||
|
# for opportunistic encryption but "Intermediate" recommendations when DANE
|
||||||
|
# is used (see next and above). The cipher lists are set above.
|
||||||
|
|
||||||
# DANE takes this a step further:
|
# DANE takes this a step further:
|
||||||
#
|
|
||||||
# Postfix queries DNS for the TLSA record on the destination MX host. If no TLSA records are found,
|
# Postfix queries DNS for the TLSA record on the destination MX host. If no TLSA records are found,
|
||||||
# then opportunistic TLS is used. Otherwise the server certificate must match the TLSA records
|
# then opportunistic TLS is used. Otherwise the server certificate must match the TLSA records
|
||||||
# or else the mail bounces. TLSA also requires DNSSEC on the MX host. Postfix doesn't do DNSSEC
|
# or else the mail bounces. TLSA also requires DNSSEC on the MX host. Postfix doesn't do DNSSEC
|
||||||
@@ -157,11 +174,12 @@ tools/editconf.py /etc/postfix/main.cf \
|
|||||||
# now see notices about trusted certs. The CA file is provided by the package `ca-certificates`.
|
# now see notices about trusted certs. The CA file is provided by the package `ca-certificates`.
|
||||||
tools/editconf.py /etc/postfix/main.cf \
|
tools/editconf.py /etc/postfix/main.cf \
|
||||||
smtp_tls_protocols=\!SSLv2,\!SSLv3 \
|
smtp_tls_protocols=\!SSLv2,\!SSLv3 \
|
||||||
smtp_tls_mandatory_protocols=\!SSLv2,\!SSLv3 \
|
|
||||||
smtp_tls_ciphers=medium \
|
smtp_tls_ciphers=medium \
|
||||||
smtp_tls_exclude_ciphers=aNULL,RC4 \
|
smtp_tls_exclude_ciphers=aNULL,RC4 \
|
||||||
smtp_tls_security_level=dane \
|
smtp_tls_security_level=dane \
|
||||||
smtp_dns_support_level=dnssec \
|
smtp_dns_support_level=dnssec \
|
||||||
|
smtp_tls_mandatory_protocols="!SSLv2,!SSLv3,!TLSv1,!TLSv1.1" \
|
||||||
|
smtp_tls_mandatory_ciphers=high \
|
||||||
smtp_tls_CAfile=/etc/ssl/certs/ca-certificates.crt \
|
smtp_tls_CAfile=/etc/ssl/certs/ca-certificates.crt \
|
||||||
smtp_tls_loglevel=2
|
smtp_tls_loglevel=2
|
||||||
|
|
||||||
@@ -210,6 +228,33 @@ tools/editconf.py /etc/postfix/main.cf \
|
|||||||
tools/editconf.py /etc/default/postgrey \
|
tools/editconf.py /etc/default/postgrey \
|
||||||
POSTGREY_OPTS=\"'--inet=127.0.0.1:10023 --delay=180'\"
|
POSTGREY_OPTS=\"'--inet=127.0.0.1:10023 --delay=180'\"
|
||||||
|
|
||||||
|
|
||||||
|
# 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;
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Mail-in-a-Box
|
||||||
|
|
||||||
|
# check we have a postgrey_whitelist_clients file and that it is not older than 28 days
|
||||||
|
if [ ! -f /etc/postgrey/whitelist_clients ] || find /etc/postgrey/whitelist_clients -mtime +28 | grep -q '.' ; then
|
||||||
|
# ok we need to update the file, so lets try to fetch it
|
||||||
|
if curl https://postgrey.schweikert.ch/pub/postgrey_whitelist_clients --output /tmp/postgrey_whitelist_clients -sS --fail > /dev/null 2>&1 ; then
|
||||||
|
# if fetching hasn't failed yet then check it is a plain text file
|
||||||
|
# curl manual states that --fail sometimes still produces output
|
||||||
|
# this final check will at least check the output is not html
|
||||||
|
# before moving it into place
|
||||||
|
if [ "\$(file -b --mime-type /tmp/postgrey_whitelist_clients)" == "text/plain" ]; then
|
||||||
|
mv /tmp/postgrey_whitelist_clients /etc/postgrey/whitelist_clients
|
||||||
|
service postgrey restart
|
||||||
|
else
|
||||||
|
rm /tmp/postgrey_whitelist_clients
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
EOF
|
||||||
|
chmod +x /etc/cron.daily/mailinabox-postgrey-whitelist
|
||||||
|
/etc/cron.daily/mailinabox-postgrey-whitelist
|
||||||
|
|
||||||
# Increase the message size limit from 10MB to 128MB.
|
# Increase the message size limit from 10MB to 128MB.
|
||||||
# The same limit is specified in nginx.conf for mail submitted via webmail and Z-Push.
|
# The same limit is specified in nginx.conf for mail submitted via webmail and Z-Push.
|
||||||
tools/editconf.py /etc/postfix/main.cf \
|
tools/editconf.py /etc/postfix/main.cf \
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ if [ ! -f $db_path ]; then
|
|||||||
echo Creating new user database: $db_path;
|
echo Creating new user database: $db_path;
|
||||||
echo "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra, privileges TEXT NOT NULL DEFAULT '');" | sqlite3 $db_path;
|
echo "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra, privileges TEXT NOT NULL DEFAULT '');" | sqlite3 $db_path;
|
||||||
echo "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);" | sqlite3 $db_path;
|
echo "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);" | sqlite3 $db_path;
|
||||||
|
echo "CREATE TABLE mfa (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, type TEXT NOT NULL, secret TEXT NOT NULL, mru_token TEXT, label TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);" | sqlite3 $db_path;
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ### User Authentication
|
# ### User Authentication
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ inst_dir=/usr/local/lib/mailinabox
|
|||||||
mkdir -p $inst_dir
|
mkdir -p $inst_dir
|
||||||
venv=$inst_dir/env
|
venv=$inst_dir/env
|
||||||
if [ ! -d $venv ]; then
|
if [ ! -d $venv ]; then
|
||||||
virtualenv -ppython3 $venv
|
hide_output virtualenv -ppython3 $venv
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Upgrade pip because the Ubuntu-packaged version is out of date.
|
# Upgrade pip because the Ubuntu-packaged version is out of date.
|
||||||
@@ -50,7 +50,8 @@ hide_output $venv/bin/pip install --upgrade pip
|
|||||||
hide_output $venv/bin/pip install --upgrade \
|
hide_output $venv/bin/pip install --upgrade \
|
||||||
rtyaml "email_validator>=1.0.0" "exclusiveprocess" \
|
rtyaml "email_validator>=1.0.0" "exclusiveprocess" \
|
||||||
flask dnspython python-dateutil \
|
flask dnspython python-dateutil \
|
||||||
"idna>=2.0.0" "cryptography==2.2.2" boto psutil
|
qrcode[pil] pyotp \
|
||||||
|
"idna>=2.0.0" "cryptography==2.2.2" boto psutil postfix-mta-sts-resolver
|
||||||
|
|
||||||
# CONFIGURATION
|
# CONFIGURATION
|
||||||
|
|
||||||
@@ -93,17 +94,19 @@ source $venv/bin/activate
|
|||||||
exec python `pwd`/management/daemon.py
|
exec python `pwd`/management/daemon.py
|
||||||
EOF
|
EOF
|
||||||
chmod +x $inst_dir/start
|
chmod +x $inst_dir/start
|
||||||
hide_output systemctl link -f conf/mailinabox.service
|
cp --remove-destination conf/mailinabox.service /lib/systemd/system/mailinabox.service # target was previously a symlink so remove it first
|
||||||
|
hide_output systemctl link -f /lib/systemd/system/mailinabox.service
|
||||||
hide_output systemctl daemon-reload
|
hide_output systemctl daemon-reload
|
||||||
hide_output systemctl enable mailinabox.service
|
hide_output systemctl enable mailinabox.service
|
||||||
|
|
||||||
# Perform nightly tasks at 3am in system time: take a backup, run
|
# Perform nightly tasks at 3am in system time: take a backup, run
|
||||||
# status checks and email the administrator any changes.
|
# status checks and email the administrator any changes.
|
||||||
|
|
||||||
|
minute=$((RANDOM % 60)) # avoid overloading mailinabox.email
|
||||||
cat > /etc/cron.d/mailinabox-nightly << EOF;
|
cat > /etc/cron.d/mailinabox-nightly << EOF;
|
||||||
# Mail-in-a-Box --- Do not edit / will be overwritten on update.
|
# Mail-in-a-Box --- Do not edit / will be overwritten on update.
|
||||||
# Run nightly tasks: backup, status checks.
|
# Run nightly tasks: backup, status checks.
|
||||||
0 3 * * * root (cd `pwd` && management/daily_tasks.sh)
|
$minute 3 * * * root (cd `pwd` && management/daily_tasks.sh)
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Start the management server.
|
# Start the management server.
|
||||||
|
|||||||
@@ -181,6 +181,12 @@ def migration_12(env):
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
def migration_13(env):
|
||||||
|
# Add the "mfa" table for configuring MFA for login to the control panel.
|
||||||
|
db = os.path.join(env["STORAGE_ROOT"], 'mail/users.sqlite')
|
||||||
|
shell("check_call", ["sqlite3", db, "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);"])
|
||||||
|
|
||||||
|
###########################################################
|
||||||
|
|
||||||
def get_current_migration():
|
def get_current_migration():
|
||||||
ver = 0
|
ver = 0
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ find /etc/munin/plugins/ -lname /usr/share/munin/plugins/ntp_ -print0 | xargs -0
|
|||||||
# Deactivate monitoring of network interfaces that are not up. Otherwise we can get a lot of empty charts.
|
# Deactivate monitoring of network interfaces that are not up. Otherwise we can get a lot of empty charts.
|
||||||
for f in $(find /etc/munin/plugins/ \( -lname /usr/share/munin/plugins/if_ -o -lname /usr/share/munin/plugins/if_err_ -o -lname /usr/share/munin/plugins/bonding_err_ \)); do
|
for f in $(find /etc/munin/plugins/ \( -lname /usr/share/munin/plugins/if_ -o -lname /usr/share/munin/plugins/if_err_ -o -lname /usr/share/munin/plugins/bonding_err_ \)); do
|
||||||
IF=$(echo $f | sed s/.*_//);
|
IF=$(echo $f | sed s/.*_//);
|
||||||
if ! ifquery $IF >/dev/null 2>/dev/null; then
|
if ! grep -qFx up /sys/class/net/$IF/operstate 2>/dev/null; then
|
||||||
rm $f;
|
rm $f;
|
||||||
fi;
|
fi;
|
||||||
done
|
done
|
||||||
@@ -64,7 +64,8 @@ mkdir -p /var/lib/munin-node/plugin-state/
|
|||||||
# Create a systemd service for munin.
|
# Create a systemd service for munin.
|
||||||
ln -sf $(pwd)/management/munin_start.sh /usr/local/lib/mailinabox/munin_start.sh
|
ln -sf $(pwd)/management/munin_start.sh /usr/local/lib/mailinabox/munin_start.sh
|
||||||
chmod 0744 /usr/local/lib/mailinabox/munin_start.sh
|
chmod 0744 /usr/local/lib/mailinabox/munin_start.sh
|
||||||
hide_output systemctl link -f conf/munin.service
|
cp --remove-destination conf/munin.service /lib/systemd/system/munin.service # target was previously a symlink so remove first
|
||||||
|
hide_output systemctl link -f /lib/systemd/system/munin.service
|
||||||
hide_output systemctl daemon-reload
|
hide_output systemctl daemon-reload
|
||||||
hide_output systemctl unmask munin.service
|
hide_output systemctl unmask munin.service
|
||||||
hide_output systemctl enable munin.service
|
hide_output systemctl enable munin.service
|
||||||
@@ -76,4 +77,8 @@ restart_service munin-node
|
|||||||
# generate initial statistics so the directory isn't empty
|
# generate initial statistics so the directory isn't empty
|
||||||
# (We get "Pango-WARNING **: error opening config file '/root/.config/pango/pangorc': Permission denied"
|
# (We get "Pango-WARNING **: error opening config file '/root/.config/pango/pangorc': Permission denied"
|
||||||
# if we don't explicitly set the HOME directory when sudo'ing.)
|
# if we don't explicitly set the HOME directory when sudo'ing.)
|
||||||
sudo -H -u munin munin-cron
|
# We check to see if munin-cron is already running, if it is, there is no need to run it simultaneously
|
||||||
|
# generating an error.
|
||||||
|
if [ ! -f /var/run/munin/munin-update.lock ]; then
|
||||||
|
sudo -H -u munin munin-cron
|
||||||
|
fi
|
||||||
|
|||||||
@@ -13,23 +13,30 @@ apt-get purge -qq -y owncloud* # we used to use the package manager
|
|||||||
|
|
||||||
apt_install php php-fpm \
|
apt_install php php-fpm \
|
||||||
php-cli php-sqlite3 php-gd php-imap php-curl php-pear curl \
|
php-cli php-sqlite3 php-gd php-imap php-curl php-pear curl \
|
||||||
php-dev php-gd php-xml php-mbstring php-zip php-apcu php-json php-intl
|
php-dev php-gd php-xml php-mbstring php-zip php-apcu php-json \
|
||||||
|
php-intl php-imagick php-gmp php-bcmath
|
||||||
|
|
||||||
InstallNextcloud() {
|
InstallNextcloud() {
|
||||||
|
|
||||||
version=$1
|
version=$1
|
||||||
hash=$2
|
hash=$2
|
||||||
|
version_contacts=$3
|
||||||
|
hash_contacts=$4
|
||||||
|
version_calendar=$5
|
||||||
|
hash_calendar=$6
|
||||||
|
version_user_external=$7
|
||||||
|
hash_user_external=$8
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Upgrading to Nextcloud version $version"
|
echo "Upgrading to Nextcloud version $version"
|
||||||
echo
|
echo
|
||||||
|
|
||||||
|
# Download and verify
|
||||||
|
wget_verify https://download.nextcloud.com/server/releases/nextcloud-$version.zip $hash /tmp/nextcloud.zip
|
||||||
|
|
||||||
# Remove the current owncloud/Nextcloud
|
# Remove the current owncloud/Nextcloud
|
||||||
rm -rf /usr/local/lib/owncloud
|
rm -rf /usr/local/lib/owncloud
|
||||||
|
|
||||||
# Download and verify
|
|
||||||
wget_verify https://download.nextcloud.com/server/releases/nextcloud-$version.zip $hash /tmp/nextcloud.zip
|
|
||||||
|
|
||||||
# Extract ownCloud/Nextcloud
|
# Extract ownCloud/Nextcloud
|
||||||
unzip -q /tmp/nextcloud.zip -d /usr/local/lib
|
unzip -q /tmp/nextcloud.zip -d /usr/local/lib
|
||||||
mv /usr/local/lib/nextcloud /usr/local/lib/owncloud
|
mv /usr/local/lib/nextcloud /usr/local/lib/owncloud
|
||||||
@@ -39,14 +46,22 @@ InstallNextcloud() {
|
|||||||
# their github repositories.
|
# their github repositories.
|
||||||
mkdir -p /usr/local/lib/owncloud/apps
|
mkdir -p /usr/local/lib/owncloud/apps
|
||||||
|
|
||||||
wget_verify https://github.com/nextcloud/contacts/releases/download/v2.1.8/contacts.tar.gz b5d5bbee33f0c32b124b46cb6aaab90c695ac170 /tmp/contacts.tgz
|
wget_verify https://github.com/nextcloud/contacts/releases/download/v$version_contacts/contacts.tar.gz $hash_contacts /tmp/contacts.tgz
|
||||||
tar xf /tmp/contacts.tgz -C /usr/local/lib/owncloud/apps/
|
tar xf /tmp/contacts.tgz -C /usr/local/lib/owncloud/apps/
|
||||||
rm /tmp/contacts.tgz
|
rm /tmp/contacts.tgz
|
||||||
|
|
||||||
wget_verify https://github.com/nextcloud/calendar/releases/download/v1.6.4/calendar.tar.gz d8a7950dba14803472b6c19625a8ceb23d6fd4ef /tmp/calendar.tgz
|
wget_verify https://github.com/nextcloud/calendar/releases/download/v$version_calendar/calendar.tar.gz $hash_calendar /tmp/calendar.tgz
|
||||||
tar xf /tmp/calendar.tgz -C /usr/local/lib/owncloud/apps/
|
tar xf /tmp/calendar.tgz -C /usr/local/lib/owncloud/apps/
|
||||||
rm /tmp/calendar.tgz
|
rm /tmp/calendar.tgz
|
||||||
|
|
||||||
|
# Starting with Nextcloud 15, the app user_external is no longer included in Nextcloud core,
|
||||||
|
# we will install from their github repository.
|
||||||
|
if [ -n "$version_user_external" ]; then
|
||||||
|
wget_verify https://github.com/nextcloud/user_external/releases/download/v$version_user_external/user_external-$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
|
||||||
|
|
||||||
# Fix weird permissions.
|
# Fix weird permissions.
|
||||||
chmod 750 /usr/local/lib/owncloud/{apps,config}
|
chmod 750 /usr/local/lib/owncloud/{apps,config}
|
||||||
|
|
||||||
@@ -75,15 +90,38 @@ InstallNextcloud() {
|
|||||||
|
|
||||||
# Add missing indices. NextCloud didn't include this in the normal upgrade because it might take some time.
|
# Add missing indices. NextCloud didn't include this in the normal upgrade because it might take some time.
|
||||||
sudo -u www-data php /usr/local/lib/owncloud/occ db:add-missing-indices
|
sudo -u www-data php /usr/local/lib/owncloud/occ db:add-missing-indices
|
||||||
|
|
||||||
|
# Run conversion to BigInt identifiers, this process may take some time on large tables.
|
||||||
|
sudo -u www-data php /usr/local/lib/owncloud/occ db:convert-filecache-bigint --no-interaction
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
nextcloud_ver=14.0.6
|
# Nextcloud Version to install. Checks are done down below to step through intermediate versions.
|
||||||
nextcloud_hash=4e43a57340f04c2da306c8eea98e30040399ae5a
|
nextcloud_ver=20.0.1
|
||||||
|
nextcloud_hash=f2b3faa570c541df73f209e873a1c2852e79eab8
|
||||||
|
contacts_ver=3.4.1
|
||||||
|
contacts_hash=aee680a75e95f26d9285efd3c1e25cf7f3bfd27e
|
||||||
|
calendar_ver=2.1.2
|
||||||
|
calendar_hash=930c07863bb7a65652dec34793802c8d80502336
|
||||||
|
user_external_ver=1.0.0
|
||||||
|
user_external_hash=3bf2609061d7214e7f0f69dd8883e55c4ec8f50a
|
||||||
|
|
||||||
# Check if Nextcloud dir exist, and check if version matches nextcloud_ver (if either doesn't - install/upgrade)
|
# Current Nextcloud Version, #1623
|
||||||
if [ ! -d /usr/local/lib/owncloud/ ] \
|
# Checking /usr/local/lib/owncloud/version.php shows version of the Nextcloud application, not the DB
|
||||||
|| ! grep -q $nextcloud_ver /usr/local/lib/owncloud/version.php; then
|
# $STORAGE_ROOT/owncloud is kept together even during a backup. It is better to rely on config.php than
|
||||||
|
# version.php since the restore procedure can leave the system in a state where you have a newer Nextcloud
|
||||||
|
# application version than the database.
|
||||||
|
|
||||||
|
# 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 -r "include(\"$STORAGE_ROOT/owncloud/config.php\"); echo(\$CONFIG['version']);")
|
||||||
|
else
|
||||||
|
CURRENT_NEXTCLOUD_VER=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If the Nextcloud directory is missing (never been installed before, or the nextcloud version to be installed is different
|
||||||
|
# from the version currently installed, do the install/upgrade
|
||||||
|
if [ ! -d /usr/local/lib/owncloud/ ] || [[ ! ${CURRENT_NEXTCLOUD_VER} =~ ^$nextcloud_ver ]]; then
|
||||||
|
|
||||||
# Stop php-fpm if running. If theyre not running (which happens on a previously failed install), dont bail.
|
# Stop php-fpm if running. If theyre not running (which happens on a previously failed install), dont bail.
|
||||||
service php7.2-fpm stop &> /dev/null || /bin/true
|
service php7.2-fpm stop &> /dev/null || /bin/true
|
||||||
@@ -104,20 +142,50 @@ if [ ! -d /usr/local/lib/owncloud/ ] \
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# If ownCloud or Nextcloud was previously installed....
|
# If ownCloud or Nextcloud was previously installed....
|
||||||
if [ -e /usr/local/lib/owncloud/version.php ]; then
|
if [ ! -z ${CURRENT_NEXTCLOUD_VER} ]; then
|
||||||
# Database migrations from ownCloud are no longer possible because ownCloud cannot be run under
|
# Database migrations from ownCloud are no longer possible because ownCloud cannot be run under
|
||||||
# PHP 7.
|
# PHP 7.
|
||||||
if grep -q "OC_VersionString = '[89]\." /usr/local/lib/owncloud/version.php; then
|
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^[89] ]]; then
|
||||||
echo "Upgrades from Mail-in-a-Box prior to v0.28 (dated July 30, 2018) with Nextcloud < 13.0.6 (you have ownCloud 8 or 9) are not supported. Upgrade to Mail-in-a-Box version v0.30 first. Setup aborting."
|
echo "Upgrades from Mail-in-a-Box prior to v0.28 (dated July 30, 2018) with Nextcloud < 13.0.6 (you have ownCloud 8 or 9) are not supported. Upgrade to Mail-in-a-Box version v0.30 first. Setup will continue, but skip the Nextcloud migration."
|
||||||
exit 1
|
return 0
|
||||||
|
elif [[ ${CURRENT_NEXTCLOUD_VER} =~ ^1[012] ]]; then
|
||||||
|
echo "Upgrades from Mail-in-a-Box prior to v0.28 (dated July 30, 2018) with Nextcloud < 13.0.6 (you have ownCloud 10, 11 or 12) are not supported. Upgrade to Mail-in-a-Box version v0.30 first. Setup will continue, but skip the Nextcloud migration."
|
||||||
|
return 0
|
||||||
|
elif [[ ${CURRENT_NEXTCLOUD_VER} =~ ^13 ]]; then
|
||||||
|
# If we are running Nextcloud 13, upgrade to Nextcloud 14
|
||||||
|
InstallNextcloud 14.0.6 4e43a57340f04c2da306c8eea98e30040399ae5a 3.3.0 e55d0357c6785d3b1f3b5f21780cb6d41d32443a 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466
|
||||||
|
CURRENT_NEXTCLOUD_VER="14.0.6"
|
||||||
fi
|
fi
|
||||||
if grep -q "OC_VersionString = '1[012]\." /usr/local/lib/owncloud/version.php; then
|
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^14 ]]; then
|
||||||
echo "Upgrades from Mail-in-a-Box prior to v0.28 (dated July 30, 2018) with Nextcloud < 13.0.6 (you have ownCloud 10, 11 or 12) are not supported. Upgrade to Mail-in-a-Box version v0.30 first. Setup aborting."
|
# During the upgrade from Nextcloud 14 to 15, user_external may cause the upgrade to fail.
|
||||||
exit 1
|
# We will disable it here before the upgrade and install it again after the upgrade.
|
||||||
|
hide_output sudo -u www-data php /usr/local/lib/owncloud/console.php app:disable user_external
|
||||||
|
InstallNextcloud 15.0.8 4129d8d4021c435f2e86876225fb7f15adf764a3 3.3.0 e55d0357c6785d3b1f3b5f21780cb6d41d32443a 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 0.7.0 555a94811daaf5bdd336c5e48a78aa8567b86437
|
||||||
|
CURRENT_NEXTCLOUD_VER="15.0.8"
|
||||||
fi
|
fi
|
||||||
|
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^15 ]]; then
|
||||||
|
InstallNextcloud 16.0.6 0bb3098455ec89f5af77a652aad553ad40a88819 3.3.0 e55d0357c6785d3b1f3b5f21780cb6d41d32443a 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 0.7.0 555a94811daaf5bdd336c5e48a78aa8567b86437
|
||||||
|
CURRENT_NEXTCLOUD_VER="16.0.6"
|
||||||
|
fi
|
||||||
|
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^16 ]]; then
|
||||||
|
InstallNextcloud 17.0.6 50b98d2c2f18510b9530e558ced9ab51eb4f11b0 3.3.0 e55d0357c6785d3b1f3b5f21780cb6d41d32443a 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 0.7.0 555a94811daaf5bdd336c5e48a78aa8567b86437
|
||||||
|
CURRENT_NEXTCLOUD_VER="17.0.6"
|
||||||
|
fi
|
||||||
|
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^17 ]]; then
|
||||||
|
echo "ALTER TABLE oc_flow_operations ADD COLUMN entity VARCHAR;" | sqlite3 $STORAGE_ROOT/owncloud/owncloud.db
|
||||||
|
InstallNextcloud 18.0.10 39c0021a8b8477c3f1733fddefacfa5ebf921c68 3.4.1 aee680a75e95f26d9285efd3c1e25cf7f3bfd27e 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 1.0.0 3bf2609061d7214e7f0f69dd8883e55c4ec8f50a
|
||||||
|
CURRENT_NEXTCLOUD_VER="18.0.10"
|
||||||
|
fi
|
||||||
|
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^18 ]]; then
|
||||||
|
InstallNextcloud 19.0.4 01e98791ba12f4860d3d4047b9803f97a1b55c60 3.4.1 aee680a75e95f26d9285efd3c1e25cf7f3bfd27e 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 1.0.0 3bf2609061d7214e7f0f69dd8883e55c4ec8f50a
|
||||||
|
CURRENT_NEXTCLOUD_VER="19.0.4"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
InstallNextcloud $nextcloud_ver $nextcloud_hash
|
InstallNextcloud $nextcloud_ver $nextcloud_hash $contacts_ver $contacts_hash $calendar_ver $calendar_hash $user_external_ver $user_external_hash
|
||||||
|
|
||||||
|
# Nextcloud 20 needs to have some optional columns added
|
||||||
|
sudo -u www-data php /usr/local/lib/owncloud/occ db:add-missing-columns
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ### Configuring Nextcloud
|
# ### Configuring Nextcloud
|
||||||
@@ -142,10 +210,12 @@ if [ ! -f $STORAGE_ROOT/owncloud/owncloud.db ]; then
|
|||||||
'overwritewebroot' => '/cloud',
|
'overwritewebroot' => '/cloud',
|
||||||
'overwrite.cli.url' => '/cloud',
|
'overwrite.cli.url' => '/cloud',
|
||||||
'user_backends' => array(
|
'user_backends' => array(
|
||||||
array(
|
array(
|
||||||
'class'=>'OC_User_IMAP',
|
'class' => 'OC_User_IMAP',
|
||||||
'arguments'=>array('{127.0.0.1:993/imap/ssl/novalidate-cert}')
|
'arguments' => array(
|
||||||
)
|
'127.0.0.1', 143, null
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
'memcache.local' => '\OC\Memcache\APCu',
|
'memcache.local' => '\OC\Memcache\APCu',
|
||||||
'mail_smtpmode' => 'sendmail',
|
'mail_smtpmode' => 'sendmail',
|
||||||
@@ -197,7 +267,7 @@ fi
|
|||||||
# * We need to set the timezone to the system timezone to allow fail2ban to ban
|
# * We need to set the timezone to the system timezone to allow fail2ban to ban
|
||||||
# users within the proper timeframe
|
# users within the proper timeframe
|
||||||
# * We need to set the logdateformat to something that will work correctly with fail2ban
|
# * We need to set the logdateformat to something that will work correctly with fail2ban
|
||||||
# * mail_domain' needs to be set every time we run the setup. Making sure we are setting
|
# * mail_domain' needs to be set every time we run the setup. Making sure we are setting
|
||||||
# the correct domain name if the domain is being change from the previous setup.
|
# the correct domain name if the domain is being change from the previous setup.
|
||||||
# Use PHP to read the settings file, modify it, and write out the new settings array.
|
# Use PHP to read the settings file, modify it, and write out the new settings array.
|
||||||
TIMEZONE=$(cat /etc/timezone)
|
TIMEZONE=$(cat /etc/timezone)
|
||||||
@@ -217,6 +287,8 @@ include("$STORAGE_ROOT/owncloud/config.php");
|
|||||||
|
|
||||||
\$CONFIG['mail_domain'] = '$PRIMARY_HOSTNAME';
|
\$CONFIG['mail_domain'] = '$PRIMARY_HOSTNAME';
|
||||||
|
|
||||||
|
\$CONFIG['user_backends'] = array(array('class' => 'OC_User_IMAP','arguments' => array('127.0.0.1', 143, null),),);
|
||||||
|
|
||||||
echo "<?php\n\\\$CONFIG = ";
|
echo "<?php\n\\\$CONFIG = ";
|
||||||
var_export(\$CONFIG);
|
var_export(\$CONFIG);
|
||||||
echo ";";
|
echo ";";
|
||||||
@@ -259,10 +331,6 @@ tools/editconf.py /etc/php/7.2/cli/conf.d/10-opcache.ini -c ';' \
|
|||||||
opcache.save_comments=1 \
|
opcache.save_comments=1 \
|
||||||
opcache.revalidate_freq=1
|
opcache.revalidate_freq=1
|
||||||
|
|
||||||
# Configure the path environment for php-fpm
|
|
||||||
tools/editconf.py /etc/php/7.2/fpm/pool.d/www.conf -c ';' \
|
|
||||||
env[PATH]=/usr/local/bin:/usr/bin:/bin
|
|
||||||
|
|
||||||
# If apc is explicitly disabled we need to enable it
|
# If apc is explicitly disabled we need to enable it
|
||||||
if grep -q apc.enabled=0 /etc/php/7.2/mods-available/apcu.ini; then
|
if grep -q apc.enabled=0 /etc/php/7.2/mods-available/apcu.ini; then
|
||||||
tools/editconf.py /etc/php/7.2/mods-available/apcu.ini -c ';' \
|
tools/editconf.py /etc/php/7.2/mods-available/apcu.ini -c ';' \
|
||||||
@@ -270,18 +338,21 @@ if grep -q apc.enabled=0 /etc/php/7.2/mods-available/apcu.ini; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Set up a cron job for Nextcloud.
|
# Set up a cron job for Nextcloud.
|
||||||
cat > /etc/cron.hourly/mailinabox-owncloud << EOF;
|
cat > /etc/cron.d/mailinabox-nextcloud << EOF;
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Mail-in-a-Box
|
# Mail-in-a-Box
|
||||||
sudo -u www-data php -f /usr/local/lib/owncloud/cron.php
|
*/5 * * * * root sudo -u www-data php -f /usr/local/lib/owncloud/cron.php
|
||||||
EOF
|
EOF
|
||||||
chmod +x /etc/cron.hourly/mailinabox-owncloud
|
chmod +x /etc/cron.d/mailinabox-nextcloud
|
||||||
|
|
||||||
|
# Remove previous hourly cronjob
|
||||||
|
rm -f /etc/cron.hourly/mailinabox-owncloud
|
||||||
|
|
||||||
# There's nothing much of interest that a user could do as an admin for Nextcloud,
|
# There's nothing much of interest that a user could do as an admin for Nextcloud,
|
||||||
# and there's a lot they could mess up, so we don't make any users admins of Nextcloud.
|
# and there's a lot they could mess up, so we don't make any users admins of Nextcloud.
|
||||||
# But if we wanted to, we would do this:
|
# But if we wanted to, we would do this:
|
||||||
# ```
|
# ```
|
||||||
# for user in $(tools/mail.py user admins); do
|
# for user in $(management/cli.py user admins); do
|
||||||
# sqlite3 $STORAGE_ROOT/owncloud/owncloud.db "INSERT OR IGNORE INTO oc_group_user VALUES ('admin', '$user')"
|
# sqlite3 $STORAGE_ROOT/owncloud/owncloud.db "INSERT OR IGNORE INTO oc_group_user VALUES ('admin', '$user')"
|
||||||
# done
|
# done
|
||||||
# ```
|
# ```
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ if [[ $EUID -ne 0 ]]; then
|
|||||||
echo
|
echo
|
||||||
echo "sudo $0"
|
echo "sudo $0"
|
||||||
echo
|
echo
|
||||||
exit
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check that we are running on Ubuntu 18.04 LTS (or 18.04.xx).
|
# Check that we are running on Ubuntu 18.04 LTS (or 18.04.xx).
|
||||||
@@ -14,7 +14,7 @@ if [ "`lsb_release -d | sed 's/.*:\s*//' | sed 's/18\.04\.[0-9]/18.04/' `" != "U
|
|||||||
lsb_release -d | sed 's/.*:\s*//'
|
lsb_release -d | sed 's/.*:\s*//'
|
||||||
echo
|
echo
|
||||||
echo "We can't write scripts that run on every possible setup, sorry."
|
echo "We can't write scripts that run on every possible setup, sorry."
|
||||||
exit
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check that we have enough memory.
|
# Check that we have enough memory.
|
||||||
@@ -26,7 +26,7 @@ fi
|
|||||||
#
|
#
|
||||||
# Skip the check if we appear to be running inside of Vagrant, because that's really just for testing.
|
# Skip the check if we appear to be running inside of Vagrant, because that's really just for testing.
|
||||||
TOTAL_PHYSICAL_MEM=$(head -n 1 /proc/meminfo | awk '{print $2}')
|
TOTAL_PHYSICAL_MEM=$(head -n 1 /proc/meminfo | awk '{print $2}')
|
||||||
if [ $TOTAL_PHYSICAL_MEM -lt 500000 ]; then
|
if [ $TOTAL_PHYSICAL_MEM -lt 490000 ]; then
|
||||||
if [ ! -d /vagrant ]; then
|
if [ ! -d /vagrant ]; then
|
||||||
TOTAL_PHYSICAL_MEM=$(expr \( \( $TOTAL_PHYSICAL_MEM \* 1024 \) / 1000 \) / 1000)
|
TOTAL_PHYSICAL_MEM=$(expr \( \( $TOTAL_PHYSICAL_MEM \* 1024 \) / 1000 \) / 1000)
|
||||||
echo "Your Mail-in-a-Box needs more memory (RAM) to function properly."
|
echo "Your Mail-in-a-Box needs more memory (RAM) to function properly."
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ tools/editconf.py /etc/default/spampd \
|
|||||||
# the X-Spam-Status & X-Spam-Score mail headers and related headers.
|
# the X-Spam-Status & X-Spam-Score mail headers and related headers.
|
||||||
tools/editconf.py /etc/spamassassin/local.cf -s \
|
tools/editconf.py /etc/spamassassin/local.cf -s \
|
||||||
report_safe=0 \
|
report_safe=0 \
|
||||||
add_header="all Report _REPORT_" \
|
"add_header all Report"=_REPORT_ \
|
||||||
add_header="all Score _SCORE_"
|
"add_header all Score"=_SCORE_
|
||||||
|
|
||||||
# Bayesean learning
|
# Bayesean learning
|
||||||
# -----------------
|
# -----------------
|
||||||
|
|||||||
@@ -82,9 +82,10 @@ if [ ! -f $STORAGE_ROOT/mailinabox.version ]; then
|
|||||||
chown $STORAGE_USER.$STORAGE_USER $STORAGE_ROOT/mailinabox.version
|
chown $STORAGE_USER.$STORAGE_USER $STORAGE_ROOT/mailinabox.version
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
# Save the global options in /etc/mailinabox.conf so that standalone
|
# Save the global options in /etc/mailinabox.conf so that standalone
|
||||||
# tools know where to look for data.
|
# tools know where to look for data. The default MTA_STS_MODE setting
|
||||||
|
# is blank unless set by an environment variable, but see web.sh for
|
||||||
|
# how that is interpreted.
|
||||||
cat > /etc/mailinabox.conf << EOF;
|
cat > /etc/mailinabox.conf << EOF;
|
||||||
STORAGE_USER=$STORAGE_USER
|
STORAGE_USER=$STORAGE_USER
|
||||||
STORAGE_ROOT=$STORAGE_ROOT
|
STORAGE_ROOT=$STORAGE_ROOT
|
||||||
@@ -93,6 +94,7 @@ PUBLIC_IP=$PUBLIC_IP
|
|||||||
PUBLIC_IPV6=$PUBLIC_IPV6
|
PUBLIC_IPV6=$PUBLIC_IPV6
|
||||||
PRIVATE_IP=$PRIVATE_IP
|
PRIVATE_IP=$PRIVATE_IP
|
||||||
PRIVATE_IPV6=$PRIVATE_IPV6
|
PRIVATE_IPV6=$PRIVATE_IPV6
|
||||||
|
MTA_STS_MODE=${MTA_STS_MODE-}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Start service configuration.
|
# Start service configuration.
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ if [ ! -f /usr/bin/add-apt-repository ]; then
|
|||||||
apt_install software-properties-common
|
apt_install software-properties-common
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Ensure the universe repository is enabled since some of our packages
|
||||||
|
# come from there and minimal Ubuntu installs may have it turned off.
|
||||||
|
hide_output add-apt-repository -y universe
|
||||||
|
|
||||||
# Install the certbot PPA.
|
# Install the certbot PPA.
|
||||||
hide_output add-apt-repository -y ppa:certbot/certbot
|
hide_output add-apt-repository -y ppa:certbot/certbot
|
||||||
|
|
||||||
@@ -121,11 +125,12 @@ apt_get_quiet autoremove
|
|||||||
# * sudo: allows privileged users to execute commands as root without being root
|
# * sudo: allows privileged users to execute commands as root without being root
|
||||||
# * coreutils: includes `nproc` tool to report number of processors, mktemp
|
# * coreutils: includes `nproc` tool to report number of processors, mktemp
|
||||||
# * bc: allows us to do math to compute sane defaults
|
# * bc: allows us to do math to compute sane defaults
|
||||||
|
# * openssh-client: provides ssh-keygen
|
||||||
|
|
||||||
echo Installing system packages...
|
echo Installing system packages...
|
||||||
apt_install python3 python3-dev python3-pip \
|
apt_install python3 python3-dev python3-pip \
|
||||||
netcat-openbsd wget curl git sudo coreutils bc \
|
netcat-openbsd wget curl git sudo coreutils bc \
|
||||||
haveged pollinate unzip \
|
haveged pollinate openssh-client unzip \
|
||||||
unattended-upgrades cron ntp fail2ban rsyslog
|
unattended-upgrades cron ntp fail2ban rsyslog
|
||||||
|
|
||||||
# ### Suppress Upgrade Prompts
|
# ### Suppress Upgrade Prompts
|
||||||
@@ -251,7 +256,7 @@ if [ -z "${DISABLE_FIREWALL:-}" ]; then
|
|||||||
apt_install ufw
|
apt_install ufw
|
||||||
|
|
||||||
# Allow incoming connections to SSH.
|
# Allow incoming connections to SSH.
|
||||||
ufw_allow ssh;
|
ufw_limit ssh;
|
||||||
|
|
||||||
# ssh might be running on an alternate port. Use sshd -T to dump sshd's #NODOC
|
# ssh might be running on an alternate port. Use sshd -T to dump sshd's #NODOC
|
||||||
# settings, find the port it is supposedly running on, and open that port #NODOC
|
# settings, find the port it is supposedly running on, and open that port #NODOC
|
||||||
@@ -261,7 +266,7 @@ if [ -z "${DISABLE_FIREWALL:-}" ]; then
|
|||||||
if [ "$SSH_PORT" != "22" ]; then
|
if [ "$SSH_PORT" != "22" ]; then
|
||||||
|
|
||||||
echo Opening alternate SSH port $SSH_PORT. #NODOC
|
echo Opening alternate SSH port $SSH_PORT. #NODOC
|
||||||
ufw_allow $SSH_PORT #NODOC
|
ufw_limit $SSH_PORT #NODOC
|
||||||
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
69
setup/web.sh
69
setup/web.sh
@@ -19,7 +19,7 @@ fi
|
|||||||
|
|
||||||
echo "Installing Nginx (web server)..."
|
echo "Installing Nginx (web server)..."
|
||||||
|
|
||||||
apt_install nginx php-cli php-fpm
|
apt_install nginx php-cli php-fpm idn2
|
||||||
|
|
||||||
rm -f /etc/nginx/sites-enabled/default
|
rm -f /etc/nginx/sites-enabled/default
|
||||||
|
|
||||||
@@ -31,14 +31,19 @@ sed "s#STORAGE_ROOT#$STORAGE_ROOT#" \
|
|||||||
conf/nginx-ssl.conf > /etc/nginx/conf.d/ssl.conf
|
conf/nginx-ssl.conf > /etc/nginx/conf.d/ssl.conf
|
||||||
|
|
||||||
# Fix some nginx defaults.
|
# Fix some nginx defaults.
|
||||||
|
#
|
||||||
# The server_names_hash_bucket_size seems to prevent long domain names!
|
# The server_names_hash_bucket_size seems to prevent long domain names!
|
||||||
# The default, according to nginx's docs, depends on "the size of the
|
# The default, according to nginx's docs, depends on "the size of the
|
||||||
# processor’s cache line." It could be as low as 32. We fixed it at
|
# processor’s cache line." It could be as low as 32. We fixed it at
|
||||||
# 64 in 2014 to accommodate a long domain name (20 characters?). But
|
# 64 in 2014 to accommodate a long domain name (20 characters?). But
|
||||||
# even at 64, a 58-character domain name won't work (#93), so now
|
# even at 64, a 58-character domain name won't work (#93), so now
|
||||||
# we're going up to 128.
|
# we're going up to 128.
|
||||||
|
#
|
||||||
|
# Drop TLSv1.0, TLSv1.1, following the Mozilla "Intermediate" recommendations
|
||||||
|
# at https://ssl-config.mozilla.org/#server=nginx&server-version=1.17.0&config=intermediate&openssl-version=1.1.1.
|
||||||
tools/editconf.py /etc/nginx/nginx.conf -s \
|
tools/editconf.py /etc/nginx/nginx.conf -s \
|
||||||
server_names_hash_bucket_size="128;"
|
server_names_hash_bucket_size="128;" \
|
||||||
|
ssl_protocols="TLSv1.2 TLSv1.3;"
|
||||||
|
|
||||||
# Tell PHP not to expose its version number in the X-Powered-By header.
|
# Tell PHP not to expose its version number in the X-Powered-By header.
|
||||||
tools/editconf.py /etc/php/7.2/fpm/php.ini -c ';' \
|
tools/editconf.py /etc/php/7.2/fpm/php.ini -c ';' \
|
||||||
@@ -48,13 +53,47 @@ tools/editconf.py /etc/php/7.2/fpm/php.ini -c ';' \
|
|||||||
tools/editconf.py /etc/php/7.2/fpm/php.ini -c ';' \
|
tools/editconf.py /etc/php/7.2/fpm/php.ini -c ';' \
|
||||||
default_charset="UTF-8"
|
default_charset="UTF-8"
|
||||||
|
|
||||||
# Switch from the dynamic process manager to the ondemand manager see #1216
|
# Configure the path environment for php-fpm
|
||||||
tools/editconf.py /etc/php/7.2/fpm/pool.d/www.conf -c ';' \
|
tools/editconf.py /etc/php/7.2/fpm/pool.d/www.conf -c ';' \
|
||||||
pm=ondemand
|
env[PATH]=/usr/local/bin:/usr/bin:/bin \
|
||||||
|
|
||||||
# Bump up PHP's max_children to support more concurrent connections
|
# Configure php-fpm based on the amount of memory the machine has
|
||||||
tools/editconf.py /etc/php/7.2/fpm/pool.d/www.conf -c ';' \
|
# This is based on the nextcloud manual for performance tuning: https://docs.nextcloud.com/server/17/admin_manual/installation/server_tuning.html
|
||||||
pm.max_children=8
|
# 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 ]
|
||||||
|
then
|
||||||
|
tools/editconf.py /etc/php/7.2/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 ]
|
||||||
|
then
|
||||||
|
tools/editconf.py /etc/php/7.2/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 ]
|
||||||
|
then
|
||||||
|
tools/editconf.py /etc/php/7.2/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/7.2/fpm/pool.d/www.conf -c ';' \
|
||||||
|
pm=dynamic \
|
||||||
|
pm.max_children=120 \
|
||||||
|
pm.start_servers=12 \
|
||||||
|
pm.min_spare_servers=6 \
|
||||||
|
pm.max_spare_servers=18
|
||||||
|
fi
|
||||||
|
|
||||||
# Other nginx settings will be configured by the management service
|
# Other nginx settings will be configured by the management service
|
||||||
# since it depends on what domains we're serving, which we don't know
|
# since it depends on what domains we're serving, which we don't know
|
||||||
@@ -83,6 +122,21 @@ cat conf/mozilla-autoconfig.xml \
|
|||||||
> /var/lib/mailinabox/mozilla-autoconfig.xml
|
> /var/lib/mailinabox/mozilla-autoconfig.xml
|
||||||
chmod a+r /var/lib/mailinabox/mozilla-autoconfig.xml
|
chmod a+r /var/lib/mailinabox/mozilla-autoconfig.xml
|
||||||
|
|
||||||
|
# Create a generic mta-sts.txt file which is exposed via the
|
||||||
|
# nginx configuration at /.well-known/mta-sts.txt
|
||||||
|
# more documentation is available on:
|
||||||
|
# https://www.uriports.com/blog/mta-sts-explained/
|
||||||
|
# default mode is "enforce". Change to "testing" which means
|
||||||
|
# "Messages will be delivered as though there was no failure
|
||||||
|
# but a report will be sent if TLS-RPT is configured" if you
|
||||||
|
# are not sure you want this yet. Or "none".
|
||||||
|
PUNY_PRIMARY_HOSTNAME=$(echo "$PRIMARY_HOSTNAME" | idn2)
|
||||||
|
cat conf/mta-sts.txt \
|
||||||
|
| sed "s/MODE/${MTA_STS_MODE:-enforce}/" \
|
||||||
|
| sed "s/PRIMARY_HOSTNAME/$PUNY_PRIMARY_HOSTNAME/" \
|
||||||
|
> /var/lib/mailinabox/mta-sts.txt
|
||||||
|
chmod a+r /var/lib/mailinabox/mta-sts.txt
|
||||||
|
|
||||||
# make a default homepage
|
# make a default homepage
|
||||||
if [ -d $STORAGE_ROOT/www/static ]; then mv $STORAGE_ROOT/www/static $STORAGE_ROOT/www/default; fi # migration #NODOC
|
if [ -d $STORAGE_ROOT/www/static ]; then mv $STORAGE_ROOT/www/static $STORAGE_ROOT/www/default; fi # migration #NODOC
|
||||||
mkdir -p $STORAGE_ROOT/www/default
|
mkdir -p $STORAGE_ROOT/www/default
|
||||||
@@ -98,4 +152,3 @@ restart_service php7.2-fpm
|
|||||||
# Open ports.
|
# Open ports.
|
||||||
ufw_allow http
|
ufw_allow http
|
||||||
ufw_allow https
|
ufw_allow https
|
||||||
|
|
||||||
|
|||||||
@@ -22,15 +22,15 @@ source /etc/mailinabox.conf # load global vars
|
|||||||
echo "Installing Roundcube (webmail)..."
|
echo "Installing Roundcube (webmail)..."
|
||||||
apt_install \
|
apt_install \
|
||||||
dbconfig-common \
|
dbconfig-common \
|
||||||
php-cli php-sqlite3 php-intl php-json php-common php-curl \
|
php-cli php-sqlite3 php-intl php-json php-common php-curl php-ldap \
|
||||||
php-gd php-pspell tinymce libjs-jquery libjs-jquery-mousewheel libmagic1 php-mbstring
|
php-gd php-pspell tinymce libjs-jquery libjs-jquery-mousewheel libmagic1 php-mbstring
|
||||||
|
|
||||||
# Install Roundcube from source if it is not already present or if it is out of date.
|
# Install Roundcube from source if it is not already present or if it is out of date.
|
||||||
# Combine the Roundcube version number with the commit hash of plugins to track
|
# Combine the Roundcube version number with the commit hash of plugins to track
|
||||||
# whether we have the latest version of everything.
|
# whether we have the latest version of everything.
|
||||||
VERSION=1.3.8
|
VERSION=1.4.9
|
||||||
HASH=90c7900ccf7b2f46fe49c650d5adb9b85ee9cc22
|
HASH=df650f4d3eae9eaae2d5a5f06d68665691daf57d
|
||||||
PERSISTENT_LOGIN_VERSION=dc5ca3d3f4415cc41edb2fde533c8a8628a94c76
|
PERSISTENT_LOGIN_VERSION=6b3fc450cae23ccb2f393d0ef67aa319e877e435
|
||||||
HTML5_NOTIFIER_VERSION=4b370e3cd60dabd2f428a26f45b677ad1b7118d5
|
HTML5_NOTIFIER_VERSION=4b370e3cd60dabd2f428a26f45b677ad1b7118d5
|
||||||
CARDDAV_VERSION=3.0.3
|
CARDDAV_VERSION=3.0.3
|
||||||
CARDDAV_HASH=d1e3b0d851ffa2c6bd42bf0c04f70d0e1d0d78f8
|
CARDDAV_HASH=d1e3b0d851ffa2c6bd42bf0c04f70d0e1d0d78f8
|
||||||
@@ -51,6 +51,13 @@ elif [[ "$UPDATE_KEY" != `cat /usr/local/lib/roundcubemail/version` ]]; then
|
|||||||
needs_update=1 #NODOC
|
needs_update=1 #NODOC
|
||||||
fi
|
fi
|
||||||
if [ $needs_update == 1 ]; then
|
if [ $needs_update == 1 ]; then
|
||||||
|
# if upgrading from 1.3.x, clear the temp_dir
|
||||||
|
if [ -f /usr/local/lib/roundcubemail/version ]; then
|
||||||
|
if [ "$(cat /usr/local/lib/roundcubemail/version | cut -c1-3)" == '1.3' ]; then
|
||||||
|
find /var/tmp/roundcubemail/ -type f ! -name 'RCMTEMP*' -delete
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# install roundcube
|
# install roundcube
|
||||||
wget_verify \
|
wget_verify \
|
||||||
https://github.com/roundcube/roundcubemail/releases/download/$VERSION/roundcubemail-$VERSION-complete.tar.gz \
|
https://github.com/roundcube/roundcubemail/releases/download/$VERSION/roundcubemail-$VERSION-complete.tar.gz \
|
||||||
@@ -110,9 +117,6 @@ cat > $RCM_CONFIG <<EOF;
|
|||||||
);
|
);
|
||||||
\$config['imap_timeout'] = 15;
|
\$config['imap_timeout'] = 15;
|
||||||
\$config['smtp_server'] = 'tls://127.0.0.1';
|
\$config['smtp_server'] = 'tls://127.0.0.1';
|
||||||
\$config['smtp_port'] = 587;
|
|
||||||
\$config['smtp_user'] = '%u';
|
|
||||||
\$config['smtp_pass'] = '%p';
|
|
||||||
\$config['smtp_conn_options'] = array(
|
\$config['smtp_conn_options'] = array(
|
||||||
'ssl' => array(
|
'ssl' => array(
|
||||||
'verify_peer' => false,
|
'verify_peer' => false,
|
||||||
@@ -123,7 +127,7 @@ cat > $RCM_CONFIG <<EOF;
|
|||||||
\$config['product_name'] = '$PRIMARY_HOSTNAME Webmail';
|
\$config['product_name'] = '$PRIMARY_HOSTNAME Webmail';
|
||||||
\$config['des_key'] = '$SECRET_KEY';
|
\$config['des_key'] = '$SECRET_KEY';
|
||||||
\$config['plugins'] = array('html5_notifier', 'archive', 'zipdownload', 'password', 'managesieve', 'jqueryui', 'persistent_login', 'carddav');
|
\$config['plugins'] = array('html5_notifier', 'archive', 'zipdownload', 'password', 'managesieve', 'jqueryui', 'persistent_login', 'carddav');
|
||||||
\$config['skin'] = 'larry';
|
\$config['skin'] = 'elastic';
|
||||||
\$config['login_autocomplete'] = 2;
|
\$config['login_autocomplete'] = 2;
|
||||||
\$config['password_charset'] = 'UTF-8';
|
\$config['password_charset'] = 'UTF-8';
|
||||||
\$config['junk_mbox'] = 'Spam';
|
\$config['junk_mbox'] = 'Spam';
|
||||||
@@ -156,7 +160,7 @@ mkdir -p /var/log/roundcubemail /var/tmp/roundcubemail $STORAGE_ROOT/mail/roundc
|
|||||||
chown -R www-data.www-data /var/log/roundcubemail /var/tmp/roundcubemail $STORAGE_ROOT/mail/roundcube
|
chown -R www-data.www-data /var/log/roundcubemail /var/tmp/roundcubemail $STORAGE_ROOT/mail/roundcube
|
||||||
|
|
||||||
# Ensure the log file monitored by fail2ban exists, or else fail2ban can't start.
|
# Ensure the log file monitored by fail2ban exists, or else fail2ban can't start.
|
||||||
sudo -u www-data touch /var/log/roundcubemail/errors
|
sudo -u www-data touch /var/log/roundcubemail/errors.log
|
||||||
|
|
||||||
# Password changing plugin settings
|
# Password changing plugin settings
|
||||||
# The config comes empty by default, so we need the settings
|
# The config comes empty by default, so we need the settings
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ apt_install \
|
|||||||
phpenmod -v php imap
|
phpenmod -v php imap
|
||||||
|
|
||||||
# Copy Z-Push into place.
|
# Copy Z-Push into place.
|
||||||
VERSION=2.4.4
|
VERSION=2.5.2
|
||||||
TARGETHASH=104d44426852429dac8ec2783a4e9ad7752d4682
|
TARGETHASH=2dc3dbd791b96b0ba2638df0d3d1e03c7e1cbab2
|
||||||
needs_update=0 #NODOC
|
needs_update=0 #NODOC
|
||||||
if [ ! -f /usr/local/lib/z-push/version ]; then
|
if [ ! -f /usr/local/lib/z-push/version ]; then
|
||||||
needs_update=1 #NODOC
|
needs_update=1 #NODOC
|
||||||
|
|||||||
@@ -90,6 +90,26 @@ def pop_test():
|
|||||||
if M:
|
if M:
|
||||||
M.quit()
|
M.quit()
|
||||||
|
|
||||||
|
def managesieve_test():
|
||||||
|
# We don't have a Python sieve client, so we'll
|
||||||
|
# just run the IMAP client and see what happens.
|
||||||
|
import imaplib
|
||||||
|
|
||||||
|
try:
|
||||||
|
M = imaplib.IMAP4(hostname, 4190)
|
||||||
|
except ConnectionRefusedError:
|
||||||
|
# looks like fail2ban worked
|
||||||
|
raise IsBlocked()
|
||||||
|
|
||||||
|
try:
|
||||||
|
M.login("fakeuser", "fakepassword")
|
||||||
|
raise Exception("authentication didn't fail")
|
||||||
|
except imaplib.IMAP4.error:
|
||||||
|
# authentication should fail
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
M.logout() # shuts down connection, has nothing to do with login()
|
||||||
|
|
||||||
def http_test(url, expected_status, postdata=None, qsargs=None, auth=None):
|
def http_test(url, expected_status, postdata=None, qsargs=None, auth=None):
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import requests
|
import requests
|
||||||
@@ -208,6 +228,9 @@ if __name__ == "__main__":
|
|||||||
# POP
|
# POP
|
||||||
run_test(pop_test, [], 20, 30, 4)
|
run_test(pop_test, [], 20, 30, 4)
|
||||||
|
|
||||||
|
# Managesieve
|
||||||
|
run_test(managesieve_test, [], 20, 30, 4)
|
||||||
|
|
||||||
# Mail-in-a-Box control panel
|
# Mail-in-a-Box control panel
|
||||||
run_test(http_test, ["/admin/me", 200], 20, 30, 1)
|
run_test(http_test, ["/admin/me", 200], 20, 30, 1)
|
||||||
|
|
||||||
|
|||||||
134
tools/mail.py
134
tools/mail.py
@@ -1,131 +1,3 @@
|
|||||||
#!/usr/bin/python3
|
#!/bin/bash
|
||||||
|
# This script has moved.
|
||||||
import sys, getpass, urllib.request, urllib.error, json, re
|
management/cli.py "$@"
|
||||||
|
|
||||||
def mgmt(cmd, data=None, is_json=False):
|
|
||||||
# The base URL for the management daemon. (Listens on IPv4 only.)
|
|
||||||
mgmt_uri = 'http://127.0.0.1:10222'
|
|
||||||
|
|
||||||
setup_key_auth(mgmt_uri)
|
|
||||||
|
|
||||||
req = urllib.request.Request(mgmt_uri + cmd, urllib.parse.urlencode(data).encode("utf8") if data else None)
|
|
||||||
try:
|
|
||||||
response = urllib.request.urlopen(req)
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
if e.code == 401:
|
|
||||||
try:
|
|
||||||
print(e.read().decode("utf8"))
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
print("The management daemon refused access. The API key file may be out of sync. Try 'service mailinabox restart'.", file=sys.stderr)
|
|
||||||
elif hasattr(e, 'read'):
|
|
||||||
print(e.read().decode('utf8'), file=sys.stderr)
|
|
||||||
else:
|
|
||||||
print(e, file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
resp = response.read().decode('utf8')
|
|
||||||
if is_json: resp = json.loads(resp)
|
|
||||||
return resp
|
|
||||||
|
|
||||||
def read_password():
|
|
||||||
while True:
|
|
||||||
first = getpass.getpass('password: ')
|
|
||||||
if len(first) < 8:
|
|
||||||
print("Passwords must be at least eight characters.")
|
|
||||||
continue
|
|
||||||
if re.search(r'[\s]', first):
|
|
||||||
print("Passwords cannot contain spaces.")
|
|
||||||
continue
|
|
||||||
second = getpass.getpass(' (again): ')
|
|
||||||
if first != second:
|
|
||||||
print("Passwords not the same. Try again.")
|
|
||||||
continue
|
|
||||||
break
|
|
||||||
return first
|
|
||||||
|
|
||||||
def setup_key_auth(mgmt_uri):
|
|
||||||
key = open('/var/lib/mailinabox/api.key').read().strip()
|
|
||||||
|
|
||||||
auth_handler = urllib.request.HTTPBasicAuthHandler()
|
|
||||||
auth_handler.add_password(
|
|
||||||
realm='Mail-in-a-Box Management Server',
|
|
||||||
uri=mgmt_uri,
|
|
||||||
user=key,
|
|
||||||
passwd='')
|
|
||||||
opener = urllib.request.build_opener(auth_handler)
|
|
||||||
urllib.request.install_opener(opener)
|
|
||||||
|
|
||||||
if len(sys.argv) < 2:
|
|
||||||
print("Usage: ")
|
|
||||||
print(" tools/mail.py user (lists users)")
|
|
||||||
print(" tools/mail.py user add user@domain.com [password]")
|
|
||||||
print(" tools/mail.py user password user@domain.com [password]")
|
|
||||||
print(" tools/mail.py user remove user@domain.com")
|
|
||||||
print(" tools/mail.py user make-admin user@domain.com")
|
|
||||||
print(" tools/mail.py user remove-admin user@domain.com")
|
|
||||||
print(" tools/mail.py user admins (lists admins)")
|
|
||||||
print(" tools/mail.py alias (lists aliases)")
|
|
||||||
print(" tools/mail.py alias add incoming.name@domain.com sent.to@other.domain.com")
|
|
||||||
print(" tools/mail.py alias add incoming.name@domain.com 'sent.to@other.domain.com, multiple.people@other.domain.com'")
|
|
||||||
print(" tools/mail.py alias remove incoming.name@domain.com")
|
|
||||||
print()
|
|
||||||
print("Removing a mail user does not delete their mail folders on disk. It only prevents IMAP/SMTP login.")
|
|
||||||
print()
|
|
||||||
|
|
||||||
elif sys.argv[1] == "user" and len(sys.argv) == 2:
|
|
||||||
# Dump a list of users, one per line. Mark admins with an asterisk.
|
|
||||||
users = mgmt("/mail/users?format=json", is_json=True)
|
|
||||||
for domain in users:
|
|
||||||
for user in domain["users"]:
|
|
||||||
if user['status'] == 'inactive': continue
|
|
||||||
print(user['email'], end='')
|
|
||||||
if "admin" in user['privileges']:
|
|
||||||
print("*", end='')
|
|
||||||
print()
|
|
||||||
|
|
||||||
elif sys.argv[1] == "user" and sys.argv[2] in ("add", "password"):
|
|
||||||
if len(sys.argv) < 5:
|
|
||||||
if len(sys.argv) < 4:
|
|
||||||
email = input("email: ")
|
|
||||||
else:
|
|
||||||
email = sys.argv[3]
|
|
||||||
pw = read_password()
|
|
||||||
else:
|
|
||||||
email, pw = sys.argv[3:5]
|
|
||||||
|
|
||||||
if sys.argv[2] == "add":
|
|
||||||
print(mgmt("/mail/users/add", { "email": email, "password": pw }))
|
|
||||||
elif sys.argv[2] == "password":
|
|
||||||
print(mgmt("/mail/users/password", { "email": email, "password": pw }))
|
|
||||||
|
|
||||||
elif sys.argv[1] == "user" and sys.argv[2] == "remove" and len(sys.argv) == 4:
|
|
||||||
print(mgmt("/mail/users/remove", { "email": sys.argv[3] }))
|
|
||||||
|
|
||||||
elif sys.argv[1] == "user" and sys.argv[2] in ("make-admin", "remove-admin") and len(sys.argv) == 4:
|
|
||||||
if sys.argv[2] == "make-admin":
|
|
||||||
action = "add"
|
|
||||||
else:
|
|
||||||
action = "remove"
|
|
||||||
print(mgmt("/mail/users/privileges/" + action, { "email": sys.argv[3], "privilege": "admin" }))
|
|
||||||
|
|
||||||
elif sys.argv[1] == "user" and sys.argv[2] == "admins":
|
|
||||||
# Dump a list of admin users.
|
|
||||||
users = mgmt("/mail/users?format=json", is_json=True)
|
|
||||||
for domain in users:
|
|
||||||
for user in domain["users"]:
|
|
||||||
if "admin" in user['privileges']:
|
|
||||||
print(user['email'])
|
|
||||||
|
|
||||||
elif sys.argv[1] == "alias" and len(sys.argv) == 2:
|
|
||||||
print(mgmt("/mail/aliases"))
|
|
||||||
|
|
||||||
elif sys.argv[1] == "alias" and sys.argv[2] == "add" and len(sys.argv) == 5:
|
|
||||||
print(mgmt("/mail/aliases/add", { "address": sys.argv[3], "forwards_to": sys.argv[4] }))
|
|
||||||
|
|
||||||
elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4:
|
|
||||||
print(mgmt("/mail/aliases/remove", { "address": sys.argv[3] }))
|
|
||||||
|
|
||||||
else:
|
|
||||||
print("Invalid command-line arguments.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ fi
|
|||||||
|
|
||||||
if [ ! -f $1/config.php ]; then
|
if [ ! -f $1/config.php ]; then
|
||||||
echo "This isn't a valid backup location"
|
echo "This isn't a valid backup location"
|
||||||
exit
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Restoring backup from $1"
|
echo "Restoring backup from $1"
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ def generate_documentation():
|
|||||||
}
|
}
|
||||||
|
|
||||||
.prose {
|
.prose {
|
||||||
padding-top: 1em;
|
padding-top: 1em;
|
||||||
padding-bottom: 1em;
|
padding-bottom: 1em;
|
||||||
}
|
}
|
||||||
.terminal {
|
.terminal {
|
||||||
@@ -261,6 +261,10 @@ class UfwAllow(Grammar):
|
|||||||
grammar = (ZERO_OR_MORE(SPACE), L("ufw_allow "), REST_OF_LINE, EOL)
|
grammar = (ZERO_OR_MORE(SPACE), L("ufw_allow "), REST_OF_LINE, EOL)
|
||||||
def value(self):
|
def value(self):
|
||||||
return shell_line("ufw allow " + self[2].string)
|
return shell_line("ufw allow " + self[2].string)
|
||||||
|
class UfwLimit(Grammar):
|
||||||
|
grammar = (ZERO_OR_MORE(SPACE), L("ufw_limit "), REST_OF_LINE, EOL)
|
||||||
|
def value(self):
|
||||||
|
return shell_line("ufw limit " + self[2].string)
|
||||||
class RestartService(Grammar):
|
class RestartService(Grammar):
|
||||||
grammar = (ZERO_OR_MORE(SPACE), L("restart_service "), REST_OF_LINE, EOL)
|
grammar = (ZERO_OR_MORE(SPACE), L("restart_service "), REST_OF_LINE, EOL)
|
||||||
def value(self):
|
def value(self):
|
||||||
@@ -275,7 +279,7 @@ class OtherLine(Grammar):
|
|||||||
return "<pre class='shell'><div>" + recode_bash(self.string.strip()) + "</div></pre>\n"
|
return "<pre class='shell'><div>" + recode_bash(self.string.strip()) + "</div></pre>\n"
|
||||||
|
|
||||||
class BashElement(Grammar):
|
class BashElement(Grammar):
|
||||||
grammar = Comment | CatEOF | EchoPipe | EchoLine | HideOutput | EditConf | SedReplace | AptGet | UfwAllow | RestartService | OtherLine
|
grammar = Comment | CatEOF | EchoPipe | EchoLine | HideOutput | EditConf | SedReplace | AptGet | UfwAllow | UfwLimit | RestartService | OtherLine
|
||||||
def value(self):
|
def value(self):
|
||||||
return self[0].value()
|
return self[0].value()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user