mirror of
https://github.com/mail-in-a-box/mailinabox.git
synced 2026-03-12 17:07:23 +01:00
Compare commits
232 Commits
encryption
...
v0.02
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1ccd22531 | ||
|
|
01c964bfe3 | ||
|
|
6c59294e7b | ||
|
|
9d40a12f44 | ||
|
|
c2ddabe683 | ||
|
|
846768efcb | ||
|
|
8dfbb90f3a | ||
|
|
c7c3bd33cf | ||
|
|
1637153566 | ||
|
|
05510f25a5 | ||
|
|
b8ea7282b0 | ||
|
|
dd91553689 | ||
|
|
98651deea4 | ||
|
|
ff0c85615b | ||
|
|
16e2350fef | ||
|
|
52b2e27451 | ||
|
|
48bb8a90d2 | ||
|
|
941684f4d9 | ||
|
|
9631fab7b2 | ||
|
|
4f4a8faa00 | ||
|
|
467f04facb | ||
|
|
196e42e8b5 | ||
|
|
f09da719f7 | ||
|
|
e9e95cbed5 | ||
|
|
98fc449b49 | ||
|
|
bab8b515ea | ||
|
|
cce6bc02a8 | ||
|
|
85d4fad030 | ||
|
|
7ea956d3bc | ||
|
|
110e0f90d9 | ||
|
|
954a234aa9 | ||
|
|
b5122770cc | ||
|
|
711b1128e3 | ||
|
|
6b13ac1ca9 | ||
|
|
9fd6958dc2 | ||
|
|
e434bf9fce | ||
|
|
03f9358de4 | ||
|
|
c7a2aad0f8 | ||
|
|
c75a2c4ca0 | ||
|
|
ecbbac2ae1 | ||
|
|
7fd32d2d0b | ||
|
|
f77f1e656c | ||
|
|
b420e560c3 | ||
|
|
7a449c76a1 | ||
|
|
3853e8dd93 | ||
|
|
4ec6692f21 | ||
|
|
6669f83bf7 | ||
|
|
7603ce0489 | ||
|
|
8b2fed1a2a | ||
|
|
ee244386ed | ||
|
|
cfffb38508 | ||
|
|
24ff0e04b1 | ||
|
|
10a37cd033 | ||
|
|
684d9b3c70 | ||
|
|
302eae3fd3 | ||
|
|
8586723e70 | ||
|
|
aa3bc3225e | ||
|
|
da2af2ea5c | ||
|
|
6a311ee7d9 | ||
|
|
1f19a855af | ||
|
|
3ce3c74273 | ||
|
|
ac767dc87b | ||
|
|
5c29275d8f | ||
|
|
756ba111a3 | ||
|
|
ab3d205ef6 | ||
|
|
699923d605 | ||
|
|
d5efb05f31 | ||
|
|
1125325718 | ||
|
|
43fe2fe282 | ||
|
|
2afd0be591 | ||
|
|
92c7815d2c | ||
|
|
06a4046d13 | ||
|
|
9b8d85de45 | ||
|
|
c0f4618bef | ||
|
|
245864caac | ||
|
|
b76cbae5a0 | ||
|
|
ed8ce16fb5 | ||
|
|
a32806da32 | ||
|
|
18f0406541 | ||
|
|
bc9d670981 | ||
|
|
3774f589c8 | ||
|
|
00b5c6ee9c | ||
|
|
76ff9735cc | ||
|
|
844f744a09 | ||
|
|
d1c7617cdb | ||
|
|
ea32af1f0e | ||
|
|
c18200d9b1 | ||
|
|
bf5016a8ac | ||
|
|
7cec45b436 | ||
|
|
e0dc8ff04a | ||
|
|
faf6f87a63 | ||
|
|
4ed69cbae5 | ||
|
|
9bfff1f679 | ||
|
|
28231ac248 | ||
|
|
90c7655d82 | ||
|
|
df20d447a9 | ||
|
|
5d42c125eb | ||
|
|
de864b998b | ||
|
|
6e3b04ce83 | ||
|
|
b0d6473c3c | ||
|
|
03bbd25a10 | ||
|
|
a0b056ae29 | ||
|
|
ff8413a622 | ||
|
|
ee9552734f | ||
|
|
a68fd6429f | ||
|
|
f7c7d5b9c3 | ||
|
|
4b75148f01 | ||
|
|
09d2a08ce6 | ||
|
|
9576594cfe | ||
|
|
76dcab3139 | ||
|
|
7e8e104964 | ||
|
|
7ea4d33e06 | ||
|
|
eab28c97ff | ||
|
|
2d5097345a | ||
|
|
294d19e0af | ||
|
|
980b83b124 | ||
|
|
2cab02c831 | ||
|
|
6b52105b62 | ||
|
|
a501256fb9 | ||
|
|
80a05c3bbf | ||
|
|
aaea954072 | ||
|
|
b6dd407aa7 | ||
|
|
28eaf7cba9 | ||
|
|
9a1989357c | ||
|
|
46f3d05034 | ||
|
|
a0df18506b | ||
|
|
da17466526 | ||
|
|
3f5f95a633 | ||
|
|
f7d2dfd1c0 | ||
|
|
91821adfd7 | ||
|
|
92acef9b87 | ||
|
|
b30d7ad80a | ||
|
|
ba8e015795 | ||
|
|
919a5a8f0b | ||
|
|
f299825a95 | ||
|
|
04454b35c6 | ||
|
|
56c7d7436e | ||
|
|
062e8b839e | ||
|
|
f41ec93cbe | ||
|
|
7e62131fbc | ||
|
|
647ab4abeb | ||
|
|
73b2751dc4 | ||
|
|
e1606df237 | ||
|
|
bbd35f4906 | ||
|
|
ae1e69a5e3 | ||
|
|
9e86c67534 | ||
|
|
6e380ade17 | ||
|
|
277f98aac8 | ||
|
|
ce4505b72b | ||
|
|
6fdef379ad | ||
|
|
8c9f278166 | ||
|
|
398b538e2b | ||
|
|
ca45c88a32 | ||
|
|
5ecbaa2b41 | ||
|
|
a10b828d5c | ||
|
|
e625a424fd | ||
|
|
59c1c670b5 | ||
|
|
7024b428ad | ||
|
|
d03bc0cefa | ||
|
|
05cc63b5d5 | ||
|
|
e828dd63e1 | ||
|
|
b92033cafe | ||
|
|
c9bf57eacd | ||
|
|
791e68a3af | ||
|
|
4d64246b22 | ||
|
|
9d6dc78b15 | ||
|
|
36654bb5b4 | ||
|
|
57a441a547 | ||
|
|
52c50621cd | ||
|
|
afb09a84b7 | ||
|
|
7396785a9a | ||
|
|
cf4f519cc0 | ||
|
|
654c200709 | ||
|
|
7b81ea1834 | ||
|
|
0eceb2012f | ||
|
|
31dda3b425 | ||
|
|
9f5fd6b474 | ||
|
|
5cf2965633 | ||
|
|
e8a1837d02 | ||
|
|
7ba79effae | ||
|
|
9d41530232 | ||
|
|
a6ba2da68b | ||
|
|
17c4edb58d | ||
|
|
d60abd0f92 | ||
|
|
7b5ebb093f | ||
|
|
2d74fad947 | ||
|
|
01d7d4e860 | ||
|
|
21d59862de | ||
|
|
bfbd85183e | ||
|
|
1e91cb0683 | ||
|
|
bc48e7d871 | ||
|
|
0bb257db2a | ||
|
|
ecfabd2dad | ||
|
|
881b693cd4 | ||
|
|
7f01146c3d | ||
|
|
54fe92615b | ||
|
|
64b1db4c30 | ||
|
|
f287ca3b6c | ||
|
|
44fcdc2066 | ||
|
|
b5928de740 | ||
|
|
a80c076d8f | ||
|
|
1621a2940f | ||
|
|
cc8e1fa7b7 | ||
|
|
d53cb88a92 | ||
|
|
20b494c3ac | ||
|
|
3540a1677d | ||
|
|
bc0c0bf0fb | ||
|
|
51bb781ffd | ||
|
|
d324f0981a | ||
|
|
a801bf2a30 | ||
|
|
0899952fe1 | ||
|
|
1312b0254b | ||
|
|
f66914d634 | ||
|
|
b6713d9a17 | ||
|
|
58e300e113 | ||
|
|
140c508ff6 | ||
|
|
e294f7c181 | ||
|
|
b56f82cb92 | ||
|
|
880ec44a0c | ||
|
|
5db12be507 | ||
|
|
64cb00b9d6 | ||
|
|
b86656243f | ||
|
|
6a512042dc | ||
|
|
6d4fab1e6a | ||
|
|
30178ef019 | ||
|
|
cd59025979 | ||
|
|
0be92d776e | ||
|
|
168c06939d | ||
|
|
c74bef12d2 | ||
|
|
6619239280 | ||
|
|
834a7b9096 | ||
|
|
3a7221a69a |
50
README.md
50
README.md
@@ -1,45 +1,32 @@
|
||||
Mail-in-a-Box
|
||||
=============
|
||||
|
||||
By [@JoshData](https://github.com/JoshData) and [contributors](https://github.com/mail-in-a-box/mailinabox/graphs/contributors).
|
||||
|
||||
Mail-in-a-Box helps individuals take back control of their email by defining a one-click, easy-to-deploy SMTP+everything else server: a mail server in a box.
|
||||
|
||||
**This is a work in progress. I work on this in my limited free time.**
|
||||
**Please see [https://mailinabox.email](https://mailinabox.email) for the project's website and setup guide!**
|
||||
|
||||
Why build this? Mass electronic surveillance by governments revealed over the last year has spurred a new movement to [re-decentralize](http://redecentralize.org/) the web, that is, to empower netizens to be their own service providers again. SMTP, the protocol of email, is decentralized in principle but highly centralized in practice due to the high cost of implementing all of the modern protocols that surround it. As a result, most individuals trade their independence for access to a “free” email service.
|
||||
* * *
|
||||
|
||||
I am trying to:
|
||||
|
||||
* Make deploying a good mail server easy.
|
||||
* Promote [decentralization](http://redecentralize.org/), innovation, and privacy on the web.
|
||||
* Have automated, auditable, and [idempotent](http://sharknet.us/2014/02/01/automated-configuration-management-challenges-with-idempotency/) configuration.
|
||||
* **Not** be a mail server that the NSA cannot hack.
|
||||
* **Not** be customizable by power users.
|
||||
|
||||
The long-term goal is to have this be a one-click email appliance with *no* user-configurable setup options.
|
||||
|
||||
For more background, see [The Rationale](https://github.com/mail-in-a-box/mailinabox/wiki).
|
||||
|
||||
This setup is what has been powering my own personal email since September 2013.
|
||||
|
||||
The Box
|
||||
-------
|
||||
|
||||
Mail-in-a-Box turns a fresh Ubuntu 14.04 LTS 64-bit machine into a working mail server, including [SMTP](http://www.postfix.org/), [IMAP](http://dovecot.org/), [webmail](http://roundcube.net/), [spam filtering](https://spamassassin.apache.org/), [greylisting](http://postgrey.schweikert.ch/), DNS, [SPF](https://en.wikipedia.org/wiki/Sender_Policy_Framework), [DKIM](https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail), [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 basic system services like a firewall, intrusion protection, and setting the system clock.
|
||||
|
||||
This setup is what has been powering my own personal email since September 2013.
|
||||
|
||||
Please see [mailinabox.email](https://mailinabox.email) for more information and how to set up a Mail-in-a-Box.
|
||||
|
||||
In short, it's like this:
|
||||
|
||||
# do this on a fresh install of Ubuntu 14.04 only!
|
||||
sudo apt-get install -y git
|
||||
git clone https://github.com/joshdata/mailinabox
|
||||
cd mailinabox
|
||||
sudo setup/start.sh
|
||||
|
||||
**Status**: This is a work in progress. It works for what it is, but it is missing such things as quotas, backup/restore, etc.
|
||||
|
||||
The Goals
|
||||
---------
|
||||
|
||||
* Create a push-button "Email Appliance" for everyday users.
|
||||
* Promote decentralization, innovation, and privacy on the web.
|
||||
* Have automated, auditable, and [idempotent](http://sharknet.us/2014/02/01/automated-configuration-management-challenges-with-idempotency/) configuration.
|
||||
|
||||
For more background, see [The Rationale](https://github.com/JoshData/mailinabox/wiki).
|
||||
|
||||
What I am not trying to do:
|
||||
|
||||
* **Not** to be a mail server that the NSA cannot hack.
|
||||
* **Not** to be customizable by power users.
|
||||
Mail-in-a-Box turns a fresh Ubuntu 14.04 LTS 64-bit machine into a working mail server, including SMTP ([postfix](http://www.postfix.org/)), IMAP ([dovecot](http://dovecot.org/)), Exchange ActiveSync ([z-push](https://github.com/fmbiete/Z-Push-contrib)), webmail ([Roundcube](http://roundcube.net/)), spam filtering ([spamassassin](https://spamassassin.apache.org/)), greylisting ([postgrey](http://postgrey.schweikert.ch/)), CardDAV/CalDAV ([ownCloud](http://owncloud.org/)), DNS, [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), [SSHFP](https://tools.ietf.org/html/rfc4255), and basic system services like a firewall, intrusion protection, and setting the system clock.
|
||||
|
||||
The Acknowledgements
|
||||
--------------------
|
||||
@@ -53,3 +40,4 @@ The History
|
||||
|
||||
* In 2007 I wrote a relatively popular Mozilla Thunderbird extension that added client-side SPF and DKIM checks to mail to warn users about possible phishing: [add-on page](https://addons.mozilla.org/en-us/thunderbird/addon/sender-verification-anti-phish/), [source](https://github.com/JoshData/thunderbird-spf).
|
||||
* Mail-in-a-Box was a semifinalist in the 2014 [Knight News Challenge](https://www.newschallenge.org/challenge/2014/submissions/mail-in-a-box), but it was not selected as a winner.
|
||||
* Mail-in-a-Box hit the front page of Hacker News in [April](https://news.ycombinator.com/item?id=7634514) and [September](https://news.ycombinator.com/item?id=8276171) 2014.
|
||||
|
||||
2
Vagrantfile
vendored
2
Vagrantfile
vendored
@@ -18,10 +18,12 @@ Vagrant.configure("2") do |config|
|
||||
# machine figure out its own public IP and it'll take a
|
||||
# subdomain on our justtesting.email domain so we can get
|
||||
# started quickly.
|
||||
export NONINTERACTIVE=1
|
||||
export PUBLIC_IP=auto
|
||||
export PUBLIC_IPV6=auto
|
||||
export PRIMARY_HOSTNAME=auto-easy
|
||||
export CSR_COUNTRY=US
|
||||
#export SKIP_NETWORK_CHECKS=1
|
||||
|
||||
# Start the setup script.
|
||||
cd /vagrant
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
# Adapted from http://blog.codefront.net/2007/06/11/nginx-php-and-a-php-fastcgi-daemon-init-script/
|
||||
|
||||
PATH=/sbin:/usr/sbin:/bin:/usr/bin
|
||||
DESC="Mail-in-a-Box Mgmt"
|
||||
DESC="Mail-in-a-Box Management Daemon"
|
||||
NAME=mailinabox
|
||||
DAEMON=/usr/local/bin/mailinabox-daemon
|
||||
PIDFILE=/var/run/$NAME.pid
|
||||
|
||||
49
conf/nginx-primaryonly.conf
Normal file
49
conf/nginx-primaryonly.conf
Normal file
@@ -0,0 +1,49 @@
|
||||
# Control Panel
|
||||
rewrite ^/admin$ /admin/;
|
||||
location /admin/ {
|
||||
proxy_pass http://localhost:10222/;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
}
|
||||
|
||||
# ownCloud configuration.
|
||||
rewrite ^/cloud$ /cloud/ redirect;
|
||||
rewrite ^/cloud/$ /cloud/index.php;
|
||||
rewrite ^/cloud/(contacts|calendar|files)$ /cloud/index.php/apps/$1/ redirect;
|
||||
rewrite ^(/cloud/core/doc/[^\/]+/)$ $1/index.html;
|
||||
location /cloud/ {
|
||||
alias /usr/local/lib/owncloud/;
|
||||
location ~ ^/(data|config|\.ht|db_structure\.xml|README) {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
location ~ ^(/cloud)(/[^/]+\.php)(/.*)?$ {
|
||||
# note: ~ has precendence over a regular location block
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME /usr/local/lib/owncloud/$2;
|
||||
fastcgi_param SCRIPT_NAME $1$2;
|
||||
fastcgi_param PATH_INFO $3;
|
||||
fastcgi_param MOD_X_ACCEL_REDIRECT_ENABLED on;
|
||||
fastcgi_read_timeout 630;
|
||||
fastcgi_pass php-fpm;
|
||||
error_page 403 /cloud/core/templates/403.php;
|
||||
error_page 404 /cloud/core/templates/404.php;
|
||||
client_max_body_size 1G;
|
||||
fastcgi_buffers 64 4K;
|
||||
}
|
||||
location ^~ /cloud/data {
|
||||
# In order to support MOD_X_ACCEL_REDIRECT_ENABLED, we need to expose
|
||||
# the data directory but only allow 'internal' redirects within nginx
|
||||
# so that this is not exposed to the world.
|
||||
internal;
|
||||
alias $STORAGE_ROOT/owncloud;
|
||||
}
|
||||
location ~ ^/((caldav|carddav|webdav).*)$ {
|
||||
# Z-Push doesn't like getting a redirect, and a plain rewrite didn't work either.
|
||||
# Properly proxying like this seems to work fine.
|
||||
proxy_pass https://$HOSTNAME/cloud/remote.php/$1;
|
||||
}
|
||||
rewrite ^/.well-known/host-meta /cloud/public.php?service=host-meta last;
|
||||
rewrite ^/.well-known/host-meta.json /cloud/public.php?service=host-meta-json last;
|
||||
rewrite ^/.well-known/carddav /cloud/remote.php/carddav/ redirect;
|
||||
rewrite ^/.well-known/caldav /cloud/remote.php/caldav/ redirect;
|
||||
|
||||
12
conf/nginx-top.conf
Normal file
12
conf/nginx-top.conf
Normal file
@@ -0,0 +1,12 @@
|
||||
## NOTE: This file is automatically generated by Mail-in-a-Box.
|
||||
## Do not edit this file. It is continually updated by
|
||||
## Mail-in-a-Box and your changes will be lost.
|
||||
##
|
||||
## Mail-in-a-Box machines are not meant to be modified.
|
||||
## If you modify any system configuration you are on
|
||||
## your own --- please do not ask for help from us.
|
||||
|
||||
upstream php-fpm {
|
||||
server unix:/var/run/php5-fpm.sock;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
## NOTE: This file is automatically generated by Mail-in-a-Box.
|
||||
## Do not edit this file. It will be replaced each time
|
||||
## Mail-in-a-Box needs up update the web configuration.
|
||||
## $HOSTNAME
|
||||
|
||||
# Redirect all HTTP to HTTPS.
|
||||
server {
|
||||
@@ -38,11 +36,12 @@ server {
|
||||
return 403;
|
||||
}
|
||||
location ~ /mail/.*\.php {
|
||||
# note: ~ has precendence over a regular location block
|
||||
include fastcgi_params;
|
||||
fastcgi_split_path_info ^/mail(/.*)()$;
|
||||
fastcgi_index index.php;
|
||||
fastcgi_param SCRIPT_FILENAME /usr/local/lib/roundcubemail/$fastcgi_script_name;
|
||||
fastcgi_pass unix:/tmp/php-fastcgi.www-data.sock;
|
||||
fastcgi_pass php-fpm;
|
||||
client_max_body_size 20M;
|
||||
}
|
||||
|
||||
@@ -50,22 +49,24 @@ server {
|
||||
location = /.well-known/webfinger {
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME /usr/local/bin/mailinabox-webfinger.php;
|
||||
fastcgi_pass unix:/tmp/php-fastcgi.www-data.sock;
|
||||
}
|
||||
|
||||
# Microsoft Exchange autodiscover.xml for email
|
||||
location /autodiscover/autodiscover.xml {
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME /usr/local/bin/mailinabox-exchange-autodiscover.php;
|
||||
fastcgi_pass unix:/tmp/php-fastcgi.www-data.sock;
|
||||
fastcgi_pass php-fpm;
|
||||
}
|
||||
|
||||
# Z-Push (Microsoft Exchange ActiveSync)
|
||||
location /Microsoft-Server-ActiveSync {
|
||||
include /etc/nginx/fastcgi_params;
|
||||
include /etc/nginx/fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME /usr/local/lib/z-push/index.php;
|
||||
fastcgi_pass unix:/tmp/php-fastcgi.www-data.sock;
|
||||
fastcgi_param PHP_VALUE "include_path=.:/usr/share/php:/usr/share/pear:/usr/share/awl/inc";
|
||||
fastcgi_read_timeout 630;
|
||||
fastcgi_pass php-fpm;
|
||||
}
|
||||
location /autodiscover/autodiscover.xml {
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME /usr/local/lib/z-push/autodiscover/autodiscover.php;
|
||||
fastcgi_param PHP_VALUE "include_path=.:/usr/share/php:/usr/share/pear:/usr/share/awl/inc";
|
||||
fastcgi_pass php-fpm;
|
||||
}
|
||||
|
||||
|
||||
# ADDITIONAL DIRECTIVES HERE
|
||||
}
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
#! /bin/sh
|
||||
### BEGIN INIT INFO
|
||||
# Provides: php-fastcgi
|
||||
# Required-Start: $all
|
||||
# Required-Stop: $all
|
||||
# Default-Start: 2 3 4 5
|
||||
# Default-Stop: 0 1 6
|
||||
# Short-Description: Start and stop php-cgi in external FASTCGI mode
|
||||
# Description: Start and stop php-cgi in external FASTCGI mode
|
||||
### END INIT INFO
|
||||
|
||||
# Author: Kurt Zankl
|
||||
# via: http://blog.codefront.net/2007/06/11/nginx-php-and-a-php-fastcgi-daemon-init-script/
|
||||
# But modified by JT.
|
||||
|
||||
# Do NOT "set -e"
|
||||
|
||||
PATH=/sbin:/usr/sbin:/bin:/usr/bin
|
||||
DESC="php-fastcgi"
|
||||
NAME=php-fastcgi
|
||||
DAEMON=/usr/bin/php-cgi
|
||||
PIDFILE=/var/run/$NAME.pid
|
||||
SCRIPTNAME=/etc/init.d/$NAME
|
||||
PHP_CONFIG_FILE=/etc/php5/cgi/php.ini
|
||||
|
||||
# Exit if the package is not installed
|
||||
[ -x "$DAEMON" ] || exit 0
|
||||
|
||||
# Set defaults.
|
||||
START=yes
|
||||
EXEC_AS_USER=www-data
|
||||
#FCGI_SOCKET=localhost:9000
|
||||
FCGI_SOCKET=/tmp/php-fastcgi.$EXEC_AS_USER.sock
|
||||
PHP_FCGI_CHILDREN=4
|
||||
PHP_FCGI_MAX_REQUESTS=1000
|
||||
|
||||
# Read configuration variable file if it is present
|
||||
[ -r /etc/default/$NAME ] && . /etc/default/$NAME
|
||||
|
||||
# Load the VERBOSE setting and other rcS variables
|
||||
. /lib/init/vars.sh
|
||||
|
||||
# Define LSB log_* functions.
|
||||
# Depend on lsb-base (>= 3.0-6) to ensure that this file is present.
|
||||
. /lib/lsb/init-functions
|
||||
|
||||
# If the daemon is not enabled, give the user a warning and then exit,
|
||||
# unless we are stopping the daemon
|
||||
if [ "$START" != "yes" -a "$1" != "stop" ]; then
|
||||
log_warning_msg "To enable $NAME, edit /etc/default/$NAME and set START=yes"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Process configuration
|
||||
export PHP_FCGI_CHILDREN PHP_FCGI_MAX_REQUESTS
|
||||
DAEMON_ARGS="-q -b $FCGI_SOCKET -c $PHP_CONFIG_FILE"
|
||||
|
||||
|
||||
do_start()
|
||||
{
|
||||
# Return
|
||||
# 0 if daemon has been started
|
||||
# 1 if daemon was already running
|
||||
# 2 if daemon could not be started
|
||||
start-stop-daemon --start --quiet --pidfile $PIDFILE --exec $DAEMON --test > /dev/null \
|
||||
|| return 1
|
||||
start-stop-daemon --start --quiet --pidfile $PIDFILE --exec $DAEMON \
|
||||
--background --make-pidfile --chuid $EXEC_AS_USER --startas $DAEMON -- \
|
||||
$DAEMON_ARGS \
|
||||
|| return 2
|
||||
}
|
||||
|
||||
do_stop()
|
||||
{
|
||||
# Return
|
||||
# 0 if daemon has been stopped
|
||||
# 1 if daemon was already stopped
|
||||
# 2 if daemon could not be stopped
|
||||
# other if a failure occurred
|
||||
start-stop-daemon --stop --quiet --retry=TERM/30/KILL/5 --pidfile $PIDFILE > /dev/null # --name $DAEMON
|
||||
RETVAL="$?"
|
||||
[ "$RETVAL" = 2 ] && return 2
|
||||
# Wait for children to finish too if this is a daemon that forks
|
||||
# and if the daemon is only ever run from this initscript.
|
||||
# If the above conditions are not satisfied then add some other code
|
||||
# that waits for the process to drop all resources that could be
|
||||
# needed by services started subsequently. A last resort is to
|
||||
# sleep for some time.
|
||||
start-stop-daemon --stop --quiet --oknodo --retry=0/30/KILL/5 --exec $DAEMON
|
||||
[ "$?" = 2 ] && return 2
|
||||
# Many daemons don't delete their pidfiles when they exit.
|
||||
rm -f $PIDFILE
|
||||
return "$RETVAL"
|
||||
}
|
||||
case "$1" in
|
||||
start)
|
||||
[ "$VERBOSE" != no ] && log_daemon_msg "Starting $DESC" "$NAME"
|
||||
do_start
|
||||
case "$?" in
|
||||
0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;;
|
||||
2) [ "$VERBOSE" != no ] && log_end_msg 1 ;;
|
||||
esac
|
||||
;;
|
||||
stop)
|
||||
[ "$VERBOSE" != no ] && log_daemon_msg "Stopping $DESC" "$NAME"
|
||||
do_stop
|
||||
case "$?" in
|
||||
0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;;
|
||||
2) [ "$VERBOSE" != no ] && log_end_msg 1 ;;
|
||||
esac
|
||||
;;
|
||||
restart|force-reload)
|
||||
log_daemon_msg "Restarting $DESC" "$NAME"
|
||||
do_stop
|
||||
case "$?" in
|
||||
0|1)
|
||||
do_start
|
||||
case "$?" in
|
||||
0) log_end_msg 0 ;;
|
||||
1) log_end_msg 1 ;; # Old process is still running
|
||||
*) log_end_msg 1 ;; # Failed to start
|
||||
esac
|
||||
;;
|
||||
*)
|
||||
# Failed to stop
|
||||
log_end_msg 1
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $SCRIPTNAME {start|stop|restart|force-reload}" >&2
|
||||
exit 3
|
||||
;;
|
||||
esac
|
||||
24
conf/zpush/autodiscover_config.php
Normal file
24
conf/zpush/autodiscover_config.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
/***********************************************
|
||||
* File : config.php
|
||||
* Project : Z-Push
|
||||
* Descr : Autodiscover configuration file
|
||||
************************************************/
|
||||
|
||||
// Defines the base path on the server
|
||||
define('BASE_PATH', dirname($_SERVER['SCRIPT_FILENAME']). '/');
|
||||
|
||||
// The Z-Push server location for the autodiscover response
|
||||
define('SERVERURL', 'https://PRIMARY_HOSTNAME/Microsoft-Server-ActiveSync');
|
||||
|
||||
define('USE_FULLEMAIL_FOR_LOGIN', true);
|
||||
|
||||
define('LOGFILEDIR', '/var/log/z-push/');
|
||||
define('LOGFILE', LOGFILEDIR . 'autodiscover.log');
|
||||
define('LOGERRORFILE', LOGFILEDIR . 'autodiscover-error.log');
|
||||
define('LOGLEVEL', LOGLEVEL_INFO);
|
||||
define('LOGUSERLEVEL', LOGLEVEL);
|
||||
|
||||
// the backend data provider
|
||||
define('BACKEND_PROVIDER', 'BackendCombined');
|
||||
?>
|
||||
18
conf/zpush/backend_caldav.php
Normal file
18
conf/zpush/backend_caldav.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
/***********************************************
|
||||
* File : config.php
|
||||
* Project : Z-Push
|
||||
* Descr : CalDAV backend configuration file
|
||||
************************************************/
|
||||
|
||||
define('CALDAV_SERVER', 'https://localhost');
|
||||
define('CALDAV_PORT', '443');
|
||||
define('CALDAV_PATH', '/caldav/calendars/%u/');
|
||||
define('CALDAV_PERSONAL', '');
|
||||
|
||||
// If the CalDAV server supports the sync-collection operation
|
||||
// DAViCal and SOGo support it
|
||||
// Setting this to false will work with most servers, but it will be slower
|
||||
define('CALDAV_SUPPORTS_SYNC', false);
|
||||
|
||||
?>
|
||||
37
conf/zpush/backend_carddav.php
Normal file
37
conf/zpush/backend_carddav.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
/***********************************************
|
||||
* File : config.php
|
||||
* Project : Z-Push
|
||||
* Descr : CardDAV backend configuration file
|
||||
************************************************/
|
||||
|
||||
|
||||
define('CARDDAV_PROTOCOL', 'https'); /* http or https */
|
||||
define('CARDDAV_SERVER', 'localhost');
|
||||
define('CARDDAV_PORT', '443');
|
||||
define('CARDDAV_PATH', '/carddav/addressbooks/%u/');
|
||||
define('CARDDAV_DEFAULT_PATH', '/carddav/addressbooks/%u/contacts/'); /* subdirectory of the main path */
|
||||
define('CARDDAV_GAL_PATH', ''); /* readonly, searchable, not syncd */
|
||||
define('CARDDAV_GAL_MIN_LENGTH', 5);
|
||||
define('CARDDAV_CONTACTS_FOLDER_NAME', '%u Addressbook');
|
||||
|
||||
|
||||
// If the CardDAV server supports the sync-collection operation
|
||||
// DAViCal supports it, but SabreDav, Owncloud, SOGo don't
|
||||
// Setting this to false will work with most servers, but it will be slower: 1 petition for the href of vcards, and 1 petition for each vcard
|
||||
define('CARDDAV_SUPPORTS_SYNC', false);
|
||||
|
||||
|
||||
// If the CardDAV server supports the FN attribute for searches
|
||||
// DAViCal supports it, but SabreDav, Owncloud and SOGo don't
|
||||
// Setting this to true will search by FN. If false will search by sn, givenName and email
|
||||
// It's safe to leave it as false
|
||||
define('CARDDAV_SUPPORTS_FN_SEARCH', false);
|
||||
|
||||
|
||||
// If your carddav server needs to use file extension to recover a vcard.
|
||||
// Davical needs it
|
||||
// SOGo official demo online needs it, but some SOGo installation don't need it, so test it
|
||||
define('CARDDAV_URL_VCARD_EXTENSION', '.vcf');
|
||||
|
||||
?>
|
||||
49
conf/zpush/backend_combined.php
Normal file
49
conf/zpush/backend_combined.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
/***********************************************
|
||||
* File : backend/combined/config.php
|
||||
* Project : Z-Push
|
||||
* Descr : configuration file for the
|
||||
* combined backend.
|
||||
************************************************/
|
||||
|
||||
class BackendCombinedConfig {
|
||||
public static function GetBackendCombinedConfig() {
|
||||
return array(
|
||||
'backends' => array(
|
||||
'i' => array(
|
||||
'name' => 'BackendIMAP',
|
||||
),
|
||||
'c' => array(
|
||||
'name' => 'BackendCalDAV',
|
||||
),
|
||||
'd' => array(
|
||||
'name' => 'BackendCardDAV',
|
||||
),
|
||||
),
|
||||
'delimiter' => '/',
|
||||
'folderbackend' => array(
|
||||
SYNC_FOLDER_TYPE_INBOX => 'i',
|
||||
SYNC_FOLDER_TYPE_DRAFTS => 'i',
|
||||
SYNC_FOLDER_TYPE_WASTEBASKET => 'i',
|
||||
SYNC_FOLDER_TYPE_SENTMAIL => 'i',
|
||||
SYNC_FOLDER_TYPE_OUTBOX => 'i',
|
||||
SYNC_FOLDER_TYPE_TASK => 'c',
|
||||
SYNC_FOLDER_TYPE_APPOINTMENT => 'c',
|
||||
SYNC_FOLDER_TYPE_CONTACT => 'd',
|
||||
SYNC_FOLDER_TYPE_NOTE => 'c',
|
||||
SYNC_FOLDER_TYPE_JOURNAL => 'c',
|
||||
SYNC_FOLDER_TYPE_OTHER => 'i',
|
||||
SYNC_FOLDER_TYPE_USER_MAIL => 'i',
|
||||
SYNC_FOLDER_TYPE_USER_APPOINTMENT => 'c',
|
||||
SYNC_FOLDER_TYPE_USER_CONTACT => 'd',
|
||||
SYNC_FOLDER_TYPE_USER_TASK => 'c',
|
||||
SYNC_FOLDER_TYPE_USER_JOURNAL => 'c',
|
||||
SYNC_FOLDER_TYPE_USER_NOTE => 'c',
|
||||
SYNC_FOLDER_TYPE_UNKNOWN => 'i',
|
||||
),
|
||||
'rootcreatefolderbackend' => 'i',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
42
conf/zpush/backend_imap.php
Normal file
42
conf/zpush/backend_imap.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
/***********************************************
|
||||
* File : config.php
|
||||
* Project : Z-Push
|
||||
* Descr : IMAP backend configuration file
|
||||
************************************************/
|
||||
|
||||
define('IMAP_SERVER', 'localhost');
|
||||
define('IMAP_PORT', 993);
|
||||
define('IMAP_OPTIONS', '/ssl/norsh/novalidate-cert');
|
||||
define('IMAP_DEFAULTFROM', '');
|
||||
|
||||
// not used
|
||||
define('IMAP_FROM_SQL_DSN', '');
|
||||
define('IMAP_FROM_SQL_USER', '');
|
||||
define('IMAP_FROM_SQL_PASSWORD', '');
|
||||
define('IMAP_FROM_SQL_OPTIONS', serialize(array(PDO::ATTR_PERSISTENT => true)));
|
||||
define('IMAP_FROM_SQL_QUERY', "select first_name, last_name, mail_address from users where mail_address = '#username@#domain'");
|
||||
define('IMAP_FROM_SQL_FIELDS', serialize(array('first_name', 'last_name', 'mail_address')));
|
||||
define('IMAP_FROM_SQL_FROM', '#first_name #last_name <#mail_address>');
|
||||
define('IMAP_FROM_LDAP_SERVER', '');
|
||||
define('IMAP_FROM_LDAP_SERVER_PORT', '389');
|
||||
define('IMAP_FROM_LDAP_USER', 'cn=zpush,ou=servers,dc=zpush,dc=org');
|
||||
define('IMAP_FROM_LDAP_PASSWORD', 'password');
|
||||
define('IMAP_FROM_LDAP_BASE', 'dc=zpush,dc=org');
|
||||
define('IMAP_FROM_LDAP_QUERY', '(mail=#username@#domain)');
|
||||
define('IMAP_FROM_LDAP_FIELDS', serialize(array('givenname', 'sn', 'mail')));
|
||||
define('IMAP_FROM_LDAP_FROM', '#givenname #sn <#mail>');
|
||||
|
||||
|
||||
// copy outgoing mail to this folder. If not set z-push will try the default folders
|
||||
define('IMAP_SENTFOLDER', '');
|
||||
define('IMAP_INLINE_FORWARD', true);
|
||||
define('IMAP_EXCLUDED_FOLDERS', '');
|
||||
define('IMAP_SMTP_METHOD', 'sendmail');
|
||||
|
||||
global $imap_smtp_params;
|
||||
$imap_smtp_params = array('host' => 'ssl://localhost', 'port' => 587, 'auth' => true, 'username' => 'imap_username', 'password' => 'imap_password');
|
||||
|
||||
define('MAIL_MIMEPART_CRLF', "\r\n");
|
||||
|
||||
?>
|
||||
@@ -2,6 +2,9 @@ import base64, os, os.path
|
||||
|
||||
from flask import make_response
|
||||
|
||||
import utils
|
||||
from mailconfig import get_mail_user_privileges
|
||||
|
||||
DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key'
|
||||
DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server'
|
||||
|
||||
@@ -37,32 +40,69 @@ class KeyAuthService:
|
||||
with create_file_with_mode(self.key_path, 0o640) as key_file:
|
||||
key_file.write(self.key + '\n')
|
||||
|
||||
def is_authenticated(self, request):
|
||||
"""Test if the client key passed in HTTP header matches the service key"""
|
||||
def is_authenticated(self, request, env):
|
||||
"""Test if the client key passed in HTTP Authorization header matches the service key
|
||||
or if the or username/password passed in the header matches an administrator user.
|
||||
Returns 'OK' if the key is good or the user is an administrator, otherwise an error message."""
|
||||
|
||||
def decode(s):
|
||||
return base64.b64decode(s.encode('utf-8')).decode('ascii')
|
||||
|
||||
def parse_api_key(header):
|
||||
if header is None:
|
||||
return
|
||||
return base64.b64decode(s.encode('ascii')).decode('ascii')
|
||||
|
||||
def parse_basic_auth(header):
|
||||
if " " not in header:
|
||||
return None, None
|
||||
scheme, credentials = header.split(maxsplit=1)
|
||||
if scheme != 'Basic':
|
||||
return
|
||||
return None, None
|
||||
|
||||
username, password = decode(credentials).split(':', maxsplit=1)
|
||||
return username
|
||||
credentials = decode(credentials)
|
||||
if ":" not in credentials:
|
||||
return None, None
|
||||
username, password = credentials.split(':', maxsplit=1)
|
||||
return username, password
|
||||
|
||||
request_key = parse_api_key(request.headers.get('Authorization'))
|
||||
header = request.headers.get('Authorization')
|
||||
if not header:
|
||||
return "No authorization header provided."
|
||||
|
||||
return request_key == self.key
|
||||
username, password = parse_basic_auth(header)
|
||||
|
||||
def make_unauthorized_response(self):
|
||||
return make_response(
|
||||
'You must pass the API key from "{0}" as the username\n'.format(self.key_path),
|
||||
401,
|
||||
{ 'WWW-Authenticate': 'Basic realm="{0}"'.format(self.auth_realm) })
|
||||
if username in (None, ""):
|
||||
return "Authorization header invalid."
|
||||
elif username == self.key:
|
||||
return "OK"
|
||||
else:
|
||||
return self.check_imap_login( username, password, env)
|
||||
|
||||
def check_imap_login(self, email, pw, env):
|
||||
# Validate a user's credentials.
|
||||
|
||||
# Sanity check.
|
||||
if email == "" or pw == "":
|
||||
return "Enter an email address and password."
|
||||
|
||||
# Authenticate.
|
||||
try:
|
||||
# Use doveadm to check credentials. doveadm will return
|
||||
# a non-zero exit status if the credentials are no good,
|
||||
# and check_call will raise an exception in that case.
|
||||
utils.shell('check_call', [
|
||||
"/usr/bin/doveadm",
|
||||
"auth", "test",
|
||||
email, pw
|
||||
])
|
||||
except:
|
||||
# Login failed.
|
||||
return "Invalid email address or password."
|
||||
|
||||
# Authorize.
|
||||
# (This call should never fail on a valid user.)
|
||||
privs = get_mail_user_privileges(email, env)
|
||||
if isinstance(privs, tuple): raise Exception("Error getting privileges.")
|
||||
if "admin" not in privs:
|
||||
return "You are not an administrator for this system."
|
||||
|
||||
return "OK"
|
||||
|
||||
def _generate_key(self):
|
||||
raw_key = os.urandom(32)
|
||||
|
||||
@@ -2,109 +2,207 @@
|
||||
|
||||
# This script performs a backup of all user data:
|
||||
# 1) System services are stopped while a copy of user data is made.
|
||||
# 2) An incremental backup is made using rdiff-backup into the
|
||||
# directory STORAGE_ROOT/backup/rdiff-history. This directory
|
||||
# will contain the latest files plus a complete history for
|
||||
# all prior backups.
|
||||
# 2) An incremental backup is made using duplicity into the
|
||||
# directory STORAGE_ROOT/backup/duplicity.
|
||||
# 3) The stopped services are restarted.
|
||||
# 4) The backup directory is compressed into a single file using tar.
|
||||
# 5) That file is encrypted with a long password stored in backup/secret_key.txt.
|
||||
# 4) The backup files are encrypted with a long password (stored in
|
||||
# backup/secret_key.txt) to STORAGE_ROOT/backup/encrypted.
|
||||
# 5) STORAGE_ROOT/backup/after-backup is executd if it exists.
|
||||
|
||||
import sys, os, os.path, shutil
|
||||
import os, os.path, shutil, glob, re, datetime
|
||||
import dateutil.parser, dateutil.relativedelta, dateutil.tz
|
||||
|
||||
from utils import exclusive_process, load_environment, shell
|
||||
|
||||
# settings
|
||||
full_backup = "--full" in sys.argv
|
||||
keep_backups_for = "31D" # destroy backups older than 31 days
|
||||
# destroy backups when the most recent increment in the chain
|
||||
# that depends on it is this many days old.
|
||||
keep_backups_for_days = 14
|
||||
|
||||
env = load_environment()
|
||||
def backup_status(env):
|
||||
# What is the current status of backups?
|
||||
# Loop through all of the files in STORAGE_ROOT/backup/duplicity to
|
||||
# get a list of all of the backups taken and sum up file sizes to
|
||||
# see how large the storage is.
|
||||
|
||||
exclusive_process("backup")
|
||||
now = datetime.datetime.now(dateutil.tz.tzlocal())
|
||||
def reldate(date, ref):
|
||||
rd = dateutil.relativedelta.relativedelta(ref, date)
|
||||
if rd.days >= 7: return "%d days" % rd.days
|
||||
if rd.days > 1: return "%d days, %d hours" % (rd.days, rd.hours)
|
||||
if rd.days == 1: return "%d day, %d hours" % (rd.days, rd.hours)
|
||||
return "%d hours, %d minutes" % (rd.hours, rd.minutes)
|
||||
|
||||
# Ensure the backup directory exists.
|
||||
backup_dir = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||
backup_duplicity_dir = os.path.join(backup_dir, 'duplicity')
|
||||
os.makedirs(backup_dir, exist_ok=True)
|
||||
backups = { }
|
||||
basedir = os.path.join(env['STORAGE_ROOT'], 'backup/duplicity/')
|
||||
encdir = os.path.join(env['STORAGE_ROOT'], 'backup/encrypted/')
|
||||
for fn in os.listdir(basedir):
|
||||
m = re.match(r"duplicity-(full|full-signatures|(inc|new-signatures)\.(?P<incbase>\d+T\d+Z)\.to)\.(?P<date>\d+T\d+Z)\.", fn)
|
||||
if not m: raise ValueError(fn)
|
||||
|
||||
# Stop services.
|
||||
shell('check_call', ["/usr/sbin/service", "dovecot", "stop"])
|
||||
shell('check_call', ["/usr/sbin/service", "postfix", "stop"])
|
||||
key = m.group("date")
|
||||
if key not in backups:
|
||||
date = dateutil.parser.parse(m.group("date"))
|
||||
backups[key] = {
|
||||
"date": m.group("date"),
|
||||
"date_str": date.strftime("%x %X"),
|
||||
"date_delta": reldate(date, now),
|
||||
"full": m.group("incbase") is None,
|
||||
"previous": m.group("incbase"),
|
||||
"size": 0,
|
||||
"encsize": 0,
|
||||
}
|
||||
|
||||
# Update the backup mirror directory which mirrors the current
|
||||
# STORAGE_ROOT (but excluding the backups themselves!).
|
||||
try:
|
||||
backups[key]["size"] += os.path.getsize(os.path.join(basedir, fn))
|
||||
|
||||
# Also check encrypted size.
|
||||
encfn = os.path.join(encdir, fn + ".enc")
|
||||
if os.path.exists(encfn):
|
||||
backups[key]["encsize"] += os.path.getsize(encfn)
|
||||
|
||||
# Ensure the rows are sorted reverse chronologically.
|
||||
# This is relied on by should_force_full() and the next step.
|
||||
backups = sorted(backups.values(), key = lambda b : b["date"], reverse=True)
|
||||
|
||||
# When will a backup be deleted?
|
||||
saw_full = False
|
||||
deleted_in = None
|
||||
days_ago = now - datetime.timedelta(days=keep_backups_for_days)
|
||||
for bak in backups:
|
||||
if deleted_in:
|
||||
# Subsequent backups are deleted when the most recent increment
|
||||
# in the chain would be deleted.
|
||||
bak["deleted_in"] = deleted_in
|
||||
if bak["full"]:
|
||||
# Reset when we get to a full backup. A new chain start next.
|
||||
saw_full = True
|
||||
deleted_in = None
|
||||
elif saw_full and not deleted_in:
|
||||
# Mark deleted_in only on the first increment after a full backup.
|
||||
deleted_in = reldate(days_ago, dateutil.parser.parse(bak["date"]))
|
||||
bak["deleted_in"] = deleted_in
|
||||
|
||||
return {
|
||||
"directory": basedir,
|
||||
"encpwfile": os.path.join(env['STORAGE_ROOT'], 'backup/secret_key.txt'),
|
||||
"encdirectory": encdir,
|
||||
"tz": now.tzname(),
|
||||
"backups": backups,
|
||||
}
|
||||
|
||||
def should_force_full(env):
|
||||
# Force a full backup when the total size of the increments
|
||||
# since the last full backup is greater than half the size
|
||||
# of that full backup.
|
||||
inc_size = 0
|
||||
for bak in backup_status(env)["backups"]:
|
||||
if not bak["full"]:
|
||||
# Scan through the incremental backups cumulating
|
||||
# size...
|
||||
inc_size += bak["size"]
|
||||
else:
|
||||
# ...until we reach the most recent full backup.
|
||||
# Return if we should to a full backup.
|
||||
return inc_size > .5*bak["size"]
|
||||
else:
|
||||
# If we got here there are no (full) backups, so make one.
|
||||
# (I love for/else blocks. Here it's just to show off.)
|
||||
return True
|
||||
|
||||
def perform_backup(full_backup):
|
||||
env = load_environment()
|
||||
|
||||
exclusive_process("backup")
|
||||
|
||||
# Ensure the backup directory exists.
|
||||
backup_dir = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||
backup_duplicity_dir = os.path.join(backup_dir, 'duplicity')
|
||||
os.makedirs(backup_duplicity_dir, exist_ok=True)
|
||||
|
||||
# On the first run, always do a full backup. Incremental
|
||||
# will fail. Otherwise do a full backup when the size of
|
||||
# the increments since the most recent full backup are
|
||||
# large.
|
||||
full_backup = full_backup or should_force_full(env)
|
||||
|
||||
# Stop services.
|
||||
shell('check_call', ["/usr/sbin/service", "dovecot", "stop"])
|
||||
shell('check_call', ["/usr/sbin/service", "postfix", "stop"])
|
||||
|
||||
# Update the backup mirror directory which mirrors the current
|
||||
# STORAGE_ROOT (but excluding the backups themselves!).
|
||||
try:
|
||||
shell('check_call', [
|
||||
"/usr/bin/duplicity",
|
||||
"full" if full_backup else "incr",
|
||||
"--no-encryption",
|
||||
"--archive-dir", "/tmp/duplicity-archive-dir",
|
||||
"--name", "mailinabox",
|
||||
"--exclude", backup_dir,
|
||||
"--volsize", "100",
|
||||
"--verbosity", "warning",
|
||||
env["STORAGE_ROOT"],
|
||||
"file://" + backup_duplicity_dir
|
||||
])
|
||||
finally:
|
||||
# Start services again.
|
||||
shell('check_call', ["/usr/sbin/service", "dovecot", "start"])
|
||||
shell('check_call', ["/usr/sbin/service", "postfix", "start"])
|
||||
|
||||
# Remove old backups. This deletes all backup data no longer needed
|
||||
# from more than 31 days ago. Must do this before destroying the
|
||||
# cache directory or else this command will re-create it.
|
||||
shell('check_call', [
|
||||
"/usr/bin/duplicity",
|
||||
"full" if full_backup else "incr",
|
||||
"--no-encryption",
|
||||
"remove-older-than",
|
||||
"%dD" % keep_backups_for_days,
|
||||
"--archive-dir", "/tmp/duplicity-archive-dir",
|
||||
"--name", "mailinabox",
|
||||
"--exclude", backup_dir,
|
||||
"--volsize", "100",
|
||||
"--force",
|
||||
"--verbosity", "warning",
|
||||
env["STORAGE_ROOT"],
|
||||
"file://" + backup_duplicity_dir
|
||||
])
|
||||
finally:
|
||||
# Start services again.
|
||||
shell('check_call', ["/usr/sbin/service", "dovecot", "start"])
|
||||
shell('check_call', ["/usr/sbin/service", "postfix", "start"])
|
||||
|
||||
# Remove old backups. This deletes all backup data no longer needed
|
||||
# from more than 31 days ago. Must do this before destroying the
|
||||
# cache directory or else this command will re-create it.
|
||||
shell('check_call', [
|
||||
"/usr/bin/duplicity",
|
||||
"remove-older-than",
|
||||
keep_backups_for,
|
||||
"--archive-dir", "/tmp/duplicity-archive-dir",
|
||||
"--name", "mailinabox",
|
||||
"--force",
|
||||
"--verbosity", "warning",
|
||||
"file://" + backup_duplicity_dir
|
||||
])
|
||||
# Remove duplicity's cache directory because it's redundant with our backup directory.
|
||||
shutil.rmtree("/tmp/duplicity-archive-dir")
|
||||
|
||||
# Remove old increments. This deletes incremental data obsoleted by
|
||||
# any subsequent full backups.
|
||||
shell('check_call', [
|
||||
"/usr/bin/duplicity",
|
||||
"remove-all-inc-of-but-n-full",
|
||||
"1",
|
||||
"--archive-dir", "/tmp/duplicity-archive-dir",
|
||||
"--name", "mailinabox",
|
||||
"--force",
|
||||
"--verbosity", "warning",
|
||||
"file://" + backup_duplicity_dir
|
||||
])
|
||||
# Encrypt all of the new files.
|
||||
backup_encrypted_dir = os.path.join(backup_dir, 'encrypted')
|
||||
os.makedirs(backup_encrypted_dir, exist_ok=True)
|
||||
for fn in os.listdir(backup_duplicity_dir):
|
||||
fn2 = os.path.join(backup_encrypted_dir, fn) + ".enc"
|
||||
if os.path.exists(fn2): continue
|
||||
|
||||
# Remove duplicity's cache directory because it's redundant with our backup directory.
|
||||
shutil.rmtree("/tmp/duplicity-archive-dir")
|
||||
# Encrypt the backup using the backup private key.
|
||||
shell('check_call', [
|
||||
"/usr/bin/openssl",
|
||||
"enc",
|
||||
"-aes-256-cbc",
|
||||
"-a",
|
||||
"-salt",
|
||||
"-in", os.path.join(backup_duplicity_dir, fn),
|
||||
"-out", fn2,
|
||||
"-pass", "file:%s" % os.path.join(backup_dir, "secret_key.txt"),
|
||||
])
|
||||
|
||||
# Encrypt all of the new files.
|
||||
backup_encrypted_dir = os.path.join(backup_dir, 'encrypted')
|
||||
os.makedirs(backup_encrypted_dir, exist_ok=True)
|
||||
for fn in os.listdir(backup_duplicity_dir):
|
||||
fn2 = os.path.join(backup_encrypted_dir, fn) + ".enc"
|
||||
if os.path.exists(fn2): continue
|
||||
# The backup can be decrypted with:
|
||||
# openssl enc -d -aes-256-cbc -a -in latest.tgz.enc -out /dev/stdout -pass file:secret_key.txt | tar -z
|
||||
|
||||
# Encrypt the backup using the backup private key.
|
||||
shell('check_call', [
|
||||
"/usr/bin/openssl",
|
||||
"enc",
|
||||
"-aes-256-cbc",
|
||||
"-a",
|
||||
"-salt",
|
||||
"-in", os.path.join(backup_duplicity_dir, fn),
|
||||
"-out", fn2,
|
||||
"-pass", "file:%s" % os.path.join(backup_dir, "secret_key.txt"),
|
||||
])
|
||||
# Remove encrypted backups that are no longer needed.
|
||||
for fn in os.listdir(backup_encrypted_dir):
|
||||
fn2 = os.path.join(backup_duplicity_dir, fn.replace(".enc", ""))
|
||||
if os.path.exists(fn2): continue
|
||||
os.unlink(os.path.join(backup_encrypted_dir, fn))
|
||||
|
||||
# The backup can be decrypted with:
|
||||
# openssl enc -d -aes-256-cbc -a -in latest.tgz.enc -out /dev/stdout -pass file:secret_key.txt | tar -z
|
||||
# Execute a post-backup script that does the copying to a remote server.
|
||||
# Run as the STORAGE_USER user, not as root. Pass our settings in
|
||||
# environment variables so the script has access to STORAGE_ROOT.
|
||||
post_script = os.path.join(backup_dir, 'after-backup')
|
||||
if os.path.exists(post_script):
|
||||
shell('check_call',
|
||||
['su', env['STORAGE_USER'], '-c', post_script],
|
||||
env=env)
|
||||
|
||||
# Remove encrypted backups that are no longer needed.
|
||||
for fn in os.listdir(backup_encrypted_dir):
|
||||
fn2 = os.path.join(backup_duplicity_dir, fn.replace(".enc", ""))
|
||||
if os.path.exists(fn2): continue
|
||||
os.unlink(os.path.join(backup_encrypted_dir, fn))
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
full_backup = "--full" in sys.argv
|
||||
perform_backup(full_backup)
|
||||
|
||||
@@ -14,7 +14,7 @@ import rtyaml
|
||||
|
||||
from utils import load_environment, shell
|
||||
from web_update import get_web_domains, get_domain_ssl_files, get_web_root
|
||||
from whats_next import check_certificate
|
||||
from status_checks import check_certificate
|
||||
|
||||
def buy_ssl_certificate(api_key, domain, command, env):
|
||||
if domain != env['PRIMARY_HOSTNAME'] \
|
||||
|
||||
@@ -1,113 +1,271 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
import os, os.path, re
|
||||
import os, os.path, re, json
|
||||
|
||||
from flask import Flask, request, render_template, abort
|
||||
app = Flask(__name__)
|
||||
from functools import wraps
|
||||
|
||||
from flask import Flask, request, render_template, abort, Response
|
||||
|
||||
import auth, utils
|
||||
from mailconfig import get_mail_users, add_mail_user, set_mail_password, remove_mail_user, get_mail_aliases, get_mail_domains, add_mail_alias, remove_mail_alias
|
||||
from mailconfig import get_mail_users, add_mail_user, set_mail_password, remove_mail_user, get_archived_mail_users
|
||||
from mailconfig import get_mail_user_privileges, add_remove_mail_user_privilege
|
||||
from mailconfig import get_mail_aliases, get_mail_domains, add_mail_alias, remove_mail_alias
|
||||
|
||||
env = utils.load_environment()
|
||||
|
||||
auth_service = auth.KeyAuthService()
|
||||
|
||||
@app.before_request
|
||||
def require_auth_key():
|
||||
if not auth_service.is_authenticated(request):
|
||||
abort(401)
|
||||
# We may deploy via a symbolic link, which confuses flask's template finding.
|
||||
me = __file__
|
||||
try:
|
||||
me = os.readlink(__file__)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
app = Flask(__name__, template_folder=os.path.abspath(os.path.join(os.path.dirname(me), "templates")))
|
||||
|
||||
# Decorator to protect views that require authentication.
|
||||
def authorized_personnel_only(viewfunc):
|
||||
@wraps(viewfunc)
|
||||
def newview(*args, **kwargs):
|
||||
# Check if the user is authorized.
|
||||
authorized_status = auth_service.is_authenticated(request, env)
|
||||
if authorized_status == "OK":
|
||||
# Authorized. Call view func.
|
||||
return viewfunc(*args, **kwargs)
|
||||
|
||||
# Not authorized. Return a 401 (send auth) and a prompt to authorize by default.
|
||||
status = 401
|
||||
headers = { 'WWW-Authenticate': 'Basic realm="{0}"'.format(auth_service.auth_realm) }
|
||||
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
# Don't issue a 401 to an AJAX request because the user will
|
||||
# be prompted for credentials, which is not helpful.
|
||||
status = 403
|
||||
headers = None
|
||||
|
||||
if request.headers.get('Accept') in (None, "", "*/*"):
|
||||
# Return plain text output.
|
||||
return Response(authorized_status+"\n", status=status, mimetype='text/plain', headers=headers)
|
||||
else:
|
||||
# Return JSON output.
|
||||
return Response(json.dumps({
|
||||
"status": "error",
|
||||
"reason": authorized_status
|
||||
}+"\n"), status=status, mimetype='application/json', headers=headers)
|
||||
|
||||
return newview
|
||||
|
||||
@app.errorhandler(401)
|
||||
def unauthorized(error):
|
||||
return auth_service.make_unauthorized_response()
|
||||
|
||||
def json_response(data):
|
||||
return Response(json.dumps(data), status=200, mimetype='application/json')
|
||||
|
||||
###################################
|
||||
|
||||
# Control Panel (unauthenticated views)
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return render_template('index.html')
|
||||
# Render the control panel. This route does not require user authentication
|
||||
# so it must be safe!
|
||||
no_admins_exist = (len([user for user in get_mail_users(env, as_json=True) if "admin" in user['privileges']]) == 0)
|
||||
return render_template('index.html',
|
||||
hostname=env['PRIMARY_HOSTNAME'],
|
||||
no_admins_exist=no_admins_exist,
|
||||
)
|
||||
|
||||
@app.route('/me')
|
||||
def me():
|
||||
# Is the caller authorized?
|
||||
authorized_status = auth_service.is_authenticated(request, env)
|
||||
if authorized_status != "OK":
|
||||
return json_response({
|
||||
"status": "not-authorized",
|
||||
"reason": authorized_status,
|
||||
})
|
||||
return json_response({
|
||||
"status": "authorized",
|
||||
"api_key": auth_service.key,
|
||||
})
|
||||
|
||||
# MAIL
|
||||
|
||||
@app.route('/mail/users')
|
||||
@authorized_personnel_only
|
||||
def mail_users():
|
||||
return "".join(x+"\n" for x in get_mail_users(env))
|
||||
if request.args.get("format", "") == "json":
|
||||
return json_response(get_mail_users(env, as_json=True) + get_archived_mail_users(env))
|
||||
else:
|
||||
return "".join(x+"\n" for x in get_mail_users(env))
|
||||
|
||||
@app.route('/mail/users/add', methods=['POST'])
|
||||
@authorized_personnel_only
|
||||
def mail_users_add():
|
||||
return add_mail_user(request.form.get('email', ''), request.form.get('password', ''), env)
|
||||
try:
|
||||
return add_mail_user(request.form.get('email', ''), request.form.get('password', ''), request.form.get('privileges', ''), env)
|
||||
except ValueError as e:
|
||||
return (str(e), 400)
|
||||
|
||||
@app.route('/mail/users/password', methods=['POST'])
|
||||
@authorized_personnel_only
|
||||
def mail_users_password():
|
||||
return set_mail_password(request.form.get('email', ''), request.form.get('password', ''), env)
|
||||
try:
|
||||
return set_mail_password(request.form.get('email', ''), request.form.get('password', ''), env)
|
||||
except ValueError as e:
|
||||
return (str(e), 400)
|
||||
|
||||
@app.route('/mail/users/remove', methods=['POST'])
|
||||
@authorized_personnel_only
|
||||
def mail_users_remove():
|
||||
return remove_mail_user(request.form.get('email', ''), env)
|
||||
|
||||
|
||||
@app.route('/mail/users/privileges')
|
||||
@authorized_personnel_only
|
||||
def mail_user_privs():
|
||||
privs = get_mail_user_privileges(request.args.get('email', ''), env)
|
||||
if isinstance(privs, tuple): return privs # error
|
||||
return "\n".join(privs)
|
||||
|
||||
@app.route('/mail/users/privileges/add', methods=['POST'])
|
||||
@authorized_personnel_only
|
||||
def mail_user_privs_add():
|
||||
return add_remove_mail_user_privilege(request.form.get('email', ''), request.form.get('privilege', ''), "add", env)
|
||||
|
||||
@app.route('/mail/users/privileges/remove', methods=['POST'])
|
||||
@authorized_personnel_only
|
||||
def mail_user_privs_remove():
|
||||
return add_remove_mail_user_privilege(request.form.get('email', ''), request.form.get('privilege', ''), "remove", env)
|
||||
|
||||
|
||||
@app.route('/mail/aliases')
|
||||
@authorized_personnel_only
|
||||
def mail_aliases():
|
||||
return "".join(x+"\t"+y+"\n" for x, y in get_mail_aliases(env))
|
||||
if request.args.get("format", "") == "json":
|
||||
return json_response(get_mail_aliases(env, as_json=True))
|
||||
else:
|
||||
return "".join(x+"\t"+y+"\n" for x, y in get_mail_aliases(env))
|
||||
|
||||
@app.route('/mail/aliases/add', methods=['POST'])
|
||||
@authorized_personnel_only
|
||||
def mail_aliases_add():
|
||||
return add_mail_alias(request.form.get('source', ''), request.form.get('destination', ''), env)
|
||||
return add_mail_alias(
|
||||
request.form.get('source', ''),
|
||||
request.form.get('destination', ''),
|
||||
env,
|
||||
update_if_exists=(request.form.get('update_if_exists', '') == '1')
|
||||
)
|
||||
|
||||
@app.route('/mail/aliases/remove', methods=['POST'])
|
||||
@authorized_personnel_only
|
||||
def mail_aliases_remove():
|
||||
return remove_mail_alias(request.form.get('source', ''), env)
|
||||
|
||||
@app.route('/mail/domains')
|
||||
@authorized_personnel_only
|
||||
def mail_domains():
|
||||
return "".join(x+"\n" for x in get_mail_domains(env))
|
||||
|
||||
# DNS
|
||||
|
||||
@app.route('/dns/update', methods=['POST'])
|
||||
@authorized_personnel_only
|
||||
def dns_update():
|
||||
from dns_update import do_dns_update
|
||||
try:
|
||||
return do_dns_update(env)
|
||||
return do_dns_update(env, force=request.form.get('force', '') == '1')
|
||||
except Exception as e:
|
||||
return (str(e), 500)
|
||||
|
||||
@app.route('/dns/ds')
|
||||
def dns_get_ds_records():
|
||||
from dns_update import get_ds_records
|
||||
@app.route('/dns/set/<qname>', methods=['POST'])
|
||||
@app.route('/dns/set/<qname>/<rtype>', methods=['POST'])
|
||||
@app.route('/dns/set/<qname>/<rtype>/<value>', methods=['POST'])
|
||||
@authorized_personnel_only
|
||||
def dns_set_record(qname, rtype="A", value=None):
|
||||
from dns_update import do_dns_update, set_custom_dns_record
|
||||
try:
|
||||
return get_ds_records(env).replace("\t", " ") # tabs confuse godaddy
|
||||
except Exception as e:
|
||||
return (str(e), 500)
|
||||
# Get the value from the URL, then the POST parameters, or if it is not set then
|
||||
# use the remote IP address of the request --- makes dynamic DNS easy. To clear a
|
||||
# value, '' must be explicitly passed.
|
||||
if value is None:
|
||||
value = request.form.get("value")
|
||||
if value is None:
|
||||
value = request.environ.get("HTTP_X_FORWARDED_FOR") # normally REMOTE_ADDR but we're behind nginx as a reverse proxy
|
||||
if value == '' or value == '__delete__':
|
||||
# request deletion
|
||||
value = None
|
||||
if set_custom_dns_record(qname, rtype, value, env):
|
||||
return do_dns_update(env)
|
||||
return "OK"
|
||||
except ValueError as e:
|
||||
return (str(e), 400)
|
||||
|
||||
@app.route('/dns/dump')
|
||||
@authorized_personnel_only
|
||||
def dns_get_dump():
|
||||
from dns_update import build_recommended_dns
|
||||
return json_response(build_recommended_dns(env))
|
||||
|
||||
# WEB
|
||||
|
||||
@app.route('/web/update', methods=['POST'])
|
||||
@authorized_personnel_only
|
||||
def web_update():
|
||||
from web_update import do_web_update
|
||||
return do_web_update(env)
|
||||
|
||||
# System
|
||||
|
||||
@app.route('/system/status', methods=["POST"])
|
||||
@authorized_personnel_only
|
||||
def system_status():
|
||||
from status_checks import run_checks
|
||||
class WebOutput:
|
||||
def __init__(self):
|
||||
self.items = []
|
||||
def add_heading(self, heading):
|
||||
self.items.append({ "type": "heading", "text": heading, "extra": [] })
|
||||
def print_ok(self, message):
|
||||
self.items.append({ "type": "ok", "text": message, "extra": [] })
|
||||
def print_error(self, message):
|
||||
self.items.append({ "type": "error", "text": message, "extra": [] })
|
||||
def print_line(self, message, monospace=False):
|
||||
self.items[-1]["extra"].append({ "text": message, "monospace": monospace })
|
||||
output = WebOutput()
|
||||
run_checks(env, output)
|
||||
return json_response(output.items)
|
||||
|
||||
@app.route('/system/updates')
|
||||
@authorized_personnel_only
|
||||
def show_updates():
|
||||
utils.shell("check_call", ["/usr/bin/apt-get", "-qq", "update"])
|
||||
simulated_install = utils.shell("check_output", ["/usr/bin/apt-get", "-qq", "-s", "upgrade"])
|
||||
pkgs = []
|
||||
for line in simulated_install.split('\n'):
|
||||
if re.match(r'^Conf .*', line): continue # remove these lines, not informative
|
||||
line = re.sub(r'^Inst (.*) \[(.*)\] \((\S*).*', r'Updated Package Available: \1 (\3)', line) # make these lines prettier
|
||||
pkgs.append(line)
|
||||
return "\n".join(pkgs)
|
||||
from status_checks import list_apt_updates
|
||||
return "".join(
|
||||
"%s (%s)\n"
|
||||
% (p["package"], p["version"])
|
||||
for p in list_apt_updates())
|
||||
|
||||
@app.route('/system/update-packages', methods=["POST"])
|
||||
@authorized_personnel_only
|
||||
def do_updates():
|
||||
utils.shell("check_call", ["/usr/bin/apt-get", "-qq", "update"])
|
||||
return utils.shell("check_output", ["/usr/bin/apt-get", "-y", "upgrade"], env={
|
||||
"DEBIAN_FRONTEND": "noninteractive"
|
||||
})
|
||||
|
||||
@app.route('/system/backup/status')
|
||||
@authorized_personnel_only
|
||||
def backup_status():
|
||||
from backup import backup_status
|
||||
return json_response(backup_status(env))
|
||||
|
||||
# APP
|
||||
|
||||
if __name__ == '__main__':
|
||||
if "DEBUG" in os.environ: app.debug = True
|
||||
if "APIKEY" in os.environ: auth_service.key = os.environ["APIKEY"]
|
||||
|
||||
if not app.debug:
|
||||
app.logger.addHandler(utils.create_syslog_handler())
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
# and mail aliases and restarts nsd.
|
||||
########################################################################
|
||||
|
||||
import os, os.path, urllib.parse, datetime, re, hashlib
|
||||
import os, os.path, urllib.parse, datetime, re, hashlib, base64
|
||||
import ipaddress
|
||||
import rtyaml
|
||||
|
||||
from mailconfig import get_mail_domains
|
||||
@@ -54,7 +55,7 @@ def get_custom_dns_config(env):
|
||||
except:
|
||||
return { }
|
||||
|
||||
def do_dns_update(env):
|
||||
def do_dns_update(env, force=False):
|
||||
# What domains (and their zone filenames) should we build?
|
||||
domains = get_dns_domains(env)
|
||||
zonefiles = get_dns_zones(env)
|
||||
@@ -71,7 +72,7 @@ def do_dns_update(env):
|
||||
|
||||
# See if the zone has changed, and if so update the serial number
|
||||
# and write the zone file.
|
||||
if not write_nsd_zone(domain, "/etc/nsd/zones/" + zonefile, records, env):
|
||||
if not write_nsd_zone(domain, "/etc/nsd/zones/" + zonefile, records, env, force):
|
||||
# Zone was not updated. There were no changes.
|
||||
continue
|
||||
|
||||
@@ -93,7 +94,7 @@ def do_dns_update(env):
|
||||
# Thus we only sign a zone if write_nsd_zone returned True
|
||||
# indicating the zone changed, and thus it got a new serial number.
|
||||
# write_nsd_zone is smart enough to check if a zone's signature
|
||||
# is nearing experiation and if so it'll bump the serial number
|
||||
# is nearing expiration and if so it'll bump the serial number
|
||||
# and return True so we get a chance to re-sign it.
|
||||
sign_zone(domain, zonefile, env)
|
||||
|
||||
@@ -104,7 +105,7 @@ def do_dns_update(env):
|
||||
zonefiles[i][1] += ".signed"
|
||||
|
||||
# Write the main nsd.conf file.
|
||||
if write_nsd_conf(zonefiles):
|
||||
if write_nsd_conf(zonefiles, env):
|
||||
# Make sure updated_domains contains *something* if we wrote an updated
|
||||
# nsd.conf so that we know to restart nsd.
|
||||
if len(updated_domains) == 0:
|
||||
@@ -115,10 +116,12 @@ def do_dns_update(env):
|
||||
shell('check_call', ["/usr/sbin/service", "nsd", "restart"])
|
||||
|
||||
# Write the OpenDKIM configuration tables.
|
||||
write_opendkim_tables(zonefiles, env)
|
||||
|
||||
# Kick opendkim.
|
||||
shell('check_call', ["/usr/sbin/service", "opendkim", "restart"])
|
||||
if write_opendkim_tables(zonefiles, env):
|
||||
# Settings changed. Kick opendkim.
|
||||
shell('check_call', ["/usr/sbin/service", "opendkim", "restart"])
|
||||
if len(updated_domains) == 0:
|
||||
# If this is the only thing that changed?
|
||||
updated_domains.append("OpenDKIM configuration")
|
||||
|
||||
if len(updated_domains) == 0:
|
||||
# if nothing was updated (except maybe OpenDKIM's files), don't show any output
|
||||
@@ -157,12 +160,16 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
|
||||
# Add a DANE TLSA record for SMTP.
|
||||
records.append(("_25._tcp", "TLSA", build_tlsa_record(env), "Recommended when DNSSEC is enabled. Advertises to mail servers connecting to the box that mandatory encryption should be used."))
|
||||
|
||||
# Add a SSHFP records to help SSH key validation. One per available SSH key on this system.
|
||||
for value in build_sshfp_records():
|
||||
records.append((None, "SSHFP", value, "Optional. Provides an out-of-band method for verifying an SSH key before connecting. Use 'VerifyHostKeyDNS yes' (or 'VerifyHostKeyDNS ask') when connecting with ssh."))
|
||||
|
||||
# The MX record says where email for the domain should be delivered: Here!
|
||||
records.append((None, "MX", "10 %s." % env["PRIMARY_HOSTNAME"], "Required. Specifies the hostname of the machine that handles @%s mail." % domain))
|
||||
records.append((None, "MX", "10 %s." % env["PRIMARY_HOSTNAME"], "Required. Specifies the hostname (and priority) of the machine that handles @%s mail." % domain))
|
||||
|
||||
# SPF record: Permit the box ('mx', see above) to send mail on behalf of
|
||||
# the domain, and no one else.
|
||||
records.append((None, "TXT", '"v=spf1 mx -all"', "Recomended. Specifies that only the box is permitted to send @%s mail." % domain))
|
||||
records.append((None, "TXT", 'v=spf1 mx -all', "Recommended. Specifies that only the box is permitted to send @%s mail." % domain))
|
||||
|
||||
# Add DNS records for any subdomains of this domain. We should not have a zone for
|
||||
# both a domain and one of its subdomains.
|
||||
@@ -190,9 +197,9 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
|
||||
|
||||
# Add defaults if not overridden by the user's custom settings.
|
||||
defaults = [
|
||||
(None, "A", env["PUBLIC_IP"], "Optional. Sets the IP address that %s resolves to, e.g. for web hosting." % domain),
|
||||
(None, "A", env["PUBLIC_IP"], "Required. May have a different value. Sets the IP address that %s resolves to for web hosting and other services besides mail. The A record must be present but its value does not affect mail delivery." % domain),
|
||||
("www", "A", env["PUBLIC_IP"], "Optional. Sets the IP address that www.%s resolves to, e.g. for web hosting." % domain),
|
||||
(None, "AAAA", env.get('PUBLIC_IPV6'), "Optional. Sets the IPv6 address that %s resolves to, e.g. for web hosting." % domain),
|
||||
(None, "AAAA", env.get('PUBLIC_IPV6'), "Optional. Sets the IPv6 address that %s resolves to, e.g. for web hosting. (It is not necessary for receiving mail on this domain.)" % domain),
|
||||
("www", "AAAA", env.get('PUBLIC_IPV6'), "Optional. Sets the IPv6 address that www.%s resolves to, e.g. for web hosting." % domain),
|
||||
]
|
||||
for qname, rtype, value, explanation in defaults:
|
||||
@@ -206,11 +213,12 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
|
||||
if os.path.exists(opendkim_record_file):
|
||||
# Append the DKIM TXT record to the zone as generated by OpenDKIM, after string formatting above.
|
||||
with open(opendkim_record_file) as orf:
|
||||
m = re.match(r"(\S+)\s+IN\s+TXT\s+(\(.*\))\s*;", orf.read(), re.S)
|
||||
records.append((m.group(1), "TXT", m.group(2), "Recommended. Specifies that only the box is permitted to send mail at this domain."))
|
||||
m = re.match(r'(\S+)\s+IN\s+TXT\s+\( "([^"]+)"\s+"([^"]+)"\s*\)', orf.read(), re.S)
|
||||
val = m.group(2) + m.group(3)
|
||||
records.append((m.group(1), "TXT", val, "Recommended. Provides a way for recipients to verify that this machine sent @%s mail." % domain))
|
||||
|
||||
# Append a DMARC record.
|
||||
records.append(("_dmarc", "TXT", '"v=DMARC1; p=quarantine"', "Optional. Specifies that mail that does not originate from the box but claims to be from @%s is suspect and should be quarantined by the recipient's mail system." % domain))
|
||||
records.append(("_dmarc", "TXT", 'v=DMARC1; p=quarantine', "Optional. Specifies that mail that does not originate from the box but claims to be from @%s is suspect and should be quarantined by the recipient's mail system." % domain))
|
||||
|
||||
# Sort the records. The None records *must* go first in the nsd zone file. Otherwise it doesn't matter.
|
||||
records.sort(key = lambda rec : list(reversed(rec[0].split(".")) if rec[0] is not None else ""))
|
||||
@@ -236,7 +244,7 @@ def get_custom_records(domain, additional_records, env):
|
||||
if isinstance(value, str):
|
||||
values = [("A", value)]
|
||||
if value == "local" and env.get("PUBLIC_IPV6"):
|
||||
values.appnd( ("AAAA", value) )
|
||||
values.append( ("AAAA", value) )
|
||||
|
||||
# A mapping creates multiple records.
|
||||
elif isinstance(value, dict):
|
||||
@@ -254,11 +262,6 @@ def get_custom_records(domain, additional_records, env):
|
||||
if rtype == "AAAA" and value2 == "local":
|
||||
if "PUBLIC_IPV6" not in env: continue # no IPv6 address is available so don't set anything
|
||||
value2 = env["PUBLIC_IPV6"]
|
||||
|
||||
# For typical zone file output, quote a text record.
|
||||
if rtype == "TXT":
|
||||
value2 = "\"" + value2 + "\""
|
||||
|
||||
yield (qname, rtype, value2)
|
||||
|
||||
########################################################################
|
||||
@@ -286,26 +289,65 @@ def build_tlsa_record(env):
|
||||
# 1: The certificate is SHA256'd here.
|
||||
return "3 0 1 " + certhash
|
||||
|
||||
def build_sshfp_records():
|
||||
# The SSHFP record is a way for us to embed this server's SSH public
|
||||
# key fingerprint into the DNS so that remote hosts have an out-of-band
|
||||
# method to confirm the fingerprint. See RFC 4255 and RFC 6594. This
|
||||
# depends on DNSSEC.
|
||||
#
|
||||
# On the client side, set SSH's VerifyHostKeyDNS option to 'ask' to
|
||||
# include this info in the key verification prompt or 'yes' to trust
|
||||
# the SSHFP record.
|
||||
#
|
||||
# See https://github.com/xelerance/sshfp for inspiriation.
|
||||
|
||||
algorithm_number = {
|
||||
"ssh-rsa": 1,
|
||||
"ssh-dss": 2,
|
||||
"ecdsa-sha2-nistp256": 3,
|
||||
}
|
||||
|
||||
# Get our local fingerprints by running ssh-keyscan. The output looks
|
||||
# like the known_hosts file: hostname, keytype, fingerprint.
|
||||
keys = shell("check_output", ["ssh-keyscan", "localhost"])
|
||||
for key in keys.split("\n"):
|
||||
if key.strip() == "" or key[0] == "#": continue
|
||||
try:
|
||||
host, keytype, pubkey = key.split(" ")
|
||||
yield "%d %d ( %s )" % (
|
||||
algorithm_number[keytype],
|
||||
2, # specifies we are using SHA-256 on next line
|
||||
hashlib.sha256(base64.b64decode(pubkey)).hexdigest().upper(),
|
||||
)
|
||||
except:
|
||||
# Lots of things can go wrong. Don't let it disturb the DNS
|
||||
# zone.
|
||||
pass
|
||||
|
||||
########################################################################
|
||||
|
||||
def write_nsd_zone(domain, zonefile, records, env):
|
||||
# We set the administrative email address for every domain to domain_contact@[domain.com].
|
||||
# You should probably create an alias to your email address.
|
||||
|
||||
def write_nsd_zone(domain, zonefile, records, env, force):
|
||||
# On the $ORIGIN line, there's typically a ';' comment at the end explaining
|
||||
# what the $ORIGIN line does. Any further data after the domain confuses
|
||||
# ldns-signzone, however. It used to say '; default zone domain'.
|
||||
|
||||
# The SOA contact address for all of the domains on this system is hostmaster
|
||||
# @ the PRIMARY_HOSTNAME. Hopefully that's legit.
|
||||
|
||||
# For the refresh through TTL fields, a good reference is:
|
||||
# http://www.peerwisdom.org/2013/05/15/dns-understanding-the-soa-record/
|
||||
|
||||
|
||||
zone = """
|
||||
$ORIGIN {domain}.
|
||||
$TTL 86400 ; default time to live
|
||||
$TTL 1800 ; default time to live
|
||||
|
||||
@ IN SOA ns1.{primary_domain}. hostmaster.{primary_domain}. (
|
||||
__SERIAL__ ; serial number
|
||||
28800 ; Refresh
|
||||
7200 ; Retry
|
||||
864000 ; Expire
|
||||
86400 ; Min TTL
|
||||
7200 ; Refresh (secondary nameserver update interval)
|
||||
1800 ; Retry (when refresh fails, how often to try again)
|
||||
1209600 ; Expire (when refresh fails, how long secondary nameserver will keep records around anyway)
|
||||
1800 ; Negative TTL (how long negative responses are cached)
|
||||
)
|
||||
"""
|
||||
|
||||
@@ -317,6 +359,10 @@ $TTL 86400 ; default time to live
|
||||
if subdomain:
|
||||
zone += subdomain
|
||||
zone += "\tIN\t" + querytype + "\t"
|
||||
if querytype == "TXT":
|
||||
value = value.replace('\\', '\\\\') # escape backslashes
|
||||
value = value.replace('"', '\\"') # escape quotes
|
||||
value = '"' + value + '"' # wrap in quotes
|
||||
zone += value + "\n"
|
||||
|
||||
# DNSSEC requires re-signing a zone periodically. That requires
|
||||
@@ -363,7 +409,7 @@ $TTL 86400 ; default time to live
|
||||
|
||||
# If the existing zone is the same as the new zone (modulo the serial number),
|
||||
# there is no need to update the file. Unless we're forcing a bump.
|
||||
if zone == existing_zone and not force_bump:
|
||||
if zone == existing_zone and not force_bump and not force:
|
||||
return False
|
||||
|
||||
# If the existing serial is not less than a serial number
|
||||
@@ -383,7 +429,7 @@ $TTL 86400 ; default time to live
|
||||
|
||||
########################################################################
|
||||
|
||||
def write_nsd_conf(zonefiles):
|
||||
def write_nsd_conf(zonefiles, env):
|
||||
# Basic header.
|
||||
nsdconf = """
|
||||
server:
|
||||
@@ -397,15 +443,13 @@ server:
|
||||
"""
|
||||
|
||||
# Since we have bind9 listening on localhost for locally-generated
|
||||
# DNS queries that require a recursive nameserver, we must have
|
||||
# nsd listen only on public network interfaces. Those interfaces
|
||||
# may have addresses different from the public IP address that the
|
||||
# Internet sees this machine on. Get those interface addresses
|
||||
# from `hostname -i` (which omits all localhost addresses).
|
||||
for ipaddr in shell("check_output", ["/bin/hostname", "-I"]).strip().split(" "):
|
||||
# DNS queries that require a recursive nameserver, and the system
|
||||
# might have other network interfaces for e.g. tunnelling, we have
|
||||
# to be specific about the network interfaces that nsd binds to.
|
||||
for ipaddr in (env.get("PRIVATE_IP", "") + " " + env.get("PRIVATE_IPV6", "")).split(" "):
|
||||
if ipaddr == "": continue
|
||||
nsdconf += " ip-address: %s\n" % ipaddr
|
||||
|
||||
|
||||
# Append the zones.
|
||||
for domain, zonefile in zonefiles:
|
||||
nsdconf += """
|
||||
@@ -480,13 +524,18 @@ def sign_zone(domain, zonefile, env):
|
||||
# zone being signed, so we can't use the .ds files generated when we created the keys.
|
||||
# The DS record points to the KSK only. Write this next to the zone file so we can
|
||||
# get it later to give to the user with instructions on what to do with it.
|
||||
rr_ds = shell('check_output', ["/usr/bin/ldns-key2ds",
|
||||
"-n", # output to stdout
|
||||
"-2", # SHA256
|
||||
dnssec_keys["KSK"] + ".key"
|
||||
])
|
||||
#
|
||||
# We want to be able to validate DS records too, but multiple forms may be valid depending
|
||||
# on the digest type. So we'll write all (both) valid records. Only one DS record should
|
||||
# actually be deployed. Preferebly the first.
|
||||
with open("/etc/nsd/zones/" + zonefile + ".ds", "w") as f:
|
||||
f.write(rr_ds)
|
||||
for digest_type in ('2', '1'):
|
||||
rr_ds = shell('check_output', ["/usr/bin/ldns-key2ds",
|
||||
"-n", # output to stdout
|
||||
"-" + digest_type, # 1=SHA1, 2=SHA256
|
||||
dnssec_keys["KSK"] + ".key"
|
||||
])
|
||||
f.write(rr_ds)
|
||||
|
||||
# Remove our temporary file.
|
||||
for fn in files_to_kill:
|
||||
@@ -494,46 +543,138 @@ def sign_zone(domain, zonefile, env):
|
||||
|
||||
########################################################################
|
||||
|
||||
def get_ds_records(env):
|
||||
zonefiles = get_dns_zones(env)
|
||||
ret = ""
|
||||
for domain, zonefile in zonefiles:
|
||||
fn = "/etc/nsd/zones/" + zonefile + ".ds"
|
||||
if os.path.exists(fn):
|
||||
with open(fn, "r") as fr:
|
||||
ret += fr.read().strip() + "\n"
|
||||
return ret
|
||||
|
||||
|
||||
########################################################################
|
||||
|
||||
def write_opendkim_tables(zonefiles, env):
|
||||
# Append a record to OpenDKIM's KeyTable and SigningTable for each domain.
|
||||
#
|
||||
# The SigningTable maps email addresses to signing information. The KeyTable
|
||||
# maps specify the hostname, the selector, and the path to the private key.
|
||||
#
|
||||
# DKIM ADSP and DMARC both only support policies where the signing domain matches
|
||||
# the From address, so the KeyTable must specify that the signing domain for a
|
||||
# sender matches the sender's domain.
|
||||
#
|
||||
# In SigningTable, we map every email address to a key record named after the domain.
|
||||
# Then we specify for the key record its domain, selector, and key.
|
||||
|
||||
opendkim_key_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.private')
|
||||
if not os.path.exists(opendkim_key_file): return
|
||||
|
||||
with open("/etc/opendkim/KeyTable", "w") as f:
|
||||
f.write("\n".join(
|
||||
"{domain} {domain}:mail:{key_file}".format(domain=domain, key_file=opendkim_key_file)
|
||||
for domain, zonefile in zonefiles
|
||||
))
|
||||
if not os.path.exists(opendkim_key_file):
|
||||
# Looks like OpenDKIM is not installed.
|
||||
return False
|
||||
|
||||
with open("/etc/opendkim/SigningTable", "w") as f:
|
||||
f.write("\n".join(
|
||||
"*@{domain} {domain}".format(domain=domain)
|
||||
for domain, zonefile in zonefiles
|
||||
))
|
||||
config = {
|
||||
# The SigningTable maps email addresses to a key in the KeyTable that
|
||||
# specifies signing information for matching email addresses. Here we
|
||||
# map each domain to a same-named key.
|
||||
#
|
||||
# Elsewhere we set the DMARC policy for each domain such that mail claiming
|
||||
# to be From: the domain must be signed with a DKIM key on the same domain.
|
||||
# So we must have a separate KeyTable entry for each domain.
|
||||
"SigningTable":
|
||||
"".join(
|
||||
"*@{domain} {domain}\n".format(domain=domain)
|
||||
for domain, zonefile in zonefiles
|
||||
),
|
||||
|
||||
# The KeyTable specifies the signing domain, the DKIM selector, and the
|
||||
# path to the private key to use for signing some mail. Per DMARC, the
|
||||
# signing domain must match the sender's From: domain.
|
||||
"KeyTable":
|
||||
"".join(
|
||||
"{domain} {domain}:mail:{key_file}\n".format(domain=domain, key_file=opendkim_key_file)
|
||||
for domain, zonefile in zonefiles
|
||||
),
|
||||
}
|
||||
|
||||
did_update = False
|
||||
for filename, content in config.items():
|
||||
# Don't write the file if it doesn't need an update.
|
||||
if os.path.exists("/etc/opendkim/" + filename):
|
||||
with open("/etc/opendkim/" + filename) as f:
|
||||
if f.read() == content:
|
||||
continue
|
||||
|
||||
# The contents needs to change.
|
||||
with open("/etc/opendkim/" + filename, "w") as f:
|
||||
f.write(content)
|
||||
did_update = True
|
||||
|
||||
# Return whether the files changed. If they didn't change, there's
|
||||
# no need to kick the opendkim process.
|
||||
return did_update
|
||||
|
||||
########################################################################
|
||||
|
||||
def set_custom_dns_record(qname, rtype, value, env):
|
||||
# validate qname
|
||||
for zone, fn in get_dns_zones(env):
|
||||
# It must match a zone apex or be a subdomain of a zone
|
||||
# that we are otherwise hosting.
|
||||
if qname == zone or qname.endswith("."+zone):
|
||||
break
|
||||
else:
|
||||
# No match.
|
||||
raise ValueError("%s is not a domain name or a subdomain of a domain name managed by this box." % qname)
|
||||
|
||||
# validate rtype
|
||||
rtype = rtype.upper()
|
||||
if value is not None:
|
||||
if rtype in ("A", "AAAA"):
|
||||
v = ipaddress.ip_address(value)
|
||||
if rtype == "A" and not isinstance(v, ipaddress.IPv4Address): raise ValueError("That's an IPv6 address.")
|
||||
if rtype == "AAAA" and not isinstance(v, ipaddress.IPv6Address): raise ValueError("That's an IPv4 address.")
|
||||
elif rtype in ("CNAME", "TXT"):
|
||||
# anything goes
|
||||
pass
|
||||
else:
|
||||
raise ValueError("Unknown record type '%s'." % rtype)
|
||||
|
||||
# load existing config
|
||||
config = get_custom_dns_config(env)
|
||||
|
||||
# update
|
||||
if qname not in config:
|
||||
if value is None:
|
||||
# Is asking to delete a record that does not exist.
|
||||
return False
|
||||
elif rtype == "A":
|
||||
# Add this record using the short form 'qname: value'.
|
||||
config[qname] = value
|
||||
else:
|
||||
# Add this record. This is the qname's first record.
|
||||
config[qname] = { rtype: value }
|
||||
else:
|
||||
if isinstance(config[qname], str):
|
||||
# This is a short-form 'qname: value' implicit-A record.
|
||||
if value is None and rtype != "A":
|
||||
# Is asking to delete a record that doesn't exist.
|
||||
return False
|
||||
elif value is None and rtype == "A":
|
||||
# Delete record.
|
||||
del config[qname]
|
||||
elif rtype == "A":
|
||||
# Update, keeping short form.
|
||||
if config[qname] == "value":
|
||||
# No change.
|
||||
return False
|
||||
config[qname] = value
|
||||
else:
|
||||
# Expand short form so we can add a new record type.
|
||||
config[qname] = { "A": config[qname], rtype: value }
|
||||
else:
|
||||
# This is the qname: { ... } (dict) format.
|
||||
if value is None:
|
||||
if rtype not in config[qname]:
|
||||
# Is asking to delete a record that doesn't exist.
|
||||
return False
|
||||
else:
|
||||
# Delete the record. If it's the last record, delete the domain.
|
||||
del config[qname][rtype]
|
||||
if len(config[qname]) == 0:
|
||||
del config[qname]
|
||||
else:
|
||||
# Update the record.
|
||||
if config[qname].get(rtype) == "value":
|
||||
# No change.
|
||||
return False
|
||||
config[qname][rtype] = value
|
||||
|
||||
# serialize & save
|
||||
config_yaml = rtyaml.dump(config)
|
||||
with open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml'), "w") as f:
|
||||
f.write(config_yaml)
|
||||
|
||||
return True
|
||||
|
||||
########################################################################
|
||||
|
||||
@@ -578,26 +719,43 @@ def justtestingdotemail(domain, records):
|
||||
|
||||
########################################################################
|
||||
|
||||
if __name__ == "__main__":
|
||||
from utils import load_environment
|
||||
env = load_environment()
|
||||
def build_recommended_dns(env):
|
||||
ret = []
|
||||
domains = get_dns_domains(env)
|
||||
zonefiles = get_dns_zones(env)
|
||||
additional_records = get_custom_dns_config(env)
|
||||
for domain, zonefile in zonefiles:
|
||||
records = build_zone(domain, domains, {}, env)
|
||||
records = build_zone(domain, domains, additional_records, env)
|
||||
|
||||
# remove records that we don't dislay
|
||||
records = [r for r in records if r[3] is not False]
|
||||
|
||||
# put Required at the top
|
||||
# put Required at the top, then Recommended, then everythiing else
|
||||
records.sort(key = lambda r : 0 if r[3].startswith("Required.") else (1 if r[3].startswith("Recommended.") else 2))
|
||||
|
||||
# print
|
||||
for qname, rtype, value, explanation in records:
|
||||
print("; " + explanation)
|
||||
if qname == None:
|
||||
# expand qnames
|
||||
for i in range(len(records)):
|
||||
if records[i][0] == None:
|
||||
qname = domain
|
||||
else:
|
||||
qname = qname + "." + domain
|
||||
print(qname, rtype, value)
|
||||
qname = records[i][0] + "." + domain
|
||||
|
||||
records[i] = {
|
||||
"qname": qname,
|
||||
"rtype": records[i][1],
|
||||
"value": records[i][2],
|
||||
"explanation": records[i][3],
|
||||
}
|
||||
|
||||
# return
|
||||
ret.append((domain, records))
|
||||
return ret
|
||||
|
||||
if __name__ == "__main__":
|
||||
from utils import load_environment
|
||||
env = load_environment()
|
||||
for zone, records in build_recommended_dns(env):
|
||||
for record in records:
|
||||
print("; " + record['explanation'])
|
||||
print(record['qname'], record['rtype'], record['value'], sep="\t")
|
||||
print()
|
||||
|
||||
@@ -46,15 +46,83 @@ def open_database(env, with_connection=False):
|
||||
else:
|
||||
return conn, conn.cursor()
|
||||
|
||||
def get_mail_users(env):
|
||||
def get_mail_users(env, as_json=False):
|
||||
c = open_database(env)
|
||||
c.execute('SELECT email FROM users')
|
||||
return [row[0] for row in c.fetchall()]
|
||||
c.execute('SELECT email, privileges FROM users')
|
||||
|
||||
def get_mail_aliases(env):
|
||||
# turn into a list of tuples, but sorted by domain & email address
|
||||
users = { row[0]: row[1] for row in c.fetchall() } # make dict
|
||||
users = [ (email, users[email]) for email in utils.sort_email_addresses(users.keys(), env) ]
|
||||
|
||||
if not as_json:
|
||||
return [email for email, privileges in users]
|
||||
else:
|
||||
aliases = get_mail_alias_map(env)
|
||||
return [
|
||||
{
|
||||
"email": email,
|
||||
"privileges": parse_privs(privileges),
|
||||
"status": "active",
|
||||
"aliases": [
|
||||
(alias, sorted(evaluate_mail_alias_map(alias, aliases, env)))
|
||||
for alias in aliases.get(email.lower(), [])
|
||||
]
|
||||
}
|
||||
for email, privileges in users
|
||||
]
|
||||
|
||||
def get_archived_mail_users(env):
|
||||
real_users = set(get_mail_users(env))
|
||||
root = os.path.join(env['STORAGE_ROOT'], 'mail/mailboxes')
|
||||
ret = []
|
||||
for domain_enc in os.listdir(root):
|
||||
for user_enc in os.listdir(os.path.join(root, domain_enc)):
|
||||
email = utils.unsafe_domain_name(user_enc) + "@" + utils.unsafe_domain_name(domain_enc)
|
||||
if email in real_users: continue
|
||||
ret.append({
|
||||
"email": email,
|
||||
"privileges": "",
|
||||
"status": "inactive"
|
||||
})
|
||||
return ret
|
||||
|
||||
def get_mail_aliases(env, as_json=False):
|
||||
c = open_database(env)
|
||||
c.execute('SELECT source, destination FROM aliases')
|
||||
return [(row[0], row[1]) for row in c.fetchall()]
|
||||
aliases = { row[0]: row[1] for row in c.fetchall() } # make dict
|
||||
|
||||
# put in a canonical order: sort by domain, then by email address lexicographically
|
||||
aliases = [ (source, aliases[source]) for source in utils.sort_email_addresses(aliases.keys(), env) ] # sort
|
||||
|
||||
# but put automatic aliases to administrator@ last
|
||||
aliases.sort(key = lambda x : x[1] == get_system_administrator(env))
|
||||
|
||||
if as_json:
|
||||
required_aliases = get_required_aliases(env)
|
||||
aliases = [
|
||||
{
|
||||
"source": alias[0],
|
||||
"destination": [d.strip() for d in alias[1].split(",")],
|
||||
"required": alias[0] in required_aliases or alias[0] == get_system_administrator(env),
|
||||
}
|
||||
for alias in aliases
|
||||
]
|
||||
return aliases
|
||||
|
||||
def get_mail_alias_map(env):
|
||||
aliases = { }
|
||||
for alias, targets in get_mail_aliases(env):
|
||||
for em in targets.split(","):
|
||||
em = em.strip().lower()
|
||||
aliases.setdefault(em, []).append(alias)
|
||||
return aliases
|
||||
|
||||
def evaluate_mail_alias_map(email, aliases, env):
|
||||
ret = set()
|
||||
for alias in aliases.get(email.lower(), []):
|
||||
ret.add(alias)
|
||||
ret |= evaluate_mail_alias_map(alias, aliases, env)
|
||||
return ret
|
||||
|
||||
def get_mail_domains(env, filter_aliases=lambda alias : True):
|
||||
def get_domain(emailaddr):
|
||||
@@ -64,10 +132,24 @@ def get_mail_domains(env, filter_aliases=lambda alias : True):
|
||||
+ [get_domain(source) for source, target in get_mail_aliases(env) if filter_aliases((source, target)) ]
|
||||
)
|
||||
|
||||
def add_mail_user(email, pw, env):
|
||||
def add_mail_user(email, pw, privs, env):
|
||||
# validate email
|
||||
if email.strip() == "":
|
||||
return ("No email address provided.", 400)
|
||||
if not validate_email(email, mode='user'):
|
||||
return ("Invalid email address.", 400)
|
||||
|
||||
validate_password(pw)
|
||||
|
||||
# validate privileges
|
||||
if privs is None or privs.strip() == "":
|
||||
privs = []
|
||||
else:
|
||||
privs = privs.split("\n")
|
||||
for p in privs:
|
||||
validation = validate_privilege(p)
|
||||
if validation: return validation
|
||||
|
||||
# get the database
|
||||
conn, c = open_database(env, with_connection=True)
|
||||
|
||||
@@ -76,14 +158,17 @@ def add_mail_user(email, pw, env):
|
||||
|
||||
# add the user to the database
|
||||
try:
|
||||
c.execute("INSERT INTO users (email, password) VALUES (?, ?)", (email, pw))
|
||||
c.execute("INSERT INTO users (email, password, privileges) VALUES (?, ?, ?)",
|
||||
(email, pw, "\n".join(privs)))
|
||||
except sqlite3.IntegrityError:
|
||||
return ("User already exists.", 400)
|
||||
|
||||
# write databasebefore next step
|
||||
conn.commit()
|
||||
|
||||
# Create the user's INBOX and Spam folders and subscribe them.
|
||||
# Create the user's INBOX, Spam, and Drafts folders, and subscribe them.
|
||||
# K-9 mail will poll every 90 seconds if a Drafts folder does not exist, so create it
|
||||
# to avoid unnecessary polling.
|
||||
|
||||
# Check if the mailboxes exist before creating them. When creating a user that had previously
|
||||
# been deleted, the mailboxes will still exist because they are still on disk.
|
||||
@@ -94,13 +179,16 @@ def add_mail_user(email, pw, env):
|
||||
conn.commit()
|
||||
return ("Failed to initialize the user: " + e.output.decode("utf8"), 400)
|
||||
|
||||
if "INBOX" not in existing_mboxes: utils.shell('check_call', ["doveadm", "mailbox", "create", "-u", email, "-s", "INBOX"])
|
||||
if "Spam" not in existing_mboxes: utils.shell('check_call', ["doveadm", "mailbox", "create", "-u", email, "-s", "Spam"])
|
||||
for folder in ("INBOX", "Spam", "Drafts"):
|
||||
if folder not in existing_mboxes:
|
||||
utils.shell('check_call', ["doveadm", "mailbox", "create", "-u", email, "-s", folder])
|
||||
|
||||
# Update things in case any new domains are added.
|
||||
return kick(env, "mail user added")
|
||||
|
||||
def set_mail_password(email, pw, env):
|
||||
validate_password(pw)
|
||||
|
||||
# hash the password
|
||||
pw = utils.shell('check_output', ["/usr/bin/doveadm", "pw", "-s", "SHA512-CRYPT", "-p", pw]).strip()
|
||||
|
||||
@@ -122,20 +210,85 @@ def remove_mail_user(email, env):
|
||||
# Update things in case any domains are removed.
|
||||
return kick(env, "mail user removed")
|
||||
|
||||
def add_mail_alias(source, destination, env, do_kick=True):
|
||||
def parse_privs(value):
|
||||
return [p for p in value.split("\n") if p.strip() != ""]
|
||||
|
||||
def get_mail_user_privileges(email, env):
|
||||
c = open_database(env)
|
||||
c.execute('SELECT privileges FROM users WHERE email=?', (email,))
|
||||
rows = c.fetchall()
|
||||
if len(rows) != 1:
|
||||
return ("That's not a user (%s)." % email, 400)
|
||||
return parse_privs(rows[0][0])
|
||||
|
||||
def validate_privilege(priv):
|
||||
if "\n" in priv or priv.strip() == "":
|
||||
return ("That's not a valid privilege (%s)." % priv, 400)
|
||||
return None
|
||||
|
||||
def add_remove_mail_user_privilege(email, priv, action, env):
|
||||
# validate
|
||||
validation = validate_privilege(priv)
|
||||
if validation: return validation
|
||||
|
||||
# get existing privs, but may fail
|
||||
privs = get_mail_user_privileges(email, env)
|
||||
if isinstance(privs, tuple): return privs # error
|
||||
|
||||
# update privs set
|
||||
if action == "add":
|
||||
if priv not in privs:
|
||||
privs.append(priv)
|
||||
elif action == "remove":
|
||||
privs = [p for p in privs if p != priv]
|
||||
else:
|
||||
return ("Invalid action.", 400)
|
||||
|
||||
# commit to database
|
||||
conn, c = open_database(env, with_connection=True)
|
||||
c.execute("UPDATE users SET privileges=? WHERE email=?", ("\n".join(privs), email))
|
||||
if c.rowcount != 1:
|
||||
return ("Something went wrong.", 400)
|
||||
conn.commit()
|
||||
|
||||
return "OK"
|
||||
|
||||
def add_mail_alias(source, destination, env, update_if_exists=False, do_kick=True):
|
||||
# validate source
|
||||
if source.strip() == "":
|
||||
return ("No incoming email address provided.", 400)
|
||||
if not validate_email(source, mode='alias'):
|
||||
return ("Invalid email address.", 400)
|
||||
return ("Invalid incoming email address (%s)." % source, 400)
|
||||
|
||||
# parse comma and \n-separated destination emails & validate
|
||||
dests = []
|
||||
for line in destination.split("\n"):
|
||||
for email in line.split(","):
|
||||
email = email.strip()
|
||||
if email == "": continue
|
||||
if not validate_email(email, mode='alias'):
|
||||
return ("Invalid destination email address (%s)." % email, 400)
|
||||
dests.append(email)
|
||||
if len(destination) == 0:
|
||||
return ("No destination email address(es) provided.", 400)
|
||||
destination = ",".join(dests)
|
||||
|
||||
conn, c = open_database(env, with_connection=True)
|
||||
try:
|
||||
c.execute("INSERT INTO aliases (source, destination) VALUES (?, ?)", (source, destination))
|
||||
return_status = "alias added"
|
||||
except sqlite3.IntegrityError:
|
||||
return ("Alias already exists (%s)." % source, 400)
|
||||
if not update_if_exists:
|
||||
return ("Alias already exists (%s)." % source, 400)
|
||||
else:
|
||||
c.execute("UPDATE aliases SET destination = ? WHERE source = ?", (destination, source))
|
||||
return_status = "alias updated"
|
||||
|
||||
conn.commit()
|
||||
|
||||
if do_kick:
|
||||
# Update things in case any new domains are added.
|
||||
return kick(env, "alias added")
|
||||
return kick(env, return_status)
|
||||
|
||||
def remove_mail_alias(source, env, do_kick=True):
|
||||
conn, c = open_database(env, with_connection=True)
|
||||
@@ -148,6 +301,35 @@ def remove_mail_alias(source, env, do_kick=True):
|
||||
# Update things in case any domains are removed.
|
||||
return kick(env, "alias removed")
|
||||
|
||||
def get_system_administrator(env):
|
||||
return "administrator@" + env['PRIMARY_HOSTNAME']
|
||||
|
||||
def get_required_aliases(env):
|
||||
# These are the aliases that must exist.
|
||||
aliases = set()
|
||||
|
||||
# The hostmaster alias is exposed in the DNS SOA for each zone.
|
||||
aliases.add("hostmaster@" + env['PRIMARY_HOSTNAME'])
|
||||
|
||||
# Get a list of domains we serve mail for, except ones for which the only
|
||||
# email on that domain is a postmaster/admin alias to the administrator.
|
||||
real_mail_domains = get_mail_domains(env,
|
||||
filter_aliases = lambda alias : \
|
||||
(not alias[0].startswith("postmaster@") \
|
||||
and not alias[0].startswith("admin@")) \
|
||||
or alias[1] != get_system_administrator(env) \
|
||||
)
|
||||
|
||||
# Create postmaster@ and admin@ for all domains we serve mail on.
|
||||
# postmaster@ is assumed to exist by our Postfix configuration. admin@
|
||||
# isn't anything, but it might save the user some trouble e.g. when
|
||||
# buying an SSL certificate.
|
||||
for domain in real_mail_domains:
|
||||
aliases.add("postmaster@" + domain)
|
||||
aliases.add("admin@" + domain)
|
||||
|
||||
return aliases
|
||||
|
||||
def kick(env, mail_result=None):
|
||||
results = []
|
||||
|
||||
@@ -156,50 +338,37 @@ def kick(env, mail_result=None):
|
||||
if mail_result is not None:
|
||||
results.append(mail_result + "\n")
|
||||
|
||||
# Create hostmaster@ for the primary domain if it does not already exist.
|
||||
# Default the target to administrator@ which the user is responsible for
|
||||
# setting and keeping up to date.
|
||||
# Ensure every required alias exists.
|
||||
|
||||
existing_users = get_mail_users(env)
|
||||
existing_aliases = get_mail_aliases(env)
|
||||
|
||||
administrator = "administrator@" + env['PRIMARY_HOSTNAME']
|
||||
required_aliases = get_required_aliases(env)
|
||||
|
||||
def ensure_admin_alias_exists(source):
|
||||
# If a user account exists with that address, we're good.
|
||||
if source in existing_users:
|
||||
return
|
||||
|
||||
# Does this alias exists?
|
||||
for s, t in existing_aliases:
|
||||
if s == source:
|
||||
return
|
||||
|
||||
# Doesn't exist.
|
||||
administrator = get_system_administrator(env)
|
||||
add_mail_alias(source, administrator, env, do_kick=False)
|
||||
results.append("added alias %s (=> %s)\n" % (source, administrator))
|
||||
|
||||
ensure_admin_alias_exists("hostmaster@" + env['PRIMARY_HOSTNAME'])
|
||||
|
||||
# Get a list of domains we serve mail for, except ones for which the only
|
||||
# email on that domain is a postmaster/admin alias to the administrator.
|
||||
for alias in required_aliases:
|
||||
ensure_admin_alias_exists(alias)
|
||||
|
||||
real_mail_domains = get_mail_domains(env,
|
||||
filter_aliases = lambda alias : \
|
||||
(not alias[0].startswith("postmaster@") \
|
||||
and not alias[0].startswith("admin@")) \
|
||||
or alias[1] != administrator \
|
||||
)
|
||||
|
||||
# Create postmaster@ and admin@ for all domains we serve mail on.
|
||||
# postmaster@ is assumed to exist by our Postfix configuration. admin@
|
||||
# isn't anything, but it might save the user some trouble e.g. when
|
||||
# buying an SSL certificate.
|
||||
for domain in real_mail_domains:
|
||||
ensure_admin_alias_exists("postmaster@" + domain)
|
||||
ensure_admin_alias_exists("admin@" + domain)
|
||||
|
||||
# Remove auto-generated hostmaster/postmaster/admin on domains we no
|
||||
# Remove auto-generated postmaster/admin on domains we no
|
||||
# longer have any other email addresses for.
|
||||
for source, target in existing_aliases:
|
||||
user, domain = source.split("@")
|
||||
if user in ("postmaster", "admin") and domain not in real_mail_domains \
|
||||
and target == administrator:
|
||||
if user in ("postmaster", "admin") \
|
||||
and source not in required_aliases \
|
||||
and target == get_system_administrator(env):
|
||||
remove_mail_alias(source, env, do_kick=False)
|
||||
results.append("removed alias %s (was to %s; domain no longer used for email)\n" % (source, target))
|
||||
|
||||
@@ -213,6 +382,16 @@ def kick(env, mail_result=None):
|
||||
|
||||
return "".join(s for s in results if s != "")
|
||||
|
||||
def validate_password(pw):
|
||||
# validate password
|
||||
if pw.strip() == "":
|
||||
raise ValueError("No password provided.")
|
||||
if re.search(r"[\s]", pw):
|
||||
raise ValueError("Passwords cannot contain spaces.")
|
||||
if len(pw) < 4:
|
||||
raise ValueError("Passwords must be at least four characters.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
if len(sys.argv) > 2 and sys.argv[1] == "validate-email":
|
||||
|
||||
557
management/status_checks.py
Executable file
557
management/status_checks.py
Executable file
@@ -0,0 +1,557 @@
|
||||
#!/usr/bin/python3
|
||||
#
|
||||
# Checks that the upstream DNS has been set correctly and that
|
||||
# SSL certificates have been signed, etc., and if not tells the user
|
||||
# what to do next.
|
||||
|
||||
__ALL__ = ['check_certificate']
|
||||
|
||||
import os, os.path, re, subprocess, datetime
|
||||
|
||||
import dns.reversename, dns.resolver
|
||||
import dateutil.parser, dateutil.tz
|
||||
|
||||
from dns_update import get_dns_zones, build_tlsa_record
|
||||
from web_update import get_web_domains, get_domain_ssl_files
|
||||
from mailconfig import get_mail_domains, get_mail_aliases
|
||||
|
||||
from utils import shell, sort_domains, load_env_vars_from_file
|
||||
|
||||
def run_checks(env, output):
|
||||
env["out"] = output
|
||||
run_system_checks(env)
|
||||
run_network_checks(env)
|
||||
run_domain_checks(env)
|
||||
|
||||
def run_system_checks(env):
|
||||
env["out"].add_heading("System")
|
||||
|
||||
# Check that SSH login with password is disabled.
|
||||
sshd = open("/etc/ssh/sshd_config").read()
|
||||
if re.search("\nPasswordAuthentication\s+yes", sshd) \
|
||||
or not re.search("\nPasswordAuthentication\s+no", sshd):
|
||||
env['out'].print_error("""The SSH server on this machine permits password-based login. A more secure
|
||||
way to log in is using a public key. Add your SSH public key to $HOME/.ssh/authorized_keys, check
|
||||
that you can log in without a password, set the option 'PasswordAuthentication no' in
|
||||
/etc/ssh/sshd_config, and then restart the openssh via 'sudo service ssh restart'.""")
|
||||
else:
|
||||
env['out'].print_ok("SSH disallows password-based login.")
|
||||
|
||||
# Check for any software package updates.
|
||||
pkgs = list_apt_updates(apt_update=False)
|
||||
if os.path.exists("/var/run/reboot-required"):
|
||||
env['out'].print_error("System updates have been installed and a reboot of the machine is required.")
|
||||
elif len(pkgs) == 0:
|
||||
env['out'].print_ok("System software is up to date.")
|
||||
else:
|
||||
env['out'].print_error("There are %d software packages that can be updated." % len(pkgs))
|
||||
for p in pkgs:
|
||||
env['out'].print_line("%s (%s)" % (p["package"], p["version"]))
|
||||
|
||||
# Check that the administrator alias exists since that's where all
|
||||
# admin email is automatically directed.
|
||||
check_alias_exists("administrator@" + env['PRIMARY_HOSTNAME'], env)
|
||||
|
||||
def run_network_checks(env):
|
||||
# Also see setup/network-checks.sh.
|
||||
|
||||
env["out"].add_heading("Network")
|
||||
|
||||
# Stop if we cannot make an outbound connection on port 25. Many residential
|
||||
# networks block outbound port 25 to prevent their network from sending spam.
|
||||
# See if we can reach one of Google's MTAs with a 5-second timeout.
|
||||
code, ret = shell("check_call", ["/bin/nc", "-z", "-w5", "aspmx.l.google.com", "25"], trap=True)
|
||||
if ret == 0:
|
||||
env['out'].print_ok("Outbound mail (SMTP port 25) is not blocked.")
|
||||
else:
|
||||
env['out'].print_error("""Outbound mail (SMTP port 25) seems to be blocked by your network. You
|
||||
will not be able to send any mail. Many residential networks block port 25 to prevent hijacked
|
||||
machines from being able to send spam. A quick connection test to Google's mail server on port 25
|
||||
failed.""")
|
||||
|
||||
# Stop if the IPv4 address is listed in the ZEN Spamhaus Block List.
|
||||
# The user might have ended up on an IP address that was previously in use
|
||||
# by a spammer, or the user may be deploying on a residential network. We
|
||||
# will not be able to reliably send mail in these cases.
|
||||
rev_ip4 = ".".join(reversed(env['PUBLIC_IP'].split('.')))
|
||||
zen = query_dns(rev_ip4+'.zen.spamhaus.org', 'A', nxdomain=None)
|
||||
if zen is None:
|
||||
env['out'].print_ok("IP address is not blacklisted by zen.spamhaus.org.")
|
||||
else:
|
||||
env['out'].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."""
|
||||
% (env['PUBLIC_IP'], zen, env['PUBLIC_IP']))
|
||||
|
||||
def run_domain_checks(env):
|
||||
# Get the list of domains we handle mail for.
|
||||
mail_domains = get_mail_domains(env)
|
||||
|
||||
# Get the list of domains we serve DNS zones for (i.e. does not include subdomains).
|
||||
dns_zonefiles = dict(get_dns_zones(env))
|
||||
dns_domains = set(dns_zonefiles)
|
||||
|
||||
# Get the list of domains we serve HTTPS for.
|
||||
web_domains = set(get_web_domains(env))
|
||||
|
||||
# Check the domains.
|
||||
for domain in sort_domains(mail_domains | dns_domains | web_domains, env):
|
||||
env["out"].add_heading(domain)
|
||||
|
||||
if domain == env["PRIMARY_HOSTNAME"]:
|
||||
check_primary_hostname_dns(domain, env)
|
||||
|
||||
if domain in dns_domains:
|
||||
check_dns_zone(domain, env, dns_zonefiles)
|
||||
|
||||
if domain in mail_domains:
|
||||
check_mail_domain(domain, env)
|
||||
|
||||
if domain in web_domains:
|
||||
check_web_domain(domain, env)
|
||||
|
||||
def check_primary_hostname_dns(domain, env):
|
||||
# Check that the ns1/ns2 hostnames resolve to A records. This information probably
|
||||
# comes from the TLD since the information is set at the registrar.
|
||||
ip = query_dns("ns1." + domain, "A") + '/' + query_dns("ns2." + domain, "A")
|
||||
if ip == env['PUBLIC_IP'] + '/' + env['PUBLIC_IP']:
|
||||
env['out'].print_ok("Nameserver glue records are correct at registrar. [ns1/ns2.%s => %s]" % (env['PRIMARY_HOSTNAME'], env['PUBLIC_IP']))
|
||||
else:
|
||||
env['out'].print_error("""Nameserver glue records are incorrect. The ns1.%s and ns2.%s nameservers must be configured at your domain name
|
||||
registrar as having the IP address %s. They currently report addresses of %s. It may take several hours for
|
||||
public DNS to update after a change."""
|
||||
% (env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'], ip))
|
||||
|
||||
# Check that PRIMARY_HOSTNAME resolves to PUBLIC_IP in public DNS.
|
||||
ip = query_dns(domain, "A")
|
||||
if ip == env['PUBLIC_IP']:
|
||||
env['out'].print_ok("Domain resolves to box's IP address. [%s => %s]" % (env['PRIMARY_HOSTNAME'], env['PUBLIC_IP']))
|
||||
else:
|
||||
env['out'].print_error("""This domain must resolve to your box's IP address (%s) in public DNS but it currently resolves
|
||||
to %s. It may take several hours for public DNS to update after a change. This problem may result from other
|
||||
issues listed here."""
|
||||
% (env['PUBLIC_IP'], ip))
|
||||
|
||||
# Check reverse DNS on the PRIMARY_HOSTNAME. Note that it might not be
|
||||
# a DNS zone if it is a subdomain of another domain we have a zone for.
|
||||
ipaddr_rev = dns.reversename.from_address(env['PUBLIC_IP'])
|
||||
existing_rdns = query_dns(ipaddr_rev, "PTR")
|
||||
if existing_rdns == domain:
|
||||
env['out'].print_ok("Reverse DNS is set correctly at ISP. [%s => %s]" % (env['PUBLIC_IP'], env['PRIMARY_HOSTNAME']))
|
||||
else:
|
||||
env['out'].print_error("""Your box's reverse DNS is currently %s, but it should be %s. Your ISP or cloud provider will have instructions
|
||||
on setting up reverse DNS for your box at %s.""" % (existing_rdns, domain, env['PUBLIC_IP']) )
|
||||
|
||||
# Check the TLSA record.
|
||||
tlsa_qname = "_25._tcp." + domain
|
||||
tlsa25 = query_dns(tlsa_qname, "TLSA", nxdomain=None)
|
||||
tlsa25_expected = build_tlsa_record(env)
|
||||
if tlsa25 == tlsa25_expected:
|
||||
env['out'].print_ok("""The DANE TLSA record for incoming mail is correct (%s).""" % tlsa_qname,)
|
||||
elif tlsa25 is None:
|
||||
env['out'].print_error("""The DANE TLSA record for incoming mail is not set. This is optional.""")
|
||||
else:
|
||||
env['out'].print_error("""The DANE TLSA record for incoming mail (%s) is not correct. It is '%s' but it should be '%s'. Try running tools/dns_update to
|
||||
regenerate the record. It may take several hours for
|
||||
public DNS to update after a change."""
|
||||
% (tlsa_qname, tlsa25, tlsa25_expected))
|
||||
|
||||
# Check that the hostmaster@ email address exists.
|
||||
check_alias_exists("hostmaster@" + domain, env)
|
||||
|
||||
def check_alias_exists(alias, env):
|
||||
mail_alises = dict(get_mail_aliases(env))
|
||||
if alias in mail_alises:
|
||||
env['out'].print_ok("%s exists as a mail alias [=> %s]" % (alias, mail_alises[alias]))
|
||||
else:
|
||||
env['out'].print_error("""You must add a mail alias for %s and direct email to you or another administrator.""" % alias)
|
||||
|
||||
def check_dns_zone(domain, env, dns_zonefiles):
|
||||
# We provide a DNS zone for the domain. It should have NS records set up
|
||||
# at the domain name's registrar pointing to this box.
|
||||
existing_ns = query_dns(domain, "NS")
|
||||
correct_ns = "ns1.BOX; ns2.BOX".replace("BOX", env['PRIMARY_HOSTNAME'])
|
||||
if existing_ns.lower() == correct_ns.lower():
|
||||
env['out'].print_ok("Nameservers are set correctly at registrar. [%s]" % correct_ns)
|
||||
else:
|
||||
env['out'].print_error("""The nameservers set on this domain are incorrect. They are currently %s. Use your domain name registar's
|
||||
control panel to set the nameservers to %s."""
|
||||
% (existing_ns, correct_ns) )
|
||||
|
||||
# See if the domain has a DS record set at the registrar. The DS record may have
|
||||
# several forms. We have to be prepared to check for any valid record. We've
|
||||
# pre-generated all of the valid digests --- read them in.
|
||||
ds_correct = open('/etc/nsd/zones/' + dns_zonefiles[domain] + '.ds').read().strip().split("\n")
|
||||
digests = { }
|
||||
for rr_ds in ds_correct:
|
||||
ds_keytag, ds_alg, ds_digalg, ds_digest = rr_ds.split("\t")[4].split(" ")
|
||||
digests[ds_digalg] = ds_digest
|
||||
|
||||
# Some registrars may want the public key so they can compute the digest. The DS
|
||||
# record that we suggest using is for the KSK (and that's how the DS records were generated).
|
||||
dnssec_keys = load_env_vars_from_file(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/keys.conf'))
|
||||
dnsssec_pubkey = open(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/' + dnssec_keys['KSK'] + '.key')).read().split("\t")[3].split(" ")[3]
|
||||
|
||||
# Query public DNS for the DS record at the registrar.
|
||||
ds = query_dns(domain, "DS", nxdomain=None)
|
||||
ds_looks_valid = ds and len(ds.split(" ")) == 4
|
||||
if ds_looks_valid: ds = ds.split(" ")
|
||||
if ds_looks_valid and ds[0] == ds_keytag and ds[1] == '7' and ds[3] == digests.get(ds[2]):
|
||||
env['out'].print_ok("DNS 'DS' record is set correctly at registrar.")
|
||||
else:
|
||||
if ds == None:
|
||||
env['out'].print_error("""This domain's DNS DS record is not set. The DS record is optional. The DS record activates DNSSEC.
|
||||
To set a DS record, you must follow the instructions provided by your domain name registrar and provide to them this information:""")
|
||||
else:
|
||||
env['out'].print_error("""This domain's DNS DS record is incorrect. The chain of trust is broken between the public DNS system
|
||||
and this machine's DNS server. It may take several hours for public DNS to update after a change. If you did not recently
|
||||
make a change, you must resolve this immediately by following the instructions provided by your domain name registrar and
|
||||
provide to them this information:""")
|
||||
env['out'].print_line("")
|
||||
env['out'].print_line("Key Tag: " + ds_keytag + ("" if not ds_looks_valid or ds[0] == ds_keytag else " (Got '%s')" % ds[0]))
|
||||
env['out'].print_line("Key Flags: KSK")
|
||||
env['out'].print_line("Algorithm: 7 / RSASHA1-NSEC3-SHA1" + ("" if not ds_looks_valid or ds[1] == '7' else " (Got '%s')" % ds[1]))
|
||||
# see http://www.iana.org/assignments/dns-sec-alg-numbers/dns-sec-alg-numbers.xhtml
|
||||
env['out'].print_line("Digest Type: 2 / SHA-256")
|
||||
# http://www.ietf.org/assignments/ds-rr-types/ds-rr-types.xml
|
||||
env['out'].print_line("Digest: " + digests['2'])
|
||||
if ds_looks_valid and ds[3] != digests.get(ds[2]):
|
||||
env['out'].print_line("(Got digest type %s and digest %s which do not match.)" % (ds[2], ds[3]))
|
||||
env['out'].print_line("Public Key: ")
|
||||
env['out'].print_line(dnsssec_pubkey, monospace=True)
|
||||
env['out'].print_line("")
|
||||
env['out'].print_line("Bulk/Record Format:")
|
||||
env['out'].print_line("" + ds_correct[0])
|
||||
env['out'].print_line("")
|
||||
|
||||
def check_mail_domain(domain, env):
|
||||
# Check the MX record.
|
||||
|
||||
mx = query_dns(domain, "MX", nxdomain=None)
|
||||
expected_mx = "10 " + env['PRIMARY_HOSTNAME']
|
||||
|
||||
if mx == expected_mx:
|
||||
env['out'].print_ok("Domain's email is directed to this domain. [%s => %s]" % (domain, mx))
|
||||
|
||||
elif mx == None:
|
||||
# A missing MX record is okay on the primary hostname because
|
||||
# the primary hostname's A record (the MX fallback) is... itself,
|
||||
# which is what we want the MX to be.
|
||||
if domain == env['PRIMARY_HOSTNAME']:
|
||||
env['out'].print_ok("Domain's email is directed to this domain. [%s has no MX record, which is ok]" % (domain,))
|
||||
|
||||
# And a missing MX record is okay on other domains if the A record
|
||||
# matches the A record of the PRIMARY_HOSTNAME. Actually this will
|
||||
# probably confuse DANE TLSA, but we'll let that slide for now.
|
||||
else:
|
||||
domain_a = query_dns(domain, "A", nxdomain=None)
|
||||
primary_a = query_dns(env['PRIMARY_HOSTNAME'], "A", nxdomain=None)
|
||||
if domain_a != None and domain_a == primary_a:
|
||||
env['out'].print_ok("Domain's email is directed to this domain. [%s has no MX record but its A record is OK]" % (domain,))
|
||||
else:
|
||||
env['out'].print_error("""This domain's DNS MX record is not set. It 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 other issues listed here.""" % (expected_mx,))
|
||||
|
||||
else:
|
||||
env['out'].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
|
||||
other issues listed here.""" % (mx, expected_mx))
|
||||
|
||||
# Check that the postmaster@ email address exists.
|
||||
check_alias_exists("postmaster@" + domain, env)
|
||||
|
||||
# Stop if the domain is listed in the Spamhaus Domain Block List.
|
||||
# The user might have chosen a domain that was previously in use by a spammer
|
||||
# and will not be able to reliably send mail.
|
||||
dbl = query_dns(domain+'.dbl.spamhaus.org', "A", nxdomain=None)
|
||||
if dbl is None:
|
||||
env['out'].print_ok("Domain is not blacklisted by dbl.spamhaus.org.")
|
||||
else:
|
||||
env['out'].print_error("""This domain is listed in the Spamhaus Domain Block List (code %s),
|
||||
which may prevent recipients from receiving your mail.
|
||||
See http://www.spamhaus.org/dbl/ and http://www.spamhaus.org/query/domain/%s.""" % (dbl, domain))
|
||||
|
||||
def check_web_domain(domain, env):
|
||||
# See if the domain's A record resolves to our PUBLIC_IP. This is already checked
|
||||
# for PRIMARY_HOSTNAME, for which it is required for mail specifically. For it and
|
||||
# other domains, it is required to access its website.
|
||||
if domain != env['PRIMARY_HOSTNAME']:
|
||||
ip = query_dns(domain, "A")
|
||||
if ip == env['PUBLIC_IP']:
|
||||
env['out'].print_ok("Domain resolves to this box's IP address. [%s => %s]" % (domain, env['PUBLIC_IP']))
|
||||
else:
|
||||
env['out'].print_error("""This domain should resolve to your box's IP address (%s) if you would like the box to serve
|
||||
webmail or a website on this domain. The domain currently resolves to %s in public DNS. It may take several hours for
|
||||
public DNS to update after a change. This problem may result from other issues listed here.""" % (env['PUBLIC_IP'], ip))
|
||||
|
||||
# We need a SSL certificate for PRIMARY_HOSTNAME because that's where the
|
||||
# user will log in with IMAP or webmail. Any other domain we serve a
|
||||
# website for also needs a signed certificate.
|
||||
check_ssl_cert(domain, env)
|
||||
|
||||
def query_dns(qname, rtype, nxdomain='[Not Set]'):
|
||||
resolver = dns.resolver.get_default_resolver()
|
||||
try:
|
||||
response = dns.resolver.query(qname, rtype)
|
||||
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
||||
# Host did not have an answer for this query; not sure what the
|
||||
# difference is between the two exceptions.
|
||||
return nxdomain
|
||||
|
||||
# There may be multiple answers; concatenate the response. Remove trailing
|
||||
# periods from responses since that's how qnames are encoded in DNS but is
|
||||
# confusing for us. The order of the answers doesn't matter, so sort so we
|
||||
# can compare to a well known order.
|
||||
return "; ".join(sorted(str(r).rstrip('.') for r in response))
|
||||
|
||||
def check_ssl_cert(domain, env):
|
||||
# Check that SSL certificate is signed.
|
||||
|
||||
# Skip the check if the A record is not pointed here.
|
||||
if query_dns(domain, "A", None) not in (env['PUBLIC_IP'], None): return
|
||||
|
||||
# Where is the SSL stored?
|
||||
ssl_key, ssl_certificate, ssl_csr_path = get_domain_ssl_files(domain, env)
|
||||
|
||||
if not os.path.exists(ssl_certificate):
|
||||
env['out'].print_error("The SSL certificate file for this domain is missing.")
|
||||
return
|
||||
|
||||
# Check that the certificate is good.
|
||||
|
||||
cert_status = check_certificate(domain, ssl_certificate, ssl_key)
|
||||
|
||||
if cert_status == "SELF-SIGNED":
|
||||
fingerprint = shell('check_output', [
|
||||
"openssl",
|
||||
"x509",
|
||||
"-in", ssl_certificate,
|
||||
"-noout",
|
||||
"-fingerprint"
|
||||
])
|
||||
fingerprint = re.sub(".*Fingerprint=", "", fingerprint).strip()
|
||||
|
||||
if domain == env['PRIMARY_HOSTNAME']:
|
||||
env['out'].print_error("""The SSL certificate for this domain is currently self-signed. You will get a security
|
||||
warning when you check or send email and when visiting this domain in a web browser (for webmail or
|
||||
static site hosting). You may choose to confirm the security exception, but check that the certificate
|
||||
fingerprint matches the following:""")
|
||||
env['out'].print_line("")
|
||||
env['out'].print_line(" " + fingerprint, monospace=True)
|
||||
else:
|
||||
env['out'].print_error("""The SSL certificate for this domain is currently self-signed. Visitors to a website on
|
||||
this domain will get a security warning. If you are not serving a website on this domain, then it is
|
||||
safe to leave the self-signed certificate in place.""")
|
||||
env['out'].print_line("")
|
||||
env['out'].print_line("""You can purchase a signed certificate from many places. You will need to provide this Certificate Signing Request (CSR)
|
||||
to whoever you purchase the SSL certificate from:""")
|
||||
env['out'].print_line("")
|
||||
env['out'].print_line(open(ssl_csr_path).read().strip(), monospace=True)
|
||||
env['out'].print_line("")
|
||||
env['out'].print_line("""When you purchase an SSL certificate you will receive a certificate in PEM format and possibly a file containing intermediate certificates in PEM format.
|
||||
If you receive intermediate certificates, use a text editor and paste your certificate on top and then the intermediate certificates
|
||||
below it. Save the file and place it onto this machine at %s. Then run "service nginx restart".""" % ssl_certificate)
|
||||
|
||||
elif cert_status == "OK":
|
||||
env['out'].print_ok("SSL certificate is signed & valid.")
|
||||
|
||||
else:
|
||||
env['out'].print_error("The SSL certificate has a problem:")
|
||||
env['out'].print_line("")
|
||||
env['out'].print_line(cert_status)
|
||||
env['out'].print_line("")
|
||||
|
||||
def check_certificate(domain, ssl_certificate, ssl_private_key):
|
||||
# Use openssl verify to check the status of a certificate.
|
||||
|
||||
# First check that the certificate is for the right domain. The domain
|
||||
# must be found in the Subject Common Name (CN) or be one of the
|
||||
# Subject Alternative Names. A wildcard might also appear as the CN
|
||||
# or in the SAN list, so check for that tool.
|
||||
cert_dump = shell('check_output', [
|
||||
"openssl", "x509",
|
||||
"-in", ssl_certificate,
|
||||
"-noout", "-text", "-nameopt", "rfc2253",
|
||||
])
|
||||
cert_dump = cert_dump.split("\n")
|
||||
certificate_names = set()
|
||||
cert_expiration_date = None
|
||||
while len(cert_dump) > 0:
|
||||
line = cert_dump.pop(0)
|
||||
|
||||
# Grab from the Subject Common Name. We include the indentation
|
||||
# at the start of the line in case maybe the cert includes the
|
||||
# common name of some other referenced entity (which would be
|
||||
# indented, I hope).
|
||||
m = re.match(" Subject: CN=([^,]+)", line)
|
||||
if m:
|
||||
certificate_names.add(m.group(1))
|
||||
|
||||
# Grab from the Subject Alternative Name, which is a comma-delim
|
||||
# list of names, like DNS:mydomain.com, DNS:otherdomain.com.
|
||||
m = re.match(" X509v3 Subject Alternative Name:", line)
|
||||
if m:
|
||||
names = re.split(",\s*", cert_dump.pop(0).strip())
|
||||
for n in names:
|
||||
m = re.match("DNS:(.*)", n)
|
||||
if m:
|
||||
certificate_names.add(m.group(1))
|
||||
|
||||
m = re.match(" Not After : (.*)", line)
|
||||
if m:
|
||||
cert_expiration_date = dateutil.parser.parse(m.group(1))
|
||||
|
||||
wildcard_domain = re.sub("^[^\.]+", "*", domain)
|
||||
if domain is not None and domain not in certificate_names and wildcard_domain not in certificate_names:
|
||||
return "This certificate is for the wrong domain names. It is for %s." % \
|
||||
", ".join(sorted(certificate_names))
|
||||
|
||||
# Second, check that the certificate matches the private key. Get the modulus of the
|
||||
# private key and of the public key in the certificate. They should match. The output
|
||||
# of each command looks like "Modulus=XXXXX".
|
||||
if ssl_private_key is not None:
|
||||
private_key_modulus = shell('check_output', [
|
||||
"openssl", "rsa",
|
||||
"-inform", "PEM",
|
||||
"-noout", "-modulus",
|
||||
"-in", ssl_private_key])
|
||||
cert_key_modulus = shell('check_output', [
|
||||
"openssl", "x509",
|
||||
"-in", ssl_certificate,
|
||||
"-noout", "-modulus"])
|
||||
if private_key_modulus != cert_key_modulus:
|
||||
return "The certificate installed at %s does not correspond to the private key at %s." % (ssl_certificate, ssl_private_key)
|
||||
|
||||
# Next validate that the certificate is valid. This checks whether the certificate
|
||||
# is self-signed, that the chain of trust makes sense, that it is signed by a CA
|
||||
# that Ubuntu has installed on this machine's list of CAs, and I think that it hasn't
|
||||
# expired.
|
||||
|
||||
# In order to verify with openssl, we need to split out any
|
||||
# intermediary certificates in the chain (if any) from our
|
||||
# certificate (at the top). They need to be passed separately.
|
||||
|
||||
cert = open(ssl_certificate).read()
|
||||
m = re.match(r'(-*BEGIN CERTIFICATE-*.*?-*END CERTIFICATE-*)(.*)', cert, re.S)
|
||||
if m == None:
|
||||
return "The certificate file is an invalid PEM certificate."
|
||||
mycert, chaincerts = m.groups()
|
||||
|
||||
# This command returns a non-zero exit status in most cases, so trap errors.
|
||||
|
||||
retcode, verifyoutput = shell('check_output', [
|
||||
"openssl",
|
||||
"verify", "-verbose",
|
||||
"-purpose", "sslserver", "-policy_check",]
|
||||
+ ([] if chaincerts.strip() == "" else ["-untrusted", "/dev/stdin"])
|
||||
+ [ssl_certificate],
|
||||
input=chaincerts.encode('ascii'),
|
||||
trap=True)
|
||||
|
||||
if "self signed" in verifyoutput:
|
||||
# Certificate is self-signed.
|
||||
return "SELF-SIGNED"
|
||||
elif retcode != 0:
|
||||
# There is some unknown problem. Return the `openssl verify` raw output.
|
||||
return verifyoutput.strip()
|
||||
else:
|
||||
# `openssl verify` returned a zero exit status so the cert is currently
|
||||
# good.
|
||||
|
||||
# But is it expiring soon?
|
||||
now = datetime.datetime.now(dateutil.tz.tzlocal())
|
||||
ndays = (cert_expiration_date-now).days
|
||||
if ndays <= 31:
|
||||
return "This certificate expires in %d days on %s." % (ndays, cert_expiration_date.strftime("%x"))
|
||||
|
||||
# Return the special OK code.
|
||||
return "OK"
|
||||
|
||||
_apt_updates = None
|
||||
def list_apt_updates(apt_update=True):
|
||||
# See if we have this information cached recently.
|
||||
# Keep the information for 8 hours.
|
||||
global _apt_updates
|
||||
if _apt_updates is not None and _apt_updates[0] > datetime.datetime.now() - datetime.timedelta(hours=8):
|
||||
return _apt_updates[1]
|
||||
|
||||
# Run apt-get update to refresh package list. This should be running daily
|
||||
# anyway, so on the status checks page don't do this because it is slow.
|
||||
if apt_update:
|
||||
shell("check_call", ["/usr/bin/apt-get", "-qq", "update"])
|
||||
|
||||
# Run apt-get upgrade in simulate mode to get a list of what
|
||||
# it would do.
|
||||
simulated_install = shell("check_output", ["/usr/bin/apt-get", "-qq", "-s", "upgrade"])
|
||||
pkgs = []
|
||||
for line in simulated_install.split('\n'):
|
||||
if line.strip() == "":
|
||||
continue
|
||||
if re.match(r'^Conf .*', line):
|
||||
# remove these lines, not informative
|
||||
continue
|
||||
m = re.match(r'^Inst (.*) \[(.*)\] \((\S*)', line)
|
||||
if m:
|
||||
pkgs.append({ "package": m.group(1), "version": m.group(3), "current_version": m.group(2) })
|
||||
else:
|
||||
pkgs.append({ "package": "[" + line + "]", "version": "", "current_version": "" })
|
||||
|
||||
# Cache for future requests.
|
||||
_apt_updates = (datetime.datetime.now(), pkgs)
|
||||
|
||||
return pkgs
|
||||
|
||||
|
||||
try:
|
||||
terminal_columns = int(shell('check_output', ['stty', 'size']).split()[1])
|
||||
except:
|
||||
terminal_columns = 76
|
||||
class ConsoleOutput:
|
||||
def add_heading(self, heading):
|
||||
print()
|
||||
print(heading)
|
||||
print("=" * len(heading))
|
||||
|
||||
def print_ok(self, message):
|
||||
self.print_block(message, first_line="✓ ")
|
||||
|
||||
def print_error(self, message):
|
||||
self.print_block(message, first_line="✖ ")
|
||||
|
||||
def print_block(self, message, first_line=" "):
|
||||
print(first_line, end='')
|
||||
message = re.sub("\n\s*", " ", message)
|
||||
words = re.split("(\s+)", message)
|
||||
linelen = 0
|
||||
for w in words:
|
||||
if linelen + len(w) > terminal_columns-1-len(first_line):
|
||||
print()
|
||||
print(" ", end="")
|
||||
linelen = 0
|
||||
if linelen == 0 and w.strip() == "": continue
|
||||
print(w, end="")
|
||||
linelen += len(w)
|
||||
print()
|
||||
|
||||
def print_line(self, message, monospace=False):
|
||||
for line in message.split("\n"):
|
||||
self.print_block(line)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
from utils import load_environment
|
||||
env = load_environment()
|
||||
if len(sys.argv) == 1:
|
||||
run_checks(env, ConsoleOutput())
|
||||
elif sys.argv[1] == "--check-primary-hostname":
|
||||
# See if the primary hostname appears resolvable and has a signed certificate.
|
||||
domain = env['PRIMARY_HOSTNAME']
|
||||
if query_dns(domain, "A") != env['PUBLIC_IP']:
|
||||
sys.exit(1)
|
||||
ssl_key, ssl_certificate, ssl_csr_path = get_domain_ssl_files(domain, env)
|
||||
if not os.path.exists(ssl_certificate):
|
||||
sys.exit(1)
|
||||
cert_status = check_certificate(domain, ssl_certificate, ssl_key)
|
||||
if cert_status != "OK":
|
||||
sys.exit(1)
|
||||
sys.exit(0)
|
||||
159
management/templates/aliases.html
Normal file
159
management/templates/aliases.html
Normal file
@@ -0,0 +1,159 @@
|
||||
<style>
|
||||
#alias_table .actions > * { padding-right: 3px; }
|
||||
#alias_table .alias-required .remove { display: none }
|
||||
</style>
|
||||
|
||||
<h2>Aliases</h2>
|
||||
|
||||
<h3>Add a mail alias</h3>
|
||||
|
||||
<p>Aliases are email forwarders. An alias can forward email to a <a href="javascript:show_panel('users')">mail user</a> or to any email address.</p>
|
||||
|
||||
<form class="form-horizontal" role="form" onsubmit="do_add_alias(); return false;">
|
||||
<div class="form-group">
|
||||
<label for="addaliasEmail" class="col-sm-2 control-label">Email Address</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" id="addaliasEmail" placeholder="Incoming Email Address">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="addaliasTargets" class="col-sm-2 control-label">Forward To</label>
|
||||
<div class="col-sm-10">
|
||||
<textarea class="form-control" rows="3" id="addaliasTargets" placeholder="Forward to these email addresses (one per line or separated by commas)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<button id="add-alias-button" type="submit" class="btn btn-primary">Add</button>
|
||||
<button id="alias-cancel" class="btn btn-default hidden" onclick="aliases_reset_form(); return false;">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<h3>Existing mail aliases</h3>
|
||||
<table id="alias_table" class="table" style="width: auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Email Address<br></th>
|
||||
<th>Forwards To</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p style="margin-top: 1.5em"><small>Hostmaster@, postmaster@, and admin@ email addresses are required on some domains.</small></p>
|
||||
|
||||
<div style="display: none">
|
||||
<table>
|
||||
<tr id="alias-template">
|
||||
<td class='actions'>
|
||||
<a href="#" onclick="aliases_edit(this); return false;" class='edit' title="Edit Alias">
|
||||
<span class="glyphicon glyphicon-pencil"></span>
|
||||
</a>
|
||||
<a href="#" onclick="aliases_remove(this); return false;" class='remove' title="Remove Alias">
|
||||
<span class="glyphicon glyphicon-trash"></span>
|
||||
</a>
|
||||
</td>
|
||||
<td class='email'> </td>
|
||||
<td class='target'> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
function show_aliases() {
|
||||
$('#alias_table tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
||||
api(
|
||||
"/mail/aliases",
|
||||
"GET",
|
||||
{ format: 'json' },
|
||||
function(r) {
|
||||
$('#alias_table tbody').html("");
|
||||
for (var i = 0; i < r.length; i++) {
|
||||
var n = $("#alias-template").clone();
|
||||
n.attr('id', '');
|
||||
|
||||
if (r[i].required) n.addClass('alias-required');
|
||||
n.attr('data-email', r[i].source);
|
||||
n.find('td.email').text(r[i].source)
|
||||
for (var j = 0; j < r[i].destination.length; j++)
|
||||
n.find('td.target').append($("<div></div>").text(r[i].destination[j]))
|
||||
$('#alias_table tbody').append(n);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var is_alias_add_update = false;
|
||||
function do_add_alias() {
|
||||
var title = (!is_alias_add_update) ? "Add Alias" : "Update Alias";
|
||||
var email = $("#addaliasEmail").val();
|
||||
var targets = $("#addaliasTargets").val();
|
||||
api(
|
||||
"/mail/aliases/add",
|
||||
"POST",
|
||||
{
|
||||
update_if_exists: is_alias_add_update ? '1' : '0',
|
||||
source: email,
|
||||
destination: targets
|
||||
},
|
||||
function(r) {
|
||||
// Responses are multiple lines of pre-formatted text.
|
||||
show_modal_error(title, $("<pre/>").text(r));
|
||||
show_aliases()
|
||||
aliases_reset_form();
|
||||
},
|
||||
function(r) {
|
||||
show_modal_error(title, r);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
function aliases_reset_form() {
|
||||
$("#addaliasEmail").prop('disabled', false);
|
||||
$("#addaliasEmail").val('')
|
||||
$("#addaliasTargets").val('')
|
||||
$('#alias-cancel').addClass('hidden');
|
||||
$('#add-alias-button').text('Add');
|
||||
is_alias_add_update = false;
|
||||
}
|
||||
|
||||
function aliases_edit(elem) {
|
||||
var email = $(elem).parents('tr').attr('data-email');
|
||||
var targetdivs = $(elem).parents('tr').find('.target div');
|
||||
var targets = "";
|
||||
for (var i = 0; i < targetdivs.length; i++)
|
||||
targets += $(targetdivs[i]).text() + "\n";
|
||||
|
||||
is_alias_add_update = true;
|
||||
$('#alias-cancel').removeClass('hidden');
|
||||
$("#addaliasEmail").prop('disabled', true);
|
||||
$("#addaliasEmail").val(email);
|
||||
$("#addaliasTargets").val(targets);
|
||||
$('#add-alias-button').text('Update');
|
||||
$('body').animate({ scrollTop: 0 })
|
||||
}
|
||||
|
||||
function aliases_remove(elem) {
|
||||
var email = $(elem).parents('tr').attr('data-email');
|
||||
show_modal_confirm(
|
||||
"Remove Alias",
|
||||
"Remove " + email + "?",
|
||||
"Remove",
|
||||
function() {
|
||||
api(
|
||||
"/mail/aliases/remove",
|
||||
"POST",
|
||||
{
|
||||
source: email
|
||||
},
|
||||
function(r) {
|
||||
// Responses are multiple lines of pre-formatted text.
|
||||
show_modal_error("Remove User", $("<pre/>").text(r));
|
||||
show_aliases();
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -1,11 +1,359 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Mail-in-a-Box Management Server</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Mail-in-a-Box Management Server</h1>
|
||||
<!DOCTYPE html>
|
||||
<!--[if lt IE 7]> <html class="no-js lt-ie9 lt-ie8 lt-ie7"> <![endif]-->
|
||||
<!--[if IE 7]> <html class="no-js lt-ie9 lt-ie8"> <![endif]-->
|
||||
<!--[if IE 8]> <html class="no-js lt-ie9"> <![endif]-->
|
||||
<!--[if gt IE 8]><!--> <html class="no-js"> <!--<![endif]-->
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
|
||||
<p>Use this server to issue commands to the Mail-in-a-Box management daemon.</p>
|
||||
</body>
|
||||
</html>
|
||||
<title>{{hostname}} - Mail-in-a-Box Control Panel</title>
|
||||
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
|
||||
<link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css">
|
||||
<style>
|
||||
@import url(https://fonts.googleapis.com/css?family=Raleway:400,700);
|
||||
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300);
|
||||
|
||||
html {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
body {
|
||||
padding-top: 50px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
font-family: Raleway, sans-serif;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 130%;
|
||||
border-bottom: 1px solid black;
|
||||
padding-bottom: 3px;
|
||||
margin-bottom: 13px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 110%;
|
||||
margin-bottom: 13px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
table.table {
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap-theme.min.css">
|
||||
<style>
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!--[if lt IE 7]>
|
||||
<p class="chromeframe">You are using an <strong>outdated</strong> browser. Please <a href="http://browsehappy.com/">upgrade your browser</a> or <a href="http://www.google.com/chromeframe/?redirect=true">activate Google Chrome Frame</a> to improve your experience.</p>
|
||||
<![endif]-->
|
||||
<div class="navbar navbar-inverse navbar-fixed-top">
|
||||
<div class="container">
|
||||
<div class="navbar-header">
|
||||
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
</button>
|
||||
<a class="navbar-brand" href="#">{{hostname}}</a>
|
||||
</div>
|
||||
<div class="navbar-collapse collapse">
|
||||
<ul class="nav navbar-nav">
|
||||
<li class="dropdown active">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown">System <b class="caret"></b></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="#system_status" onclick="return show_panel(this);">Status Checks</a></li>
|
||||
<li><a href="#system_backup" onclick="return show_panel(this);">Backup Status</a></li>
|
||||
<li class="divider"></li>
|
||||
<li class="dropdown-header">Super Advanced Options</li>
|
||||
<li><a href="#system_dns" onclick="return show_panel(this);">DNS (Custom/External)</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="dropdown active">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown">Mail/Sync <b class="caret"></b></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="#mail-guide" onclick="return show_panel(this);">Mail Instructions</a></li>
|
||||
<li><a href="#sync_guide" onclick="return show_panel(this);">Contacts/Calendar</a></li>
|
||||
<li class="divider"></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>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="nav navbar-nav navbar-right">
|
||||
<li><a href="#" onclick="do_logout(); return false;" style="color: white">Log out?</a></li>
|
||||
</ul>
|
||||
</div><!--/.navbar-collapse -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div id="panel_system_status" class="container panel">
|
||||
{% include "system-status.html" %}
|
||||
</div>
|
||||
|
||||
<div id="panel_system_backup" class="container panel">
|
||||
{% include "system-backup.html" %}
|
||||
</div>
|
||||
|
||||
<div id="panel_system_dns" class="container panel">
|
||||
{% include "system-dns.html" %}
|
||||
</div>
|
||||
|
||||
<div id="panel_login" class="panel">
|
||||
{% include "login.html" %}
|
||||
</div>
|
||||
|
||||
<div id="panel_mail-guide" class="container panel">
|
||||
{% include "mail-guide.html" %}
|
||||
</div>
|
||||
|
||||
<div id="panel_users" class="container panel">
|
||||
{% include "users.html" %}
|
||||
</div>
|
||||
|
||||
<div id="panel_aliases" class="container panel">
|
||||
{% include "aliases.html" %}
|
||||
</div>
|
||||
|
||||
<div id="panel_sync_guide" class="container panel">
|
||||
{% include "sync-guide.html" %}
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<footer>
|
||||
<p>This is a <a href="https://mailinabox.email">Mail-in-a-Box</a>.</p>
|
||||
</footer>
|
||||
</div> <!-- /container -->
|
||||
|
||||
<div id="ajax_loading_indicator" style="display: none; position: fixed; left: 0; top: 0; width: 100%; height: 100%; text-align: center; background-color: rgba(255,255,255,.75)">
|
||||
<div style="margin: 20% auto">
|
||||
<div><span class="glyphicon glyphicon-time"></span></div>
|
||||
<div>Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="global_modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="errorModalTitle" aria-hidden="true">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
|
||||
<h4 class="modal-title" id="errorModalTitle"> </h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p> </p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">OK</button>
|
||||
<button type="button" class="btn btn-danger" data-dismiss="modal">Yes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="//ajax.googleapis.com/ajax/libs/jquery/1.10.1/jquery.min.js"></script>
|
||||
<script src="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/js/bootstrap.min.js"></script>
|
||||
|
||||
<script>
|
||||
var global_modal_state = null;
|
||||
var global_modal_funcs = null;
|
||||
|
||||
$(function() {
|
||||
$('#global_modal').on('shown.bs.modal', function (e) {
|
||||
// set focus to first input in the global modal's body
|
||||
var input = $('#global_modal .modal-body input');
|
||||
if (input.length > 0) $(input[0]).focus();
|
||||
})
|
||||
$('#global_modal .btn-danger').click(function() {
|
||||
// Don't take action now. Wait for the modal to be totally hidden
|
||||
// so that we don't attempt to show another modal while this one
|
||||
// is closing.
|
||||
global_modal_state = 0; // OK
|
||||
})
|
||||
$('#global_modal .btn-default').click(function() {
|
||||
global_modal_state = 1; // Cancel
|
||||
})
|
||||
$('#global_modal').on('hidden.bs.modal', function (e) {
|
||||
// do the cancel function
|
||||
if (global_modal_state == null) global_modal_state = 1; // cancel if the user hit ESC or clicked outside of the modal
|
||||
if (global_modal_funcs && global_modal_funcs[global_modal_state])
|
||||
global_modal_funcs[global_modal_state]();
|
||||
})
|
||||
})
|
||||
|
||||
function show_modal_error(title, message, callback) {
|
||||
$('#global_modal h4').text(title);
|
||||
$('#global_modal .modal-body').html("<p/>");
|
||||
if (typeof question == String) {
|
||||
$('#global_modal p').text(message);
|
||||
$('#global_modal .modal-dialog').addClass("modal-sm");
|
||||
} else {
|
||||
$('#global_modal p').html("").append(message);
|
||||
$('#global_modal .modal-dialog').removeClass("modal-sm");
|
||||
}
|
||||
$('#global_modal .btn-default').show().text("OK");
|
||||
$('#global_modal .btn-danger').hide();
|
||||
global_modal_funcs = [callback, callback];
|
||||
global_modal_state = null;
|
||||
$('#global_modal').modal({});
|
||||
}
|
||||
|
||||
function show_modal_confirm(title, question, verb, yes_callback, cancel_callback) {
|
||||
$('#global_modal h4').text(title);
|
||||
if (typeof question == String) {
|
||||
$('#global_modal .modal-dialog').addClass("modal-sm");
|
||||
$('#global_modal .modal-body').html("<p/>");
|
||||
$('#global_modal p').text(question);
|
||||
} else {
|
||||
$('#global_modal .modal-dialog').removeClass("modal-sm");
|
||||
$('#global_modal .modal-body').html("").append(question);
|
||||
}
|
||||
$('#global_modal .btn-default').show().text("Cancel");
|
||||
$('#global_modal .btn-danger').show().text(verb);
|
||||
global_modal_funcs = [yes_callback, cancel_callback];
|
||||
global_modal_state = null;
|
||||
$('#global_modal').modal({});
|
||||
}
|
||||
|
||||
var is_ajax_loading = false;
|
||||
function ajax(options) {
|
||||
setTimeout("if (is_ajax_loading) $('#ajax_loading_indicator').fadeIn()", 100);
|
||||
function hide_loading_indicator() {
|
||||
is_ajax_loading = false;
|
||||
$('#ajax_loading_indicator').hide();
|
||||
}
|
||||
var old_success = options.success;
|
||||
var old_error = options.error;
|
||||
options.success = function(data) {
|
||||
hide_loading_indicator();
|
||||
if (data.status == "error")
|
||||
show_modal_error("Error", data.message);
|
||||
else if (old_success)
|
||||
old_success(data);
|
||||
};
|
||||
options.error = function(jqxhr) {
|
||||
hide_loading_indicator();
|
||||
if (!old_error)
|
||||
show_modal_error("Error", "Something went wrong, sorry.")
|
||||
else
|
||||
old_error(jqxhr.responseText);
|
||||
};
|
||||
is_ajax_loading = true;
|
||||
$.ajax(options);
|
||||
}
|
||||
|
||||
var api_credentials = ["", ""];
|
||||
function api(url, method, data, callback, callback_error) {
|
||||
// from http://www.webtoolkit.info/javascript-base64.html
|
||||
function base64encode(input) {
|
||||
_keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
|
||||
var output = "";
|
||||
var chr1, chr2, chr3, enc1, enc2, enc3, enc4;
|
||||
var i = 0;
|
||||
while (i < input.length) {
|
||||
chr1 = input.charCodeAt(i++);
|
||||
chr2 = input.charCodeAt(i++);
|
||||
chr3 = input.charCodeAt(i++);
|
||||
enc1 = chr1 >> 2;
|
||||
enc2 = ((chr1 & 3) << 4) | (chr2 >> 4);
|
||||
enc3 = ((chr2 & 15) << 2) | (chr3 >> 6);
|
||||
enc4 = chr3 & 63;
|
||||
if (isNaN(chr2)) {
|
||||
enc3 = enc4 = 64;
|
||||
} else if (isNaN(chr3)) {
|
||||
enc4 = 64;
|
||||
}
|
||||
output = output +
|
||||
_keyStr.charAt(enc1) + _keyStr.charAt(enc2) +
|
||||
_keyStr.charAt(enc3) + _keyStr.charAt(enc4);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
ajax({
|
||||
url: "/admin" + url,
|
||||
method: method,
|
||||
data: data,
|
||||
beforeSend: function(xhr) {
|
||||
// We don't store user credentials in a cookie to avoid the hassle of CSRF
|
||||
// attacks. The Authorization header only gets set in our AJAX calls triggered
|
||||
// by user actions.
|
||||
xhr.setRequestHeader(
|
||||
'Authorization',
|
||||
'Basic ' + base64encode(api_credentials[0] + ':' + api_credentials[1]));
|
||||
},
|
||||
success: callback,
|
||||
error: callback_error,
|
||||
statusCode: {
|
||||
403: function(xhr) {
|
||||
// Credentials are no longer valid. Try to login again.
|
||||
var p = current_panel;
|
||||
show_panel('login');
|
||||
switch_back_to_panel = p;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var current_panel = null;
|
||||
var switch_back_to_panel = null;
|
||||
function show_panel(panelid) {
|
||||
if (panelid.getAttribute)
|
||||
// we might be passed an HTMLElement <a>.
|
||||
panelid = panelid.getAttribute('href').substring(1);
|
||||
|
||||
$('.panel').hide();
|
||||
$('#panel_' + panelid).show();
|
||||
if (typeof localStorage != 'undefined')
|
||||
localStorage.setItem("miab-cp-lastpanel", panelid);
|
||||
if (window["show_" + panelid])
|
||||
window["show_" + panelid]();
|
||||
|
||||
current_panel = panelid;
|
||||
switch_back_to_panel = null;
|
||||
|
||||
return false; // when called from onclick, cancel navigation
|
||||
}
|
||||
|
||||
$(function() {
|
||||
// Recall saved user credentials.
|
||||
if (typeof sessionStorage != 'undefined' && sessionStorage.getItem("miab-cp-credentials"))
|
||||
api_credentials = sessionStorage.getItem("miab-cp-credentials").split(":");
|
||||
else if (typeof localStorage != 'undefined' && localStorage.getItem("miab-cp-credentials"))
|
||||
api_credentials = localStorage.getItem("miab-cp-credentials").split(":");
|
||||
|
||||
// Recall what the user was last looking at.
|
||||
if (typeof localStorage != 'undefined' && localStorage.getItem("miab-cp-lastpanel")) {
|
||||
show_panel(localStorage.getItem("miab-cp-lastpanel"));
|
||||
} else {
|
||||
show_panel('login');
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
116
management/templates/login.html
Normal file
116
management/templates/login.html
Normal file
@@ -0,0 +1,116 @@
|
||||
<h1 style="margin: 1em; text-align: center">{{hostname}}</h1>
|
||||
|
||||
{% if no_admins_exist %}
|
||||
<div class="container">
|
||||
<div class="col-md-offset-2 col-md-8">
|
||||
<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>
|
||||
<pre>cd mailinabox
|
||||
sudo tools/mail.py user make-admin your@emailaddress.com</pre>
|
||||
<hr>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-offset-2 col-sm-8 col-md-offset-3 col-md-6 col-lg-offset-4 col-lg-4">
|
||||
<center>
|
||||
<p style="margin: 2em">Log in here for your Mail-in-a-Box control panel.</p>
|
||||
</center>
|
||||
|
||||
<form class="form-horizontal" role="form" onsubmit="do_login(); return false;">
|
||||
<div class="form-group">
|
||||
<label for="inputEmail3" class="col-sm-2 control-label">Email</label>
|
||||
<div class="col-sm-10">
|
||||
<input name="email" type="email" class="form-control" id="loginEmail" placeholder="Email">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="inputPassword3" class="col-sm-2 control-label">Password</label>
|
||||
<div class="col-sm-10">
|
||||
<input name="password" type="password" class="form-control" id="loginPassword" placeholder="Password">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input name='remember' type="checkbox" id="loginRemember"> Remember me
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<button type="submit" class="btn btn-default">Sign in</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
function do_login() {
|
||||
if ($('#loginEmail').val() == "") {
|
||||
show_modal_error("Login Failed", "Enter your email address.")
|
||||
return false;
|
||||
}
|
||||
if ($('#loginPassword').val() == "") {
|
||||
show_modal_error("Login Failed", "Enter your email password.")
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exchange the email address & password for an API key.
|
||||
api_credentials = [$('#loginEmail').val(), $('#loginPassword').val()]
|
||||
|
||||
api(
|
||||
"/me",
|
||||
"GET",
|
||||
{ },
|
||||
function(response){
|
||||
// This API call always succeeds. It returns a JSON object indicating
|
||||
// whether the request was authenticated or not.
|
||||
if (response.status != "authorized") {
|
||||
// Show why the login failed.
|
||||
show_modal_error("Login Failed", response.reason)
|
||||
|
||||
// Reset any saved credentials.
|
||||
do_logout();
|
||||
|
||||
} else {
|
||||
// Login succeeded.
|
||||
|
||||
// Save the new credentials.
|
||||
api_credentials = [response.api_key, ""];
|
||||
|
||||
// Try to wipe the username/password information.
|
||||
$('#loginEmail').val('');
|
||||
$('#loginPassword').val('');
|
||||
|
||||
// Remember the credentials.
|
||||
if (typeof localStorage != 'undefined' && typeof sessionStorage != 'undefined') {
|
||||
if ($('#loginRemember').val()) {
|
||||
localStorage.setItem("miab-cp-credentials", api_credentials.join(":"));
|
||||
sessionStorage.removeItem("miab-cp-credentials");
|
||||
} else {
|
||||
localStorage.removeItem("miab-cp-credentials");
|
||||
sessionStorage.setItem("miab-cp-credentials", api_credentials.join(":"));
|
||||
}
|
||||
}
|
||||
|
||||
// Open the next panel the user wants to go to.
|
||||
show_panel(!switch_back_to_panel ? 'system_status' : switch_back_to_panel)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
</script>
|
||||
62
management/templates/mail-guide.html
Normal file
62
management/templates/mail-guide.html
Normal file
@@ -0,0 +1,62 @@
|
||||
<style>#panel_mail-guide table.table { width: auto; margin-left: 1.5em; }</style>
|
||||
|
||||
<div class="container">
|
||||
<h2>Checking and Sending Mail</h2>
|
||||
|
||||
<p>You can use either webmail, IMAP/SMTP, or Exchange/ActiveSync to access your mail.</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h4>How to log in</h4>
|
||||
|
||||
<p>Your username and password are the same no matter how you check your mail:</p>
|
||||
|
||||
<table class="table" style="max-width: 30em">
|
||||
<tr><th>Username:</th> <td>Your whole email address.</td></tr>
|
||||
<tr><th>Password:</th> <td>Your mail password.</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-6">
|
||||
<h4>Webmail</h4>
|
||||
|
||||
<p>Your webmail site is <a href="https://{{hostname}}/mail">https://{{hostname}}/mail</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-6">
|
||||
<h4>IMAP/SMTP settings</h4>
|
||||
|
||||
<p>This method is preferred on Android devices and is not available on iOS devices.</p>
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th style="padding-bottom: 1em; border-bottom: none">Server:</th> <td colspan="2">{{hostname}}</td></tr>
|
||||
<tr><th>Protocol</th> <th>Port</th> <th>Options</th></tr>
|
||||
</thead>
|
||||
<tr><th>IMAP</th> <td>993</td> <td>SSL</td></tr>
|
||||
<tr><th>SMTP</th> <td>587</td> <td>STARTTLS</td></tr>
|
||||
</table>
|
||||
|
||||
<p>In addition to setting up your email, you’ll also need to set up <a href="#sync_guide" onclick="return show_panel(this);">contacts and calendar synchronization</a>.</p>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-6">
|
||||
<h4>Exchange/ActiveSync settings</h4>
|
||||
|
||||
<p>On iOS devices and devices on this <a href="http://z-push.org/compatibility/">compatibility list</a>, set up your mail as an Exchange or ActiveSync server:</p>
|
||||
|
||||
<table class="table">
|
||||
<tr><th>Server</th> <td>{{hostname}}</td></tr>
|
||||
<tr><th>Options</th> <td>Secure Connection</td></tr>
|
||||
</table>
|
||||
|
||||
<p>Your device should also provide a contacts list and calendar that syncs to this box when you use this method.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
48
management/templates/sync-guide.html
Normal file
48
management/templates/sync-guide.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<div class="container">
|
||||
<h2>Contacts & Calendar Synchronization</h2>
|
||||
|
||||
<p>This box can hold your contacts and calendar, just like it holds your email.</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-6">
|
||||
<h4>In your browser</h4>
|
||||
|
||||
<p>You can edit your contacts and calendar from your web browser.</p>
|
||||
|
||||
<table class="table">
|
||||
<thead><tr><th>For...</th> <th>Visit this URL</th></tr></thead>
|
||||
<tr><td>Contacts</td> <td><a href="https://{{hostname}}/cloud/contacts">https://{{hostname}}/cloud/contacts</a></td></tr>
|
||||
<tr><td>Calendar</td> <td><a href="https://{{hostname}}/cloud/calendar">https://{{hostname}}/cloud/calendar</a></td></tr>
|
||||
</table>
|
||||
|
||||
<p>Log in settings are the same as with <a href="#mail-guide" onclick="return show_panel(this);">mail</a>: your
|
||||
complete email address and your mail password.</p>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-6">
|
||||
<h4>On your mobile device</h4>
|
||||
|
||||
<p>If you set up your <a href="#mail-guide" onclick="return show_panel(this);">mail</a> using Exchange/ActiveSync,
|
||||
your contacts and calendar may already appear on your device.</p>
|
||||
<p>Otherwise, here are some apps that can synchronize your contacts and calendar to your Android phone.</p>
|
||||
|
||||
<table class="table">
|
||||
<thead><tr><th>For...</th> <th>Use...</th></tr></thead>
|
||||
<tr><td>Contacts</td> <td><a href="https://play.google.com/store/apps/details?id=org.dmfs.carddav.sync">CardDAV-Sync free beta</a> (free)</td></tr>
|
||||
<tr><td>Calendar</td> <td><a href="https://play.google.com/store/apps/details?id=org.dmfs.caldav.lib">CalDAV-Sync</a> ($2.89)</td></tr>
|
||||
</table>
|
||||
|
||||
<p>Use the following settings:</p>
|
||||
|
||||
<table class="table">
|
||||
<tr><td>Account Type</td> <td>CardDAV or CalDAV</td></tr>
|
||||
<tr><td>Server Name</td> <td>{{hostname}}</td></tr>
|
||||
<tr><td>Use SSL</td> <td>Yes</td></tr>
|
||||
<tr><td>Username</td> <td>Your complete email address.</td></tr>
|
||||
<tr><td>Password</td> <td>Your mail password.</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
78
management/templates/system-backup.html
Normal file
78
management/templates/system-backup.html
Normal file
@@ -0,0 +1,78 @@
|
||||
<style>
|
||||
#backup-status th { text-align: center; }
|
||||
#backup-status tr.full-backup td { font-weight: bold; }
|
||||
</style>
|
||||
|
||||
<h2>Backup Status</h2>
|
||||
|
||||
<h3>Copying Backup Files</h3>
|
||||
|
||||
<p>The box makes an incremental backup each night. The backup is stored on the machine itself. You are responsible for copying the backup files off of the machine.</p>
|
||||
|
||||
<p>Many cloud providers make this easy by allowing you to take snapshots of the machine's disk.</p>
|
||||
|
||||
<p>You can also use SFTP (FTP over SSH) to copy files from <tt id="backup-location"></tt>. These files are encrpyted, so they are safe to store anywhere. Copy the encryption password from <tt id="backup-encpassword-file"></tt> also but keep it in a safe location.</p>
|
||||
|
||||
<h3>Current Backups</h3>
|
||||
|
||||
<p>The backup directory currently contains the backups listed below. The total size on disk of the backups is <span id="backup-total-size"></span>.</p>
|
||||
|
||||
<table id="backup-status" class="table" style="width: auto">
|
||||
<thead>
|
||||
<th colspan="2">When</th>
|
||||
<th>Type</th>
|
||||
<th>Size</th>
|
||||
<th>Deleted in...</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p><small>The size column in the table indicates the size of the encrpyted backup, but the total size on disk shown above includes storage for unencrpyted intermediate files.</small></p>
|
||||
|
||||
<script>
|
||||
function nice_size(bytes) {
|
||||
var powers = ['bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
while (true) {
|
||||
if (powers.length == 1) break;
|
||||
if (bytes < 1000) break;
|
||||
bytes /= 1024;
|
||||
powers.shift();
|
||||
}
|
||||
return (Math.round(bytes*10)/10) + " " + powers[0];
|
||||
}
|
||||
|
||||
function show_system_backup() {
|
||||
$('#backup-status tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
||||
api(
|
||||
"/system/backup/status",
|
||||
"GET",
|
||||
{ },
|
||||
function(r) {
|
||||
$('#backup-location').text(r.encdirectory);
|
||||
$('#backup-encpassword-file').text(r.encpwfile);
|
||||
|
||||
$('#backup-status tbody').html("");
|
||||
var total_disk_size = 0;
|
||||
for (var i = 0; i < r.backups.length; i++) {
|
||||
var b = r.backups[i];
|
||||
var tr = $('<tr/>');
|
||||
if (b.full) tr.addClass("full-backup");
|
||||
tr.append( $('<td/>').text(b.date_str + " " + r.tz) );
|
||||
tr.append( $('<td/>').text(b.date_delta + " ago") );
|
||||
tr.append( $('<td/>').text(b.full ? "full" : "increment") );
|
||||
tr.append( $('<td style="text-align: right"/>').text( nice_size(b.encsize)) );
|
||||
if (b.deleted_in)
|
||||
tr.append( $('<td/>').text(b.deleted_in) );
|
||||
else
|
||||
tr.append( $('<td class="text-muted">n/a</td>') );
|
||||
$('#backup-status tbody').append(tr);
|
||||
|
||||
total_disk_size += b.size;
|
||||
total_disk_size += b.encsize;
|
||||
}
|
||||
|
||||
$('#backup-total-size').text(nice_size(total_disk_size));
|
||||
})
|
||||
}
|
||||
</script>
|
||||
118
management/templates/system-dns.html
Normal file
118
management/templates/system-dns.html
Normal file
@@ -0,0 +1,118 @@
|
||||
<style>
|
||||
#external_dns_settings .heading td {
|
||||
font-weight: bold;
|
||||
font-size: 120%;
|
||||
padding-top: 1.5em;
|
||||
}
|
||||
#external_dns_settings .heading.first td {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
#external_dns_settings .values td {
|
||||
border: 0;
|
||||
padding-top: .75em;
|
||||
padding-bottom: 0;
|
||||
max-width: 50vw;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
#external_dns_settings .explanation td {
|
||||
border: 0;
|
||||
padding-top: .5em;
|
||||
padding-bottom: .75em;
|
||||
font-style: italic;
|
||||
font-size: 95%;
|
||||
color: #777;
|
||||
}
|
||||
</style>
|
||||
|
||||
<h2>DNS (Advanced)</h2>
|
||||
|
||||
<p class="text-danger">Custom and external DNS are for advanced configurations.</p>
|
||||
|
||||
<h3>Custom DNS</h3>
|
||||
|
||||
<p>It is possible to set custom DNS records on domains hosted here. For instance, you can create your own dynamic DNS service. To do so, you will need to call your box’s DNS API.</p>
|
||||
|
||||
<h4>The HTTP POST request</h4>
|
||||
|
||||
<p>Send a POST request like this:</p>
|
||||
|
||||
<pre>curl -d "" --user {email}:{password} https://{{hostname}}/admin/dns/set/<b>qname</b>[/<b>rtype</b>[/<b>value</b>]]</pre>
|
||||
|
||||
<table class="table">
|
||||
<thead><th>Parameter</th> <th>Value</th></thead>
|
||||
<tr><td>email</td> <td>The email address of any administrative user here.</td></tr>
|
||||
<tr><td>password</td> <td>That user’s password.</td></tr>
|
||||
<tr><td>qname</td> <td>The fully qualified domain name for the record you are trying to set.</td></tr>
|
||||
<tr><td>rtype</td> <td>The resource type. <code>A</code> if omitted. Possible values: <code>A</code> (an IPv4 address), <code>AAAA</code> (an IPv6 address), <code>TXT</code> (a text string), or <code>CNAME</code> (an alias, which is a fully qualified domain name).</td></tr>
|
||||
<tr><td>value</td> <td>The new record’s value. If omitted, the IPv4 address of the remote host is used. This is handy for dynamic DNS! To delete a record, use “__delete__”.</td></tr>
|
||||
</table>
|
||||
|
||||
<p style="margin-top: 1em">Note that <code>-d ""</code> is merely to ensure curl sends a POST request. You do not need to put anything inside the quotes. You can also pass the value using typical form encoding in the POST body.</p>
|
||||
|
||||
<h4>Examples:</h4>
|
||||
|
||||
<pre># sets laptop.mydomain.com to point to the IP address of the machine you are executing curl on
|
||||
curl -d "" --user me@mydomain.com:###### https://{{hostname}}/admin/dns/set/laptop.mydomain.com
|
||||
|
||||
# sets an alias
|
||||
curl -d "" --user me@mydomain.com:###### https://{{hostname}}/admin/dns/set/foo.mydomain.com/cname/bar.mydomain.com
|
||||
|
||||
# clears the alias
|
||||
curl -d "" --user me@mydomain.com:###### https://{{hostname}}/admin/dns/set/bar.mydomain.com/cname/__delete__
|
||||
|
||||
# sets a TXT record using the alternate value syntax
|
||||
curl -d "value=something%20here" --user me@mydomain.com:###### https://{{hostname}}/admin/dns/set/foo.mydomain.com/txt
|
||||
</pre>
|
||||
|
||||
|
||||
<h3>External DNS</h3>
|
||||
|
||||
<p>Although your box is configured to serve its own DNS, it is possible to host your DNS elsewhere.</p>
|
||||
|
||||
<p>If you do so, you are responsible for keeping your DNS entries up to date! If you previously enabled DNSSEC on your domain name by setting a DS record at your registrar, you will likely have to turn it off before changing nameservers.</p>
|
||||
|
||||
<p>Enter the following DNS entries at your DNS provider:</p>
|
||||
|
||||
<table id="external_dns_settings" class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>QName</th>
|
||||
<th>Type</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<script>
|
||||
function show_system_dns() {
|
||||
$('#external_dns_settings tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
||||
api(
|
||||
"/dns/dump",
|
||||
"GET",
|
||||
{ },
|
||||
function(zones) {
|
||||
$('#external_dns_settings tbody').html("");
|
||||
for (var j = 0; j < zones.length; j++) {
|
||||
var h = $("<tr class='heading'><td colspan='3'></td></tr>");
|
||||
h.find("td").text(zones[j][0]);
|
||||
$('#external_dns_settings tbody').append(h);
|
||||
|
||||
var r = zones[j][1];
|
||||
for (var i = 0; i < r.length; i++) {
|
||||
var n = $("<tr class='values'><td class='qname'/><td class='rtype'/><td class='value'/></tr>");
|
||||
n.find('.qname').text(r[i].qname);
|
||||
n.find('.rtype').text(r[i].rtype);
|
||||
n.find('.value').text(r[i].value);
|
||||
$('#external_dns_settings tbody').append(n);
|
||||
|
||||
var n = $("<tr class='explanation'><td colspan='3'/></tr>");
|
||||
n.find('td').text(r[i].explanation);
|
||||
$('#external_dns_settings tbody').append(n);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
79
management/templates/system-status.html
Normal file
79
management/templates/system-status.html
Normal file
@@ -0,0 +1,79 @@
|
||||
<h2>System Status Checks</h2>
|
||||
|
||||
<style>
|
||||
#system-checks .heading td {
|
||||
font-weight: bold;
|
||||
font-size: 120%;
|
||||
padding-top: 1.5em;
|
||||
}
|
||||
#system-checks .heading.first td {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
#system-checks .error td {
|
||||
color: #733;
|
||||
}
|
||||
#system-checks .ok td {
|
||||
color: #030;
|
||||
}
|
||||
#system-checks div.extra {
|
||||
display: none;
|
||||
margin-top: 1em;
|
||||
max-width: 50em;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
#system-checks a.showhide {
|
||||
display: none;
|
||||
font-size: 85%;
|
||||
}
|
||||
#system-checks .pre {
|
||||
margin: 1em;
|
||||
font-family: monospace;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
<table id="system-checks" class="table" style="max-width: 60em">
|
||||
<thead>
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<script>
|
||||
function show_system_status() {
|
||||
$('#system-checks tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
||||
api(
|
||||
"/system/status",
|
||||
"POST",
|
||||
{ },
|
||||
function(r) {
|
||||
$('#system-checks tbody').html("");
|
||||
for (var i = 0; i < r.length; i++) {
|
||||
var n = $("<tr><td class='status'/><td class='message'><p style='margin: 0'/><div class='extra'/><a class='showhide' href='#'/></tr>");
|
||||
if (i == 0) n.addClass('first')
|
||||
n.addClass(r[i].type)
|
||||
if (r[i].type == "ok") n.find('td.status').text("✓")
|
||||
if (r[i].type == "error") n.find('td.status').text("✖")
|
||||
n.find('td.message p').text(r[i].text)
|
||||
$('#system-checks tbody').append(n);
|
||||
|
||||
if (r[i].extra.length > 0) {
|
||||
n.find('a.showhide').show().text("show more").click(function() {
|
||||
$(this).hide();
|
||||
$(this).parent().find('.extra').fadeIn();
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
for (var j = 0; j < r[i].extra.length; j++) {
|
||||
|
||||
var m = $("<div/>").text(r[i].extra[j].text)
|
||||
if (r[i].extra[j].monospace)
|
||||
m.addClass("pre");
|
||||
n.find('> td.message > div').append(m);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
227
management/templates/users.html
Normal file
227
management/templates/users.html
Normal file
@@ -0,0 +1,227 @@
|
||||
<h2>Users</h2>
|
||||
|
||||
<style>
|
||||
#user_table tr.account_inactive td .address { color: #888; text-decoration: line-through; }
|
||||
#user_table .aliases { margin-top: .33em; font-size: 95%; }
|
||||
#user_table .aliases div:before { content: "⇖ "; }
|
||||
#user_table .aliases div { }
|
||||
#user_table .actions { margin-top: .33em; font-size: 95%; }
|
||||
#user_table .account_inactive .if_active { display: none; }
|
||||
#user_table .account_active .if_inactive { display: none; }
|
||||
</style>
|
||||
|
||||
<h3>Add a mail user</h3>
|
||||
|
||||
<p>Add an email address to this system. This will create a new login username/password. (Use <a href="javascript:show_panel('aliases')">aliases</a> to create email addresses that forward to existing accounts.)</p>
|
||||
|
||||
<form class="form-inline" role="form" onsubmit="return do_add_user(); return false;">
|
||||
<div class="form-group">
|
||||
<label class="sr-only" for="adduserEmail">Email address</label>
|
||||
<input type="email" class="form-control" id="adduserEmail" placeholder="Email Address">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="sr-only" for="adduserPassword">Password</label>
|
||||
<input type="password" class="form-control" id="adduserPassword" placeholder="Password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<select class="form-control" id="adduserPrivs">
|
||||
<option value="">Normal User</option>
|
||||
<option value="admin">Administrator</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add User</button>
|
||||
</form>
|
||||
<p style="margin-top: .5em"><small>
|
||||
Passwords must be at least four characters and may not contain spaces.
|
||||
Administrators get access to this control panel.
|
||||
</small></p>
|
||||
|
||||
<h3>Existing mail users</h3>
|
||||
<table id="user_table" class="table" style="width: auto">
|
||||
<tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style="display: none">
|
||||
<table>
|
||||
<tr id="user-template">
|
||||
<td class='email'>
|
||||
<div class='address'> </div>
|
||||
|
||||
<div class='actions'>
|
||||
<span class='privs'>
|
||||
</span>
|
||||
|
||||
<span class="if_active">
|
||||
<a href="#" onclick="users_set_password(this); return false;" class='setpw' title="Set Password">
|
||||
set password
|
||||
</a>
|
||||
|
|
||||
</span>
|
||||
|
||||
<span class='add-privs'>
|
||||
</span>
|
||||
|
||||
<a href="#" onclick="users_remove(this); return false;" class='if_active' title="Archive Account">
|
||||
archive account
|
||||
</a>
|
||||
|
||||
<div class='if_inactive' style='color: #888; font-size: 90%'>To restore account, create a new account with this email address.</div>
|
||||
</div>
|
||||
|
||||
<div class='aliases' style='display: none'> </div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
function show_users() {
|
||||
$('#user_table tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
||||
api(
|
||||
"/mail/users",
|
||||
"GET",
|
||||
{ format: 'json' },
|
||||
function(r) {
|
||||
$('#user_table tbody').html("");
|
||||
for (var i = 0; i < r.length; i++) {
|
||||
var n = $("#user-template").clone();
|
||||
n.attr('id', '');
|
||||
|
||||
n.addClass("account_" + r[i].status);
|
||||
n.attr('data-email', r[i].email);
|
||||
n.find('td.email .address').text(r[i].email)
|
||||
$('#user_table tbody').append(n);
|
||||
|
||||
if (r[i].status == 'inactive') continue;
|
||||
|
||||
var add_privs = ["admin"];
|
||||
|
||||
for (var j = 0; j < r[i].privileges.length; j++) {
|
||||
var p = $("<span><b><span class='name'></span></b> (<a href='#' onclick='mod_priv(this, \"remove\"); return false;' title='Remove Privilege'>remove privilege</a>) |</span>");
|
||||
p.find('span.name').text(r[i].privileges[j]);
|
||||
n.find('.privs').append(p);
|
||||
if (add_privs.indexOf(r[i].privileges[j]) >= 0)
|
||||
add_privs.splice(add_privs.indexOf(r[i].privileges[j]), 1);
|
||||
}
|
||||
|
||||
for (var j = 0; j < add_privs.length; j++) {
|
||||
var p = $("<span><a href='#' onclick='mod_priv(this, \"add\"); return false;' title='Add Privilege'>make <span class='name'></span></a> | </span>");
|
||||
p.find('span.name').text(add_privs[j]);
|
||||
n.find('.add-privs').append(p);
|
||||
}
|
||||
|
||||
if (r[i].aliases && r[i].aliases.length > 0) {
|
||||
n.find('.aliases').show();
|
||||
for (var j = 0; j < r[i].aliases.length; j++) {
|
||||
n.find('td.email .aliases').append($("<div/>").text(
|
||||
r[i].aliases[j][0]
|
||||
+ (r[i].aliases[j][1].length > 0 ? " ⇐ " + r[i].aliases[j][1].join(", ") : "")
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function do_add_user() {
|
||||
var email = $("#adduserEmail").val();
|
||||
var pw = $("#adduserPassword").val();
|
||||
var privs = $("#adduserPrivs").val();
|
||||
api(
|
||||
"/mail/users/add",
|
||||
"POST",
|
||||
{
|
||||
email: email,
|
||||
password: pw,
|
||||
privileges: privs
|
||||
},
|
||||
function(r) {
|
||||
// Responses are multiple lines of pre-formatted text.
|
||||
show_modal_error("Add User", $("<pre/>").text(r));
|
||||
show_users()
|
||||
},
|
||||
function(r) {
|
||||
show_modal_error("Add User", r);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
function users_set_password(elem) {
|
||||
var email = $(elem).parents('tr').attr('data-email');
|
||||
show_modal_confirm(
|
||||
"Archive User",
|
||||
$("<p>Set a new password for <b>" + email + "</b>?</p> <p><label for='users_set_password_pw' style='display: block; font-weight: normal'>New Password:</label><input type='password' id='users_set_password_pw'></p><p><small>Passwords must be at least four characters and may not contain spaces.</small></p>"),
|
||||
"Set Password",
|
||||
function() {
|
||||
api(
|
||||
"/mail/users/password",
|
||||
"POST",
|
||||
{
|
||||
email: email,
|
||||
password: $('#users_set_password_pw').val()
|
||||
},
|
||||
function(r) {
|
||||
// Responses are multiple lines of pre-formatted text.
|
||||
show_modal_error("Set Password", $("<pre/>").text(r));
|
||||
},
|
||||
function(r) {
|
||||
show_modal_error("Set Password", r);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function users_remove(elem) {
|
||||
var email = $(elem).parents('tr').attr('data-email');
|
||||
show_modal_confirm(
|
||||
"Archive User",
|
||||
$("<p>Are you sure you want to archive <b>" + email + "</b>?</p> <p>The user's mailboxes will not be deleted (you can do that later), but the user will no longer be able to log into any services on this machine.</p>"),
|
||||
"Archive",
|
||||
function() {
|
||||
api(
|
||||
"/mail/users/remove",
|
||||
"POST",
|
||||
{
|
||||
email: email
|
||||
},
|
||||
function(r) {
|
||||
// Responses are multiple lines of pre-formatted text.
|
||||
show_modal_error("Remove User", $("<pre/>").text(r));
|
||||
show_users();
|
||||
},
|
||||
function(r) {
|
||||
show_modal_error("Remove User", r);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function mod_priv(elem, add_remove) {
|
||||
var email = $(elem).parents('tr').attr('data-email');
|
||||
var priv = $(elem).parents('td').find('.name').text();
|
||||
|
||||
// can't remove your own admin access
|
||||
if (priv == "admin" && add_remove == "remove" && api_credentials != null && email == api_credentials[0]) {
|
||||
show_modal_error("Modify Privileges", "You cannot remove the admin privilege from yourself.");
|
||||
return;
|
||||
}
|
||||
|
||||
var add_remove1 = add_remove.charAt(0).toUpperCase() + add_remove.substring(1);
|
||||
show_modal_confirm(
|
||||
"Modify Privileges",
|
||||
"Are you sure you want to " + add_remove + " the " + priv + " privilege for <b>" + email + "</b>?",
|
||||
add_remove1,
|
||||
function() {
|
||||
api(
|
||||
"/mail/users/privileges/" + add_remove,
|
||||
"POST",
|
||||
{
|
||||
email: email,
|
||||
privilege: priv
|
||||
},
|
||||
function(r) {
|
||||
show_users();
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -23,6 +23,10 @@ def safe_domain_name(name):
|
||||
import urllib.parse
|
||||
return urllib.parse.quote(name, safe='')
|
||||
|
||||
def unsafe_domain_name(name_encoded):
|
||||
import urllib.parse
|
||||
return urllib.parse.unquote(name_encoded)
|
||||
|
||||
def sort_domains(domain_names, env):
|
||||
# Put domain names in a nice sorted order. For web_update, PRIMARY_HOSTNAME
|
||||
# must appear first so it becomes the nginx default server.
|
||||
@@ -51,6 +55,17 @@ def sort_domains(domain_names, env):
|
||||
|
||||
return groups[0] + groups[1] + groups[2]
|
||||
|
||||
def sort_email_addresses(email_addresses, env):
|
||||
email_addresses = set(email_addresses)
|
||||
domains = set(email.split("@", 1)[1] for email in email_addresses if "@" in email)
|
||||
ret = []
|
||||
for domain in sort_domains(domains, env):
|
||||
domain_emails = set(email for email in email_addresses if email.endswith("@" + domain))
|
||||
ret.extend(sorted(domain_emails))
|
||||
email_addresses -= domain_emails
|
||||
ret.extend(sorted(email_addresses)) # whatever is left
|
||||
return ret
|
||||
|
||||
def exclusive_process(name):
|
||||
# Ensure that a process named `name` does not execute multiple
|
||||
# times concurrently.
|
||||
|
||||
@@ -40,10 +40,13 @@ def get_web_domains(env):
|
||||
|
||||
def do_web_update(env):
|
||||
# Build an nginx configuration file.
|
||||
nginx_conf = ""
|
||||
template = open(os.path.join(os.path.dirname(__file__), "../conf/nginx.conf")).read()
|
||||
nginx_conf = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-top.conf")).read()
|
||||
|
||||
# Add configuration for each web domain.
|
||||
template1 = open(os.path.join(os.path.dirname(__file__), "../conf/nginx.conf")).read()
|
||||
template2 = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-primaryonly.conf")).read()
|
||||
for domain in get_web_domains(env):
|
||||
nginx_conf += make_domain_config(domain, template, env)
|
||||
nginx_conf += make_domain_config(domain, template1, template2, env)
|
||||
|
||||
# Did the file change? If not, don't bother writing & restarting nginx.
|
||||
nginx_conf_fn = "/etc/nginx/conf.d/local.conf"
|
||||
@@ -56,12 +59,15 @@ def do_web_update(env):
|
||||
with open(nginx_conf_fn, "w") as f:
|
||||
f.write(nginx_conf)
|
||||
|
||||
# Kick nginx.
|
||||
shell('check_call', ["/usr/sbin/service", "nginx", "restart"])
|
||||
# Kick nginx. Since this might be called from the web admin
|
||||
# don't do a 'restart'. That would kill the connection before
|
||||
# the API returns its response. A 'reload' should be good
|
||||
# enough and doesn't break any open connections.
|
||||
shell('check_call', ["/usr/sbin/service", "nginx", "reload"])
|
||||
|
||||
return "web updated\n"
|
||||
|
||||
def make_domain_config(domain, template, env):
|
||||
def make_domain_config(domain, template, template_for_primaryhost, env):
|
||||
# How will we configure this domain.
|
||||
|
||||
# Where will its root directory be for static files?
|
||||
@@ -75,25 +81,35 @@ def make_domain_config(domain, template, env):
|
||||
# available. Make a self-signed one now if one doesn't exist.
|
||||
ensure_ssl_certificate_exists(domain, ssl_key, ssl_certificate, csr_path, env)
|
||||
|
||||
# Put pieces together.
|
||||
nginx_conf_parts = re.split("\s*# ADDITIONAL DIRECTIVES HERE\s*", template)
|
||||
nginx_conf = nginx_conf_parts[0] + "\n"
|
||||
if domain == env['PRIMARY_HOSTNAME']:
|
||||
nginx_conf += template_for_primaryhost + "\n"
|
||||
|
||||
# Replace substitution strings in the template & return.
|
||||
nginx_conf = template
|
||||
nginx_conf = nginx_conf.replace("$STORAGE_ROOT", env['STORAGE_ROOT'])
|
||||
nginx_conf = nginx_conf.replace("$HOSTNAME", domain)
|
||||
nginx_conf = nginx_conf.replace("$ROOT", root)
|
||||
nginx_conf = nginx_conf.replace("$SSL_KEY", ssl_key)
|
||||
nginx_conf = nginx_conf.replace("$SSL_CERTIFICATE", ssl_certificate)
|
||||
|
||||
# Add in any user customizations.
|
||||
nginx_conf_parts = re.split("(# ADDITIONAL DIRECTIVES HERE\n)", nginx_conf)
|
||||
# Add in any user customizations in YAML format.
|
||||
nginx_conf_custom_fn = os.path.join(env["STORAGE_ROOT"], "www/custom.yaml")
|
||||
if os.path.exists(nginx_conf_custom_fn):
|
||||
yaml = rtyaml.load(open(nginx_conf_custom_fn))
|
||||
if domain in yaml:
|
||||
yaml = yaml[domain]
|
||||
if "proxy" in yaml:
|
||||
nginx_conf_parts[1] += "\tlocation / {\n\t\tproxy_pass %s;\n\t}\n" % yaml["proxy"]
|
||||
for path, url in yaml.get("proxies", {}).items():
|
||||
nginx_conf += "\tlocation %s {\n\t\tproxy_pass %s;\n\t}\n" % (path, url)
|
||||
|
||||
# Put it all together.
|
||||
nginx_conf = "".join(nginx_conf_parts)
|
||||
# Add in any user customizations in the includes/ folder.
|
||||
nginx_conf_custom_include = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(domain) + ".conf")
|
||||
if os.path.exists(nginx_conf_custom_include):
|
||||
nginx_conf += "\tinclude %s;\n" % (nginx_conf_custom_include)
|
||||
|
||||
# Ending.
|
||||
nginx_conf += nginx_conf_parts[1]
|
||||
|
||||
return nginx_conf
|
||||
|
||||
@@ -129,7 +145,7 @@ def get_domain_ssl_files(domain, env):
|
||||
# a Subject Alternative Name matching this domain. Don't do this if
|
||||
# the user has uploaded a different private key for this domain.
|
||||
if not ssl_key_is_alt:
|
||||
from whats_next import check_certificate
|
||||
from status_checks import check_certificate
|
||||
if check_certificate(domain, ssl_certificate_primary, None) == "OK":
|
||||
ssl_certificate = ssl_certificate_primary
|
||||
|
||||
@@ -166,6 +182,7 @@ def ensure_ssl_certificate_exists(domain, ssl_key, ssl_certificate, csr_path, en
|
||||
"openssl", "req", "-new",
|
||||
"-key", ssl_key,
|
||||
"-out", csr_path,
|
||||
"-sha256",
|
||||
"-subj", "/C=%s/ST=/L=/O=/CN=%s" % (env["CSR_COUNTRY"], domain)])
|
||||
|
||||
# And then make the certificate.
|
||||
|
||||
@@ -1,392 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
#
|
||||
# Checks that the upstream DNS has been set correctly and that
|
||||
# SSL certificates have been signed, etc., and if not tells the user
|
||||
# what to do next.
|
||||
|
||||
__ALL__ = ['check_certificate']
|
||||
|
||||
import os, os.path, re, subprocess
|
||||
|
||||
import dns.reversename, dns.resolver
|
||||
|
||||
from dns_update import get_dns_zones
|
||||
from web_update import get_web_domains, get_domain_ssl_files
|
||||
from mailconfig import get_mail_domains, get_mail_aliases
|
||||
|
||||
from utils import shell, sort_domains
|
||||
|
||||
def run_checks(env):
|
||||
run_system_checks(env)
|
||||
run_domain_checks(env)
|
||||
|
||||
def run_system_checks(env):
|
||||
print("System")
|
||||
print("======")
|
||||
|
||||
# Check that SSH login with password is disabled.
|
||||
sshd = open("/etc/ssh/sshd_config").read()
|
||||
if re.search("\nPasswordAuthentication\s+yes", sshd) \
|
||||
or not re.search("\nPasswordAuthentication\s+no", sshd):
|
||||
print_error("""The SSH server on this machine permits password-based login. A more secure
|
||||
way to log in is using a public key. Add your SSH public key to $HOME/.ssh/authorized_keys, check
|
||||
that you can log in without a password, set the option 'PasswordAuthentication no' in
|
||||
/etc/ssh/sshd_config, and then restart the openssh via 'sudo service ssh restart'.""")
|
||||
else:
|
||||
print_ok("SSH disallows password-based login.")
|
||||
|
||||
# Check that the administrator alias exists since that's where all
|
||||
# admin email is automatically directed.
|
||||
check_alias_exists("administrator@" + env['PRIMARY_HOSTNAME'], env)
|
||||
|
||||
print()
|
||||
|
||||
def run_domain_checks(env):
|
||||
# Get the list of domains we handle mail for.
|
||||
mail_domains = get_mail_domains(env)
|
||||
|
||||
# Get the list of domains we serve DNS zones for (i.e. does not include subdomains).
|
||||
dns_zonefiles = dict(get_dns_zones(env))
|
||||
dns_domains = set(dns_zonefiles)
|
||||
|
||||
# Get the list of domains we serve HTTPS for.
|
||||
web_domains = set(get_web_domains(env))
|
||||
|
||||
# Check the domains.
|
||||
for domain in sort_domains(mail_domains | dns_domains | web_domains, env):
|
||||
print(domain)
|
||||
print("=" * len(domain))
|
||||
|
||||
if domain == env["PRIMARY_HOSTNAME"]:
|
||||
check_primary_hostname_dns(domain, env)
|
||||
|
||||
if domain in dns_domains:
|
||||
check_dns_zone(domain, env, dns_zonefiles)
|
||||
|
||||
if domain in mail_domains:
|
||||
check_mail_domain(domain, env)
|
||||
|
||||
if domain in web_domains:
|
||||
check_web_domain(domain, env)
|
||||
|
||||
print()
|
||||
|
||||
def check_primary_hostname_dns(domain, env):
|
||||
# Check that the ns1/ns2 hostnames resolve to A records. This information probably
|
||||
# comes from the TLD since the information is set at the registrar.
|
||||
ip = query_dns("ns1." + domain, "A") + '/' + query_dns("ns2." + domain, "A")
|
||||
if ip == env['PUBLIC_IP'] + '/' + env['PUBLIC_IP']:
|
||||
print_ok("Nameserver IPs are correct at registrar. [ns1/ns2.%s => %s]" % (env['PRIMARY_HOSTNAME'], env['PUBLIC_IP']))
|
||||
else:
|
||||
print_error("""Nameserver IP addresses are incorrect. The ns1.%s and ns2.%s nameservers must be configured at your domain name
|
||||
registrar as having the IP address %s. They currently report addresses of %s. It may take several hours for
|
||||
public DNS to update after a change."""
|
||||
% (env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'], ip))
|
||||
|
||||
# Check that PRIMARY_HOSTNAME resolves to PUBLIC_IP in public DNS.
|
||||
ip = query_dns(domain, "A")
|
||||
if ip == env['PUBLIC_IP']:
|
||||
print_ok("Domain resolves to box's IP address. [%s => %s]" % (env['PRIMARY_HOSTNAME'], env['PUBLIC_IP']))
|
||||
else:
|
||||
print_error("""This domain must resolve to your box's IP address (%s) in public DNS but it currently resolves
|
||||
to %s. It may take several hours for public DNS to update after a change. This problem may result from other
|
||||
issues listed here."""
|
||||
% (env['PUBLIC_IP'], ip))
|
||||
|
||||
# Check reverse DNS on the PRIMARY_HOSTNAME. Note that it might not be
|
||||
# a DNS zone if it is a subdomain of another domain we have a zone for.
|
||||
ipaddr_rev = dns.reversename.from_address(env['PUBLIC_IP'])
|
||||
existing_rdns = query_dns(ipaddr_rev, "PTR")
|
||||
if existing_rdns == domain:
|
||||
print_ok("Reverse DNS is set correctly at ISP. [%s => %s]" % (env['PUBLIC_IP'], env['PRIMARY_HOSTNAME']))
|
||||
else:
|
||||
print_error("""Your box's reverse DNS is currently %s, but it should be %s. Your ISP or cloud provider will have instructions
|
||||
on setting up reverse DNS for your box at %s.""" % (existing_rdns, domain, env['PUBLIC_IP']) )
|
||||
|
||||
# Check that the hostmaster@ email address exists.
|
||||
check_alias_exists("hostmaster@" + domain, env)
|
||||
|
||||
def check_alias_exists(alias, env):
|
||||
mail_alises = dict(get_mail_aliases(env))
|
||||
if alias in mail_alises:
|
||||
print_ok("%s exists as a mail alias [=> %s]" % (alias, mail_alises[alias]))
|
||||
else:
|
||||
print_error("""You must add a mail alias for %s and direct email to you or another administrator.""" % alias)
|
||||
|
||||
def check_dns_zone(domain, env, dns_zonefiles):
|
||||
# We provide a DNS zone for the domain. It should have NS records set up
|
||||
# at the domain name's registrar pointing to this box.
|
||||
existing_ns = query_dns(domain, "NS")
|
||||
correct_ns = "ns1.BOX; ns2.BOX".replace("BOX", env['PRIMARY_HOSTNAME'])
|
||||
if existing_ns == correct_ns:
|
||||
print_ok("Nameservers are set correctly at registrar. [%s]" % correct_ns)
|
||||
else:
|
||||
print_error("""The nameservers set on this domain are incorrect. They are currently %s. Use your domain name registar's
|
||||
control panel to set the nameservers to %s."""
|
||||
% (existing_ns, correct_ns) )
|
||||
|
||||
# See if the domain has a DS record set.
|
||||
ds = query_dns(domain, "DS", nxdomain=None)
|
||||
ds_correct = open('/etc/nsd/zones/' + dns_zonefiles[domain] + '.ds').read().strip()
|
||||
ds_expected = re.sub(r"\S+\.\s+3600\s+IN\s+DS\s*", "", ds_correct)
|
||||
if ds == ds_expected:
|
||||
print_ok("DNS 'DS' record is set correctly at registrar.")
|
||||
elif ds == None:
|
||||
print_error("""This domain's DNS DS record is not set. The DS record is optional. The DS record activates DNSSEC.
|
||||
To set a DS record, you must follow the instructions provided by your domain name registrar and provide to them this information:""")
|
||||
print("")
|
||||
print(" " + ds_correct)
|
||||
print("")
|
||||
else:
|
||||
print_error("""This domain's DNS DS record is incorrect. The chain of trust is broken between the public DNS system
|
||||
and this machine's DNS server. It may take several hours for public DNS to update after a change. If you did not recently
|
||||
make a change, you must resolve this immediately by following the instructions provided by your domain name registrar and
|
||||
provide to them this information:""")
|
||||
print("")
|
||||
print(" " + ds_correct)
|
||||
print("")
|
||||
|
||||
def check_mail_domain(domain, env):
|
||||
# Check the MX record.
|
||||
|
||||
mx = query_dns(domain, "MX", nxdomain=None)
|
||||
expected_mx = "10 " + env['PRIMARY_HOSTNAME']
|
||||
|
||||
if mx == expected_mx:
|
||||
print_ok("Domain's email is directed to this domain. [%s => %s]" % (domain, mx))
|
||||
|
||||
elif mx == None:
|
||||
# A missing MX record is okay on the primary hostname because
|
||||
# the primary hostname's A record (the MX fallback) is... itself,
|
||||
# which is what we want the MX to be.
|
||||
if domain == env['PRIMARY_HOSTNAME']:
|
||||
print_ok("Domain's email is directed to this domain. [%s has no MX record, which is ok]" % (domain,))
|
||||
|
||||
# And a missing MX record is okay on other domains if the A record
|
||||
# matches the A record of the PRIMARY_HOSTNAME. Actually this will
|
||||
# probably confuse DANE TLSA, but we'll let that slide for now.
|
||||
else:
|
||||
domain_a = query_dns(domain, "A", nxdomain=None)
|
||||
primary_a = query_dns(env['PRIMARY_HOSTNAME'], "A", nxdomain=None)
|
||||
if domain_a != None and domain_a == primary_a:
|
||||
print_ok("Domain's email is directed to this domain. [%s has no MX record but its A record is OK]" % (domain,))
|
||||
else:
|
||||
print_error("""This domain's DNS MX record is not set. It 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 other issues listed here.""" % (expected_mx,))
|
||||
|
||||
else:
|
||||
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
|
||||
other issues listed here.""" % (mx, expected_mx))
|
||||
|
||||
# Check that the postmaster@ email address exists.
|
||||
check_alias_exists("postmaster@" + domain, env)
|
||||
|
||||
def check_web_domain(domain, env):
|
||||
# See if the domain's A record resolves to our PUBLIC_IP. This is already checked
|
||||
# for PRIMARY_HOSTNAME, for which it is required for mail specifically. For it and
|
||||
# other domains, it is required to access its website.
|
||||
if domain != env['PRIMARY_HOSTNAME']:
|
||||
ip = query_dns(domain, "A")
|
||||
if ip == env['PUBLIC_IP']:
|
||||
print_ok("Domain resolves to this box's IP address. [%s => %s]" % (domain, env['PUBLIC_IP']))
|
||||
else:
|
||||
print_error("""This domain should resolve to your box's IP address (%s) if you would like the box to serve
|
||||
webmail or a website on this domain. The domain currently resolves to %s in public DNS. It may take several hours for
|
||||
public DNS to update after a change. This problem may result from other issues listed here.""" % (env['PUBLIC_IP'], ip))
|
||||
|
||||
# We need a SSL certificate for PRIMARY_HOSTNAME because that's where the
|
||||
# user will log in with IMAP or webmail. Any other domain we serve a
|
||||
# website for also needs a signed certificate.
|
||||
check_ssl_cert(domain, env)
|
||||
|
||||
def query_dns(qname, rtype, nxdomain='[Not Set]'):
|
||||
resolver = dns.resolver.get_default_resolver()
|
||||
try:
|
||||
response = dns.resolver.query(qname, rtype)
|
||||
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
||||
# Host did not have an answer for this query; not sure what the
|
||||
# difference is between the two exceptions.
|
||||
return nxdomain
|
||||
|
||||
# There may be multiple answers; concatenate the response. Remove trailing
|
||||
# periods from responses since that's how qnames are encoded in DNS but is
|
||||
# confusing for us.
|
||||
return "; ".join(str(r).rstrip('.') for r in response)
|
||||
|
||||
def check_ssl_cert(domain, env):
|
||||
# Check that SSL certificate is signed.
|
||||
|
||||
# Skip the check if the A record is not pointed here.
|
||||
if query_dns(domain, "A") != env['PUBLIC_IP']: return
|
||||
|
||||
# Where is the SSL stored?
|
||||
ssl_key, ssl_certificate, ssl_csr_path = get_domain_ssl_files(domain, env)
|
||||
|
||||
if not os.path.exists(ssl_certificate):
|
||||
print_error("The SSL certificate file for this domain is missing.")
|
||||
return
|
||||
|
||||
# Check that the certificate is good.
|
||||
|
||||
cert_status = check_certificate(domain, ssl_certificate, ssl_key)
|
||||
|
||||
if cert_status == "SELF-SIGNED":
|
||||
fingerprint = shell('check_output', [
|
||||
"openssl",
|
||||
"x509",
|
||||
"-in", ssl_certificate,
|
||||
"-noout",
|
||||
"-fingerprint"
|
||||
])
|
||||
fingerprint = re.sub(".*Fingerprint=", "", fingerprint).strip()
|
||||
|
||||
if domain == env['PRIMARY_HOSTNAME']:
|
||||
print_error("""The SSL certificate for this domain is currently self-signed. You will get a security
|
||||
warning when you check or send email and when visiting this domain in a web browser (for webmail or
|
||||
static site hosting). You may choose to confirm the security exception, but check that the certificate
|
||||
fingerprint matches the following:""")
|
||||
print()
|
||||
print(" " + fingerprint)
|
||||
else:
|
||||
print_error("""The SSL certificate for this domain is currently self-signed. Visitors to a website on
|
||||
this domain will get a security warning. If you are not serving a website on this domain, then it is
|
||||
safe to leave the self-signed certificate in place.""")
|
||||
print()
|
||||
print_block("""You can purchase a signed certificate from many places. You will need to provide this Certificate Signing Request (CSR)
|
||||
to whoever you purchase the SSL certificate from:""")
|
||||
print()
|
||||
print(open(ssl_csr_path).read().strip())
|
||||
print()
|
||||
print_block("""When you purchase an SSL certificate you will receive a certificate in PEM format and possibly a file containing intermediate certificates in PEM format.
|
||||
If you receive intermediate certificates, use a text editor and paste your certificate on top and then the intermediate certificates
|
||||
below it. Save the file and place it onto this machine at %s. Then run "service nginx restart".""" % ssl_certificate)
|
||||
|
||||
elif cert_status == "OK":
|
||||
print_ok("SSL certificate is signed & valid.")
|
||||
|
||||
else:
|
||||
print_error("The SSL certificate has a problem:")
|
||||
print("")
|
||||
print(cert_status)
|
||||
print("")
|
||||
|
||||
def check_certificate(domain, ssl_certificate, ssl_private_key):
|
||||
# Use openssl verify to check the status of a certificate.
|
||||
|
||||
# First check that the certificate is for the right domain. The domain
|
||||
# must be found in the Subject Common Name (CN) or be one of the
|
||||
# Subject Alternative Names.
|
||||
cert_dump = shell('check_output', [
|
||||
"openssl", "x509",
|
||||
"-in", ssl_certificate,
|
||||
"-noout", "-text", "-nameopt", "rfc2253",
|
||||
])
|
||||
cert_dump = cert_dump.split("\n")
|
||||
certificate_names = set()
|
||||
while len(cert_dump) > 0:
|
||||
line = cert_dump.pop(0)
|
||||
|
||||
# Grab from the Subject Common Name. We include the indentation
|
||||
# at the start of the line in case maybe the cert includes the
|
||||
# common name of some other referenced entity (which would be
|
||||
# indented, I hope).
|
||||
m = re.match(" Subject: CN=([^,]+)", line)
|
||||
if m:
|
||||
certificate_names.add(m.group(1))
|
||||
|
||||
# Grab from the Subject Alternative Name, which is a comma-delim
|
||||
# list of names, like DNS:mydomain.com, DNS:otherdomain.com.
|
||||
m = re.match(" X509v3 Subject Alternative Name:", line)
|
||||
if m:
|
||||
names = re.split(",\s*", cert_dump.pop(0).strip())
|
||||
for n in names:
|
||||
m = re.match("DNS:(.*)", n)
|
||||
if m:
|
||||
certificate_names.add(m.group(1))
|
||||
|
||||
if domain is not None and domain not in certificate_names:
|
||||
return "This certificate is for the wrong domain names. It is for %s." % \
|
||||
", ".join(sorted(certificate_names))
|
||||
|
||||
# Second, check that the certificate matches the private key. Get the modulus of the
|
||||
# private key and of the public key in the certificate. They should match. The output
|
||||
# of each command looks like "Modulus=XXXXX".
|
||||
if ssl_private_key is not None:
|
||||
private_key_modulus = shell('check_output', [
|
||||
"openssl", "rsa",
|
||||
"-inform", "PEM",
|
||||
"-noout", "-modulus",
|
||||
"-in", ssl_private_key])
|
||||
cert_key_modulus = shell('check_output', [
|
||||
"openssl", "x509",
|
||||
"-in", ssl_certificate,
|
||||
"-noout", "-modulus"])
|
||||
if private_key_modulus != cert_key_modulus:
|
||||
return "The certificate installed at %s does not correspond to the private key at %s." % (ssl_certificate, ssl_private_key)
|
||||
|
||||
# Next validate that the certificate is valid. This checks whether the certificate
|
||||
# is self-signed, that the chain of trust makes sense, that it is signed by a CA
|
||||
# that Ubuntu has installed on this machine's list of CAs, and I think that it hasn't
|
||||
# expired.
|
||||
|
||||
# In order to verify with openssl, we need to split out any
|
||||
# intermediary certificates in the chain (if any) from our
|
||||
# certificate (at the top). They need to be passed separately.
|
||||
|
||||
cert = open(ssl_certificate).read()
|
||||
m = re.match(r'(-*BEGIN CERTIFICATE-*.*?-*END CERTIFICATE-*)(.*)', cert, re.S)
|
||||
if m == None:
|
||||
return "The certificate file is an invalid PEM certificate."
|
||||
mycert, chaincerts = m.groups()
|
||||
|
||||
# This command returns a non-zero exit status in most cases, so trap errors.
|
||||
|
||||
retcode, verifyoutput = shell('check_output', [
|
||||
"openssl",
|
||||
"verify", "-verbose",
|
||||
"-purpose", "sslserver", "-policy_check",]
|
||||
+ ([] if chaincerts.strip() == "" else ["-untrusted", "/dev/stdin"])
|
||||
+ [ssl_certificate],
|
||||
input=chaincerts.encode('ascii'),
|
||||
trap=True)
|
||||
|
||||
if "self signed" in verifyoutput:
|
||||
# Certificate is self-signed.
|
||||
return "SELF-SIGNED"
|
||||
elif retcode == 0:
|
||||
# Certificate is OK.
|
||||
return "OK"
|
||||
else:
|
||||
return verifyoutput.strip()
|
||||
|
||||
def print_ok(message):
|
||||
print_block(message, first_line="✓ ")
|
||||
|
||||
def print_error(message):
|
||||
print_block(message, first_line="✖ ")
|
||||
|
||||
try:
|
||||
terminal_columns = int(shell('check_output', ['stty', 'size']).split()[1])
|
||||
except:
|
||||
terminal_columns = 76
|
||||
def print_block(message, first_line=" "):
|
||||
print(first_line, end='')
|
||||
message = re.sub("\n\s*", " ", message)
|
||||
words = re.split("(\s+)", message)
|
||||
linelen = 0
|
||||
for w in words:
|
||||
if linelen + len(w) > terminal_columns-1-len(first_line):
|
||||
print()
|
||||
print(" ", end="")
|
||||
linelen = 0
|
||||
if linelen == 0 and w.strip() == "": continue
|
||||
print(w, end="")
|
||||
linelen += len(w)
|
||||
if linelen > 0:
|
||||
print()
|
||||
|
||||
if __name__ == "__main__":
|
||||
from utils import load_environment
|
||||
run_checks(load_environment())
|
||||
@@ -1,62 +0,0 @@
|
||||
# Spam filtering with dspam.
|
||||
#
|
||||
# This mostly works. But dspam crashes. So..... we're not using this.
|
||||
|
||||
apt-get -q -y install dspam libdspam7-drv-sqlite3 dovecot-antispam dovecot-sieve
|
||||
|
||||
# Let it turn on.
|
||||
sed -i "s/START=no/START=yes/" /etc/default/dspam
|
||||
|
||||
# Override some of the basic settings that have default values we don't like.
|
||||
# Listen as an SMTP server, and pass messages back directly to dovecot.
|
||||
tools/editconf.py /etc/dspam/dspam.conf -s \
|
||||
Home=$STORAGE_ROOT/mail/dspam \
|
||||
ServerMode=standard \
|
||||
ServerHost=127.0.0.1 \
|
||||
ServerParameters=--deliver=innocent \
|
||||
DeliveryProto=LMTP \
|
||||
DeliveryHost=/var/run/dovecot/lmtp \
|
||||
Tokenizer=osb
|
||||
|
||||
# Put other settings into a local configuration file.
|
||||
cat > /etc/dspam/dspam.d/local.conf << EOF;
|
||||
IgnoreHeader X-Spam-Status
|
||||
IgnoreHeader X-Spam-Scanned
|
||||
IgnoreHeader X-Virus-Scanner-Result
|
||||
IgnoreHeader X-Virus-Scanned
|
||||
IgnoreHeader X-DKIM
|
||||
IgnoreHeader DKIM-Signature
|
||||
IgnoreHeader DomainKey-Signature
|
||||
IgnoreHeader X-Google-Dkim-Signature
|
||||
EOF
|
||||
|
||||
# Global preferences.
|
||||
tools/editconf.py /etc/dspam/default.prefs \
|
||||
spamAction=deliver \
|
||||
signatureLocation=headers \
|
||||
showFactors=on
|
||||
|
||||
# Hook into postfix. Replace dovecot with dspam as the mail delivery agent.
|
||||
# dspam is configured above to pass mail on to dovecot next.
|
||||
tools/editconf.py /etc/postfix/main.cf virtual_transport=lmtp:[127.0.0.1]:2424
|
||||
|
||||
# Hook into dovecot... these aren't tested.
|
||||
|
||||
# Automatically move spam into a folder called Spam. Enable the sieve plugin.
|
||||
# (Note: Be careful if we want to use multiple plugins later.)
|
||||
sudo sed -i "s/#mail_plugins = .*/mail_plugins = \$mail_plugins sieve/" /etc/dovecot/conf.d/20-lmtp.conf
|
||||
|
||||
# The sieve scripts are installed by users_update.sh.
|
||||
|
||||
# to detect when a message moves between folders so we can
|
||||
# pass it to dspam for training. (Be careful if we use multiple plugins later.)
|
||||
# This is not finished.
|
||||
sudo sed -i "s/#mail_plugins = .*/mail_plugins = \$mail_plugins antispam/" /etc/dovecot/conf.d/20-imap.conf
|
||||
|
||||
# Create storage space.
|
||||
mkdir -p $STORAGE_ROOT/mail/dspam
|
||||
chown dspam:dspam $STORAGE_ROOT/mail/dspam
|
||||
|
||||
service dspam restart
|
||||
service postfix restart
|
||||
|
||||
44
setup/bootstrap.sh
Executable file
44
setup/bootstrap.sh
Executable file
@@ -0,0 +1,44 @@
|
||||
#!/bin/bash
|
||||
#########################################################
|
||||
# This script is intended to be run like this:
|
||||
#
|
||||
# curl https://.../bootstrap.sh | sudo bash
|
||||
#
|
||||
#########################################################
|
||||
|
||||
if [ -z "$TAG" ]; then
|
||||
TAG=v0.02
|
||||
fi
|
||||
|
||||
# Are we running as root?
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "This script must be run as root. Did you leave out sudo?"
|
||||
exit
|
||||
fi
|
||||
|
||||
# Go to root's home directory.
|
||||
cd
|
||||
|
||||
# Clone the Mail-in-a-Box repository if it doesn't exist.
|
||||
if [ ! -d mailinabox ]; then
|
||||
echo Installing git . . .
|
||||
apt-get -q -q install -y git
|
||||
|
||||
echo Downloading Mail-in-a-Box . . .
|
||||
git clone -b $TAG --depth 1 https://github.com/mail-in-a-box/mailinabox 2> /dev/null
|
||||
cd mailinabox
|
||||
|
||||
# If it does exist, update it.
|
||||
else
|
||||
echo Updating Mail-in-a-Box to $TAG . . .
|
||||
cd mailinabox
|
||||
git fetch
|
||||
if ! git checkout -q $TAG; then
|
||||
echo "Update failed. Did you modify something in `pwd`?"
|
||||
exit
|
||||
fi
|
||||
fi
|
||||
|
||||
# Start setup script.
|
||||
setup/start.sh
|
||||
|
||||
253
setup/csr_country_codes.tsv
Normal file
253
setup/csr_country_codes.tsv
Normal file
@@ -0,0 +1,253 @@
|
||||
# This list is derived from https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2.
|
||||
# The columns are ISO_3166-1_alpha-2 code, display name, Wikipedia page name.
|
||||
# The top 20 countries by number of Internet users are grouped first, see
|
||||
# https://en.wikipedia.org/wiki/List_of_countries_by_number_of_Internet_users.
|
||||
BR Brazil
|
||||
CA Canada
|
||||
CN China
|
||||
EG Egypt
|
||||
FR France
|
||||
DE Germany
|
||||
IN India
|
||||
ID Indonesia
|
||||
IT Italy
|
||||
JP Japan
|
||||
MX Mexico
|
||||
NG Nigeria
|
||||
PH Philippines
|
||||
RU Russian Federation Russia
|
||||
ES Spain
|
||||
KR South Korea
|
||||
TR Turkey
|
||||
GB United Kingdom
|
||||
US United States
|
||||
VN Vietnam
|
||||
AD Andorra
|
||||
AE United Arab Emirates
|
||||
AF Afghanistan
|
||||
AG Antigua and Barbuda
|
||||
AI Anguilla
|
||||
AL Albania
|
||||
AM Armenia
|
||||
AO Angola
|
||||
AQ Antarctica
|
||||
AR Argentina
|
||||
AS American Samoa
|
||||
AT Austria
|
||||
AU Australia
|
||||
AW Aruba
|
||||
AX Åland Islands
|
||||
AZ Azerbaijan
|
||||
BA Bosnia and Herzegovina
|
||||
BB Barbados
|
||||
BD Bangladesh
|
||||
BE Belgium
|
||||
BF Burkina Faso
|
||||
BG Bulgaria
|
||||
BH Bahrain
|
||||
BI Burundi
|
||||
BJ Benin
|
||||
BL Saint Barthélemy
|
||||
BM Bermuda
|
||||
BN Brunei
|
||||
BO Bolivia
|
||||
BQ Bonaire, Sint Eustatius and Saba Caribbean Netherlands
|
||||
BS Bahamas The Bahamas
|
||||
BT Bhutan
|
||||
BV Bouvet Island
|
||||
BW Botswana
|
||||
BY Belarus
|
||||
BZ Belize
|
||||
CC Cocos (Keeling) Islands
|
||||
CD Congo, the Democratic Republic of the Democratic Republic of the Congo
|
||||
CF Central African Republic
|
||||
CG Congo Republic of the Congo
|
||||
CH Switzerland
|
||||
CI Côte d'Ivoire
|
||||
CK Cook Islands
|
||||
CL Chile
|
||||
CM Cameroon
|
||||
CO Colombia
|
||||
CR Costa Rica
|
||||
CU Cuba
|
||||
CV Cabo Verde
|
||||
CW Curaçao
|
||||
CX Christmas Island
|
||||
CY Cyprus
|
||||
CZ Czech Republic
|
||||
DJ Djibouti
|
||||
DK Denmark
|
||||
DM Dominica
|
||||
DO Dominican Republic
|
||||
DZ Algeria
|
||||
EC Ecuador
|
||||
EE Estonia
|
||||
EH Western Sahara
|
||||
ER Eritrea
|
||||
ET Ethiopia
|
||||
FI Finland
|
||||
FJ Fiji
|
||||
FK Falkland Islands (Malvinas) Falkland Islands
|
||||
FM Federated States of Micronesia
|
||||
FO Faroe Islands
|
||||
GA Gabon
|
||||
GD Grenada
|
||||
GE Georgia Georgia (country)
|
||||
GF French Guiana
|
||||
GG Guernsey
|
||||
GH Ghana
|
||||
GI Gibraltar
|
||||
GL Greenland
|
||||
GM Gambia The Gambia
|
||||
GN Guinea
|
||||
GP Guadeloupe
|
||||
GQ Equatorial Guinea
|
||||
GR Greece
|
||||
GS South Georgia and the South Sandwich Islands
|
||||
GT Guatemala
|
||||
GU Guam
|
||||
GW Guinea-Bissau
|
||||
GY Guyana
|
||||
HK Hong Kong
|
||||
HM Heard Island and McDonald Islands
|
||||
HN Honduras
|
||||
HR Croatia
|
||||
HT Haiti
|
||||
HU Hungary
|
||||
IE Ireland Republic of Ireland
|
||||
IL Israel
|
||||
IM Isle of Man
|
||||
IO British Indian Ocean Territory
|
||||
IQ Iraq
|
||||
IR Iran
|
||||
IS Iceland
|
||||
JE Jersey
|
||||
JM Jamaica
|
||||
JO Jordan
|
||||
KE Kenya
|
||||
KG Kyrgyzstan
|
||||
KH Cambodia
|
||||
KI Kiribati
|
||||
KM Comoros
|
||||
KN Saint Kitts and Nevis
|
||||
KP North Korea
|
||||
KW Kuwait
|
||||
KY Cayman Islands
|
||||
KZ Kazakhstan
|
||||
LA Laos
|
||||
LB Lebanon
|
||||
LC Saint Lucia
|
||||
LI Liechtenstein
|
||||
LK Sri Lanka
|
||||
LR Liberia
|
||||
LS Lesotho
|
||||
LT Lithuania
|
||||
LU Luxembourg
|
||||
LV Latvia
|
||||
LY Libya
|
||||
MA Morocco
|
||||
MC Monaco
|
||||
MD Moldova
|
||||
ME Montenegro
|
||||
MF Saint Martin (French part) Collectivity of Saint Martin
|
||||
MG Madagascar
|
||||
MH Marshall Islands
|
||||
MK Macedonia Republic of Macedonia
|
||||
ML Mali
|
||||
MM Myanmar
|
||||
MN Mongolia
|
||||
MO Macao Macau
|
||||
MP Northern Mariana Islands
|
||||
MQ Martinique
|
||||
MR Mauritania
|
||||
MS Montserrat
|
||||
MT Malta
|
||||
MU Mauritius
|
||||
MV Maldives
|
||||
MW Malawi
|
||||
MY Malaysia
|
||||
MZ Mozambique
|
||||
NA Namibia
|
||||
NC New Caledonia
|
||||
NE Niger
|
||||
NF Norfolk Island
|
||||
NI Nicaragua
|
||||
NL Netherlands
|
||||
NO Norway
|
||||
NP Nepal
|
||||
NR Nauru
|
||||
NU Niue
|
||||
NZ New Zealand
|
||||
OM Oman
|
||||
PA Panama
|
||||
PE Peru
|
||||
PF French Polynesia
|
||||
PG Papua New Guinea
|
||||
PK Pakistan
|
||||
PL Poland
|
||||
PM Saint Pierre and Miquelon
|
||||
PN Pitcairn Pitcairn Islands
|
||||
PR Puerto Rico
|
||||
PS Palestine State of Palestine
|
||||
PT Portugal
|
||||
PW Palau
|
||||
PY Paraguay
|
||||
QA Qatar
|
||||
RE Réunion
|
||||
RO Romania
|
||||
RS Serbia
|
||||
RW Rwanda
|
||||
SA Saudi Arabia
|
||||
SB Solomon Islands
|
||||
SC Seychelles
|
||||
SD Sudan
|
||||
SE Sweden
|
||||
SG Singapore
|
||||
SH Saint Helena, Ascension and Tristan da Cunha
|
||||
SI Slovenia
|
||||
SJ Svalbard and Jan Mayen
|
||||
SK Slovakia
|
||||
SL Sierra Leone
|
||||
SM San Marino
|
||||
SN Senegal
|
||||
SO Somalia
|
||||
SR Suriname
|
||||
SS South Sudan
|
||||
ST Sao Tome and Principe
|
||||
SV El Salvador
|
||||
SX Sint Maarten (Dutch part) Sint Maarten
|
||||
SY Syria
|
||||
SZ Swaziland
|
||||
TC Turks and Caicos Islands
|
||||
TD Chad
|
||||
TF French Southern Territories French Southern and Antarctic Lands
|
||||
TG Togo
|
||||
TH Thailand
|
||||
TJ Tajikistan
|
||||
TK Tokelau
|
||||
TL Timor-Leste East Timor
|
||||
TM Turkmenistan
|
||||
TN Tunisia
|
||||
TO Tonga
|
||||
TT Trinidad and Tobago
|
||||
TV Tuvalu
|
||||
TW Taiwan
|
||||
TZ Tanzania
|
||||
UA Ukraine
|
||||
UG Uganda
|
||||
UM United States Minor Outlying Islands
|
||||
UY Uruguay
|
||||
UZ Uzbekistan
|
||||
VA Vatican City
|
||||
VC Saint Vincent and the Grenadines
|
||||
VE Venezuela
|
||||
VG Virgin Islands, British British Virgin Islands
|
||||
VI Virgin Islands, U.S. United States Virgin Islands
|
||||
VU Vanuatu
|
||||
WF Wallis and Futuna
|
||||
WS Samoa
|
||||
YE Yemen
|
||||
YT Mayotte
|
||||
ZA South Africa
|
||||
ZM Zambia
|
||||
ZW Zimbabwe
|
||||
|
23
setup/dns.sh
23
setup/dns.sh
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# DNS: Configure a DNS server using nsd
|
||||
#######################################
|
||||
# DNS: Configure a DNS server to host our own DNS
|
||||
# -----------------------------------------------
|
||||
|
||||
# This script installs packages, but the DNS zone files are only
|
||||
# created by the /dns/update API in the management server because
|
||||
@@ -9,21 +9,25 @@
|
||||
|
||||
source setup/functions.sh # load our functions
|
||||
|
||||
# Install nsd, our DNS server software, and ldnsutils which helps
|
||||
# Install `nsd`, our DNS server software, and `ldnsutils` which helps
|
||||
# us sign zones for DNSSEC.
|
||||
|
||||
# ...but first, we have to create the user because the
|
||||
# current Ubuntu forgets to do so in the .deb
|
||||
# see issue #25 and https://bugs.launchpad.net/ubuntu/+source/nsd/+bug/1311886
|
||||
if id nsd > /dev/null 2>&1; then
|
||||
true; #echo "nsd user exists... good";
|
||||
true; #echo "nsd user exists... good"; #NODOC
|
||||
else
|
||||
useradd nsd;
|
||||
fi
|
||||
|
||||
# Okay now install the packages.
|
||||
#
|
||||
# * nsd: The non-recursive nameserver that publishes our DNS records.
|
||||
# * ldnsutils: Helper utilities for signing DNSSEC zones.
|
||||
# * openssh-client: Provides ssh-keyscan which we use to create SSHFP records.
|
||||
|
||||
apt_install nsd ldnsutils
|
||||
apt_install nsd ldnsutils openssh-client
|
||||
|
||||
# Prepare nsd's configuration.
|
||||
|
||||
@@ -33,7 +37,7 @@ sudo mkdir -p /var/run/nsd
|
||||
|
||||
mkdir -p "$STORAGE_ROOT/dns/dnssec";
|
||||
if [ ! -f "$STORAGE_ROOT/dns/dnssec/keys.conf" ]; then
|
||||
# These two steps take a while.
|
||||
echo "Generating DNSSEC signing keys. This may take a few minutes..."
|
||||
|
||||
# Create the Key-Signing Key (KSK) (-k) which is the so-called
|
||||
# Secure Entry Point. Use a NSEC3-compatible algorithm (best
|
||||
@@ -49,9 +53,10 @@ if [ ! -f "$STORAGE_ROOT/dns/dnssec/keys.conf" ]; then
|
||||
ZSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -a RSASHA1-NSEC3-SHA1 -b 1024 _domain_);
|
||||
|
||||
# These generate two sets of files like:
|
||||
# K_domain_.+007+08882.ds <- DS record for adding to NSD configuration files
|
||||
# K_domain_.+007+08882.key <- public key (goes into DS record & upstream DNS provider like your registrar)
|
||||
# K_domain_.+007+08882.private <- private key (secret!)
|
||||
#
|
||||
# * `K_domain_.+007+08882.ds`: DS record to provide to domain name registrar
|
||||
# * `K_domain_.+007+08882.key`: public key (goes into DS record & upstream DNS provider like your registrar)
|
||||
# * `K_domain_.+007+08882.private`: private key (secret!)
|
||||
|
||||
# The filenames are unpredictable and encode the key generation
|
||||
# options. So we'll store the names of the files we just generated.
|
||||
|
||||
57
setup/firstuser.sh
Normal file
57
setup/firstuser.sh
Normal file
@@ -0,0 +1,57 @@
|
||||
# If there aren't any mail users yet, create one.
|
||||
if [ -z "`tools/mail.py user`" ]; then
|
||||
# The outut of "tools/mail.py user" is a list of mail users. If there
|
||||
# aren't any yet, it'll be empty.
|
||||
|
||||
# If we didn't ask for an email address at the start, do so now.
|
||||
if [ -z "$EMAIL_ADDR" ]; then
|
||||
# In an interactive shell, ask the user for an email address.
|
||||
if [ -z "$NONINTERACTIVE" ]; then
|
||||
input_box "Mail Account" \
|
||||
"Let's create your first mail account.
|
||||
\n\nWhat email address do you want?" \
|
||||
me@`get_default_hostname` \
|
||||
EMAIL_ADDR
|
||||
|
||||
if [ -z "$EMAIL_ADDR" ]; then
|
||||
# user hit ESC/cancel
|
||||
exit
|
||||
fi
|
||||
while ! management/mailconfig.py validate-email "$EMAIL_ADDR"
|
||||
do
|
||||
input_box "Mail Account" \
|
||||
"That's not a valid email address.
|
||||
\n\nWhat email address do you want?" \
|
||||
$EMAIL_ADDR \
|
||||
EMAIL_ADDR
|
||||
if [ -z "$EMAIL_ADDR" ]; then
|
||||
# user hit ESC/cancel
|
||||
exit
|
||||
fi
|
||||
done
|
||||
|
||||
# But in a non-interactive shell, just make something up.
|
||||
# This is normally for testing.
|
||||
else
|
||||
# Use me@PRIMARY_HOSTNAME
|
||||
EMAIL_ADDR=me@$PRIMARY_HOSTNAME
|
||||
EMAIL_PW=1234
|
||||
echo
|
||||
echo "Creating a new administrative mail account for $EMAIL_ADDR with password $EMAIL_PW."
|
||||
echo
|
||||
fi
|
||||
else
|
||||
echo
|
||||
echo "Okay. I'm about to set up $EMAIL_ADDR for you. This account will also"
|
||||
echo "have access to the box's control panel."
|
||||
fi
|
||||
|
||||
# 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
|
||||
|
||||
# Make it an admin.
|
||||
hide_output tools/mail.py user make-admin $EMAIL_ADDR
|
||||
|
||||
# Create an alias to which we'll direct all automatically-created administrative aliases.
|
||||
tools/mail.py alias add administrator@$PRIMARY_HOSTNAME $EMAIL_ADDR
|
||||
fi
|
||||
@@ -70,69 +70,71 @@ function get_default_hostname {
|
||||
printf '%s\n' "$1" # return this value
|
||||
}
|
||||
|
||||
function get_default_publicip {
|
||||
# Get the machine's public IP address. The machine might have
|
||||
# an IP on a private network, but the IP address that we put
|
||||
# into DNS must be one on the public Internet. Try a public
|
||||
# API, but if that fails (maybe we don't have Internet access
|
||||
# right now) then use the IP address that this machine knows
|
||||
# itself as.
|
||||
get_publicip_from_web_service || get_publicip_fallback
|
||||
}
|
||||
|
||||
function get_default_publicipv6 {
|
||||
get_publicipv6_from_web_service || get_publicipv6_fallback
|
||||
}
|
||||
|
||||
function get_publicip_from_web_service {
|
||||
# This seems to be the most reliable way to determine the
|
||||
# machine's public IP address: asking a very nice web API
|
||||
# for how they see us. Thanks go out to icanhazip.com.
|
||||
curl -4 --fail --silent icanhazip.com 2>/dev/null
|
||||
# See: https://major.io/icanhazip-com-faq/
|
||||
#
|
||||
# Pass '4' or '6' as an argument to this function to specify
|
||||
# what type of address to get (IPv4, IPv6).
|
||||
curl -$1 --fail --silent --max-time 15 icanhazip.com 2>/dev/null
|
||||
}
|
||||
|
||||
function get_publicipv6_from_web_service {
|
||||
curl -6 --fail --silent icanhazip.com 2>/dev/null
|
||||
}
|
||||
function get_default_privateip {
|
||||
# Return the IP address of the network interface connected
|
||||
# to the Internet.
|
||||
#
|
||||
# Pass '4' or '6' as an argument to this function to specify
|
||||
# what type of address to get (IPv4, IPv6).
|
||||
#
|
||||
# We used to use `hostname -I` and then filter for either
|
||||
# IPv4 or IPv6 addresses. However if there are multiple
|
||||
# network interfaces on the machine, not all may be for
|
||||
# reaching the Internet.
|
||||
#
|
||||
# Instead use `ip route get` which asks the kernel to use
|
||||
# the system's routes to select which interface would be
|
||||
# used to reach a public address. We'll use 8.8.8.8 as
|
||||
# the destination. It happens to be Google Public DNS, but
|
||||
# no connection is made. We're just seeing how the box
|
||||
# would connect to it. There many be multiple IP addresses
|
||||
# assigned to an interface. `ip route get` reports the
|
||||
# preferred. That's good enough for us. See issue #121.
|
||||
#
|
||||
# With IPv6, the best route may be via an interface that
|
||||
# only has a link-local address (fe80::*). These addresses
|
||||
# are only unique to an interface and so need an explicit
|
||||
# interface specification in order to use them with bind().
|
||||
# In these cases, we append "%interface" to the address.
|
||||
# See the Notes section in the man page for getaddrinfo and
|
||||
# https://discourse.mailinabox.email/t/update-broke-mailinabox/34/9.
|
||||
#
|
||||
# Also see ae67409603c49b7fa73c227449264ddd10aae6a9 and
|
||||
# issue #3 for why/how we originally added IPv6.
|
||||
|
||||
function get_publicip_fallback {
|
||||
# Return the IP address that this machine knows itself as.
|
||||
# It certainly may not be the IP address that this machine
|
||||
# operates as on the public Internet. The machine might
|
||||
# have multiple addresses if it has multiple network adapters.
|
||||
set -- $(hostname --ip-address 2>/dev/null) \
|
||||
$(hostname --all-ip-addresses 2>/dev/null)
|
||||
while (( $# )) && { ! is_ipv4 "$1" || is_loopback_ip "$1"; }; do
|
||||
shift
|
||||
done
|
||||
printf '%s\n' "$1" # return this value
|
||||
}
|
||||
target=8.8.8.8
|
||||
|
||||
function get_publicipv6_fallback {
|
||||
set -- $(hostname --ip-address 2>/dev/null) \
|
||||
$(hostname --all-ip-addresses 2>/dev/null)
|
||||
while (( $# )) && { ! is_ipv6 "$1" || is_loopback_ipv6 "$1"; }; do
|
||||
shift
|
||||
done
|
||||
printf '%s\n' "$1" # return this value
|
||||
}
|
||||
# For the IPv6 route, use the corresponding IPv6 address
|
||||
# of Google Public DNS. Again, it doesn't matter so long
|
||||
# as it's an address on the public Internet.
|
||||
if [ "$1" == "6" ]; then target=2001:4860:4860::8888; fi
|
||||
|
||||
function is_ipv4 {
|
||||
# helper for get_publicip_fallback
|
||||
[[ "$1" == *.*.*.* ]]
|
||||
}
|
||||
# Get the route information.
|
||||
route=$(ip -$1 -o route get $target | grep -v unreachable)
|
||||
|
||||
function is_ipv6 {
|
||||
[[ "$1" == *:*:* ]]
|
||||
}
|
||||
# Parse the address out of the route information.
|
||||
address=$(echo $route | sed "s/.* src \([^ ]*\).*/\1/")
|
||||
|
||||
function is_loopback_ip {
|
||||
# helper for get_publicip_fallback
|
||||
[[ "$1" == 127.* ]]
|
||||
}
|
||||
if [[ "$1" == "6" && $address == fe80:* ]]; then
|
||||
# For IPv6 link-local addresses, parse the interface out
|
||||
# of the route information and append it with a '%'.
|
||||
interface=$(echo $route | sed "s/.* dev \([^ ]*\).*/\1/")
|
||||
address=$address%$interface
|
||||
fi
|
||||
|
||||
function is_loopback_ipv6 {
|
||||
[[ "$1" == ::1 ]]
|
||||
echo $address
|
||||
|
||||
}
|
||||
|
||||
function ufw_allow {
|
||||
@@ -145,3 +147,29 @@ function ufw_allow {
|
||||
function restart_service {
|
||||
hide_output service $1 restart
|
||||
}
|
||||
|
||||
## Dialog Functions ##
|
||||
function message_box {
|
||||
dialog --title "$1" --msgbox "$2" 0 0
|
||||
}
|
||||
|
||||
function input_box {
|
||||
# input_box "title" "prompt" "defaultvalue" VARIABLE
|
||||
# The user's input will be stored in the variable VARIABLE.
|
||||
# The exit code from dialog will be stored in VARIABLE_EXITCODE.
|
||||
declare -n result=$4
|
||||
declare -n result_code=$4_EXITCODE
|
||||
result=$(dialog --stdout --title "$1" --inputbox "$2" 0 0 "$3")
|
||||
result_code=$?
|
||||
}
|
||||
|
||||
function input_menu {
|
||||
# input_menu "title" "prompt" "tag item tag item" VARIABLE
|
||||
# The user's input will be stored in the variable VARIABLE.
|
||||
# The exit code from dialog will be stored in VARIABLE_EXITCODE.
|
||||
declare -n result=$4
|
||||
declare -n result_code=$4_EXITCODE
|
||||
local IFS=^$'\n'
|
||||
result=$(dialog --stdout --title "$1" --menu "$2" 0 0 0 $3)
|
||||
result_code=$?
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Dovecot (IMAP and LDA)
|
||||
# ----------------------
|
||||
#
|
||||
# Dovecot is *both* the IMAP server (the protocol that email applications
|
||||
# use to query a mailbox) as well as the local delivery agent (LDA),
|
||||
@@ -17,13 +18,13 @@
|
||||
source setup/functions.sh # load our functions
|
||||
source /etc/mailinabox.conf # load global vars
|
||||
|
||||
# Install packages.
|
||||
# ### Install packages and basic setup
|
||||
|
||||
apt_install \
|
||||
dovecot-core dovecot-imapd dovecot-lmtpd dovecot-sqlite sqlite3 \
|
||||
dovecot-sieve dovecot-managesieved
|
||||
|
||||
# The dovecot-imapd dovecot-lmtpd packages automatically enable IMAP and LMTP protocols.
|
||||
# The dovecot-imapd and dovecot-lmtpd packages automatically enable IMAP and LMTP protocols.
|
||||
|
||||
# Set the location where we'll store user mailboxes.
|
||||
tools/editconf.py /etc/dovecot/conf.d/10-mail.conf \
|
||||
@@ -31,7 +32,7 @@ tools/editconf.py /etc/dovecot/conf.d/10-mail.conf \
|
||||
mail_privileged_group=mail \
|
||||
first_valid_uid=0
|
||||
|
||||
# IMAP
|
||||
# ### IMAP
|
||||
|
||||
# Require that passwords are sent over SSL only, and allow the usual IMAP authentication mechanisms.
|
||||
# The LOGIN mechanism is supposedly for Microsoft products like Outlook to do SMTP login (I guess
|
||||
@@ -53,7 +54,16 @@ tools/editconf.py /etc/dovecot/conf.d/10-ssl.conf \
|
||||
sed -i "s/#port = 143/port = 0/" /etc/dovecot/conf.d/10-master.conf
|
||||
sed -i "s/#port = 110/port = 0/" /etc/dovecot/conf.d/10-master.conf
|
||||
|
||||
# LDA (LMTP)
|
||||
# Make IMAP IDLE slightly more efficient. By default, Dovecot says "still here"
|
||||
# every two minutes. With K-9 mail, the bandwidth and battery usage due to
|
||||
# this are minimal. But for good measure, let's go to 4 minutes to halve the
|
||||
# bandwidth and number of times the device's networking might be woken up.
|
||||
# The risk is that if the connection is silent for too long it might be reset
|
||||
# by a peer. See #129 and http://razor.occams.info/blog/2014/08/09/how-bad-is-imap-idle/.
|
||||
tools/editconf.py /etc/dovecot/conf.d/20-imap.conf \
|
||||
imap_idle_notify_interval="4 mins"
|
||||
|
||||
# ### LDA (LMTP)
|
||||
|
||||
# Enable Dovecot's LDA service with the LMTP protocol. It will listen
|
||||
# in port 10026, and Spamassassin will be configured to pass mail there.
|
||||
@@ -85,12 +95,12 @@ EOF
|
||||
tools/editconf.py /etc/dovecot/conf.d/15-lda.conf \
|
||||
postmaster_address=postmaster@$PRIMARY_HOSTNAME
|
||||
|
||||
# SIEVE
|
||||
# ### Sieve
|
||||
|
||||
# Enable the Dovecot sieve plugin which let's users run scripts that process
|
||||
# mail as it comes in. We'll also set a global script that moves mail marked
|
||||
# as spam by Spamassassin into the user's Spam folder.
|
||||
sudo sed -i "s/#mail_plugins = .*/mail_plugins = \$mail_plugins sieve/" /etc/dovecot/conf.d/20-lmtp.conf
|
||||
sed -i "s/#mail_plugins = .*/mail_plugins = \$mail_plugins sieve/" /etc/dovecot/conf.d/20-lmtp.conf
|
||||
|
||||
cat > /etc/dovecot/conf.d/99-local-sieve.conf << EOF;
|
||||
plugin {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Postfix (SMTP)
|
||||
# --------------
|
||||
#
|
||||
# Postfix handles the transmission of email between servers
|
||||
# using the SMTP protocol. It is a Mail Transfer Agent (MTA).
|
||||
@@ -29,11 +30,11 @@
|
||||
source setup/functions.sh # load our functions
|
||||
source /etc/mailinabox.conf # load global vars
|
||||
|
||||
# Install packages.
|
||||
# ### Install packages.
|
||||
|
||||
apt_install postfix postgrey postfix-pcre
|
||||
apt_install postfix postgrey postfix-pcre ca-certificates
|
||||
|
||||
# Basic Settings
|
||||
# ### Basic Settings
|
||||
|
||||
# Have postfix listen on all network interfaces, set our name (the Debian default seems to be localhost),
|
||||
# and set the name of the local machine to localhost for xxx@localhost mail (but I don't think this will have any effect because
|
||||
@@ -41,18 +42,19 @@ apt_install postfix postgrey postfix-pcre
|
||||
tools/editconf.py /etc/postfix/main.cf \
|
||||
inet_interfaces=all \
|
||||
myhostname=$PRIMARY_HOSTNAME\
|
||||
smtpd_banner="\$myhostname ESMTP Hi, I'm a Mail-in-a-Box (Ubuntu/Postfix; see https://github.com/joshdata/mailinabox)" \
|
||||
smtpd_banner="\$myhostname ESMTP Hi, I'm a Mail-in-a-Box (Ubuntu/Postfix; see https://mailinabox.email/)" \
|
||||
mydestination=localhost
|
||||
|
||||
# Outgoing Mail
|
||||
# ### Outgoing Mail
|
||||
|
||||
# Enable the 'submission' port 587 smtpd server and tweak its settings.
|
||||
# a) Require the best ciphers for incoming connections per http://baldric.net/2013/12/07/tls-ciphers-in-postfix-and-dovecot/.
|
||||
# but without affecting opportunistic TLS on incoming mail, which will allow any cipher (it's better than none).
|
||||
# b) Give it a different name in syslog to distinguish it from the port 25 smtpd server.
|
||||
# c) Add a new cleanup service specific to the submission service ('authclean')
|
||||
# that filters out privacy-sensitive headers on mail being sent out by
|
||||
# authenticated users.
|
||||
#
|
||||
# * Require the best ciphers for incoming connections per http://baldric.net/2013/12/07/tls-ciphers-in-postfix-and-dovecot/.
|
||||
# but without affecting opportunistic TLS on incoming mail, which will allow any cipher (it's better than none).
|
||||
# * Give it a different name in syslog to distinguish it from the port 25 smtpd server.
|
||||
# * Add a new cleanup service specific to the submission service ('authclean')
|
||||
# that filters out privacy-sensitive headers on mail being sent out by
|
||||
# authenticated users.
|
||||
tools/editconf.py /etc/postfix/master.cf -s -w \
|
||||
"submission=inet n - - - - smtpd
|
||||
-o syslog_name=postfix/submission
|
||||
@@ -64,7 +66,7 @@ tools/editconf.py /etc/postfix/master.cf -s -w \
|
||||
# Install the `outgoing_mail_header_filters` file required by the new 'authclean' service.
|
||||
cp conf/postfix_outgoing_mail_header_filters /etc/postfix/outgoing_mail_header_filters
|
||||
|
||||
# Enable TLS on incoming connections (i.e. ports 25 *and* 587) and
|
||||
# Enable TLS on these and all other connections (i.e. ports 25 *and* 587) and
|
||||
# require TLS before a user is allowed to authenticate. This also makes
|
||||
# opportunistic TLS available on *incoming* mail.
|
||||
tools/editconf.py /etc/postfix/main.cf \
|
||||
@@ -74,48 +76,64 @@ tools/editconf.py /etc/postfix/main.cf \
|
||||
smtpd_tls_key_file=$STORAGE_ROOT/ssl/ssl_private_key.pem \
|
||||
smtpd_tls_received_header=yes
|
||||
|
||||
# Prevent non-authenticated users from sending mail that requires being
|
||||
# relayed elsewhere. We don't want to be an "open relay". On outbound
|
||||
# mail, require one of:
|
||||
#
|
||||
# * permit_sasl_authenticated: Authenticated users (i.e. on port 587).
|
||||
# * permit_mynetworks: Mail that originates locally.
|
||||
# * reject_unauth_destination: No one else. (Permits mail whose destination is local and rejects other mail.)
|
||||
tools/editconf.py /etc/postfix/main.cf \
|
||||
smtpd_relay_restrictions=permit_sasl_authenticated,permit_mynetworks,reject_unauth_destination
|
||||
|
||||
|
||||
# ### DANE
|
||||
#
|
||||
# When connecting to remote SMTP servers, prefer TLS and use DANE if available.
|
||||
# Postfix queries for the TLSA record on the destination MX host. If no TLSA records are found,
|
||||
#
|
||||
# Prefering ("opportunistic") TLS means Postfix will accept whatever SSL certificate the remote
|
||||
# end provides, if the remote end offers STARTTLS during the connection. DANE takes this a
|
||||
# step further:
|
||||
#
|
||||
# Postfix queries DNS for the TLSA record on the destination MX host. If no TLSA records are found,
|
||||
# then opportunistic TLS is used. Otherwise the server certificate must match the TLSA records
|
||||
# or else the mail bounces. TLSA also requires DNSSEC on the MX host. Postfix doesn't do DNSSEC
|
||||
# itself but assumes the system's nameserver does and reports DNSSEC status. Thus this also
|
||||
# relies on our local bind9 server being present and smtp_dns_support_level being set to dnssec
|
||||
# to use it.
|
||||
#
|
||||
# The smtp_tls_CAfile is superflous, but it turns warnings in the logs about untrusted certs
|
||||
# into notices about trusted certs. Since in these cases Postfix is doing opportunistic TLS,
|
||||
# it does not care about whether the remote certificate is trusted. But, looking at the logs,
|
||||
# it's nice to be able to see that the connection was in fact encrypted for the right party.
|
||||
# The CA file is provided by the package ca-certificates.
|
||||
tools/editconf.py /etc/postfix/main.cf \
|
||||
smtp_tls_security_level=dane \
|
||||
smtp_dns_support_level=dnssec \
|
||||
smtp_tls_CAfile=/etc/ssl/certs/ca-certificates.crt \
|
||||
smtp_tls_loglevel=2
|
||||
|
||||
# Incoming Mail
|
||||
# ### Incoming Mail
|
||||
|
||||
# Pass any incoming mail over to a local delivery agent. Spamassassin
|
||||
# will act as the LDA agent at first. It is listening on port 10025
|
||||
# with LMTP. Spamassassin will pass the mail over to Dovecot after.
|
||||
#
|
||||
# In a basic setup we would pass mail directly to Dovecot like so:
|
||||
# tools/editconf.py /etc/postfix/main.cf virtual_transport=lmtp:unix:private/dovecot-lmtp
|
||||
# In a basic setup we would pass mail directly to Dovecot by setting
|
||||
# virtual_transport to `lmtp:unix:private/dovecot-lmtp`.
|
||||
#
|
||||
tools/editconf.py /etc/postfix/main.cf virtual_transport=lmtp:[127.0.0.1]:10025
|
||||
|
||||
# Who can send outbound mail? The purpose of this is to prevent
|
||||
# non-authenticated users from sending mail that requires being
|
||||
# relayed elsewhere. We don't want to be an "open relay".
|
||||
#
|
||||
# permit_sasl_authenticated: Authenticated users (i.e. on port 587).
|
||||
# permit_mynetworks: Mail that originates locally.
|
||||
# reject_unauth_destination: No one else. (Permits mail whose destination is local and rejects other mail.)
|
||||
tools/editconf.py /etc/postfix/main.cf \
|
||||
smtpd_relay_restrictions=permit_sasl_authenticated,permit_mynetworks,reject_unauth_destination
|
||||
|
||||
# Who can send mail to us? Some basic filters.
|
||||
#
|
||||
# reject_non_fqdn_sender: Reject not-nice-looking return paths.
|
||||
# reject_unknown_sender_domain: Reject return paths with invalid domains.
|
||||
# reject_rhsbl_sender: Reject return paths that use blacklisted domains.
|
||||
# permit_sasl_authenticated: Authenticated users (i.e. on port 587).
|
||||
# permit_mynetworks: Mail that originates locally.
|
||||
# reject_rbl_client: Reject connections from IP addresses blacklisted in zen.spamhaus.org
|
||||
# check_policy_service: Apply greylisting using postgrey.
|
||||
# * reject_non_fqdn_sender: Reject not-nice-looking return paths.
|
||||
# * reject_unknown_sender_domain: Reject return paths with invalid domains.
|
||||
# * reject_rhsbl_sender: Reject return paths that use blacklisted domains.
|
||||
# * permit_sasl_authenticated: Authenticated users (i.e. on port 587) can skip further checks.
|
||||
# * permit_mynetworks: Mail that originates locally can skip further checks.
|
||||
# * reject_rbl_client: Reject connections from IP addresses blacklisted in zen.spamhaus.org
|
||||
# * reject_unlisted_recipient: Although Postfix will reject mail to unknown recipients, it's nicer to reject such mail ahead of greylisting rather than after.
|
||||
# * check_policy_service: Apply greylisting using postgrey.
|
||||
#
|
||||
# Notes:
|
||||
# permit_dnswl_client can pass through mail from whitelisted IP addresses, which would be good to put before greylisting
|
||||
@@ -124,7 +142,7 @@ tools/editconf.py /etc/postfix/main.cf \
|
||||
# "450 4.7.1 Client host rejected: Service unavailable". This is a retry code, so the mail doesn't properly bounce.
|
||||
tools/editconf.py /etc/postfix/main.cf \
|
||||
smtpd_sender_restrictions="reject_non_fqdn_sender,reject_unknown_sender_domain,reject_rhsbl_sender dbl.spamhaus.org" \
|
||||
smtpd_recipient_restrictions=permit_sasl_authenticated,permit_mynetworks,"reject_rbl_client zen.spamhaus.org","check_policy_service inet:127.0.0.1:10023"
|
||||
smtpd_recipient_restrictions=permit_sasl_authenticated,permit_mynetworks,"reject_rbl_client zen.spamhaus.org",reject_unlisted_recipient,"check_policy_service inet:127.0.0.1:10023"
|
||||
|
||||
# Increase the message size limit from 10MB to 128MB.
|
||||
tools/editconf.py /etc/postfix/main.cf \
|
||||
@@ -137,4 +155,4 @@ ufw_allow submission
|
||||
|
||||
# Restart services
|
||||
|
||||
restart_service postfix
|
||||
restart_service postfix
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# User Authentication and Destination Validation
|
||||
# ----------------------------------------------
|
||||
#
|
||||
# This script configures user authentication for Dovecot
|
||||
# and Postfix (which relies on Dovecot) and destination
|
||||
@@ -9,6 +10,8 @@
|
||||
source setup/functions.sh # load our functions
|
||||
source /etc/mailinabox.conf # load global vars
|
||||
|
||||
# ### User and Alias Database
|
||||
|
||||
# The database of mail users (i.e. authenticated users, who have mailboxes)
|
||||
# and aliases (forwarders).
|
||||
|
||||
@@ -17,12 +20,11 @@ db_path=$STORAGE_ROOT/mail/users.sqlite
|
||||
# Create an empty database if it doesn't yet exist.
|
||||
if [ ! -f $db_path ]; then
|
||||
echo Creating new user database: $db_path;
|
||||
echo "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra);" | 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);" | sqlite3 $db_path;
|
||||
fi
|
||||
|
||||
# User Authentication
|
||||
#####################
|
||||
# ### User Authentication
|
||||
|
||||
# Have Dovecot query our database, and not system users, for authentication.
|
||||
sed -i "s/#*\(\!include auth-system.conf.ext\)/#\1/" /etc/dovecot/conf.d/10-auth.conf
|
||||
@@ -68,8 +70,7 @@ tools/editconf.py /etc/postfix/main.cf \
|
||||
smtpd_sasl_path=private/auth \
|
||||
smtpd_sasl_auth_enable=yes
|
||||
|
||||
# Destination Validation
|
||||
########################
|
||||
# ### Destination Validation
|
||||
|
||||
# Use a Sqlite3 database to check whether a destination email address exists,
|
||||
# and to perform any email alias rewrites in Postfix.
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
source setup/functions.sh
|
||||
|
||||
apt_install python3-flask links duplicity libyaml-dev python3-dnspython
|
||||
apt_install python3-flask links duplicity libyaml-dev python3-dnspython python3-dateutil
|
||||
hide_output pip3 install rtyaml
|
||||
|
||||
# Create a backup directory and a random key for encrypting backups.
|
||||
mkdir -p $STORAGE_ROOT/backup
|
||||
if [ ! -f $STORAGE_ROOT/backup/secret_key.txt ]; then
|
||||
openssl rand -base64 2048 > $STORAGE_ROOT/backup/secret_key.txt
|
||||
$(umask 077; openssl rand -base64 2048 > $STORAGE_ROOT/backup/secret_key.txt)
|
||||
fi
|
||||
|
||||
# Link the management server daemon into a well known location.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import sys, os, os.path, glob, re, shutil
|
||||
|
||||
sys.path.insert(0, 'management')
|
||||
from utils import load_environment, save_environment, safe_domain_name
|
||||
from utils import load_environment, save_environment, shell
|
||||
|
||||
def migration_1(env):
|
||||
# Re-arrange where we store SSL certificates. There was a typo also.
|
||||
@@ -45,6 +45,21 @@ def migration_2(env):
|
||||
for fn in glob.glob(os.path.join(env["STORAGE_ROOT"], 'mail/mailboxes/*/*/.dovecot.svbin')):
|
||||
os.unlink(fn)
|
||||
|
||||
def migration_3(env):
|
||||
# Move the migration ID from /etc/mailinabox.conf to $STORAGE_ROOT/mailinabox.version
|
||||
# so that the ID stays with the data files that it describes the format of. The writing
|
||||
# of the file will be handled by the main function.
|
||||
pass
|
||||
|
||||
def migration_4(env):
|
||||
# Add a new column to the mail users table where we can store administrative privileges.
|
||||
db = os.path.join(env["STORAGE_ROOT"], 'mail/users.sqlite')
|
||||
shell("check_call", ["sqlite3", db, "ALTER TABLE users ADD privileges TEXT NOT NULL DEFAULT ''"])
|
||||
|
||||
def migration_5(env):
|
||||
# The secret key for encrypting backups was world readable. Fix here.
|
||||
os.chmod(os.path.join(env["STORAGE_ROOT"], 'backup/secret_key.txt'), 0o600)
|
||||
|
||||
def get_current_migration():
|
||||
ver = 0
|
||||
while True:
|
||||
@@ -61,7 +76,14 @@ def run_migrations():
|
||||
|
||||
env = load_environment()
|
||||
|
||||
ourver = int(env.get("MIGRATIONID", "0"))
|
||||
migration_id_file = os.path.join(env['STORAGE_ROOT'], 'mailinabox.version')
|
||||
if os.path.exists(migration_id_file):
|
||||
with open(migration_id_file) as f:
|
||||
ourver = int(f.read().strip())
|
||||
else:
|
||||
# Load the legacy location of the migration ID. We'll drop support
|
||||
# for this eventually.
|
||||
ourver = int(env.get("MIGRATIONID", "0"))
|
||||
|
||||
while True:
|
||||
next_ver = (ourver + 1)
|
||||
@@ -71,6 +93,7 @@ def run_migrations():
|
||||
# No more migrations to run.
|
||||
break
|
||||
|
||||
print()
|
||||
print("Running migration to Mail-in-a-Box #%d..." % next_ver)
|
||||
|
||||
try:
|
||||
@@ -88,8 +111,13 @@ def run_migrations():
|
||||
|
||||
# Write out our current version now. Do this sooner rather than later
|
||||
# in case of any problems.
|
||||
env["MIGRATIONID"] = ourver
|
||||
save_environment(env)
|
||||
with open(migration_id_file, "w") as f:
|
||||
f.write(str(ourver) + "\n")
|
||||
|
||||
# Delete the legacy location of this field.
|
||||
if "MIGRATIONID" in env:
|
||||
del env["MIGRATIONID"]
|
||||
save_environment(env)
|
||||
|
||||
# iterate and try next version...
|
||||
|
||||
|
||||
57
setup/network-checks.sh
Normal file
57
setup/network-checks.sh
Normal file
@@ -0,0 +1,57 @@
|
||||
# Install the 'host', 'sed', and and 'nc' tools. This script is run before
|
||||
# the rest of the system setup so we may not yet have things installed.
|
||||
hide_output apt-get -y install bind9-host sed netcat-openbsd
|
||||
|
||||
# Stop if the PRIMARY_HOSTNAME is listed in the Spamhaus Domain Block List.
|
||||
# The user might have chosen a name that was previously in use by a spammer
|
||||
# and will not be able to reliably send mail. Do this after any automatic
|
||||
# choices made above.
|
||||
if host $PRIMARY_HOSTNAME.dbl.spamhaus.org > /dev/null; then
|
||||
echo
|
||||
echo "The hostname you chose '$PRIMARY_HOSTNAME' is listed in the"
|
||||
echo "Spamhaus Domain Block List. See http://www.spamhaus.org/dbl/"
|
||||
echo "and http://www.spamhaus.org/query/domain/$PRIMARY_HOSTNAME."
|
||||
echo
|
||||
echo "You will not be able to send mail using this domain name, so"
|
||||
echo "setup cannot continue."
|
||||
echo
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Stop if the IPv4 address is listed in the ZEN Spamhouse Block List.
|
||||
# The user might have ended up on an IP address that was previously in use
|
||||
# by a spammer, or the user may be deploying on a residential network. We
|
||||
# will not be able to reliably send mail in these cases.
|
||||
REVERSED_IPV4=$(echo $PUBLIC_IP | sed "s/\([0-9]*\).\([0-9]*\).\([0-9]*\).\([0-9]*\)/\4.\3.\2.\1/")
|
||||
if host $REVERSED_IPV4.zen.spamhaus.org > /dev/null; then
|
||||
echo
|
||||
echo "The IP address $PUBLIC_IP is listed in the Spamhaus Block List."
|
||||
echo "See http://www.spamhaus.org/query/ip/$PUBLIC_IP."
|
||||
echo
|
||||
echo "You will not be able to send mail using this machine, so setup"
|
||||
echo "cannot continue."
|
||||
echo
|
||||
echo "Associate a different IP address with this machine if possible."
|
||||
echo "Many residential network IP addresses are listed, so Mail-in-a-Box"
|
||||
echo "typically cannot be used on a residential Internet connection."
|
||||
echo
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Stop if we cannot make an outbound connection on port 25. Many residential
|
||||
# networks block outbound port 25 to prevent their network from sending spam.
|
||||
# See if we can reach one of Google's MTAs with a 5-second timeout.
|
||||
if ! nc -z -w5 aspmx.l.google.com 25; then
|
||||
echo
|
||||
echo "Outbound mail (port 25) seems to be blocked by your network."
|
||||
echo
|
||||
echo "You will not be able to send mail using this machine, so setup"
|
||||
echo "cannot continue."
|
||||
echo
|
||||
echo "Many residential networks block port 25 to prevent hijacked"
|
||||
echo "machines from being able to send spam. I just tried to connect"
|
||||
echo "to Google's mail server on port 25 but the connection did not"
|
||||
echo "succeed."
|
||||
echo
|
||||
exit 1
|
||||
fi
|
||||
141
setup/owncloud.sh
Executable file
141
setup/owncloud.sh
Executable file
@@ -0,0 +1,141 @@
|
||||
#!/bin/bash
|
||||
# Owncloud
|
||||
##########################
|
||||
|
||||
source setup/functions.sh # load our functions
|
||||
source /etc/mailinabox.conf # load global vars
|
||||
|
||||
# ### Installing ownCloud
|
||||
|
||||
apt_install \
|
||||
dbconfig-common \
|
||||
php5-cli php5-sqlite php5-gd php5-imap php5-curl php-pear php-apc curl libapr1 libtool libcurl4-openssl-dev php-xml-parser \
|
||||
php5 php5-dev php5-gd php5-fpm memcached php5-memcache unzip
|
||||
|
||||
apt-get purge -qq -y owncloud*
|
||||
|
||||
# Install ownCloud from source of this version:
|
||||
owncloud_ver=7.0.2
|
||||
|
||||
# Check if ownCloud dir exist, and check if version matches owncloud_ver (if either doesn't - install/upgrade)
|
||||
if [ ! -d /usr/local/lib/owncloud/ ] \
|
||||
|| ! grep -q $owncloud_ver /usr/local/lib/owncloud/version.php; then
|
||||
|
||||
echo installing ownCloud...
|
||||
rm -f /tmp/owncloud.zip
|
||||
wget -qO /tmp/owncloud.zip https://download.owncloud.org/community/owncloud-$owncloud_ver.zip
|
||||
unzip -u -o -q /tmp/owncloud.zip -d /usr/local/lib #either extracts new or replaces current files
|
||||
hide_output php /usr/local/lib/owncloud/occ upgrade #if OC is up-to-date it wont matter
|
||||
rm -f /tmp/owncloud.zip
|
||||
fi
|
||||
|
||||
# ### Configuring ownCloud
|
||||
|
||||
# Setup ownCloud if the ownCloud database does not yet exist. Running setup when
|
||||
# the database does exist wipes the database and user data.
|
||||
if [ ! -f $STORAGE_ROOT/owncloud/owncloud.db ]; then
|
||||
# Create a configuration file.
|
||||
TIMEZONE=$(cat /etc/timezone)
|
||||
instanceid=oc$(echo $PRIMARY_HOSTNAME | sha1sum | fold -w 10 | head -n 1)
|
||||
cat > /usr/local/lib/owncloud/config/config.php <<EOF;
|
||||
<?php
|
||||
\$CONFIG = array (
|
||||
'datadirectory' => '$STORAGE_ROOT/owncloud',
|
||||
|
||||
'instanceid' => '$instanceid',
|
||||
|
||||
'trusted_domains' =>
|
||||
array (
|
||||
0 => '$PRIMARY_HOSTNAME',
|
||||
),
|
||||
'forcessl' => true, # if unset/false, ownCloud sends a HSTS=0 header, which conflicts with nginx config
|
||||
|
||||
'overwritewebroot' => '/cloud',
|
||||
'user_backends' => array(
|
||||
array(
|
||||
'class'=>'OC_User_IMAP',
|
||||
'arguments'=>array('{localhost:993/imap/ssl/novalidate-cert}')
|
||||
)
|
||||
),
|
||||
"memcached_servers" => array (
|
||||
array('localhost', 11211),
|
||||
),
|
||||
'mail_smtpmode' => 'sendmail',
|
||||
'mail_smtpsecure' => '',
|
||||
'mail_smtpauthtype' => 'LOGIN',
|
||||
'mail_smtpauth' => false,
|
||||
'mail_smtphost' => '',
|
||||
'mail_smtpport' => '',
|
||||
'mail_smtpname' => '',
|
||||
'mail_smtppassword' => '',
|
||||
'mail_from_address' => 'owncloud',
|
||||
'mail_domain' => '$PRIMARY_HOSTNAME',
|
||||
'logtimezone' => '$TIMEZONE',
|
||||
);
|
||||
?>
|
||||
EOF
|
||||
|
||||
# Create an auto-configuration file to fill in database settings
|
||||
# when the install script is run. Make an administrator account
|
||||
# here or else the install can't finish.
|
||||
adminpassword=$(dd if=/dev/random bs=1 count=40 2>/dev/null | sha1sum | fold -w 30 | head -n 1)
|
||||
cat > /usr/local/lib/owncloud/config/autoconfig.php <<EOF;
|
||||
<?php
|
||||
\$AUTOCONFIG = array (
|
||||
# storage/database
|
||||
'directory' => '$STORAGE_ROOT/owncloud',
|
||||
'dbtype' => 'sqlite3',
|
||||
|
||||
# create an administrator account with a random password so that
|
||||
# the user does not have to enter anything on first load of ownCloud
|
||||
'adminlogin' => 'root',
|
||||
'adminpass' => '$adminpassword',
|
||||
);
|
||||
?>
|
||||
EOF
|
||||
|
||||
# Create user data directory and set permissions
|
||||
mkdir -p $STORAGE_ROOT/owncloud
|
||||
chown -R www-data.www-data $STORAGE_ROOT/owncloud /usr/local/lib/owncloud
|
||||
|
||||
# Execute ownCloud's setup step, which creates the ownCloud sqlite database.
|
||||
# It also wipes it if it exists. And it deletes the autoconfig.php file.
|
||||
(cd /usr/local/lib/owncloud; sudo -u www-data php /usr/local/lib/owncloud/index.php;)
|
||||
fi
|
||||
|
||||
# Enable/disable apps. Note that this must be done after the ownCloud setup.
|
||||
# The firstrunwizard gave Josh all sorts of problems, so disabling that.
|
||||
# user_external is what allows ownCloud to use IMAP for login.
|
||||
hide_output php /usr/local/lib/owncloud/console.php app:disable firstrunwizard
|
||||
hide_output php /usr/local/lib/owncloud/console.php app:enable user_external
|
||||
|
||||
# Set PHP FPM values to support large file uploads
|
||||
# (semicolon is the comment character in this file, hashes produce deprecation warnings)
|
||||
tools/editconf.py /etc/php5/fpm/php.ini -c ';' \
|
||||
upload_max_filesize=16G \
|
||||
post_max_size=16G \
|
||||
output_buffering=16384 \
|
||||
memory_limit=512M \
|
||||
max_execution_time=600 \
|
||||
short_open_tag=On
|
||||
|
||||
# Set up a cron job for owncloud.
|
||||
cat > /etc/cron.hourly/mailinabox-owncloud << EOF;
|
||||
#!/bin/bash
|
||||
# Mail-in-a-Box
|
||||
sudo -u www-data php -f /usr/local/lib/owncloud/cron.php
|
||||
EOF
|
||||
chmod +x /etc/cron.hourly/mailinabox-owncloud
|
||||
|
||||
# There's nothing much of interest that a user could do as an admin for ownCloud,
|
||||
# and there's a lot they could mess up, so we don't make any users admins of ownCloud.
|
||||
# But if we wanted to, we would do this:
|
||||
# ```
|
||||
# for user in $(tools/mail.py user admins); do
|
||||
# sqlite3 $STORAGE_ROOT/owncloud/owncloud.db "INSERT OR IGNORE INTO oc_group_user VALUES ('admin', '$user')"
|
||||
# done
|
||||
# ```
|
||||
|
||||
# Enable PHP modules and restart PHP.
|
||||
php5enmod imap
|
||||
restart_service php5-fpm
|
||||
29
setup/preflight.sh
Normal file
29
setup/preflight.sh
Normal file
@@ -0,0 +1,29 @@
|
||||
# Are we running as root?
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "This script must be run as root. Please re-run like this:"
|
||||
echo
|
||||
echo "sudo $0"
|
||||
echo
|
||||
exit
|
||||
fi
|
||||
|
||||
# Check that we are running on Ubuntu 14.04 LTS (or 14.04.xx).
|
||||
if [ "`lsb_release -d | sed 's/.*:\s*//' | sed 's/14\.04\.[0-9]/14.04/' `" != "Ubuntu 14.04 LTS" ]; then
|
||||
echo "Mail-in-a-Box only supports being installed on Ubuntu 14.04, sorry. You are running:"
|
||||
echo
|
||||
lsb_release -d | sed 's/.*:\s*//'
|
||||
echo
|
||||
echo "We can't write scripts that run on every possible setup, sorry."
|
||||
exit
|
||||
fi
|
||||
|
||||
# Check that we have enough memory. Skip the check if we appear to be
|
||||
# running inside of Vagrant, because that's really just for testing.
|
||||
TOTAL_PHYSICAL_MEM=$(head -n 1 /proc/meminfo | awk '{print $2}')
|
||||
if [ $TOTAL_PHYSICAL_MEM -lt 786432 ]; then
|
||||
if [ ! -d /vagrant ]; then
|
||||
echo "Your Mail-in-a-Box needs more than $TOTAL_PHYSICAL_MEM MB RAM."
|
||||
echo "Please provision a machine with at least 768 MB, 1 GB recommended."
|
||||
exit
|
||||
fi
|
||||
fi
|
||||
188
setup/questions.sh
Normal file
188
setup/questions.sh
Normal file
@@ -0,0 +1,188 @@
|
||||
if [ -z "$NONINTERACTIVE" ]; then
|
||||
# Install 'dialog' so we can ask the user questions. The original motivation for
|
||||
# this was being able to ask the user for input even if stdin has been redirected,
|
||||
# e.g. if we piped a bootstrapping install script to bash to get started. In that
|
||||
# case, the nifty '[ -t 0 ]' test won't work. But with Vagrant we must suppress so we
|
||||
# use a shell flag instead. Really supress any output from installing dialog.
|
||||
hide_output apt-get -y install dialog
|
||||
message_box "Mail-in-a-Box Installation" \
|
||||
"Hello and thanks for deploying a Mail-in-a-Box!
|
||||
\n\nI'm going to ask you a few questions.
|
||||
\n\nTo change your answers later, just run 'sudo mailinabox' from the command line."
|
||||
fi
|
||||
|
||||
# The box needs a name.
|
||||
if [ -z "$PRIMARY_HOSTNAME" ]; then
|
||||
if [ -z "$DEFAULT_PRIMARY_HOSTNAME" ]; then
|
||||
# We recommend to use box.example.com as this hosts name. The
|
||||
# domain the user possibly wants to use is example.com then.
|
||||
# We strip the string "box." from the hostname to get the mail
|
||||
# domain. If the hostname differs, nothing happens here.
|
||||
DEFAULT_DOMAIN_GUESS=$(echo $(get_default_hostname) | sed -e 's/^box\.//')
|
||||
|
||||
# This is the first run. Ask the user for his email address so we can
|
||||
# provide the best default for the box's hostname.
|
||||
input_box "Your Email Address" \
|
||||
"What email address are you setting this box up to manage?
|
||||
\n\nThe part after the @-sign must be a domain name or subdomain
|
||||
that you control. You can add other email addresses to this
|
||||
box later (including email addresses on other domain names
|
||||
or subdomains you control).
|
||||
\n\nWe've guessed an email address. Backspace it and type in what
|
||||
you really want.
|
||||
\n\nEmail Address:" \
|
||||
"me@$DEFAULT_DOMAIN_GUESS" \
|
||||
EMAIL_ADDR
|
||||
|
||||
if [ -z "$EMAIL_ADDR" ]; then
|
||||
# user hit ESC/cancel
|
||||
exit
|
||||
fi
|
||||
while ! management/mailconfig.py validate-email "$EMAIL_ADDR"
|
||||
do
|
||||
input_box "Your Email Address" \
|
||||
"That's not a valid email address.\n\nWhat email address are you setting this box up to manage?" \
|
||||
$EMAIL_ADDR \
|
||||
EMAIL_ADDR
|
||||
if [ -z "$EMAIL_ADDR" ]; then
|
||||
# user hit ESC/cancel
|
||||
exit
|
||||
fi
|
||||
done
|
||||
|
||||
# Take the part after the @-sign as the user's domain name, and add
|
||||
# 'box.' to the beginning to create a default hostname for this machine.
|
||||
DEFAULT_PRIMARY_HOSTNAME=box.$(echo $EMAIL_ADDR | sed 's/.*@//')
|
||||
fi
|
||||
|
||||
input_box "Hostname" \
|
||||
"This box needs a name, called a 'hostname'. The name will form a part of the box's web address.
|
||||
\n\nWe recommend that the name be a subdomain of the domain in your email
|
||||
address, so we're suggesting $DEFAULT_PRIMARY_HOSTNAME.
|
||||
\n\nYou can change it, but we recommend you don't.
|
||||
\n\nHostname:" \
|
||||
$DEFAULT_PRIMARY_HOSTNAME \
|
||||
PRIMARY_HOSTNAME
|
||||
|
||||
if [ -z "$PRIMARY_HOSTNAME" ]; then
|
||||
# user hit ESC/cancel
|
||||
exit
|
||||
fi
|
||||
fi
|
||||
|
||||
# If the machine is behind a NAT, inside a VM, etc., it may not know
|
||||
# its IP address on the public network / the Internet. Ask the Internet
|
||||
# and possibly confirm with user.
|
||||
if [ -z "$PUBLIC_IP" ]; then
|
||||
# Ask the Internet.
|
||||
GUESSED_IP=$(get_publicip_from_web_service 4)
|
||||
|
||||
# On the first run, if we got an answer from the Internet then don't
|
||||
# ask the user.
|
||||
if [[ -z "$DEFAULT_PUBLIC_IP" && ! -z "$GUESSED_IP" ]]; then
|
||||
PUBLIC_IP=$GUESSED_IP
|
||||
|
||||
# Otherwise on the first run at least provide a default.
|
||||
elif [[ -z "$DEFAULT_PUBLIC_IP" ]]; then
|
||||
DEFAULT_PUBLIC_IP=$(get_default_privateip 4)
|
||||
|
||||
# On later runs, if the previous value matches the guessed value then
|
||||
# don't ask the user either.
|
||||
elif [ "$DEFAULT_PUBLIC_IP" == "$GUESSED_IP" ]; then
|
||||
PUBLIC_IP=$GUESSED_IP
|
||||
fi
|
||||
|
||||
if [ -z "$PUBLIC_IP" ]; then
|
||||
input_box "Public IP Address" \
|
||||
"Enter the public IP address of this machine, as given to you by your ISP.
|
||||
\n\nPublic IP address:" \
|
||||
$DEFAULT_PUBLIC_IP \
|
||||
PUBLIC_IP
|
||||
|
||||
if [ -z "$PUBLIC_IP" ]; then
|
||||
# user hit ESC/cancel
|
||||
exit
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Same for IPv6. But it's optional. Also, if it looks like the system
|
||||
# doesn't have an IPv6, don't ask for one.
|
||||
if [ -z "$PUBLIC_IPV6" ]; then
|
||||
# Ask the Internet.
|
||||
GUESSED_IP=$(get_publicip_from_web_service 6)
|
||||
MATCHED=0
|
||||
if [[ -z "$DEFAULT_PUBLIC_IPV6" && ! -z "$GUESSED_IP" ]]; then
|
||||
PUBLIC_IPV6=$GUESSED_IP
|
||||
elif [[ "$DEFAULT_PUBLIC_IPV6" == "$GUESSED_IP" ]]; then
|
||||
# No IPv6 entered and machine seems to have none, or what
|
||||
# the user entered matches what the Internet tells us.
|
||||
PUBLIC_IPV6=$GUESSED_IP
|
||||
MATCHED=1
|
||||
elif [[ -z "$DEFAULT_PUBLIC_IPV6" ]]; then
|
||||
DEFAULT_PUBLIC_IP=$(get_default_privateip 6)
|
||||
fi
|
||||
|
||||
if [[ -z "$PUBLIC_IPV6" && $MATCHED == 0 ]]; then
|
||||
input_box "IPv6 Address (Optional)" \
|
||||
"Enter the public IPv6 address of this machine, as given to you by your ISP.
|
||||
\n\nLeave blank if the machine does not have an IPv6 address.
|
||||
\n\nPublic IPv6 address:" \
|
||||
$DEFAULT_PUBLIC_IPV6 \
|
||||
PUBLIC_IPV6
|
||||
|
||||
if [ ! $PUBLIC_IPV6_EXITCODE ]; then
|
||||
# user hit ESC/cancel
|
||||
exit
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Get the IP addresses of the local network interface(s) that are connected
|
||||
# to the Internet. We need these when we want to have services bind only to
|
||||
# the public network interfaces (not loopback, not tunnel interfaces).
|
||||
if [ -z "$PRIVATE_IP" ]; then
|
||||
PRIVATE_IP=$(get_default_privateip 4)
|
||||
fi
|
||||
if [ -z "$PRIVATE_IPV6" ]; then
|
||||
PRIVATE_IPV6=$(get_default_privateip 6)
|
||||
fi
|
||||
if [[ -z "$PRIVATE_IP" && -z "$PRIVATE_IPV6" ]]; then
|
||||
echo
|
||||
echo "I could not determine the IP or IPv6 address of the network inteface"
|
||||
echo "for connecting to the Internet. Setup must stop."
|
||||
echo
|
||||
hostname -I
|
||||
route
|
||||
echo
|
||||
exit
|
||||
fi
|
||||
|
||||
# We need a country code to generate a certificate signing request. However
|
||||
# if a CSR already exists then we won't be generating a new one and there's
|
||||
# no reason to ask for the country code now. $STORAGE_ROOT has not yet been
|
||||
# set so we'll check if $DEFAULT_STORAGE_ROOT and $DEFAULT_CSR_COUNTRY are
|
||||
# set (the values from the current mailinabox.conf) and if the CSR exists
|
||||
# in the expected location.
|
||||
if [ ! -z "$DEFAULT_STORAGE_ROOT" ] && [ ! -z "$DEFAULT_CSR_COUNTRY" ] && [ -f $DEFAULT_STORAGE_ROOT/ssl/ssl_cert_sign_req.csr ]; then
|
||||
CSR_COUNTRY=$DEFAULT_CSR_COUNTRY
|
||||
fi
|
||||
|
||||
if [ -z "$CSR_COUNTRY" ]; then
|
||||
# Get a list of country codes. Separate codes from country names with a ^.
|
||||
# The input_menu function modifies shell word expansion to ignore spaces
|
||||
# (since country names can have spaces) and use ^ instead.
|
||||
country_code_list=$(grep -v "^#" setup/csr_country_codes.tsv | sed "s/\(..\)\t\([^\t]*\).*/\1^\2/")
|
||||
|
||||
input_menu "Country Code" \
|
||||
"Choose the country where you live or where your organization is based.
|
||||
\n\n(This is used to create an SSL certificate.)
|
||||
\n\nCountry Code:" \
|
||||
"$country_code_list" \
|
||||
CSR_COUNTRY
|
||||
|
||||
if [ -z "$CSR_COUNTRY" ]; then
|
||||
# user hit ESC/cancel
|
||||
exit
|
||||
fi
|
||||
fi
|
||||
20
setup/ssl.sh
20
setup/ssl.sh
@@ -1,6 +1,7 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# SSL Certificate
|
||||
# ---------------
|
||||
#
|
||||
# Create a self-signed SSL certificate if one has not yet been created.
|
||||
#
|
||||
@@ -21,27 +22,24 @@ source /etc/mailinabox.conf # load global vars
|
||||
apt_install openssl
|
||||
|
||||
mkdir -p $STORAGE_ROOT/ssl
|
||||
if [ ! -f $STORAGE_ROOT/ssl/ssl_certificate.pem ]; then
|
||||
# Generate a new private key if one doesn't already exist.
|
||||
# Set the umask so the key file is not world-readable.
|
||||
# Generate a new private key if one doesn't already exist.
|
||||
# Set the umask so the key file is not world-readable.
|
||||
if [ ! -f $STORAGE_ROOT/ssl/ssl_private_key.pem ]; then
|
||||
(umask 077; hide_output \
|
||||
openssl genrsa -out $STORAGE_ROOT/ssl/ssl_private_key.pem 2048)
|
||||
fi
|
||||
|
||||
# Generate a certificate signing request if one doesn't already exist.
|
||||
if [ ! -f $STORAGE_ROOT/ssl/ssl_cert_sign_req.csr ]; then
|
||||
# Generate a certificate signing request if one doesn't already exist.
|
||||
hide_output \
|
||||
openssl req -new -key $STORAGE_ROOT/ssl/ssl_private_key.pem -out $STORAGE_ROOT/ssl/ssl_cert_sign_req.csr \
|
||||
-subj "/C=$CSR_COUNTRY/ST=/L=/O=/CN=$PRIMARY_HOSTNAME"
|
||||
-sha256 -subj "/C=$CSR_COUNTRY/ST=/L=/O=/CN=$PRIMARY_HOSTNAME"
|
||||
fi
|
||||
|
||||
# Generate a SSL certificate by self-signing if a SSL certificate doesn't yet exist.
|
||||
if [ ! -f $STORAGE_ROOT/ssl/ssl_certificate.pem ]; then
|
||||
# Generate a SSL certificate by self-signing if a SSL certificate doesn't yet exist.
|
||||
hide_output \
|
||||
openssl x509 -req -days 365 \
|
||||
-in $STORAGE_ROOT/ssl/ssl_cert_sign_req.csr -signkey $STORAGE_ROOT/ssl/ssl_private_key.pem -out $STORAGE_ROOT/ssl/ssl_certificate.pem
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Your SSL certificate's fingerpint is:"
|
||||
openssl x509 -in $STORAGE_ROOT/ssl/ssl_certificate.pem -noout -fingerprint \
|
||||
| sed "s/SHA1 Fingerprint=//"
|
||||
echo
|
||||
|
||||
259
setup/start.sh
259
setup/start.sh
@@ -4,179 +4,73 @@
|
||||
|
||||
source setup/functions.sh # load our functions
|
||||
|
||||
# Check system setup.
|
||||
|
||||
# Are we running as root?
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "This script must be run as root. Please re-run like this:"
|
||||
echo
|
||||
echo "sudo setup/start.sh"
|
||||
echo
|
||||
exit
|
||||
fi
|
||||
|
||||
# Check that we are running on Ubuntu 14.04 LTS (or 14.04.xx).
|
||||
if [ "`lsb_release -d | sed 's/.*:\s*//' | sed 's/14\.04\.[0-9]/14.04/' `" != "Ubuntu 14.04 LTS" ]; then
|
||||
echo "Mail-in-a-Box only supports being installed on Ubuntu 14.04, sorry. You are running:"
|
||||
echo
|
||||
lsb_release -d | sed 's/.*:\s*//'
|
||||
echo
|
||||
echo "We can't write scripts that run on every possible setup, sorry."
|
||||
exit
|
||||
fi
|
||||
|
||||
# Check that we have enough memory. Skip the check if we appear to be
|
||||
# running inside of Vagrant, because that's really just for testing.
|
||||
TOTAL_PHYSICAL_MEM=$(free -m | grep ^Mem: | sed "s/^Mem: *\([0-9]*\).*/\1/")
|
||||
if [ $TOTAL_PHYSICAL_MEM -lt 768 ]; then
|
||||
if [ ! -d /vagrant ]; then
|
||||
echo "Your Mail-in-a-Box needs more than $TOTAL_PHYSICAL_MEM MB RAM."
|
||||
echo "Please provision a machine with at least 768 MB, 1 GB recommended."
|
||||
exit
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -t 0 ]; then
|
||||
# In an interactive shell...
|
||||
echo
|
||||
echo "Hello and thanks for deploying a Mail-in-a-Box!"
|
||||
echo "-----------------------------------------------"
|
||||
echo
|
||||
echo "I'm going to ask you a few questions. To change your answers later,"
|
||||
echo "later, just re-run this script."
|
||||
fi
|
||||
# Check system setup: Are we running as root on Ubuntu 14.04 on a
|
||||
# machine with enough memory? If not, this shows an error and exits.
|
||||
. setup/preflight.sh
|
||||
|
||||
# Recall the last settings used if we're running this a second time.
|
||||
if [ -f /etc/mailinabox.conf ]; then
|
||||
# Run any system migrations before proceeding. Since this is a second run,
|
||||
# we assume we have Python already installed.
|
||||
echo
|
||||
setup/migrate.py --migrate
|
||||
|
||||
# Okay now load the old .conf file to get existing configuration options.
|
||||
# Load the old .conf file to get existing configuration options loaded
|
||||
# into variables with a DEFAULT_ prefix.
|
||||
cat /etc/mailinabox.conf | sed s/^/DEFAULT_/ > /tmp/mailinabox.prev.conf
|
||||
source /tmp/mailinabox.prev.conf
|
||||
MIGRATIONID=$DEFAULT_MIGRATIONID
|
||||
else
|
||||
# What migration are we at for new installs?
|
||||
MIGRATIONID=$(setup/migrate.py --current)
|
||||
rm -f /tmp/mailinabox.prev.conf
|
||||
fi
|
||||
|
||||
# The box needs a name.
|
||||
if [ -z "$PRIMARY_HOSTNAME" ]; then
|
||||
if [ -z "$DEFAULT_PRIMARY_HOSTNAME" ]; then
|
||||
# This is the first run. Ask the user for his email address so we can
|
||||
# provide the best default for the box's hostname.
|
||||
echo
|
||||
echo "What email address are you setting this box up to manage?"
|
||||
echo ""
|
||||
echo "The part after the @-sign must be a domain name or subdomain"
|
||||
echo "that you control. You can add other email addresses to this"
|
||||
echo "box later (including email addresses on other domain names"
|
||||
echo "or subdomains you control)."
|
||||
echo
|
||||
echo "We've guessed an email address. Backspace it and type in what"
|
||||
echo "you really want."
|
||||
echo
|
||||
read -e -i "me@`get_default_hostname`" -p "Email Address: " EMAIL_ADDR
|
||||
# Put a start script in a global location. We tell the user to run 'mailinabox'
|
||||
# in the first dialog prompt, so we should do this before that starts.
|
||||
cat > /usr/local/bin/mailinabox << EOF;
|
||||
#!/bin/bash
|
||||
cd `pwd`
|
||||
source setup/start.sh
|
||||
EOF
|
||||
chmod +x /usr/local/bin/mailinabox
|
||||
|
||||
while ! management/mailconfig.py validate-email "$EMAIL_ADDR"
|
||||
do
|
||||
echo "That's not a valid email address."
|
||||
echo
|
||||
read -e -i "$EMAIL_ADDR" -p "Email Address: " EMAIL_ADDR
|
||||
done
|
||||
|
||||
# Take the part after the @-sign as the user's domain name, and add
|
||||
# 'box.' to the beginning to create a default hostname for this machine.
|
||||
DEFAULT_PRIMARY_HOSTNAME=box.$(echo $EMAIL_ADDR | sed 's/.*@//')
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "This box needs a name, called a 'hostname'. The name will form a part"
|
||||
echo "of the box's web address."
|
||||
echo
|
||||
echo "We recommend that the name be a subdomain of the domain in your email"
|
||||
echo "address, so we're suggesting $DEFAULT_PRIMARY_HOSTNAME."
|
||||
echo
|
||||
echo "You can change it, but we recommend you don't."
|
||||
echo
|
||||
|
||||
read -e -i "$DEFAULT_PRIMARY_HOSTNAME" -p "Hostname: " PRIMARY_HOSTNAME
|
||||
fi
|
||||
|
||||
# If the machine is behind a NAT, inside a VM, etc., it may not know
|
||||
# its IP address on the public network / the Internet. We need to
|
||||
# confirm our best guess with the user.
|
||||
if [ -z "$PUBLIC_IP" ]; then
|
||||
if [ -z "$DEFAULT_PUBLIC_IP" ]; then
|
||||
# set a default on first run
|
||||
DEFAULT_PUBLIC_IP=`get_default_publicip`
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Enter the public IP address of this machine, as given to you by your"
|
||||
echo "ISP. We've guessed a value, but just backspace it if it's wrong."
|
||||
echo
|
||||
|
||||
read -e -i "$DEFAULT_PUBLIC_IP" -p "Public IP: " PUBLIC_IP
|
||||
fi
|
||||
|
||||
# Same for IPv6.
|
||||
if [ -z "$PUBLIC_IPV6" ]; then
|
||||
if [ -z "$DEFAULT_PUBLIC_IPV6" ]; then
|
||||
# set a default on first run
|
||||
DEFAULT_PUBLIC_IPV6=`get_default_publicipv6`
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "(Optional) Enter the IPv6 address of this machine. Leave blank"
|
||||
echo " if the machine does not have an IPv6 address."
|
||||
|
||||
read -e -i "$DEFAULT_PUBLIC_IPV6" -p "Public IPv6: " PUBLIC_IPV6
|
||||
fi
|
||||
|
||||
# We need a country code to generate a certificate signing request. However
|
||||
# if a CSR already exists then we won't be generating a new one and there's
|
||||
# no reason to ask for the country code now. $STORAGE_ROOT has not yet been
|
||||
# set so we'll check if $DEFAULT_STORAGE_ROOT and $DEFAULT_CSR_COUNTRY are
|
||||
# set (the values from the current mailinabox.conf) and if the CSR exists
|
||||
# in the expected location.
|
||||
if [ ! -z "$DEFAULT_STORAGE_ROOT" ] && [ ! -z "$DEFAULT_CSR_COUNTRY" ] && [ -f $DEFAULT_STORAGE_ROOT/ssl/ssl_cert_sign_req.csr ]; then
|
||||
CSR_COUNTRY=$DEFAULT_CSR_COUNTRY
|
||||
fi
|
||||
if [ -z "$CSR_COUNTRY" ]; then
|
||||
echo
|
||||
echo "Enter the two-letter, uppercase country code for where you"
|
||||
echo "live or where your organization is based. (This is used to"
|
||||
echo "create an SSL certificate.)"
|
||||
echo
|
||||
|
||||
#if [ -z "$DEFAULT_CSR_COUNTRY" ]; then
|
||||
# # set a default on first run
|
||||
# DEFAULT_CSR_COUNTRY=...?
|
||||
#fi
|
||||
|
||||
read -e -i "$DEFAULT_CSR_COUNTRY" -p "Country Code: " CSR_COUNTRY
|
||||
fi
|
||||
# Ask the user for the PRIMARY_HOSTNAME, PUBLIC_IP, PUBLIC_IPV6, and CSR_COUNTRY
|
||||
# if values have not already been set in environment variables. When running
|
||||
# non-interactively, be sure to set values for all!
|
||||
. setup/questions.sh
|
||||
|
||||
# Automatic configuration, e.g. as used in our Vagrant configuration.
|
||||
if [ "$PUBLIC_IP" = "auto" ]; then
|
||||
# Use a public API to get our public IP address.
|
||||
PUBLIC_IP=`get_default_publicip`
|
||||
echo "IP Address: $PUBLIC_IP"
|
||||
# Use a public API to get our public IP address, or fall back to local network configuration.
|
||||
PUBLIC_IP=$(get_publicip_from_web_service 4 || get_default_privateip 4)
|
||||
fi
|
||||
if [ "$PUBLIC_IPV6" = "auto" ]; then
|
||||
# Use a public API to get our public IP address.
|
||||
PUBLIC_IPV6=`get_default_publicipv6`
|
||||
echo "IPv6 Address: $PUBLIC_IPV6"
|
||||
# Use a public API to get our public IPv6 address, or fall back to local network configuration.
|
||||
PUBLIC_IPV6=$(get_publicip_from_web_service 6 || get_default_privateip 6)
|
||||
fi
|
||||
if [ "$PRIMARY_HOSTNAME" = "auto-easy" ]; then
|
||||
# Generate a probably-unique subdomain under our justtesting.email domain.
|
||||
PRIMARY_HOSTNAME=m`get_default_publicip | sha1sum | cut -c1-5`.justtesting.email
|
||||
echo "Primary Hostname: $PRIMARY_HOSTNAME"
|
||||
PRIMARY_HOSTNAME=`echo $PUBLIC_IP | sha1sum | cut -c1-5`.justtesting.email
|
||||
fi
|
||||
|
||||
# Show the configuration, since the user may have not entered it manually.
|
||||
echo
|
||||
echo "Primary Hostname: $PRIMARY_HOSTNAME"
|
||||
echo "Public IP Address: $PUBLIC_IP"
|
||||
if [ ! -z "$PUBLIC_IPV6" ]; then
|
||||
echo "Public IPv6 Address: $PUBLIC_IPV6"
|
||||
fi
|
||||
if [ "$PRIVATE_IP" != "$PUBLIC_IP" ]; then
|
||||
echo "Private IP Address: $PRIVATE_IP"
|
||||
fi
|
||||
if [ "$PRIVATE_IPV6" != "$PUBLIC_IPV6" ]; then
|
||||
echo "Private IPv6 Address: $PRIVATE_IPV6"
|
||||
fi
|
||||
if [ -f /usr/bin/git ]; then
|
||||
echo "Mail-in-a-Box Version: " $(git describe)
|
||||
fi
|
||||
echo
|
||||
|
||||
# Run some network checks to make sure setup on this machine makes sense.
|
||||
if [ -z "$SKIP_NETWORK_CHECKS" ]; then
|
||||
. setup/network-checks.sh
|
||||
fi
|
||||
|
||||
# Create the user named "user-data" and store all persistent user
|
||||
# data (mailboxes, etc.) in that user's home directory.
|
||||
@@ -185,6 +79,8 @@ if [ -z "$STORAGE_ROOT" ]; then
|
||||
if [ ! -d /home/$STORAGE_USER ]; then useradd -m $STORAGE_USER; fi
|
||||
STORAGE_ROOT=/home/$STORAGE_USER
|
||||
mkdir -p $STORAGE_ROOT
|
||||
echo $(setup/migrate.py --current) > $STORAGE_ROOT/mailinabox.version
|
||||
chown $STORAGE_USER.$STORAGE_USER $STORAGE_ROOT/mailinabox.version
|
||||
fi
|
||||
|
||||
# Save the global options in /etc/mailinabox.conf so that standalone
|
||||
@@ -195,8 +91,9 @@ STORAGE_ROOT=$STORAGE_ROOT
|
||||
PRIMARY_HOSTNAME=$PRIMARY_HOSTNAME
|
||||
PUBLIC_IP=$PUBLIC_IP
|
||||
PUBLIC_IPV6=$PUBLIC_IPV6
|
||||
PRIVATE_IP=$PRIVATE_IP
|
||||
PRIVATE_IPV6=$PRIVATE_IPV6
|
||||
CSR_COUNTRY=$CSR_COUNTRY
|
||||
MIGRATIONID=$MIGRATIONID
|
||||
EOF
|
||||
|
||||
# Start service configuration.
|
||||
@@ -210,6 +107,7 @@ EOF
|
||||
. setup/spamassassin.sh
|
||||
. setup/web.sh
|
||||
. setup/webmail.sh
|
||||
. setup/owncloud.sh
|
||||
. setup/zpush.sh
|
||||
. setup/management.sh
|
||||
|
||||
@@ -219,37 +117,32 @@ curl -s -d POSTDATA --user $(</var/lib/mailinabox/api.key): http://127.0.0.1:102
|
||||
curl -s -d POSTDATA --user $(</var/lib/mailinabox/api.key): http://127.0.0.1:10222/web/update
|
||||
|
||||
# If there aren't any mail users yet, create one.
|
||||
if [ -z "`tools/mail.py user`" ]; then
|
||||
# The outut of "tools/mail.py user" is a list of mail users. If there
|
||||
# aren't any yet, it'll be empty.
|
||||
. setup/firstuser.sh
|
||||
|
||||
# If we didn't ask for an email address at the start, do so now.
|
||||
if [ -z "$EMAIL_ADDR" ]; then
|
||||
# In an interactive shell, ask the user for an email address.
|
||||
if [ -t 0 ]; then
|
||||
echo
|
||||
echo "Let's create your first mail user."
|
||||
read -e -i "user@$PRIMARY_HOSTNAME" -p "Email Address: " EMAIL_ADDR
|
||||
|
||||
# But in a non-interactive shell, just make something up. This
|
||||
# is normally for testing.
|
||||
else
|
||||
# Use me@PRIMARY_HOSTNAME
|
||||
EMAIL_ADDR=me@$PRIMARY_HOSTNAME
|
||||
EMAIL_PW=1234
|
||||
echo
|
||||
echo "Creating a new mail account for $EMAIL_ADDR with password $EMAIL_PW."
|
||||
echo
|
||||
fi
|
||||
else
|
||||
echo
|
||||
echo "Okay. I'm about to set up $EMAIL_ADDR for you."
|
||||
fi
|
||||
|
||||
# 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
|
||||
|
||||
# Create an alias to which we'll direct all automatically-created administrative aliases.
|
||||
tools/mail.py alias add administrator@$PRIMARY_HOSTNAME $EMAIL_ADDR
|
||||
# Done.
|
||||
echo
|
||||
echo "-----------------------------------------------"
|
||||
echo
|
||||
echo Your Mail-in-a-Box is running.
|
||||
echo
|
||||
echo Please log in to the control panel for further instructions at:
|
||||
echo
|
||||
if management/status_checks.py --check-primary-hostname; then
|
||||
# Show the nice URL if it appears to be resolving and has a valid certificate.
|
||||
echo https://$PRIMARY_HOSTNAME/admin
|
||||
echo
|
||||
echo If you have a DNS problem use the box\'s IP address and check the SSL fingerprint:
|
||||
echo https://$PUBLIC_IP/admin
|
||||
else
|
||||
echo https://$PUBLIC_IP/admin
|
||||
echo
|
||||
echo You will be alerted that the website has an invalid certificate. Check that
|
||||
echo the certificate fingerprint matches:
|
||||
echo
|
||||
fi
|
||||
openssl x509 -in $STORAGE_ROOT/ssl/ssl_certificate.pem -noout -fingerprint \
|
||||
| sed "s/SHA1 Fingerprint=//"
|
||||
echo
|
||||
echo Then you can confirm the security exception and continue.
|
||||
echo
|
||||
|
||||
|
||||
100
setup/system.sh
100
setup/system.sh
@@ -1,6 +1,11 @@
|
||||
source setup/functions.sh # load our functions
|
||||
|
||||
# Base system configuration.
|
||||
# Base system configuration
|
||||
# -------------------------
|
||||
|
||||
# ### Base packages
|
||||
|
||||
# Update system packages:
|
||||
|
||||
echo Updating system packages...
|
||||
hide_output apt-get update
|
||||
@@ -8,55 +13,81 @@ hide_output apt-get -y upgrade
|
||||
|
||||
# Install basic utilities.
|
||||
#
|
||||
# haveged: Provides extra entropy to /dev/random so it doesn't stall
|
||||
# * haveged: Provides extra entropy to /dev/random so it doesn't stall
|
||||
# when generating random numbers for private keys (e.g. during
|
||||
# ldns-keygen).
|
||||
# * unattended-upgrades: Apt tool to install security updates automatically.
|
||||
# * ntp: keeps the system time correct
|
||||
# * fail2ban: scans log files for repeated failed login attempts and blocks the remote IP at the firewall
|
||||
|
||||
apt_install python3 python3-pip wget curl bind9-host haveged
|
||||
apt_install python3 python3-dev python3-pip \
|
||||
wget curl \
|
||||
haveged unattended-upgrades ntp fail2ban
|
||||
|
||||
# Turn on basic services:
|
||||
# Allow apt to install system updates automatically every day.
|
||||
|
||||
cat > /etc/apt/apt.conf.d/02periodic <<EOF;
|
||||
APT::Periodic::MaxAge "7";
|
||||
APT::Periodic::Update-Package-Lists "1";
|
||||
APT::Periodic::Unattended-Upgrade "1";
|
||||
APT::Periodic::Verbose "1";
|
||||
EOF
|
||||
|
||||
# ### Firewall
|
||||
|
||||
# Turn on the firewall.
|
||||
#
|
||||
# ntp: keeps the system time correct
|
||||
#
|
||||
# fail2ban: scans log files for repeated failed login attempts and blocks the remote IP at the firewall
|
||||
#
|
||||
# These services don't need further configuration and are started immediately after installation.
|
||||
|
||||
apt_install ntp fail2ban
|
||||
|
||||
# Various virtualized environments like Docker and some VPSs don't provide #NODOC
|
||||
# a kernel that supports iptables. To avoid error-like output in these cases, #NODOC
|
||||
# we skip this if the user sets DISABLE_FIREWALL=1. #NODOC
|
||||
if [ -z "$DISABLE_FIREWALL" ]; then
|
||||
# Turn on the firewall. First allow incoming SSH, then turn on the firewall.
|
||||
# Other ports will be opened at the point where we set up those services.
|
||||
#
|
||||
# Various virtualized environments like Docker and some VPSs don't provide
|
||||
# a kernel that supports iptables. To avoid error-like output in these cases,
|
||||
# let us disable the firewall.
|
||||
# Install `ufw` which provides a simple firewall configuration.
|
||||
apt_install ufw
|
||||
ufw_allow ssh;
|
||||
ufw --force enable;
|
||||
fi
|
||||
|
||||
# Resolve DNS using bind9 locally, rather than whatever DNS server is supplied
|
||||
# by the machine's network configuration. We do this to ensure that DNS queries
|
||||
# Allow incoming connections to SSH.
|
||||
ufw_allow ssh;
|
||||
|
||||
# ssh might be running on an alternate port. Use sshd -T to dump sshd's
|
||||
# settings, find the port it is supposedly running on, and open that port
|
||||
# too.
|
||||
SSH_PORT=$(sshd -T 2>/dev/null | grep "^port " | sed "s/port //")
|
||||
if [ ! -z "$SSH_PORT" ]; then
|
||||
if [ "$SSH_PORT" != "22" ]; then
|
||||
|
||||
echo Opening alternate SSH port $SSH_PORT.
|
||||
ufw_allow $SSH_PORT;
|
||||
|
||||
fi
|
||||
fi
|
||||
|
||||
ufw --force enable;
|
||||
fi #NODOC
|
||||
|
||||
# ### Local DNS Service
|
||||
|
||||
# Install a local DNS server, rather than using the DNS server provided by the
|
||||
# ISP's network configuration.
|
||||
#
|
||||
# We do this to ensure that DNS queries
|
||||
# that *we* make (i.e. looking up other external domains) perform DNSSEC checks.
|
||||
# We could use Google's Public DNS, but we don't want to create a dependency on
|
||||
# Google per our goals of decentralization. bind9, as packaged for Ubuntu, has
|
||||
# Google per our goals of decentralization. `bind9`, as packaged for Ubuntu, has
|
||||
# DNSSEC enabled by default via "dnssec-validation auto".
|
||||
#
|
||||
# So we'll be running bind9 bound to 127.0.0.1 for locally-issued DNS queries
|
||||
# and nsd bound to the public ethernet interface for remote DNS queries asking
|
||||
# about our domain names. nsd is configured in dns.sh.
|
||||
# So we'll be running `bind9` bound to 127.0.0.1 for locally-issued DNS queries
|
||||
# and `nsd` bound to the public ethernet interface for remote DNS queries asking
|
||||
# about our domain names. `nsd` is configured later.
|
||||
#
|
||||
# About the settings:
|
||||
#
|
||||
# * RESOLVCONF=yes will have bind9 take over /etc/resolv.conf to tell
|
||||
# * RESOLVCONF=yes will have `bind9` take over /etc/resolv.conf to tell
|
||||
# local services that DNS queries are handled on localhost.
|
||||
# * Adding -4 to OPTIONS will have bind9 not listen on IPv6 addresses
|
||||
# * Adding -4 to OPTIONS will have `bind9` not listen on IPv6 addresses
|
||||
# so that we're sure there's no conflict with nsd, our public domain
|
||||
# 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.
|
||||
apt_install bind9
|
||||
apt_install bind9 resolvconf
|
||||
tools/editconf.py /etc/default/bind9 \
|
||||
RESOLVCONF=yes \
|
||||
"OPTIONS=\"-u bind -4\""
|
||||
@@ -64,5 +95,12 @@ 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.
|
||||
sed -i "s/^}/\n\tlisten-on { 127.0.0.1; };\n}/" /etc/bind/named.conf.options
|
||||
fi
|
||||
if [ -f /etc/resolvconf/resolv.conf.d/original ]; then
|
||||
echo "Archiving old resolv.conf (was /etc/resolvconf/resolv.conf.d/original, now /etc/resolvconf/resolv.conf.original)." #NODOC
|
||||
mv /etc/resolvconf/resolv.conf.d/original /etc/resolvconf/resolv.conf.original #NODOC
|
||||
fi
|
||||
|
||||
# Restart the DNS services.
|
||||
|
||||
restart_service bind9
|
||||
restart_service resolvconf
|
||||
|
||||
32
setup/web.sh
32
setup/web.sh
@@ -5,7 +5,7 @@
|
||||
source setup/functions.sh # load our functions
|
||||
source /etc/mailinabox.conf # load global vars
|
||||
|
||||
apt_install nginx php5-cgi
|
||||
apt_install nginx php5-fpm
|
||||
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
|
||||
@@ -18,6 +18,10 @@ cp conf/nginx-ssl.conf /etc/nginx/nginx-ssl.conf
|
||||
tools/editconf.py /etc/nginx/nginx.conf -s \
|
||||
server_names_hash_bucket_size="64;"
|
||||
|
||||
# Bump up max_children to support more concurrent connections
|
||||
tools/editconf.py /etc/php5/fpm/pool.d/www.conf -c ';' \
|
||||
pm.max_children=8
|
||||
|
||||
# Other nginx settings will be configured by the management service
|
||||
# since it depends on what domains we're serving, which we don't know
|
||||
# until mail accounts have been created.
|
||||
@@ -30,26 +34,34 @@ if [ ! -f $STORAGE_ROOT/www/default/index.html ]; then
|
||||
fi
|
||||
chown -R $STORAGE_USER $STORAGE_ROOT/www
|
||||
|
||||
# Create an init script to start the PHP FastCGI daemon and keep it
|
||||
# running after a reboot. Allows us to serve Roundcube for webmail.
|
||||
rm -f /etc/init.d/php-fastcgi
|
||||
ln -s $(pwd)/conf/phpfcgi-initscript /etc/init.d/php-fastcgi
|
||||
hide_output update-rc.d php-fastcgi defaults
|
||||
# We previously installed a custom init script to start the PHP FastCGI daemon.
|
||||
# Remove it now that we're using php5-fpm.
|
||||
if [ -L /etc/init.d/php-fastcgi ]; then
|
||||
echo "Removing /etc/init.d/php-fastcgi, php5-cgi..."
|
||||
rm -f /etc/init.d/php-fastcgi
|
||||
hide_output update-rc.d php-fastcgi remove
|
||||
apt-get -y purge php5-cgi
|
||||
fi
|
||||
|
||||
# Put our webfinger and Exchange autodiscover.xml server scripts
|
||||
# into a well-known location.
|
||||
for f in webfinger exchange-autodiscover; do
|
||||
# Put our webfinger script into a well-known location.
|
||||
for f in webfinger; do
|
||||
cp tools/$f.php /usr/local/bin/mailinabox-$f.php
|
||||
chown www-data.www-data /usr/local/bin/mailinabox-$f.php
|
||||
done
|
||||
|
||||
# Remove obsoleted scripts.
|
||||
# exchange-autodiscover is now handled by Z-Push.
|
||||
for f in exchange-autodiscover; do
|
||||
rm -f /usr/local/bin/mailinabox-$f.php
|
||||
done
|
||||
|
||||
# Make some space for users to customize their webfinger responses.
|
||||
mkdir -p $STORAGE_ROOT/webfinger/acct;
|
||||
chown -R $STORAGE_USER $STORAGE_ROOT/webfinger
|
||||
|
||||
# Start services.
|
||||
restart_service nginx
|
||||
restart_service php-fastcgi
|
||||
restart_service php5-fpm
|
||||
|
||||
# Open ports.
|
||||
ufw_allow http
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
# Webmail: Using roundcube
|
||||
##########################
|
||||
# Webmail with Roundcube
|
||||
# ----------------------
|
||||
|
||||
source setup/functions.sh # load our functions
|
||||
source /etc/mailinabox.conf # load global vars
|
||||
|
||||
# Ubuntu's roundcube-core has dependencies on Apache & MySQL, which we don't want, so we can't
|
||||
# install roundcube directly via apt-get install.
|
||||
# ### Installing Roundcube
|
||||
|
||||
# We install Roundcube from sources, rather than from Ubuntu, because:
|
||||
#
|
||||
# Additionally, the Roundcube shipped with Ubuntu is consistently out of date.
|
||||
# 1. Ubuntu's `roundcube-core` package has dependencies on Apache & MySQL, which we don't want.
|
||||
#
|
||||
# And it's packaged incorrectly --- it seems to be missing a directory of files.
|
||||
# 2. The Roundcube shipped with Ubuntu is consistently out of date.
|
||||
#
|
||||
# 3. It's packaged incorrectly --- it seems to be missing a directory of files.
|
||||
#
|
||||
# So we'll use apt-get to manually install the dependencies of roundcube that we know we need,
|
||||
# and then we'll manually install roundcube from source.
|
||||
|
||||
# These dependencies are from 'apt-cache showpkg roundcube-core'.
|
||||
# These dependencies are from `apt-cache showpkg roundcube-core`.
|
||||
apt_install \
|
||||
dbconfig-common \
|
||||
php5 php5-sqlite php5-mcrypt php5-intl php5-json php5-common php-auth php-net-smtp php-net-socket php-net-sieve php-mail-mime php-crypt-gpg php5-gd php5-pspell \
|
||||
@@ -29,21 +32,23 @@ apt-get purge -qq -y roundcube*
|
||||
# TODO: Check version?
|
||||
if [ ! -d /usr/local/lib/roundcubemail ]; then
|
||||
rm -f /tmp/roundcube.tgz
|
||||
wget -qO /tmp/roundcube.tgz http://downloads.sourceforge.net/project/roundcubemail/roundcubemail/1.0.1/roundcubemail-1.0.1.tar.gz
|
||||
wget -qO /tmp/roundcube.tgz http://downloads.sourceforge.net/project/roundcubemail/roundcubemail/1.0.2/roundcubemail-1.0.2.tar.gz
|
||||
tar -C /usr/local/lib -zxf /tmp/roundcube.tgz
|
||||
mv /usr/local/lib/roundcubemail-1.0.1/ /usr/local/lib/roundcubemail
|
||||
mv /usr/local/lib/roundcubemail-1.0.2/ /usr/local/lib/roundcubemail
|
||||
rm -f /tmp/roundcube.tgz
|
||||
fi
|
||||
|
||||
# ### Configuring Roundcube
|
||||
|
||||
# Generate a safe 24-character secret key of safe characters.
|
||||
SECRET_KEY=$(dd if=/dev/random bs=20 count=1 2>/dev/null | base64 | fold -w 24 | head -n 1)
|
||||
SECRET_KEY=$(dd if=/dev/random bs=1 count=18 2>/dev/null | base64 | fold -w 24 | head -n 1)
|
||||
|
||||
# Create a configuration file.
|
||||
#
|
||||
# For security, temp and log files are not stored in the default locations
|
||||
# which are inside the roundcube sources directory. We put them instead
|
||||
# in normal places.
|
||||
cat - > /usr/local/lib/roundcubemail/config/config.inc.php <<EOF;
|
||||
cat > /usr/local/lib/roundcubemail/config/config.inc.php <<EOF;
|
||||
<?php
|
||||
/*
|
||||
* Do not edit. Written by Mail-in-a-Box. Regenerated on updates.
|
||||
@@ -100,4 +105,4 @@ chmod 664 $STORAGE_ROOT/mail/users.sqlite
|
||||
|
||||
# Enable PHP modules.
|
||||
php5enmod mcrypt
|
||||
restart_service php-fastcgi
|
||||
restart_service php5-fpm
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Z-Push: The Microsoft Exchange protocol server.
|
||||
# Z-Push: The Microsoft Exchange protocol server
|
||||
# ----------------------------------------------
|
||||
#
|
||||
# Mostly for use on iOS which doesn't support IMAP.
|
||||
#
|
||||
# Although Ubuntu ships Z-Push (as d-push) it has a dependency on Apache
|
||||
@@ -14,30 +16,56 @@ source /etc/mailinabox.conf # load global vars
|
||||
# Prereqs.
|
||||
|
||||
apt_install \
|
||||
php-soap php5-imap
|
||||
php-soap php5-imap libawl-php php5-xsl
|
||||
|
||||
php5enmod imap
|
||||
|
||||
# Copy Z-Push into place.
|
||||
|
||||
if [ ! -d /usr/local/lib/z-push ]; then
|
||||
ZPUSH=z-push-2.1.3-1892
|
||||
wget -qO /tmp/zpush.tgz http://download.z-push.org/final/2.1/$ZPUSH.tar.gz
|
||||
tar -C /tmp -zxf /tmp/zpush.tgz
|
||||
mv /tmp/$ZPUSH /usr/local/lib/z-push
|
||||
needs_update=0 #NODOC
|
||||
if [ ! -f /usr/local/lib/z-push/version ]; then
|
||||
needs_update=1 #NODOC
|
||||
elif [[ `curl -s https://api.github.com/repos/fmbiete/Z-Push-contrib/git/refs/heads/master` != `cat /usr/local/lib/z-push/version` ]]; then
|
||||
# checks if the version
|
||||
needs_update=1 #NODOC
|
||||
fi
|
||||
if [ $needs_update == 1 ]; then
|
||||
rm -rf /usr/local/lib/z-push
|
||||
rm -f /tmp/zpush.zip
|
||||
echo installing z-push \(fmbiete fork\)...
|
||||
wget -qO /tmp/zpush.zip https://github.com/fmbiete/Z-Push-contrib/archive/master.zip
|
||||
unzip -q /tmp/zpush.zip -d /usr/local/lib/
|
||||
mv /usr/local/lib/Z-Push-contrib-master /usr/local/lib/z-push
|
||||
rm -f /usr/sbin/z-push-{admin,top}
|
||||
ln -s /usr/local/lib/z-push/z-push-admin.php /usr/sbin/z-push-admin
|
||||
ln -s /usr/local/lib/z-push/z-push-top.php /usr/sbin/z-push-top
|
||||
rm /tmp/zpush.tgz;
|
||||
rm /tmp/zpush.zip;
|
||||
curl -s https://api.github.com/repos/fmbiete/Z-Push-contrib/git/refs/heads/master > /usr/local/lib/z-push/version
|
||||
fi
|
||||
|
||||
# Configure. Tell is to connect to email via IMAP using SSL. Since we connect on
|
||||
# localhost, the certificate won't match (it may be self-signed and invalid anyway)
|
||||
# so don't check the cert.
|
||||
sed -i "s/define('BACKEND_PROVIDER', .*/define('BACKEND_PROVIDER', 'BackendIMAP');/" /usr/local/lib/z-push/config.php
|
||||
#sed -i "s/define('IMAP_SERVER', .*/define('IMAP_SERVER', '$PRIMARY_HOSTNAME');/" /usr/local/lib/z-push/backend/imap/config.php
|
||||
sed -i "s/define('IMAP_PORT', .*/define('IMAP_PORT', 993);/" /usr/local/lib/z-push/backend/imap/config.php
|
||||
sed -i "s/define('IMAP_OPTIONS', .*/define('IMAP_OPTIONS', '\/ssl\/norsh\/novalidate-cert');/" /usr/local/lib/z-push/backend/imap/config.php
|
||||
# Configure default config.
|
||||
sed -i "s^define('TIMEZONE', .*^define('TIMEZONE', '$(cat /etc/timezone)');^" /usr/local/lib/z-push/config.php
|
||||
sed -i "s/define('BACKEND_PROVIDER', .*/define('BACKEND_PROVIDER', 'BackendCombined');/" /usr/local/lib/z-push/config.php
|
||||
|
||||
# Configure BACKEND
|
||||
rm -f /usr/local/lib/z-push/backend/combined/config.php
|
||||
cp conf/zpush/backend_combined.php /usr/local/lib/z-push/backend/combined/config.php
|
||||
|
||||
# Configure IMAP
|
||||
rm -f /usr/local/lib/z-push/backend/imap/config.php
|
||||
cp conf/zpush/backend_imap.php /usr/local/lib/z-push/backend/imap/config.php
|
||||
|
||||
# Configure CardDav
|
||||
rm -f /usr/local/lib/z-push/backend/carddav/config.php
|
||||
cp conf/zpush/backend_carddav.php /usr/local/lib/z-push/backend/carddav/config.php
|
||||
|
||||
# Configure CalDav
|
||||
rm -f /usr/local/lib/z-push/backend/caldav/config.php
|
||||
cp conf/zpush/backend_caldav.php /usr/local/lib/z-push/backend/caldav/config.php
|
||||
|
||||
# Configure Autodiscover
|
||||
rm -f /usr/local/lib/z-push/autodiscover/config.php
|
||||
cp conf/zpush/autodiscover_config.php /usr/local/lib/z-push/autodiscover/config.php
|
||||
sed -i "s/PRIMARY_HOSTNAME/$PRIMARY_HOSTNAME/" /usr/local/lib/z-push/autodiscover/config.php
|
||||
|
||||
# Some directories it will use.
|
||||
|
||||
@@ -50,4 +78,4 @@ chown www-data:www-data /var/lib/z-push
|
||||
|
||||
# Restart service.
|
||||
|
||||
restart_service php-fastcgi
|
||||
restart_service php5-fpm
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
#!/bin/bash
|
||||
curl -s -d POSTDATA --user $(</var/lib/mailinabox/api.key): http://127.0.0.1:10222/dns/update
|
||||
POSTDATA=dummy
|
||||
if [ "$1" == "--force" ]; then
|
||||
POSTDATA=force=1
|
||||
fi
|
||||
curl -s -d $POSTDATA --user $(</var/lib/mailinabox/api.key): http://127.0.0.1:10222/dns/update
|
||||
|
||||
@@ -33,6 +33,7 @@ settings = sys.argv[2:]
|
||||
|
||||
delimiter = "="
|
||||
delimiter_re = r"\s*=\s*"
|
||||
comment_char = "#"
|
||||
folded_lines = False
|
||||
testing = False
|
||||
while settings[0][0] == "-" and settings[0] != "--":
|
||||
@@ -42,7 +43,11 @@ while settings[0][0] == "-" and settings[0] != "--":
|
||||
delimiter = " "
|
||||
delimiter_re = r"\s+"
|
||||
elif opt == "-w":
|
||||
# Line folding is possible in this file.
|
||||
folded_lines = True
|
||||
elif opt == "-c":
|
||||
# Specifies a different comment character.
|
||||
comment_char = settings.pop(0)
|
||||
elif opt == "-t":
|
||||
testing = True
|
||||
else:
|
||||
@@ -60,7 +65,7 @@ while len(input_lines) > 0:
|
||||
|
||||
# If this configuration file uses folded lines, append any folded lines
|
||||
# into our input buffer.
|
||||
if folded_lines and line[0] not in ("#", " ", ""):
|
||||
if folded_lines and line[0] not in (comment_char, " ", ""):
|
||||
while len(input_lines) > 0 and input_lines[0][0] in " \t":
|
||||
line += input_lines.pop(0)
|
||||
|
||||
@@ -68,7 +73,11 @@ while len(input_lines) > 0:
|
||||
for i in range(len(settings)):
|
||||
# Check that this line contain this setting from the command-line arguments.
|
||||
name, val = settings[i].split("=", 1)
|
||||
m = re.match("(\s*)(#\s*)?" + re.escape(name) + delimiter_re + "(.*?)\s*$", line, re.S)
|
||||
m = re.match(
|
||||
"(\s*)"
|
||||
+ "(" + re.escape(comment_char) + "\s*)?"
|
||||
+ re.escape(name) + delimiter_re + "(.*?)\s*$",
|
||||
line, re.S)
|
||||
if not m: continue
|
||||
indent, is_comment, existing_val = m.groups()
|
||||
|
||||
@@ -83,7 +92,7 @@ while len(input_lines) > 0:
|
||||
|
||||
# comment-out the existing line (also comment any folded lines)
|
||||
if is_comment is None:
|
||||
buf += "#" + line.rstrip().replace("\n", "\n#") + "\n"
|
||||
buf += comment_char + line.rstrip().replace("\n", "\n" + comment_char) + "\n"
|
||||
else:
|
||||
# the line is already commented, pass it through
|
||||
buf += line
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
<?php
|
||||
// Parse our configuration file to get the PRIMARY_HOSTNAME.
|
||||
$PRIMARY_HOSTNAME = NULL;
|
||||
foreach (file("/etc/mailinabox.conf") as $line) {
|
||||
$line = explode("=", rtrim($line), 2);
|
||||
if ($line[0] == "PRIMARY_HOSTNAME") {
|
||||
$PRIMARY_HOSTNAME = $line[1];
|
||||
}
|
||||
}
|
||||
if ($PRIMARY_HOSTNAME == NULL) exit("no PRIMARY_HOSTNAME");
|
||||
|
||||
// We might get two kinds of requests.
|
||||
$post_body = file_get_contents('php://input');
|
||||
preg_match('/<AcceptableResponseSchema>(.*?)<\/AcceptableResponseSchema>/', $post_body, $match);
|
||||
$AcceptableResponseSchema = $match[1];
|
||||
|
||||
if ($AcceptableResponseSchema == "http://schemas.microsoft.com/exchange/autodiscover/mobilesync/responseschema/2006") {
|
||||
// There is no way to convey the user's login name with this?
|
||||
?>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Autodiscover
|
||||
xmlns:autodiscover="http://schemas.microsoft.com/exchange/autodiscover/mobilesync/responseschema/2006">
|
||||
<autodiscover:Response>
|
||||
<autodiscover:Action>
|
||||
<autodiscover:Settings>
|
||||
<autodiscover:Server>
|
||||
<autodiscover:Type>MobileSync</autodiscover:Type>
|
||||
<autodiscover:Url>https://<?php echo $PRIMARY_HOSTNAME ?></autodiscover:Url>
|
||||
<autodiscover:Name>https://<?php echo $PRIMARY_HOSTNAME ?></autodiscover:Name>
|
||||
</autodiscover:Server>
|
||||
</autodiscover:Settings>
|
||||
</autodiscover:Action>
|
||||
</autodiscover:Response>
|
||||
</Autodiscover>
|
||||
<?php
|
||||
} else {
|
||||
|
||||
// I don't know when this is actually used. I implemented this before seeing that
|
||||
// it is not what my phone wanted.
|
||||
|
||||
// Parse the email address out of the POST request, which
|
||||
// we pass back as the login name.
|
||||
preg_match('/<EMailAddress>(.*?)<\/EMailAddress>/', $post_body, $match);
|
||||
$LOGIN = $match[1];
|
||||
|
||||
header("Content-type: text/xml");
|
||||
?>
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006">
|
||||
<Response xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a">
|
||||
<ServiceHome>https://<?php echo $PRIMARY_HOSTNAME ?></ServiceHome>
|
||||
<Account>
|
||||
<AccountType>email</AccountType>
|
||||
<Action>settings</Action>
|
||||
|
||||
<Protocol>
|
||||
<Type>IMAP</Type>
|
||||
<Server><?php echo $PRIMARY_HOSTNAME ?></Server>
|
||||
<Port>993</Port>
|
||||
<SSL>on</SSL>
|
||||
<LoginName><?php echo $LOGIN ?></LoginName>
|
||||
</Protocol>
|
||||
|
||||
<Protocol>
|
||||
<Type>SMTP</Type>
|
||||
<Server><?php echo $PRIMARY_HOSTNAME ?></Server>
|
||||
<Port>587</Port>
|
||||
<SSL>on</SSL>
|
||||
<LoginName><?php echo $LOGIN ?></LoginName>
|
||||
</Protocol>
|
||||
|
||||
<Protocol>
|
||||
<Type>DAV</Type>
|
||||
<Server>https://<?php echo $PRIMARY_HOSTNAME ?></Server>
|
||||
<SSL>on</SSL>
|
||||
<DomainRequired>on</DomainRequired>
|
||||
<LoginName><?php echo $LOGIN ?></LoginName>
|
||||
</Protocol>
|
||||
|
||||
<Protocol>
|
||||
<Type>WEB</Type>
|
||||
<Server>https://<?php echo $PRIMARY_HOSTNAME ?>/mail</Server>
|
||||
<SSL>on</SSL>
|
||||
</Protocol>
|
||||
</Account>
|
||||
</Response>
|
||||
</Autodiscover>
|
||||
|
||||
<?php
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
import sys, getpass, urllib.request, urllib.error
|
||||
import sys, getpass, urllib.request, urllib.error, json
|
||||
|
||||
def mgmt(cmd, data=None):
|
||||
def mgmt(cmd, data=None, is_json=False):
|
||||
mgmt_uri = 'http://localhost:10222'
|
||||
|
||||
setup_key_auth(mgmt_uri)
|
||||
@@ -11,9 +11,20 @@ def mgmt(cmd, data=None):
|
||||
try:
|
||||
response = urllib.request.urlopen(req)
|
||||
except urllib.error.HTTPError as e:
|
||||
print(e.read().decode('utf8'))
|
||||
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)
|
||||
return response.read().decode('utf8')
|
||||
resp = response.read().decode('utf8')
|
||||
if is_json: resp = json.loads(resp)
|
||||
return resp
|
||||
|
||||
def read_password():
|
||||
first = getpass.getpass('password: ')
|
||||
@@ -42,15 +53,26 @@ if len(sys.argv) < 2:
|
||||
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:
|
||||
print(mgmt("/mail/users"))
|
||||
# Dump a list of users, one per line. Mark admins with an asterisk.
|
||||
users = mgmt("/mail/users?format=json", is_json=True)
|
||||
for user in 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:
|
||||
@@ -70,6 +92,20 @@ elif sys.argv[1] == "user" and sys.argv[2] in ("add", "password"):
|
||||
elif sys.argv[1] == "user" and sys.argv[2] == "remove" and len(sys.argv) == 4:
|
||||
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 user in users:
|
||||
if "admin" in user['privileges']:
|
||||
print(user['email'])
|
||||
|
||||
elif sys.argv[1] == "alias" and len(sys.argv) == 2:
|
||||
print(mgmt("/mail/aliases"))
|
||||
|
||||
@@ -81,4 +117,5 @@ elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4:
|
||||
|
||||
else:
|
||||
print("Invalid command-line arguments.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
23
tools/owncloud-unlockadmin.sh
Executable file
23
tools/owncloud-unlockadmin.sh
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# This script will give you administrative access to the ownCloud
|
||||
# instance running here.
|
||||
#
|
||||
# Run this at your own risk. This is for testing & experimentation
|
||||
# purpopses only. After this point you are on your own.
|
||||
|
||||
source /etc/mailinabox.conf # load global vars
|
||||
|
||||
ADMIN=$(./mail.py user admins | head -n 1)
|
||||
test -z "$1" || ADMIN=$1
|
||||
|
||||
echo I am going to unlock admin features for $ADMIN.
|
||||
echo You can provide another user to unlock as the first argument of this script.
|
||||
echo
|
||||
echo WARNING: you could break mail-in-a-box when fiddling around with owncloud\'s admin interface
|
||||
echo If in doubt, press CTRL-C to cancel.
|
||||
echo
|
||||
echo Press enter to continue.
|
||||
read
|
||||
|
||||
sqlite3 $STORAGE_ROOT/owncloud/owncloud.db "INSERT OR IGNORE INTO oc_group_user VALUES ('admin', '$ADMIN')" && echo Done.
|
||||
266
tools/readable_bash.py
Normal file
266
tools/readable_bash.py
Normal file
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/python3
|
||||
#
|
||||
# Generate documentation for how this machine works by
|
||||
# parsing our bash scripts!
|
||||
|
||||
import cgi, re
|
||||
import markdown
|
||||
from modgrammar import *
|
||||
|
||||
def generate_documentation():
|
||||
print("""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
|
||||
<title>Build Your Own Mail Server From Scratch</title>
|
||||
|
||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap-theme.min.css">
|
||||
|
||||
<style>
|
||||
@import url(https://fonts.googleapis.com/css?family=Iceland);
|
||||
@import url(https://fonts.googleapis.com/css?family=Raleway:400,700);
|
||||
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,500);
|
||||
body {
|
||||
font-family: Raleway, sans-serif;
|
||||
font-size: 16px;
|
||||
color: #555;
|
||||
}
|
||||
h2, h3 {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
p {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 1em 1em 1.5em 1em;
|
||||
color: black;
|
||||
}
|
||||
|
||||
div.write-to {
|
||||
margin: 1em;
|
||||
border: 1px solid #999;
|
||||
}
|
||||
div.write-to p {
|
||||
padding: .5em;
|
||||
margin: 0;
|
||||
}
|
||||
div.write-to .filename {
|
||||
background-color: #EEE;
|
||||
padding: .5em;
|
||||
font-weight: bold;
|
||||
}
|
||||
div.write-to pre {
|
||||
padding: .5em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
pre.shell > div:before {
|
||||
content: "$ ";
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<h1>Build Your Own Mail Server From Scratch</h1>
|
||||
<p>Here’s how you can build your own mail server from scratch. This document is generated automatically from our setup script.</p>
|
||||
<hr>
|
||||
""")
|
||||
|
||||
parser = Source.parser()
|
||||
for line in open("setup/start.sh"):
|
||||
try:
|
||||
fn = parser.parse_string(line).filename()
|
||||
except:
|
||||
continue
|
||||
if fn in ("setup/preflight.sh", "setup/questions.sh", "setup/firstuser.sh", "setup/management.sh"):
|
||||
continue
|
||||
|
||||
import sys
|
||||
print(fn, file=sys.stderr)
|
||||
|
||||
print(BashScript.parse(fn))
|
||||
|
||||
print("""
|
||||
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.10.1/jquery.min.js"></script>
|
||||
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.2.0/js/bootstrap.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
""")
|
||||
|
||||
class HashBang(Grammar):
|
||||
grammar = (L('#!'), REST_OF_LINE, EOL)
|
||||
def value(self):
|
||||
return ""
|
||||
|
||||
def strip_indent(s):
|
||||
lines = s.split("\n")
|
||||
min_indent = min(len(re.match(r"\s*", line).group(0)) for line in lines if len(line) > 0)
|
||||
lines = [line[min_indent:] for line in lines]
|
||||
return "\n".join(lines)
|
||||
|
||||
class Comment(Grammar):
|
||||
grammar = ONE_OR_MORE(ZERO_OR_MORE(SPACE), L('#'), REST_OF_LINE, EOL)
|
||||
def value(self):
|
||||
if self.string.replace("#", "").strip() == "":
|
||||
return "\n"
|
||||
lines = [x[2].string for x in self[0]]
|
||||
content = "\n".join(lines)
|
||||
content = strip_indent(content)
|
||||
return markdown.markdown(content, output_format="html4") + "\n\n"
|
||||
|
||||
FILENAME = WORD('a-z0-9-/.')
|
||||
|
||||
class Source(Grammar):
|
||||
grammar = ((L('.') | L('source')), L(' '), FILENAME, Comment | EOL)
|
||||
def filename(self):
|
||||
return self[2].string.strip()
|
||||
def value(self):
|
||||
return BashScript.parse(self.filename())
|
||||
|
||||
class CatEOF(Grammar):
|
||||
grammar = (ZERO_OR_MORE(SPACE), L('cat > '), ANY_EXCEPT(WHITESPACE), L(" <<"), OPTIONAL(SPACE), L("EOF;"), EOL, REPEAT(ANY, greedy=False), EOL, L("EOF"), EOL)
|
||||
def value(self):
|
||||
content = self[7].string
|
||||
content = re.sub(r"\\([$])", r"\1", content) # un-escape bash-escaped characters
|
||||
return "<div class='write-to'><div class='filename'>overwrite<br>" + self[2].string + "</div><pre>" + cgi.escape(content) + "</pre></div>\n"
|
||||
|
||||
class HideOutput(Grammar):
|
||||
grammar = (L("hide_output "), REF("BashElement"))
|
||||
def value(self):
|
||||
return self[1].value()
|
||||
|
||||
class SuppressedLine(Grammar):
|
||||
grammar = (OPTIONAL(SPACE), L("echo "), REST_OF_LINE, EOL)
|
||||
def value(self):
|
||||
if "|" in self.string or ">" in self.string:
|
||||
return "<pre class='shell'><div>" + cgi.escape(self.string.strip()) + "</div></pre>\n"
|
||||
return ""
|
||||
|
||||
class EditConf(Grammar):
|
||||
grammar = (
|
||||
L('tools/editconf.py '),
|
||||
FILENAME,
|
||||
SPACE,
|
||||
OPTIONAL((LIST_OF(
|
||||
L("-w") | L("-s") | L("-c ';'"),
|
||||
sep=SPACE,
|
||||
), SPACE)),
|
||||
REST_OF_LINE,
|
||||
OPTIONAL(SPACE),
|
||||
EOL
|
||||
)
|
||||
def value(self):
|
||||
conffile = self[1]
|
||||
options = [""]
|
||||
mode = 1
|
||||
for c in self[4].string:
|
||||
if mode == 1 and c in (" ", "\t") and options[-1] != "":
|
||||
# new word
|
||||
options.append("")
|
||||
elif mode < 0:
|
||||
# escaped character
|
||||
options[-1] += c
|
||||
mode = -mode
|
||||
elif c == "\\":
|
||||
# escape next character
|
||||
mode = -mode
|
||||
elif mode == 1 and c == '"':
|
||||
mode = 2
|
||||
elif mode == 2 and c == '"':
|
||||
mode = 1
|
||||
else:
|
||||
options[-1] += c
|
||||
if options[-1] == "": options.pop(-1)
|
||||
return "<div class='write-to'><div class='filename'>additional settings for<br>" + self[1].string + "</div><pre>" + "\n".join(cgi.escape(s) for s in options) + "</pre></div>\n"
|
||||
|
||||
class CaptureOutput(Grammar):
|
||||
grammar = OPTIONAL(SPACE), WORD("A-Za-z_"), L('=$('), REST_OF_LINE, L(")"), OPTIONAL(L(';')), EOL
|
||||
def value(self):
|
||||
cmd = self[3].string
|
||||
cmd = cmd.replace("; ", "\n")
|
||||
return "<div class='write-to'><div class='filename'>$" + self[1].string + "=</div><pre>" + cgi.escape(cmd) + "</pre></div>\n"
|
||||
|
||||
class SedReplace(Grammar):
|
||||
grammar = OPTIONAL(SPACE), L('sed -i "s/'), OPTIONAL(L('^')), ONE_OR_MORE(WORD("-A-Za-z0-9 #=\\{};.*$_!()")), L('/'), ONE_OR_MORE(WORD("-A-Za-z0-9 #=\\{};.*$_!()")), L('/"'), SPACE, FILENAME, EOL
|
||||
def value(self):
|
||||
return "<div class='write-to'><div class='filename'>edit<br>" + self[8].string + "</div><p>replace</p><pre>" + cgi.escape(self[3].string.replace(".*", ". . .")) + "</pre><p>with</p><pre>" + cgi.escape(self[5].string.replace("\\n", "\n").replace("\\t", "\t")) + "</pre></div>\n"
|
||||
|
||||
def shell_line(bash):
|
||||
return "<pre class='shell'><div>" + cgi.escape(wrap_lines(bash.strip())) + "</div></pre>\n"
|
||||
|
||||
class AptGet(Grammar):
|
||||
grammar = (ZERO_OR_MORE(SPACE), L("apt_install "), REST_OF_LINE, EOL)
|
||||
def value(self):
|
||||
return shell_line("apt-get install -y " + re.sub(r"\s+", " ", self[2].string))
|
||||
class UfwAllow(Grammar):
|
||||
grammar = (ZERO_OR_MORE(SPACE), L("ufw_allow "), REST_OF_LINE, EOL)
|
||||
def value(self):
|
||||
return shell_line("ufw allow " + self[2].string)
|
||||
class RestartService(Grammar):
|
||||
grammar = (ZERO_OR_MORE(SPACE), L("restart_service "), REST_OF_LINE, EOL)
|
||||
def value(self):
|
||||
return shell_line("service " + self[2].string + " restart")
|
||||
|
||||
class OtherLine(Grammar):
|
||||
grammar = (REST_OF_LINE, EOL)
|
||||
def value(self):
|
||||
if self.string.strip() == "": return ""
|
||||
return "<pre class='shell'><div>" + cgi.escape(self.string.rstrip()) + "</div></pre>\n"
|
||||
|
||||
class BashElement(Grammar):
|
||||
grammar = Comment | Source | CatEOF | SuppressedLine | HideOutput | EditConf | CaptureOutput | SedReplace | AptGet | UfwAllow | RestartService | OtherLine
|
||||
def value(self):
|
||||
return self[0].value()
|
||||
|
||||
class BashScript(Grammar):
|
||||
grammar = (OPTIONAL(HashBang), REPEAT(BashElement))
|
||||
def value(self):
|
||||
return [line.value() for line in self[1]]
|
||||
|
||||
@staticmethod
|
||||
def parse(fn):
|
||||
if fn in ("setup/functions.sh", "/etc/mailinabox.conf"): return ""
|
||||
parser = BashScript.parser()
|
||||
string = open(fn).read()
|
||||
string = re.sub(r"\s*\\\n\s*", " ", string)
|
||||
string = re.sub(".* #NODOC\n", "", string)
|
||||
string = re.sub("\n\s*if .*|\n\s*fi|\n\s*else|\n\s*elif .*", "", string)
|
||||
string = re.sub("hide_output ", "", string)
|
||||
result = parser.parse_string(string)
|
||||
|
||||
v = "<div class='sourcefile'><a href=\"%s\">%s</a></div>\n" % ("https://github.com/mail-in-a-box/mailinabox/tree/master/" + fn, fn)
|
||||
v += "".join(result.value())
|
||||
|
||||
v = v.replace("</pre>\n<pre class='shell'>", "")
|
||||
v = re.sub("<pre>([\w\W]*?)</pre>", lambda m : "<pre>" + strip_indent(m.group(1)) + "</pre>", v)
|
||||
|
||||
v = re.sub(r"\$?PRIMARY_HOSTNAME", "<b>box.yourdomain.com</b>", v)
|
||||
v = re.sub(r"\$?STORAGE_ROOT", "<code><b>/path/to/user-data</b></code>", v)
|
||||
v = v.replace("`pwd`", "<code><b>/path/to/mailinabox</b></code>")
|
||||
|
||||
return v
|
||||
|
||||
def wrap_lines(text, cols=60):
|
||||
ret = ""
|
||||
words = re.split("(\s+)", text)
|
||||
linelen = 0
|
||||
for w in words:
|
||||
if linelen + len(w) > cols-1:
|
||||
ret += " \\\n"
|
||||
ret += " "
|
||||
linelen = 0
|
||||
if linelen == 0 and w.strip() == "": continue
|
||||
ret += w
|
||||
linelen += len(w)
|
||||
return ret
|
||||
|
||||
if __name__ == '__main__':
|
||||
generate_documentation()
|
||||
@@ -1,5 +1,9 @@
|
||||
<?php
|
||||
$resource = $_GET['resource'];
|
||||
$resource = '';
|
||||
|
||||
if(isset($_GET['resource'])){
|
||||
$resource = $_GET['resource'];
|
||||
}
|
||||
|
||||
// Parse our configuration file to get the STORAGE_ROOT.
|
||||
$STORAGE_ROOT = NULL;
|
||||
|
||||
Reference in New Issue
Block a user