mirror of
https://github.com/mail-in-a-box/mailinabox.git
synced 2026-03-12 17:07:23 +01:00
Compare commits
187 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b5bf602aa | ||
|
|
06e074bd32 | ||
|
|
b5b3fca137 | ||
|
|
048e35a80f | ||
|
|
fb3045f456 | ||
|
|
495790d81d | ||
|
|
dfe0a9f187 | ||
|
|
7ca54a2bfb | ||
|
|
a81bf93fdc | ||
|
|
54d01746fe | ||
|
|
57f8ee0b09 | ||
|
|
64220292f1 | ||
|
|
82851d6d2d | ||
|
|
2f952a7915 | ||
|
|
f9eceec746 | ||
|
|
b26abc947e | ||
|
|
b78eae1351 | ||
|
|
ca57560f11 | ||
|
|
17331e7d82 | ||
|
|
5130b279d8 | ||
|
|
aac6e49b94 | ||
|
|
2f4eccd9a9 | ||
|
|
ac49912b39 | ||
|
|
0441a2e2e3 | ||
|
|
8566b78202 | ||
|
|
06a8ce1c9d | ||
|
|
443b084a17 | ||
|
|
990649af2d | ||
|
|
6f4d29a410 | ||
|
|
6ab29c3244 | ||
|
|
bf9b770255 | ||
|
|
9210ebdb9f | ||
|
|
a56bb984d6 | ||
|
|
7d1c0b3834 | ||
|
|
20892b5d5b | ||
|
|
4cf53cd8ee | ||
|
|
711db9352c | ||
|
|
f42a1c5a74 | ||
|
|
092c842a87 | ||
|
|
d9ecc50119 | ||
|
|
7c2092d48f | ||
|
|
5fd107cae5 | ||
|
|
db0967446b | ||
|
|
2ff5038c84 | ||
|
|
4ae76aa2dd | ||
|
|
ba33669a62 | ||
|
|
779d921410 | ||
|
|
68efef1164 | ||
|
|
6ecada7eed | ||
|
|
94c4352f45 | ||
|
|
6dd6353d41 | ||
|
|
d06bfa6c1b | ||
|
|
5c7ba2a4c7 | ||
|
|
e9cc3fdaab | ||
|
|
8bd37ea53c | ||
|
|
698ae03505 | ||
|
|
a4c70f7a92 | ||
|
|
39bca053ed | ||
|
|
c2eb8e5330 | ||
|
|
ab47144ae3 | ||
|
|
9b6f9859d1 | ||
|
|
4e6d572de9 | ||
|
|
145186a6b6 | ||
|
|
5714b3c6b7 | ||
|
|
8e0967dd8e | ||
|
|
5a89f3c633 | ||
|
|
ed8fb2d06d | ||
|
|
8c8d9304ac | ||
|
|
c1ccd22531 | ||
|
|
01c964bfe3 | ||
|
|
6c59294e7b | ||
|
|
9d40a12f44 | ||
|
|
c2ddabe683 | ||
|
|
846768efcb | ||
|
|
8dfbb90f3a | ||
|
|
c7c3bd33cf | ||
|
|
1637153566 | ||
|
|
05510f25a5 | ||
|
|
b8ea7282b0 | ||
|
|
b5bb12d0d2 | ||
|
|
dd91553689 | ||
|
|
98651deea4 | ||
|
|
ff0c85615b | ||
|
|
16e2350fef | ||
|
|
52b2e27451 | ||
|
|
48bb8a90d2 | ||
|
|
941684f4d9 | ||
|
|
9631fab7b2 | ||
|
|
93a722f85b | ||
|
|
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 |
73
CHANGELOG.md
Normal file
73
CHANGELOG.md
Normal file
@@ -0,0 +1,73 @@
|
||||
CHANGELOG
|
||||
=========
|
||||
|
||||
To-be-released
|
||||
--------------
|
||||
|
||||
Breaking changes:
|
||||
|
||||
* On-disk backups are now retained for a minimum of 3 days instead of 14. Beyond that the user is responsible for making off-site copies.
|
||||
* IMAP no longer supports the legacy SSLv3 protocol. SSLv3 is now known to be insecure. I don't believe any modern devices will be affected by this. HTTPS and SMTP submission already had SSLv3 disabled.
|
||||
|
||||
Control panel:
|
||||
|
||||
* The control panel has a new page for installing SSL certificates.
|
||||
* The control panel has a new page for hosting static websites.
|
||||
* The control panel now shows mailbox sizes on disk.
|
||||
* It is now possible to create catch-all aliases from the control panel.
|
||||
* Many usability improvements in the control panel.
|
||||
|
||||
DNS:
|
||||
|
||||
* Custom DNS A/AAAA records on subdomains were ignored.
|
||||
* It is now possible to set up a secondary DNS server.
|
||||
* DNS zones were updating even when nothing changed.
|
||||
* Strict SPF and DMARC settings are now set on all subdomains not used for mail.
|
||||
|
||||
Security:
|
||||
|
||||
* DNSSEC is now supported for the .email TLD which required a different key algorithm.
|
||||
* Nginx and Postfix now use 2048 bits of DH parameters instead of 1024.
|
||||
|
||||
Other:
|
||||
|
||||
* Some things were broken if the machine had an IPv6 address.
|
||||
* Other things were broken if the machine was on a non-utf8 locale.
|
||||
* No longer implementing webfinger.
|
||||
* Removes apache before installing nginx, in case it has been installed by distro.
|
||||
|
||||
v0.03 (September 24, 2014)
|
||||
--------------------------
|
||||
|
||||
* Update existing installs of Roundcube.
|
||||
* Disabled catch-alls pending figuring out how to get users to take precedence.
|
||||
* Z-Push was not working because in v0.02 we had accidentally moved to a different version.
|
||||
* Z-Push is now locked to a specific commit so it doesn't change on us accidentally.
|
||||
* The start script is now symlinked to /usr/local/bin/mailinabox.
|
||||
|
||||
v0.02 (September 21, 2014)
|
||||
--------------------------
|
||||
|
||||
* Open the firewall to an alternative SSH port if set.
|
||||
* Fixed missing dependencies.
|
||||
* Set Z-Push to use sync command with ownCloud.
|
||||
* Support more concurrent connections for z-push.
|
||||
* In the status checks, handle wildcard certificates.
|
||||
* Show the status of backups in the control panel.
|
||||
* The control panel can now update a user's password.
|
||||
* Some usability improvements in the control panel.
|
||||
* Warn if a SSL cert is expiring in 30 days.
|
||||
* Use SHA2 to generate CSRs.
|
||||
* Better logic for determining when to take a full backup.
|
||||
* Reduce DNS TTL, not that it seems to really matter.
|
||||
* Add SSHFP DNS records.
|
||||
* Add an API for setting custom DNS records
|
||||
* Update to ownCloud 7.0.2.
|
||||
* Some things were broken if the machine had an IPv6 address.
|
||||
* Use a dialogs library to ask users questions during setup.
|
||||
* Other fixes.
|
||||
|
||||
v0.01 (August 19, 2014)
|
||||
-----------------------
|
||||
|
||||
First release.
|
||||
54
README.md
54
README.md
@@ -1,59 +1,43 @@
|
||||
Mail-in-a-Box
|
||||
=============
|
||||
|
||||
By [@JoshData](https://github.com/JoshData) and contributors.
|
||||
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 ([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), 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/mail-in-a-box/mailinabox
|
||||
cd mailinabox
|
||||
sudo setup/start.sh
|
||||
|
||||
Congratulations! You should now have a working setup. You'll be given the address of the administrative interface for further instructions.
|
||||
|
||||
**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/mail-in-a-box/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
|
||||
--------------------
|
||||
|
||||
This project was inspired in part by the ["NSA-proof your email in 2 hours"](http://sealedabstract.com/code/nsa-proof-your-e-mail-in-2-hours/) blog post by Drew Crawford, [Sovereign](https://github.com/al3x/sovereign) by Alex Payne, and conversations with <a href="http://twitter.com/shevski" target="_blank">@shevski</a>, <a href="https://github.com/konklone" target="_blank">@konklone</a>, and <a href="https://github.com/gregelin" target="_blank">@GregElin</a>.
|
||||
|
||||
Mail-in-a-Box is similar to [iRedMail](http://www.iredmail.org/).
|
||||
Mail-in-a-Box is similar to [iRedMail](http://www.iredmail.org/) and [Modoboa](https://github.com/tonioo/modoboa).
|
||||
|
||||
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.
|
||||
|
||||
1
Vagrantfile
vendored
1
Vagrantfile
vendored
@@ -18,6 +18,7 @@ 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
|
||||
|
||||
@@ -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
|
||||
@@ -25,6 +25,16 @@ SCRIPTNAME=/etc/init.d/$NAME
|
||||
START=yes
|
||||
EXEC_AS_USER=root
|
||||
|
||||
# Ensure Python reads/writes files in UTF-8. If the machine
|
||||
# triggers some other locale in Python, like ASCII encoding,
|
||||
# Python may not be able to read/write files. Here and in
|
||||
# setup/start.sh (where the locale is also installed if not
|
||||
# already present).
|
||||
export LANGUAGE=en_US.UTF-8
|
||||
export LC_ALL=en_US.UTF-8
|
||||
export LANG=en_US.UTF-8
|
||||
export LC_TYPE=en_US.UTF-8
|
||||
|
||||
# Read configuration variable file if it is present
|
||||
[ -r /etc/default/$NAME ] && . /etc/default/$NAME
|
||||
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
# Control Panel
|
||||
# Proxy /admin to our Python based control panel daemon. It is
|
||||
# listening on IPv4 only so use an IP address and not 'localhost'.
|
||||
rewrite ^/admin$ /admin/;
|
||||
location /admin/ {
|
||||
proxy_pass http://127.0.0.1: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/;
|
||||
|
||||
@@ -55,7 +55,7 @@ spdy_headers_comp 0;
|
||||
# openssl dhparam -outform pem -out dhparam2048.pem 2048
|
||||
#
|
||||
# Note: raising the bits to 2048 excludes Java 6 clients. Comment out if a problem.
|
||||
#ssl_dhparam /path/to/dhparam2048.pem;
|
||||
ssl_dhparam STORAGE_ROOT/ssl/dh2048.pem;
|
||||
|
||||
|
||||
# OCSP stapling - means nginx will poll the CA for signed OCSP responses,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
## 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 to update the web configuration.
|
||||
## 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;
|
||||
|
||||
@@ -24,10 +24,14 @@ server {
|
||||
root $ROOT;
|
||||
index index.html index.htm;
|
||||
|
||||
# Control Panel
|
||||
rewrite ^/admin$ /admin/;
|
||||
location /admin/ {
|
||||
proxy_pass http://localhost:10222/;
|
||||
location = /robots.txt {
|
||||
log_not_found off;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location = /favicon.ico {
|
||||
log_not_found off;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# Roundcube Webmail configuration.
|
||||
@@ -51,13 +55,6 @@ server {
|
||||
client_max_body_size 20M;
|
||||
}
|
||||
|
||||
# Webfinger configuration.
|
||||
location = /.well-known/webfinger {
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME /usr/local/bin/mailinabox-webfinger.php;
|
||||
fastcgi_pass php-fpm;
|
||||
}
|
||||
|
||||
# Z-Push (Microsoft Exchange ActiveSync)
|
||||
location /Microsoft-Server-ActiveSync {
|
||||
include /etc/nginx/fastcgi_params;
|
||||
|
||||
@@ -9,10 +9,6 @@ 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);
|
||||
define('CALDAV_SUPPORTS_SYNC', true);
|
||||
|
||||
?>
|
||||
|
||||
@@ -14,13 +14,7 @@ define('CARDDAV_DEFAULT_PATH', '/carddav/addressbooks/%u/contacts/'); /* subdire
|
||||
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);
|
||||
|
||||
define('CARDDAV_SUPPORTS_SYNC', true);
|
||||
|
||||
// If the CardDAV server supports the FN attribute for searches
|
||||
// DAViCal supports it, but SabreDav, Owncloud and SOGo don't
|
||||
|
||||
@@ -2,119 +2,210 @@
|
||||
|
||||
# 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, glob
|
||||
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 except the most recent full backup
|
||||
# destroy backups when the most recent increment in the chain
|
||||
# that depends on it is this many days old.
|
||||
keep_backups_for_days = 3
|
||||
|
||||
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, clip):
|
||||
if ref < date: return clip
|
||||
rd = dateutil.relativedelta.relativedelta(ref, date)
|
||||
if rd.months > 1: return "%d months, %d days" % (rd.months, rd.days)
|
||||
if rd.months == 1: return "%d month, %d days" % (rd.months, rd.days)
|
||||
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)
|
||||
|
||||
# On the first run, always do a full backup. Incremental
|
||||
# will fail.
|
||||
if len(os.listdir(backup_duplicity_dir)) == 0:
|
||||
full_backup = True
|
||||
else:
|
||||
# When the size of incremental backups exceeds the size of existing
|
||||
# full backups, take a new full backup. We want to avoid full backups
|
||||
# because they are costly to synchronize off-site.
|
||||
full_sz = sum(os.path.getsize(f) for f in glob.glob(backup_duplicity_dir + '/*-full.*'))
|
||||
inc_sz = sum(os.path.getsize(f) for f in glob.glob(backup_duplicity_dir + '/*-inc.*'))
|
||||
# (n.b. not counting size of new-signatures files because they are relatively small)
|
||||
if inc_sz > full_sz * 1.5:
|
||||
full_backup = True
|
||||
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, "the future?"),
|
||||
"full": m.group("incbase") is None,
|
||||
"previous": m.group("incbase"),
|
||||
"size": 0,
|
||||
"encsize": 0,
|
||||
}
|
||||
|
||||
# Stop services.
|
||||
shell('check_call', ["/usr/sbin/service", "dovecot", "stop"])
|
||||
shell('check_call', ["/usr/sbin/service", "postfix", "stop"])
|
||||
backups[key]["size"] += os.path.getsize(os.path.join(basedir, fn))
|
||||
|
||||
# Update the backup mirror directory which mirrors the current
|
||||
# STORAGE_ROOT (but excluding the backups themselves!).
|
||||
try:
|
||||
# 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"]), "on next daily backup")
|
||||
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 duplicity's cache directory because it's redundant with our backup directory.
|
||||
shutil.rmtree("/tmp/duplicity-archive-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
|
||||
|
||||
# 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
|
||||
# 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 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"),
|
||||
])
|
||||
# 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
|
||||
|
||||
# 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
|
||||
# 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))
|
||||
|
||||
# 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))
|
||||
# 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)
|
||||
|
||||
# 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)
|
||||
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'] \
|
||||
@@ -108,7 +108,7 @@ def buy_ssl_certificate(api_key, domain, command, env):
|
||||
|
||||
# Check before we overwrite something we shouldn't.
|
||||
if os.path.exists(ssl_certificate):
|
||||
cert_status = check_certificate(None, ssl_certificate, None)
|
||||
cert_status, cert_status_details = check_certificate(None, ssl_certificate, None)
|
||||
if cert_status != "SELF-SIGNED":
|
||||
print("Please back up and delete the file %s so I can save your new certificate." % ssl_certificate)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -7,9 +7,9 @@ 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_archived_mail_users
|
||||
from mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, remove_mail_user
|
||||
from mailconfig import get_mail_user_privileges, add_remove_mail_user_privilege
|
||||
from mailconfig import get_mail_aliases, get_mail_domains, add_mail_alias, remove_mail_alias
|
||||
from mailconfig import get_mail_aliases, get_mail_aliases_ex, get_mail_domains, add_mail_alias, remove_mail_alias
|
||||
|
||||
env = utils.load_environment()
|
||||
|
||||
@@ -71,8 +71,11 @@ def json_response(data):
|
||||
def index():
|
||||
# Render the control panel. This route does not require user authentication
|
||||
# so it must be safe!
|
||||
no_admins_exist = (len(get_admins(env)) == 0)
|
||||
return render_template('index.html',
|
||||
hostname=env['PRIMARY_HOSTNAME'],
|
||||
storage_root=env['STORAGE_ROOT'],
|
||||
no_admins_exist=no_admins_exist,
|
||||
)
|
||||
|
||||
@app.route('/me')
|
||||
@@ -95,19 +98,25 @@ def me():
|
||||
@authorized_personnel_only
|
||||
def mail_users():
|
||||
if request.args.get("format", "") == "json":
|
||||
return json_response(get_mail_users(env, as_json=True) + get_archived_mail_users(env))
|
||||
return json_response(get_mail_users_ex(env, with_archived=True, with_slow_info=True))
|
||||
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', ''), request.form.get('privileges', ''), 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
|
||||
@@ -137,7 +146,7 @@ def mail_user_privs_remove():
|
||||
@authorized_personnel_only
|
||||
def mail_aliases():
|
||||
if request.args.get("format", "") == "json":
|
||||
return json_response(get_mail_aliases(env, as_json=True))
|
||||
return json_response(get_mail_aliases_ex(env))
|
||||
else:
|
||||
return "".join(x+"\t"+y+"\n" for x, y in get_mail_aliases(env))
|
||||
|
||||
@@ -172,14 +181,77 @@ def dns_update():
|
||||
except Exception as e:
|
||||
return (str(e), 500)
|
||||
|
||||
@app.route('/dns/secondary-nameserver')
|
||||
@authorized_personnel_only
|
||||
def dns_get_secondary_nameserver():
|
||||
from dns_update import get_custom_dns_config
|
||||
return json_response({ "hostname": get_custom_dns_config(env).get("_secondary_nameserver") })
|
||||
|
||||
@app.route('/dns/secondary-nameserver', methods=['POST'])
|
||||
@authorized_personnel_only
|
||||
def dns_set_secondary_nameserver():
|
||||
from dns_update import set_secondary_dns
|
||||
try:
|
||||
return set_secondary_dns(request.form.get('hostname'), env)
|
||||
except ValueError as e:
|
||||
return (str(e), 400)
|
||||
|
||||
|
||||
@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:
|
||||
# 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))
|
||||
|
||||
# SSL
|
||||
|
||||
@app.route('/ssl/csr/<domain>', methods=['POST'])
|
||||
@authorized_personnel_only
|
||||
def ssl_get_csr(domain):
|
||||
from web_update import get_domain_ssl_files, create_csr
|
||||
ssl_key, ssl_certificate, csr_path = get_domain_ssl_files(domain, env)
|
||||
return create_csr(domain, ssl_key, env)
|
||||
|
||||
@app.route('/ssl/install', methods=['POST'])
|
||||
@authorized_personnel_only
|
||||
def ssl_install_cert():
|
||||
from web_update import install_cert
|
||||
domain = request.form.get('domain')
|
||||
ssl_cert = request.form.get('cert')
|
||||
ssl_chain = request.form.get('chain')
|
||||
return install_cert(domain, ssl_cert, ssl_chain, env)
|
||||
|
||||
# WEB
|
||||
|
||||
@app.route('/web/domains')
|
||||
@authorized_personnel_only
|
||||
def web_get_domains():
|
||||
from web_update import get_web_domains_info
|
||||
return json_response(get_web_domains_info(env))
|
||||
|
||||
@app.route('/web/update', methods=['POST'])
|
||||
@authorized_personnel_only
|
||||
def web_update():
|
||||
@@ -191,7 +263,7 @@ def web_update():
|
||||
@app.route('/system/status', methods=["POST"])
|
||||
@authorized_personnel_only
|
||||
def system_status():
|
||||
from whats_next import run_checks
|
||||
from status_checks import run_checks
|
||||
class WebOutput:
|
||||
def __init__(self):
|
||||
self.items = []
|
||||
@@ -201,6 +273,8 @@ def system_status():
|
||||
self.items.append({ "type": "ok", "text": message, "extra": [] })
|
||||
def print_error(self, message):
|
||||
self.items.append({ "type": "error", "text": message, "extra": [] })
|
||||
def print_warning(self, message):
|
||||
self.items.append({ "type": "warning", "text": message, "extra": [] })
|
||||
def print_line(self, message, monospace=False):
|
||||
self.items[-1]["extra"].append({ "text": message, "monospace": monospace })
|
||||
output = WebOutput()
|
||||
@@ -210,14 +284,11 @@ def system_status():
|
||||
@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
|
||||
@@ -227,6 +298,12 @@ def do_updates():
|
||||
"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__':
|
||||
@@ -244,5 +321,6 @@ if __name__ == '__main__':
|
||||
# debug console and enter that as the username
|
||||
app.logger.info('API key: ' + auth_service.key)
|
||||
|
||||
# Start the application server. Listens on 127.0.0.1 (IPv4 only).
|
||||
app.run(port=10222)
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
# 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
|
||||
import dns.resolver
|
||||
|
||||
from mailconfig import get_mail_domains
|
||||
from utils import shell, load_env_vars_from_file, safe_domain_name, sort_domains
|
||||
@@ -54,6 +56,11 @@ def get_custom_dns_config(env):
|
||||
except:
|
||||
return { }
|
||||
|
||||
def write_custom_dns_config(config, env):
|
||||
config_yaml = rtyaml.dump(config)
|
||||
with open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml'), "w") as f:
|
||||
f.write(config_yaml)
|
||||
|
||||
def do_dns_update(env, force=False):
|
||||
# What domains (and their zone filenames) should we build?
|
||||
domains = get_dns_domains(env)
|
||||
@@ -104,7 +111,7 @@ def do_dns_update(env, force=False):
|
||||
zonefiles[i][1] += ".signed"
|
||||
|
||||
# Write the main nsd.conf file.
|
||||
if write_nsd_conf(zonefiles, env):
|
||||
if write_nsd_conf(zonefiles, additional_records, 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:
|
||||
@@ -133,12 +140,22 @@ def do_dns_update(env, force=False):
|
||||
def build_zone(domain, all_domains, additional_records, env, is_zone=True):
|
||||
records = []
|
||||
|
||||
# For top-level zones, define ourselves as the authoritative name server.
|
||||
# For top-level zones, define the authoritative name servers.
|
||||
#
|
||||
# Normally we are our own nameservers. Some TLDs require two distinct IP addresses,
|
||||
# so we allow the user to override the second nameserver definition so that
|
||||
# secondary DNS can be set up elsewhere.
|
||||
#
|
||||
# 'False' in the tuple indicates these records would not be used if the zone
|
||||
# is managed outside of the box.
|
||||
if is_zone:
|
||||
# Obligatory definition of ns1.PRIMARY_HOSTNAME.
|
||||
records.append((None, "NS", "ns1.%s." % env["PRIMARY_HOSTNAME"], False))
|
||||
records.append((None, "NS", "ns2.%s." % env["PRIMARY_HOSTNAME"], False))
|
||||
|
||||
# Define ns2.PRIMARY_HOSTNAME or whatever the user overrides.
|
||||
secondary_ns = additional_records.get("_secondary_nameserver", "ns2." + env["PRIMARY_HOSTNAME"])
|
||||
records.append((None, "NS", secondary_ns+'.', False))
|
||||
|
||||
|
||||
# In PRIMARY_HOSTNAME...
|
||||
if domain == env["PRIMARY_HOSTNAME"]:
|
||||
@@ -159,19 +176,23 @@ 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 (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"', "Recommended. 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.
|
||||
subdomains = [d for d in all_domains if d.endswith("." + domain)]
|
||||
for subdomain in subdomains:
|
||||
subdomain_qname = subdomain[0:-len("." + domain)]
|
||||
subzone = build_zone(subdomain, [], {}, env, is_zone=False)
|
||||
subzone = build_zone(subdomain, [], additional_records, env, is_zone=False)
|
||||
for child_qname, child_rtype, child_value, child_explanation in subzone:
|
||||
if child_qname == None:
|
||||
child_qname = subdomain_qname
|
||||
@@ -179,9 +200,9 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
|
||||
child_qname += "." + subdomain_qname
|
||||
records.append((child_qname, child_rtype, child_value, child_explanation))
|
||||
|
||||
def has_rec(qname, rtype):
|
||||
def has_rec(qname, rtype, prefix=None):
|
||||
for rec in records:
|
||||
if rec[0] == qname and rec[1] == rtype:
|
||||
if rec[0] == qname and rec[1] == rtype and (prefix is None or rec[2].startswith(prefix)):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -190,9 +211,9 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
|
||||
if has_rec(qname, rtype): continue
|
||||
records.append((qname, rtype, value, "(Set by user.)"))
|
||||
|
||||
# Add defaults if not overridden by the user's custom settings.
|
||||
# Add defaults if not overridden by the user's custom settings (and not otherwise configured).
|
||||
defaults = [
|
||||
(None, "A", env["PUBLIC_IP"], "Optional. Sets the IP address that %s resolves to, e.g. for web hosting. (It is not necessary for receiving mail on this domain.)" % 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. (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),
|
||||
@@ -203,16 +224,25 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
|
||||
if not has_rec(qname, rtype):
|
||||
records.append((qname, rtype, value, explanation))
|
||||
|
||||
# If OpenDKIM is in use..
|
||||
# Append the DKIM TXT record to the zone as generated by OpenDKIM.
|
||||
opendkim_record_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.txt')
|
||||
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. Provides a way for recipients to verify that this machine sent @%s mail." % domain))
|
||||
with open(opendkim_record_file) as orf:
|
||||
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))
|
||||
# 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))
|
||||
|
||||
# For any subdomain with an A record but no SPF or DMARC record, add strict policy records.
|
||||
all_resolvable_qnames = set(r[0] for r in records if r[1] in ("A", "AAAA"))
|
||||
for qname in all_resolvable_qnames:
|
||||
if not has_rec(qname, "TXT", prefix="v=spf1 "):
|
||||
records.append((qname, "TXT", 'v=spf1 a mx -all', "Prevents unauthorized use of this domain name for outbound mail by requiring outbound mail to originate from the indicated host(s)."))
|
||||
dmarc_qname = "_dmarc" + ("" if qname is None else "." + qname)
|
||||
if not has_rec(dmarc_qname, "TXT", prefix="v=DMARC1; "):
|
||||
records.append((dmarc_qname, "TXT", 'v=DMARC1; p=reject', "Prevents unauthorized use of this domain name for outbound mail by requiring a valid DKIM signature."))
|
||||
|
||||
|
||||
# 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 ""))
|
||||
@@ -238,7 +268,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):
|
||||
@@ -256,11 +286,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)
|
||||
|
||||
########################################################################
|
||||
@@ -288,26 +313,67 @@ 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. The order
|
||||
# of the output is arbitrary, so sort it to prevent spurrious updates
|
||||
# to the zone file (that trigger bumping the serial number).
|
||||
keys = shell("check_output", ["ssh-keyscan", "localhost"])
|
||||
for key in sorted(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, force):
|
||||
# We set the administrative email address for every domain to domain_contact@[domain.com].
|
||||
# You should probably create an alias to your email address.
|
||||
|
||||
# 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)
|
||||
)
|
||||
"""
|
||||
|
||||
@@ -319,6 +385,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
|
||||
@@ -385,7 +455,7 @@ $TTL 86400 ; default time to live
|
||||
|
||||
########################################################################
|
||||
|
||||
def write_nsd_conf(zonefiles, env):
|
||||
def write_nsd_conf(zonefiles, additional_records, env):
|
||||
# Basic header.
|
||||
nsdconf = """
|
||||
server:
|
||||
@@ -414,6 +484,19 @@ zone:
|
||||
zonefile: %s
|
||||
""" % (domain, zonefile)
|
||||
|
||||
# If a custom secondary nameserver has been set, allow zone transfers
|
||||
# and notifies to that nameserver.
|
||||
if additional_records.get("_secondary_nameserver"):
|
||||
# Get the IP address of the nameserver by resolving it.
|
||||
hostname = additional_records.get("_secondary_nameserver")
|
||||
resolver = dns.resolver.get_default_resolver()
|
||||
response = dns.resolver.query(hostname, "A")
|
||||
ipaddr = str(response[0])
|
||||
nsdconf += """\tnotify: %s NOKEY
|
||||
provide-xfr: %s NOKEY
|
||||
""" % (ipaddr, ipaddr)
|
||||
|
||||
|
||||
# Check if the nsd.conf is changing. If it isn't changing,
|
||||
# return False to flag that no change was made.
|
||||
with open("/etc/nsd/nsd.conf") as f:
|
||||
@@ -427,8 +510,18 @@ zone:
|
||||
|
||||
########################################################################
|
||||
|
||||
def dnssec_choose_algo(domain, env):
|
||||
if domain.endswith(".email"):
|
||||
# At least at GoDaddy, this is the only algorithm supported.
|
||||
return "RSASHA256"
|
||||
|
||||
# For any domain we were able to sign before, don't change the algorithm
|
||||
# on existing users. We'll probably want to migrate to SHA256 later.
|
||||
return "RSASHA1-NSEC3-SHA1"
|
||||
|
||||
def sign_zone(domain, zonefile, env):
|
||||
dnssec_keys = load_env_vars_from_file(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/keys.conf'))
|
||||
algo = dnssec_choose_algo(domain, env)
|
||||
dnssec_keys = load_env_vars_from_file(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/%s.conf' % algo))
|
||||
|
||||
# In order to use the same keys for all domains, we have to generate
|
||||
# a new .key file with a DNSSEC record for the specific domain. We
|
||||
@@ -496,7 +589,7 @@ def sign_zone(domain, zonefile, env):
|
||||
# Remove our temporary file.
|
||||
for fn in files_to_kill:
|
||||
os.unlink(fn)
|
||||
|
||||
|
||||
########################################################################
|
||||
|
||||
def write_opendkim_tables(zonefiles, env):
|
||||
@@ -551,6 +644,113 @@ def write_opendkim_tables(zonefiles, env):
|
||||
|
||||
########################################################################
|
||||
|
||||
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
|
||||
write_custom_dns_config(config, env)
|
||||
|
||||
return True
|
||||
|
||||
########################################################################
|
||||
|
||||
def set_secondary_dns(hostname, env):
|
||||
config = get_custom_dns_config(env)
|
||||
|
||||
if hostname in (None, ""):
|
||||
# Clear.
|
||||
if "_secondary_nameserver" in config:
|
||||
del config["_secondary_nameserver"]
|
||||
else:
|
||||
# Validate.
|
||||
hostname = hostname.strip().lower()
|
||||
resolver = dns.resolver.get_default_resolver()
|
||||
try:
|
||||
response = dns.resolver.query(hostname, "A")
|
||||
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
||||
raise ValueError("Could not resolve the IP address of %s." % hostname)
|
||||
|
||||
# Set.
|
||||
config["_secondary_nameserver"] = hostname
|
||||
|
||||
# Save and apply.
|
||||
write_custom_dns_config(config, env)
|
||||
return do_dns_update(env)
|
||||
|
||||
|
||||
########################################################################
|
||||
|
||||
def justtestingdotemail(domain, records):
|
||||
# If the domain is a subdomain of justtesting.email, which we own,
|
||||
# automatically populate the zone where it is set up on dns4e.com.
|
||||
@@ -596,8 +796,9 @@ 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]
|
||||
|
||||
105
management/mail_log.py
Executable file
105
management/mail_log.py
Executable file
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
import re, os.path
|
||||
import dateutil.parser
|
||||
|
||||
import mailconfig
|
||||
import utils
|
||||
|
||||
def scan_mail_log(logger, env):
|
||||
collector = {
|
||||
"other-services": set(),
|
||||
"imap-logins": { },
|
||||
"postgrey": { },
|
||||
"rejected-mail": { },
|
||||
}
|
||||
|
||||
collector["real_mail_addresses"] = set(mailconfig.get_mail_users(env)) | set(alias[0] for alias in mailconfig.get_mail_aliases(env))
|
||||
|
||||
for fn in ('/var/log/mail.log.1', '/var/log/mail.log'):
|
||||
if not os.path.exists(fn): continue
|
||||
with open(fn) as log:
|
||||
for line in log:
|
||||
scan_mail_log_line(line.strip(), collector)
|
||||
|
||||
if collector["imap-logins"]:
|
||||
logger.add_heading("Recent IMAP Logins")
|
||||
logger.print_block("The most recent login from each remote IP adddress is show.")
|
||||
for k in utils.sort_email_addresses(collector["imap-logins"], env):
|
||||
for ip, date in sorted(collector["imap-logins"][k].items(), key = lambda kv : kv[1]):
|
||||
logger.print_line(k + "\t" + str(date) + "\t" + ip)
|
||||
|
||||
if collector["postgrey"]:
|
||||
logger.add_heading("Greylisted Mail")
|
||||
logger.print_block("The following mail was greylisted, meaning the emails were temporarily rejected. Legitimate senders will try again within ten minutes.")
|
||||
logger.print_line("recipient" + "\t" + "received" + "\t" + "sender" + "\t" + "delivered")
|
||||
for recipient in utils.sort_email_addresses(collector["postgrey"], env):
|
||||
for (client_address, sender), (first_date, delivered_date) in sorted(collector["postgrey"][recipient].items(), key = lambda kv : kv[1][0]):
|
||||
logger.print_line(recipient + "\t" + str(first_date) + "\t" + sender + "\t" + (("delivered " + str(delivered_date)) if delivered_date else "no retry yet"))
|
||||
|
||||
if collector["rejected-mail"]:
|
||||
logger.add_heading("Rejected Mail")
|
||||
logger.print_block("The following incoming mail was rejected.")
|
||||
for k in utils.sort_email_addresses(collector["rejected-mail"], env):
|
||||
for date, sender, message in collector["rejected-mail"][k]:
|
||||
logger.print_line(k + "\t" + str(date) + "\t" + sender + "\t" + message)
|
||||
|
||||
if len(collector["other-services"]) > 0:
|
||||
logger.add_heading("Other")
|
||||
logger.print_block("Unrecognized services in the log: " + ", ".join(collector["other-services"]))
|
||||
|
||||
def scan_mail_log_line(line, collector):
|
||||
m = re.match(r"(\S+ \d+ \d+:\d+:\d+) (\S+) (\S+?)(\[\d+\])?: (.*)", line)
|
||||
if not m: return
|
||||
|
||||
date, system, service, pid, log = m.groups()
|
||||
date = dateutil.parser.parse(date)
|
||||
|
||||
if service == "dovecot":
|
||||
scan_dovecot_line(date, log, collector)
|
||||
|
||||
elif service == "postgrey":
|
||||
scan_postgrey_line(date, log, collector)
|
||||
|
||||
elif service == "postfix/smtpd":
|
||||
scan_postfix_smtpd_line(date, log, collector)
|
||||
|
||||
elif service in ("postfix/qmgr", "postfix/pickup", "postfix/cleanup",
|
||||
"postfix/scache", "spampd", "postfix/anvil", "postfix/master",
|
||||
"opendkim", "postfix/lmtp", "postfix/tlsmgr"):
|
||||
# nothing to look at
|
||||
pass
|
||||
|
||||
else:
|
||||
collector["other-services"].add(service)
|
||||
|
||||
def scan_dovecot_line(date, log, collector):
|
||||
m = re.match("imap-login: Login: user=<(.*?)>, method=PLAIN, rip=(.*?),", log)
|
||||
if m:
|
||||
login, ip = m.group(1), m.group(2)
|
||||
if ip != "127.0.0.1": # local login from webmail/zpush
|
||||
collector["imap-logins"].setdefault(login, {})[ip] = date
|
||||
|
||||
def scan_postgrey_line(date, log, collector):
|
||||
m = re.match("action=(greylist|pass), reason=(.*?), (?:delay=\d+, )?client_name=(.*), client_address=(.*), sender=(.*), recipient=(.*)", log)
|
||||
if m:
|
||||
action, reason, client_name, client_address, sender, recipient = m.groups()
|
||||
key = (client_address, sender)
|
||||
if action == "greylist" and reason == "new":
|
||||
collector["postgrey"].setdefault(recipient, {})[key] = (date, None)
|
||||
elif action == "pass" and reason == "triplet found" and key in collector["postgrey"].get(recipient, {}):
|
||||
collector["postgrey"][recipient][key] = (collector["postgrey"][recipient][key][0], date)
|
||||
|
||||
def scan_postfix_smtpd_line(date, log, collector):
|
||||
m = re.match("NOQUEUE: reject: RCPT from .*?: (.*?); from=<(.*?)> to=<(.*?)>", log)
|
||||
if m:
|
||||
message, sender, recipient = m.groups()
|
||||
if recipient in collector["real_mail_addresses"]:
|
||||
# only log mail to real recipients
|
||||
collector["rejected-mail"].setdefault(recipient, []).append( (date, sender, message) )
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from status_checks import ConsoleOutput
|
||||
env = utils.load_environment()
|
||||
scan_mail_log(ConsoleOutput(), env)
|
||||
@@ -46,69 +46,164 @@ def open_database(env, with_connection=False):
|
||||
else:
|
||||
return conn, conn.cursor()
|
||||
|
||||
def get_mail_users(env, as_json=False):
|
||||
def get_mail_users(env):
|
||||
# Returns a flat, sorted list of all user accounts.
|
||||
c = open_database(env)
|
||||
c.execute('SELECT email FROM users')
|
||||
users = [ row[0] for row in c.fetchall() ]
|
||||
return utils.sort_email_addresses(users, env)
|
||||
|
||||
def get_mail_users_ex(env, with_archived=False, with_slow_info=False):
|
||||
# Returns a complex data structure of all user accounts, optionally
|
||||
# including archived (status="inactive") accounts.
|
||||
#
|
||||
# [
|
||||
# {
|
||||
# domain: "domain.tld",
|
||||
# users: [
|
||||
# {
|
||||
# email: "name@domain.tld",
|
||||
# privileges: [ "priv1", "priv2", ... ],
|
||||
# status: "active",
|
||||
# aliases: [
|
||||
# ("alias@domain.tld", ["indirect.alias@domain.tld", ...]),
|
||||
# ...
|
||||
# ]
|
||||
# },
|
||||
# ...
|
||||
# ]
|
||||
# },
|
||||
# ...
|
||||
# ]
|
||||
|
||||
# Pre-load all aliases.
|
||||
aliases = get_mail_alias_map(env)
|
||||
|
||||
# Get users and their privileges.
|
||||
users = []
|
||||
active_accounts = set()
|
||||
c = open_database(env)
|
||||
c.execute('SELECT email, privileges FROM users')
|
||||
for email, privileges in c.fetchall():
|
||||
active_accounts.add(email)
|
||||
|
||||
# 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) ]
|
||||
user = {
|
||||
"email": email,
|
||||
"privileges": parse_privs(privileges),
|
||||
"status": "active",
|
||||
}
|
||||
users.append(user)
|
||||
|
||||
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
|
||||
]
|
||||
if with_slow_info:
|
||||
user["aliases"] = [
|
||||
(alias, sorted(evaluate_mail_alias_map(alias, aliases, env)))
|
||||
for alias in aliases.get(email.lower(), [])
|
||||
]
|
||||
user["mailbox_size"] = utils.du(os.path.join(env['STORAGE_ROOT'], 'mail/mailboxes', *reversed(email.split("@"))))
|
||||
|
||||
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
|
||||
# Add in archived accounts.
|
||||
if with_archived:
|
||||
root = os.path.join(env['STORAGE_ROOT'], 'mail/mailboxes')
|
||||
for domain in os.listdir(root):
|
||||
for user in os.listdir(os.path.join(root, domain)):
|
||||
email = user + "@" + domain
|
||||
mbox = os.path.join(root, domain, user)
|
||||
if email in active_accounts: continue
|
||||
user = {
|
||||
"email": email,
|
||||
"privileges": "",
|
||||
"status": "inactive",
|
||||
"mailbox": mbox,
|
||||
}
|
||||
users.append(user)
|
||||
if with_slow_info:
|
||||
user["mailbox_size"] = utils.du(mbox)
|
||||
|
||||
def get_mail_aliases(env, as_json=False):
|
||||
# Group by domain.
|
||||
domains = { }
|
||||
for user in users:
|
||||
domain = get_domain(user["email"])
|
||||
if domain not in domains:
|
||||
domains[domain] = {
|
||||
"domain": domain,
|
||||
"users": []
|
||||
}
|
||||
domains[domain]["users"].append(user)
|
||||
|
||||
# Sort domains.
|
||||
domains = [domains[domain] for domain in utils.sort_domains(domains.keys(), env)]
|
||||
|
||||
# Sort users within each domain first by status then lexicographically by email address.
|
||||
for domain in domains:
|
||||
domain["users"].sort(key = lambda user : (user["status"] != "active", user["email"]))
|
||||
|
||||
return domains
|
||||
|
||||
def get_admins(env):
|
||||
# Returns a set of users with admin privileges.
|
||||
users = set()
|
||||
for domain in get_mail_users_ex(env):
|
||||
for user in domain["users"]:
|
||||
if "admin" in user["privileges"]:
|
||||
users.add(user["email"])
|
||||
return users
|
||||
|
||||
def get_mail_aliases(env):
|
||||
# Returns a sorted list of tuples of (alias, forward-to string).
|
||||
c = open_database(env)
|
||||
c.execute('SELECT source, destination FROM aliases')
|
||||
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
|
||||
]
|
||||
aliases = [ (source, aliases[source]) for source in utils.sort_email_addresses(aliases.keys(), env) ]
|
||||
return aliases
|
||||
|
||||
def get_mail_aliases_ex(env):
|
||||
# Returns a complex data structure of all mail aliases, similar
|
||||
# to get_mail_users_ex.
|
||||
#
|
||||
# [
|
||||
# {
|
||||
# domain: "domain.tld",
|
||||
# alias: [
|
||||
# {
|
||||
# source: "name@domain.tld",
|
||||
# destination: ["target1@domain.com", "target2@domain.com", ...],
|
||||
# required: True|False
|
||||
# },
|
||||
# ...
|
||||
# ]
|
||||
# },
|
||||
# ...
|
||||
# ]
|
||||
|
||||
required_aliases = get_required_aliases(env)
|
||||
domains = {}
|
||||
for source, destination in get_mail_aliases(env):
|
||||
# get alias info
|
||||
domain = get_domain(source)
|
||||
required = ((source in required_aliases) or (source == get_system_administrator(env)))
|
||||
|
||||
# add to list
|
||||
if not domain in domains:
|
||||
domains[domain] = {
|
||||
"domain": domain,
|
||||
"aliases": [],
|
||||
}
|
||||
domains[domain]["aliases"].append({
|
||||
"source": source,
|
||||
"destination": [d.strip() for d in destination.split(",")],
|
||||
"required": required,
|
||||
})
|
||||
|
||||
# Sort domains.
|
||||
domains = [domains[domain] for domain in utils.sort_domains(domains.keys(), env)]
|
||||
|
||||
# Sort aliases within each domain first by required-ness then lexicographically by source address.
|
||||
for domain in domains:
|
||||
domain["aliases"].sort(key = lambda alias : (alias["required"], alias["source"]))
|
||||
return domains
|
||||
|
||||
def get_mail_alias_map(env):
|
||||
aliases = { }
|
||||
for alias, targets in get_mail_aliases(env):
|
||||
@@ -124,9 +219,10 @@ def evaluate_mail_alias_map(email, aliases, env):
|
||||
ret |= evaluate_mail_alias_map(alias, aliases, env)
|
||||
return ret
|
||||
|
||||
def get_domain(emailaddr):
|
||||
return emailaddr.split('@', 1)[1]
|
||||
|
||||
def get_mail_domains(env, filter_aliases=lambda alias : True):
|
||||
def get_domain(emailaddr):
|
||||
return emailaddr.split('@', 1)[1]
|
||||
return set(
|
||||
[get_domain(addr) for addr in get_mail_users(env)]
|
||||
+ [get_domain(source) for source, target in get_mail_aliases(env) if filter_aliases((source, target)) ]
|
||||
@@ -139,13 +235,7 @@ def add_mail_user(email, pw, privs, env):
|
||||
if not validate_email(email, mode='user'):
|
||||
return ("Invalid email address.", 400)
|
||||
|
||||
# validate password
|
||||
if pw.strip() == "":
|
||||
return ("No password provided.", 400)
|
||||
if re.search(r"[\s]", pw):
|
||||
return ("Passwords cannot contain spaces.", 400)
|
||||
if len(pw) < 4:
|
||||
return ("Passwords must be at least four characters.", 400)
|
||||
validate_password(pw)
|
||||
|
||||
# validate privileges
|
||||
if privs is None or privs.strip() == "":
|
||||
@@ -193,6 +283,8 @@ def add_mail_user(email, pw, privs, env):
|
||||
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()
|
||||
|
||||
@@ -312,7 +404,7 @@ def get_required_aliases(env):
|
||||
# These are the aliases that must exist.
|
||||
aliases = set()
|
||||
|
||||
# The hostmaster aliase is exposed in the DNS SOA for each zone.
|
||||
# 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
|
||||
@@ -344,10 +436,15 @@ def kick(env, mail_result=None):
|
||||
|
||||
# Ensure every required alias exists.
|
||||
|
||||
existing_users = get_mail_users(env)
|
||||
existing_aliases = get_mail_aliases(env)
|
||||
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:
|
||||
@@ -381,6 +478,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":
|
||||
|
||||
@@ -6,11 +6,12 @@
|
||||
|
||||
__ALL__ = ['check_certificate']
|
||||
|
||||
import os, os.path, re, subprocess
|
||||
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 dns_update import get_dns_zones, build_tlsa_record, get_custom_dns_config
|
||||
from web_update import get_web_domains, get_domain_ssl_files
|
||||
from mailconfig import get_mail_domains, get_mail_aliases
|
||||
|
||||
@@ -36,10 +37,34 @@ def run_system_checks(env):
|
||||
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)
|
||||
|
||||
# Check free disk space.
|
||||
st = os.statvfs(env['STORAGE_ROOT'])
|
||||
bytes_total = st.f_blocks * st.f_frsize
|
||||
bytes_free = st.f_bavail * st.f_frsize
|
||||
disk_msg = "The disk has %s GB space remaining." % str(round(bytes_free/1024.0/1024.0/1024.0*10.0)/10.0)
|
||||
if bytes_free > .3 * bytes_total:
|
||||
env['out'].print_ok(disk_msg)
|
||||
elif bytes_free > .15 * bytes_total:
|
||||
env['out'].print_warning(disk_msg)
|
||||
else:
|
||||
env['out'].print_error(disk_msg)
|
||||
|
||||
|
||||
def run_network_checks(env):
|
||||
# Also see setup/network-checks.sh.
|
||||
|
||||
@@ -57,17 +82,18 @@ def run_network_checks(env):
|
||||
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 Spamhouse Block List.
|
||||
# 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('.')))
|
||||
if not query_dns(rev_ip4+'.zen.spamhaus.org', 'A', nxdomain=None):
|
||||
zen = query_dns(rev_ip4+'.zen.spamhaus.org', 'A', nxdomain=None)
|
||||
if zen is None:
|
||||
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,
|
||||
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'], env['PUBLIC_IP']))
|
||||
% (env['PUBLIC_IP'], zen, env['PUBLIC_IP']))
|
||||
|
||||
def run_domain_checks(env):
|
||||
# Get the list of domains we handle mail for.
|
||||
@@ -85,7 +111,7 @@ def run_domain_checks(env):
|
||||
env["out"].add_heading(domain)
|
||||
|
||||
if domain == env["PRIMARY_HOSTNAME"]:
|
||||
check_primary_hostname_dns(domain, env)
|
||||
check_primary_hostname_dns(domain, env, dns_domains, dns_zonefiles)
|
||||
|
||||
if domain in dns_domains:
|
||||
check_dns_zone(domain, env, dns_zonefiles)
|
||||
@@ -96,9 +122,21 @@ def run_domain_checks(env):
|
||||
if domain in web_domains:
|
||||
check_web_domain(domain, env)
|
||||
|
||||
def check_primary_hostname_dns(domain, env):
|
||||
if domain in dns_domains:
|
||||
check_dns_zone_suggestions(domain, env, dns_zonefiles)
|
||||
|
||||
def check_primary_hostname_dns(domain, env, dns_domains, dns_zonefiles):
|
||||
# If a DS record is set on the zone containing this domain, check DNSSEC now.
|
||||
for zone in dns_domains:
|
||||
if zone == domain or domain.endswith("." + zone):
|
||||
if query_dns(zone, "DS", nxdomain=None) is not None:
|
||||
check_dnssec(zone, env, dns_zonefiles, is_checking_primary=True)
|
||||
|
||||
# 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.
|
||||
# comes from the TLD since the information is set at the registrar as glue records.
|
||||
# We're probably not actually checking that here but instead checking that we, as
|
||||
# the nameserver, are reporting the right info --- but if the glue is incorrect this
|
||||
# will probably fail.
|
||||
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']))
|
||||
@@ -153,17 +191,38 @@ def check_alias_exists(alias, env):
|
||||
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):
|
||||
# If a DS record is set at the registrar, check DNSSEC first because it will affect the NS query.
|
||||
# If it is not set, we suggest it last.
|
||||
if query_dns(domain, "DS", nxdomain=None) is not None:
|
||||
check_dnssec(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.
|
||||
# at the domain name's registrar pointing to this box. The secondary DNS
|
||||
# server may be customized. Unfortunately this may not check the domain's
|
||||
# whois information -- we may be getting the NS records from us rather than
|
||||
# the TLD, and so we're not actually checking the TLD. For that we'd need
|
||||
# to do a DNS trace.
|
||||
custom_dns = get_custom_dns_config(env)
|
||||
existing_ns = query_dns(domain, "NS")
|
||||
correct_ns = "ns1.BOX; ns2.BOX".replace("BOX", env['PRIMARY_HOSTNAME'])
|
||||
correct_ns = "; ".join([
|
||||
"ns1." + env['PRIMARY_HOSTNAME'],
|
||||
custom_dns.get("_secondary_nameserver", "ns2." + 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
|
||||
env['out'].print_error("""The nameservers set on this domain are incorrect. They are currently %s. Use your domain name registrar's
|
||||
control panel to set the nameservers to %s."""
|
||||
% (existing_ns, correct_ns) )
|
||||
|
||||
def check_dns_zone_suggestions(domain, env, dns_zonefiles):
|
||||
# Since DNSSEC is optional, if a DS record is NOT set at the registrar suggest it.
|
||||
# (If it was set, we did the check earlier.)
|
||||
if query_dns(domain, "DS", nxdomain=None) is None:
|
||||
check_dnssec(domain, env, dns_zonefiles)
|
||||
|
||||
|
||||
def check_dnssec(domain, env, dns_zonefiles, is_checking_primary=False):
|
||||
# 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.
|
||||
@@ -175,29 +234,39 @@ def check_dns_zone(domain, env, dns_zonefiles):
|
||||
|
||||
# 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'))
|
||||
alg_name_map = { '7': 'RSASHA1-NSEC3-SHA1', '8': 'RSASHA256' }
|
||||
dnssec_keys = load_env_vars_from_file(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/%s.conf' % alg_name_map[ds_alg]))
|
||||
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.")
|
||||
if ds_looks_valid and ds[0] == ds_keytag and ds[1] == ds_alg and ds[3] == digests.get(ds[2]):
|
||||
if is_checking_primary: return
|
||||
env['out'].print_ok("DNSSEC '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.
|
||||
if is_checking_primary: return
|
||||
env['out'].print_error("""This domain's DNSSEC 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
|
||||
if is_checking_primary:
|
||||
env['out'].print_error("""The DNSSEC 'DS' record for %s is incorrect. See further details below.""" % domain)
|
||||
return
|
||||
env['out'].print_error("""This domain's DNSSEC DS record is incorrect. The chain of trust is broken between the public DNS system
|
||||
and this machine's DNS server. It may take several hours for public DNS to update after a change. If you did not recently
|
||||
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]))
|
||||
env['out'].print_line(
|
||||
("Algorithm: %s / %s" % (ds_alg, alg_name_map[ds_alg]))
|
||||
+ ("" if not ds_looks_valid or ds[1] == ds_alg 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]))
|
||||
@@ -248,11 +317,13 @@ def check_mail_domain(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.
|
||||
if not query_dns(domain+'.dbl.spamhaus.org', "A", nxdomain=None):
|
||||
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, which may prevent recipients from receiving your mail.
|
||||
See http://www.spamhaus.org/dbl/ and http://www.spamhaus.org/query/domain/%s.""" % domain)
|
||||
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
|
||||
@@ -302,9 +373,15 @@ def check_ssl_cert(domain, env):
|
||||
|
||||
# Check that the certificate is good.
|
||||
|
||||
cert_status = check_certificate(domain, ssl_certificate, ssl_key)
|
||||
cert_status, cert_status_details = check_certificate(domain, ssl_certificate, ssl_key)
|
||||
|
||||
if cert_status == "OK":
|
||||
# The certificate is ok. The details has expiry info.
|
||||
env['out'].print_ok("SSL certificate is signed & valid. " + cert_status_details)
|
||||
|
||||
elif cert_status == "SELF-SIGNED":
|
||||
# Offer instructions for purchasing a signed certificate.
|
||||
|
||||
if cert_status == "SELF-SIGNED":
|
||||
fingerprint = shell('check_output', [
|
||||
"openssl",
|
||||
"x509",
|
||||
@@ -317,46 +394,46 @@ def check_ssl_cert(domain, env):
|
||||
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:""")
|
||||
static site hosting). Use the SSL Certificates page in this control panel to install a signed SSL certificate.
|
||||
You may choose to leave the self-signed certificate in place and 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
|
||||
env['out'].print_warning("""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.")
|
||||
safe to leave the self-signed certificate in place. Use the SSL Certificates page in this control panel to
|
||||
install a signed SSL certificate.""")
|
||||
|
||||
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("")
|
||||
env['out'].print_error("The SSL certificate has a problem: " + cert_status)
|
||||
if cert_status_details:
|
||||
env['out'].print_line("")
|
||||
env['out'].print_line(cert_status_details)
|
||||
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.
|
||||
cert_dump = shell('check_output', [
|
||||
# Subject Alternative Names. A wildcard might also appear as the CN
|
||||
# or in the SAN list, so check for that tool.
|
||||
retcode, cert_dump = shell('check_output', [
|
||||
"openssl", "x509",
|
||||
"-in", ssl_certificate,
|
||||
"-noout", "-text", "-nameopt", "rfc2253",
|
||||
])
|
||||
], trap=True)
|
||||
|
||||
# If the certificate is catastrophically bad, catch that now and report it.
|
||||
# More information was probably written to stderr (which we aren't capturing),
|
||||
# but it is probably not helpful to the user anyway.
|
||||
if retcode != 0:
|
||||
return ("The SSL certificate appears to be corrupted or not a PEM-formatted SSL certificate file. (%s)" % ssl_certificate, None)
|
||||
|
||||
cert_dump = cert_dump.split("\n")
|
||||
certificate_names = set()
|
||||
cert_expiration_date = None
|
||||
while len(cert_dump) > 0:
|
||||
line = cert_dump.pop(0)
|
||||
|
||||
@@ -378,9 +455,14 @@ def check_certificate(domain, ssl_certificate, ssl_private_key):
|
||||
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))
|
||||
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 ("The certificate is for the wrong domain name. It is for %s."
|
||||
% ", ".join(sorted(certificate_names)), None)
|
||||
|
||||
# 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
|
||||
@@ -396,7 +478,7 @@ def check_certificate(domain, ssl_certificate, ssl_private_key):
|
||||
"-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)
|
||||
return ("The certificate installed at %s does not correspond to the private key at %s." % (ssl_certificate, ssl_private_key), None)
|
||||
|
||||
# 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
|
||||
@@ -410,7 +492,7 @@ def check_certificate(domain, ssl_certificate, ssl_private_key):
|
||||
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."
|
||||
return ("The certificate file is an invalid PEM certificate.", None)
|
||||
mycert, chaincerts = m.groups()
|
||||
|
||||
# This command returns a non-zero exit status in most cases, so trap errors.
|
||||
@@ -426,12 +508,58 @@ def check_certificate(domain, ssl_certificate, ssl_private_key):
|
||||
|
||||
if "self signed" in verifyoutput:
|
||||
# Certificate is self-signed.
|
||||
return "SELF-SIGNED"
|
||||
elif retcode == 0:
|
||||
# Certificate is OK.
|
||||
return "OK"
|
||||
return ("SELF-SIGNED", None)
|
||||
elif retcode != 0:
|
||||
# There is some unknown problem. Return the `openssl verify` raw output.
|
||||
return ("There is a problem with the SSL certificate.", verifyoutput.strip())
|
||||
else:
|
||||
return verifyoutput.strip()
|
||||
# `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
|
||||
expiry_info = "The certificate expires in %d days on %s." % (ndays, cert_expiration_date.strftime("%x"))
|
||||
if ndays <= 31:
|
||||
return ("The certificate is expiring soon: " + expiry_info, None)
|
||||
|
||||
# Return the special OK code.
|
||||
return ("OK", expiry_info)
|
||||
|
||||
_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])
|
||||
@@ -449,6 +577,9 @@ class ConsoleOutput:
|
||||
def print_error(self, message):
|
||||
self.print_block(message, first_line="✖ ")
|
||||
|
||||
def print_warning(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)
|
||||
@@ -482,7 +613,7 @@ if __name__ == "__main__":
|
||||
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)
|
||||
cert_status, cert_status_details = check_certificate(domain, ssl_certificate, ssl_key)
|
||||
if cert_status != "OK":
|
||||
sys.exit(1)
|
||||
sys.exit(0)
|
||||
@@ -11,20 +11,29 @@
|
||||
|
||||
<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="email" class="form-control" id="addaliasEmail" placeholder="Incoming Email Address">
|
||||
<div class="col-sm-offset-1 col-sm-11">
|
||||
<div id="alias_type_buttons" class="btn-group btn-group-xs">
|
||||
<button type="button" class="btn btn-default active">Regular</button>
|
||||
<button type="button" class="btn btn-default">Catch-All</button>
|
||||
</div>
|
||||
<div id="alias_catchall_info" class="text-info small" style="display: none; margin: .5em 0 0 0;">A catch-all alias captures all otherwise unmatched email to a domain. Enter just a part of an email address starting with the @-sign.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="addaliasTargets" class="col-sm-2 control-label">Forward To</label>
|
||||
<label for="addaliasEmail" class="col-sm-1 control-label">Alias</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>
|
||||
<input type="email" class="form-control" id="addaliasEmail" placeholder="incoming email address (you@yourdomain.com)">
|
||||
</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>
|
||||
<label for="addaliasTargets" class="col-sm-1 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-1 col-sm-11">
|
||||
<button id="add-alias-button" type="submit" class="btn btn-primary">Add Alias</button>
|
||||
<button id="alias-cancel" class="btn btn-default hidden" onclick="aliases_reset_form(); return false;">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -35,7 +44,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Email Address<br></th>
|
||||
<th>Alias<br></th>
|
||||
<th>Forwards To</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -49,7 +58,7 @@
|
||||
<table>
|
||||
<tr id="alias-template">
|
||||
<td class='actions'>
|
||||
<a href="#" onclick="aliases_edit(this); return false;" class='edit' title="Edit Alias">
|
||||
<a href="#" onclick="aliases_edit(this); scroll_top(); 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">
|
||||
@@ -73,17 +82,41 @@ function show_aliases() {
|
||||
function(r) {
|
||||
$('#alias_table tbody').html("");
|
||||
for (var i = 0; i < r.length; i++) {
|
||||
var n = $("#alias-template").clone();
|
||||
n.attr('id', '');
|
||||
var hdr = $("<tr><td colspan='3'><h4/></td></tr>");
|
||||
hdr.find('h4').text(r[i].domain);
|
||||
$('#alias_table tbody').append(hdr);
|
||||
|
||||
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);
|
||||
for (var k = 0; k < r[i].aliases.length; k++) {
|
||||
var alias = r[i].aliases[k];
|
||||
|
||||
var n = $("#alias-template").clone();
|
||||
n.attr('id', '');
|
||||
|
||||
if (alias.required) n.addClass('alias-required');
|
||||
n.attr('data-email', alias.source);
|
||||
n.find('td.email').text(alias.source)
|
||||
for (var j = 0; j < alias.destination.length; j++)
|
||||
n.find('td.target').append($("<div></div>").text(alias.destination[j]))
|
||||
$('#alias_table tbody').append(n);
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
$(function() {
|
||||
$('#alias_type_buttons button').off('click').click(function() {
|
||||
$('#alias_type_buttons button').removeClass('active');
|
||||
$(this).addClass('active');
|
||||
if ($(this).text() == "Regular") {
|
||||
$('#addaliasEmail').attr('type', 'email');
|
||||
$('#addaliasEmail').attr('placeholder', 'incoming email address (you@yourdomain.com)');
|
||||
$('#alias_catchall_info').slideUp();
|
||||
} else {
|
||||
$('#addaliasEmail').attr('type', 'text');
|
||||
$('#addaliasEmail').attr('placeholder', 'incoming catch-all address (@yourdomain.com)');
|
||||
$('#alias_catchall_info').slideDown();
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
var is_alias_add_update = false;
|
||||
@@ -116,7 +149,7 @@ function aliases_reset_form() {
|
||||
$("#addaliasEmail").val('')
|
||||
$("#addaliasTargets").val('')
|
||||
$('#alias-cancel').addClass('hidden');
|
||||
$('#add-alias-button').text('Add');
|
||||
$('#add-alias-button').text('Add Alias');
|
||||
is_alias_add_update = false;
|
||||
}
|
||||
|
||||
@@ -156,4 +189,10 @@ function aliases_remove(elem) {
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
function scroll_top() {
|
||||
$('html, body').animate({
|
||||
scrollTop: $("#panel_aliases").offset().top
|
||||
}, 1000);
|
||||
}
|
||||
</script>
|
||||
|
||||
99
management/templates/custom-dns.html
Normal file
99
management/templates/custom-dns.html
Normal file
@@ -0,0 +1,99 @@
|
||||
<style>
|
||||
</style>
|
||||
|
||||
<h2>Custom DNS</h2>
|
||||
|
||||
<p class="text-warning">This is an advanced configuration page.</p>
|
||||
|
||||
<p>It is possible to set custom DNS records on domains hosted here.</p>
|
||||
|
||||
<h3>Using a Secondary Nameserver</h3>
|
||||
|
||||
<p>If your TLD requires you to have two separate nameservers, you can either set up a secondary (aka “slave”) nameserver or, alternatively, set up <a href="#" onclick="return show_panel('external_dns')">external DNS</a> and ignore the DNS server on this box. If you choose to use a seconday/slave nameserver, you must find a seconday/slave nameserver service provider. Your domain name registrar or virtual cloud provider may provide this service for you. Once you set up the seconday/slave nameserver service, enter the hostname of <em>their</em> secondary nameserver:</p>
|
||||
|
||||
<form class="form-horizontal" role="form" onsubmit="do_set_secondary_dns(); return false;">
|
||||
<div class="form-group">
|
||||
<label for="secondarydnsHostname" class="col-sm-1 control-label">Hostname</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" id="secondarydnsHostname" placeholder="ns1.cloudprovider.com">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-1 col-sm-11">
|
||||
<button type="submit" class="btn btn-primary">Update</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="secondarydns-clear-instructions" class="form-group" style="display: none">
|
||||
<div class="col-sm-offset-1 col-sm-11">
|
||||
<p class="small">Clear the input field above and click Update to use this machine itself as secondary DNS, which is the default/normal setup.</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<h3>Custom DNS API</h3>
|
||||
|
||||
<p>Use your box’s DNS API to set custom DNS records on domains hosted here. For instance, you can create your own dynamic DNS service.</p>
|
||||
|
||||
<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>
|
||||
|
||||
<h4>HTTP POST parameters</h4>
|
||||
|
||||
<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>
|
||||
|
||||
<p>Strict <a href="http://tools.ietf.org/html/rfc4408">SPF</a> and <a href="https://datatracker.ietf.org/doc/draft-kucherawy-dmarc-base/?include_text=1">DMARC</a> records will be added to all custom domains unless you override them.</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>
|
||||
|
||||
<script>
|
||||
function show_custom_dns() {
|
||||
api(
|
||||
"/dns/secondary-nameserver",
|
||||
"GET",
|
||||
{ },
|
||||
function(data) {
|
||||
$('#secondarydnsHostname').val(data.hostname ? data.hostname : '');
|
||||
$('#secondarydns-clear-instructions').toggle(data.hostname != null);
|
||||
});
|
||||
}
|
||||
|
||||
function do_set_secondary_dns() {
|
||||
api(
|
||||
"/dns/secondary-nameserver",
|
||||
"POST",
|
||||
{
|
||||
hostname: $('#secondarydnsHostname').val()
|
||||
},
|
||||
function(data) {
|
||||
if (data == "") return; // nothing updated
|
||||
show_modal_error("Secondary DNS", $("<pre/>").text(data));
|
||||
$('#secondarydns-clear-instructions').slideDown();
|
||||
},
|
||||
function(err) {
|
||||
show_modal_error("Secondary DNS", $("<pre/>").text(err));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -9,6 +9,7 @@
|
||||
padding-top: 0;
|
||||
}
|
||||
#external_dns_settings .values td {
|
||||
border: 0;
|
||||
padding-top: .75em;
|
||||
padding-bottom: 0;
|
||||
max-width: 50vw;
|
||||
@@ -19,23 +20,18 @@
|
||||
padding-top: .5em;
|
||||
padding-bottom: .75em;
|
||||
font-style: italic;
|
||||
font-size: 95%;
|
||||
color: #777;
|
||||
}
|
||||
</style>
|
||||
|
||||
<h2>External DNS</h2>
|
||||
|
||||
<p class="text-danger">This is for advanced configurations.</p>
|
||||
<p class="text-warning">This is an advanced configuration page.</p>
|
||||
|
||||
<h3>Overview</h3>
|
||||
<p>Although your box is configured to serve its own DNS, it is possible to host your DNS elsewhere — such as in the DNS control panel provided by your domain name registrar or virtual cloud provider — by copying the DNS zone information shown in the table below into your external DNS server’s control panel.</p>
|
||||
|
||||
<p>Although your box is configured to serve its own DNS, it is possible to host your DNS elsewhere. We do not recommend this.</p>
|
||||
|
||||
<p>If you do so, you are responsible for keeping your DNS entries up to date. In particular DNSSEC entries must be re-signed periodically. Do not set a DS record at your registrar or publish DNSSEC entries in your DNS zones if you do not intend to keep them up to date.</p>
|
||||
|
||||
<h3>DNS Settings</h3>
|
||||
|
||||
<p>Enter the following DNS entries at your DNS provider:</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>
|
||||
|
||||
<table id="external_dns_settings" class="table">
|
||||
<thead>
|
||||
@@ -50,7 +46,7 @@
|
||||
</table>
|
||||
|
||||
<script>
|
||||
function show_system_external_dns() {
|
||||
function show_external_dns() {
|
||||
$('#external_dns_settings tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
||||
api(
|
||||
"/dns/dump",
|
||||
@@ -30,7 +30,7 @@
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
h1, h2, h3, h4 {
|
||||
font-family: Raleway, sans-serif;
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -44,7 +44,13 @@
|
||||
border-bottom: 1px solid black;
|
||||
padding-bottom: 3px;
|
||||
margin-bottom: 13px;
|
||||
margin-top: 26px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 110%;
|
||||
margin-bottom: 13px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
@@ -54,6 +60,10 @@
|
||||
table.table {
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
|
||||
ol li {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap-theme.min.css">
|
||||
<style>
|
||||
@@ -75,27 +85,28 @@
|
||||
</div>
|
||||
<div class="navbar-collapse collapse">
|
||||
<ul class="nav navbar-nav">
|
||||
<li class="dropdown active">
|
||||
<li class="dropdown">
|
||||
<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_external_dns" onclick="return show_panel(this);">External DNS (Advanced)</a></li>
|
||||
<li><a href="#ssl" onclick="return show_panel(this);">SSL Certificates</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="#custom_dns" onclick="return show_panel(this);">Custom DNS</a></li>
|
||||
<li><a href="#external_dns" onclick="return show_panel(this);">External DNS</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="dropdown active">
|
||||
<li class="dropdown">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown">Mail <b class="caret"></b></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="#mail-guide" onclick="return show_panel(this);">Instructions</a></li>
|
||||
<li><a href="#users" onclick="return show_panel(this);">Users</a></li>
|
||||
<li><a href="#aliases" onclick="return show_panel(this);">Aliases</a></li>
|
||||
<!--<li><a href="#">Another action</a></li>
|
||||
<li><a href="#">Something else here</a></li>
|
||||
<li class="divider"></li>
|
||||
<li class="dropdown-header">Nav header</li>
|
||||
<li><a href="#">Separated link</a></li>
|
||||
<li><a href="#">One more separated link</a></li>-->
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="#sync_guide" onclick="return show_panel(this);">Contacts/Calendar</a></li>
|
||||
<li><a href="#web" onclick="return show_panel(this);">Web</a></li>
|
||||
</ul>
|
||||
<ul class="nav navbar-nav navbar-right">
|
||||
<li><a href="#" onclick="do_logout(); return false;" style="color: white">Log out?</a></li>
|
||||
@@ -109,8 +120,16 @@
|
||||
{% include "system-status.html" %}
|
||||
</div>
|
||||
|
||||
<div id="panel_system_external_dns" class="container panel">
|
||||
{% include "system-external-dns.html" %}
|
||||
<div id="panel_system_backup" class="container panel">
|
||||
{% include "system-backup.html" %}
|
||||
</div>
|
||||
|
||||
<div id="panel_external_dns" class="container panel">
|
||||
{% include "external-dns.html" %}
|
||||
</div>
|
||||
|
||||
<div id="panel_custom_dns" class="container panel">
|
||||
{% include "custom-dns.html" %}
|
||||
</div>
|
||||
|
||||
<div id="panel_login" class="panel">
|
||||
@@ -129,6 +148,18 @@
|
||||
{% include "aliases.html" %}
|
||||
</div>
|
||||
|
||||
<div id="panel_sync_guide" class="container panel">
|
||||
{% include "sync-guide.html" %}
|
||||
</div>
|
||||
|
||||
<div id="panel_web" class="container panel">
|
||||
{% include "web.html" %}
|
||||
</div>
|
||||
|
||||
<div id="panel_ssl" class="container panel">
|
||||
{% include "ssl.html" %}
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<footer>
|
||||
@@ -136,7 +167,7 @@
|
||||
</footer>
|
||||
</div> <!-- /container -->
|
||||
|
||||
<div id="ajax_loading_indicator" style="display: none; position: absolute; left: 0; top: 0; width: 100%; height: 100%; text-align: center; background-color: rgba(255,255,255,.75)">
|
||||
<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>
|
||||
@@ -169,6 +200,11 @@ 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
|
||||
@@ -241,7 +277,7 @@ function ajax(options) {
|
||||
if (!old_error)
|
||||
show_modal_error("Error", "Something went wrong, sorry.")
|
||||
else
|
||||
old_error(jqxhr.responseText);
|
||||
old_error(jqxhr.responseText, jqxhr);
|
||||
};
|
||||
is_ajax_loading = true;
|
||||
$.ajax(options);
|
||||
@@ -276,6 +312,11 @@ function api(url, method, data, callback, callback_error) {
|
||||
return output;
|
||||
}
|
||||
|
||||
function default_error(text, xhr) {
|
||||
if (xhr.status != 403) // else handled below
|
||||
show_modal_error("Error", "Something went wrong, sorry.")
|
||||
}
|
||||
|
||||
ajax({
|
||||
url: "/admin" + url,
|
||||
method: method,
|
||||
@@ -289,7 +330,7 @@ function api(url, method, data, callback, callback_error) {
|
||||
'Basic ' + base64encode(api_credentials[0] + ':' + api_credentials[1]));
|
||||
},
|
||||
success: callback,
|
||||
error: callback_error,
|
||||
error: callback_error || default_error,
|
||||
statusCode: {
|
||||
403: function(xhr) {
|
||||
// Credentials are no longer valid. Try to login again.
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
<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>
|
||||
<h1 style="margin: 1em">{{hostname}}</h1>
|
||||
<p style="margin: 2em">Log in here for your Mail-in-a-Box control panel.</p>
|
||||
</center>
|
||||
|
||||
@@ -36,6 +49,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
function do_login() {
|
||||
if ($('#loginEmail').val() == "") {
|
||||
@@ -85,8 +99,10 @@ function do_login() {
|
||||
}
|
||||
}
|
||||
|
||||
// Open the next panel the user wants to go to.
|
||||
show_panel(!switch_back_to_panel ? 'system_status' : switch_back_to_panel)
|
||||
// Open the next panel the user wants to go to. Do this after the XHR response
|
||||
// is over so that we don't start a new XHR request while this one is finishing,
|
||||
// which confuses the loading indicator.
|
||||
setTimeout(function() { show_panel(!switch_back_to_panel ? 'system_status' : switch_back_to_panel) }, 300);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,41 +1,75 @@
|
||||
<style>#panel_mail-guide table.table { width: auto; margin-left: .5em; }</style>
|
||||
|
||||
<div class="container">
|
||||
<h2>Checking and Sending Mail</h2>
|
||||
<h2 style="margin-bottom: 0">Checking and Sending Mail</h2>
|
||||
|
||||
<h4>App Configuration</h4>
|
||||
<div class="row">
|
||||
<div class="col-sm-6">
|
||||
<h3>How to log in</h3>
|
||||
|
||||
<p>You can access your email using webmail, desktop mail clients, or mobile apps.</p>
|
||||
<p>Your username and password are the same no matter how you check your mail:</p>
|
||||
|
||||
<p>Here is what you need to know for webmail:</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>
|
||||
|
||||
<style>#panel_mail-guide table.table { width: auto; margin-left: 1.5em; }</style>
|
||||
<h3>Webmail</h3>
|
||||
|
||||
<table class="table">
|
||||
<tr><th>Webmail Address:</th> <td><a href="https://{{hostname}}/mail"><b>https://{{hostname}}/mail</b></a></td></tr>
|
||||
<tr><th>Username:</th> <td>Your whole email address.</td></tr>
|
||||
<tr><th>Password:</th> <td>Your mail password.</td></tr>
|
||||
</table>
|
||||
<p>Webmail lets you check your email from any web browser. Your webmail site is:</p>
|
||||
<p style="margin-left: 2em"><strong><a href="https://{{hostname}}/mail">https://{{hostname}}/mail</a></strong></p>
|
||||
|
||||
<p>On mobile devices you might need to install a “mail client” app. We recommend <a href="https://play.google.com/store/apps/details?id=com.fsck.k9">K-9 Mail</a>. On a desktop you could try <a href="https://www.mozilla.org/en-US/thunderbird/">Mozilla Thunderbird</a>.</p>
|
||||
<h3>Mobile/desktop apps</h3>
|
||||
|
||||
<p>Configure your device or desktop mail client as follows:</p>
|
||||
<p>When you set up your email on your phone, desktop, or other device, you will be asked to choose a protocol.</p>
|
||||
|
||||
<table class="table" style="max-width: 30em">
|
||||
<tr><th>Server Name:</th> <td>{{hostname}}</td></tr>
|
||||
<tr><th>Username:</th> <td>Your whole email address.</td></tr>
|
||||
<tr><th>Password:</th> <td>Your mail password.</td></tr>
|
||||
</table>
|
||||
<ul>
|
||||
<li>On Android devices, look for IMAP and SMTP.</li>
|
||||
<li>On iOS devices, look for Exchange or ActiveSync.</li>
|
||||
</ul>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<h4>IMAP/SMTP settings</h4>
|
||||
|
||||
<table class="table">
|
||||
<thead><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>
|
||||
<tr><th>Exchange ActiveSync</th> <td>n/a</td> <td>Secure Connection</td></tr>
|
||||
</table>
|
||||
<p>This method is preferred on Android devices but is not available on iOS devices.</p>
|
||||
|
||||
<p>Depending on your mail program, you will use either IMAP & SMTP or Exchange ActiveSync. See this <a href="http://z-push.org/compatibility/">list of compatible devices</a> for Exchange ActiveSync.</p>
|
||||
<p>Your mail server is <strong>{{hostname}}</strong>. Use the following settings when prompted:</p>
|
||||
|
||||
<h4>Notes</h4>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<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 <span>(“always” or “required”, if prompted)</span></td></tr>
|
||||
</table>
|
||||
|
||||
<p>Mail-in-a-Box uses <a href="http://en.wikipedia.org/wiki/Greylisting">greylisting</a> to cut down on spam. The first time you receive an email from a recipient, it may be delayed for ten minutes.</p>
|
||||
<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> separately.</p>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-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. Use these settings when prompted:</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>
|
||||
|
||||
<div class="col-sm-6">
|
||||
<h3>Other information about mail on your box</h3>
|
||||
|
||||
<h4>Greylisting</h4>
|
||||
<p>Your box using a technique called greylisting to cut down on spam. Greylisting works by delaying mail from people you haven’t received mail from before for up to about 10 minutes. The vast majority of spam gets tricked by this. If you are waiting for an email from someone new, such as if you are registering on a new website and are waiting for an email confirmation, please give it up to 10-15 minutes to arrive.</p>
|
||||
|
||||
<h4>Use this box to send as you</h4>
|
||||
<p>Your box sets strict email sending policies for your domain names to make it harder for spam and other fraudulent mail to claim to be you. Only this machine is authorized to send email on behalf of your domain names. If you use any other service to send email as you, it will likely get spam filtered by recipients.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
118
management/templates/ssl.html
Normal file
118
management/templates/ssl.html
Normal file
@@ -0,0 +1,118 @@
|
||||
<style>
|
||||
</style>
|
||||
|
||||
<h2>SSL Certificates</h2>
|
||||
|
||||
<h3>Certificate Status</h3>
|
||||
|
||||
|
||||
<table id="ssl_domains" class="table" style="margin-bottom: 2em; width: auto;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domain</th>
|
||||
<th>Certificate Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3 id="ssl_install_header">Install SSL Certificate</h3>
|
||||
|
||||
<p>There are many places where you can get a free or cheap SSL certificate. We recommend <a href="https://www.namecheap.com/cart/remove.aspx?itemid=47016639&i=i2">Namecheap’s $9 certificate</a> or <a href="https://www.startssl.com/">StartSSL’s free express lane</a>.</p>
|
||||
|
||||
<p>Which domain are you getting an SSL certificate for?</p>
|
||||
|
||||
<p><select id="ssldomain" onchange="show_csr()" class="form-control" style="width: auto"></select></p>
|
||||
|
||||
<div id="csr_info" style="display: none">
|
||||
<p>You will need to provide the SSL certificate provider this Certificate Signing Request (CSR):</p>
|
||||
|
||||
<pre id="ssl_csr"></pre>
|
||||
|
||||
<p><small>The CSR is safe to share. It can only be used in combination with a secret key stored on this machine.</small></p>
|
||||
|
||||
<p>The SSL certificate provider will then provide you with an SSL certificate. They may also provide you with an intermediate chain. Paste each separately into the boxes below:</p>
|
||||
|
||||
<p style="margin-bottom: .5em">SSL certificate:</p>
|
||||
<p><textarea id="ssl_paste_cert" class="form-control" style="max-width: 40em; height: 8em" placeholder="-----BEGIN CERTIFICATE-----
stuff here
-----END CERTIFICATE-----"></textarea></p>
|
||||
|
||||
<p style="margin-bottom: .5em">SSL intermediate chain (if provided):</p>
|
||||
<p><textarea id="ssl_paste_chain" class="form-control" style="max-width: 40em; height: 8em" placeholder="-----BEGIN CERTIFICATE-----
stuff here
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
more stuff here
-----END CERTIFICATE-----"></textarea></p>
|
||||
|
||||
<p>After you paste in the information, click the install button.</p>
|
||||
|
||||
<button class="btn-primary" onclick="install_cert()">Install</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function show_ssl() {
|
||||
api(
|
||||
"/web/domains",
|
||||
"GET",
|
||||
{
|
||||
},
|
||||
function(domains) {
|
||||
var tb = $('#ssl_domains tbody');
|
||||
tb.text('');
|
||||
$('#ssldomain').html('<option value="">(select)</option>');
|
||||
|
||||
for (var i = 0; i < domains.length; i++) {
|
||||
var row = $("<tr><th class='domain'><a href=''></a></th><td class='status'></td> <td class='actions'><a href='#' onclick='return ssl_install(this);' class='btn btn-xs'>Install Certificate</a></td></tr>");
|
||||
tb.append(row);
|
||||
row.attr('data-domain', domains[i].domain);
|
||||
row.find('.domain a').text(domains[i].domain);
|
||||
row.find('.domain a').attr('href', 'https://' + domains[i].domain);
|
||||
row.addClass("text-" + domains[i].ssl_certificate[0]);
|
||||
row.find('.status').text(domains[i].ssl_certificate[1]);
|
||||
if (domains[i].ssl_certificate[0] == "success") {
|
||||
row.find('.actions a').addClass('btn-default').text('Replace Certificate');
|
||||
} else {
|
||||
row.find('.actions a').addClass('btn-primary').text('Install Certificate');
|
||||
}
|
||||
|
||||
$('#ssldomain').append($('<option>').text(domains[i].domain));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function ssl_install(elem) {
|
||||
var domain = $(elem).parents('tr').attr('data-domain');
|
||||
$('#ssldomain').val(domain);
|
||||
$('#csr_info').slideDown();
|
||||
$('#ssl_csr').text('Loading...');
|
||||
show_csr();
|
||||
$('html, body').animate({ scrollTop: $('#ssl_install_header').offset().top })
|
||||
return false;
|
||||
}
|
||||
|
||||
function show_csr() {
|
||||
api(
|
||||
"/ssl/csr/" + $('#ssldomain').val(),
|
||||
"POST",
|
||||
{
|
||||
},
|
||||
function(data) {
|
||||
$('#ssl_csr').text(data);
|
||||
});
|
||||
}
|
||||
|
||||
function install_cert() {
|
||||
api(
|
||||
"/ssl/install",
|
||||
"POST",
|
||||
{
|
||||
domain: $('#ssldomain').val(),
|
||||
cert: $('#ssl_paste_cert').val(),
|
||||
chain: $('#ssl_paste_chain').val()
|
||||
},
|
||||
function(status) {
|
||||
if (status == "") {
|
||||
show_modal_error("SSL Certificate Installation", "Certificate has been installed. Check that you have no connection problems to the domain.", function() { show_ssl(); $('#csr_info').slideUp(); });
|
||||
} else {
|
||||
show_modal_error("SSL Certificate Installation", status);
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
49
management/templates/sync-guide.html
Normal file
49
management/templates/sync-guide.html
Normal file
@@ -0,0 +1,49 @@
|
||||
<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><th>Contacts</td> <td><a href="https://{{hostname}}/cloud/contacts">https://{{hostname}}/cloud/contacts</a></td></tr>
|
||||
<tr><th>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 and Calendar</td> <td><a href="https://play.google.com/store/apps/details?id=at.bitfire.davdroid">DAVdroid</a> ($3.69; free <a href="https://f-droid.org/repository/browse/?fdfilter=dav&fdid=at.bitfire.davdroid">here</a>)</td></tr>
|
||||
<tr><td>Only 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>Only 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>
|
||||
83
management/templates/system-backup.html
Normal file
83
management/templates/system-backup.html
Normal file
@@ -0,0 +1,83 @@
|
||||
<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();
|
||||
}
|
||||
// round to have three significant figures but at most one decimal place
|
||||
if (bytes >= 100)
|
||||
bytes = Math.round(bytes)
|
||||
else
|
||||
bytes = Math.round(bytes*10)/10;
|
||||
return bytes + " " + 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>
|
||||
@@ -10,11 +10,14 @@
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
#system-checks .error td {
|
||||
#system-checks .status-error td {
|
||||
color: #733;
|
||||
}
|
||||
#system-checks .ok td {
|
||||
color: #030;
|
||||
#system-checks .status-warning td {
|
||||
color: #770;
|
||||
}
|
||||
#system-checks .status-ok td {
|
||||
color: #040;
|
||||
}
|
||||
#system-checks div.extra {
|
||||
display: none;
|
||||
@@ -52,9 +55,13 @@ function show_system_status() {
|
||||
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 == "heading")
|
||||
n.addClass(r[i].type)
|
||||
else
|
||||
n.addClass("status-" + r[i].type)
|
||||
if (r[i].type == "ok") n.find('td.status').text("✓")
|
||||
if (r[i].type == "error") n.find('td.status').text("✖")
|
||||
if (r[i].type == "warning") n.find('td.status').text("?")
|
||||
n.find('td.message p').text(r[i].text)
|
||||
$('#system-checks tbody').append(n);
|
||||
|
||||
@@ -76,4 +83,4 @@ function show_system_status() {
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
<h2>Users</h2>
|
||||
|
||||
<style>
|
||||
#user_table tr.account_inactive td .address { color: #888; text-decoration: line-through; }
|
||||
#user_table .aliases { margin: .25em 0 0 1em; font-size: 95%; }
|
||||
#user_table tr.account_inactive td.address { color: #888; text-decoration: line-through; }
|
||||
#user_table .aliases { font-size: 90%; }
|
||||
#user_table .aliases div:before { content: "⇖ "; }
|
||||
#user_table .aliases div { }
|
||||
#user_table .actions { margin: .25em 0 0 1em; font-size: 95%; }
|
||||
#user_table .actions > * { display: none; }
|
||||
#user_table .account_active .actions a.archive { display: inline; }
|
||||
#user_table .account_inactive .actions .restore { display: inline; }
|
||||
#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>
|
||||
@@ -41,9 +40,9 @@
|
||||
<table id="user_table" class="table" style="width: auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Email Address<br><small style="font-weight: normal">(Also the user’s login username.)</small></th>
|
||||
<th>Privileges</th>
|
||||
<th width="50%">Email Address</th>
|
||||
<th>Actions</th>
|
||||
<th>Mailbox Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -53,19 +52,35 @@
|
||||
<div style="display: none">
|
||||
<table>
|
||||
<tr id="user-template">
|
||||
<td class='address'>
|
||||
</td>
|
||||
<td class='actions'>
|
||||
<a href="#" onclick="users_remove(this); return false;" class='archive' title="Archive Account">
|
||||
<span class="glyphicon glyphicon-trash"></span>
|
||||
<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>
|
||||
</td>
|
||||
<td class='email'>
|
||||
<div class='address'> </div>
|
||||
<div class='aliases' style='display: none'> </div>
|
||||
<div class='actions'>
|
||||
<span class='restore'>To restore account, create a new account with this email address.</span>
|
||||
</div>
|
||||
<td class='mailboxsize'>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="user-extra-template">
|
||||
<td colspan="3" style="border-top: 0; padding-top: 0">
|
||||
<div class='if_inactive restore_info' style='color: #888; font-size: 90%'>To restore account, create a new account with this email address. Or to permanently delete the mailbox, delete the directory <tt></tt> on the machine.</div>
|
||||
|
||||
<div class='aliases' style='display: none'> </div>
|
||||
</td>
|
||||
<td class='privs'> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
@@ -81,39 +96,56 @@ function show_users() {
|
||||
function(r) {
|
||||
$('#user_table tbody').html("");
|
||||
for (var i = 0; i < r.length; i++) {
|
||||
var n = $("#user-template").clone();
|
||||
n.attr('id', '');
|
||||
var hdr = $("<tr><td><h4/></td></tr>");
|
||||
hdr.find('h4').text(r[i].domain);
|
||||
$('#user_table tbody').append(hdr);
|
||||
|
||||
n.addClass("account_" + r[i].status);
|
||||
n.attr('data-email', r[i].email);
|
||||
n.find('td.email .address').text(r[i].email)
|
||||
for (var k = 0; k < r[i].users.length; k++) {
|
||||
var user = r[i].users[k];
|
||||
|
||||
var add_privs = ["admin"];
|
||||
var n = $("#user-template").clone();
|
||||
var n2 = $("#user-extra-template").clone();
|
||||
n.attr('id', '');
|
||||
n2.attr('id', '');
|
||||
$('#user_table tbody').append(n);
|
||||
$('#user_table tbody').append(n2);
|
||||
|
||||
for (var j = 0; j < r[i].privileges.length; j++) {
|
||||
var p = $("<div><span class='name'></span> <a href='#' onclick='mod_priv(this, \"remove\"); return false;'><span class=\"glyphicon glyphicon-trash\" style='font-size: 90%'></span></a></div>");
|
||||
p.find('span.name').text(r[i].privileges[j]);
|
||||
n.find('td.privs').append(p);
|
||||
if (add_privs.indexOf(r[i].privileges[j]) >= 0)
|
||||
add_privs.splice(add_privs.indexOf(r[i].privileges[j]), 1);
|
||||
}
|
||||
n.addClass("account_" + user.status);
|
||||
n2.addClass("account_" + user.status);
|
||||
|
||||
for (var j = 0; j < add_privs.length; j++) {
|
||||
var p = $("<div><small><a href='#' onclick='mod_priv(this, \"add\"); return false;'><span class=\"glyphicon glyphicon-plus\" style='font-size: 90%'></span> <span class='name'></span>?</a></small></div>");
|
||||
p.find('span.name').text(add_privs[j]);
|
||||
n.find('td.privs').append(p);
|
||||
}
|
||||
n.attr('data-email', user.email);
|
||||
n.find('.address').text(user.email)
|
||||
n.find('.mailboxsize').text(nice_size(user.mailbox_size))
|
||||
n2.find('.restore_info tt').text(user.mailbox);
|
||||
|
||||
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(", ") : "")
|
||||
))
|
||||
if (user.status == 'inactive') continue;
|
||||
|
||||
var add_privs = ["admin"];
|
||||
|
||||
for (var j = 0; j < user.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(user.privileges[j]);
|
||||
n.find('.privs').append(p);
|
||||
if (add_privs.indexOf(user.privileges[j]) >= 0)
|
||||
add_privs.splice(add_privs.indexOf(user.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 (user.aliases && user.aliases.length > 0) {
|
||||
n2.find('.aliases').show();
|
||||
for (var j = 0; j < user.aliases.length; j++) {
|
||||
n2.find('.aliases').append($("<div/>").text(
|
||||
user.aliases[j][0]
|
||||
+ (user.aliases[j][1].length > 0 ? " ⇐ " + user.aliases[j][1].join(", ") : "")
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
$('#user_table tbody').append(n);
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -141,11 +173,35 @@ function do_add_user() {
|
||||
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 " + email + "?</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>"),
|
||||
$("<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(
|
||||
@@ -178,7 +234,7 @@ function mod_priv(elem, add_remove) {
|
||||
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 " + email + "?",
|
||||
"Are you sure you want to " + add_remove + " the " + priv + " privilege for <b>" + email + "</b>?",
|
||||
add_remove1,
|
||||
function() {
|
||||
api(
|
||||
@@ -193,4 +249,4 @@ function mod_priv(elem, add_remove) {
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
102
management/templates/web.html
Normal file
102
management/templates/web.html
Normal file
@@ -0,0 +1,102 @@
|
||||
<style>
|
||||
</style>
|
||||
|
||||
<h2>Static Web Hosting</h2>
|
||||
|
||||
<p>This machine is serving a simple, static website at <a href="https://{{hostname}}">https://{{hostname}}</a> and at all domain names that you set up an email user or alias for.</p>
|
||||
|
||||
<h3>Uploading web files</h3>
|
||||
|
||||
<p>You can replace the default website with your own HTML pages and other static files. This control panel won’t help you design a website, but once you have <tt>.html</tt> files you can upload it following these instructions:</p>
|
||||
|
||||
<ol>
|
||||
<li>Ensure that any domains you are publishing a website for have no problems on the <a href="#system_status" onclick="return show_panel(this);">Status Checks</a> page.</li>
|
||||
|
||||
<li>On your personal computer, install an SSH file transfer program such as <a href="https://filezilla-project.org/">FileZilla</a> or <a href="http://linuxcommand.org/man_pages/scp1.html">scp</a>.</li>
|
||||
|
||||
<li>Log in to this machine with the file transfer program. The server is <strong>{{hostname}}</strong>, the protocol is SSH or SFTP, and use the <strong>SSH login credentials</strong> that you used when you originally created this machine at your cloud host provider. This is <strong>not</strong> what you use to log in either for email or this control panel. Your SSH credentials probably involves a private key file.</li>
|
||||
|
||||
<li>Upload your <tt>.html</tt> or other files to the directory <tt>{{storage_root}}/www/default</tt> on this machine. They will appear directly and immediately on the web.</li>
|
||||
|
||||
<li>The websites set up on this machine are listed in the table below with where to put the files for each website (if you have customized that, see next section).</li>
|
||||
|
||||
<table id="web_domains_existing" class="table" style="margin-bottom: 2em; width: auto;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Site</th>
|
||||
<th>Directory for Files</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<li>If you want to have this box host a static website on a domain that is not listed in the table, create a dummy <a href="#users" onclick="return show_panel(this);">mail user</a> or <a href="#aliases" onclick="return show_panel(this);">alias</a> on the domain first.</li>
|
||||
|
||||
</ol>
|
||||
|
||||
<h3>Different sites for different domains</h3>
|
||||
|
||||
<p>Create one of the directories shown in the table below to create a space for different files for one of the websites.</p>
|
||||
|
||||
<p>After you create one of these directories, click <button id="web_update" class="btn btn-primary" onclick="do_web_update()">Web Update</button> to restart the box’s web server so that it sees the new website file location.</p>
|
||||
|
||||
<table id="web_domains_custom" class="table" style="margin-bottom: 2em; width: auto;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Site</th>
|
||||
<th>Create Directory</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
<script>
|
||||
function show_web() {
|
||||
api(
|
||||
"/web/domains",
|
||||
"GET",
|
||||
{
|
||||
},
|
||||
function(domains) {
|
||||
var tb = $('#web_domains_existing tbody');
|
||||
tb.text('');
|
||||
for (var i = 0; i < domains.length; i++) {
|
||||
var row = $("<tr><th class='domain'><a href=''></a></th><td class='directory'><tt/></td></tr>");
|
||||
tb.append(row);
|
||||
row.find('.domain a').text('https://' + domains[i].domain);
|
||||
row.find('.domain a').attr('href', 'https://' + domains[i].domain);
|
||||
row.find('.directory tt').text(domains[i].root);
|
||||
}
|
||||
|
||||
tb = $('#web_domains_custom tbody');
|
||||
tb.text('');
|
||||
for (var i = 0; i < domains.length; i++) {
|
||||
if (domains[i].root != domains[i].custom_root) {
|
||||
var row = $("<tr><th class='domain'><a href=''></a></th><td class='directory'><tt></td></tr>");
|
||||
tb.append(row);
|
||||
row.find('.domain a').text('https://' + domains[i].domain);
|
||||
row.find('.domain a').attr('href', 'https://' + domains[i].domain);
|
||||
row.find('.directory tt').text(domains[i].custom_root);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function do_web_update() {
|
||||
api(
|
||||
"/web/update",
|
||||
"POST",
|
||||
{
|
||||
},
|
||||
function(data) {
|
||||
if (data == "")
|
||||
data = "Nothing changed.";
|
||||
else
|
||||
data = $("<pre/>").text(data);
|
||||
show_modal_error("Web Update", data, function() { show_web() });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -23,10 +23,6 @@ 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.
|
||||
@@ -169,3 +165,22 @@ def create_syslog_handler():
|
||||
handler = logging.handlers.SysLogHandler(address='/dev/log')
|
||||
handler.setLevel(logging.WARNING)
|
||||
return handler
|
||||
|
||||
def du(path):
|
||||
# Computes the size of all files in the path, like the `du` command.
|
||||
# Based on http://stackoverflow.com/a/17936789. Takes into account
|
||||
# soft and hard links.
|
||||
total_size = 0
|
||||
seen = set()
|
||||
for dirpath, dirnames, filenames in os.walk(path):
|
||||
for f in filenames:
|
||||
fp = os.path.join(dirpath, f)
|
||||
try:
|
||||
stat = os.lstat(fp)
|
||||
except OSError:
|
||||
continue
|
||||
if stat.st_ino in seen:
|
||||
continue
|
||||
seen.add(stat.st_ino)
|
||||
total_size += stat.st_size
|
||||
return total_size
|
||||
|
||||
@@ -38,7 +38,7 @@ def get_web_domains(env):
|
||||
|
||||
return domains
|
||||
|
||||
def do_web_update(env):
|
||||
def do_web_update(env, ok_status="web updated\n"):
|
||||
# Build an nginx configuration file.
|
||||
nginx_conf = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-top.conf")).read()
|
||||
|
||||
@@ -65,7 +65,7 @@ def do_web_update(env):
|
||||
# enough and doesn't break any open connections.
|
||||
shell('check_call', ["/usr/sbin/service", "nginx", "reload"])
|
||||
|
||||
return "web updated\n"
|
||||
return ok_status
|
||||
|
||||
def make_domain_config(domain, template, template_for_primaryhost, env):
|
||||
# How will we configure this domain.
|
||||
@@ -94,7 +94,20 @@ def make_domain_config(domain, template, template_for_primaryhost, env):
|
||||
nginx_conf = nginx_conf.replace("$SSL_KEY", ssl_key)
|
||||
nginx_conf = nginx_conf.replace("$SSL_CERTIFICATE", ssl_certificate)
|
||||
|
||||
# Add in any user customizations.
|
||||
# Because the certificate may change, we should recognize this so we
|
||||
# can trigger an nginx update.
|
||||
def hashfile(filepath):
|
||||
import hashlib
|
||||
sha1 = hashlib.sha1()
|
||||
f = open(filepath, 'rb')
|
||||
try:
|
||||
sha1.update(f.read())
|
||||
finally:
|
||||
f.close()
|
||||
return sha1.hexdigest()
|
||||
nginx_conf += "# ssl files sha1: %s / %s\n" % (hashfile(ssl_key), hashfile(ssl_certificate))
|
||||
|
||||
# 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))
|
||||
@@ -103,16 +116,21 @@ def make_domain_config(domain, template, template_for_primaryhost, env):
|
||||
for path, url in yaml.get("proxies", {}).items():
|
||||
nginx_conf += "\tlocation %s {\n\t\tproxy_pass %s;\n\t}\n" % (path, url)
|
||||
|
||||
# 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
|
||||
|
||||
def get_web_root(domain, env):
|
||||
def get_web_root(domain, env, test_exists=True):
|
||||
# Try STORAGE_ROOT/web/domain_name if it exists, but fall back to STORAGE_ROOT/web/default.
|
||||
for test_domain in (domain, 'default'):
|
||||
root = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(test_domain))
|
||||
if os.path.exists(root): break
|
||||
if os.path.exists(root) or not test_exists: break
|
||||
return root
|
||||
|
||||
def get_domain_ssl_files(domain, env):
|
||||
@@ -140,8 +158,8 @@ 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
|
||||
if check_certificate(domain, ssl_certificate_primary, None) == "OK":
|
||||
from status_checks import check_certificate
|
||||
if check_certificate(domain, ssl_certificate_primary, None)[0] == "OK":
|
||||
ssl_certificate = ssl_certificate_primary
|
||||
|
||||
# Where would the CSR go? As with the SSL cert itself, the CSR must be
|
||||
@@ -173,11 +191,8 @@ def ensure_ssl_certificate_exists(domain, ssl_key, ssl_certificate, csr_path, en
|
||||
# Generate a new self-signed certificate using the same private key that we already have.
|
||||
|
||||
# Start with a CSR.
|
||||
shell("check_call", [
|
||||
"openssl", "req", "-new",
|
||||
"-key", ssl_key,
|
||||
"-out", csr_path,
|
||||
"-subj", "/C=%s/ST=/L=/O=/CN=%s" % (env["CSR_COUNTRY"], domain)])
|
||||
with open(csr_path, "w") as f:
|
||||
f.write(create_csr(domain, ssl_key, env))
|
||||
|
||||
# And then make the certificate.
|
||||
shell("check_call", [
|
||||
@@ -187,3 +202,62 @@ def ensure_ssl_certificate_exists(domain, ssl_key, ssl_certificate, csr_path, en
|
||||
"-signkey", ssl_key,
|
||||
"-out", ssl_certificate])
|
||||
|
||||
def create_csr(domain, ssl_key, env):
|
||||
return shell("check_output", [
|
||||
"openssl", "req", "-new",
|
||||
"-key", ssl_key,
|
||||
"-out", "/dev/stdout",
|
||||
"-sha256",
|
||||
"-subj", "/C=%s/ST=/L=/O=/CN=%s" % (env["CSR_COUNTRY"], domain)])
|
||||
|
||||
def install_cert(domain, ssl_cert, ssl_chain, env):
|
||||
if domain not in get_web_domains(env):
|
||||
return "Invalid domain name."
|
||||
|
||||
# Write the combined cert+chain to a temporary path and validate that it is OK.
|
||||
# The certificate always goes above the chain.
|
||||
import tempfile, os
|
||||
fd, fn = tempfile.mkstemp('.pem')
|
||||
os.write(fd, (ssl_cert + '\n' + ssl_chain).encode("ascii"))
|
||||
os.close(fd)
|
||||
|
||||
# Do validation on the certificate before installing it.
|
||||
from status_checks import check_certificate
|
||||
ssl_key, ssl_certificate, ssl_csr_path = get_domain_ssl_files(domain, env)
|
||||
cert_status, cert_status_details = check_certificate(domain, fn, ssl_key)
|
||||
if cert_status != "OK":
|
||||
if cert_status == "SELF-SIGNED":
|
||||
cert_status = "This is a self-signed certificate. I can't install that."
|
||||
os.unlink(fn)
|
||||
return cert_status
|
||||
|
||||
# Copy the certificate to its expected location.
|
||||
os.makedirs(os.path.dirname(ssl_certificate), exist_ok=True)
|
||||
os.rename(fn, ssl_certificate)
|
||||
|
||||
# Kick nginx so it sees the cert.
|
||||
return do_web_update(env, ok_status="")
|
||||
|
||||
def get_web_domains_info(env):
|
||||
def check_cert(domain):
|
||||
from status_checks import check_certificate
|
||||
ssl_key, ssl_certificate, ssl_csr_path = get_domain_ssl_files(domain, env)
|
||||
if not os.path.exists(ssl_certificate):
|
||||
return ("danger", "No Certificate Installed")
|
||||
cert_status, cert_status_details = check_certificate(domain, ssl_certificate, ssl_key)
|
||||
if cert_status == "OK":
|
||||
return ("success", "Signed & valid. " + cert_status_details)
|
||||
elif cert_status == "SELF-SIGNED":
|
||||
return ("warning", "Self-signed. Get a signed certificate to stop warnings.")
|
||||
else:
|
||||
return ("danger", "Certificate has a problem: " + cert_status)
|
||||
|
||||
return [
|
||||
{
|
||||
"domain": domain,
|
||||
"root": get_web_root(domain, env),
|
||||
"custom_root": get_web_root(domain, env, test_exists=False),
|
||||
"ssl_certificate": check_cert(domain),
|
||||
}
|
||||
for domain in get_web_domains(env)
|
||||
]
|
||||
|
||||
45
setup/bootstrap.sh
Executable file → Normal file
45
setup/bootstrap.sh
Executable file → Normal file
@@ -2,39 +2,50 @@
|
||||
#########################################################
|
||||
# This script is intended to be run like this:
|
||||
#
|
||||
# wget https://raw.githubusercontent.com/mail-in-a-box/mailinabox/master/setup/bootstrap.sh
|
||||
# sudo bash bootstrap.sh
|
||||
#
|
||||
# We can't pipe directly to bash because setup/start.sh
|
||||
# asks for user input on stdin.
|
||||
# curl https://.../bootstrap.sh | sudo bash
|
||||
#
|
||||
#########################################################
|
||||
|
||||
if [ -z "$TAG" ]; then
|
||||
TAG=v0.03
|
||||
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 Downloading Mail-in-a-Box . . .
|
||||
apt-get -q -q install -y git
|
||||
git clone -q --depth 1 -b master https://github.com/mail-in-a-box/mailinabox
|
||||
cd mailinabox
|
||||
echo Installing git . . .
|
||||
DEBIAN_FRONTEND=noninteractive apt-get -q -q install -y git < /dev/null
|
||||
echo
|
||||
|
||||
# If it does exist, update it.
|
||||
else
|
||||
echo Updating Mail-in-a-Box . . .
|
||||
cd mailinabox
|
||||
if ! git pull -q --ff-only; then
|
||||
echo Downloading Mail-in-a-Box . . .
|
||||
git clone \
|
||||
-b $TAG --depth 1 \
|
||||
https://github.com/mail-in-a-box/mailinabox \
|
||||
$HOME/mailinabox \
|
||||
< /dev/null 2> /dev/null
|
||||
|
||||
echo
|
||||
fi
|
||||
|
||||
# Change directory to it.
|
||||
cd $HOME/mailinabox
|
||||
|
||||
# Update it.
|
||||
if [ "$TAG" != `git describe` ]; then
|
||||
echo Updating Mail-in-a-Box to $TAG . . .
|
||||
git fetch
|
||||
if ! git checkout -q $TAG; then
|
||||
echo "Update failed. Did you modify something in `pwd`?"
|
||||
exit
|
||||
fi
|
||||
echo
|
||||
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
|
||||
|
@@ -1,12 +1,13 @@
|
||||
# OpenDKIM: Sign outgoing mail with DKIM
|
||||
########################################
|
||||
|
||||
# After this, you'll still need to run dns_update.sh to get the DKIM
|
||||
# signature in the DNS zones.
|
||||
# OpenDKIM
|
||||
# --------
|
||||
#
|
||||
# OpenDKIM provides a service that puts a DKIM signature on outbound mail.
|
||||
#
|
||||
# The DNS configuration for DKIM is done in the management daemon.
|
||||
|
||||
source setup/functions.sh # load our functions
|
||||
|
||||
# Install DKIM
|
||||
# Install DKIM...
|
||||
apt_install opendkim opendkim-tools
|
||||
|
||||
# Make sure configuration directories exist.
|
||||
@@ -18,9 +19,9 @@ mkdir -p $STORAGE_ROOT/mail/dkim
|
||||
echo "127.0.0.1" > /etc/opendkim/TrustedHosts
|
||||
|
||||
if grep -q "ExternalIgnoreList" /etc/opendkim.conf; then
|
||||
true; # already done
|
||||
true # already done #NODOC
|
||||
else
|
||||
# Add various configuration options to the end.
|
||||
# Add various configuration options to the end of `opendkim.conf`.
|
||||
cat >> /etc/opendkim.conf << EOF;
|
||||
MinimumKeyBits 1024
|
||||
ExternalIgnoreList refile:/etc/opendkim/TrustedHosts
|
||||
@@ -32,7 +33,7 @@ RequireSafeKeys false
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Create a new DKIM key if we don't have one already. This creates
|
||||
# Create a new DKIM key. This creates
|
||||
# mail.private and mail.txt in $STORAGE_ROOT/mail/dkim. The former
|
||||
# is the actual private key and the latter is the suggested DNS TXT
|
||||
# entry which we'll want to include in our DNS setup.
|
||||
@@ -47,7 +48,7 @@ chmod go-rwx $STORAGE_ROOT/mail/dkim
|
||||
|
||||
# Add OpenDKIM as a milter to postfix, which is how it intercepts outgoing
|
||||
# mail to perform the signing (by adding a mail header).
|
||||
# Be careful. If we add other milters later, it needs to be concatenated on the smtpd_milters line.
|
||||
# Be careful. If we add other milters later, it needs to be concatenated on the smtpd_milters line. #NODOC
|
||||
tools/editconf.py /etc/postfix/main.cf \
|
||||
smtpd_milters=inet:127.0.0.1:8891 \
|
||||
non_smtpd_milters=\$smtpd_milters \
|
||||
|
||||
76
setup/dns.sh
76
setup/dns.sh
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# DNS: Configure a DNS server using nsd
|
||||
#######################################
|
||||
# DNS
|
||||
# -----------------------------------------------
|
||||
|
||||
# This script installs packages, but the DNS zone files are only
|
||||
# created by the /dns/update API in the management server because
|
||||
@@ -8,62 +8,94 @@
|
||||
# mail users & aliases created by the user later.
|
||||
|
||||
source setup/functions.sh # load our functions
|
||||
source /etc/mailinabox.conf # load global vars
|
||||
|
||||
# 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
|
||||
# (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.
|
||||
|
||||
sudo mkdir -p /var/run/nsd
|
||||
mkdir -p /var/run/nsd
|
||||
|
||||
# Create DNSSEC signing keys.
|
||||
|
||||
mkdir -p "$STORAGE_ROOT/dns/dnssec";
|
||||
if [ ! -f "$STORAGE_ROOT/dns/dnssec/keys.conf" ]; then
|
||||
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
|
||||
# practice), and a nice and long keylength. The domain name we
|
||||
# provide ("_domain_") doesn't matter -- we'll use the same
|
||||
# keys for all our domains.
|
||||
KSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -a RSASHA1-NSEC3-SHA1 -b 2048 -k _domain_);
|
||||
# TLDs don't all support the same algorithms, so we'll generate keys using a few
|
||||
# different algorithms. RSASHA1-NSEC3-SHA1 was possibly the first widely used
|
||||
# algorithm that supported NSEC3, which is a security best practice. However TLDs
|
||||
# will probably be moving away from it to a a SHA256-based algorithm.
|
||||
#
|
||||
# Supports `RSASHA1-NSEC3-SHA1` (didn't test with `RSASHA256`):
|
||||
#
|
||||
# * .info
|
||||
# * .me
|
||||
#
|
||||
# Requires `RSASHA256`
|
||||
#
|
||||
# * .email
|
||||
|
||||
FIRST=1 #NODOC
|
||||
for algo in RSASHA1-NSEC3-SHA1 RSASHA256; do
|
||||
if [ ! -f "$STORAGE_ROOT/dns/dnssec/$algo.conf" ]; then
|
||||
if [ $FIRST == 1 ]; then
|
||||
echo "Generating DNSSEC signing keys. This may take a few minutes..."
|
||||
FIRST=0 #NODOC
|
||||
fi
|
||||
|
||||
# Create the Key-Signing Key (KSK) (with `-k`) which is the so-called
|
||||
# Secure Entry Point. The domain name we provide ("_domain_") doesn't
|
||||
# matter -- we'll use the same keys for all our domains.
|
||||
#
|
||||
# `ldns-keygen` outputs the new key's filename to stdout, which
|
||||
# we're capturing into the `KSK` variable.
|
||||
KSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -a $algo -b 2048 -k _domain_);
|
||||
|
||||
# Now create a Zone-Signing Key (ZSK) which is expected to be
|
||||
# rotated more often than a KSK, although we have no plans to
|
||||
# rotate it (and doing so would be difficult to do without
|
||||
# disturbing DNS availability.) Omit '-k' and use a shorter key.
|
||||
ZSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -a RSASHA1-NSEC3-SHA1 -b 1024 _domain_);
|
||||
# disturbing DNS availability.) Omit `-k` and use a shorter key length.
|
||||
ZSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -a $algo -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 normally provided to domain name registrar (but it's actually invalid with `_domain_`)
|
||||
# * `K_domain_.+007+08882.key`: public key
|
||||
# * `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.
|
||||
# We might have multiple keys down the road. This will identify
|
||||
# what keys are the current keys.
|
||||
cat > $STORAGE_ROOT/dns/dnssec/keys.conf << EOF;
|
||||
cat > $STORAGE_ROOT/dns/dnssec/$algo.conf << EOF;
|
||||
KSK=$KSK
|
||||
ZSK=$ZSK
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Force the dns_update script to be run every day to re-sign zones for DNSSEC.
|
||||
# And loop to do the next algorithm...
|
||||
done
|
||||
|
||||
# Force the dns_update script to be run every day to re-sign zones for DNSSEC
|
||||
# before they expire. When we sign zones (in `dns_update.py`) we specify a
|
||||
# 30-day validation window, so we had better re-sign before then.
|
||||
cat > /etc/cron.daily/mailinabox-dnssec << EOF;
|
||||
#!/bin/bash
|
||||
# Mail-in-a-Box
|
||||
|
||||
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
|
||||
@@ -85,6 +85,9 @@ 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
|
||||
@@ -99,11 +102,16 @@ function get_default_privateip {
|
||||
# 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.
|
||||
#
|
||||
# Pass '4' or '6' as an argument to this function to specify
|
||||
# what type of address to get (IPv4, IPv6).
|
||||
|
||||
target=8.8.8.8
|
||||
|
||||
@@ -112,9 +120,21 @@ function get_default_privateip {
|
||||
# as it's an address on the public Internet.
|
||||
if [ "$1" == "6" ]; then target=2001:4860:4860::8888; fi
|
||||
|
||||
ip -$1 -o route get $target \
|
||||
| grep -v unreachable \
|
||||
| sed "s/.* src \([^ ]*\).*/\1/"
|
||||
# Get the route information.
|
||||
route=$(ip -$1 -o route get $target | grep -v unreachable)
|
||||
|
||||
# Parse the address out of the route information.
|
||||
address=$(echo $route | sed "s/.* src \([^ ]*\).*/\1/")
|
||||
|
||||
if [[ "$1" == "6" && $address == fe80:* ]]; then
|
||||
# For IPv6 link-local addresses, parse the interface out
|
||||
# of the route information and append it with a '%'.
|
||||
interface=$(echo $route | sed "s/.* dev \([^ ]*\).*/\1/")
|
||||
address=$address%$interface
|
||||
fi
|
||||
|
||||
echo $address
|
||||
|
||||
}
|
||||
|
||||
function ufw_allow {
|
||||
@@ -127,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,21 +18,23 @@
|
||||
source setup/functions.sh # load our functions
|
||||
source /etc/mailinabox.conf # load global vars
|
||||
|
||||
# Install packages.
|
||||
# Install packages...
|
||||
|
||||
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.
|
||||
# Set the location where we'll store user mailboxes. '%d' is the domain name and '%n' is the
|
||||
# username part of the user's email address. We'll ensure that no bad domains or email addresses
|
||||
# are created within the management daemon.
|
||||
tools/editconf.py /etc/dovecot/conf.d/10-mail.conf \
|
||||
mail_location=maildir:$STORAGE_ROOT/mail/mailboxes/%d/%n \
|
||||
mail_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
|
||||
@@ -40,16 +43,18 @@ tools/editconf.py /etc/dovecot/conf.d/10-auth.conf \
|
||||
disable_plaintext_auth=yes \
|
||||
"auth_mechanisms=plain login"
|
||||
|
||||
# Enable SSL, specify the location of the SSL certificate and private key files,
|
||||
# and allow only good ciphers per http://baldric.net/2013/12/07/tls-ciphers-in-postfix-and-dovecot/.
|
||||
# Enable SSL, specify the location of the SSL certificate and private key files.
|
||||
# Disable obsolete SSL protocols and allow only good ciphers per http://baldric.net/2013/12/07/tls-ciphers-in-postfix-and-dovecot/.
|
||||
tools/editconf.py /etc/dovecot/conf.d/10-ssl.conf \
|
||||
ssl=required \
|
||||
"ssl_cert=<$STORAGE_ROOT/ssl/ssl_certificate.pem" \
|
||||
"ssl_key=<$STORAGE_ROOT/ssl/ssl_private_key.pem" \
|
||||
"ssl_protocols=!SSLv3 !SSLv2" \
|
||||
"ssl_cipher_list=TLSv1+HIGH !SSLv2 !RC4 !aNULL !eNULL !3DES @STRENGTH"
|
||||
|
||||
# Disable in-the-clear IMAP and POP because we're paranoid (we haven't even
|
||||
# enabled POP).
|
||||
# Disable in-the-clear IMAP because there is no reason for a user to transmit
|
||||
# login credentials outside of an encrypted connection. Although we haven't
|
||||
# even installed the POP server, ensure it is disabled too.
|
||||
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
|
||||
|
||||
@@ -62,10 +67,10 @@ sed -i "s/#port = 110/port = 0/" /etc/dovecot/conf.d/10-master.conf
|
||||
tools/editconf.py /etc/dovecot/conf.d/20-imap.conf \
|
||||
imap_idle_notify_interval="4 mins"
|
||||
|
||||
# LDA (LMTP)
|
||||
# ### 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.
|
||||
# on port 10026, and Spamassassin will be configured to pass mail there.
|
||||
#
|
||||
# The disabled unix socket listener is normally how Postfix and Dovecot
|
||||
# would communicate (see the Postfix setup script for the corresponding
|
||||
@@ -90,30 +95,32 @@ protocol imap {
|
||||
}
|
||||
EOF
|
||||
|
||||
# Setting a postmaster_address seems to be required or LMTP won't start.
|
||||
# Setting a `postmaster_address` is required or LMTP won't start. An alias
|
||||
# will be created automatically by our management daemon.
|
||||
tools/editconf.py /etc/dovecot/conf.d/15-lda.conf \
|
||||
postmaster_address=postmaster@$PRIMARY_HOSTNAME
|
||||
|
||||
# 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
|
||||
# mail as it comes in.
|
||||
sed -i "s/#mail_plugins = .*/mail_plugins = \$mail_plugins sieve/" /etc/dovecot/conf.d/20-lmtp.conf
|
||||
|
||||
# Configure sieve. We'll create a global script that moves mail marked
|
||||
# as spam by Spamassassin into the user's Spam folder.
|
||||
#
|
||||
# * `sieve_before`: The path to our global sieve which handles moving spam to the Spam folder.
|
||||
#
|
||||
# * `sieve`: The path to the user's main active script. ManageSieve will create a symbolic
|
||||
# link here to the actual sieve script. It should not be in the mailbox directory
|
||||
# (because then it might appear as a folder) and it should not be in the sieve_dir
|
||||
# (because then I suppose it might appear to the user as one of their scripts).
|
||||
# * `sieve_dir`: Directory for :personal include scripts for the include extension. This
|
||||
# is also where the ManageSieve service stores the user's scripts.
|
||||
cat > /etc/dovecot/conf.d/99-local-sieve.conf << EOF;
|
||||
plugin {
|
||||
# The path to our global sieve which handles moving spam to the Spam folder.
|
||||
sieve_before = /etc/dovecot/sieve-spam.sieve
|
||||
|
||||
# The path to the user's main active script. ManageSieve will create a symbolic
|
||||
# link here to the actual sieve script. It should not be in the mailbox directory
|
||||
# (because then it might appear as a folder) and it should not be in the sieve_dir
|
||||
# (because then I suppose it might appear to the user as one of their scripts).
|
||||
sieve = $STORAGE_ROOT/mail/sieve/%d/%n.sieve
|
||||
|
||||
# Directory for :personal include scripts for the include extension. This
|
||||
# is also where the ManageSieve service stores the user's scripts.
|
||||
sieve_dir = $STORAGE_ROOT/mail/sieve/%d/%n
|
||||
}
|
||||
EOF
|
||||
@@ -121,7 +128,7 @@ EOF
|
||||
# Copy the global sieve script into where we've told Dovecot to look for it. Then
|
||||
# compile it. Global scripts must be compiled now because Dovecot won't have
|
||||
# permission later.
|
||||
cp `pwd`/conf/sieve-spam.txt /etc/dovecot/sieve-spam.sieve
|
||||
cp conf/sieve-spam.txt /etc/dovecot/sieve-spam.sieve
|
||||
sievec /etc/dovecot/sieve-spam.sieve
|
||||
|
||||
# PERMISSIONS
|
||||
|
||||
@@ -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,30 +30,44 @@
|
||||
source setup/functions.sh # load our functions
|
||||
source /etc/mailinabox.conf # load global vars
|
||||
|
||||
# Install packages.
|
||||
# ### Install packages.
|
||||
|
||||
apt_install postfix postgrey postfix-pcre ca-certificates
|
||||
# Install postfix's packages.
|
||||
#
|
||||
# * `postfix`: The SMTP server.
|
||||
# * `postfix-pcre`: Enables header filtering.
|
||||
# * `postgrey`: A mail policy service that soft-rejects mail the first time
|
||||
# it is received. Spammers don't usually try agian. Legitimate mail
|
||||
# always will.
|
||||
# * `ca-certificates`: A trust store used to squelch postfix warnings about
|
||||
# untrusted opportunistically-encrypted connections.
|
||||
|
||||
# Basic Settings
|
||||
apt_install postfix postfix-pcre postgrey ca-certificates
|
||||
|
||||
# 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
|
||||
# there is no true local mail delivery). Also set the banner (must have the hostname first, then anything).
|
||||
# ### Basic Settings
|
||||
|
||||
# Set some basic settings...
|
||||
#
|
||||
# * Have postfix listen on all network interfaces.
|
||||
# * Set our name (the Debian default seems to be "localhost" but make it our hostname).
|
||||
# * Set the name of the local machine to localhost, which means xxx@localhost is delivered locally, although we don't use it.
|
||||
# * Set the SMTP banner (which must have the hostname first, then anything).
|
||||
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://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/.
|
||||
# By putting this setting here we leave opportunistic TLS on incoming mail at default cipher settings (any cipher is better than none).
|
||||
# * Give it a different name in syslog to distinguish it from the port 25 smtpd server.
|
||||
# * 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,78 +79,83 @@ 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.
|
||||
# Set stronger DH parameters, which via openssl tend to default to 1024 bits
|
||||
# (see ssl.sh).
|
||||
tools/editconf.py /etc/postfix/main.cf \
|
||||
smtpd_tls_security_level=may\
|
||||
smtpd_tls_auth_only=yes \
|
||||
smtpd_tls_cert_file=$STORAGE_ROOT/ssl/ssl_certificate.pem \
|
||||
smtpd_tls_key_file=$STORAGE_ROOT/ssl/ssl_private_key.pem \
|
||||
smtpd_tls_dh1024_param_file=$STORAGE_ROOT/ssl/dh2048.pem \
|
||||
smtpd_tls_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.
|
||||
#
|
||||
# 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:
|
||||
# Prefering ("opportunistic") TLS means Postfix will use TLS if the remote end
|
||||
# offers it, otherwise it will transmit the message in the clear. Postfix will
|
||||
# accept whatever SSL certificate the remote end provides. Opportunistic TLS
|
||||
# protects against passive easvesdropping (but not man-in-the-middle attacks).
|
||||
# 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.
|
||||
# relies on our local bind9 server being present and `smtp_dns_support_level=dnssec`.
|
||||
#
|
||||
# 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.
|
||||
# The `smtp_tls_CAfile` is superflous, but it eliminates warnings in the logs about untrusted certs,
|
||||
# which we don't care about seeing because Postfix is doing opportunistic TLS anyway. Better to encrypt,
|
||||
# even if we don't know if it's to the right party, than to not encrypt at all. Instead we'll
|
||||
# now see notices about trusted certs. The CA file is provided by the package `ca-certificates`.
|
||||
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.
|
||||
# * `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.
|
||||
#
|
||||
# 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
|
||||
# so these IPs get mail delivered quickly. But when an IP is not listed in the permit_dnswl_client list (i.e. it is not
|
||||
# whitelisted) then postfix does a DEFER_IF_REJECT, which results in all "unknown user" sorts of messages turning into
|
||||
# "450 4.7.1 Client host rejected: Service unavailable". This is a retry code, so the mail doesn't properly bounce.
|
||||
# Notes: #NODOC
|
||||
# permit_dnswl_client can pass through mail from whitelisted IP addresses, which would be good to put before greylisting #NODOC
|
||||
# so these IPs get mail delivered quickly. But when an IP is not listed in the permit_dnswl_client list (i.e. it is not #NODOC
|
||||
# whitelisted) then postfix does a DEFER_IF_REJECT, which results in all "unknown user" sorts of messages turning into #NODOC
|
||||
# "450 4.7.1 Client host rejected: Service unavailable". This is a retry code, so the mail doesn't properly bounce. #NODOC
|
||||
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",reject_unlisted_recipient,"check_policy_service inet:127.0.0.1:10023"
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -21,8 +24,7 @@ if [ ! -f $db_path ]; then
|
||||
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
|
||||
@@ -51,7 +53,6 @@ EOF
|
||||
chmod 0600 /etc/dovecot/dovecot-sql.conf.ext # per Dovecot instructions
|
||||
|
||||
# Have Dovecot provide an authorization service that Postfix can access & use.
|
||||
# Drew Crawford sets the auth-worker process to run as the mail user, but we don't care if it runs as root.
|
||||
cat > /etc/dovecot/conf.d/99-local-auth.conf << EOF;
|
||||
service auth {
|
||||
unix_listener /var/spool/postfix/private/auth {
|
||||
@@ -68,8 +69,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.
|
||||
@@ -92,9 +92,16 @@ query = SELECT 1 FROM users WHERE email='%s'
|
||||
EOF
|
||||
|
||||
# SQL statement to rewrite an email address if an alias is present.
|
||||
# Aliases have precedence over users, but that's counter-intuitive for
|
||||
# catch-all aliases ("@domain.com") which should *not* catch mail users.
|
||||
# To fix this, not only query the aliases table but also the users
|
||||
# table, i.e. turn users into aliases from themselves to themselves.
|
||||
# If there is both an alias and a user for the same address either
|
||||
# might be returned by the UNION, so the whole query is wrapped in
|
||||
# another select that prioritizes the alias definition.
|
||||
cat > /etc/postfix/virtual-alias-maps.cf << EOF;
|
||||
dbpath=$db_path
|
||||
query = SELECT destination FROM aliases WHERE source='%s'
|
||||
query = SELECT destination from (SELECT destination, 0 as priority FROM aliases WHERE source='%s' UNION SELECT email as destination, 1 as priority FROM users WHERE email='%s') ORDER BY priority LIMIT 1;
|
||||
EOF
|
||||
|
||||
# Restart Services
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -56,6 +56,17 @@ def migration_4(env):
|
||||
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 migration_6(env):
|
||||
# We now will generate multiple DNSSEC keys for different algorithms, since TLDs may
|
||||
# not support them all. .email only supports RSA/SHA-256. Rename the keys.conf file
|
||||
# to be algorithm-specific.
|
||||
basepath = os.path.join(env["STORAGE_ROOT"], 'dns/dnssec')
|
||||
shutil.move(os.path.join(basepath, 'keys.conf'), os.path.join(basepath, 'RSASHA1-NSEC3-SHA1.conf'))
|
||||
|
||||
def get_current_migration():
|
||||
ver = 0
|
||||
while True:
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# 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
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
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 \
|
||||
@@ -12,23 +14,30 @@ apt_install \
|
||||
|
||||
apt-get purge -qq -y owncloud*
|
||||
|
||||
# Install ownCloud from source if it is not already present
|
||||
# TODO: Check version?
|
||||
if [ ! -d /usr/local/lib/owncloud ]; then
|
||||
# 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-7.0.1.zip
|
||||
unzip -q /tmp/owncloud.zip -d /usr/local/lib
|
||||
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`
|
||||
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;
|
||||
cat > /usr/local/lib/owncloud/config/config.php <<EOF;
|
||||
<?php
|
||||
\$CONFIG = array (
|
||||
'datadirectory' => '$STORAGE_ROOT/owncloud',
|
||||
@@ -69,8 +78,8 @@ 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=40 count=1 2>/dev/null | sha1sum | fold -w 30 | head -n 1)
|
||||
cat - > /usr/local/lib/owncloud/config/autoconfig.php <<EOF;
|
||||
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
|
||||
@@ -118,13 +127,15 @@ sudo -u www-data php -f /usr/local/lib/owncloud/cron.php
|
||||
EOF
|
||||
chmod +x /etc/cron.hourly/mailinabox-owncloud
|
||||
|
||||
## Ensure all system admins are ownCloud admins.
|
||||
## Actually we don't do this. There's nothing much of interest that the user could
|
||||
## change from the ownCloud admin, and there's a lot they could mess up.
|
||||
#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
|
||||
# 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
|
||||
# ```
|
||||
|
||||
# Finished.
|
||||
# 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
|
||||
81
setup/spamassassin.sh
Normal file → Executable file
81
setup/spamassassin.sh
Normal file → Executable file
@@ -1,16 +1,20 @@
|
||||
#!/bin/bash
|
||||
# Spam filtering with spamassassin via spampd
|
||||
#############################################
|
||||
|
||||
# -------------------------------------------
|
||||
#
|
||||
# spampd sits between postfix and dovecot. It takes mail from postfix
|
||||
# over the LMTP protocol, runs spamassassin on it, and then passes the
|
||||
# message over LMTP to dovecot for local delivery.
|
||||
|
||||
#
|
||||
# In order to move spam automatically into the Spam folder we use the dovecot sieve
|
||||
# plugin. The tools/mail.py tool creates the necessary sieve script for each mail
|
||||
# user when the mail user is created.
|
||||
# plugin.
|
||||
|
||||
source /etc/mailinabox.conf # get global vars
|
||||
source setup/functions.sh # load our functions
|
||||
|
||||
# Install packages and basic configuration
|
||||
# ----------------------------------------
|
||||
|
||||
# Install packages.
|
||||
apt_install spampd razor pyzor dovecot-antispam
|
||||
|
||||
@@ -26,20 +30,33 @@ hide_output pyzor discover
|
||||
# We've already configured Dovecot to listen on this port.
|
||||
tools/editconf.py /etc/default/spampd DESTPORT=10026
|
||||
|
||||
# Enable the Dovecot antispam plugin to detect when a message moves between folders so we can
|
||||
# pass it to sa-learn for training. (Be careful if we use multiple plugins later.)
|
||||
sudo sed -i "s/#mail_plugins = .*/mail_plugins = \$mail_plugins antispam/" /etc/dovecot/conf.d/20-imap.conf
|
||||
# Bayesean learning
|
||||
# -----------------
|
||||
#
|
||||
# Spamassassin can learn from mail marked as spam or ham, but it needs to be
|
||||
# configured. We'll store the learning data in our storage area.
|
||||
#
|
||||
# These files must be:
|
||||
#
|
||||
# * Writable by sa-learn-pipe script below, which run as the 'mail' user, for manual tagging of mail as spam/ham.
|
||||
# * Readable by the spampd process ('spampd' user) during mail filtering.
|
||||
# * Writable by the debian-spamd user, which runs /etc/cron.daily/spamassassin.
|
||||
#
|
||||
# We'll have these files owned by spampd and grant access to the other two processes.
|
||||
|
||||
# When mail is moved in or out of the Dovecot Spam folder, re-train using this script
|
||||
# that sends the mail to spamassassin.
|
||||
# from http://wiki2.dovecot.org/Plugins/Antispam
|
||||
cat > /usr/bin/sa-learn-pipe.sh << EOF;
|
||||
cat<&0 >> /tmp/sendmail-msg-\$\$.txt
|
||||
/usr/bin/sa-learn \$* /tmp/sendmail-msg-\$\$.txt > /dev/null
|
||||
rm -f /tmp/sendmail-msg-\$\$.txt
|
||||
exit 0
|
||||
EOF
|
||||
chmod a+x /usr/bin/sa-learn-pipe.sh
|
||||
tools/editconf.py /etc/spamassassin/local.cf -s \
|
||||
bayes_path=$STORAGE_ROOT/mail/spamassassin/bayes
|
||||
|
||||
mkdir -p $STORAGE_ROOT/mail/spamassassin
|
||||
chown -R spampd:spampd $STORAGE_ROOT/mail/spamassassin
|
||||
|
||||
# To mark mail as spam or ham, just drag it in or out of the Spam folder. We'll
|
||||
# use the Dovecot antispam plugin to detect the message move operation and execute
|
||||
# a shell script that invokes learning.
|
||||
|
||||
# Enable the Dovecot antispam plugin.
|
||||
# (Be careful if we use multiple plugins later.) #NODOC
|
||||
sed -i "s/#mail_plugins = .*/mail_plugins = \$mail_plugins antispam/" /etc/dovecot/conf.d/20-imap.conf
|
||||
|
||||
# Configure the antispam plugin to call sa-learn-pipe.sh.
|
||||
cat > /etc/dovecot/conf.d/99-local-spampd.conf << EOF;
|
||||
@@ -47,12 +64,36 @@ plugin {
|
||||
antispam_backend = pipe
|
||||
antispam_spam_pattern_ignorecase = SPAM
|
||||
antispam_allow_append_to_spam = yes
|
||||
antispam_pipe_program_spam_args = /usr/bin/sa-learn-pipe.sh;--spam
|
||||
antispam_pipe_program_notspam_args = /usr/bin/sa-learn-pipe.sh;--ham
|
||||
antispam_pipe_program_spam_args = /usr/local/bin/sa-learn-pipe.sh;--spam
|
||||
antispam_pipe_program_notspam_args = /usr/local/bin/sa-learn-pipe.sh;--ham
|
||||
antispam_pipe_program = /bin/bash
|
||||
}
|
||||
EOF
|
||||
|
||||
# Have Dovecot run its mail process with a supplementary group (the spampd group)
|
||||
# so that it can access the learning files.
|
||||
|
||||
tools/editconf.py /etc/dovecot/conf.d/10-mail.conf \
|
||||
mail_access_groups=spampd
|
||||
|
||||
# Here's the script that the antispam plugin executes. It spools the message into
|
||||
# a temporary file and then runs sa-learn on it.
|
||||
# from http://wiki2.dovecot.org/Plugins/Antispam
|
||||
rm -f /usr/bin/sa-learn-pipe.sh # legacy location #NODOC
|
||||
cat > /usr/local/bin/sa-learn-pipe.sh << EOF;
|
||||
cat<&0 >> /tmp/sendmail-msg-\$\$.txt
|
||||
/usr/bin/sa-learn \$* /tmp/sendmail-msg-\$\$.txt > /dev/null
|
||||
rm -f /tmp/sendmail-msg-\$\$.txt
|
||||
exit 0
|
||||
EOF
|
||||
chmod a+x /usr/local/bin/sa-learn-pipe.sh
|
||||
|
||||
# Create empty bayes training data (if it doesn't exist). Once the files exist,
|
||||
# ensure they are group-writable so that the Dovecot process has access.
|
||||
sudo -u spampd /usr/bin/sa-learn --sync 2>/dev/null
|
||||
chmod -R 660 $STORAGE_ROOT/mail/spamassassin
|
||||
chmod 770 $STORAGE_ROOT/mail/spamassassin
|
||||
|
||||
# Initial training?
|
||||
# sa-learn --ham storage/mail/mailboxes/*/*/cur/
|
||||
# sa-learn --spam storage/mail/mailboxes/*/*/.Spam/cur/
|
||||
|
||||
22
setup/ssl.sh
22
setup/ssl.sh
@@ -1,7 +1,8 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# SSL Certificate
|
||||
#
|
||||
# ---------------
|
||||
|
||||
# Create a self-signed SSL certificate if one has not yet been created.
|
||||
#
|
||||
# The certificate is for PRIMARY_HOSTNAME specifically and is used for:
|
||||
@@ -21,22 +22,31 @@ source /etc/mailinabox.conf # load global vars
|
||||
apt_install openssl
|
||||
|
||||
mkdir -p $STORAGE_ROOT/ssl
|
||||
# Generate a new private key.
|
||||
# Set the umask so the key file is not world-readable.
|
||||
if [ ! -f $STORAGE_ROOT/ssl/ssl_private_key.pem ]; then
|
||||
# Generate a new private key if one doesn't already exist.
|
||||
# Set the umask so the key file is not world-readable.
|
||||
(umask 077; hide_output \
|
||||
openssl genrsa -out $STORAGE_ROOT/ssl/ssl_private_key.pem 2048)
|
||||
fi
|
||||
|
||||
# Generate a certificate signing request.
|
||||
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 [ ! -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
|
||||
|
||||
# For nginx and postfix, pre-generate some Diffie-Hellman cipher bits which is
|
||||
# used when a Diffie-Hellman cipher is selected during TLS negotiation. Diffie-Hellman
|
||||
# provides Perfect Forward Security. openssl's default is 1024 bits, but we'll
|
||||
# create 2048.
|
||||
if [ ! -f $STORAGE_ROOT/ssl/dh2048.pem ]; then
|
||||
openssl dhparam -out $STORAGE_ROOT/ssl/dh2048.pem 2048
|
||||
fi
|
||||
|
||||
292
setup/start.sh
292
setup/start.sh
@@ -4,47 +4,24 @@
|
||||
|
||||
source setup/functions.sh # load our functions
|
||||
|
||||
# Check system setup.
|
||||
# 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.
|
||||
source setup/preflight.sh
|
||||
|
||||
# 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
|
||||
# Ensure Python reads/writes files in UTF-8. If the machine
|
||||
# triggers some other locale in Python, like ASCII encoding,
|
||||
# Python may not be able to read/write files. Here and in
|
||||
# the management daemon startup script.
|
||||
|
||||
if [ -z `locale -a | grep en_US.utf8` ]; then
|
||||
# Generate locale if not exists
|
||||
hide_output locale-gen en_US.UTF-8
|
||||
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
|
||||
export LANGUAGE=en_US.UTF-8
|
||||
export LC_ALL=en_US.UTF-8
|
||||
export LANG=en_US.UTF-8
|
||||
export LC_TYPE=en_US.UTF-8
|
||||
|
||||
# Recall the last settings used if we're running this a second time.
|
||||
if [ -f /etc/mailinabox.conf ]; then
|
||||
@@ -59,151 +36,19 @@ if [ -f /etc/mailinabox.conf ]; then
|
||||
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. 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
|
||||
echo
|
||||
echo "Enter the public IP address of this machine, as given to you by your ISP."
|
||||
echo
|
||||
|
||||
read -e -i "$DEFAULT_PUBLIC_IP" -p "Public IP: " PUBLIC_IP
|
||||
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
|
||||
echo
|
||||
echo "Optional:"
|
||||
echo "Enter the public IPv6 address of this machine, as given to you by your ISP."
|
||||
echo "Leave blank if the machine does not have an IPv6 address."
|
||||
echo
|
||||
|
||||
read -e -i "$DEFAULT_PUBLIC_IPV6" -p "Public IPv6: " PUBLIC_IPV6
|
||||
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
|
||||
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!
|
||||
source setup/questions.sh
|
||||
|
||||
# Automatic configuration, e.g. as used in our Vagrant configuration.
|
||||
if [ "$PUBLIC_IP" = "auto" ]; then
|
||||
@@ -232,6 +77,9 @@ 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.
|
||||
@@ -264,19 +112,19 @@ CSR_COUNTRY=$CSR_COUNTRY
|
||||
EOF
|
||||
|
||||
# Start service configuration.
|
||||
. setup/system.sh
|
||||
. setup/ssl.sh
|
||||
. setup/dns.sh
|
||||
. setup/mail-postfix.sh
|
||||
. setup/mail-dovecot.sh
|
||||
. setup/mail-users.sh
|
||||
. setup/dkim.sh
|
||||
. setup/spamassassin.sh
|
||||
. setup/web.sh
|
||||
. setup/webmail.sh
|
||||
. setup/owncloud.sh
|
||||
. setup/zpush.sh
|
||||
. setup/management.sh
|
||||
source setup/system.sh
|
||||
source setup/ssl.sh
|
||||
source setup/dns.sh
|
||||
source setup/mail-postfix.sh
|
||||
source setup/mail-dovecot.sh
|
||||
source setup/mail-users.sh
|
||||
source setup/dkim.sh
|
||||
source setup/spamassassin.sh
|
||||
source setup/web.sh
|
||||
source setup/webmail.sh
|
||||
source setup/owncloud.sh
|
||||
source setup/zpush.sh
|
||||
source setup/management.sh
|
||||
|
||||
# Write the DNS and nginx configuration files.
|
||||
sleep 5 # wait for the daemon to start
|
||||
@@ -284,44 +132,9 @@ 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.
|
||||
|
||||
# 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 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
|
||||
source setup/firstuser.sh
|
||||
|
||||
# Done.
|
||||
echo
|
||||
echo "-----------------------------------------------"
|
||||
echo
|
||||
@@ -329,18 +142,19 @@ echo Your Mail-in-a-Box is running.
|
||||
echo
|
||||
echo Please log in to the control panel for further instructions at:
|
||||
echo
|
||||
if management/whats_next.py --check-primary-hostname; then
|
||||
if management/status_checks.py --check-primary-hostname; then
|
||||
# Show the nice URL if it appears to be resolving and has a valid certificate.
|
||||
echo https://$PRIMARY_HOSTNAME/admin
|
||||
echo
|
||||
echo If there are problems with this URL, instead use:
|
||||
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
|
||||
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
|
||||
openssl x509 -in $STORAGE_ROOT/ssl/ssl_certificate.pem -noout -fingerprint \
|
||||
| sed "s/SHA1 Fingerprint=//"
|
||||
echo
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
source setup/functions.sh # load our functions
|
||||
|
||||
# Base system configuration.
|
||||
# Basic System Configuration
|
||||
# -------------------------
|
||||
|
||||
# ### Install Packages
|
||||
|
||||
# Update system packages to make sure we have the latest upstream versions of things from Ubuntu.
|
||||
|
||||
echo Updating system packages...
|
||||
hide_output apt-get update
|
||||
@@ -8,53 +13,77 @@ 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:
|
||||
#
|
||||
# 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.
|
||||
# Allow apt to install system updates automatically every day.
|
||||
|
||||
apt_install ntp fail2ban
|
||||
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
|
||||
|
||||
# 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 #NODOC
|
||||
# settings, find the port it is supposedly running on, and open that port #NODOC
|
||||
# too. #NODOC
|
||||
SSH_PORT=$(sshd -T 2>/dev/null | grep "^port " | sed "s/port //") #NODOC
|
||||
if [ ! -z "$SSH_PORT" ]; then
|
||||
if [ "$SSH_PORT" != "22" ]; then
|
||||
|
||||
echo Opening alternate SSH port $SSH_PORT. #NODOC
|
||||
ufw_allow $SSH_PORT #NODOC
|
||||
|
||||
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 resolvconf
|
||||
tools/editconf.py /etc/default/bind9 \
|
||||
@@ -65,9 +94,11 @@ if ! grep -q "listen-on " /etc/bind/named.conf.options; then
|
||||
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)."
|
||||
mv /etc/resolvconf/resolv.conf.d/original /etc/resolvconf/resolv.conf.original
|
||||
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
|
||||
|
||||
58
setup/web.sh
58
setup/web.sh
@@ -5,55 +5,63 @@
|
||||
source setup/functions.sh # load our functions
|
||||
source /etc/mailinabox.conf # load global vars
|
||||
|
||||
# Some Ubuntu images start off with Apache. Remove it since we
|
||||
# will use nginx. Use autoremove to remove any Apache depenencies.
|
||||
if [ -f /usr/sbin/apache2 ]; then
|
||||
echo Removing apache...
|
||||
hide_output apt-get -y purge apache2 apache2-*
|
||||
hide_output apt-get -y --purge autoremove
|
||||
fi
|
||||
|
||||
# Install nginx and a PHP FastCGI daemon.
|
||||
#
|
||||
# Turn off nginx's default website.
|
||||
|
||||
apt_install nginx php5-fpm
|
||||
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
|
||||
# copy in a nginx configuration file for common and best-practices
|
||||
# SSL settings from @konklone
|
||||
cp conf/nginx-ssl.conf /etc/nginx/nginx-ssl.conf
|
||||
# Copy in a nginx configuration file for common and best-practices
|
||||
# SSL settings from @konklone. Replace STORAGE_ROOT so it can find
|
||||
# the DH params.
|
||||
sed "s#STORAGE_ROOT#$STORAGE_ROOT#" \
|
||||
conf/nginx-ssl.conf > /etc/nginx/nginx-ssl.conf
|
||||
|
||||
# Fix some nginx defaults.
|
||||
# The server_names_hash_bucket_size seems to prevent long domain names?
|
||||
tools/editconf.py /etc/nginx/nginx.conf -s \
|
||||
server_names_hash_bucket_size="64;"
|
||||
|
||||
# Bump up PHP's 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.
|
||||
|
||||
# make a default homepage
|
||||
if [ -d $STORAGE_ROOT/www/static ]; then mv $STORAGE_ROOT/www/static $STORAGE_ROOT/www/default; fi # migration
|
||||
if [ -d $STORAGE_ROOT/www/static ]; then mv $STORAGE_ROOT/www/static $STORAGE_ROOT/www/default; fi # migration #NODOC
|
||||
mkdir -p $STORAGE_ROOT/www/default
|
||||
if [ ! -f $STORAGE_ROOT/www/default/index.html ]; then
|
||||
cp conf/www_default.html $STORAGE_ROOT/www/default/index.html
|
||||
fi
|
||||
chown -R $STORAGE_USER $STORAGE_ROOT/www
|
||||
|
||||
# We previously installed a custom init script to start the PHP FastCGI daemon.
|
||||
# Remove it now that we're using php5-fpm.
|
||||
# We previously installed a custom init script to start the PHP FastCGI daemon. #NODOC
|
||||
# Remove it now that we're using php5-fpm. #NODOC
|
||||
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
|
||||
echo "Removing /etc/init.d/php-fastcgi, php5-cgi..." #NODOC
|
||||
rm -f /etc/init.d/php-fastcgi #NODOC
|
||||
hide_output update-rc.d php-fastcgi remove #NODOC
|
||||
apt-get -y purge php5-cgi #NODOC
|
||||
fi
|
||||
|
||||
# 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 /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
|
||||
# Remove obsoleted scripts. #NODOC
|
||||
# exchange-autodiscover is now handled by Z-Push. #NODOC
|
||||
for f in webfinger exchange-autodiscover; do #NODOC
|
||||
rm -f /usr/local/bin/mailinabox-$f.php #NODOC
|
||||
done #NODOC
|
||||
|
||||
# Start services.
|
||||
restart_service nginx
|
||||
|
||||
@@ -1,49 +1,64 @@
|
||||
# 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 \
|
||||
tinymce libjs-jquery libjs-jquery-mousewheel libmagic1
|
||||
|
||||
# We used to install Roundcube from Ubuntu, without triggering the dependencies
|
||||
# on Apache and MySQL, by downloading the debs and installing them manually.
|
||||
# Now that we're beyond that, get rid of those debs before installing from source.
|
||||
apt-get purge -qq -y roundcube*
|
||||
# We used to install Roundcube from Ubuntu, without triggering the dependencies #NODOC
|
||||
# on Apache and MySQL, by downloading the debs and installing them manually. #NODOC
|
||||
# Now that we're beyond that, get rid of those debs before installing from source. #NODOC
|
||||
apt-get purge -qq -y roundcube* #NODOC
|
||||
|
||||
# Install Roundcube from source if it is not already present.
|
||||
# TODO: Check version?
|
||||
if [ ! -d /usr/local/lib/roundcubemail ]; then
|
||||
# Install Roundcube from source if it is not already present or if it is out of date.
|
||||
VERSION=1.0.2
|
||||
needs_update=0 #NODOC
|
||||
if [ ! -f /usr/local/lib/roundcubemail/version ]; then
|
||||
# not installed yet #NODOC
|
||||
needs_update=1 #NODOC
|
||||
elif [[ $VERSION != `cat /usr/local/lib/roundcubemail/version` ]]; then
|
||||
# checks if the version is what we want
|
||||
needs_update=1 #NODOC
|
||||
fi
|
||||
if [ $needs_update == 1 ]; then
|
||||
echo installing roudcube webmail $VERSION...
|
||||
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/$VERSION/roundcubemail-$VERSION.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-$VERSION/ /usr/local/lib/roundcubemail
|
||||
rm -f /tmp/roundcube.tgz
|
||||
echo $VERSION > /usr/local/lib/roundcubemail/version
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
@@ -19,30 +21,33 @@ apt_install \
|
||||
php5enmod imap
|
||||
|
||||
# Copy Z-Push into place.
|
||||
needs_update=0
|
||||
TARGETHASH=d0cd5a47c53afac5c3b287006dc8a48a1c4ffcd5
|
||||
needs_update=0 #NODOC
|
||||
if [ ! -f /usr/local/lib/z-push/version ]; then
|
||||
needs_update=1
|
||||
elif [[ `curl -s https://api.github.com/repos/fmbiete/Z-Push-contrib/git/refs/heads/master` != `cat /usr/local/lib/z-push/version` ]]; then
|
||||
needs_update=1 #NODOC
|
||||
elif [[ $TARGETHASH != `cat /usr/local/lib/z-push/version` ]]; then
|
||||
# checks if the version
|
||||
needs_update=1
|
||||
needs_update=1 #NODOC
|
||||
fi
|
||||
if [ $needs_update == 1 ]; then
|
||||
rm -rf /usr/local/lib/z-push
|
||||
rm -f /tmp/zpush.zip
|
||||
rm -f /tmp/zpush-repo
|
||||
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
|
||||
git clone -q https://github.com/fmbiete/Z-Push-contrib /tmp/zpush-repo
|
||||
(cd /tmp/zpush-repo/; git checkout -q $TARGETHASH;)
|
||||
rm -rf /tmp/zpush-repo/.git
|
||||
mv /tmp/zpush-repo /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.zip;
|
||||
curl -s https://api.github.com/repos/fmbiete/Z-Push-contrib/git/refs/heads/master > /usr/local/lib/z-push/version
|
||||
rm -f /tmp/zpush-repo
|
||||
echo $TARGETHASH > /usr/local/lib/z-push/version
|
||||
fi
|
||||
|
||||
# Configure default config.
|
||||
sed -i "s/define('TIMEZONE', .*/define('TIMEZONE', 'Etc\/UTC');/" /usr/local/lib/z-push/config.php
|
||||
sed -i "s^define('TIMEZONE', .*^define('TIMEZONE', '$(cat /etc/timezone)');^" /usr/local/lib/z-push/config.php
|
||||
sed -i "s/define('BACKEND_PROVIDER', .*/define('BACKEND_PROVIDER', 'BackendCombined');/" /usr/local/lib/z-push/config.php
|
||||
sed -i "s/define('USE_FULLEMAIL_FOR_LOGIN', .*/define('USE_FULLEMAIL_FOR_LOGIN', true);/" /usr/local/lib/z-push/config.php
|
||||
|
||||
# Configure BACKEND
|
||||
rm -f /usr/local/lib/z-push/backend/combined/config.php
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import sys, getpass, urllib.request, urllib.error, json
|
||||
|
||||
def mgmt(cmd, data=None, is_json=False):
|
||||
mgmt_uri = 'http://localhost:10222'
|
||||
# The base URL for the management daemon. (Listens on IPv4 only.)
|
||||
mgmt_uri = 'http://127.0.0.1:10222'
|
||||
|
||||
setup_key_auth(mgmt_uri)
|
||||
|
||||
@@ -58,6 +59,7 @@ if len(sys.argv) < 2:
|
||||
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.")
|
||||
@@ -66,11 +68,13 @@ if len(sys.argv) < 2:
|
||||
elif sys.argv[1] == "user" and len(sys.argv) == 2:
|
||||
# Dump a list of users, one per line. Mark admins with an asterisk.
|
||||
users = mgmt("/mail/users?format=json", is_json=True)
|
||||
for user in users:
|
||||
print(user['email'], end='')
|
||||
if "admin" in user['privileges']:
|
||||
print("*", end='')
|
||||
print()
|
||||
for domain in users:
|
||||
for user in domain["users"]:
|
||||
if user['status'] == 'inactive': continue
|
||||
print(user['email'], end='')
|
||||
if "admin" in user['privileges']:
|
||||
print("*", end='')
|
||||
print()
|
||||
|
||||
elif sys.argv[1] == "user" and sys.argv[2] in ("add", "password"):
|
||||
if len(sys.argv) < 5:
|
||||
@@ -100,9 +104,10 @@ elif sys.argv[1] == "user" and sys.argv[2] in ("make-admin", "remove-admin") and
|
||||
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'])
|
||||
for domain in users:
|
||||
for user in domain["users"]:
|
||||
if "admin" in user['privileges']:
|
||||
print(user['email'])
|
||||
|
||||
elif sys.argv[1] == "alias" and len(sys.argv) == 2:
|
||||
print(mgmt("/mail/aliases"))
|
||||
|
||||
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.
|
||||
481
tools/readable_bash.py
Normal file
481
tools/readable_bash.py
Normal file
@@ -0,0 +1,481 @@
|
||||
#!/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-top: .25em;
|
||||
margin-bottom: .75em;
|
||||
}
|
||||
p {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.intro p {
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
li {
|
||||
margin-bottom: .33em;
|
||||
}
|
||||
|
||||
.sourcefile {
|
||||
padding-top: 1.5em;
|
||||
padding-bottom: 1em;
|
||||
font-size: 90%;
|
||||
text-align: right;
|
||||
}
|
||||
.sourcefile a {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.instructions .row.contd {
|
||||
border-top: 1px solid #E0E0E0;
|
||||
}
|
||||
|
||||
.prose {
|
||||
padding-top: 1em;
|
||||
padding-bottom: 1em;
|
||||
}
|
||||
.terminal {
|
||||
background-color: #EEE;
|
||||
padding-top: 1em;
|
||||
padding-bottom: 1em;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: 1.25em;
|
||||
}
|
||||
|
||||
pre {
|
||||
color: black;
|
||||
border: 0;
|
||||
background: none;
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
div.write-to {
|
||||
margin: 0 0 1em .5em;
|
||||
}
|
||||
div.write-to p {
|
||||
padding: .5em;
|
||||
margin: 0;
|
||||
}
|
||||
div.write-to .filename {
|
||||
padding: .25em .5em;
|
||||
background-color: #666;
|
||||
color: white;
|
||||
font-family: monospace;
|
||||
font-weight: bold;
|
||||
}
|
||||
div.write-to .filename span {
|
||||
font-family: sans-serif;
|
||||
font-weight: normal;
|
||||
}
|
||||
div.write-to pre {
|
||||
margin: 0;
|
||||
padding: .5em;
|
||||
border: 1px solid #999;
|
||||
border-radius: 0;
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
pre.shell > div:before {
|
||||
content: "$ ";
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="row intro">
|
||||
<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.</p>
|
||||
<p>This document is generated automatically from <a href="https://mailinabox.email">Mail-in-a-Box</a>’s setup script <a href="https://github.com/mail-in-a-box/mailinabox">source code</a>.</p>
|
||||
<hr>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container instructions">
|
||||
""")
|
||||
|
||||
parser = Source.parser()
|
||||
for line in open("setup/start.sh"):
|
||||
try:
|
||||
fn = parser.parse_string(line).filename()
|
||||
except:
|
||||
continue
|
||||
if fn in ("setup/start.sh", "setup/preflight.sh", "setup/questions.sh", "setup/firstuser.sh", "setup/management.sh"):
|
||||
continue
|
||||
|
||||
import sys
|
||||
print(fn, file=sys.stderr)
|
||||
|
||||
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>
|
||||
<script>
|
||||
$(function() {
|
||||
$('.terminal').each(function() {
|
||||
$(this).outerHeight( $(this).parent().innerHeight() );
|
||||
});
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""")
|
||||
|
||||
class HashBang(Grammar):
|
||||
grammar = (L('#!'), REST_OF_LINE, EOL)
|
||||
def value(self):
|
||||
return ""
|
||||
|
||||
def strip_indent(s):
|
||||
s = s.replace("\t", " ")
|
||||
lines = s.split("\n")
|
||||
try:
|
||||
min_indent = min(len(re.match(r"\s*", line).group(0)) for line in lines if len(line) > 0)
|
||||
except ValueError:
|
||||
# No non-empty lines.
|
||||
min_indent = 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 '), L('>') | L('>>'), L(' '), ANY_EXCEPT(WHITESPACE), L(" <<"), OPTIONAL(SPACE), L("EOF"), EOL, REPEAT(ANY, greedy=False), EOL, L("EOF"), EOL)
|
||||
def value(self):
|
||||
content = self[9].string
|
||||
content = re.sub(r"\\([$])", r"\1", content) # un-escape bash-escaped characters
|
||||
return "<div class='write-to'><div class='filename'>%s <span>(%s)</span></div><pre>%s</pre></div>\n" \
|
||||
% (self[4].string,
|
||||
"overwrite" if ">>" not in self[2].string else "append to",
|
||||
cgi.escape(content))
|
||||
|
||||
class HideOutput(Grammar):
|
||||
grammar = (L("hide_output "), REF("BashElement"))
|
||||
def value(self):
|
||||
return self[1].value()
|
||||
|
||||
class EchoLine(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>" + recode_bash(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 = []
|
||||
eq = "="
|
||||
if self[3] and "-s" in self[3].string: eq = " "
|
||||
for opt in re.split("\s+", self[4].string):
|
||||
k, v = opt.split("=", 1)
|
||||
v = re.sub(r"\n+", "", fixup_tokens(v)) # not sure why newlines are getting doubled
|
||||
options.append("%s%s%s" % (k, eq, v))
|
||||
return "<div class='write-to'><div class='filename'>" + self[1].string + " <span>(change settings)</span></div><pre>" + "\n".join(cgi.escape(s) for s in options) + "</pre></div>\n"
|
||||
|
||||
class CaptureOutput(Grammar):
|
||||
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"
|
||||
|
||||
class EchoPipe(Grammar):
|
||||
grammar = OPTIONAL(SPACE), L("echo "), REST_OF_LINE, L(' | '), REST_OF_LINE, EOL
|
||||
def value(self):
|
||||
text = " ".join("\"%s\"" % s for s in self[2].string.split(" "))
|
||||
return "<pre class='shell'><div>echo " + recode_bash(text) + " \<br> | " + recode_bash(self[4].string) + "</div></pre>\n"
|
||||
|
||||
def shell_line(bash):
|
||||
return "<pre class='shell'><div>" + recode_bash(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 ""
|
||||
if "source setup/functions.sh" in self.string: return ""
|
||||
if "source /etc/mailinabox.conf" in self.string: return ""
|
||||
return "<pre class='shell'><div>" + recode_bash(self.string.strip()) + "</div></pre>\n"
|
||||
|
||||
class BashElement(Grammar):
|
||||
grammar = Comment | CatEOF | EchoPipe | EchoLine | HideOutput | EditConf | SedReplace | AptGet | UfwAllow | RestartService | OtherLine
|
||||
def value(self):
|
||||
return self[0].value()
|
||||
|
||||
# Make some special characters to private use Unicode code points.
|
||||
bash_special_characters1 = {
|
||||
"\n": "\uE000",
|
||||
" ": "\uE001",
|
||||
}
|
||||
bash_special_characters2 = {
|
||||
"$": "\uE010",
|
||||
}
|
||||
bash_escapes = {
|
||||
"n": "\uE020",
|
||||
"t": "\uE021",
|
||||
}
|
||||
|
||||
def quasitokenize(bashscript):
|
||||
# Make a parse of bash easier by making the tokenization easy.
|
||||
newscript = ""
|
||||
quote_mode = None
|
||||
escape_next = False
|
||||
line_comment = False
|
||||
subshell = 0
|
||||
for c in bashscript:
|
||||
if line_comment:
|
||||
# We're in a comment until the end of the line.
|
||||
newscript += c
|
||||
if c == '\n':
|
||||
line_comment = False
|
||||
elif escape_next:
|
||||
# Previous character was a \. Normally the next character
|
||||
# comes through literally, but escaped newlines are line
|
||||
# continuations and some escapes are for special characters
|
||||
# which we'll recode and then turn back into escapes later.
|
||||
if c == "\n":
|
||||
c = " "
|
||||
elif c in bash_escapes:
|
||||
c = bash_escapes[c]
|
||||
newscript += c
|
||||
escape_next = False
|
||||
elif c == "\\":
|
||||
# Escaping next character.
|
||||
escape_next = True
|
||||
elif quote_mode is None and c in ('"', "'"):
|
||||
# Starting a quoted word.
|
||||
quote_mode = c
|
||||
elif c == quote_mode:
|
||||
# Ending a quoted word.
|
||||
quote_mode = None
|
||||
elif quote_mode is not None and quote_mode != "EOF" and c in bash_special_characters1:
|
||||
# Replace special tokens within quoted words so that they
|
||||
# don't interfere with tokenization later.
|
||||
newscript += bash_special_characters1[c]
|
||||
elif quote_mode is None and c == '#':
|
||||
# Start of a line comment.
|
||||
newscript += c
|
||||
line_comment = True
|
||||
elif quote_mode is None and c == ';' and subshell == 0:
|
||||
# End of a statement.
|
||||
newscript += "\n"
|
||||
elif quote_mode is None and c == '(':
|
||||
# Start of a subshell.
|
||||
newscript += c
|
||||
subshell += 1
|
||||
elif quote_mode is None and c == ')':
|
||||
# End of a subshell.
|
||||
newscript += c
|
||||
subshell -= 1
|
||||
elif quote_mode is None and c == '\t':
|
||||
# Make these just spaces.
|
||||
if newscript[-1] != " ":
|
||||
newscript += " "
|
||||
elif quote_mode is None and c == ' ':
|
||||
# Collapse consecutive spaces.
|
||||
if newscript[-1] != " ":
|
||||
newscript += " "
|
||||
elif c in bash_special_characters2:
|
||||
newscript += bash_special_characters2[c]
|
||||
else:
|
||||
# All other characters.
|
||||
newscript += c
|
||||
|
||||
# "<< EOF" escaping.
|
||||
if quote_mode is None and re.search("<<\s*EOF\n$", newscript):
|
||||
quote_mode = "EOF"
|
||||
elif quote_mode == "EOF" and re.search("\nEOF\n$", newscript):
|
||||
quote_mode = None
|
||||
|
||||
return newscript
|
||||
|
||||
def recode_bash(s):
|
||||
def requote(tok):
|
||||
tok = tok.replace("\\", "\\\\")
|
||||
for c in bash_special_characters2:
|
||||
tok = tok.replace(c, "\\" + c)
|
||||
tok = fixup_tokens(tok)
|
||||
if " " in tok or '"' in tok:
|
||||
tok = tok.replace("\"", "\\\"")
|
||||
tok = '"' + tok +'"'
|
||||
else:
|
||||
tok = tok.replace("'", "\\'")
|
||||
return tok
|
||||
return cgi.escape(" ".join(requote(tok) for tok in s.split(" ")))
|
||||
|
||||
def fixup_tokens(s):
|
||||
for c, enc in bash_special_characters1.items():
|
||||
s = s.replace(enc, c)
|
||||
for c, enc in bash_special_characters2.items():
|
||||
s = s.replace(enc, c)
|
||||
for esc, c in bash_escapes.items():
|
||||
s = s.replace(c, "\\" + esc)
|
||||
return s
|
||||
|
||||
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 ""
|
||||
string = open(fn).read()
|
||||
|
||||
# tokenize
|
||||
string = re.sub(".* #NODOC\n", "", string)
|
||||
string = re.sub("\n\s*if .*then.*|\n\s*fi|\n\s*else|\n\s*elif .*", "", string)
|
||||
string = quasitokenize(string)
|
||||
string = re.sub("hide_output ", "", string)
|
||||
|
||||
parser = BashScript.parser()
|
||||
result = parser.parse_string(string)
|
||||
|
||||
v = "<div class='row'><div class='col-xs-12 sourcefile'>view the bash source for the following section at <a href=\"%s\">%s</a></div></div>\n" \
|
||||
% ("https://github.com/mail-in-a-box/mailinabox/tree/master/" + fn, fn)
|
||||
|
||||
mode = 0
|
||||
for item in result.value():
|
||||
if item.strip() == "":
|
||||
pass
|
||||
elif item.startswith("<p") and not item.startswith("<pre"):
|
||||
clz = ""
|
||||
if mode == 2:
|
||||
v += "</div>\n" # col
|
||||
v += "</div>\n" # row
|
||||
mode = 0
|
||||
clz = "contd"
|
||||
if mode == 0:
|
||||
v += "<div class='row %s'>\n" % clz
|
||||
v += "<div class='col-md-6 prose'>\n"
|
||||
v += item
|
||||
mode = 1
|
||||
elif item.startswith("<h"):
|
||||
if mode != 0:
|
||||
v += "</div>\n" # col
|
||||
v += "</div>\n" # row
|
||||
v += "<div class='row'>\n"
|
||||
v += "<div class='col-md-6 header'>\n"
|
||||
v += item
|
||||
v += "</div>\n" # col
|
||||
v += "<div class='col-md-6 terminal'> </div>\n"
|
||||
v += "</div>\n" # row
|
||||
mode = 0
|
||||
else:
|
||||
if mode == 0:
|
||||
v += "<div class='row'>\n"
|
||||
v += "<div class='col-md-offset-6 col-md-6 terminal'>\n"
|
||||
elif mode == 1:
|
||||
v += "</div>\n"
|
||||
v += "<div class='col-md-6 terminal'>\n"
|
||||
mode = 2
|
||||
v += item
|
||||
|
||||
v += "</div>\n" # col
|
||||
v += "</div>\n" # row
|
||||
|
||||
v = fixup_tokens(v)
|
||||
|
||||
v = v.replace("</pre>\n<pre class='shell'>", "")
|
||||
v = re.sub("<pre>([\w\W]*?)</pre>", lambda m : "<pre>" + strip_indent(m.group(1)) + "</pre>", v)
|
||||
|
||||
v = re.sub(r"(\$?)PRIMARY_HOSTNAME", r"<b>box.yourdomain.com</b>", v)
|
||||
v = re.sub(r"\$STORAGE_ROOT", r"<b>$STORE</b>", v)
|
||||
v = re.sub(r"\$CSR_COUNTRY", r"<b>US</b>", 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,46 +0,0 @@
|
||||
<?php
|
||||
$resource = '';
|
||||
|
||||
if(isset($_GET['resource'])){
|
||||
$resource = $_GET['resource'];
|
||||
}
|
||||
|
||||
// Parse our configuration file to get the STORAGE_ROOT.
|
||||
$STORAGE_ROOT = NULL;
|
||||
foreach (file("/etc/mailinabox.conf") as $line) {
|
||||
$line = explode("=", rtrim($line), 2);
|
||||
if ($line[0] == "STORAGE_ROOT") {
|
||||
$STORAGE_ROOT = $line[1];
|
||||
}
|
||||
}
|
||||
if ($STORAGE_ROOT == NULL) exit("no STORAGE_ROOT");
|
||||
|
||||
// Turn the resource into a file path. First URL-encode the resource
|
||||
// so that it is filepath-safe.
|
||||
$fn = urlencode($resource);
|
||||
|
||||
// Replace the first colon (it's URL-encoded) with a slash since we'll
|
||||
// break off the files into scheme subdirectories.
|
||||
$fn = preg_replace("/%3A/", "/", $fn, 1);
|
||||
|
||||
// Since this is often for email addresses, un-escape @-signs so they
|
||||
// are not odd-looking. It's filename-safe anyway.
|
||||
$fn = preg_replace("/%40/", "@", $fn);
|
||||
|
||||
// Combine with root path.
|
||||
$fn = $STORAGE_ROOT . "/webfinger/" . $fn . ".json";
|
||||
|
||||
// See if the file exists.
|
||||
if (!file_exists($fn)) {
|
||||
header("HTTP/1.0 404 Not Found");
|
||||
exit;
|
||||
}
|
||||
|
||||
header("Content-type: application/json");
|
||||
echo file_get_contents($fn);
|
||||
|
||||
//json_encode(array(
|
||||
// subject => $resource,
|
||||
//), JSON_PRETTY_PRINT);
|
||||
?>
|
||||
|
||||
Reference in New Issue
Block a user