mirror of
https://github.com/mail-in-a-box/mailinabox.git
synced 2026-03-12 17:07:23 +01:00
Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90d63fd208 | ||
|
|
e81963e585 | ||
|
|
b1d703a5e7 | ||
|
|
e3d98b781e | ||
|
|
50d50ba653 | ||
|
|
879467d358 | ||
|
|
8025c41ee4 | ||
|
|
7a5d729a53 | ||
|
|
e2f9cd845a | ||
|
|
e26cf4512c | ||
|
|
c7280055a8 | ||
|
|
003e8b7bb1 | ||
|
|
3422cc61ce | ||
|
|
8664afa997 | ||
|
|
82229ce04b | ||
|
|
f66e609d3f | ||
|
|
b85b86e6de | ||
|
|
7fd35bbd11 | ||
|
|
7ce41e3865 | ||
|
|
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 | ||
|
|
4bbe4af377 | ||
|
|
1098e2b48e | ||
|
|
c50170b816 | ||
|
|
cd518e6820 | ||
|
|
967409b157 | ||
|
|
1b2711fc42 | ||
|
|
e6102eacfb | ||
|
|
6fd3195275 | ||
|
|
224242dfde | ||
|
|
9db2fc7f05 | ||
|
|
e03a6541ce | ||
|
|
df9bb263dc | ||
|
|
339c330b4f | ||
|
|
cfc8fb484c | ||
|
|
bc1be9d70a | ||
|
|
3a4b8da8fd | ||
|
|
37dad9d4bb | ||
|
|
b805f8695e | ||
|
|
10bedad3a3 | ||
|
|
afc9f9686a |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ tools/__pycache__/
|
|||||||
externals/
|
externals/
|
||||||
.env
|
.env
|
||||||
.vagrant
|
.vagrant
|
||||||
|
api/docs/api-docs.html
|
||||||
86
CHANGELOG.md
86
CHANGELOG.md
@@ -1,6 +1,92 @@
|
|||||||
CHANGELOG
|
CHANGELOG
|
||||||
=========
|
=========
|
||||||
|
|
||||||
|
v0.52 (January 31, 2021)
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
Software updates:
|
||||||
|
|
||||||
|
* Upgraded Roundcube to version 1.4.10.
|
||||||
|
* Upgraded zpush to 2.6.1.
|
||||||
|
|
||||||
|
Mail:
|
||||||
|
|
||||||
|
* Incoming emails with SPF/DKIM/DMARC failures now get a higher spam score, and these messages are more likely to appear in the junk folder, since they are often spam/phishing.
|
||||||
|
* Fixed the MTA-STS policy file's line endings.
|
||||||
|
|
||||||
|
Control panel:
|
||||||
|
|
||||||
|
* A new Download button in the control panel's External DNS page can be used to download the required DNS records in zonefile format.
|
||||||
|
* Fixed the problem when the control panel would report DNS entries as Not Set by increasing a bind query limit.
|
||||||
|
* Fixed a control panel startup bug on some systems.
|
||||||
|
* Improved an error message on a DNS lookup timeout.
|
||||||
|
* A typo was fixed.
|
||||||
|
|
||||||
|
DNS:
|
||||||
|
|
||||||
|
* The TTL for NS records has been increased to 1 day to comply with some registrar requirements.
|
||||||
|
|
||||||
|
System:
|
||||||
|
|
||||||
|
* Nextcloud's photos, dashboard, and activity apps are disabled since we only support contacts and calendar.
|
||||||
|
|
||||||
|
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)
|
v0.47 (July 29, 2020)
|
||||||
---------------------
|
---------------------
|
||||||
|
|
||||||
|
|||||||
58
README.md
58
README.md
@@ -19,27 +19,34 @@ Our goals are to:
|
|||||||
|
|
||||||
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
|
||||||
------------
|
------------
|
||||||
|
|
||||||
@@ -47,40 +54,26 @@ See the [setup guide](https://mailinabox.email/guide.html) for detailed, user-fr
|
|||||||
|
|
||||||
For experts, start with a completely fresh (really, I mean it) Ubuntu 18.04 LTS 64-bit machine. On the machine...
|
For experts, start with a completely fresh (really, I mean it) Ubuntu 18.04 LTS 64-bit machine. On the machine...
|
||||||
|
|
||||||
Clone this repository:
|
Clone this repository and checkout the tag corresponding to the most recent release:
|
||||||
|
|
||||||
$ git clone https://github.com/mail-in-a-box/mailinabox
|
$ git clone https://github.com/mail-in-a-box/mailinabox
|
||||||
$ cd mailinabox
|
$ cd mailinabox
|
||||||
|
$ git checkout v0.52
|
||||||
_Optional:_ Download Josh's PGP key and then verify that the sources were signed
|
|
||||||
by him:
|
|
||||||
|
|
||||||
$ curl -s https://keybase.io/joshdata/key.asc | gpg --import
|
|
||||||
gpg: key C10BDD81: public key "Joshua Tauberer <jt@occams.info>" imported
|
|
||||||
|
|
||||||
$ git verify-tag v0.47
|
|
||||||
gpg: Signature made ..... using RSA key ID C10BDD81
|
|
||||||
gpg: Good signature from "Joshua Tauberer <jt@occams.info>"
|
|
||||||
gpg: WARNING: This key is not certified with a trusted signature!
|
|
||||||
gpg: There is no indication that the signature belongs to the owner.
|
|
||||||
Primary key fingerprint: 5F4C 0E73 13CC D744 693B 2AEA B920 41F4 C10B DD81
|
|
||||||
|
|
||||||
You'll get a lot of warnings, but that's OK. Check that the primary key fingerprint matches the
|
|
||||||
fingerprint in the key details at [https://keybase.io/joshdata](https://keybase.io/joshdata)
|
|
||||||
and on his [personal homepage](https://razor.occams.info/). (Of course, if this repository has been compromised you can't trust these instructions.)
|
|
||||||
|
|
||||||
Checkout the tag corresponding to the most recent release:
|
|
||||||
|
|
||||||
$ git checkout v0.47
|
|
||||||
|
|
||||||
Begin the installation.
|
Begin the installation.
|
||||||
|
|
||||||
$ sudo setup/start.sh
|
$ sudo setup/start.sh
|
||||||
|
|
||||||
|
The installation will install, uninstall, and configure packages to turn the machine into a working, good mail server.
|
||||||
|
|
||||||
For help, DO NOT contact Josh directly --- I don't do tech support by email or tweet (no exceptions).
|
For help, DO NOT contact Josh directly --- I don't do tech support by email or tweet (no exceptions).
|
||||||
|
|
||||||
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 +87,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>
|
||||||
2707
api/mailinabox.yml
Normal file
2707
api/mailinabox.yml
Normal file
File diff suppressed because it is too large
Load Diff
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
|
||||||
@@ -21,6 +21,9 @@
|
|||||||
location = /mail/config-v1.1.xml {
|
location = /mail/config-v1.1.xml {
|
||||||
alias /var/lib/mailinabox/mozilla-autoconfig.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;
|
||||||
|
|||||||
@@ -22,20 +22,20 @@
|
|||||||
rewrite ^(/cloud/oc[sm]-provider)/$ $1/index.php redirect;
|
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
|
# Enable paths for service and cloud federation discovery
|
||||||
# Resolves warning in Nextcloud Settings panel
|
# Resolves warning in Nextcloud Settings panel
|
||||||
location ~ ^/cloud/(oc[sm]-provider)?/([^/]+\.php)$ {
|
location ~ ^/cloud/(oc[sm]-provider)?/([^/]+\.php)$ {
|
||||||
index index.php;
|
index index.php;
|
||||||
include fastcgi_params;
|
include fastcgi_params;
|
||||||
fastcgi_param SCRIPT_FILENAME /usr/local/lib/owncloud/$1/$2;
|
fastcgi_param SCRIPT_FILENAME /usr/local/lib/owncloud/$1/$2;
|
||||||
fastcgi_pass php-fpm;
|
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,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)
|
||||||
|
|||||||
@@ -456,6 +456,23 @@ def list_target_files(config):
|
|||||||
raise ValueError(e.reason)
|
raise ValueError(e.reason)
|
||||||
|
|
||||||
return [(key.name[len(path):], key.size) for key in bucket.list(prefix=path)]
|
return [(key.name[len(path):], key.size) for key in bucket.list(prefix=path)]
|
||||||
|
elif target.scheme == 'b2':
|
||||||
|
from b2sdk.v1 import InMemoryAccountInfo, B2Api
|
||||||
|
from b2sdk.v1.exception import NonExistentBucket
|
||||||
|
info = InMemoryAccountInfo()
|
||||||
|
b2_api = B2Api(info)
|
||||||
|
|
||||||
|
# Extract information from target
|
||||||
|
b2_application_keyid = target.netloc[:target.netloc.index(':')]
|
||||||
|
b2_application_key = target.netloc[target.netloc.index(':')+1:target.netloc.index('@')]
|
||||||
|
b2_bucket = target.netloc[target.netloc.index('@')+1:]
|
||||||
|
|
||||||
|
try:
|
||||||
|
b2_api.authorize_account("production", b2_application_keyid, b2_application_key)
|
||||||
|
bucket = b2_api.get_bucket_by_name(b2_bucket)
|
||||||
|
except NonExistentBucket as e:
|
||||||
|
raise ValueError("B2 Bucket does not exist. Please double check your information!")
|
||||||
|
return [(key.file_name, key.size) for key, _ in bucket.ls()]
|
||||||
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(config["target"])
|
raise ValueError(config["target"])
|
||||||
|
|||||||
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,24 @@
|
|||||||
|
#!/usr/local/lib/mailinabox/env/bin/python3
|
||||||
|
#
|
||||||
|
# During development, you can start the Mail-in-a-Box control panel
|
||||||
|
# by running this script, e.g.:
|
||||||
|
#
|
||||||
|
# service mailinabox stop # stop the system process
|
||||||
|
# DEBUG=1 management/daemon.py
|
||||||
|
# service mailinabox start # when done debugging, start it up again
|
||||||
|
|
||||||
import os, os.path, re, json, time
|
import os, os.path, re, json, time
|
||||||
import 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 +45,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 +101,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 +137,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 = {
|
||||||
@@ -324,6 +347,12 @@ def dns_get_dump():
|
|||||||
from dns_update import build_recommended_dns
|
from dns_update import build_recommended_dns
|
||||||
return json_response(build_recommended_dns(env))
|
return json_response(build_recommended_dns(env))
|
||||||
|
|
||||||
|
@app.route('/dns/zonefile/<zone>')
|
||||||
|
@authorized_personnel_only
|
||||||
|
def dns_get_zonefile(zone):
|
||||||
|
from dns_update import get_dns_zonefile
|
||||||
|
return Response(get_dns_zonefile(zone, env), status=200, mimetype='text/plain')
|
||||||
|
|
||||||
# SSL
|
# SSL
|
||||||
|
|
||||||
@app.route('/ssl/status')
|
@app.route('/ssl/status')
|
||||||
@@ -383,6 +412,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 +520,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')
|
||||||
@@ -605,7 +687,22 @@ def log_failed_login(request):
|
|||||||
# APP
|
# APP
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
if "DEBUG" in os.environ: app.debug = True
|
if "DEBUG" in os.environ:
|
||||||
|
# Turn on Flask debugging.
|
||||||
|
app.debug = True
|
||||||
|
|
||||||
|
# Use a stable-ish master API key so that login sessions don't restart on each run.
|
||||||
|
# Use /etc/machine-id to seed the key with a stable secret, but add something
|
||||||
|
# and hash it to prevent possibly exposing the machine id, using the time so that
|
||||||
|
# the key is not valid indefinitely.
|
||||||
|
import hashlib
|
||||||
|
with open("/etc/machine-id") as f:
|
||||||
|
api_key = f.read()
|
||||||
|
api_key += "|" + str(int(time.time() / (60*60*2)))
|
||||||
|
hasher = hashlib.sha1()
|
||||||
|
hasher.update(api_key.encode("ascii"))
|
||||||
|
auth_service.key = hasher.hexdigest()
|
||||||
|
|
||||||
if "APIKEY" in os.environ: auth_service.key = os.environ["APIKEY"]
|
if "APIKEY" in os.environ: auth_service.key = os.environ["APIKEY"]
|
||||||
|
|
||||||
if not app.debug:
|
if not app.debug:
|
||||||
|
|||||||
@@ -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 "TLS Certificate Provisioning Result"
|
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,25 +281,81 @@ 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.
|
# 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.
|
# This allows the following clients to automatically configure email addresses in the respective applications.
|
||||||
# autodiscover.* - Z-Push ActiveSync Autodiscover
|
# autodiscover.* - Z-Push ActiveSync Autodiscover
|
||||||
# autoconfig.* - Thunderbird Autoconfig
|
# autoconfig.* - Thunderbird Autoconfig
|
||||||
autodiscover_records = [
|
if domain in get_mail_domains(env, users_only=True):
|
||||||
("autodiscover", "A", env["PUBLIC_IP"], "Provides email configuration autodiscovery support for Z-Push ActiveSync Autodiscover."),
|
autodiscover_records = [
|
||||||
("autodiscover", "AAAA", env["PUBLIC_IPV6"], "Provides email configuration autodiscovery support for Z-Push ActiveSync Autodiscover."),
|
("autodiscover", "A", env["PUBLIC_IP"], "Provides email configuration autodiscovery support for Z-Push ActiveSync Autodiscover."),
|
||||||
("autoconfig", "A", env["PUBLIC_IP"], "Provides email configuration autodiscovery support for Thunderbird Autoconfig."),
|
("autodiscover", "AAAA", env["PUBLIC_IPV6"], "Provides email configuration autodiscovery support for Z-Push ActiveSync Autodiscover."),
|
||||||
("autoconfig", "AAAA", env["PUBLIC_IPV6"], "Provides email configuration autodiscovery support for Thunderbird Autoconfig.")
|
("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."),
|
||||||
]
|
]
|
||||||
for qname, rtype, value, explanation in autodiscover_records:
|
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 value is None or value.strip() == "": continue # skip IPV6 if not set
|
||||||
if not has_rec(qname, rtype):
|
if not has_rec(qname, rtype):
|
||||||
records.append((qname, rtype, value, explanation))
|
records.append((qname, rtype, value, explanation))
|
||||||
@@ -413,14 +470,14 @@ def write_nsd_zone(domain, zonefile, records, env, force):
|
|||||||
|
|
||||||
zone = """
|
zone = """
|
||||||
$ORIGIN {domain}.
|
$ORIGIN {domain}.
|
||||||
$TTL 1800 ; default time to live
|
$TTL 86400 ; default time to live
|
||||||
|
|
||||||
@ IN SOA ns1.{primary_domain}. hostmaster.{primary_domain}. (
|
@ IN SOA ns1.{primary_domain}. hostmaster.{primary_domain}. (
|
||||||
__SERIAL__ ; serial number
|
__SERIAL__ ; serial number
|
||||||
7200 ; Refresh (secondary nameserver update interval)
|
7200 ; Refresh (secondary nameserver update interval)
|
||||||
1800 ; Retry (when refresh fails, how often to try again)
|
86400 ; Retry (when refresh fails, how often to try again)
|
||||||
1209600 ; Expire (when refresh fails, how long secondary nameserver will keep records around anyway)
|
1209600 ; Expire (when refresh fails, how long secondary nameserver will keep records around anyway)
|
||||||
1800 ; Negative TTL (how long negative responses are cached)
|
86400 ; Negative TTL (how long negative responses are cached)
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -507,6 +564,17 @@ $TTL 1800 ; default time to live
|
|||||||
|
|
||||||
return True # file is updated
|
return True # file is updated
|
||||||
|
|
||||||
|
def get_dns_zonefile(zone, env):
|
||||||
|
for domain, fn in get_dns_zones(env):
|
||||||
|
if zone == domain:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise ValueError("%s is not a domain name that corresponds to a zone." % zone)
|
||||||
|
|
||||||
|
nsd_zonefile = "/etc/nsd/zones/" + fn
|
||||||
|
with open(nsd_zonefile, "r") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
########################################################################
|
########################################################################
|
||||||
|
|
||||||
def write_nsd_conf(zonefiles, additional_records, env):
|
def write_nsd_conf(zonefiles, additional_records, env):
|
||||||
@@ -906,18 +974,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:
|
||||||
if "/" in item[4:]:
|
if "/" in item[4:]:
|
||||||
v = ipaddress.ip_network(item[4:]) # raises a ValueError if there's a problem
|
v = ipaddress.ip_network(item[4:]) # raises a ValueError if there's a problem
|
||||||
if not isinstance(v, ipaddress.IPv4Network): raise ValueError("That's an IPv6 subnet.")
|
|
||||||
else:
|
else:
|
||||||
v = ipaddress.ip_address(item[4:]) # raises a ValueError if there's a problem
|
v = ipaddress.ip_address(item[4:]) # raises a ValueError if there's a problem
|
||||||
if not isinstance(v, ipaddress.IPv4Address): raise ValueError("That's an IPv6 address.")
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValueError("'%s' is not an IPv4 address or subnet." % 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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -291,6 +293,8 @@ def run_network_checks(env, output):
|
|||||||
zen = query_dns(rev_ip4+'.zen.spamhaus.org', 'A', nxdomain=None)
|
zen = query_dns(rev_ip4+'.zen.spamhaus.org', 'A', nxdomain=None)
|
||||||
if zen is None:
|
if zen is None:
|
||||||
output.print_ok("IP address is not blacklisted by zen.spamhaus.org.")
|
output.print_ok("IP address is not blacklisted by zen.spamhaus.org.")
|
||||||
|
elif zen == "[timeout]":
|
||||||
|
output.print_warning("Connection to zen.spamhaus.org timed out. We could not determine whether your server's IP address is blacklisted. Please try again later.")
|
||||||
else:
|
else:
|
||||||
output.print_error("""The IP address of this machine %s is listed in the Spamhaus Block List (code %s),
|
output.print_error("""The IP address of this machine %s is listed in the Spamhaus Block List (code %s),
|
||||||
which may prevent recipients from receiving your email. See http://www.spamhaus.org/query/ip/%s."""
|
which may prevent recipients from receiving your email. See http://www.spamhaus.org/query/ip/%s."""
|
||||||
@@ -309,6 +313,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 +342,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 +374,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):
|
||||||
@@ -611,6 +651,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
|
||||||
@@ -627,6 +680,8 @@ def check_mail_domain(domain, env, output):
|
|||||||
dbl = query_dns(domain+'.dbl.spamhaus.org', "A", nxdomain=None)
|
dbl = query_dns(domain+'.dbl.spamhaus.org', "A", nxdomain=None)
|
||||||
if dbl is None:
|
if dbl is None:
|
||||||
output.print_ok("Domain is not blacklisted by dbl.spamhaus.org.")
|
output.print_ok("Domain is not blacklisted by dbl.spamhaus.org.")
|
||||||
|
elif dbl == "[timeout]":
|
||||||
|
output.print_warning("Connection to dbl.spamhaus.org timed out. We could not determine whether the domain {} is blacklisted. Please try again later.".format(domain))
|
||||||
else:
|
else:
|
||||||
output.print_error("""This domain is listed in the Spamhaus Domain Block List (code %s),
|
output.print_error("""This domain is listed in the Spamhaus Domain Block List (code %s),
|
||||||
which may prevent recipients from receiving your mail.
|
which may prevent recipients from receiving your mail.
|
||||||
@@ -970,13 +1025,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 an IP address or subnet using <code>xfr:10.20.30.40</code> or <code>xfr:10.20.30.40/24</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.
|
||||||
|
|||||||
@@ -42,6 +42,19 @@
|
|||||||
You may need to adopt this technique when adding DomainKeys. Use a tool like <code>named-checkzone</code> to validate your zone file.
|
You may need to adopt this technique when adding DomainKeys. Use a tool like <code>named-checkzone</code> to validate your zone file.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<h3>Download zonefile</h3>
|
||||||
|
<p>You can download your zonefiles here or use the table of records below.</p>
|
||||||
|
<form class="form-inline" role="form" onsubmit="do_download_zonefile(); return false;">
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="downloadZonefile" class="control-label sr-only">Zone</label>
|
||||||
|
<select id="downloadZonefile" class="form-control" style="width: auto"> </select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Download</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h3>Records</h3>
|
||||||
|
|
||||||
<table id="external_dns_settings" class="table">
|
<table id="external_dns_settings" class="table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -57,6 +70,18 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
function show_external_dns() {
|
function show_external_dns() {
|
||||||
|
api(
|
||||||
|
"/dns/zones",
|
||||||
|
"GET",
|
||||||
|
{ },
|
||||||
|
function(data) {
|
||||||
|
var zones = $('#downloadZonefile');
|
||||||
|
zones.text('');
|
||||||
|
for (var j = 0; j < data.length; j++) {
|
||||||
|
zones.append($('<option/>').text(data[j]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$('#external_dns_settings tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
$('#external_dns_settings tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
||||||
api(
|
api(
|
||||||
"/dns/dump",
|
"/dns/dump",
|
||||||
@@ -84,4 +109,19 @@ function show_external_dns() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function do_download_zonefile() {
|
||||||
|
var zone = $('#downloadZonefile').val();
|
||||||
|
|
||||||
|
api(
|
||||||
|
"/dns/zonefile/"+ zone,
|
||||||
|
"GET",
|
||||||
|
{},
|
||||||
|
function(data) {
|
||||||
|
show_modal_error("Download Zonefile", $("<pre/>").text(data));
|
||||||
|
},
|
||||||
|
function(err) {
|
||||||
|
show_modal_error("Download Zonefile (Error)", $("<pre/>").text(err));
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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;" method="get">
|
<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>
|
||||||
@@ -111,12 +111,37 @@
|
|||||||
<input type="text" class="form-control" rows="1" id="backup-target-pass">
|
<input type="text" class="form-control" rows="1" id="backup-target-pass">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Backblaze -->
|
||||||
|
<div class="form-group backup-target-b2">
|
||||||
|
<div class="col-sm-10 col-sm-offset-2">
|
||||||
|
<p>Backups are stored in a <a href="https://www.backblaze.com/" target="_blank" rel="noreferrer">Backblaze</a> B2 bucket. You must have a Backblaze account already.</p>
|
||||||
|
<p>You MUST manually copy the encryption password from <tt class="backup-encpassword-file"></tt> to a safe and secure location. You will need this file to decrypt backup files. It is NOT stored in your Backblaze B2 bucket.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group backup-target-b2">
|
||||||
|
<label for="backup-target-b2-user" class="col-sm-2 control-label">B2 Application KeyID</label>
|
||||||
|
<div class="col-sm-8">
|
||||||
|
<input type="text" class="form-control" rows="1" id="backup-target-b2-user">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group backup-target-b2">
|
||||||
|
<label for="backup-target-b2-pass" class="col-sm-2 control-label">B2 Application Key</label>
|
||||||
|
<div class="col-sm-8">
|
||||||
|
<input type="text" class="form-control" rows="1" id="backup-target-b2-pass">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group backup-target-b2">
|
||||||
|
<label for="backup-target-b2-bucket" class="col-sm-2 control-label">B2 Bucket</label>
|
||||||
|
<div class="col-sm-8">
|
||||||
|
<input type="text" class="form-control" rows="1" id="backup-target-b2-bucket">
|
||||||
|
</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">
|
||||||
@@ -144,7 +169,7 @@
|
|||||||
|
|
||||||
function toggle_form() {
|
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, .backup-target-b2").hide();
|
||||||
$(".backup-target-" + target_type).show();
|
$(".backup-target-" + target_type).show();
|
||||||
|
|
||||||
init_inputs(target_type);
|
init_inputs(target_type);
|
||||||
@@ -215,7 +240,7 @@ function show_system_backup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function show_custom_backup() {
|
function show_custom_backup() {
|
||||||
$(".backup-target-local, .backup-target-rsync, .backup-target-s3").hide();
|
$(".backup-target-local, .backup-target-rsync, .backup-target-s3, .backup-target-b2").hide();
|
||||||
api(
|
api(
|
||||||
"/system/backup/config",
|
"/system/backup/config",
|
||||||
"GET",
|
"GET",
|
||||||
@@ -245,6 +270,15 @@ function show_custom_backup() {
|
|||||||
var host = hostpath.shift();
|
var host = hostpath.shift();
|
||||||
$("#backup-target-s3-host").val(host);
|
$("#backup-target-s3-host").val(host);
|
||||||
$("#backup-target-s3-path").val(hostpath.join('/'));
|
$("#backup-target-s3-path").val(hostpath.join('/'));
|
||||||
|
} else if (r.target.substring(0, 5) == "b2://") {
|
||||||
|
$("#backup-target-type").val("b2");
|
||||||
|
var targetPath = r.target.substring(5);
|
||||||
|
var b2_application_keyid = targetPath.split(':')[0];
|
||||||
|
var b2_applicationkey = targetPath.split(':')[1].split('@')[0];
|
||||||
|
var b2_bucket = targetPath.split('@')[1];
|
||||||
|
$("#backup-target-b2-user").val(b2_application_keyid);
|
||||||
|
$("#backup-target-b2-pass").val(b2_applicationkey);
|
||||||
|
$("#backup-target-b2-bucket").val(b2_bucket);
|
||||||
}
|
}
|
||||||
toggle_form()
|
toggle_form()
|
||||||
})
|
})
|
||||||
@@ -264,6 +298,11 @@ function set_custom_backup() {
|
|||||||
target = "rsync://" + $("#backup-target-rsync-user").val() + "@" + $("#backup-target-rsync-host").val()
|
target = "rsync://" + $("#backup-target-rsync-user").val() + "@" + $("#backup-target-rsync-host").val()
|
||||||
+ "/" + $("#backup-target-rsync-path").val();
|
+ "/" + $("#backup-target-rsync-path").val();
|
||||||
target_user = '';
|
target_user = '';
|
||||||
|
} else if (target_type == "b2") {
|
||||||
|
target = 'b2://' + $('#backup-target-b2-user').val() + ':' + $('#backup-target-b2-pass').val()
|
||||||
|
+ '@' + $('#backup-target-b2-bucket').val()
|
||||||
|
target_user = '';
|
||||||
|
target_pass = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
<button type="submit" class="btn btn-primary">Add User</button>
|
<button type="submit" class="btn btn-primary">Add User</button>
|
||||||
</form>
|
</form>
|
||||||
<ul style="margin-top: 1em; padding-left: 1.5em; font-size: 90%;">
|
<ul style="margin-top: 1em; padding-left: 1.5em; font-size: 90%;">
|
||||||
<li>Passwords must be at least eight characters consisting of English lettters and numbers only. For best results, <a href="#" onclick="return generate_random_password()">generate a random password</a>.</li>
|
<li>Passwords must be at least eight characters consisting of English letters and numbers only. For best results, <a href="#" onclick="return generate_random_password()">generate a random password</a>.</li>
|
||||||
<li>Use <a href="#" onclick="return show_panel('aliases')">aliases</a> to create email addresses that forward to existing accounts.</li>
|
<li>Use <a href="#" onclick="return show_panel('aliases')">aliases</a> to create email addresses that forward to existing accounts.</li>
|
||||||
<li>Administrators get access to this control panel.</li>
|
<li>Administrators get access to this control panel.</li>
|
||||||
<li>User accounts cannot contain any international (non-ASCII) characters, but <a href="#" onclick="return show_panel('aliases');">aliases</a> can.</li>
|
<li>User accounts cannot contain any international (non-ASCII) characters, but <a href="#" onclick="return show_panel('aliases');">aliases</a> can.</li>
|
||||||
|
|||||||
@@ -24,11 +24,14 @@ def get_web_domains(env, include_www_redirects=True, exclude_dns_elsewhere=True)
|
|||||||
# 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, allowing us to serve correct SSL certs.
|
# Add Autoconfiguration domains for domains that there are user accounts at:
|
||||||
# 'autoconfig.' for Mozilla Thunderbird auto setup.
|
# 'autoconfig.' for Mozilla Thunderbird auto setup.
|
||||||
# 'autodiscover.' for Activesync autodiscovery.
|
# 'autodiscover.' for Activesync autodiscovery.
|
||||||
domains |= set('autoconfig.' + maildomain for maildomain in get_mail_domains(env))
|
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))
|
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
|
||||||
@@ -143,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"
|
||||||
@@ -155,9 +158,23 @@ 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"
|
nginx_conf_extra += "\n\t}\n"
|
||||||
for path, alias in yaml.get("aliases", {}).items():
|
for path, alias in yaml.get("aliases", {}).items():
|
||||||
nginx_conf_extra += "\tlocation %s {" % path
|
nginx_conf_extra += "\tlocation %s {" % path
|
||||||
@@ -171,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")
|
||||||
|
|||||||
@@ -101,9 +101,13 @@ The box restricts the envelope sender address (also called the return path or MA
|
|||||||
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.47
|
TAG=v0.52
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -64,6 +64,32 @@ tools/editconf.py /etc/opendmarc.conf -s \
|
|||||||
"Syslog=true" \
|
"Syslog=true" \
|
||||||
"Socket=inet:8893@[127.0.0.1]"
|
"Socket=inet:8893@[127.0.0.1]"
|
||||||
|
|
||||||
|
# SPFIgnoreResults causes the filter to ignore any SPF results in the header
|
||||||
|
# of the message. This is useful if you want the filter to perfrom SPF checks
|
||||||
|
# itself, or because you don't trust the arriving header. This added header is
|
||||||
|
# used by spamassassin to evaluate the mail for spamminess.
|
||||||
|
|
||||||
|
tools/editconf.py /etc/opendmarc.conf -s \
|
||||||
|
"SPFIgnoreResults=true"
|
||||||
|
|
||||||
|
# SPFSelfValidate causes the filter to perform a fallback SPF check itself
|
||||||
|
# when it can find no SPF results in the message header. If SPFIgnoreResults
|
||||||
|
# is also set, it never looks for SPF results in headers and always performs
|
||||||
|
# the SPF check itself when this is set. This added header is used by
|
||||||
|
# spamassassin to evaluate the mail for spamminess.
|
||||||
|
|
||||||
|
tools/editconf.py /etc/opendmarc.conf -s \
|
||||||
|
"SPFSelfValidate=true"
|
||||||
|
|
||||||
|
# AlwaysAddARHeader Adds an "Authentication-Results:" header field even to
|
||||||
|
# unsigned messages from domains with no "signs all" policy. The reported DKIM
|
||||||
|
# result will be "none" in such cases. Normally unsigned mail from non-strict
|
||||||
|
# domains does not cause the results header field to be added. This added header
|
||||||
|
# is used by spamassassin to evaluate the mail for spamminess.
|
||||||
|
|
||||||
|
tools/editconf.py /etc/opendkim.conf -s \
|
||||||
|
"AlwaysAddARHeader=true"
|
||||||
|
|
||||||
# Add OpenDKIM and OpenDMARC as milters to postfix, which is how OpenDKIM
|
# Add OpenDKIM and OpenDMARC as milters to postfix, which is how OpenDKIM
|
||||||
# intercepts outgoing mail to perform the signing (by adding a mail header)
|
# intercepts outgoing mail to perform the signing (by adding a mail header)
|
||||||
# and how they both intercept incoming mail to add Authentication-Results
|
# and how they both intercept incoming mail to add Authentication-Results
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ service imap-login {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
protocol imap {
|
protocol imap {
|
||||||
mail_max_userip_connections = 20
|
mail_max_userip_connections = 40
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -18,11 +18,7 @@ while [ -d /usr/local/lib/python3.4/dist-packages/acme ]; do
|
|||||||
pip3 uninstall -y acme;
|
pip3 uninstall -y acme;
|
||||||
done
|
done
|
||||||
|
|
||||||
# duplicity is used to make backups of user data. It uses boto
|
# duplicity is used to make backups of user data.
|
||||||
# (via Python 2) to do backups to AWS S3. boto from the Ubuntu
|
|
||||||
# package manager is too out-of-date -- it doesn't support the newer
|
|
||||||
# S3 api used in some regions, which breaks backups to those regions.
|
|
||||||
# See #627, #653.
|
|
||||||
#
|
#
|
||||||
# virtualenv is used to isolate the Python 3 packages we
|
# virtualenv is used to isolate the Python 3 packages we
|
||||||
# install via pip from the system-installed packages.
|
# install via pip from the system-installed packages.
|
||||||
@@ -30,7 +26,10 @@ done
|
|||||||
# certbot installs EFF's certbot which we use to
|
# certbot installs EFF's certbot which we use to
|
||||||
# provision free TLS certificates.
|
# provision free TLS certificates.
|
||||||
apt_install duplicity python-pip virtualenv certbot
|
apt_install duplicity python-pip virtualenv certbot
|
||||||
hide_output pip2 install --upgrade boto
|
|
||||||
|
# boto is used for amazon aws backups.
|
||||||
|
# Both are installed outside the pipenv, so they can be used by duplicity
|
||||||
|
hide_output pip3 install --upgrade boto
|
||||||
|
|
||||||
# Create a virtualenv for the installation of Python 3 packages
|
# Create a virtualenv for the installation of Python 3 packages
|
||||||
# used by the management daemon.
|
# used by the management daemon.
|
||||||
@@ -50,7 +49,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
|
||||||
|
|
||||||
@@ -89,6 +89,12 @@ rm -f /tmp/bootstrap.zip
|
|||||||
# running after a reboot.
|
# running after a reboot.
|
||||||
cat > $inst_dir/start <<EOF;
|
cat > $inst_dir/start <<EOF;
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
# Set character encoding flags to ensure that any non-ASCII don't cause problems.
|
||||||
|
export LANGUAGE=en_US.UTF-8
|
||||||
|
export LC_ALL=en_US.UTF-8
|
||||||
|
export LANG=en_US.UTF-8
|
||||||
|
export LC_TYPE=en_US.UTF-8
|
||||||
|
|
||||||
source $venv/bin/activate
|
source $venv/bin/activate
|
||||||
exec python `pwd`/management/daemon.py
|
exec python `pwd`/management/daemon.py
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -14,12 +14,18 @@ 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-dev php-gd php-xml php-mbstring php-zip php-apcu php-json \
|
||||||
php-intl php-imagick
|
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"
|
||||||
@@ -40,18 +46,18 @@ InstallNextcloud() {
|
|||||||
# their github repositories.
|
# their github repositories.
|
||||||
mkdir -p /usr/local/lib/owncloud/apps
|
mkdir -p /usr/local/lib/owncloud/apps
|
||||||
|
|
||||||
wget_verify https://github.com/nextcloud/contacts/releases/download/v3.3.0/contacts.tar.gz e55d0357c6785d3b1f3b5f21780cb6d41d32443a /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/v2.0.3/calendar.tar.gz 9d9717b29337613b72c74e9914c69b74b346c466 /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,
|
# Starting with Nextcloud 15, the app user_external is no longer included in Nextcloud core,
|
||||||
# we will install from their github repository.
|
# we will install from their github repository.
|
||||||
if [[ $version =~ ^1[567] ]]; then
|
if [ -n "$version_user_external" ]; then
|
||||||
wget_verify https://github.com/nextcloud/user_external/releases/download/v0.7.0/user_external-0.7.0.tar.gz 555a94811daaf5bdd336c5e48a78aa8567b86437 /tmp/user_external.tgz
|
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/
|
tar -xf /tmp/user_external.tgz -C /usr/local/lib/owncloud/apps/
|
||||||
rm /tmp/user_external.tgz
|
rm /tmp/user_external.tgz
|
||||||
fi
|
fi
|
||||||
@@ -91,8 +97,14 @@ InstallNextcloud() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Nextcloud Version to install. Checks are done down below to step through intermediate versions.
|
# Nextcloud Version to install. Checks are done down below to step through intermediate versions.
|
||||||
nextcloud_ver=17.0.6
|
nextcloud_ver=20.0.1
|
||||||
nextcloud_hash=50b98d2c2f18510b9530e558ced9ab51eb4f11b0
|
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
|
||||||
|
|
||||||
# Current Nextcloud Version, #1623
|
# Current Nextcloud Version, #1623
|
||||||
# Checking /usr/local/lib/owncloud/version.php shows version of the Nextcloud application, not the DB
|
# Checking /usr/local/lib/owncloud/version.php shows version of the Nextcloud application, not the DB
|
||||||
@@ -134,30 +146,46 @@ if [ ! -d /usr/local/lib/owncloud/ ] || [[ ! ${CURRENT_NEXTCLOUD_VER} =~ ^$nextc
|
|||||||
# 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 [[ ${CURRENT_NEXTCLOUD_VER} =~ ^[89] ]]; then
|
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^[89] ]]; then
|
||||||
echo "Upgrades from Mail-in-a-Box prior to v0.28 (dated July 30, 2018) with Nextcloud < 13.0.6 (you have ownCloud 8 or 9) are not supported. Upgrade to Mail-in-a-Box version v0.30 first. Setup 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
|
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 aborting."
|
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."
|
||||||
exit 1
|
return 0
|
||||||
elif [[ ${CURRENT_NEXTCLOUD_VER} =~ ^13 ]]; then
|
elif [[ ${CURRENT_NEXTCLOUD_VER} =~ ^13 ]]; then
|
||||||
# If we are running Nextcloud 13, upgrade to Nextcloud 14
|
# If we are running Nextcloud 13, upgrade to Nextcloud 14
|
||||||
InstallNextcloud 14.0.6 4e43a57340f04c2da306c8eea98e30040399ae5a
|
InstallNextcloud 14.0.6 4e43a57340f04c2da306c8eea98e30040399ae5a 3.3.0 e55d0357c6785d3b1f3b5f21780cb6d41d32443a 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466
|
||||||
CURRENT_NEXTCLOUD_VER="14.0.6"
|
CURRENT_NEXTCLOUD_VER="14.0.6"
|
||||||
fi
|
fi
|
||||||
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^14 ]]; then
|
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^14 ]]; then
|
||||||
# During the upgrade from Nextcloud 14 to 15, user_external may cause the upgrade to fail.
|
# During the upgrade from Nextcloud 14 to 15, user_external may cause the upgrade to fail.
|
||||||
# We will disable it here before the upgrade and install it again after the upgrade.
|
# 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
|
hide_output sudo -u www-data php /usr/local/lib/owncloud/console.php app:disable user_external
|
||||||
InstallNextcloud 15.0.8 4129d8d4021c435f2e86876225fb7f15adf764a3
|
InstallNextcloud 15.0.8 4129d8d4021c435f2e86876225fb7f15adf764a3 3.3.0 e55d0357c6785d3b1f3b5f21780cb6d41d32443a 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 0.7.0 555a94811daaf5bdd336c5e48a78aa8567b86437
|
||||||
CURRENT_NEXTCLOUD_VER="15.0.8"
|
CURRENT_NEXTCLOUD_VER="15.0.8"
|
||||||
fi
|
fi
|
||||||
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^15 ]]; then
|
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^15 ]]; then
|
||||||
InstallNextcloud 16.0.6 0bb3098455ec89f5af77a652aad553ad40a88819
|
InstallNextcloud 16.0.6 0bb3098455ec89f5af77a652aad553ad40a88819 3.3.0 e55d0357c6785d3b1f3b5f21780cb6d41d32443a 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 0.7.0 555a94811daaf5bdd336c5e48a78aa8567b86437
|
||||||
CURRENT_NEXTCLOUD_VER="16.0.6"
|
CURRENT_NEXTCLOUD_VER="16.0.6"
|
||||||
fi
|
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
|
||||||
@@ -283,6 +311,9 @@ hide_output sudo -u www-data php /usr/local/lib/owncloud/console.php app:enable
|
|||||||
sudo -u www-data php /usr/local/lib/owncloud/occ upgrade
|
sudo -u www-data php /usr/local/lib/owncloud/occ upgrade
|
||||||
if [ \( $? -ne 0 \) -a \( $? -ne 3 \) ]; then exit 1; fi
|
if [ \( $? -ne 0 \) -a \( $? -ne 3 \) ]; then exit 1; fi
|
||||||
|
|
||||||
|
# Disable default apps that we don't support
|
||||||
|
sudo -u www-data php /usr/local/lib/owncloud/occ app:disable photos dashboard activity
|
||||||
|
|
||||||
# Set PHP FPM values to support large file uploads
|
# Set PHP FPM values to support large file uploads
|
||||||
# (semicolon is the comment character in this file, hashes produce deprecation warnings)
|
# (semicolon is the comment character in this file, hashes produce deprecation warnings)
|
||||||
tools/editconf.py /etc/php/7.2/fpm/php.ini -c ';' \
|
tools/editconf.py /etc/php/7.2/fpm/php.ini -c ';' \
|
||||||
@@ -324,7 +355,7 @@ rm -f /etc/cron.hourly/mailinabox-owncloud
|
|||||||
# 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
|
||||||
# ```
|
# ```
|
||||||
|
|||||||
@@ -67,6 +67,56 @@ tools/editconf.py /etc/spamassassin/local.cf -s \
|
|||||||
"add_header all Report"=_REPORT_ \
|
"add_header all Report"=_REPORT_ \
|
||||||
"add_header all Score"=_SCORE_
|
"add_header all Score"=_SCORE_
|
||||||
|
|
||||||
|
|
||||||
|
# Authentication-Results SPF/Dmarc checks
|
||||||
|
# ---------------------------------------
|
||||||
|
# OpenDKIM and OpenDMARC are configured to validate and add "Authentication-Results: ..."
|
||||||
|
# headers by checking the sender's SPF & DMARC policies. Instead of blocking mail that fails
|
||||||
|
# these checks, we can use these headers to evaluate the mail as spam.
|
||||||
|
#
|
||||||
|
# Our custom rules are added to their own file so that an update to the deb package config
|
||||||
|
# does not remove our changes.
|
||||||
|
#
|
||||||
|
# We need to escape period's in $PRIMARY_HOSTNAME since spamassassin config uses regex.
|
||||||
|
|
||||||
|
escapedprimaryhostname="${PRIMARY_HOSTNAME//./\\.}"
|
||||||
|
|
||||||
|
cat > /etc/spamassassin/miab_spf_dmarc.cf << EOF
|
||||||
|
# Evaluate DMARC Authentication-Results
|
||||||
|
header DMARC_PASS Authentication-Results =~ /$escapedprimaryhostname; dmarc=pass/
|
||||||
|
describe DMARC_PASS DMARC check passed
|
||||||
|
score DMARC_PASS -0.1
|
||||||
|
|
||||||
|
header DMARC_NONE Authentication-Results =~ /$escapedprimaryhostname; dmarc=none/
|
||||||
|
describe DMARC_NONE DMARC record not found
|
||||||
|
score DMARC_NONE 0.1
|
||||||
|
|
||||||
|
header DMARC_FAIL_NONE Authentication-Results =~ /$escapedprimaryhostname; dmarc=fail \(p=none/
|
||||||
|
describe DMARC_FAIL_NONE DMARC check failed (p=none)
|
||||||
|
score DMARC_FAIL_NONE 2.0
|
||||||
|
|
||||||
|
header DMARC_FAIL_QUARANTINE Authentication-Results =~ /$escapedprimaryhostname; dmarc=fail \(p=quarantine/
|
||||||
|
describe DMARC_FAIL_QUARANTINE DMARC check failed (p=quarantine)
|
||||||
|
score DMARC_FAIL_QUARANTINE 5.0
|
||||||
|
|
||||||
|
header DMARC_FAIL_REJECT Authentication-Results =~ /$escapedprimaryhostname; dmarc=fail \(p=reject/
|
||||||
|
describe DMARC_FAIL_REJECT DMARC check failed (p=reject)
|
||||||
|
score DMARC_FAIL_REJECT 10.0
|
||||||
|
|
||||||
|
# Evaluate SPF Authentication-Results
|
||||||
|
header SPF_PASS Authentication-Results =~ /$escapedprimaryhostname; spf=pass/
|
||||||
|
describe SPF_PASS SPF check passed
|
||||||
|
score SPF_PASS -0.1
|
||||||
|
|
||||||
|
header SPF_NONE Authentication-Results =~ /$escapedprimaryhostname; spf=none/
|
||||||
|
describe SPF_NONE SPF record not found
|
||||||
|
score SPF_NONE 2.0
|
||||||
|
|
||||||
|
header SPF_FAIL Authentication-Results =~ /$escapedprimaryhostname; spf=fail/
|
||||||
|
describe SPF_FAIL SPF check failed
|
||||||
|
score SPF_FAIL 5.0
|
||||||
|
EOF
|
||||||
|
|
||||||
# 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.
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ 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
|
||||||
|
|
||||||
|
# Install the duplicity PPA.
|
||||||
|
hide_output add-apt-repository -y ppa:duplicity-team/duplicity-release-git
|
||||||
|
|
||||||
# ### Update Packages
|
# ### Update Packages
|
||||||
|
|
||||||
# Update system packages to make sure we have the latest upstream versions
|
# Update system packages to make sure we have the latest upstream versions
|
||||||
@@ -317,6 +320,9 @@ fi #NODOC
|
|||||||
# name server, on IPV6.
|
# name server, on IPV6.
|
||||||
# * The listen-on directive in named.conf.options restricts `bind9` to
|
# * The listen-on directive in named.conf.options restricts `bind9` to
|
||||||
# binding to the loopback interface instead of all interfaces.
|
# binding to the loopback interface instead of all interfaces.
|
||||||
|
# * The max-recursion-queries directive increases the maximum number of iterative queries.
|
||||||
|
# If more queries than specified are sent, bind9 returns SERVFAIL. After flushing the cache during system checks,
|
||||||
|
# we ran into the limit thus we are increasing it from 75 (default value) to 100.
|
||||||
apt_install bind9
|
apt_install bind9
|
||||||
tools/editconf.py /etc/default/bind9 \
|
tools/editconf.py /etc/default/bind9 \
|
||||||
"OPTIONS=\"-u bind -4\""
|
"OPTIONS=\"-u bind -4\""
|
||||||
@@ -324,6 +330,10 @@ if ! grep -q "listen-on " /etc/bind/named.conf.options; then
|
|||||||
# Add a listen-on directive if it doesn't exist inside the options block.
|
# Add a listen-on directive if it doesn't exist inside the options block.
|
||||||
sed -i "s/^}/\n\tlisten-on { 127.0.0.1; };\n}/" /etc/bind/named.conf.options
|
sed -i "s/^}/\n\tlisten-on { 127.0.0.1; };\n}/" /etc/bind/named.conf.options
|
||||||
fi
|
fi
|
||||||
|
if ! grep -q "max-recursion-queries " /etc/bind/named.conf.options; then
|
||||||
|
# Add a max-recursion-queries directive if it doesn't exist inside the options block.
|
||||||
|
sed -i "s/^}/\n\tmax-recursion-queries 100;\n}/" /etc/bind/named.conf.options
|
||||||
|
fi
|
||||||
|
|
||||||
# First we'll disable systemd-resolved's management of resolv.conf and its stub server.
|
# First we'll disable systemd-resolved's management of resolv.conf and its stub server.
|
||||||
# Breaking the symlink to /run/systemd/resolve/stub-resolv.conf means
|
# Breaking the symlink to /run/systemd/resolve/stub-resolv.conf means
|
||||||
|
|||||||
18
setup/web.sh
18
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
|
||||||
|
|
||||||
@@ -122,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
|
||||||
@@ -137,4 +152,3 @@ restart_service php7.2-fpm
|
|||||||
# Open ports.
|
# Open ports.
|
||||||
ufw_allow http
|
ufw_allow http
|
||||||
ufw_allow https
|
ufw_allow https
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ apt_install \
|
|||||||
# 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.4.7
|
VERSION=1.4.10
|
||||||
HASH=49F194D25AC7B9BF175BD52285BB61CDE7BAED44
|
HASH=36b2351030e1ebddb8e39190d7b0ba82b1bbec1b
|
||||||
PERSISTENT_LOGIN_VERSION=6b3fc450cae23ccb2f393d0ef67aa319e877e435
|
PERSISTENT_LOGIN_VERSION=6b3fc450cae23ccb2f393d0ef67aa319e877e435
|
||||||
HTML5_NOTIFIER_VERSION=4b370e3cd60dabd2f428a26f45b677ad1b7118d5
|
HTML5_NOTIFIER_VERSION=4b370e3cd60dabd2f428a26f45b677ad1b7118d5
|
||||||
CARDDAV_VERSION=3.0.3
|
CARDDAV_VERSION=3.0.3
|
||||||
|
|||||||
@@ -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.5.2
|
VERSION=2.6.1
|
||||||
TARGETHASH=2dc3dbd791b96b0ba2638df0d3d1e03c7e1cbab2
|
TARGETHASH=a4415f0dc0ed884acc8ad5c506944fc7e6d68eeb
|
||||||
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
|
||||||
|
|||||||
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)
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user