mirror of
https://github.com/mail-in-a-box/mailinabox.git
synced 2026-03-13 17:17:23 +01:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e004041de2 | ||
|
|
4eb9af2ebd | ||
|
|
51d89a780d | ||
|
|
299a5c6355 |
120
CHANGELOG.md
120
CHANGELOG.md
@@ -1,129 +1,18 @@
|
|||||||
CHANGELOG
|
CHANGELOG
|
||||||
=========
|
=========
|
||||||
|
|
||||||
v0.14 (November 4, 2015)
|
In Development
|
||||||
------------------------
|
--------------
|
||||||
|
|
||||||
Mail:
|
|
||||||
|
|
||||||
* Spamassassin's network-based tests (Pyzor, others) and DKIM tests are now enabled. (Pyzor had always been installed but was not active due to a misconfiguration.)
|
|
||||||
* Moving spam out of the Spam folder and into Trash would incorrectly train Spamassassin that those messages were not spam.
|
|
||||||
* Automatically create the Sent and Archive folders for new users.
|
|
||||||
* The HTML5_Notifier plugin for Roundcube is now included, which when turned on in Roundcube settings provides desktop notifications for new mail.
|
|
||||||
* The Exchange/ActiveSync backend Z-Push has been updated to fix a problem with CC'd emails not being sent to the CC recipients.
|
|
||||||
|
|
||||||
Calender/Contacts:
|
|
||||||
|
|
||||||
* CalDAV/CardDAV and Exchange/ActiveSync for calendar/contacts wasn't working in some network configurations.
|
|
||||||
|
|
||||||
Web:
|
|
||||||
|
|
||||||
* When a new domain is added to the box, rather than applying a new self-signed certificate for that domain, the SSL certificate for the box's primary hostname will be used instead.
|
|
||||||
* If a custom DNS record is set on a domain or 'www'+domain, web would not be served for that domain. If the custom DNS record is just the box's IP address, that's a configuration mistake, but allow it and let web continue to be served.
|
|
||||||
* Accommodate really long domain names by increasing an nginx setting.
|
|
||||||
|
|
||||||
Control panel:
|
|
||||||
|
|
||||||
* Added an option to check for new Mail-in-a-Box versions within status checks. It is off by default so that boxes don't "phone home" without permission.
|
|
||||||
* Added a random password generator on the users page to simplify creating new accounts.
|
|
||||||
* When S3 backup credentials are set, the credentials are now no longer ever sent back from the box to the client, for better security.
|
|
||||||
* Fixed the jumpiness when a modal is displayed.
|
|
||||||
* Focus is put into the login form fields when the login form is displayed.
|
|
||||||
* Status checks now include a warning if a custom DNS record has been set on a domain that would normally serve web and as a result that domain no longer is serving web.
|
|
||||||
* Status checks now check that secondary nameservers, if specified, are actually serving the domains.
|
|
||||||
* Some errors in the control panel when there is invalid data in the database or an improperly named archived user account have been suppressed.
|
|
||||||
* Added subresource integrity attributes to all remotely-sourced resources (i.e. via CDNs) to guard against CDNs being used as an attack vector.
|
|
||||||
|
|
||||||
System:
|
|
||||||
|
|
||||||
* Tweaks to fail2ban settings.
|
|
||||||
* Fixed a spurrious warning while installing munin.
|
|
||||||
|
|
||||||
v0.13b (August 30, 2015)
|
|
||||||
------------------------
|
|
||||||
|
|
||||||
Another ownCloud 8.1.1 issue was found. New installations left ownCloud improperly setup ("You are accessing the server from an untrusted domain."). Upgrading to this version will fix that.
|
|
||||||
|
|
||||||
v0.13a (August 23, 2015)
|
|
||||||
------------------------
|
|
||||||
|
|
||||||
Note: v0.13 (no 'a', August 19, 2015) was pulled immediately due to an ownCloud bug that prevented upgrades. v0.13a works around that problem.
|
|
||||||
|
|
||||||
Mail:
|
|
||||||
|
|
||||||
* Outbound mail headers (the Recieved: header) are tweaked to possibly improve deliverability.
|
|
||||||
* Some MIME messages would hang Roundcube due to a missing package.
|
|
||||||
* The users permitted to send as an alias can now be different from where an alias forwards to.
|
|
||||||
|
|
||||||
DNS:
|
|
||||||
|
|
||||||
* The secondary nameservers option in the control panel now accepts more than one nameserver and a special xfr:IP format to specify zone-transfer-only IP addresses.
|
|
||||||
* A TLSA record is added for HTTPS for DNSSEC-aware clients that support it.
|
|
||||||
|
|
||||||
System:
|
|
||||||
|
|
||||||
* Backups can now be turned off, or stored in Amazon S3, through new control panel options.
|
|
||||||
* Munin was not working on machines confused about their hostname and had lots of errors related to PANGO, NTP peers and network interfaces that were not up.
|
|
||||||
* ownCloud updated to version 8.1.1 (with upgrade work-around), its memcached caching enabled.
|
|
||||||
* When upgrading, network checks like blocked port 25 are now skipped.
|
|
||||||
* Tweaks to the intrusion detection rules for IMAP.
|
|
||||||
* Mail-in-a-Box's setup is a lot quieter, hiding lots of irrelevant messages.
|
|
||||||
|
|
||||||
Control panel:
|
|
||||||
|
|
||||||
* SSL certificate checks were failing on OVH/OpenVZ servers due to missing /dev/stdin.
|
|
||||||
* Improve the sort order of the domains in the status checks.
|
|
||||||
* Some links in the control panel were only working in Chrome.
|
|
||||||
|
|
||||||
v0.12c (July 19, 2015)
|
|
||||||
----------------------
|
|
||||||
|
|
||||||
v0.12c was posted to work around the current Sourceforge.net outage: pyzor's remote server is now hard-coded rather than accessing a file hosted on Sourceforge, and roundcube is now downloaded from a Mail-in-a-Box mirror rather than from Sourceforge.
|
|
||||||
|
|
||||||
v0.12b (July 4, 2015)
|
|
||||||
---------------------
|
|
||||||
|
|
||||||
This version corrects a minor regression in v0.12 related to creating aliases targetting multiple addresses.
|
|
||||||
|
|
||||||
v0.12 (July 3, 2015)
|
|
||||||
--------------------
|
|
||||||
|
|
||||||
This is a minor update to v0.11, which was a major update. Please read v0.11's advisories.
|
|
||||||
|
|
||||||
* The administrator@ alias was incorrectly created starting with v0.11. If your first install was v0.11, check that the administrator@ alias forwards mail to you.
|
|
||||||
* Intrusion detection rules (fail2ban) are relaxed (i.e. less is blocked).
|
|
||||||
* SSL certificates could not be installed for the new automatic 'www.' redirect domains.
|
|
||||||
* PHP's default character encoding is changed from no default to UTF8. The effect of this change is unclear but should prevent possible future text conversion issues.
|
|
||||||
* User-installed SSL private keys in the BEGIN PRIVATE KEY format were not accepted.
|
|
||||||
* SSL certificates with SAN domains with IDNA encoding were broken in v0.11.
|
|
||||||
* Some IDNA functionality was using IDNA 2003 rather than IDNA 2008.
|
|
||||||
|
|
||||||
v0.11b (June 29, 2015)
|
|
||||||
----------------------
|
|
||||||
|
|
||||||
v0.11b was posted shortly after the initial posting of v0.11 to correct a missing dependency for the new PPA.
|
|
||||||
|
|
||||||
v0.11 (June 29, 2015)
|
|
||||||
---------------------
|
|
||||||
|
|
||||||
Advisories:
|
|
||||||
* Users can no longer spoof arbitrary email addresses in outbound mail. When sending mail, the email address configured in your mail client must match the SMTP login username being used, or the email address must be an alias with the SMTP login username listed as one of the alias's targets.
|
|
||||||
* This update replaces your DKIM signing key with a stronger key. Because of DNS caching/propagation, mail sent within a few hours after this update could be marked as spam by recipients. If you use External DNS, you will need to update your DNS records.
|
|
||||||
* The box will now install software from a new Mail-in-a-Box PPA on Launchpad.net, where we are distributing two of our own packages: a patched postgrey and dovecot-lucene.
|
|
||||||
|
|
||||||
Mail:
|
Mail:
|
||||||
* Greylisting will now let some reputable senders pass through immediately.
|
* Greylisting will now let some reputable senders pass through immediately.
|
||||||
* Searching mail (via IMAP) will now be much faster using the dovecot lucene full text search plugin.
|
* Searching mail (via IMAP) will now be much faster using the dovecot lucene full text search plugin.
|
||||||
* Users can no longer spoof arbitrary email addresses in outbound mail (see above).
|
|
||||||
* Fix for deleting admin@ and postmaster@ addresses.
|
* Fix for deleting admin@ and postmaster@ addresses.
|
||||||
* Roundcube is updated to version 1.1.2, plugins updated.
|
* Roundcube is updated to version 1.1.2, plugins updated.
|
||||||
* Exchange/ActiveSync autoconfiguration was not working on all devices (e.g. iPhone) because of a case-sensitive URL.
|
|
||||||
* The DKIM signing key has been increased to 2048 bits, from 1024, replacing the existing key.
|
|
||||||
|
|
||||||
Web:
|
Web:
|
||||||
* 'www' subdomains now automatically redirect to their parent domain (but you'll need to install an SSL certificate).
|
* 'www' subdomains now automatically redirect to their parent domain (but you'll need to install an SSL certificate).
|
||||||
* OCSP no longer uses Google Public DNS.
|
* OCSP no longer uses Google Public DNS.
|
||||||
* The installed PHP version is no longer exposed through HTTP response headers, for better security.
|
|
||||||
|
|
||||||
DNS:
|
DNS:
|
||||||
* Default IPv6 AAAA records were missing since version 0.09.
|
* Default IPv6 AAAA records were missing since version 0.09.
|
||||||
@@ -131,13 +20,10 @@ DNS:
|
|||||||
Control panel:
|
Control panel:
|
||||||
* Resetting a user's password now forces them to log in again everywhere.
|
* Resetting a user's password now forces them to log in again everywhere.
|
||||||
* Status checks were not working if an ssh server was not installed.
|
* Status checks were not working if an ssh server was not installed.
|
||||||
* SSL certificate validation now uses the Python cryptography module in some places where openssl was used.
|
|
||||||
* There is a new tab to show the installed version of Mail-in-a-Box and to fetch the latest released version.
|
|
||||||
|
|
||||||
System:
|
System:
|
||||||
* The munin system monitoring tool is now installed and accessible at /admin/munin.
|
* The munin system monitoring tool is now installed and accessible at /admin/munin.
|
||||||
* ownCloud updated to version 8.0.4. The ownCloud installation step now is reslient to download problems. The ownCloud configuration file is now stored in STORAGE_ROOT to fix loss of data when moving STORAGE_ROOT to a new machine.
|
* ownCloud updated to version 8.0.4.
|
||||||
* The setup scripts now run `apt-get update` prior to installing anything to ensure the apt database is in sync with the packages actually available.
|
|
||||||
|
|
||||||
|
|
||||||
v0.10 (June 1, 2015)
|
v0.10 (June 1, 2015)
|
||||||
|
|||||||
57
Dockerfile
Normal file
57
Dockerfile
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Mail-in-a-Box Dockerfile
|
||||||
|
###########################
|
||||||
|
#
|
||||||
|
# This file lets Mail-in-a-Box run inside of Docker (https://docker.io),
|
||||||
|
# a virtualization/containerization manager.
|
||||||
|
#
|
||||||
|
# Run:
|
||||||
|
# $ containers/docker/run.sh
|
||||||
|
# to build the image, launch a storage container, and launch a Mail-in-a-Box
|
||||||
|
# container.
|
||||||
|
#
|
||||||
|
###########################################
|
||||||
|
|
||||||
|
# We need a better starting image than docker's ubuntu image because that
|
||||||
|
# base image doesn't provide enough to run most Ubuntu services. See
|
||||||
|
# http://phusion.github.io/baseimage-docker/ for an explanation.
|
||||||
|
|
||||||
|
FROM phusion/baseimage:0.9.16
|
||||||
|
|
||||||
|
# Dockerfile metadata.
|
||||||
|
MAINTAINER Joshua Tauberer (http://razor.occams.info)
|
||||||
|
EXPOSE 25 53/udp 53/tcp 80 443 587 993 4190
|
||||||
|
VOLUME /home/user-data
|
||||||
|
|
||||||
|
# Use baseimage's init system. A correct init process is required for
|
||||||
|
# process #1 in order to have a functioning Linux system.
|
||||||
|
CMD ["/sbin/my_init"]
|
||||||
|
|
||||||
|
# Create the user-data user, so the start script doesn't have to.
|
||||||
|
RUN useradd -m user-data
|
||||||
|
|
||||||
|
# Docker has a beautiful way to cache images after each step. The next few
|
||||||
|
# steps of installing system packages are very intensive, so we take care
|
||||||
|
# of them early and let docker cache the image after that, before doing
|
||||||
|
# any Mail-in-a-Box specific system configuration. That makes rebuilds
|
||||||
|
# of the image extremely fast.
|
||||||
|
|
||||||
|
# Update system packages.
|
||||||
|
RUN apt-get update
|
||||||
|
RUN DEBIAN_FRONTEND=noninteractive apt-get upgrade -y
|
||||||
|
|
||||||
|
# Install packages needed by Mail-in-a-Box.
|
||||||
|
ADD containers/docker/apt_package_list.txt /tmp/mailinabox_apt_package_list.txt
|
||||||
|
RUN DEBIAN_FRONTEND=noninteractive apt-get install -y $(cat /tmp/mailinabox_apt_package_list.txt)
|
||||||
|
|
||||||
|
# from questions.sh -- needs merging into the above line
|
||||||
|
RUN DEBIAN_FRONTEND=noninteractive apt-get install -y dialog python3 python3-pip
|
||||||
|
RUN pip3 install "email_validator==0.1.0-rc4"
|
||||||
|
|
||||||
|
# Now add Mail-in-a-Box to the system.
|
||||||
|
ADD . /usr/local/mailinabox
|
||||||
|
|
||||||
|
# Configure runit services.
|
||||||
|
RUN /usr/local/mailinabox/containers/docker/tools/configure_services.sh
|
||||||
|
|
||||||
|
# Add my_init scripts
|
||||||
|
ADD containers/docker/my_init.d/* /etc/my_init.d/
|
||||||
45
README.md
45
README.md
@@ -24,7 +24,7 @@ The Box
|
|||||||
|
|
||||||
Mail-in-a-Box turns a fresh Ubuntu 14.04 LTS 64-bit machine into a working mail server by installing and configuring various components.
|
Mail-in-a-Box turns a fresh Ubuntu 14.04 LTS 64-bit machine into a working mail server by installing and configuring various components.
|
||||||
|
|
||||||
It is a one-click email appliance. There are no user-configurable setup options. It "just works".
|
It is a one-click email appliance (see the [setup guide](https://mailinabox.email/guide.html)). There are no user-configurable setup options. It "just works".
|
||||||
|
|
||||||
The components installed are:
|
The components installed are:
|
||||||
|
|
||||||
@@ -32,55 +32,39 @@ The components installed are:
|
|||||||
* Webmail ([Roundcube](http://roundcube.net/)), static website hosting ([nginx](http://nginx.org/))
|
* Webmail ([Roundcube](http://roundcube.net/)), static website hosting ([nginx](http://nginx.org/))
|
||||||
* Spam filtering ([spamassassin](https://spamassassin.apache.org/)), greylisting ([postgrey](http://postgrey.schweikert.ch/))
|
* Spam filtering ([spamassassin](https://spamassassin.apache.org/)), greylisting ([postgrey](http://postgrey.schweikert.ch/))
|
||||||
* DNS ([nsd4](http://www.nlnetlabs.nl/projects/nsd/)) with [SPF](https://en.wikipedia.org/wiki/Sender_Policy_Framework), DKIM ([OpenDKIM](http://www.opendkim.org/)), [DMARC](https://en.wikipedia.org/wiki/DMARC), [DNSSEC](https://en.wikipedia.org/wiki/DNSSEC), [DANE TLSA](https://en.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities), and [SSHFP](https://tools.ietf.org/html/rfc4255) records automatically set
|
* DNS ([nsd4](http://www.nlnetlabs.nl/projects/nsd/)) with [SPF](https://en.wikipedia.org/wiki/Sender_Policy_Framework), DKIM ([OpenDKIM](http://www.opendkim.org/)), [DMARC](https://en.wikipedia.org/wiki/DMARC), [DNSSEC](https://en.wikipedia.org/wiki/DNSSEC), [DANE TLSA](https://en.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities), and [SSHFP](https://tools.ietf.org/html/rfc4255) records automatically set
|
||||||
* Backups ([duplicity](http://duplicity.nongnu.org/)), firewall ([ufw](https://launchpad.net/ufw)), intrusion protection ([fail2ban](http://www.fail2ban.org/wiki/index.php/Main_Page)), system monitoring ([munin](http://munin-monitoring.org/))
|
* Firewall ([ufw](https://launchpad.net/ufw)), intrusion protection ([fail2ban](http://www.fail2ban.org/wiki/index.php/Main_Page)), system monitoring ([munin](http://munin-monitoring.org/))
|
||||||
|
|
||||||
It also includes:
|
It also includes:
|
||||||
|
|
||||||
* A control panel and API for adding/removing mail users, aliases, custom DNS records, etc. and detailed system monitoring.
|
* A control panel and API for adding/removing mail users, aliases, custom DNS records, etc. and detailed system monitoring.
|
||||||
* Our own builds of postgrey (adding better whitelisting) and dovecot-lucene (faster search for mail) distributed via the [Mail-in-a-Box PPA](https://launchpad.net/~mail-in-a-box/+archive/ubuntu/ppa) on Launchpad.
|
* Our own builds of postgrey and dovecot-lucene distributed via the [Mail-in-a-Box PPA](https://launchpad.net/~mail-in-a-box/+archive/ubuntu/ppa) on Launchpad.
|
||||||
|
|
||||||
For more information on how Mail-in-a-Box handles your privacy, see the [security details page](security.md).
|
For more information on how Mail-in-a-Box handles your privacy, see the [security details page](security.md).
|
||||||
|
|
||||||
Installation
|
The Security
|
||||||
------------
|
------------
|
||||||
|
|
||||||
See the [setup guide](https://mailinabox.email/guide.html) for detailed, user-friendly instructions.
|
See the [security guide](security.md) for more information about the box's security configuration (TLS, password storage, etc).
|
||||||
|
|
||||||
For experts, start with a completely fresh (really, I mean it) Ubuntu 14.04 LTS 64-bit machine. On the machine...
|
I sign the release tags on git. To verify that a tag is signed by me, you can perform the following steps:
|
||||||
|
|
||||||
Clone this repository:
|
|
||||||
|
|
||||||
$ git clone https://github.com/mail-in-a-box/mailinabox
|
|
||||||
$ cd mailinabox
|
|
||||||
|
|
||||||
_Optional:_ Download my PGP key and then verify that the sources were signed
|
|
||||||
by me:
|
|
||||||
|
|
||||||
|
# Download my PGP key.
|
||||||
$ curl -s https://keybase.io/joshdata/key.asc | gpg --import
|
$ curl -s https://keybase.io/joshdata/key.asc | gpg --import
|
||||||
gpg: key C10BDD81: public key "Joshua Tauberer <jt@occams.info>" imported
|
gpg: key C10BDD81: public key "Joshua Tauberer <jt@occams.info>" imported
|
||||||
|
|
||||||
$ git verify-tag v0.14
|
# Clone this repository.
|
||||||
|
$ git clone https://github.com/mail-in-a-box/mailinabox
|
||||||
|
$ cd mailinabox
|
||||||
|
|
||||||
|
# Verify the tag.
|
||||||
|
$ git verify-tag v0.10
|
||||||
gpg: Signature made ..... using RSA key ID C10BDD81
|
gpg: Signature made ..... using RSA key ID C10BDD81
|
||||||
gpg: Good signature from "Joshua Tauberer <jt@occams.info>"
|
gpg: Good signature from "Joshua Tauberer <jt@occams.info>"
|
||||||
gpg: WARNING: This key is not certified with a trusted signature!
|
gpg: WARNING: This key is not certified with a trusted signature!
|
||||||
gpg: There is no indication that the signature belongs to the owner.
|
gpg: There is no indication that the signature belongs to the owner.
|
||||||
Primary key fingerprint: 5F4C 0E73 13CC D744 693B 2AEA B920 41F4 C10B DD81
|
Primary key fingerprint: 5F4C 0E73 13CC D744 693B 2AEA B920 41F4 C10B DD81
|
||||||
|
|
||||||
You'll get a lot of warnings, but that's OK. Check that the primary key fingerprint matchs the
|
The key ID and fingerprint above should match my [Keybase.io key](https://keybase.io/joshdata) and the fingerprint I publish on [my homepage](https://razor.occams.info/).
|
||||||
fingerprint in the key details at [https://keybase.io/joshdata](https://keybase.io/joshdata)
|
|
||||||
and on my [personal homepage](https://razor.occams.info/). (Of course, if this repository has been compromised you can't trust these instructions.)
|
|
||||||
|
|
||||||
Checkout the tag corresponding to the most recent release:
|
|
||||||
|
|
||||||
$ git checkout v0.14
|
|
||||||
|
|
||||||
Begin the installation.
|
|
||||||
|
|
||||||
$ sudo setup/start.sh
|
|
||||||
|
|
||||||
For help, DO NOT contact me directly --- I don't do tech support by email or tweet (no exceptions).
|
|
||||||
|
|
||||||
Post your question on the [discussion forum](https://discourse.mailinabox.email/) instead, where me and other Mail-in-a-Box users may be able to help you.
|
|
||||||
|
|
||||||
The Acknowledgements
|
The Acknowledgements
|
||||||
--------------------
|
--------------------
|
||||||
@@ -96,4 +80,3 @@ The History
|
|||||||
* In August 2013 I began Mail-in-a-Box by combining my own mail server configuration with the setup in ["NSA-proof your email in 2 hours"](http://sealedabstract.com/code/nsa-proof-your-e-mail-in-2-hours/) and making the setup steps reproducible with bash scripts.
|
* In August 2013 I began Mail-in-a-Box by combining my own mail server configuration with the setup in ["NSA-proof your email in 2 hours"](http://sealedabstract.com/code/nsa-proof-your-e-mail-in-2-hours/) and making the setup steps reproducible with bash scripts.
|
||||||
* 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 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) 2014, [September](https://news.ycombinator.com/item?id=8276171) 2014, and [May](https://news.ycombinator.com/item?id=9624267) 2015.
|
* Mail-in-a-Box hit the front page of Hacker News in [April](https://news.ycombinator.com/item?id=7634514) 2014, [September](https://news.ycombinator.com/item?id=8276171) 2014, and [May](https://news.ycombinator.com/item?id=9624267) 2015.
|
||||||
* FastCompany mentioned Mail-in-a-Box a [roundup of privacy projects](http://www.fastcompany.com/3047645/your-own-private-cloud) on June 26, 2015.
|
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
## NOTE: This file is automatically generated by Mail-in-a-Box.
|
|
||||||
## Do not edit this file. It is continually updated by
|
|
||||||
## Mail-in-a-Box and your changes will be lost.
|
|
||||||
##
|
|
||||||
## Mail-in-a-Box machines are not meant to be modified.
|
|
||||||
## If you modify any system configuration you are on
|
|
||||||
## your own --- please do not ask for help from us.
|
|
||||||
|
|
||||||
namespace inbox {
|
|
||||||
# Automatically create & subscribe some folders.
|
|
||||||
# * Create and subscribe the INBOX folder.
|
|
||||||
# * Our sieve rule for spam expects that the Spam folder exists.
|
|
||||||
# * Z-Push must be configured with the same settings in conf/zpush/backend_imap.php (#580).
|
|
||||||
|
|
||||||
# MUA notes:
|
|
||||||
# * Roundcube will show an error if the user tries to delete a message before the Trash folder exists (#359).
|
|
||||||
# * K-9 mail will poll every 90 seconds if a Drafts folder does not exist.
|
|
||||||
# * Apple's OS X Mail app will create 'Sent Messages' if it doesn't see a folder with the \Sent flag (#571, #573) and won't be able to archive messages unless 'Archive' exists (#581).
|
|
||||||
# * Thunderbird's default in its UI is 'Archives' (plural) but it will configure new accounts to use whatever we say here (#581).
|
|
||||||
|
|
||||||
# auto:
|
|
||||||
# 'create' will automatically create this mailbox.
|
|
||||||
# 'subscribe' will both create and subscribe to the mailbox.
|
|
||||||
|
|
||||||
# special_use is a space separated list of IMAP SPECIAL-USE
|
|
||||||
# attributes as specified by RFC 6154:
|
|
||||||
# \All \Archive \Drafts \Flagged \Junk \Sent \Trash
|
|
||||||
|
|
||||||
mailbox INBOX {
|
|
||||||
auto = subscribe
|
|
||||||
}
|
|
||||||
mailbox Spam {
|
|
||||||
special_use = \Junk
|
|
||||||
auto = subscribe
|
|
||||||
}
|
|
||||||
mailbox Drafts {
|
|
||||||
special_use = \Drafts
|
|
||||||
auto = subscribe
|
|
||||||
}
|
|
||||||
mailbox Sent {
|
|
||||||
special_use = \Sent
|
|
||||||
auto = subscribe
|
|
||||||
}
|
|
||||||
mailbox Trash {
|
|
||||||
special_use = \Trash
|
|
||||||
auto = subscribe
|
|
||||||
}
|
|
||||||
mailbox Archive {
|
|
||||||
special_use = \Archive
|
|
||||||
auto = subscribe
|
|
||||||
}
|
|
||||||
|
|
||||||
# dovevot's standard mailboxes configuration file marks two sent folders
|
|
||||||
# with the \Sent attribute, just in case clients don't agree about which
|
|
||||||
# they're using. We'll keep that, plus add Junk as an alterative for Spam.
|
|
||||||
# These are not auto-created.
|
|
||||||
mailbox "Sent Messages" {
|
|
||||||
special_use = \Sent
|
|
||||||
}
|
|
||||||
mailbox Junk {
|
|
||||||
special_use = \Junk
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,34 @@
|
|||||||
# Fail2Ban configuration file for Mail-in-a-Box
|
# Fail2Ban configuration file.
|
||||||
|
# For Mail-in-a-Box
|
||||||
|
[DEFAULT]
|
||||||
|
|
||||||
|
# bantime in seconds
|
||||||
|
bantime = 60
|
||||||
|
|
||||||
|
# This should ban dumb brute-force attacks, not oblivious users.
|
||||||
|
findtime = 30
|
||||||
|
maxretry = 20
|
||||||
|
|
||||||
|
#
|
||||||
# JAILS
|
# JAILS
|
||||||
|
#
|
||||||
|
|
||||||
[ssh]
|
[ssh]
|
||||||
maxretry = 7
|
|
||||||
bantime = 3600
|
enabled = true
|
||||||
|
logpath = /var/log/auth.log
|
||||||
|
maxretry = 20
|
||||||
|
|
||||||
[ssh-ddos]
|
[ssh-ddos]
|
||||||
|
|
||||||
enabled = true
|
enabled = true
|
||||||
|
maxretry = 20
|
||||||
|
|
||||||
[sasl]
|
[sasl]
|
||||||
|
|
||||||
enabled = true
|
enabled = true
|
||||||
|
|
||||||
[dovecot]
|
[dovecot]
|
||||||
|
|
||||||
enabled = true
|
enabled = true
|
||||||
filter = dovecotimap
|
filter = dovecotimap
|
||||||
findtime = 30
|
|
||||||
maxretry = 20
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
# file upload limit to match the corresponding Postfix limit.
|
# file upload limit to match the corresponding Postfix limit.
|
||||||
client_max_body_size 128M;
|
client_max_body_size 128M;
|
||||||
}
|
}
|
||||||
location ~* ^/autodiscover/autodiscover.xml$ {
|
location /autodiscover/autodiscover.xml {
|
||||||
include fastcgi_params;
|
include fastcgi_params;
|
||||||
fastcgi_param SCRIPT_FILENAME /usr/local/lib/z-push/autodiscover/autodiscover.php;
|
fastcgi_param SCRIPT_FILENAME /usr/local/lib/z-push/autodiscover/autodiscover.php;
|
||||||
fastcgi_param PHP_VALUE "include_path=.:/usr/share/php:/usr/share/pear:/usr/share/awl/inc";
|
fastcgi_param PHP_VALUE "include_path=.:/usr/share/php:/usr/share/pear:/usr/share/awl/inc";
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
location ~ ^/((caldav|carddav|webdav).*)$ {
|
location ~ ^/((caldav|carddav|webdav).*)$ {
|
||||||
# Z-Push doesn't like getting a redirect, and a plain rewrite didn't work either.
|
# Z-Push doesn't like getting a redirect, and a plain rewrite didn't work either.
|
||||||
# Properly proxying like this seems to work fine.
|
# Properly proxying like this seems to work fine.
|
||||||
proxy_pass https://127.0.0.1/cloud/remote.php/$1;
|
proxy_pass https://$HOSTNAME/cloud/remote.php/$1;
|
||||||
}
|
}
|
||||||
rewrite ^/.well-known/host-meta /cloud/public.php?service=host-meta last;
|
rewrite ^/.well-known/host-meta /cloud/public.php?service=host-meta last;
|
||||||
rewrite ^/.well-known/host-meta.json /cloud/public.php?service=host-meta-json last;
|
rewrite ^/.well-known/host-meta.json /cloud/public.php?service=host-meta-json last;
|
||||||
|
|||||||
@@ -16,9 +16,7 @@
|
|||||||
#ssl_certificate_key /path/to/my-private-decrypted.key;
|
#ssl_certificate_key /path/to/my-private-decrypted.key;
|
||||||
|
|
||||||
# Tell browsers to require SSL (warning: difficult to change your mind)
|
# Tell browsers to require SSL (warning: difficult to change your mind)
|
||||||
# Handled by the management daemon because we can toggle this version or a
|
add_header Strict-Transport-Security max-age=31536000;
|
||||||
# preload version.
|
|
||||||
#add_header Strict-Transport-Security max-age=31536000;
|
|
||||||
|
|
||||||
# Prefer certain ciphersuites, to enforce Forward Secrecy and avoid known vulnerabilities.
|
# Prefer certain ciphersuites, to enforce Forward Secrecy and avoid known vulnerabilities.
|
||||||
#
|
#
|
||||||
@@ -38,7 +36,7 @@ ssl_protocols TLSv1.2 TLSv1.1 TLSv1;
|
|||||||
# as recommended by http://nginx.org/en/docs/http/configuring_https_servers.html
|
# as recommended by http://nginx.org/en/docs/http/configuring_https_servers.html
|
||||||
ssl_session_cache shared:SSL:10m;
|
ssl_session_cache shared:SSL:10m;
|
||||||
ssl_session_timeout 10m;
|
ssl_session_timeout 10m;
|
||||||
#keepalive_timeout 70; # in Ubuntu 14.04/nginx 1.4.6 the default is 65, so plenty good
|
keepalive_timeout 70;
|
||||||
|
|
||||||
# Buffer size of 1400 bytes fits in one MTU.
|
# Buffer size of 1400 bytes fits in one MTU.
|
||||||
# nginx 1.5.9+ ONLY
|
# nginx 1.5.9+ ONLY
|
||||||
@@ -71,6 +69,6 @@ ssl_dhparam STORAGE_ROOT/ssl/dh2048.pem;
|
|||||||
# 8.8.8.8 and 8.8.4.4 below are Google's public IPv4 DNS servers.
|
# 8.8.8.8 and 8.8.4.4 below are Google's public IPv4 DNS servers.
|
||||||
# nginx will use them to talk to the CA.
|
# nginx will use them to talk to the CA.
|
||||||
ssl_stapling on;
|
ssl_stapling on;
|
||||||
ssl_stapling_verify on;
|
ssl_stapling_verify off;
|
||||||
resolver 127.0.0.1 valid=86400;
|
resolver 127.0.0.1 valid=86400;
|
||||||
resolver_timeout 10;
|
resolver_timeout 10;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ server {
|
|||||||
|
|
||||||
ssl_certificate $SSL_CERTIFICATE;
|
ssl_certificate $SSL_CERTIFICATE;
|
||||||
ssl_certificate_key $SSL_KEY;
|
ssl_certificate_key $SSL_KEY;
|
||||||
|
include /etc/nginx/nginx-ssl.conf;
|
||||||
|
|
||||||
# ADDITIONAL DIRECTIVES HERE
|
# ADDITIONAL DIRECTIVES HERE
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Remove the first line of the Received: header. Note that we cannot fully remove the Received: header
|
# Remove the first line of the Received: header. Note that we cannot fully remove the Received: header
|
||||||
# because OpenDKIM requires that a header be present when signing outbound mail. The first line is
|
# because OpenDKIM requires that a header be present when signing outbound mail. The first line is
|
||||||
# where the user's home IP address would be.
|
# where the user's home IP address would be.
|
||||||
/^\s*Received:[^\n]*(.*)/ REPLACE Received: from authenticated-user (PRIMARY_HOSTNAME [PUBLIC_IP])$1
|
/^\s*Received:[^\n]*(.*)/ REPLACE Received: from authenticated-user (unknown [127.0.0.1])$1
|
||||||
|
|
||||||
# Remove other typically private information.
|
# Remove other typically private information.
|
||||||
/^\s*User-Agent:/ IGNORE
|
/^\s*User-Agent:/ IGNORE
|
||||||
|
|||||||
@@ -5,12 +5,10 @@
|
|||||||
* Descr : CalDAV backend configuration file
|
* Descr : CalDAV backend configuration file
|
||||||
************************************************/
|
************************************************/
|
||||||
|
|
||||||
define('CALDAV_PROTOCOL', 'https');
|
define('CALDAV_SERVER', 'https://localhost');
|
||||||
define('CALDAV_SERVER', 'localhost');
|
|
||||||
define('CALDAV_PORT', '443');
|
define('CALDAV_PORT', '443');
|
||||||
define('CALDAV_PATH', '/caldav/calendars/%u/');
|
define('CALDAV_PATH', '/caldav/calendars/%u/');
|
||||||
define('CALDAV_PERSONAL', 'PRINCIPAL');
|
define('CALDAV_PERSONAL', '');
|
||||||
define('CALDAV_SUPPORTS_SYNC', false);
|
define('CALDAV_SUPPORTS_SYNC', true);
|
||||||
define('CALDAV_MAX_SYNC_PERIOD', 2147483647);
|
|
||||||
|
|
||||||
?>
|
?>
|
||||||
|
|||||||
@@ -10,20 +10,6 @@ define('IMAP_PORT', 993);
|
|||||||
define('IMAP_OPTIONS', '/ssl/norsh/novalidate-cert');
|
define('IMAP_OPTIONS', '/ssl/norsh/novalidate-cert');
|
||||||
define('IMAP_DEFAULTFROM', '');
|
define('IMAP_DEFAULTFROM', '');
|
||||||
|
|
||||||
define('SYSTEM_MIME_TYPES_MAPPING', '/etc/mime.types');
|
|
||||||
define('IMAP_AUTOSEEN_ON_DELETE', false);
|
|
||||||
define('IMAP_FOLDER_CONFIGURED', true);
|
|
||||||
define('IMAP_FOLDER_PREFIX', '');
|
|
||||||
define('IMAP_FOLDER_PREFIX_IN_INBOX', false);
|
|
||||||
// see our conf/dovecot-mailboxes.conf file for IMAP special flags settings
|
|
||||||
define('IMAP_FOLDER_INBOX', 'INBOX');
|
|
||||||
define('IMAP_FOLDER_SENT', 'SENT');
|
|
||||||
define('IMAP_FOLDER_DRAFT', 'DRAFTS');
|
|
||||||
define('IMAP_FOLDER_TRASH', 'TRASH');
|
|
||||||
define('IMAP_FOLDER_SPAM', 'SPAM');
|
|
||||||
define('IMAP_FOLDER_ARCHIVE', 'ARCHIVE');
|
|
||||||
|
|
||||||
|
|
||||||
// not used
|
// not used
|
||||||
define('IMAP_FROM_SQL_DSN', '');
|
define('IMAP_FROM_SQL_DSN', '');
|
||||||
define('IMAP_FROM_SQL_USER', '');
|
define('IMAP_FROM_SQL_USER', '');
|
||||||
@@ -42,6 +28,12 @@ define('IMAP_FROM_LDAP_FIELDS', serialize(array('givenname', 'sn', 'mail')));
|
|||||||
define('IMAP_FROM_LDAP_FROM', '#givenname #sn <#mail>');
|
define('IMAP_FROM_LDAP_FROM', '#givenname #sn <#mail>');
|
||||||
|
|
||||||
|
|
||||||
|
// copy outgoing mail to this folder. If not set z-push will try the default folders
|
||||||
|
define('IMAP_SENTFOLDER', '');
|
||||||
|
define('IMAP_INLINE_FORWARD', true);
|
||||||
|
define('IMAP_EXCLUDED_FOLDERS', '');
|
||||||
|
define('IMAP_SMTP_METHOD', 'sendmail');
|
||||||
|
|
||||||
global $imap_smtp_params;
|
global $imap_smtp_params;
|
||||||
$imap_smtp_params = array('host' => 'ssl://localhost', 'port' => 587, 'auth' => true, 'username' => 'imap_username', 'password' => 'imap_password');
|
$imap_smtp_params = array('host' => 'ssl://localhost', 'port' => 587, 'auth' => true, 'username' => 'imap_username', 'password' => 'imap_password');
|
||||||
|
|
||||||
|
|||||||
86
containers/docker/apt_package_list.txt
Normal file
86
containers/docker/apt_package_list.txt
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
bc
|
||||||
|
bind9
|
||||||
|
ca-certificates
|
||||||
|
coreutils
|
||||||
|
cron
|
||||||
|
curl
|
||||||
|
dbconfig-common
|
||||||
|
dovecot-antispam
|
||||||
|
dovecot-core
|
||||||
|
dovecot-imapd
|
||||||
|
dovecot-lmtpd
|
||||||
|
dovecot-lucene
|
||||||
|
dovecot-managesieved
|
||||||
|
dovecot-pop3d
|
||||||
|
dovecot-sieve
|
||||||
|
dovecot-sqlite
|
||||||
|
duplicity
|
||||||
|
fail2ban
|
||||||
|
git
|
||||||
|
haveged
|
||||||
|
ldnsutils
|
||||||
|
libapr1
|
||||||
|
libawl-php
|
||||||
|
libcurl4-openssl-dev
|
||||||
|
libjs-jquery
|
||||||
|
libjs-jquery-mousewheel
|
||||||
|
libmagic1
|
||||||
|
libtool
|
||||||
|
libyaml-dev
|
||||||
|
links
|
||||||
|
memcached
|
||||||
|
munin
|
||||||
|
munin-node
|
||||||
|
nginx
|
||||||
|
nsd
|
||||||
|
ntp
|
||||||
|
opendkim
|
||||||
|
opendkim-tools
|
||||||
|
opendmarc
|
||||||
|
openssh-client
|
||||||
|
openssl
|
||||||
|
php-apc
|
||||||
|
php-auth
|
||||||
|
php-crypt-gpg
|
||||||
|
php-mail-mime
|
||||||
|
php-net-sieve
|
||||||
|
php-net-smtp
|
||||||
|
php-net-socket
|
||||||
|
php-pear
|
||||||
|
php-soap
|
||||||
|
php-xml-parser
|
||||||
|
php5
|
||||||
|
php5-cli
|
||||||
|
php5-common
|
||||||
|
php5-curl
|
||||||
|
php5-dev
|
||||||
|
php5-fpm
|
||||||
|
php5-gd
|
||||||
|
php5-imap
|
||||||
|
php5-intl
|
||||||
|
php5-json
|
||||||
|
php5-mcrypt
|
||||||
|
php5-memcache
|
||||||
|
php5-pspell
|
||||||
|
php5-sqlite
|
||||||
|
php5-xsl
|
||||||
|
postfix
|
||||||
|
postfix-pcre
|
||||||
|
postgrey
|
||||||
|
python3
|
||||||
|
python3-dateutil
|
||||||
|
python3-dev
|
||||||
|
python3-dnspython
|
||||||
|
python3-flask
|
||||||
|
python3-pip
|
||||||
|
pyzor
|
||||||
|
razor
|
||||||
|
resolvconf
|
||||||
|
spampd
|
||||||
|
sqlite3
|
||||||
|
sudo
|
||||||
|
tinymce
|
||||||
|
ufw
|
||||||
|
unattended-upgrades
|
||||||
|
unzip
|
||||||
|
wget
|
||||||
58
containers/docker/my_init.d/10-mailinabox.sh
Executable file
58
containers/docker/my_init.d/10-mailinabox.sh
Executable file
@@ -0,0 +1,58 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# This script is used within containers to turn it into a Mail-in-a-Box.
|
||||||
|
# It is referenced by the Dockerfile. You should not run it directly.
|
||||||
|
########################################################################
|
||||||
|
|
||||||
|
# Local configuration details were not known at the time the Docker
|
||||||
|
# image was created, so all setup is defered until the container
|
||||||
|
# is started. That's when this script runs.
|
||||||
|
|
||||||
|
# If we're not in an interactive shell, set defaults.
|
||||||
|
if [ ! -t 0 ]; then
|
||||||
|
echo '*** Non interactive shell detected...'
|
||||||
|
export PUBLIC_IP=auto
|
||||||
|
export PUBLIC_IPV6=auto
|
||||||
|
export PRIMARY_HOSTNAME=auto
|
||||||
|
export CSR_COUNTRY=US
|
||||||
|
export NONINTERACTIVE=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ([ -z "$FORCE_INSTALL" ] && [ -f /var/lib/mailinabox/api.key ]); then
|
||||||
|
# Mailinabox is already installed and we don't want to reinstall
|
||||||
|
export SKIP_INSTALL=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If we are skipping install, reload from /etc/mailinabox.conf if exists
|
||||||
|
if ([ -f /var/lib/mailinabox/api.key ] && [ ! -z "$SKIP_INSTALL" ]); then
|
||||||
|
echo '*** Loading variables from "/etc/mailinabox.conf"...'
|
||||||
|
|
||||||
|
source /etc/mailinabox.conf
|
||||||
|
unset PRIVATE_IP
|
||||||
|
unset PRIVATE_IPV6
|
||||||
|
export SKIP_NETWORK_CHECKS=1
|
||||||
|
export NONINTERACTIVE=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export DISABLE_FIREWALL=1
|
||||||
|
cd /usr/local/mailinabox
|
||||||
|
|
||||||
|
if [ -z "$SKIP_INSTALL" ]; then
|
||||||
|
echo "*** Starting mailinabox installation..."
|
||||||
|
# Run in background to avoid blocking runit initialization while installing.
|
||||||
|
source setup/start.sh &
|
||||||
|
else
|
||||||
|
echo "*** Configuring mailinabox..."
|
||||||
|
# Run in foreground for services to be started after configuration is re-written.
|
||||||
|
source setup/questions.sh
|
||||||
|
cat > /etc/mailinabox.conf << EOF;
|
||||||
|
STORAGE_USER=$STORAGE_USER
|
||||||
|
STORAGE_ROOT=$STORAGE_ROOT
|
||||||
|
PRIMARY_HOSTNAME=$PRIMARY_HOSTNAME
|
||||||
|
PUBLIC_IP=$PUBLIC_IP
|
||||||
|
PUBLIC_IPV6=$PUBLIC_IPV6
|
||||||
|
PRIVATE_IP=$PRIVATE_IP
|
||||||
|
PRIVATE_IPV6=$PRIVATE_IPV6
|
||||||
|
CSR_COUNTRY=$CSR_COUNTRY
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
114
containers/docker/run
Executable file
114
containers/docker/run
Executable file
@@ -0,0 +1,114 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Use this script to launch Mail-in-a-Box within a docker container.
|
||||||
|
# ==================================================================
|
||||||
|
#
|
||||||
|
# Run this script from the base directory of the Mail-in-a-Box
|
||||||
|
# repository (i.e. run as './containers/docker/run').
|
||||||
|
#
|
||||||
|
# Set these optional environment variables as needed:
|
||||||
|
# * HOST_HTTP_PORT: Host http: port to bind (default: 80).
|
||||||
|
# * HOST_HTTPS_PORT: Host https: port to bind (default: 443).
|
||||||
|
# * SKIP_BUILD: Skip the build of docker image (default: unset).
|
||||||
|
# * NODNS: Skip mapping of DNS ports (53 tcp/upd). They are not always available on host, as another DNS server can be running (default: unset).
|
||||||
|
# * CONTAINER_NAME: Name of the main container (default: mailinabox).
|
||||||
|
# * CONTAINER_DATA_NAME: Name of the data container (default: mailinabox-data).
|
||||||
|
# * NONINTERACTIVE: Use this when mailinabox is already installed on the volume container. Else, it's not recommanded (default: unset).
|
||||||
|
#
|
||||||
|
# A base image is created first. The base image installs Ubuntu
|
||||||
|
# packages and pulls in the Mail-in-a-Box source code. This is
|
||||||
|
# defined in Dockerfile at the root of this repository.
|
||||||
|
#
|
||||||
|
# A mailinabox-data container is created next. This container
|
||||||
|
# contains nothing but a shared volume for storing user data.
|
||||||
|
# It is segregated from the rest of the live system to make backups
|
||||||
|
# easier.
|
||||||
|
#
|
||||||
|
# The mailinabox container is started last. It is the
|
||||||
|
# real thing: it runs the mailinabox image. This container will
|
||||||
|
# initialize itself and will initialize the mailinabox-data
|
||||||
|
# volume if the volume is new.
|
||||||
|
|
||||||
|
# Build or rebuild the image.
|
||||||
|
# Rebuilds are very fast.
|
||||||
|
|
||||||
|
HOST_HTTP_PORT=${HOST_HTTP_PORT:-80}
|
||||||
|
HOST_HTTPS_PORT=${HOST_HTTPS_PORT:-443}
|
||||||
|
CONTAINER_NAME=${CONTAINER_NAME:-mailinabox}
|
||||||
|
CONTAINER_DATA_NAME=${CONTAINER_DATA_NAME:-${CONTAINER_NAME}-data}
|
||||||
|
|
||||||
|
if [ -z "$SKIP_BUILD" ]; then
|
||||||
|
tput setaf 2
|
||||||
|
echo "Building/updating base image (mailinabox)..."
|
||||||
|
tput setaf 7
|
||||||
|
|
||||||
|
docker build -q -t mailinabox . || exit 1
|
||||||
|
fi;
|
||||||
|
|
||||||
|
if ! docker inspect ${CONTAINER_DATA_NAME} > /dev/null; then
|
||||||
|
tput setaf 2
|
||||||
|
echo
|
||||||
|
echo "Creating a new container for your data (${CONTAINER_DATA_NAME})..."
|
||||||
|
tput setaf 7
|
||||||
|
|
||||||
|
docker create \
|
||||||
|
--name ${CONTAINER_DATA_NAME} \
|
||||||
|
-v /home/user-data \
|
||||||
|
phusion/baseimage:0.9.16 || exit 1
|
||||||
|
else
|
||||||
|
tput setaf 2
|
||||||
|
echo
|
||||||
|
echo "Using existing container ${CONTAINER_DATA_NAME} for your data."
|
||||||
|
tput setaf 7
|
||||||
|
fi
|
||||||
|
|
||||||
|
# End a running container.
|
||||||
|
if docker inspect ${CONTAINER_NAME} > /dev/null; then
|
||||||
|
tput setaf 2
|
||||||
|
echo
|
||||||
|
echo "Destroying ${CONTAINER_NAME} container..."
|
||||||
|
tput setaf 7
|
||||||
|
|
||||||
|
docker rm -f ${CONTAINER_NAME}
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start container.
|
||||||
|
tput setaf 2
|
||||||
|
echo
|
||||||
|
echo "Starting new container (${CONTAINER_NAME})..."
|
||||||
|
tput setaf 7
|
||||||
|
|
||||||
|
# Run the services container
|
||||||
|
# detached if NONINTERACTIVE is set,
|
||||||
|
# interactively if NONINTERACTIVE is not set,
|
||||||
|
# Notes:
|
||||||
|
# * Passing through SKIP_NETWORK_CHECKS makes it easier to do testing
|
||||||
|
# on a residential network.
|
||||||
|
# * --privileged flag cause an issue with bind9/named failing to start in this case
|
||||||
|
# see docker/docker#7318
|
||||||
|
docker run \
|
||||||
|
-v /dev/urandom:/dev/random \
|
||||||
|
-p 25:25 \
|
||||||
|
$([ -z "$NODNS" ] && echo "-p 53:53/udp -p 53:53/tcp") \
|
||||||
|
-p $HOST_HTTP_PORT:80 \
|
||||||
|
-p $HOST_HTTPS_PORT:443 \
|
||||||
|
-p 587:587 \
|
||||||
|
-p 993:993 \
|
||||||
|
-p 4190:4190 \
|
||||||
|
--name ${CONTAINER_NAME} \
|
||||||
|
--volumes-from ${CONTAINER_DATA_NAME} \
|
||||||
|
--restart always \
|
||||||
|
$([ ! -z "$NONINTERACTIVE" ] && echo "-d") \
|
||||||
|
-it \
|
||||||
|
-e "IS_DOCKER=1" \
|
||||||
|
-e "SKIP_NETWORK_CHECKS=$SKIP_NETWORK_CHECKS" \
|
||||||
|
mailinabox \
|
||||||
|
|| exit 1
|
||||||
|
|
||||||
|
if [ -z "$NONINTERACTIVE" ]; then
|
||||||
|
tput setaf 2
|
||||||
|
echo
|
||||||
|
echo "Restarting container ${CONTAINER_NAME}..."
|
||||||
|
tput setaf 7
|
||||||
|
|
||||||
|
docker restart ${CONTAINER_NAME} || exit 1
|
||||||
|
fi
|
||||||
3
containers/docker/runit/dovecot.sh
Executable file
3
containers/docker/runit/dovecot.sh
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
/usr/sbin/dovecot -F -c /etc/dovecot/dovecot.conf &> /var/log/dovecot.log
|
||||||
103
containers/docker/tools/configure_services.sh
Executable file
103
containers/docker/tools/configure_services.sh
Executable file
@@ -0,0 +1,103 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# The phusion/baseimage base image we use for a working Ubuntu
|
||||||
|
# replaces the normal Upstart system service management with
|
||||||
|
# a ligher-weight service management system called runit that
|
||||||
|
# requires a different configuration. We need to create service
|
||||||
|
# run files that do not daemonize.
|
||||||
|
|
||||||
|
# This removes /etc/init.d service if service exists in runit.
|
||||||
|
# It also creates a symlink from /usr/bin/sv to /etc/init.d/$service
|
||||||
|
# to support SysV syntax: service $service <command> or /etc/init.d/$service <command>
|
||||||
|
SERVICES=/etc/service/*
|
||||||
|
for f in $SERVICES
|
||||||
|
do
|
||||||
|
service=$(basename "$f")
|
||||||
|
if [ -d /etc/service/$service ]; then
|
||||||
|
if [ -f /etc/init.d/$service ]; then
|
||||||
|
mv /etc/init.d/$service /etc/init.d/$service.lsb
|
||||||
|
chmod -x /etc/init.d/$service.lsb
|
||||||
|
fi
|
||||||
|
ln -s /usr/bin/sv /etc/init.d/$service
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Create runit services from sysv services. For most of the services,
|
||||||
|
# there is a common pattern we can use: execute the init.d script that
|
||||||
|
# the Ubuntu package installs, and then poll for the termination of
|
||||||
|
# the daemon.
|
||||||
|
function make_runit_service {
|
||||||
|
INITD_NAME=$1
|
||||||
|
WAIT_ON_PROCESS_NAME=$2
|
||||||
|
mkdir -p /etc/service/$INITD_NAME
|
||||||
|
cat > /etc/service/$INITD_NAME/run <<EOF;
|
||||||
|
#!/bin/bash
|
||||||
|
source /usr/local/mailinabox/setup/functions.sh
|
||||||
|
hide_output /etc/init.d/$INITD_NAME restart
|
||||||
|
while [ \`ps a -C $WAIT_ON_PROCESS_NAME -o pid= | wc -l\` -gt 0 ]; do
|
||||||
|
sleep 30
|
||||||
|
done
|
||||||
|
echo $WAIT_ON_PROCESS_NAME died.
|
||||||
|
sleep 20
|
||||||
|
EOF
|
||||||
|
chmod +x /etc/service/$INITD_NAME/run
|
||||||
|
}
|
||||||
|
make_runit_service bind9 named
|
||||||
|
make_runit_service resolvconf resolvconf
|
||||||
|
make_runit_service fail2ban fail2ban
|
||||||
|
make_runit_service mailinabox mailinabox-daemon
|
||||||
|
make_runit_service memcached memcached
|
||||||
|
make_runit_service nginx nginx
|
||||||
|
make_runit_service nsd nsd
|
||||||
|
make_runit_service opendkim opendkim
|
||||||
|
make_runit_service opendmarc opendmarc
|
||||||
|
make_runit_service php5-fpm php5-fpm
|
||||||
|
make_runit_service postfix postfix
|
||||||
|
make_runit_service postgrey postgrey
|
||||||
|
make_runit_service spampd spampd
|
||||||
|
|
||||||
|
# Dovecot doesn't provide an init.d script, but it does provide
|
||||||
|
# a way to launch without daemonization. We wrote a script for
|
||||||
|
# that specifically.
|
||||||
|
for service in dovecot; do
|
||||||
|
mkdir -p /etc/service/$service
|
||||||
|
cp /usr/local/mailinabox/containers/docker/runit/$service.sh /etc/service/$service/run
|
||||||
|
chmod +x /etc/service/$service/run
|
||||||
|
done
|
||||||
|
|
||||||
|
# This adds a log/run file on each runit service directory.
|
||||||
|
# This file make services stdout/stderr output to svlogd log
|
||||||
|
# directory located in /var/log/runit/$service.
|
||||||
|
SERVICES=/etc/service/*
|
||||||
|
for f in $SERVICES
|
||||||
|
do
|
||||||
|
service=$(basename "$f")
|
||||||
|
if [ -d /etc/service/$service ]; then
|
||||||
|
mkdir -p /etc/service/$service/log
|
||||||
|
cat > /etc/service/$service/log/run <<EOF;
|
||||||
|
#!/bin/bash
|
||||||
|
mkdir -p /var/log/runit
|
||||||
|
chmod o-wrx /var/log/runit
|
||||||
|
mkdir -p /var/log/runit/$service
|
||||||
|
chmod o-wrx /var/log/runit/$service
|
||||||
|
exec svlogd -tt /var/log/runit/$service/
|
||||||
|
EOF
|
||||||
|
chmod +x /etc/service/$service/log/run
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Disable services for now. Until Mail-in-a-Box is installed the
|
||||||
|
# services won't be configured right and there would be errors if
|
||||||
|
# they got run prematurely.
|
||||||
|
SERVICES=/etc/service/*
|
||||||
|
for f in $SERVICES
|
||||||
|
do
|
||||||
|
service=$(basename "$f")
|
||||||
|
if [ "$service" = "syslog-ng" ]; then continue; fi;
|
||||||
|
if [ "$service" = "syslog-forwarder" ]; then continue; fi;
|
||||||
|
if [ "$service" = "ssh" ]; then continue; fi;
|
||||||
|
if [ "$service" = "cron" ]; then continue; fi;
|
||||||
|
if ([ -d /etc/service/$service ] && [ ! -f /etc/service/$service/down ]); then
|
||||||
|
touch /etc/service/$service/down
|
||||||
|
fi
|
||||||
|
done
|
||||||
@@ -10,27 +10,20 @@
|
|||||||
|
|
||||||
import os, os.path, shutil, glob, re, datetime
|
import os, os.path, shutil, glob, re, datetime
|
||||||
import dateutil.parser, dateutil.relativedelta, dateutil.tz
|
import dateutil.parser, dateutil.relativedelta, dateutil.tz
|
||||||
import rtyaml
|
|
||||||
|
|
||||||
from utils import exclusive_process, load_environment, shell, wait_for_service
|
from utils import exclusive_process, load_environment, shell, wait_for_service
|
||||||
|
|
||||||
|
# Destroy backups when the most recent increment in the chain
|
||||||
|
# that depends on it is this many days old.
|
||||||
|
keep_backups_for_days = 3
|
||||||
|
|
||||||
def backup_status(env):
|
def backup_status(env):
|
||||||
# Root folder
|
|
||||||
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
|
||||||
|
|
||||||
# What is the current status of backups?
|
# What is the current status of backups?
|
||||||
# Query duplicity to get a list of all backups.
|
# Loop through all of the files in STORAGE_ROOT/backup/encrypted to
|
||||||
# Use the number of volumes to estimate the size.
|
# get a list of all of the backups taken and sum up file sizes to
|
||||||
config = get_backup_config(env)
|
# see how large the storage is.
|
||||||
|
|
||||||
now = datetime.datetime.now(dateutil.tz.tzlocal())
|
now = datetime.datetime.now(dateutil.tz.tzlocal())
|
||||||
|
|
||||||
# Are backups dissbled?
|
|
||||||
if config["target"] == "off":
|
|
||||||
return { }
|
|
||||||
|
|
||||||
backups = { }
|
|
||||||
backup_cache_dir = os.path.join(backup_root, 'cache')
|
|
||||||
|
|
||||||
def reldate(date, ref, clip):
|
def reldate(date, ref, clip):
|
||||||
if ref < date: return clip
|
if ref < date: return clip
|
||||||
rd = dateutil.relativedelta.relativedelta(ref, date)
|
rd = dateutil.relativedelta.relativedelta(ref, date)
|
||||||
@@ -41,43 +34,27 @@ def backup_status(env):
|
|||||||
if rd.days == 1: return "%d day, %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)
|
return "%d hours, %d minutes" % (rd.hours, rd.minutes)
|
||||||
|
|
||||||
# Get duplicity collection status and parse for a list of backups.
|
backups = { }
|
||||||
def parse_line(line):
|
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||||
keys = line.strip().split()
|
backup_dir = os.path.join(backup_root, 'encrypted')
|
||||||
date = dateutil.parser.parse(keys[1])
|
os.makedirs(backup_dir, exist_ok=True) # os.listdir fails if directory does not exist
|
||||||
return {
|
for fn in os.listdir(backup_dir):
|
||||||
"date": keys[1],
|
|
||||||
"date_str": date.strftime("%x %X"),
|
|
||||||
"date_delta": reldate(date, now, "the future?"),
|
|
||||||
"full": keys[0] == "full",
|
|
||||||
"size": 0, # collection-status doesn't give us the size
|
|
||||||
"volumes": keys[2], # number of archive volumes for this backup (not really helpful)
|
|
||||||
}
|
|
||||||
code, collection_status = shell('check_output', [
|
|
||||||
"/usr/bin/duplicity",
|
|
||||||
"collection-status",
|
|
||||||
"--archive-dir", backup_cache_dir,
|
|
||||||
"--gpg-options", "--cipher-algo=AES256",
|
|
||||||
"--log-fd", "1",
|
|
||||||
config["target"],
|
|
||||||
],
|
|
||||||
get_env(env),
|
|
||||||
trap=True)
|
|
||||||
if code != 0:
|
|
||||||
# Command failed. This is likely due to an improperly configured remote
|
|
||||||
# destination for the backups.
|
|
||||||
return { }
|
|
||||||
for line in collection_status.split('\n'):
|
|
||||||
if line.startswith(" full") or line.startswith(" inc"):
|
|
||||||
backup = parse_line(line)
|
|
||||||
backups[backup["date"]] = backup
|
|
||||||
|
|
||||||
# Look at the target to get the sizes of each of the backups. There is more than one file per backup.
|
|
||||||
for fn, size in list_target_files(config):
|
|
||||||
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)
|
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: continue # not a part of a current backup chain
|
if not m: raise ValueError(fn)
|
||||||
|
|
||||||
key = m.group("date")
|
key = m.group("date")
|
||||||
backups[key]["size"] += size
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
backups[key]["size"] += os.path.getsize(os.path.join(backup_dir, fn))
|
||||||
|
|
||||||
# Ensure the rows are sorted reverse chronologically.
|
# Ensure the rows are sorted reverse chronologically.
|
||||||
# This is relied on by should_force_full() and the next step.
|
# This is relied on by should_force_full() and the next step.
|
||||||
@@ -102,11 +79,11 @@ def backup_status(env):
|
|||||||
# when the threshold is met.
|
# when the threshold is met.
|
||||||
deleted_in = None
|
deleted_in = None
|
||||||
if incremental_count > 0 and first_full_size is not None:
|
if incremental_count > 0 and first_full_size is not None:
|
||||||
deleted_in = "approx. %d days" % round(config["min_age_in_days"] + (.5 * first_full_size - incremental_size) / (incremental_size/incremental_count) + .5)
|
deleted_in = "approx. %d days" % round(keep_backups_for_days + (.5 * first_full_size - incremental_size) / (incremental_size/incremental_count) + .5)
|
||||||
|
|
||||||
# When will a backup be deleted?
|
# When will a backup be deleted?
|
||||||
saw_full = False
|
saw_full = False
|
||||||
days_ago = now - datetime.timedelta(days=config["min_age_in_days"])
|
days_ago = now - datetime.timedelta(days=keep_backups_for_days)
|
||||||
for bak in backups:
|
for bak in backups:
|
||||||
if deleted_in:
|
if deleted_in:
|
||||||
# Subsequent backups are deleted when the most recent increment
|
# Subsequent backups are deleted when the most recent increment
|
||||||
@@ -122,6 +99,8 @@ def backup_status(env):
|
|||||||
bak["deleted_in"] = deleted_in
|
bak["deleted_in"] = deleted_in
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
"directory": backup_dir,
|
||||||
|
"encpwfile": os.path.join(backup_root, 'secret_key.txt'),
|
||||||
"tz": now.tzname(),
|
"tz": now.tzname(),
|
||||||
"backups": backups,
|
"backups": backups,
|
||||||
}
|
}
|
||||||
@@ -145,48 +124,15 @@ def should_force_full(env):
|
|||||||
# (I love for/else blocks. Here it's just to show off.)
|
# (I love for/else blocks. Here it's just to show off.)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_passphrase(env):
|
|
||||||
# Get the encryption passphrase. secret_key.txt is 2048 random
|
|
||||||
# bits base64-encoded and with line breaks every 65 characters.
|
|
||||||
# gpg will only take the first line of text, so sanity check that
|
|
||||||
# that line is long enough to be a reasonable passphrase. It
|
|
||||||
# only needs to be 43 base64-characters to match AES256's key
|
|
||||||
# length of 32 bytes.
|
|
||||||
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
|
||||||
with open(os.path.join(backup_root, 'secret_key.txt')) as f:
|
|
||||||
passphrase = f.readline().strip()
|
|
||||||
if len(passphrase) < 43: raise Exception("secret_key.txt's first line is too short!")
|
|
||||||
|
|
||||||
return passphrase
|
|
||||||
|
|
||||||
def get_env(env):
|
|
||||||
config = get_backup_config(env)
|
|
||||||
|
|
||||||
env = { "PASSPHRASE" : get_passphrase(env) }
|
|
||||||
|
|
||||||
if get_target_type(config) == 's3':
|
|
||||||
env["AWS_ACCESS_KEY_ID"] = config["target_user"]
|
|
||||||
env["AWS_SECRET_ACCESS_KEY"] = config["target_pass"]
|
|
||||||
|
|
||||||
return env
|
|
||||||
|
|
||||||
def get_target_type(config):
|
|
||||||
protocol = config["target"].split(":")[0]
|
|
||||||
return protocol
|
|
||||||
|
|
||||||
def perform_backup(full_backup):
|
def perform_backup(full_backup):
|
||||||
env = load_environment()
|
env = load_environment()
|
||||||
|
|
||||||
exclusive_process("backup")
|
exclusive_process("backup")
|
||||||
config = get_backup_config(env)
|
|
||||||
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||||
backup_cache_dir = os.path.join(backup_root, 'cache')
|
backup_cache_dir = os.path.join(backup_root, 'cache')
|
||||||
backup_dir = os.path.join(backup_root, 'encrypted')
|
backup_dir = os.path.join(backup_root, 'encrypted')
|
||||||
|
|
||||||
# Are backups dissbled?
|
|
||||||
if config["target"] == "off":
|
|
||||||
return
|
|
||||||
|
|
||||||
# In an older version of this script, duplicity was called
|
# In an older version of this script, duplicity was called
|
||||||
# such that it did not encrypt the backups it created (in
|
# such that it did not encrypt the backups it created (in
|
||||||
# backup/duplicity), and instead openssl was called separately
|
# backup/duplicity), and instead openssl was called separately
|
||||||
@@ -223,6 +169,17 @@ def perform_backup(full_backup):
|
|||||||
shell('check_call', ["/usr/sbin/service", "dovecot", "stop"])
|
shell('check_call', ["/usr/sbin/service", "dovecot", "stop"])
|
||||||
shell('check_call', ["/usr/sbin/service", "postfix", "stop"])
|
shell('check_call', ["/usr/sbin/service", "postfix", "stop"])
|
||||||
|
|
||||||
|
# Get the encryption passphrase. secret_key.txt is 2048 random
|
||||||
|
# bits base64-encoded and with line breaks every 65 characters.
|
||||||
|
# gpg will only take the first line of text, so sanity check that
|
||||||
|
# that line is long enough to be a reasonable passphrase. It
|
||||||
|
# only needs to be 43 base64-characters to match AES256's key
|
||||||
|
# length of 32 bytes.
|
||||||
|
with open(os.path.join(backup_root, 'secret_key.txt')) as f:
|
||||||
|
passphrase = f.readline().strip()
|
||||||
|
if len(passphrase) < 43: raise Exception("secret_key.txt's first line is too short!")
|
||||||
|
env_with_passphrase = { "PASSPHRASE" : passphrase }
|
||||||
|
|
||||||
# Run a backup of STORAGE_ROOT (but excluding the backups themselves!).
|
# Run a backup of STORAGE_ROOT (but excluding the backups themselves!).
|
||||||
# --allow-source-mismatch is needed in case the box's hostname is changed
|
# --allow-source-mismatch is needed in case the box's hostname is changed
|
||||||
# after the first backup. See #396.
|
# after the first backup. See #396.
|
||||||
@@ -235,10 +192,10 @@ def perform_backup(full_backup):
|
|||||||
"--volsize", "250",
|
"--volsize", "250",
|
||||||
"--gpg-options", "--cipher-algo=AES256",
|
"--gpg-options", "--cipher-algo=AES256",
|
||||||
env["STORAGE_ROOT"],
|
env["STORAGE_ROOT"],
|
||||||
config["target"],
|
"file://" + backup_dir,
|
||||||
"--allow-source-mismatch"
|
"--allow-source-mismatch"
|
||||||
],
|
],
|
||||||
get_env(env))
|
env_with_passphrase)
|
||||||
finally:
|
finally:
|
||||||
# Start services again.
|
# Start services again.
|
||||||
shell('check_call', ["/usr/sbin/service", "dovecot", "start"])
|
shell('check_call', ["/usr/sbin/service", "dovecot", "start"])
|
||||||
@@ -253,12 +210,12 @@ def perform_backup(full_backup):
|
|||||||
shell('check_call', [
|
shell('check_call', [
|
||||||
"/usr/bin/duplicity",
|
"/usr/bin/duplicity",
|
||||||
"remove-older-than",
|
"remove-older-than",
|
||||||
"%dD" % config["min_age_in_days"],
|
"%dD" % keep_backups_for_days,
|
||||||
"--archive-dir", backup_cache_dir,
|
"--archive-dir", backup_cache_dir,
|
||||||
"--force",
|
"--force",
|
||||||
config["target"]
|
"file://" + backup_dir
|
||||||
],
|
],
|
||||||
get_env(env))
|
env_with_passphrase)
|
||||||
|
|
||||||
# From duplicity's manual:
|
# From duplicity's manual:
|
||||||
# "This should only be necessary after a duplicity session fails or is
|
# "This should only be necessary after a duplicity session fails or is
|
||||||
@@ -270,14 +227,13 @@ def perform_backup(full_backup):
|
|||||||
"cleanup",
|
"cleanup",
|
||||||
"--archive-dir", backup_cache_dir,
|
"--archive-dir", backup_cache_dir,
|
||||||
"--force",
|
"--force",
|
||||||
config["target"]
|
"file://" + backup_dir
|
||||||
],
|
],
|
||||||
get_env(env))
|
env_with_passphrase)
|
||||||
|
|
||||||
# Change ownership of backups to the user-data user, so that the after-bcakup
|
# Change ownership of backups to the user-data user, so that the after-bcakup
|
||||||
# script can access them.
|
# script can access them.
|
||||||
if get_target_type(config) == 'file':
|
shell('check_call', ["/bin/chown", "-R", env["STORAGE_USER"], backup_dir])
|
||||||
shell('check_call', ["/bin/chown", "-R", env["STORAGE_USER"], backup_dir])
|
|
||||||
|
|
||||||
# Execute a post-backup script that does the copying to a remote server.
|
# 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
|
# Run as the STORAGE_USER user, not as root. Pass our settings in
|
||||||
@@ -285,7 +241,7 @@ def perform_backup(full_backup):
|
|||||||
post_script = os.path.join(backup_root, 'after-backup')
|
post_script = os.path.join(backup_root, 'after-backup')
|
||||||
if os.path.exists(post_script):
|
if os.path.exists(post_script):
|
||||||
shell('check_call',
|
shell('check_call',
|
||||||
['su', env['STORAGE_USER'], '-c', post_script, config["target"]],
|
['su', env['STORAGE_USER'], '-c', post_script],
|
||||||
env=env)
|
env=env)
|
||||||
|
|
||||||
# Our nightly cron job executes system status checks immediately after this
|
# Our nightly cron job executes system status checks immediately after this
|
||||||
@@ -298,9 +254,9 @@ def perform_backup(full_backup):
|
|||||||
def run_duplicity_verification():
|
def run_duplicity_verification():
|
||||||
env = load_environment()
|
env = load_environment()
|
||||||
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||||
config = get_backup_config(env)
|
|
||||||
backup_cache_dir = os.path.join(backup_root, 'cache')
|
backup_cache_dir = os.path.join(backup_root, 'cache')
|
||||||
|
backup_dir = os.path.join(backup_root, 'encrypted')
|
||||||
|
env_with_passphrase = { "PASSPHRASE" : open(os.path.join(backup_root, 'secret_key.txt')).read() }
|
||||||
shell('check_call', [
|
shell('check_call', [
|
||||||
"/usr/bin/duplicity",
|
"/usr/bin/duplicity",
|
||||||
"--verbosity", "info",
|
"--verbosity", "info",
|
||||||
@@ -308,125 +264,9 @@ def run_duplicity_verification():
|
|||||||
"--compare-data",
|
"--compare-data",
|
||||||
"--archive-dir", backup_cache_dir,
|
"--archive-dir", backup_cache_dir,
|
||||||
"--exclude", backup_root,
|
"--exclude", backup_root,
|
||||||
config["target"],
|
"file://" + backup_dir,
|
||||||
env["STORAGE_ROOT"],
|
env["STORAGE_ROOT"],
|
||||||
], get_env(env))
|
], env_with_passphrase)
|
||||||
|
|
||||||
def list_target_files(config):
|
|
||||||
import urllib.parse
|
|
||||||
try:
|
|
||||||
p = urllib.parse.urlparse(config["target"])
|
|
||||||
except ValueError:
|
|
||||||
return "invalid target"
|
|
||||||
|
|
||||||
if p.scheme == "file":
|
|
||||||
return [(fn, os.path.getsize(os.path.join(p.path, fn))) for fn in os.listdir(p.path)]
|
|
||||||
|
|
||||||
elif p.scheme == "s3":
|
|
||||||
# match to a Region
|
|
||||||
import boto.s3
|
|
||||||
from boto.exception import BotoServerError
|
|
||||||
for region in boto.s3.regions():
|
|
||||||
if region.endpoint == p.hostname:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
raise ValueError("Invalid S3 region/host.")
|
|
||||||
|
|
||||||
bucket = p.path[1:].split('/')[0]
|
|
||||||
path = '/'.join(p.path[1:].split('/')[1:]) + '/'
|
|
||||||
|
|
||||||
# If no prefix is specified, set the path to '', otherwise boto won't list the files
|
|
||||||
if path == '/':
|
|
||||||
path = ''
|
|
||||||
|
|
||||||
if bucket == "":
|
|
||||||
raise ValueError("Enter an S3 bucket name.")
|
|
||||||
|
|
||||||
# connect to the region & bucket
|
|
||||||
try:
|
|
||||||
conn = region.connect(aws_access_key_id=config["target_user"], aws_secret_access_key=config["target_pass"])
|
|
||||||
bucket = conn.get_bucket(bucket)
|
|
||||||
except BotoServerError as e:
|
|
||||||
if e.status == 403:
|
|
||||||
raise ValueError("Invalid S3 access key or secret access key.")
|
|
||||||
elif e.status == 404:
|
|
||||||
raise ValueError("Invalid S3 bucket name.")
|
|
||||||
elif e.status == 301:
|
|
||||||
raise ValueError("Incorrect region for this bucket.")
|
|
||||||
raise ValueError(e.reason)
|
|
||||||
|
|
||||||
return [(key.name[len(path):], key.size) for key in bucket.list(prefix=path)]
|
|
||||||
|
|
||||||
else:
|
|
||||||
raise ValueError(config["target"])
|
|
||||||
|
|
||||||
|
|
||||||
def backup_set_custom(env, target, target_user, target_pass, min_age):
|
|
||||||
config = get_backup_config(env, for_save=True)
|
|
||||||
|
|
||||||
# min_age must be an int
|
|
||||||
if isinstance(min_age, str):
|
|
||||||
min_age = int(min_age)
|
|
||||||
|
|
||||||
config["target"] = target
|
|
||||||
config["target_user"] = target_user
|
|
||||||
config["target_pass"] = target_pass
|
|
||||||
config["min_age_in_days"] = min_age
|
|
||||||
|
|
||||||
# Validate.
|
|
||||||
try:
|
|
||||||
if config["target"] not in ("off", "local"):
|
|
||||||
# these aren't supported by the following function, which expects a full url in the target key,
|
|
||||||
# which is what is there except when loading the config prior to saving
|
|
||||||
list_target_files(config)
|
|
||||||
except ValueError as e:
|
|
||||||
return str(e)
|
|
||||||
|
|
||||||
write_backup_config(env, config)
|
|
||||||
|
|
||||||
return "OK"
|
|
||||||
|
|
||||||
def get_backup_config(env, for_save=False, for_ui=False):
|
|
||||||
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
|
||||||
|
|
||||||
# Defaults.
|
|
||||||
config = {
|
|
||||||
"min_age_in_days": 3,
|
|
||||||
"target": "local",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Merge in anything written to custom.yaml.
|
|
||||||
try:
|
|
||||||
custom_config = rtyaml.load(open(os.path.join(backup_root, 'custom.yaml')))
|
|
||||||
if not isinstance(custom_config, dict): raise ValueError() # caught below
|
|
||||||
config.update(custom_config)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# When updating config.yaml, don't do any further processing on what we find.
|
|
||||||
if for_save:
|
|
||||||
return config
|
|
||||||
|
|
||||||
# When passing this back to the admin to show the current settings, do not include
|
|
||||||
# authentication details. The user will have to re-enter it.
|
|
||||||
if for_ui:
|
|
||||||
for field in ("target_user", "target_pass"):
|
|
||||||
if field in config:
|
|
||||||
del config[field]
|
|
||||||
|
|
||||||
# helper fields for the admin
|
|
||||||
config["file_target_directory"] = os.path.join(backup_root, 'encrypted')
|
|
||||||
config["enc_pw_file"] = os.path.join(backup_root, 'secret_key.txt')
|
|
||||||
if config["target"] == "local":
|
|
||||||
# Expand to the full URL.
|
|
||||||
config["target"] = "file://" + config["file_target_directory"]
|
|
||||||
|
|
||||||
return config
|
|
||||||
|
|
||||||
def write_backup_config(env, newconfig):
|
|
||||||
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
|
||||||
with open(os.path.join(backup_root, 'custom.yaml'), "w") as f:
|
|
||||||
f.write(rtyaml.dump(newconfig))
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import sys
|
import sys
|
||||||
@@ -434,7 +274,6 @@ if __name__ == "__main__":
|
|||||||
# Run duplicity's verification command to check a) the backup files
|
# Run duplicity's verification command to check a) the backup files
|
||||||
# are readable, and b) report if they are up to date.
|
# are readable, and b) report if they are up to date.
|
||||||
run_duplicity_verification()
|
run_duplicity_verification()
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Perform a backup. Add --full to force a full backup rather than
|
# Perform a backup. Add --full to force a full backup rather than
|
||||||
# possibly performing an incremental backup.
|
# possibly performing an incremental backup.
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ def authorized_personnel_only(viewfunc):
|
|||||||
|
|
||||||
# Authorized to access an API view?
|
# Authorized to access an API view?
|
||||||
if "admin" in privs:
|
if "admin" in privs:
|
||||||
# Call view func.
|
# Call view func.
|
||||||
return viewfunc(*args, **kwargs)
|
return viewfunc(*args, **kwargs)
|
||||||
elif not error:
|
elif not error:
|
||||||
error = "You are not an administrator."
|
error = "You are not an administrator."
|
||||||
@@ -90,19 +90,13 @@ def json_response(data):
|
|||||||
def index():
|
def index():
|
||||||
# Render the control panel. This route does not require user authentication
|
# Render the control panel. This route does not require user authentication
|
||||||
# so it must be safe!
|
# so it must be safe!
|
||||||
|
|
||||||
no_users_exist = (len(get_mail_users(env)) == 0)
|
no_users_exist = (len(get_mail_users(env)) == 0)
|
||||||
no_admins_exist = (len(get_admins(env)) == 0)
|
no_admins_exist = (len(get_admins(env)) == 0)
|
||||||
|
|
||||||
import boto.s3
|
|
||||||
backup_s3_hosts = [(r.name, r.endpoint) for r in boto.s3.regions()]
|
|
||||||
|
|
||||||
return render_template('index.html',
|
return render_template('index.html',
|
||||||
hostname=env['PRIMARY_HOSTNAME'],
|
hostname=env['PRIMARY_HOSTNAME'],
|
||||||
storage_root=env['STORAGE_ROOT'],
|
storage_root=env['STORAGE_ROOT'],
|
||||||
no_users_exist=no_users_exist,
|
no_users_exist=no_users_exist,
|
||||||
no_admins_exist=no_admins_exist,
|
no_admins_exist=no_admins_exist,
|
||||||
backup_s3_hosts=backup_s3_hosts,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.route('/me')
|
@app.route('/me')
|
||||||
@@ -185,15 +179,14 @@ def mail_aliases():
|
|||||||
if request.args.get("format", "") == "json":
|
if request.args.get("format", "") == "json":
|
||||||
return json_response(get_mail_aliases_ex(env))
|
return json_response(get_mail_aliases_ex(env))
|
||||||
else:
|
else:
|
||||||
return "".join(address+"\t"+receivers+"\t"+(senders or "")+"\n" for address, receivers, senders in get_mail_aliases(env))
|
return "".join(x+"\t"+y+"\n" for x, y in get_mail_aliases(env))
|
||||||
|
|
||||||
@app.route('/mail/aliases/add', methods=['POST'])
|
@app.route('/mail/aliases/add', methods=['POST'])
|
||||||
@authorized_personnel_only
|
@authorized_personnel_only
|
||||||
def mail_aliases_add():
|
def mail_aliases_add():
|
||||||
return add_mail_alias(
|
return add_mail_alias(
|
||||||
request.form.get('address', ''),
|
request.form.get('source', ''),
|
||||||
request.form.get('forwards_to', ''),
|
request.form.get('destination', ''),
|
||||||
request.form.get('permitted_senders', ''),
|
|
||||||
env,
|
env,
|
||||||
update_if_exists=(request.form.get('update_if_exists', '') == '1')
|
update_if_exists=(request.form.get('update_if_exists', '') == '1')
|
||||||
)
|
)
|
||||||
@@ -201,7 +194,7 @@ def mail_aliases_add():
|
|||||||
@app.route('/mail/aliases/remove', methods=['POST'])
|
@app.route('/mail/aliases/remove', methods=['POST'])
|
||||||
@authorized_personnel_only
|
@authorized_personnel_only
|
||||||
def mail_aliases_remove():
|
def mail_aliases_remove():
|
||||||
return remove_mail_alias(request.form.get('address', ''), env)
|
return remove_mail_alias(request.form.get('source', ''), env)
|
||||||
|
|
||||||
@app.route('/mail/domains')
|
@app.route('/mail/domains')
|
||||||
@authorized_personnel_only
|
@authorized_personnel_only
|
||||||
@@ -229,14 +222,14 @@ def dns_update():
|
|||||||
@authorized_personnel_only
|
@authorized_personnel_only
|
||||||
def dns_get_secondary_nameserver():
|
def dns_get_secondary_nameserver():
|
||||||
from dns_update import get_custom_dns_config, get_secondary_dns
|
from dns_update import get_custom_dns_config, get_secondary_dns
|
||||||
return json_response({ "hostnames": get_secondary_dns(get_custom_dns_config(env), mode=None) })
|
return json_response({ "hostname": get_secondary_dns(get_custom_dns_config(env)) })
|
||||||
|
|
||||||
@app.route('/dns/secondary-nameserver', methods=['POST'])
|
@app.route('/dns/secondary-nameserver', methods=['POST'])
|
||||||
@authorized_personnel_only
|
@authorized_personnel_only
|
||||||
def dns_set_secondary_nameserver():
|
def dns_set_secondary_nameserver():
|
||||||
from dns_update import set_secondary_dns
|
from dns_update import set_secondary_dns
|
||||||
try:
|
try:
|
||||||
return set_secondary_dns([ns.strip() for ns in re.split(r"[, ]+", request.form.get('hostnames') or "") if ns.strip() != ""], env)
|
return set_secondary_dns(request.form.get('hostname'), env)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return (str(e), 400)
|
return (str(e), 400)
|
||||||
|
|
||||||
@@ -290,7 +283,7 @@ def dns_set_record(qname, rtype="A"):
|
|||||||
# make this action set (replace all records for this
|
# make this action set (replace all records for this
|
||||||
# qname-rtype pair) rather than add (add a new record).
|
# qname-rtype pair) rather than add (add a new record).
|
||||||
action = "set"
|
action = "set"
|
||||||
|
|
||||||
elif request.method == "DELETE":
|
elif request.method == "DELETE":
|
||||||
if value == '':
|
if value == '':
|
||||||
# Delete all records for this qname-type pair.
|
# Delete all records for this qname-type pair.
|
||||||
@@ -318,9 +311,9 @@ def dns_get_dump():
|
|||||||
@app.route('/ssl/csr/<domain>', methods=['POST'])
|
@app.route('/ssl/csr/<domain>', methods=['POST'])
|
||||||
@authorized_personnel_only
|
@authorized_personnel_only
|
||||||
def ssl_get_csr(domain):
|
def ssl_get_csr(domain):
|
||||||
from web_update import create_csr
|
from web_update import get_domain_ssl_files, create_csr
|
||||||
ssl_private_key = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_private_key.pem'))
|
ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, env)
|
||||||
return create_csr(domain, ssl_private_key, env)
|
return create_csr(domain, ssl_key, env)
|
||||||
|
|
||||||
@app.route('/ssl/install', methods=['POST'])
|
@app.route('/ssl/install', methods=['POST'])
|
||||||
@authorized_personnel_only
|
@authorized_personnel_only
|
||||||
@@ -347,24 +340,6 @@ def web_update():
|
|||||||
|
|
||||||
# System
|
# System
|
||||||
|
|
||||||
@app.route('/system/version', methods=["GET"])
|
|
||||||
@authorized_personnel_only
|
|
||||||
def system_version():
|
|
||||||
from status_checks import what_version_is_this
|
|
||||||
try:
|
|
||||||
return what_version_is_this(env)
|
|
||||||
except Exception as e:
|
|
||||||
return (str(e), 500)
|
|
||||||
|
|
||||||
@app.route('/system/latest-upstream-version', methods=["POST"])
|
|
||||||
@authorized_personnel_only
|
|
||||||
def system_latest_upstream_version():
|
|
||||||
from status_checks import get_latest_miab_version
|
|
||||||
try:
|
|
||||||
return get_latest_miab_version()
|
|
||||||
except Exception as e:
|
|
||||||
return (str(e), 500)
|
|
||||||
|
|
||||||
@app.route('/system/status', methods=["POST"])
|
@app.route('/system/status', methods=["POST"])
|
||||||
@authorized_personnel_only
|
@authorized_personnel_only
|
||||||
def system_status():
|
def system_status():
|
||||||
@@ -409,37 +384,6 @@ def backup_status():
|
|||||||
from backup import backup_status
|
from backup import backup_status
|
||||||
return json_response(backup_status(env))
|
return json_response(backup_status(env))
|
||||||
|
|
||||||
@app.route('/system/backup/config', methods=["GET"])
|
|
||||||
@authorized_personnel_only
|
|
||||||
def backup_get_custom():
|
|
||||||
from backup import get_backup_config
|
|
||||||
return json_response(get_backup_config(env, for_ui=True))
|
|
||||||
|
|
||||||
@app.route('/system/backup/config', methods=["POST"])
|
|
||||||
@authorized_personnel_only
|
|
||||||
def backup_set_custom():
|
|
||||||
from backup import backup_set_custom
|
|
||||||
return json_response(backup_set_custom(env,
|
|
||||||
request.form.get('target', ''),
|
|
||||||
request.form.get('target_user', ''),
|
|
||||||
request.form.get('target_pass', ''),
|
|
||||||
request.form.get('min_age', '')
|
|
||||||
))
|
|
||||||
|
|
||||||
@app.route('/system/privacy', methods=["GET"])
|
|
||||||
@authorized_personnel_only
|
|
||||||
def privacy_status_get():
|
|
||||||
config = utils.load_settings(env)
|
|
||||||
return json_response(config.get("privacy", True))
|
|
||||||
|
|
||||||
@app.route('/system/privacy', methods=["POST"])
|
|
||||||
@authorized_personnel_only
|
|
||||||
def privacy_status_set():
|
|
||||||
config = utils.load_settings(env)
|
|
||||||
config["privacy"] = (request.form.get('value') == "private")
|
|
||||||
utils.write_settings(config, env)
|
|
||||||
return "OK"
|
|
||||||
|
|
||||||
# MUNIN
|
# MUNIN
|
||||||
|
|
||||||
@app.route('/munin/')
|
@app.route('/munin/')
|
||||||
@@ -470,3 +414,4 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
# Start the application server. Listens on 127.0.0.1 (IPv4 only).
|
# Start the application server. Listens on 127.0.0.1 (IPv4 only).
|
||||||
app.run(port=10222)
|
app.run(port=10222)
|
||||||
|
|
||||||
|
|||||||
@@ -120,10 +120,6 @@ def do_dns_update(env, force=False):
|
|||||||
# If this is the only thing that changed?
|
# If this is the only thing that changed?
|
||||||
updated_domains.append("OpenDKIM configuration")
|
updated_domains.append("OpenDKIM configuration")
|
||||||
|
|
||||||
# Clear bind9's DNS cache so our own DNS resolver is up to date.
|
|
||||||
# (ignore errors with trap=True)
|
|
||||||
shell('check_call', ["/usr/sbin/rndc", "flush"], trap=True)
|
|
||||||
|
|
||||||
if len(updated_domains) == 0:
|
if len(updated_domains) == 0:
|
||||||
# if nothing was updated (except maybe OpenDKIM's files), don't show any output
|
# if nothing was updated (except maybe OpenDKIM's files), don't show any output
|
||||||
return ""
|
return ""
|
||||||
@@ -148,11 +144,8 @@ def build_zone(domain, all_domains, additional_records, www_redirect_domains, en
|
|||||||
records.append((None, "NS", "ns1.%s." % env["PRIMARY_HOSTNAME"], False))
|
records.append((None, "NS", "ns1.%s." % env["PRIMARY_HOSTNAME"], False))
|
||||||
|
|
||||||
# Define ns2.PRIMARY_HOSTNAME or whatever the user overrides.
|
# Define ns2.PRIMARY_HOSTNAME or whatever the user overrides.
|
||||||
# User may provide one or more additional nameservers
|
secondary_ns = get_secondary_dns(additional_records) or ("ns2." + env["PRIMARY_HOSTNAME"])
|
||||||
secondary_ns_list = get_secondary_dns(additional_records, mode="NS") \
|
records.append((None, "NS", secondary_ns+'.', False))
|
||||||
or ["ns2." + env["PRIMARY_HOSTNAME"]]
|
|
||||||
for secondary_ns in secondary_ns_list:
|
|
||||||
records.append((None, "NS", secondary_ns+'.', False))
|
|
||||||
|
|
||||||
|
|
||||||
# In PRIMARY_HOSTNAME...
|
# In PRIMARY_HOSTNAME...
|
||||||
@@ -174,9 +167,6 @@ def build_zone(domain, all_domains, additional_records, www_redirect_domains, en
|
|||||||
# Add a DANE TLSA record for SMTP.
|
# 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."))
|
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 DANE TLSA record for HTTPS, which some browser extensions might make use of.
|
|
||||||
records.append(("_443._tcp", "TLSA", build_tlsa_record(env), "Optional. When DNSSEC is enabled, provides out-of-band HTTPS certificate validation for a few web clients that support it."))
|
|
||||||
|
|
||||||
# Add a SSHFP records to help SSH key validation. One per available SSH key on this system.
|
# Add a SSHFP records to help SSH key validation. One per available SSH key on this system.
|
||||||
for value in build_sshfp_records():
|
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."))
|
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."))
|
||||||
@@ -260,8 +250,8 @@ def build_zone(domain, all_domains, additional_records, www_redirect_domains, en
|
|||||||
# Skip if the user has set a DKIM record already.
|
# Skip if the user has set a DKIM record already.
|
||||||
opendkim_record_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.txt')
|
opendkim_record_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.txt')
|
||||||
with open(opendkim_record_file) as orf:
|
with open(opendkim_record_file) as orf:
|
||||||
m = re.match(r'(\S+)\s+IN\s+TXT\s+\( ((?:"[^"]+"\s+)+)\)', orf.read(), re.S)
|
m = re.match(r'(\S+)\s+IN\s+TXT\s+\( "([^"]+)"\s+"([^"]+)"\s*\)', orf.read(), re.S)
|
||||||
val = "".join(re.findall(r'"([^"]+)"', m.group(2)))
|
val = m.group(2) + m.group(3)
|
||||||
if not has_rec(m.group(1), "TXT", prefix="v=DKIM1; "):
|
if not has_rec(m.group(1), "TXT", prefix="v=DKIM1; "):
|
||||||
records.append((m.group(1), "TXT", val, "Recommended. Provides a way for recipients to verify that this machine sent @%s mail." % domain))
|
records.append((m.group(1), "TXT", val, "Recommended. Provides a way for recipients to verify that this machine sent @%s mail." % domain))
|
||||||
|
|
||||||
@@ -383,16 +373,9 @@ $TTL 1800 ; default time to live
|
|||||||
zone += subdomain
|
zone += subdomain
|
||||||
zone += "\tIN\t" + querytype + "\t"
|
zone += "\tIN\t" + querytype + "\t"
|
||||||
if querytype == "TXT":
|
if querytype == "TXT":
|
||||||
# Divide into 255-byte max substrings.
|
value = value.replace('\\', '\\\\') # escape backslashes
|
||||||
v2 = ""
|
value = value.replace('"', '\\"') # escape quotes
|
||||||
while len(value) > 0:
|
value = '"' + value + '"' # wrap in quotes
|
||||||
s = value[0:255]
|
|
||||||
value = value[255:]
|
|
||||||
s = s.replace('\\', '\\\\') # escape backslashes
|
|
||||||
s = s.replace('"', '\\"') # escape quotes
|
|
||||||
s = '"' + s + '"' # wrap in quotes
|
|
||||||
v2 += s + " "
|
|
||||||
value = v2
|
|
||||||
zone += value + "\n"
|
zone += value + "\n"
|
||||||
|
|
||||||
# DNSSEC requires re-signing a zone periodically. That requires
|
# DNSSEC requires re-signing a zone periodically. That requires
|
||||||
@@ -472,10 +455,17 @@ zone:
|
|||||||
zonefile: %s
|
zonefile: %s
|
||||||
""" % (domain, zonefile)
|
""" % (domain, zonefile)
|
||||||
|
|
||||||
# If custom secondary nameservers have been set, allow zone transfers
|
# If a custom secondary nameserver has been set, allow zone transfers
|
||||||
# and notifies to them.
|
# and notifies to that nameserver.
|
||||||
for ipaddr in get_secondary_dns(additional_records, mode="xfr"):
|
if get_secondary_dns(additional_records):
|
||||||
nsdconf += "\n\tnotify: %s NOKEY\n\tprovide-xfr: %s NOKEY\n" % (ipaddr, ipaddr)
|
# Get the IP address of the nameserver by resolving it.
|
||||||
|
hostname = get_secondary_dns(additional_records)
|
||||||
|
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 file is changing. If it isn't changing,
|
# Check if the file is changing. If it isn't changing,
|
||||||
# return False to flag that no change was made.
|
# return False to flag that no change was made.
|
||||||
@@ -788,75 +778,38 @@ def set_custom_dns_record(qname, rtype, value, action, env):
|
|||||||
if made_change:
|
if made_change:
|
||||||
# serialize & save
|
# serialize & save
|
||||||
write_custom_dns_config(newconfig, env)
|
write_custom_dns_config(newconfig, env)
|
||||||
|
|
||||||
return made_change
|
return made_change
|
||||||
|
|
||||||
########################################################################
|
########################################################################
|
||||||
|
|
||||||
def get_secondary_dns(custom_dns, mode=None):
|
def get_secondary_dns(custom_dns):
|
||||||
resolver = dns.resolver.get_default_resolver()
|
|
||||||
|
|
||||||
values = []
|
|
||||||
for qname, rtype, value in custom_dns:
|
for qname, rtype, value in custom_dns:
|
||||||
if qname != '_secondary_nameserver': continue
|
if qname == "_secondary_nameserver":
|
||||||
for hostname in value.split(" "):
|
return value
|
||||||
hostname = hostname.strip()
|
return None
|
||||||
if mode == None:
|
|
||||||
# Just return the setting.
|
|
||||||
values.append(hostname)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# This is a hostname. Before including in zone xfr lines,
|
def set_secondary_dns(hostname, env):
|
||||||
# resolve to an IP address. Otherwise just return the hostname.
|
|
||||||
if not hostname.startswith("xfr:"):
|
|
||||||
if mode == "xfr":
|
|
||||||
response = dns.resolver.query(hostname+'.', "A")
|
|
||||||
hostname = str(response[0])
|
|
||||||
values.append(hostname)
|
|
||||||
|
|
||||||
# This is a zone-xfer-only IP address. Do not return if
|
if hostname in (None, ""):
|
||||||
# we're querying for NS record hostnames. Only return if
|
|
||||||
# we're querying for zone xfer IP addresses - return the
|
|
||||||
# IP address.
|
|
||||||
elif mode == "xfr":
|
|
||||||
values.append(hostname[4:])
|
|
||||||
|
|
||||||
return values
|
|
||||||
|
|
||||||
def set_secondary_dns(hostnames, env):
|
|
||||||
if len(hostnames) > 0:
|
|
||||||
# Validate that all hostnames are valid and that all zone-xfer IP addresses are valid.
|
|
||||||
resolver = dns.resolver.get_default_resolver()
|
|
||||||
for item in hostnames:
|
|
||||||
if not item.startswith("xfr:"):
|
|
||||||
# Resolve hostname.
|
|
||||||
try:
|
|
||||||
response = dns.resolver.query(item, "A")
|
|
||||||
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
|
||||||
raise ValueError("Could not resolve the IP address of %s." % item)
|
|
||||||
else:
|
|
||||||
# Validate IP address.
|
|
||||||
try:
|
|
||||||
v = ipaddress.ip_address(item[4:]) # raises a ValueError if there's a problem
|
|
||||||
if not isinstance(v, ipaddress.IPv4Address): raise ValueError("That's an IPv6 address.")
|
|
||||||
except ValueError:
|
|
||||||
raise ValueError("'%s' is not an IPv4 address." % item[4:])
|
|
||||||
|
|
||||||
# Set.
|
|
||||||
set_custom_dns_record("_secondary_nameserver", "A", " ".join(hostnames), "set", env)
|
|
||||||
else:
|
|
||||||
# Clear.
|
# Clear.
|
||||||
set_custom_dns_record("_secondary_nameserver", "A", None, "set", env)
|
set_custom_dns_record("_secondary_nameserver", "A", None, "set", env)
|
||||||
|
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.
|
||||||
|
set_custom_dns_record("_secondary_nameserver", "A", hostname, "set", env)
|
||||||
|
|
||||||
# Apply.
|
# Apply.
|
||||||
return do_dns_update(env)
|
return do_dns_update(env)
|
||||||
|
|
||||||
|
|
||||||
def get_custom_dns_record(custom_dns, qname, rtype):
|
|
||||||
for qname1, rtype1, value in custom_dns:
|
|
||||||
if qname1 == qname and rtype1 == rtype:
|
|
||||||
return value
|
|
||||||
return None
|
|
||||||
|
|
||||||
########################################################################
|
########################################################################
|
||||||
|
|
||||||
def justtestingdotemail(domain, records):
|
def justtestingdotemail(domain, records):
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
|
|
||||||
from collections import defaultdict
|
|
||||||
import re, os.path
|
import re, os.path
|
||||||
import dateutil.parser
|
import dateutil.parser
|
||||||
|
|
||||||
@@ -13,7 +12,6 @@ def scan_mail_log(logger, env):
|
|||||||
"imap-logins": { },
|
"imap-logins": { },
|
||||||
"postgrey": { },
|
"postgrey": { },
|
||||||
"rejected-mail": { },
|
"rejected-mail": { },
|
||||||
"activity-by-hour": { "imap-logins": defaultdict(int), "smtp-sends": defaultdict(int) },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
collector["real_mail_addresses"] = set(mailconfig.get_mail_users(env)) | set(alias[0] for alias in mailconfig.get_mail_aliases(env))
|
collector["real_mail_addresses"] = set(mailconfig.get_mail_users(env)) | set(alias[0] for alias in mailconfig.get_mail_aliases(env))
|
||||||
@@ -47,10 +45,6 @@ def scan_mail_log(logger, env):
|
|||||||
for date, sender, message in collector["rejected-mail"][k]:
|
for date, sender, message in collector["rejected-mail"][k]:
|
||||||
logger.print_line(k + "\t" + str(date) + "\t" + sender + "\t" + message)
|
logger.print_line(k + "\t" + str(date) + "\t" + sender + "\t" + message)
|
||||||
|
|
||||||
logger.add_heading("Activity by Hour")
|
|
||||||
for h in range(24):
|
|
||||||
logger.print_line("%d\t%d\t%d" % (h, collector["activity-by-hour"]["imap-logins"][h], collector["activity-by-hour"]["smtp-sends"][h] ))
|
|
||||||
|
|
||||||
if len(collector["other-services"]) > 0:
|
if len(collector["other-services"]) > 0:
|
||||||
logger.add_heading("Other")
|
logger.add_heading("Other")
|
||||||
logger.print_block("Unrecognized services in the log: " + ", ".join(collector["other-services"]))
|
logger.print_block("Unrecognized services in the log: " + ", ".join(collector["other-services"]))
|
||||||
@@ -71,9 +65,6 @@ def scan_mail_log_line(line, collector):
|
|||||||
elif service == "postfix/smtpd":
|
elif service == "postfix/smtpd":
|
||||||
scan_postfix_smtpd_line(date, log, collector)
|
scan_postfix_smtpd_line(date, log, collector)
|
||||||
|
|
||||||
elif service == "postfix/submission/smtpd":
|
|
||||||
scan_postfix_submission_line(date, log, collector)
|
|
||||||
|
|
||||||
elif service in ("postfix/qmgr", "postfix/pickup", "postfix/cleanup",
|
elif service in ("postfix/qmgr", "postfix/pickup", "postfix/cleanup",
|
||||||
"postfix/scache", "spampd", "postfix/anvil", "postfix/master",
|
"postfix/scache", "spampd", "postfix/anvil", "postfix/master",
|
||||||
"opendkim", "postfix/lmtp", "postfix/tlsmgr"):
|
"opendkim", "postfix/lmtp", "postfix/tlsmgr"):
|
||||||
@@ -89,7 +80,6 @@ def scan_dovecot_line(date, log, collector):
|
|||||||
login, ip = m.group(1), m.group(2)
|
login, ip = m.group(1), m.group(2)
|
||||||
if ip != "127.0.0.1": # local login from webmail/zpush
|
if ip != "127.0.0.1": # local login from webmail/zpush
|
||||||
collector["imap-logins"].setdefault(login, {})[ip] = date
|
collector["imap-logins"].setdefault(login, {})[ip] = date
|
||||||
collector["activity-by-hour"]["imap-logins"][date.hour] += 1
|
|
||||||
|
|
||||||
def scan_postgrey_line(date, log, collector):
|
def scan_postgrey_line(date, log, collector):
|
||||||
m = re.match("action=(greylist|pass), reason=(.*?), (?:delay=\d+, )?client_name=(.*), client_address=(.*), sender=(.*), recipient=(.*)", log)
|
m = re.match("action=(greylist|pass), reason=(.*?), (?:delay=\d+, )?client_name=(.*), client_address=(.*), sender=(.*), recipient=(.*)", log)
|
||||||
@@ -124,11 +114,6 @@ def scan_postfix_smtpd_line(date, log, collector):
|
|||||||
|
|
||||||
collector["rejected-mail"].setdefault(recipient, []).append( (date, sender, message) )
|
collector["rejected-mail"].setdefault(recipient, []).append( (date, sender, message) )
|
||||||
|
|
||||||
def scan_postfix_submission_line(date, log, collector):
|
|
||||||
m = re.match("([A-Z0-9]+): client=(\S+), sasl_method=PLAIN, sasl_username=(\S+)", log)
|
|
||||||
if m:
|
|
||||||
procid, client, user = m.groups()
|
|
||||||
collector["activity-by-hour"]["smtp-sends"][date.hour] += 1
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
from status_checks import ConsoleOutput
|
from status_checks import ConsoleOutput
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import subprocess, shutil, os, sqlite3, re
|
import subprocess, shutil, os, sqlite3, re
|
||||||
import utils
|
import utils
|
||||||
from email_validator import validate_email as validate_email_, EmailNotValidError
|
from email_validator import validate_email as validate_email_, EmailNotValidError
|
||||||
import idna
|
|
||||||
|
|
||||||
def validate_email(email, mode=None):
|
def validate_email(email, mode=None):
|
||||||
# Checks that an email address is syntactically valid. Returns True/False.
|
# Checks that an email address is syntactically valid. Returns True/False.
|
||||||
@@ -53,13 +52,11 @@ def sanitize_idn_email_address(email):
|
|||||||
# to the underlying protocols.
|
# to the underlying protocols.
|
||||||
try:
|
try:
|
||||||
localpart, domainpart = email.split("@")
|
localpart, domainpart = email.split("@")
|
||||||
domainpart = idna.encode(domainpart).decode('ascii')
|
domainpart = domainpart.encode("idna").decode('ascii')
|
||||||
return localpart + "@" + domainpart
|
return localpart + "@" + domainpart
|
||||||
except (ValueError, idna.IDNAError):
|
except:
|
||||||
# ValueError: String does not have a single @-sign, so it is not
|
# Domain part is not IDNA-valid, so leave unchanged. If there
|
||||||
# a valid email address. IDNAError: Domain part is not IDNA-valid.
|
# are non-ASCII characters it will be filtered out by
|
||||||
# Validation is not this function's job, so return value unchanged.
|
|
||||||
# If there are non-ASCII characters it will be filtered out by
|
|
||||||
# validate_email.
|
# validate_email.
|
||||||
return email
|
return email
|
||||||
|
|
||||||
@@ -68,11 +65,10 @@ def prettify_idn_email_address(email):
|
|||||||
# names in IDNA in the database, but we want to show Unicode to the user.
|
# names in IDNA in the database, but we want to show Unicode to the user.
|
||||||
try:
|
try:
|
||||||
localpart, domainpart = email.split("@")
|
localpart, domainpart = email.split("@")
|
||||||
domainpart = idna.decode(domainpart.encode("ascii"))
|
domainpart = domainpart.encode("ascii").decode('idna')
|
||||||
return localpart + "@" + domainpart
|
return localpart + "@" + domainpart
|
||||||
except (ValueError, UnicodeError, idna.IDNAError):
|
except:
|
||||||
# Failed to decode IDNA, or the email address does not have a
|
# Failed to decode IDNA. Should never happen.
|
||||||
# single @-sign. Should never happen.
|
|
||||||
return email
|
return email
|
||||||
|
|
||||||
def is_dcv_address(email):
|
def is_dcv_address(email):
|
||||||
@@ -181,13 +177,13 @@ def get_admins(env):
|
|||||||
return users
|
return users
|
||||||
|
|
||||||
def get_mail_aliases(env):
|
def get_mail_aliases(env):
|
||||||
# Returns a sorted list of tuples of (address, forward-tos, permitted-senders).
|
# Returns a sorted list of tuples of (alias, forward-to string).
|
||||||
c = open_database(env)
|
c = open_database(env)
|
||||||
c.execute('SELECT source, destination, permitted_senders FROM aliases')
|
c.execute('SELECT source, destination FROM aliases')
|
||||||
aliases = { row[0]: row for row in c.fetchall() } # make dict
|
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
|
# put in a canonical order: sort by domain, then by email address lexicographically
|
||||||
aliases = [ aliases[address] for address in utils.sort_email_addresses(aliases.keys(), env) ]
|
aliases = [ (source, aliases[source]) for source in utils.sort_email_addresses(aliases.keys(), env) ]
|
||||||
return aliases
|
return aliases
|
||||||
|
|
||||||
def get_mail_aliases_ex(env):
|
def get_mail_aliases_ex(env):
|
||||||
@@ -199,10 +195,9 @@ def get_mail_aliases_ex(env):
|
|||||||
# domain: "domain.tld",
|
# domain: "domain.tld",
|
||||||
# alias: [
|
# alias: [
|
||||||
# {
|
# {
|
||||||
# address: "name@domain.tld", # IDNA-encoded
|
# source: "name@domain.tld", # IDNA-encoded
|
||||||
# address_display: "name@domain.tld", # full Unicode
|
# source_display: "name@domain.tld", # full Unicode
|
||||||
# forwards_to: ["user1@domain.com", "receiver-only1@domain.com", ...],
|
# destination: ["target1@domain.com", "target2@domain.com", ...],
|
||||||
# permitted_senders: ["user1@domain.com", "sender-only1@domain.com", ...] OR null,
|
|
||||||
# required: True|False
|
# required: True|False
|
||||||
# },
|
# },
|
||||||
# ...
|
# ...
|
||||||
@@ -213,10 +208,10 @@ def get_mail_aliases_ex(env):
|
|||||||
|
|
||||||
required_aliases = get_required_aliases(env)
|
required_aliases = get_required_aliases(env)
|
||||||
domains = {}
|
domains = {}
|
||||||
for address, forwards_to, permitted_senders in get_mail_aliases(env):
|
for source, destination in get_mail_aliases(env):
|
||||||
# get alias info
|
# get alias info
|
||||||
domain = get_domain(address)
|
domain = get_domain(source)
|
||||||
required = (address in required_aliases)
|
required = (source in required_aliases)
|
||||||
|
|
||||||
# add to list
|
# add to list
|
||||||
if not domain in domains:
|
if not domain in domains:
|
||||||
@@ -225,40 +220,33 @@ def get_mail_aliases_ex(env):
|
|||||||
"aliases": [],
|
"aliases": [],
|
||||||
}
|
}
|
||||||
domains[domain]["aliases"].append({
|
domains[domain]["aliases"].append({
|
||||||
"address": address,
|
"source": source,
|
||||||
"address_display": prettify_idn_email_address(address),
|
"source_display": prettify_idn_email_address(source),
|
||||||
"forwards_to": [prettify_idn_email_address(r.strip()) for r in forwards_to.split(",")],
|
"destination": [prettify_idn_email_address(d.strip()) for d in destination.split(",")],
|
||||||
"permitted_senders": [prettify_idn_email_address(s.strip()) for s in permitted_senders.split(",")] if permitted_senders is not None else None,
|
|
||||||
"required": required,
|
"required": required,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Sort domains.
|
# Sort domains.
|
||||||
domains = [domains[domain] for domain in utils.sort_domains(domains.keys(), env)]
|
domains = [domains[domain] for domain in utils.sort_domains(domains.keys(), env)]
|
||||||
|
|
||||||
# Sort aliases within each domain first by required-ness then lexicographically by address.
|
# Sort aliases within each domain first by required-ness then lexicographically by source address.
|
||||||
for domain in domains:
|
for domain in domains:
|
||||||
domain["aliases"].sort(key = lambda alias : (alias["required"], alias["address"]))
|
domain["aliases"].sort(key = lambda alias : (alias["required"], alias["source"]))
|
||||||
return domains
|
return domains
|
||||||
|
|
||||||
def get_domain(emailaddr, as_unicode=True):
|
def get_domain(emailaddr, as_unicode=True):
|
||||||
# Gets the domain part of an email address. Turns IDNA
|
# Gets the domain part of an email address. Turns IDNA
|
||||||
# back to Unicode for display.
|
# back to Unicode for display.
|
||||||
ret = emailaddr.split('@', 1)[1]
|
ret = emailaddr.split('@', 1)[1]
|
||||||
if as_unicode:
|
if as_unicode: ret = ret.encode('ascii').decode('idna')
|
||||||
try:
|
|
||||||
ret = idna.decode(ret.encode('ascii'))
|
|
||||||
except (ValueError, UnicodeError, idna.IDNAError):
|
|
||||||
# Looks like we have an invalid email address in
|
|
||||||
# the database. Now is not the time to complain.
|
|
||||||
pass
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def get_mail_domains(env, filter_aliases=lambda alias : True):
|
def get_mail_domains(env, filter_aliases=lambda alias : True):
|
||||||
# Returns the domain names (IDNA-encoded) of all of the email addresses
|
# Returns the domain names (IDNA-encoded) of all of the email addresses
|
||||||
# configured on the system.
|
# configured on the system.
|
||||||
return set(
|
return set(
|
||||||
[get_domain(login, as_unicode=False) for login in get_mail_users(env)]
|
[get_domain(addr, as_unicode=False) for addr in get_mail_users(env)]
|
||||||
+ [get_domain(address, as_unicode=False) for address, *_ in get_mail_aliases(env) if filter_aliases(address) ]
|
+ [get_domain(source, as_unicode=False) for source, target in get_mail_aliases(env) if filter_aliases((source, target)) ]
|
||||||
)
|
)
|
||||||
|
|
||||||
def add_mail_user(email, pw, privs, env):
|
def add_mail_user(email, pw, privs, env):
|
||||||
@@ -303,6 +291,25 @@ def add_mail_user(email, pw, privs, env):
|
|||||||
# write databasebefore next step
|
# write databasebefore next step
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
# Create & subscribe the user's INBOX, Trash, Spam, and Drafts folders.
|
||||||
|
# * Our sieve rule for spam expects that the Spam folder exists.
|
||||||
|
# * Roundcube will show an error if the user tries to delete a message before the Trash folder exists (#359).
|
||||||
|
# * K-9 mail will poll every 90 seconds if a Drafts folder does not exist, so create it
|
||||||
|
# to avoid unnecessary polling.
|
||||||
|
|
||||||
|
# Check if the mailboxes exist before creating them. When creating a user that had previously
|
||||||
|
# been deleted, the mailboxes will still exist because they are still on disk.
|
||||||
|
try:
|
||||||
|
existing_mboxes = utils.shell('check_output', ["doveadm", "mailbox", "list", "-u", email, "-8"], capture_stderr=True).split("\n")
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
c.execute("DELETE FROM users WHERE email=?", (email,))
|
||||||
|
conn.commit()
|
||||||
|
return ("Failed to initialize the user: " + e.output.decode("utf8"), 400)
|
||||||
|
|
||||||
|
for folder in ("INBOX", "Trash", "Spam", "Drafts"):
|
||||||
|
if folder not in existing_mboxes:
|
||||||
|
utils.shell('check_call', ["doveadm", "mailbox", "create", "-u", email, "-s", folder])
|
||||||
|
|
||||||
# Update things in case any new domains are added.
|
# Update things in case any new domains are added.
|
||||||
return kick(env, "mail user added")
|
return kick(env, "mail user added")
|
||||||
|
|
||||||
@@ -395,91 +402,67 @@ def add_remove_mail_user_privilege(email, priv, action, env):
|
|||||||
|
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
def add_mail_alias(address, forwards_to, permitted_senders, env, update_if_exists=False, do_kick=True):
|
def add_mail_alias(source, destination, env, update_if_exists=False, do_kick=True):
|
||||||
# convert Unicode domain to IDNA
|
# convert Unicode domain to IDNA
|
||||||
address = sanitize_idn_email_address(address)
|
source = sanitize_idn_email_address(source)
|
||||||
|
|
||||||
# Our database is case sensitive (oops), which affects mail delivery
|
# Our database is case sensitive (oops), which affects mail delivery
|
||||||
# (Postfix always queries in lowercase?), so force lowercase.
|
# (Postfix always queries in lowercase?), so force lowercase.
|
||||||
address = address.lower()
|
source = source.lower()
|
||||||
|
|
||||||
# validate address
|
# validate source
|
||||||
address = address.strip()
|
source = source.strip()
|
||||||
if address == "":
|
if source == "":
|
||||||
return ("No email address provided.", 400)
|
return ("No incoming email address provided.", 400)
|
||||||
if not validate_email(address, mode='alias'):
|
if not validate_email(source, mode='alias'):
|
||||||
return ("Invalid email address (%s)." % address, 400)
|
return ("Invalid incoming email address (%s)." % source, 400)
|
||||||
|
|
||||||
# validate forwards_to
|
|
||||||
validated_forwards_to = []
|
|
||||||
forwards_to = forwards_to.strip()
|
|
||||||
|
|
||||||
# extra checks for email addresses used in domain control validation
|
# extra checks for email addresses used in domain control validation
|
||||||
is_dcv_source = is_dcv_address(address)
|
is_dcv_source = is_dcv_address(source)
|
||||||
|
|
||||||
|
# validate destination
|
||||||
|
dests = []
|
||||||
|
destination = destination.strip()
|
||||||
|
|
||||||
# Postfix allows a single @domain.tld as the destination, which means
|
# Postfix allows a single @domain.tld as the destination, which means
|
||||||
# the local part on the address is preserved in the rewrite. We must
|
# the local part on the address is preserved in the rewrite. We must
|
||||||
# try to convert Unicode to IDNA first before validating that it's a
|
# try to convert Unicode to IDNA first before validating that it's a
|
||||||
# legitimate alias address. Don't allow this sort of rewriting for
|
# legitimate alias address. Don't allow this sort of rewriting for
|
||||||
# DCV source addresses.
|
# DCV source addresses.
|
||||||
r1 = sanitize_idn_email_address(forwards_to)
|
d1 = sanitize_idn_email_address(destination)
|
||||||
if validate_email(r1, mode='alias') and not is_dcv_source:
|
if validate_email(d1, mode='alias') and not is_dcv_source:
|
||||||
validated_forwards_to.append(r1)
|
dests.append(d1)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Parse comma and \n-separated destination emails & validate. In this
|
# Parse comma and \n-separated destination emails & validate. In this
|
||||||
# case, the forwards_to must be complete email addresses.
|
# case, the recipients must be complete email addresses.
|
||||||
for line in forwards_to.split("\n"):
|
for line in destination.split("\n"):
|
||||||
for email in line.split(","):
|
for email in line.split(","):
|
||||||
email = email.strip()
|
email = email.strip()
|
||||||
if email == "": continue
|
if email == "": continue
|
||||||
email = sanitize_idn_email_address(email) # Unicode => IDNA
|
email = sanitize_idn_email_address(email) # Unicode => IDNA
|
||||||
if not validate_email(email):
|
if not validate_email(email):
|
||||||
return ("Invalid receiver email address (%s)." % email, 400)
|
return ("Invalid destination email address (%s)." % email, 400)
|
||||||
if is_dcv_source and not is_dcv_address(email) and "admin" not in get_mail_user_privileges(email, env, empty_on_error=True):
|
if is_dcv_source and not is_dcv_address(email) and "admin" not in get_mail_user_privileges(email, env, empty_on_error=True):
|
||||||
# Make domain control validation hijacking a little harder to mess up by
|
# Make domain control validation hijacking a little harder to mess up by
|
||||||
# requiring aliases for email addresses typically used in DCV to forward
|
# requiring aliases for email addresses typically used in DCV to forward
|
||||||
# only to accounts that are administrators on this system.
|
# only to accounts that are administrators on this system.
|
||||||
return ("This alias can only have administrators of this system as destinations because the address is frequently used for domain control validation.", 400)
|
return ("This alias can only have administrators of this system as destinations because the address is frequently used for domain control validation.", 400)
|
||||||
validated_forwards_to.append(email)
|
dests.append(email)
|
||||||
|
if len(destination) == 0:
|
||||||
# validate permitted_senders
|
return ("No destination email address(es) provided.", 400)
|
||||||
valid_logins = get_mail_users(env)
|
destination = ",".join(dests)
|
||||||
validated_permitted_senders = []
|
|
||||||
permitted_senders = permitted_senders.strip()
|
|
||||||
|
|
||||||
# Parse comma and \n-separated sender logins & validate. The permitted_senders must be
|
|
||||||
# valid usernames.
|
|
||||||
for line in permitted_senders.split("\n"):
|
|
||||||
for login in line.split(","):
|
|
||||||
login = login.strip()
|
|
||||||
if login == "": continue
|
|
||||||
if login not in valid_logins:
|
|
||||||
return ("Invalid permitted sender: %s is not a user on this system." % login, 400)
|
|
||||||
validated_permitted_senders.append(login)
|
|
||||||
|
|
||||||
# Make sure the alias has either a forwards_to or a permitted_sender.
|
|
||||||
if len(validated_forwards_to) + len(validated_permitted_senders) == 0:
|
|
||||||
return ("The alias must either forward to an address or have a permitted sender.", 400)
|
|
||||||
|
|
||||||
# save to db
|
# save to db
|
||||||
|
|
||||||
forwards_to = ",".join(validated_forwards_to)
|
|
||||||
|
|
||||||
if len(validated_permitted_senders) == 0:
|
|
||||||
permitted_senders = None
|
|
||||||
else:
|
|
||||||
permitted_senders = ",".join(validated_permitted_senders)
|
|
||||||
|
|
||||||
conn, c = open_database(env, with_connection=True)
|
conn, c = open_database(env, with_connection=True)
|
||||||
try:
|
try:
|
||||||
c.execute("INSERT INTO aliases (source, destination, permitted_senders) VALUES (?, ?, ?)", (address, forwards_to, permitted_senders))
|
c.execute("INSERT INTO aliases (source, destination) VALUES (?, ?)", (source, destination))
|
||||||
return_status = "alias added"
|
return_status = "alias added"
|
||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
if not update_if_exists:
|
if not update_if_exists:
|
||||||
return ("Alias already exists (%s)." % address, 400)
|
return ("Alias already exists (%s)." % source, 400)
|
||||||
else:
|
else:
|
||||||
c.execute("UPDATE aliases SET destination = ?, permitted_senders = ? WHERE source = ?", (forwards_to, permitted_senders, address))
|
c.execute("UPDATE aliases SET destination = ? WHERE source = ?", (destination, source))
|
||||||
return_status = "alias updated"
|
return_status = "alias updated"
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -488,15 +471,15 @@ def add_mail_alias(address, forwards_to, permitted_senders, env, update_if_exist
|
|||||||
# Update things in case any new domains are added.
|
# Update things in case any new domains are added.
|
||||||
return kick(env, return_status)
|
return kick(env, return_status)
|
||||||
|
|
||||||
def remove_mail_alias(address, env, do_kick=True):
|
def remove_mail_alias(source, env, do_kick=True):
|
||||||
# convert Unicode domain to IDNA
|
# convert Unicode domain to IDNA
|
||||||
address = sanitize_idn_email_address(address)
|
source = sanitize_idn_email_address(source)
|
||||||
|
|
||||||
# remove
|
# remove
|
||||||
conn, c = open_database(env, with_connection=True)
|
conn, c = open_database(env, with_connection=True)
|
||||||
c.execute("DELETE FROM aliases WHERE source=?", (address,))
|
c.execute("DELETE FROM aliases WHERE source=?", (source,))
|
||||||
if c.rowcount != 1:
|
if c.rowcount != 1:
|
||||||
return ("That's not an alias (%s)." % address, 400)
|
return ("That's not an alias (%s)." % source, 400)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
if do_kick:
|
if do_kick:
|
||||||
@@ -520,8 +503,8 @@ def get_required_aliases(env):
|
|||||||
# email on that domain are the required aliases or a catch-all/domain-forwarder.
|
# email on that domain are the required aliases or a catch-all/domain-forwarder.
|
||||||
real_mail_domains = get_mail_domains(env,
|
real_mail_domains = get_mail_domains(env,
|
||||||
filter_aliases = lambda alias :
|
filter_aliases = lambda alias :
|
||||||
not alias.startswith("postmaster@") and not alias.startswith("admin@")
|
not alias[0].startswith("postmaster@") and not alias[0].startswith("admin@")
|
||||||
and not alias.startswith("@")
|
and not alias[0].startswith("@")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create postmaster@ and admin@ for all domains we serve mail on.
|
# Create postmaster@ and admin@ for all domains we serve mail on.
|
||||||
@@ -545,38 +528,36 @@ def kick(env, mail_result=None):
|
|||||||
# Ensure every required alias exists.
|
# Ensure every required alias exists.
|
||||||
|
|
||||||
existing_users = get_mail_users(env)
|
existing_users = get_mail_users(env)
|
||||||
existing_alias_records = get_mail_aliases(env)
|
existing_aliases = get_mail_aliases(env)
|
||||||
existing_aliases = set(a for a, *_ in existing_alias_records) # just first entry in tuple
|
|
||||||
required_aliases = get_required_aliases(env)
|
required_aliases = get_required_aliases(env)
|
||||||
|
|
||||||
def ensure_admin_alias_exists(address):
|
def ensure_admin_alias_exists(source):
|
||||||
# If a user account exists with that address, we're good.
|
# If a user account exists with that address, we're good.
|
||||||
if address in existing_users:
|
if source in existing_users:
|
||||||
return
|
return
|
||||||
|
|
||||||
# If the alias already exists, we're good.
|
# Does this alias exists?
|
||||||
if address in existing_aliases:
|
for s, t in existing_aliases:
|
||||||
return
|
if s == source:
|
||||||
|
return
|
||||||
|
|
||||||
# Doesn't exist.
|
# Doesn't exist.
|
||||||
administrator = get_system_administrator(env)
|
administrator = get_system_administrator(env)
|
||||||
if address == administrator: return # don't make an alias from the administrator to itself --- this alias must be created manually
|
add_mail_alias(source, administrator, env, do_kick=False)
|
||||||
add_mail_alias(address, administrator, "", env, do_kick=False)
|
results.append("added alias %s (=> %s)\n" % (source, administrator))
|
||||||
if administrator not in existing_aliases: return # don't report the alias in output if the administrator alias isn't in yet -- this is a hack to supress confusing output on initial setup
|
|
||||||
results.append("added alias %s (=> %s)\n" % (address, administrator))
|
|
||||||
|
|
||||||
for address in required_aliases:
|
for alias in required_aliases:
|
||||||
ensure_admin_alias_exists(address)
|
ensure_admin_alias_exists(alias)
|
||||||
|
|
||||||
# Remove auto-generated postmaster/admin on domains we no
|
# Remove auto-generated postmaster/admin on domains we no
|
||||||
# longer have any other email addresses for.
|
# longer have any other email addresses for.
|
||||||
for address, forwards_to, *_ in existing_alias_records:
|
for source, target in existing_aliases:
|
||||||
user, domain = address.split("@")
|
user, domain = source.split("@")
|
||||||
if user in ("postmaster", "admin") \
|
if user in ("postmaster", "admin") \
|
||||||
and address not in required_aliases \
|
and source not in required_aliases \
|
||||||
and forwards_to == get_system_administrator(env):
|
and target == get_system_administrator(env):
|
||||||
remove_mail_alias(address, env, do_kick=False)
|
remove_mail_alias(source, env, do_kick=False)
|
||||||
results.append("removed alias %s (was to %s; domain no longer used for email)\n" % (address, forwards_to))
|
results.append("removed alias %s (was to %s; domain no longer used for email)\n" % (source, target))
|
||||||
|
|
||||||
# Update DNS and nginx in case any domains are added/removed.
|
# Update DNS and nginx in case any domains are added/removed.
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,12 @@ import sys, os, os.path, re, subprocess, datetime, multiprocessing.pool
|
|||||||
|
|
||||||
import dns.reversename, dns.resolver
|
import dns.reversename, dns.resolver
|
||||||
import dateutil.parser, dateutil.tz
|
import dateutil.parser, dateutil.tz
|
||||||
import idna
|
|
||||||
|
|
||||||
from dns_update import get_dns_zones, build_tlsa_record, get_custom_dns_config, get_secondary_dns, get_custom_dns_record
|
from dns_update import get_dns_zones, build_tlsa_record, get_custom_dns_config, get_secondary_dns
|
||||||
from web_update import get_web_domains, get_default_www_redirects, get_ssl_certificates, get_domain_ssl_files, get_domains_with_a_records
|
from web_update import get_web_domains, get_default_www_redirects, get_domain_ssl_files
|
||||||
from mailconfig import get_mail_domains, get_mail_aliases
|
from mailconfig import get_mail_domains, get_mail_aliases
|
||||||
|
|
||||||
from utils import shell, sort_domains, load_env_vars_from_file, load_settings
|
from utils import shell, sort_domains, load_env_vars_from_file
|
||||||
|
|
||||||
def run_checks(rounded_values, env, output, pool):
|
def run_checks(rounded_values, env, output, pool):
|
||||||
# run systems checks
|
# run systems checks
|
||||||
@@ -33,7 +32,7 @@ def run_checks(rounded_values, env, output, pool):
|
|||||||
# (ignore errors; if bind9/rndc isn't running we'd already report
|
# (ignore errors; if bind9/rndc isn't running we'd already report
|
||||||
# that in run_services checks.)
|
# that in run_services checks.)
|
||||||
shell('check_call', ["/usr/sbin/rndc", "flush"], trap=True)
|
shell('check_call', ["/usr/sbin/rndc", "flush"], trap=True)
|
||||||
|
|
||||||
run_system_checks(rounded_values, env, output)
|
run_system_checks(rounded_values, env, output)
|
||||||
|
|
||||||
# perform other checks asynchronously
|
# perform other checks asynchronously
|
||||||
@@ -149,7 +148,6 @@ def check_service(i, service, env):
|
|||||||
def run_system_checks(rounded_values, env, output):
|
def run_system_checks(rounded_values, env, output):
|
||||||
check_ssh_password(env, output)
|
check_ssh_password(env, output)
|
||||||
check_software_updates(env, output)
|
check_software_updates(env, output)
|
||||||
check_miab_version(env, output)
|
|
||||||
check_system_aliases(env, output)
|
check_system_aliases(env, output)
|
||||||
check_free_disk_space(rounded_values, env, output)
|
check_free_disk_space(rounded_values, env, output)
|
||||||
|
|
||||||
@@ -245,49 +243,38 @@ def run_domain_checks(rounded_time, env, output, pool):
|
|||||||
|
|
||||||
domains_to_check = mail_domains | dns_domains | web_domains
|
domains_to_check = mail_domains | dns_domains | web_domains
|
||||||
|
|
||||||
# Get the list of domains that we don't serve web for because of a custom CNAME/A record.
|
|
||||||
domains_with_a_records = get_domains_with_a_records(env)
|
|
||||||
|
|
||||||
ssl_certificates = get_ssl_certificates(env)
|
|
||||||
|
|
||||||
# Serial version:
|
# Serial version:
|
||||||
#for domain in sort_domains(domains_to_check, env):
|
#for domain in sort_domains(domains_to_check, env):
|
||||||
# run_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains)
|
# run_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains)
|
||||||
|
|
||||||
# Parallelize the checks across a worker pool.
|
# Parallelize the checks across a worker pool.
|
||||||
args = ((domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains, domains_with_a_records, ssl_certificates)
|
args = ((domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains)
|
||||||
for domain in domains_to_check)
|
for domain in domains_to_check)
|
||||||
ret = pool.starmap(run_domain_checks_on_domain, args, chunksize=1)
|
ret = pool.starmap(run_domain_checks_on_domain, args, chunksize=1)
|
||||||
ret = dict(ret) # (domain, output) => { domain: output }
|
ret = dict(ret) # (domain, output) => { domain: output }
|
||||||
for domain in sort_domains(ret, env):
|
for domain in sort_domains(ret, env):
|
||||||
ret[domain].playback(output)
|
ret[domain].playback(output)
|
||||||
|
|
||||||
def run_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains, domains_with_a_records, ssl_certificates):
|
def run_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains):
|
||||||
output = BufferedOutput()
|
output = BufferedOutput()
|
||||||
|
|
||||||
# The domain is IDNA-encoded in the database, but for display use Unicode.
|
# The domain is IDNA-encoded, but for display use Unicode.
|
||||||
try:
|
output.add_heading(domain.encode('ascii').decode('idna'))
|
||||||
domain_display = idna.decode(domain.encode('ascii'))
|
|
||||||
output.add_heading(domain_display)
|
|
||||||
except (ValueError, UnicodeError, idna.IDNAError) as e:
|
|
||||||
# Looks like we have some invalid data in our database.
|
|
||||||
output.add_heading(domain)
|
|
||||||
output.print_error("Domain name is invalid: " + str(e))
|
|
||||||
|
|
||||||
if domain == env["PRIMARY_HOSTNAME"]:
|
if domain == env["PRIMARY_HOSTNAME"]:
|
||||||
check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles)
|
check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles)
|
||||||
|
|
||||||
if domain in dns_domains:
|
if domain in dns_domains:
|
||||||
check_dns_zone(domain, env, output, dns_zonefiles)
|
check_dns_zone(domain, env, output, dns_zonefiles)
|
||||||
|
|
||||||
if domain in mail_domains:
|
if domain in mail_domains:
|
||||||
check_mail_domain(domain, env, output)
|
check_mail_domain(domain, env, output)
|
||||||
|
|
||||||
if domain in web_domains:
|
if domain in web_domains:
|
||||||
check_web_domain(domain, rounded_time, ssl_certificates, env, output)
|
check_web_domain(domain, rounded_time, env, output)
|
||||||
|
|
||||||
if domain in dns_domains:
|
if domain in dns_domains:
|
||||||
check_dns_zone_suggestions(domain, env, output, dns_zonefiles, domains_with_a_records)
|
check_dns_zone_suggestions(domain, env, output, dns_zonefiles)
|
||||||
|
|
||||||
return (domain, output)
|
return (domain, output)
|
||||||
|
|
||||||
@@ -363,14 +350,11 @@ def check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles):
|
|||||||
check_alias_exists("Hostmaster contact address", "hostmaster@" + domain, env, output)
|
check_alias_exists("Hostmaster contact address", "hostmaster@" + domain, env, output)
|
||||||
|
|
||||||
def check_alias_exists(alias_name, alias, env, output):
|
def check_alias_exists(alias_name, alias, env, output):
|
||||||
mail_aliases = dict([(address, receivers) for address, receivers, *_ in get_mail_aliases(env)])
|
mail_alises = dict(get_mail_aliases(env))
|
||||||
if alias in mail_aliases:
|
if alias in mail_alises:
|
||||||
if mail_aliases[alias]:
|
output.print_ok("%s exists as a mail alias. [%s ↦ %s]" % (alias_name, alias, mail_alises[alias]))
|
||||||
output.print_ok("%s exists as a mail alias. [%s ↦ %s]" % (alias_name, alias, mail_aliases[alias]))
|
|
||||||
else:
|
|
||||||
output.print_error("""You must set the destination of the mail alias for %s to direct email to you or another administrator.""" % alias)
|
|
||||||
else:
|
else:
|
||||||
output.print_error("""You must add a mail alias for %s which directs email to you or another administrator.""" % alias)
|
output.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, output, dns_zonefiles):
|
def check_dns_zone(domain, env, output, dns_zonefiles):
|
||||||
# If a DS record is set at the registrar, check DNSSEC first because it will affect the NS query.
|
# If a DS record is set at the registrar, check DNSSEC first because it will affect the NS query.
|
||||||
@@ -380,24 +364,20 @@ def check_dns_zone(domain, env, output, dns_zonefiles):
|
|||||||
|
|
||||||
# We provide a DNS zone for the domain. It should have NS records set up
|
# 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. The secondary DNS
|
# at the domain name's registrar pointing to this box. The secondary DNS
|
||||||
# server may be customized.
|
# server may be customized. Unfortunately this may not check the domain's
|
||||||
# (I'm not sure whether this necessarily tests the TLD's configuration,
|
# whois information -- we may be getting the NS records from us rather than
|
||||||
# as it should, or if one successful NS line at the TLD will result in
|
# the TLD, and so we're not actually checking the TLD. For that we'd need
|
||||||
# this query being answered by the box, which would mean the test is only
|
# to do a DNS trace.
|
||||||
# half working.)
|
|
||||||
|
|
||||||
custom_dns_records = list(get_custom_dns_config(env)) # generator => list so we can reuse it
|
|
||||||
correct_ip = get_custom_dns_record(custom_dns_records, domain, "A") or env['PUBLIC_IP']
|
|
||||||
custom_secondary_ns = get_secondary_dns(custom_dns_records, mode="NS")
|
|
||||||
secondary_ns = custom_secondary_ns or ["ns2." + env['PRIMARY_HOSTNAME']]
|
|
||||||
|
|
||||||
existing_ns = query_dns(domain, "NS")
|
|
||||||
correct_ns = "; ".join(sorted(["ns1." + env['PRIMARY_HOSTNAME']] + secondary_ns))
|
|
||||||
ip = query_dns(domain, "A")
|
ip = query_dns(domain, "A")
|
||||||
|
secondary_ns = get_secondary_dns(get_custom_dns_config(env)) or "ns2." + env['PRIMARY_HOSTNAME']
|
||||||
|
existing_ns = query_dns(domain, "NS")
|
||||||
|
correct_ns = "; ".join(sorted([
|
||||||
|
"ns1." + env['PRIMARY_HOSTNAME'],
|
||||||
|
secondary_ns,
|
||||||
|
]))
|
||||||
if existing_ns.lower() == correct_ns.lower():
|
if existing_ns.lower() == correct_ns.lower():
|
||||||
output.print_ok("Nameservers are set correctly at registrar. [%s]" % correct_ns)
|
output.print_ok("Nameservers are set correctly at registrar. [%s]" % correct_ns)
|
||||||
elif ip == correct_ip:
|
elif ip == env['PUBLIC_IP']:
|
||||||
# The domain resolves correctly, so maybe the user is using External DNS.
|
# The domain resolves correctly, so maybe the user is using External DNS.
|
||||||
output.print_warning("""The nameservers set on this domain at your domain name registrar should be %s. They are currently %s.
|
output.print_warning("""The nameservers set on this domain at your domain name registrar should be %s. They are currently %s.
|
||||||
If you are using External DNS, this may be OK."""
|
If you are using External DNS, this may be OK."""
|
||||||
@@ -407,33 +387,7 @@ def check_dns_zone(domain, env, output, dns_zonefiles):
|
|||||||
control panel to set the nameservers to %s."""
|
control panel to set the nameservers to %s."""
|
||||||
% (existing_ns, correct_ns) )
|
% (existing_ns, correct_ns) )
|
||||||
|
|
||||||
# Check that each custom secondary nameserver resolves the IP address.
|
def check_dns_zone_suggestions(domain, env, output, dns_zonefiles):
|
||||||
|
|
||||||
if custom_secondary_ns:
|
|
||||||
for ns in custom_secondary_ns:
|
|
||||||
# We must first resolve the nameserver to an IP address so we can query it.
|
|
||||||
ns_ip = query_dns(ns, "A")
|
|
||||||
if not ns_ip:
|
|
||||||
output.print_error("Secondary nameserver %s is not valid (it doesn't resolve to an IP address)." % ns)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Now query it to see what it says about this domain.
|
|
||||||
ip = query_dns(domain, "A", at=ns_ip, nxdomain=None)
|
|
||||||
if ip == correct_ip:
|
|
||||||
output.print_ok("Secondary nameserver %s resolved the domain correctly." % ns)
|
|
||||||
elif ip is None:
|
|
||||||
output.print_error("Secondary nameserver %s is not configured to resolve this domain." % ns)
|
|
||||||
else:
|
|
||||||
output.print_error("Secondary nameserver %s is not configured correctly. (It resolved this domain as %s. It should be %s.)" % (ns, ip, env['PUBLIC_IP']))
|
|
||||||
|
|
||||||
def check_dns_zone_suggestions(domain, env, output, dns_zonefiles, domains_with_a_records):
|
|
||||||
# Warn if a custom DNS record is preventing this or the automatic www redirect from
|
|
||||||
# being served.
|
|
||||||
if domain in domains_with_a_records:
|
|
||||||
output.print_warning("""Web has been disabled for this domain because you have set a custom DNS record.""")
|
|
||||||
if "www." + domain in domains_with_a_records:
|
|
||||||
output.print_warning("""A redirect from 'www.%s' has been disabled for this domain because you have set a custom DNS record on the www subdomain.""" % domain)
|
|
||||||
|
|
||||||
# Since DNSSEC is optional, if a DS record is NOT set at the registrar suggest it.
|
# 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 it was set, we did the check earlier.)
|
||||||
if query_dns(domain, "DS", nxdomain=None) is None:
|
if query_dns(domain, "DS", nxdomain=None) is None:
|
||||||
@@ -444,9 +398,7 @@ def check_dnssec(domain, env, output, dns_zonefiles, is_checking_primary=False):
|
|||||||
# See if the domain has a DS record set at the registrar. The DS record may have
|
# 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
|
# 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.
|
# pre-generated all of the valid digests --- read them in.
|
||||||
ds_file = '/etc/nsd/zones/' + dns_zonefiles[domain] + '.ds'
|
ds_correct = open('/etc/nsd/zones/' + dns_zonefiles[domain] + '.ds').read().strip().split("\n")
|
||||||
if not os.path.exists(ds_file): return # Domain is in our database but DNS has not yet been updated.
|
|
||||||
ds_correct = open(ds_file).read().strip().split("\n")
|
|
||||||
digests = { }
|
digests = { }
|
||||||
for rr_ds in ds_correct:
|
for rr_ds in ds_correct:
|
||||||
ds_keytag, ds_alg, ds_digalg, ds_digest = rr_ds.split("\t")[4].split(" ")
|
ds_keytag, ds_alg, ds_digalg, ds_digest = rr_ds.split("\t")[4].split(" ")
|
||||||
@@ -542,7 +494,7 @@ def check_mail_domain(domain, env, output):
|
|||||||
|
|
||||||
# Check that the postmaster@ email address exists. Not required if the domain has a
|
# Check that the postmaster@ email address exists. Not required if the domain has a
|
||||||
# catch-all address or domain alias.
|
# catch-all address or domain alias.
|
||||||
if "@" + domain not in [address for address, *_ in get_mail_aliases(env)]:
|
if "@" + domain not in dict(get_mail_aliases(env)):
|
||||||
check_alias_exists("Postmaster contact address", "postmaster@" + domain, env, output)
|
check_alias_exists("Postmaster contact address", "postmaster@" + domain, env, output)
|
||||||
|
|
||||||
# Stop if the domain is listed in the Spamhaus Domain Block List.
|
# Stop if the domain is listed in the Spamhaus Domain Block List.
|
||||||
@@ -556,7 +508,7 @@ def check_mail_domain(domain, env, output):
|
|||||||
which may prevent recipients from receiving your mail.
|
which may prevent recipients from receiving your mail.
|
||||||
See http://www.spamhaus.org/dbl/ and http://www.spamhaus.org/query/domain/%s.""" % (dbl, domain))
|
See http://www.spamhaus.org/dbl/ and http://www.spamhaus.org/query/domain/%s.""" % (dbl, domain))
|
||||||
|
|
||||||
def check_web_domain(domain, rounded_time, ssl_certificates, env, output):
|
def check_web_domain(domain, rounded_time, env, output):
|
||||||
# See if the domain's A record resolves to our PUBLIC_IP. This is already checked
|
# See if the domain's A record resolves to our PUBLIC_IP. This is already checked
|
||||||
# for PRIMARY_HOSTNAME, for which it is required for mail specifically. For it and
|
# for PRIMARY_HOSTNAME, for which it is required for mail specifically. For it and
|
||||||
# other domains, it is required to access its website.
|
# other domains, it is required to access its website.
|
||||||
@@ -572,9 +524,9 @@ def check_web_domain(domain, rounded_time, ssl_certificates, env, output):
|
|||||||
# We need a SSL certificate for PRIMARY_HOSTNAME because that's where the
|
# We need a SSL certificate for PRIMARY_HOSTNAME because that's where the
|
||||||
# user will log in with IMAP or webmail. Any other domain we serve a
|
# user will log in with IMAP or webmail. Any other domain we serve a
|
||||||
# website for also needs a signed certificate.
|
# website for also needs a signed certificate.
|
||||||
check_ssl_cert(domain, rounded_time, ssl_certificates, env, output)
|
check_ssl_cert(domain, rounded_time, env, output)
|
||||||
|
|
||||||
def query_dns(qname, rtype, nxdomain='[Not Set]', at=None):
|
def query_dns(qname, rtype, nxdomain='[Not Set]'):
|
||||||
# Make the qname absolute by appending a period. Without this, dns.resolver.query
|
# Make the qname absolute by appending a period. Without this, dns.resolver.query
|
||||||
# will fall back a failed lookup to a second query with this machine's hostname
|
# will fall back a failed lookup to a second query with this machine's hostname
|
||||||
# appended. This has been causing some false-positive Spamhaus reports. The
|
# appended. This has been causing some false-positive Spamhaus reports. The
|
||||||
@@ -583,17 +535,9 @@ def query_dns(qname, rtype, nxdomain='[Not Set]', at=None):
|
|||||||
if isinstance(qname, str):
|
if isinstance(qname, str):
|
||||||
qname += "."
|
qname += "."
|
||||||
|
|
||||||
# Use the default nameservers (as defined by the system, which is our locally
|
|
||||||
# running bind server), or if the 'at' argument is specified, use that host
|
|
||||||
# as the nameserver.
|
|
||||||
resolver = dns.resolver.get_default_resolver()
|
|
||||||
if at:
|
|
||||||
resolver = dns.resolver.Resolver()
|
|
||||||
resolver.nameservers = [at]
|
|
||||||
|
|
||||||
# Do the query.
|
# Do the query.
|
||||||
try:
|
try:
|
||||||
response = resolver.query(qname, rtype)
|
response = dns.resolver.query(qname, rtype)
|
||||||
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
||||||
# Host did not have an answer for this query; not sure what the
|
# Host did not have an answer for this query; not sure what the
|
||||||
# difference is between the two exceptions.
|
# difference is between the two exceptions.
|
||||||
@@ -607,24 +551,19 @@ def query_dns(qname, rtype, nxdomain='[Not Set]', at=None):
|
|||||||
# can compare to a well known order.
|
# can compare to a well known order.
|
||||||
return "; ".join(sorted(str(r).rstrip('.') for r in response))
|
return "; ".join(sorted(str(r).rstrip('.') for r in response))
|
||||||
|
|
||||||
def check_ssl_cert(domain, rounded_time, ssl_certificates, env, output):
|
def check_ssl_cert(domain, rounded_time, env, output):
|
||||||
# Check that SSL certificate is signed.
|
# Check that SSL certificate is signed.
|
||||||
|
|
||||||
# Skip the check if the A record is not pointed here.
|
# Skip the check if the A record is not pointed here.
|
||||||
if query_dns(domain, "A", None) not in (env['PUBLIC_IP'], None): return
|
if query_dns(domain, "A", None) not in (env['PUBLIC_IP'], None): return
|
||||||
|
|
||||||
# Where is the SSL stored?
|
# Where is the SSL stored?
|
||||||
x = get_domain_ssl_files(domain, ssl_certificates, env, allow_missing_cert=True)
|
ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, env)
|
||||||
|
|
||||||
if x is None:
|
if not os.path.exists(ssl_certificate):
|
||||||
output.print_warning("""No SSL certificate is installed for this domain. Visitors to a website on
|
output.print_error("The SSL certificate file for this domain is missing.")
|
||||||
this domain will get a security warning. If you are not serving a website on this domain, you do
|
|
||||||
not need to take any action. Use the SSL Certificates page in the control panel to install a
|
|
||||||
SSL certificate.""")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
ssl_key, ssl_certificate, ssl_via = x
|
|
||||||
|
|
||||||
# Check that the certificate is good.
|
# Check that the certificate is good.
|
||||||
|
|
||||||
cert_status, cert_status_details = check_certificate(domain, ssl_certificate, ssl_key, rounded_time=rounded_time)
|
cert_status, cert_status_details = check_certificate(domain, ssl_certificate, ssl_key, rounded_time=rounded_time)
|
||||||
@@ -648,13 +587,16 @@ def check_ssl_cert(domain, rounded_time, ssl_certificates, env, output):
|
|||||||
if domain == env['PRIMARY_HOSTNAME']:
|
if domain == env['PRIMARY_HOSTNAME']:
|
||||||
output.print_error("""The SSL certificate for this domain is currently self-signed. You will get a security
|
output.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
|
warning when you check or send email and when visiting this domain in a web browser (for webmail or
|
||||||
static site hosting). Use the SSL Certificates page in the control panel to install a signed SSL certificate.
|
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
|
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:""")
|
the certificate fingerprint matches the following:""")
|
||||||
output.print_line("")
|
output.print_line("")
|
||||||
output.print_line(" " + fingerprint, monospace=True)
|
output.print_line(" " + fingerprint, monospace=True)
|
||||||
else:
|
else:
|
||||||
output.print_error("""The SSL certificate for this domain is self-signed.""")
|
output.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. Use the SSL Certificates page in this control panel to
|
||||||
|
install a signed SSL certificate.""")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
output.print_error("The SSL certificate has a problem: " + cert_status)
|
output.print_error("The SSL certificate has a problem: " + cert_status)
|
||||||
@@ -663,88 +605,103 @@ def check_ssl_cert(domain, rounded_time, ssl_certificates, env, output):
|
|||||||
output.print_line(cert_status_details)
|
output.print_line(cert_status_details)
|
||||||
output.print_line("")
|
output.print_line("")
|
||||||
|
|
||||||
def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring_soon=True, rounded_time=False, just_check_domain=False):
|
def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring_soon=True, rounded_time=False):
|
||||||
# Check that the ssl_certificate & ssl_private_key files are good
|
# Use openssl verify to check the status of a certificate.
|
||||||
# for the provided domain.
|
|
||||||
|
|
||||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
# First check that the certificate is for the right domain. The domain
|
||||||
from cryptography.x509 import Certificate
|
# must be found in the Subject Common Name (CN) or be one of the
|
||||||
|
# Subject Alternative Names. A wildcard might also appear as the CN
|
||||||
|
# or in the SAN list, so check for that tool.
|
||||||
|
retcode, cert_dump = shell('check_output', [
|
||||||
|
"openssl", "x509",
|
||||||
|
"-in", ssl_certificate,
|
||||||
|
"-noout", "-text", "-nameopt", "rfc2253",
|
||||||
|
], trap=True)
|
||||||
|
|
||||||
# The ssl_certificate file may contain a chain of certificates. We'll
|
# If the certificate is catastrophically bad, catch that now and report it.
|
||||||
# need to split that up before we can pass anything to openssl or
|
# More information was probably written to stderr (which we aren't capturing),
|
||||||
# parse them in Python. Parse it with the cryptography library.
|
# but it is probably not helpful to the user anyway.
|
||||||
try:
|
if retcode != 0:
|
||||||
ssl_cert_chain = load_cert_chain(ssl_certificate)
|
return ("The SSL certificate appears to be corrupted or not a PEM-formatted SSL certificate file. (%s)" % ssl_certificate, None)
|
||||||
cert = load_pem(ssl_cert_chain[0])
|
|
||||||
if not isinstance(cert, Certificate): raise ValueError("This is not a certificate file.")
|
|
||||||
except ValueError as e:
|
|
||||||
return ("There is a problem with the certificate file: %s" % str(e), None)
|
|
||||||
|
|
||||||
# First check that the domain name is one of the names allowed by
|
cert_dump = cert_dump.split("\n")
|
||||||
# the certificate.
|
certificate_names = set()
|
||||||
if domain is not None:
|
cert_expiration_date = None
|
||||||
certificate_names, cert_primary_name = get_certificate_domains(cert)
|
while len(cert_dump) > 0:
|
||||||
|
line = cert_dump.pop(0)
|
||||||
|
|
||||||
# Check that the domain appears among the acceptable names, or a wildcard
|
# Grab from the Subject Common Name. We include the indentation
|
||||||
# form of the domain name (which is a stricter check than the specs but
|
# at the start of the line in case maybe the cert includes the
|
||||||
# should work in normal cases).
|
# common name of some other referenced entity (which would be
|
||||||
wildcard_domain = re.sub("^[^\.]+", "*", domain)
|
# indented, I hope).
|
||||||
if domain not in certificate_names and wildcard_domain not in certificate_names:
|
m = re.match(" Subject: CN=([^,]+)", line)
|
||||||
return ("The certificate is for the wrong domain name. It is for %s."
|
if m:
|
||||||
% ", ".join(sorted(certificate_names)), None)
|
certificate_names.add(m.group(1))
|
||||||
|
|
||||||
|
# Grab from the Subject Alternative Name, which is a comma-delim
|
||||||
|
# list of names, like DNS:mydomain.com, DNS:otherdomain.com.
|
||||||
|
m = re.match(" X509v3 Subject Alternative Name:", line)
|
||||||
|
if m:
|
||||||
|
names = re.split(",\s*", cert_dump.pop(0).strip())
|
||||||
|
for n in names:
|
||||||
|
m = re.match("DNS:(.*)", n)
|
||||||
|
if m:
|
||||||
|
certificate_names.add(m.group(1))
|
||||||
|
|
||||||
# Second, check that the certificate matches the private key.
|
# Grab the expiration date for testing later.
|
||||||
|
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
|
||||||
|
# of each command looks like "Modulus=XXXXX".
|
||||||
if ssl_private_key is not None:
|
if ssl_private_key is not None:
|
||||||
try:
|
private_key_modulus = shell('check_output', [
|
||||||
priv_key = load_pem(open(ssl_private_key, 'rb').read())
|
"openssl", "rsa",
|
||||||
except ValueError as e:
|
"-inform", "PEM",
|
||||||
return ("The private key file %s is not a private key file: %s" % (ssl_private_key, str(e)), None)
|
"-noout", "-modulus",
|
||||||
|
"-in", ssl_private_key])
|
||||||
if not isinstance(priv_key, RSAPrivateKey):
|
cert_key_modulus = shell('check_output', [
|
||||||
return ("The private key file %s is not a private key file." % ssl_private_key, None)
|
"openssl", "x509",
|
||||||
|
"-in", ssl_certificate,
|
||||||
if priv_key.public_key().public_numbers() != cert.public_key().public_numbers():
|
"-noout", "-modulus"])
|
||||||
return ("The certificate does not correspond to the private key at %s." % ssl_private_key, None)
|
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), None)
|
||||||
# We could also use the openssl command line tool to get the modulus
|
|
||||||
# listed in each file. The output of each command below looks like "Modulus=XXXXX".
|
|
||||||
# $ openssl rsa -inform PEM -noout -modulus -in ssl_private_key
|
|
||||||
# $ openssl x509 -in ssl_certificate -noout -modulus
|
|
||||||
|
|
||||||
# Third, check if the certificate is self-signed. Return a special flag string.
|
|
||||||
if cert.issuer == cert.subject:
|
|
||||||
return ("SELF-SIGNED", None)
|
|
||||||
|
|
||||||
# When selecting which certificate to use for non-primary domains, we check if the primary
|
|
||||||
# certificate or a www-parent-domain certificate is good for the domain. There's no need
|
|
||||||
# to run extra checks beyond this point.
|
|
||||||
if just_check_domain:
|
|
||||||
return ("OK", None)
|
|
||||||
|
|
||||||
# Check that the certificate hasn't expired. The datetimes returned by the
|
|
||||||
# certificate are 'naive' and in UTC. We need to get the current time in UTC.
|
|
||||||
now = datetime.datetime.utcnow()
|
|
||||||
if not(cert.not_valid_before <= now <= cert.not_valid_after):
|
|
||||||
return ("The certificate has expired or is not yet valid. It is valid from %s to %s." % (cert.not_valid_before, cert.not_valid_after), None)
|
|
||||||
|
|
||||||
# Next validate that the certificate is valid. This checks whether the certificate
|
# 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
|
# is self-signed, that the chain of trust makes sense, that it is signed by a CA
|
||||||
# that Ubuntu has installed on this machine's list of CAs, and I think that it hasn't
|
# that Ubuntu has installed on this machine's list of CAs, and I think that it hasn't
|
||||||
# expired.
|
# expired.
|
||||||
|
|
||||||
# The certificate chain has to be passed separately and is given via STDIN.
|
# In order to verify with openssl, we need to split out any
|
||||||
|
# intermediary certificates in the chain (if any) from our
|
||||||
|
# certificate (at the top). They need to be passed separately.
|
||||||
|
|
||||||
|
cert = open(ssl_certificate).read()
|
||||||
|
m = re.match(r'(-*BEGIN CERTIFICATE-*.*?-*END CERTIFICATE-*)(.*)', cert, re.S)
|
||||||
|
if m == None:
|
||||||
|
return ("The certificate file is an invalid PEM certificate.", None)
|
||||||
|
mycert, chaincerts = m.groups()
|
||||||
|
|
||||||
# This command returns a non-zero exit status in most cases, so trap errors.
|
# This command returns a non-zero exit status in most cases, so trap errors.
|
||||||
|
|
||||||
retcode, verifyoutput = shell('check_output', [
|
retcode, verifyoutput = shell('check_output', [
|
||||||
"openssl",
|
"openssl",
|
||||||
"verify", "-verbose",
|
"verify", "-verbose",
|
||||||
"-purpose", "sslserver", "-policy_check",]
|
"-purpose", "sslserver", "-policy_check",]
|
||||||
+ ([] if len(ssl_cert_chain) == 1 else ["-untrusted", "/proc/self/fd/0"])
|
+ ([] if chaincerts.strip() == "" else ["-untrusted", "/dev/stdin"])
|
||||||
+ [ssl_certificate],
|
+ [ssl_certificate],
|
||||||
input=b"\n\n".join(ssl_cert_chain[1:]),
|
input=chaincerts.encode('ascii'),
|
||||||
trap=True)
|
trap=True)
|
||||||
|
|
||||||
if "self signed" in verifyoutput:
|
if "self signed" in verifyoutput:
|
||||||
# Certificate is self-signed. Probably we detected this above.
|
# Certificate is self-signed.
|
||||||
return ("SELF-SIGNED", None)
|
return ("SELF-SIGNED", None)
|
||||||
|
|
||||||
elif retcode != 0:
|
elif retcode != 0:
|
||||||
@@ -759,7 +716,7 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
|
|||||||
# good.
|
# good.
|
||||||
|
|
||||||
# But is it expiring soon?
|
# But is it expiring soon?
|
||||||
cert_expiration_date = cert.not_valid_after
|
now = datetime.datetime.now(dateutil.tz.tzlocal())
|
||||||
ndays = (cert_expiration_date-now).days
|
ndays = (cert_expiration_date-now).days
|
||||||
if not rounded_time or ndays < 7:
|
if not rounded_time or ndays < 7:
|
||||||
expiry_info = "The certificate expires in %d days on %s." % (ndays, cert_expiration_date.strftime("%x"))
|
expiry_info = "The certificate expires in %d days on %s." % (ndays, cert_expiration_date.strftime("%x"))
|
||||||
@@ -776,68 +733,6 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
|
|||||||
# Return the special OK code.
|
# Return the special OK code.
|
||||||
return ("OK", expiry_info)
|
return ("OK", expiry_info)
|
||||||
|
|
||||||
def load_cert_chain(pemfile):
|
|
||||||
# A certificate .pem file may contain a chain of certificates.
|
|
||||||
# Load the file and split them apart.
|
|
||||||
re_pem = rb"(-+BEGIN (?:.+)-+[\r\n]+(?:[A-Za-z0-9+/=]{1,64}[\r\n]+)+-+END (?:.+)-+[\r\n]+)"
|
|
||||||
with open(pemfile, "rb") as f:
|
|
||||||
pem = f.read() + b"\n" # ensure trailing newline
|
|
||||||
pemblocks = re.findall(re_pem, pem)
|
|
||||||
if len(pemblocks) == 0:
|
|
||||||
raise ValueError("File does not contain valid PEM data.")
|
|
||||||
return pemblocks
|
|
||||||
|
|
||||||
def load_pem(pem):
|
|
||||||
# Parse a "---BEGIN .... END---" PEM string and return a Python object for it
|
|
||||||
# using classes from the cryptography package.
|
|
||||||
from cryptography.x509 import load_pem_x509_certificate
|
|
||||||
from cryptography.hazmat.primitives import serialization
|
|
||||||
from cryptography.hazmat.backends import default_backend
|
|
||||||
pem_type = re.match(b"-+BEGIN (.*?)-+[\r\n]", pem)
|
|
||||||
if pem_type is None:
|
|
||||||
raise ValueError("File is not a valid PEM-formatted file.")
|
|
||||||
pem_type = pem_type.group(1)
|
|
||||||
if pem_type in (b"RSA PRIVATE KEY", b"PRIVATE KEY"):
|
|
||||||
return serialization.load_pem_private_key(pem, password=None, backend=default_backend())
|
|
||||||
if pem_type == b"CERTIFICATE":
|
|
||||||
return load_pem_x509_certificate(pem, default_backend())
|
|
||||||
raise ValueError("Unsupported PEM object type: " + pem_type.decode("ascii", "replace"))
|
|
||||||
|
|
||||||
def get_certificate_domains(cert):
|
|
||||||
from cryptography.x509 import DNSName, ExtensionNotFound, OID_COMMON_NAME, OID_SUBJECT_ALTERNATIVE_NAME
|
|
||||||
import idna
|
|
||||||
|
|
||||||
names = set()
|
|
||||||
cn = None
|
|
||||||
|
|
||||||
# The domain may be found in the Subject Common Name (CN). This comes back as an IDNA (ASCII)
|
|
||||||
# string, which is the format we store domains in - so good.
|
|
||||||
try:
|
|
||||||
cn = cert.subject.get_attributes_for_oid(OID_COMMON_NAME)[0].value
|
|
||||||
names.add(cn)
|
|
||||||
except IndexError:
|
|
||||||
# No common name? Certificate is probably generated incorrectly.
|
|
||||||
# But we'll let it error-out when it doesn't find the domain.
|
|
||||||
pass
|
|
||||||
|
|
||||||
# ... or be one of the Subject Alternative Names. The cryptography library handily IDNA-decodes
|
|
||||||
# the names for us. We must encode back to ASCII, but wildcard certificates can't pass through
|
|
||||||
# IDNA encoding/decoding so we must special-case. See https://github.com/pyca/cryptography/pull/2071.
|
|
||||||
def idna_decode_dns_name(dns_name):
|
|
||||||
if dns_name.startswith("*."):
|
|
||||||
return "*." + idna.encode(dns_name[2:]).decode('ascii')
|
|
||||||
else:
|
|
||||||
return idna.encode(dns_name).decode('ascii')
|
|
||||||
|
|
||||||
try:
|
|
||||||
sans = cert.extensions.get_extension_for_oid(OID_SUBJECT_ALTERNATIVE_NAME).value.get_values_for_type(DNSName)
|
|
||||||
for san in sans:
|
|
||||||
names.add(idna_decode_dns_name(san))
|
|
||||||
except ExtensionNotFound:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return names, cn
|
|
||||||
|
|
||||||
_apt_updates = None
|
_apt_updates = None
|
||||||
def list_apt_updates(apt_update=True):
|
def list_apt_updates(apt_update=True):
|
||||||
# See if we have this information cached recently.
|
# See if we have this information cached recently.
|
||||||
@@ -872,34 +767,6 @@ def list_apt_updates(apt_update=True):
|
|||||||
|
|
||||||
return pkgs
|
return pkgs
|
||||||
|
|
||||||
def what_version_is_this(env):
|
|
||||||
# This function runs `git describe --abbrev=0` on the Mail-in-a-Box installation directory.
|
|
||||||
# Git may not be installed and Mail-in-a-Box may not have been cloned from github,
|
|
||||||
# so this function may raise all sorts of exceptions.
|
|
||||||
miab_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
tag = shell("check_output", ["/usr/bin/git", "describe", "--abbrev=0"], env={"GIT_DIR": os.path.join(miab_dir, '.git')}).strip()
|
|
||||||
return tag
|
|
||||||
|
|
||||||
def get_latest_miab_version():
|
|
||||||
# This pings https://mailinabox.email/bootstrap.sh and extracts the tag named in
|
|
||||||
# the script to determine the current product version.
|
|
||||||
import urllib.request
|
|
||||||
return re.search(b'TAG=(.*)', urllib.request.urlopen("https://mailinabox.email/bootstrap.sh?ping=1").read()).group(1).decode("utf8")
|
|
||||||
|
|
||||||
def check_miab_version(env, output):
|
|
||||||
config = load_settings(env)
|
|
||||||
|
|
||||||
if config.get("privacy", True):
|
|
||||||
output.print_warning("Mail-in-a-Box version check disabled by privacy setting.")
|
|
||||||
else:
|
|
||||||
this_ver = what_version_is_this(env)
|
|
||||||
latest_ver = get_latest_miab_version()
|
|
||||||
if this_ver == latest_ver:
|
|
||||||
output.print_ok("Mail-in-a-Box is up to date. You are running version %s." % this_ver)
|
|
||||||
else:
|
|
||||||
output.print_error("A new version of Mail-in-a-Box is available. You are running version %s. The latest version is %s. For upgrade instructions, see https://mailinabox.email. "
|
|
||||||
% (this_ver, latest_ver))
|
|
||||||
|
|
||||||
def run_and_output_changes(env, pool, send_via_email):
|
def run_and_output_changes(env, pool, send_via_email):
|
||||||
import json
|
import json
|
||||||
from difflib import SequenceMatcher
|
from difflib import SequenceMatcher
|
||||||
@@ -966,7 +833,7 @@ def run_and_output_changes(env, pool, send_via_email):
|
|||||||
if category not in cur_status:
|
if category not in cur_status:
|
||||||
out.add_heading(category)
|
out.add_heading(category)
|
||||||
out.print_warning("This section was removed.")
|
out.print_warning("This section was removed.")
|
||||||
|
|
||||||
if send_via_email:
|
if send_via_email:
|
||||||
# If there were changes, send off an email.
|
# If there were changes, send off an email.
|
||||||
buf = out.buf.getvalue()
|
buf = out.buf.getvalue()
|
||||||
@@ -978,7 +845,7 @@ def run_and_output_changes(env, pool, send_via_email):
|
|||||||
msg['To'] = "administrator@%s" % env['PRIMARY_HOSTNAME']
|
msg['To'] = "administrator@%s" % env['PRIMARY_HOSTNAME']
|
||||||
msg['Subject'] = "[%s] Status Checks Change Notice" % env['PRIMARY_HOSTNAME']
|
msg['Subject'] = "[%s] Status Checks Change Notice" % env['PRIMARY_HOSTNAME']
|
||||||
msg.set_payload(buf, "UTF-8")
|
msg.set_payload(buf, "UTF-8")
|
||||||
|
|
||||||
# send to administrator@
|
# send to administrator@
|
||||||
import smtplib
|
import smtplib
|
||||||
mailserver = smtplib.SMTP('localhost', 25)
|
mailserver = smtplib.SMTP('localhost', 25)
|
||||||
@@ -988,7 +855,7 @@ def run_and_output_changes(env, pool, send_via_email):
|
|||||||
"administrator@%s" % env['PRIMARY_HOSTNAME'], # RCPT TO
|
"administrator@%s" % env['PRIMARY_HOSTNAME'], # RCPT TO
|
||||||
msg.as_string())
|
msg.as_string())
|
||||||
mailserver.quit()
|
mailserver.quit()
|
||||||
|
|
||||||
# Store the current status checks output for next time.
|
# Store the current status checks output for next time.
|
||||||
os.makedirs(os.path.dirname(cache_fn), exist_ok=True)
|
os.makedirs(os.path.dirname(cache_fn), exist_ok=True)
|
||||||
with open(cache_fn, "w") as f:
|
with open(cache_fn, "w") as f:
|
||||||
@@ -1073,14 +940,10 @@ if __name__ == "__main__":
|
|||||||
domain = env['PRIMARY_HOSTNAME']
|
domain = env['PRIMARY_HOSTNAME']
|
||||||
if query_dns(domain, "A") != env['PUBLIC_IP']:
|
if query_dns(domain, "A") != env['PUBLIC_IP']:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
ssl_certificates = get_ssl_certificates(env)
|
ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, env)
|
||||||
ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, ssl_certificates, env)
|
|
||||||
if not os.path.exists(ssl_certificate):
|
if not os.path.exists(ssl_certificate):
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
cert_status, cert_status_details = check_certificate(domain, ssl_certificate, ssl_key, warn_if_expiring_soon=False)
|
cert_status, cert_status_details = check_certificate(domain, ssl_certificate, ssl_key, warn_if_expiring_soon=False)
|
||||||
if cert_status != "OK":
|
if cert_status != "OK":
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
elif sys.argv[1] == "--version":
|
|
||||||
print(what_version_is_this(env))
|
|
||||||
|
|||||||
@@ -7,61 +7,33 @@
|
|||||||
|
|
||||||
<h3>Add a mail alias</h3>
|
<h3>Add a mail alias</h3>
|
||||||
|
|
||||||
<p>Aliases are email forwarders. An alias can forward email to a <a href="#" onclick="return show_panel('users')">mail user</a> or to any email address.</p>
|
<p>Aliases are email forwarders. An alias can forward email to a <a href="javascript:show_panel('users')">mail user</a> or to any email address.</p>
|
||||||
|
|
||||||
<p>To use an alias or any address besides your own login username in outbound mail, the sending user must be included as a permitted sender for the alias.</p>
|
<form class="form-horizontal" role="form" onsubmit="do_add_alias(); return false;">
|
||||||
|
|
||||||
<form id="addalias-form" class="form-horizontal" role="form" onsubmit="do_add_alias(); return false;">
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="col-sm-offset-1 col-sm-11">
|
<div class="col-sm-offset-1 col-sm-11">
|
||||||
<div id="alias_type_buttons" class="btn-group btn-group-xs">
|
<div id="alias_type_buttons" class="btn-group btn-group-xs">
|
||||||
<button type="button" class="btn btn-default" data-mode="regular">Regular</button>
|
<button type="button" class="btn btn-default active" data-mode="regular">Regular</button>
|
||||||
<button type="button" class="btn btn-default" data-mode="catchall">Catch-All</button>
|
<button type="button" class="btn btn-default" data-mode="catchall">Catch-All</button>
|
||||||
<button type="button" class="btn btn-default" data-mode="domainalias">Domain Alias</button>
|
<button type="button" class="btn btn-default" data-mode="domainalias">Domain Alias</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="alias_mode_info" class="text-info small" style="display: none; margin: .5em 0 0 0;">
|
<div id="alias_mode_info" class="text-info small" style="display: none; margin: .5em 0 0 0;">
|
||||||
<span class="catchall hidden">A catch-all alias captures all otherwise unmatched email to a domain.</span>
|
<span class="catchall hidden">A catch-all alias captures all otherwise unmatched email to a domain. Enter just a part of an email address starting with the @-sign.</span>
|
||||||
<span class="domainalias hidden">A domain alias forwards all otherwise unmatched email from one domain to another domain, preserving the part before the @-sign.</span>
|
<span class="domainalias hidden">A domain alias forwards all otherwise unmatched mail from one domain to another domain, preserving the part before the @-sign.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="addaliasAddress" class="col-sm-1 control-label">Alias</label>
|
<label for="addaliasEmail" class="col-sm-1 control-label">Alias</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
<input type="email" class="form-control" id="addaliasAddress">
|
<input type="email" class="form-control" id="addaliasEmail">
|
||||||
<div style="margin-top: 3px; padding-left: 3px; font-size: 90%" class="text-muted">
|
<div style="margin-top: 3px; padding-left: 3px; font-size: 90%" class="text-muted">You may use international (non-ASCII) characters for the domain part of the email address only.</div>
|
||||||
<span class="catchall domainalias">Enter just the part of an email address starting with the @-sign.</span>
|
|
||||||
You may use international (non-ASCII) characters for the domain part of the email address only.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="addaliasForwardsTo" class="col-sm-1 control-label">Forwards To</label>
|
<label for="addaliasTargets" class="col-sm-1 control-label">Forward To</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
<textarea class="form-control" rows="3" id="addaliasForwardsTo"></textarea>
|
<textarea class="form-control" rows="3" id="addaliasTargets"></textarea>
|
||||||
<div style="margin-top: 3px; padding-left: 3px; font-size: 90%" class="text-muted">
|
|
||||||
<span class="domainalias">Enter just the part of an email address starting with the @-sign.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="addaliasSenders" class="col-sm-1 control-label">Permitted Senders</label>
|
|
||||||
<div class="col-sm-10">
|
|
||||||
<div class="radio">
|
|
||||||
<label>
|
|
||||||
<input id="addaliasForwardsToNotAdvanced" name="addaliasForwardsToDivToggle" type="radio" checked onclick="$('#addaliasForwardsToDiv').toggle(false)">
|
|
||||||
Any mail user listed in the Fowards To box can send mail claiming to be from <span class="regularalias">the alias address</span><span class="catchall domainalias">any address on the alias domain</span>.
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="radio">
|
|
||||||
<label>
|
|
||||||
<input id="addaliasForwardsToAdvanced" name="addaliasForwardsToDivToggle" type="radio" id="addaliasForwardsToDivShower" onclick="$('#addaliasForwardsToDiv').toggle(true)">
|
|
||||||
I’ll enter the mail users that can send mail claiming to be from <span class="regularalias">the alias address</span><span class="catchall domainalias">any address on the alias domain</span>.
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div id="addaliasForwardsToDiv" style="margin-top: .5em; margin-left: 1.4em; display: none;">
|
|
||||||
<textarea class="form-control" rows="3" id="addaliasSenders" placeholder="one user per line or separated by commas"></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -79,7 +51,6 @@
|
|||||||
<th></th>
|
<th></th>
|
||||||
<th>Alias<br></th>
|
<th>Alias<br></th>
|
||||||
<th>Forwards To</th>
|
<th>Forwards To</th>
|
||||||
<th>Permitted Senders</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -99,9 +70,8 @@
|
|||||||
<span class="glyphicon glyphicon-trash"></span>
|
<span class="glyphicon glyphicon-trash"></span>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td class='address'> </td>
|
<td class='email'> </td>
|
||||||
<td class='forwardsTo'> </td>
|
<td class='target'> </td>
|
||||||
<td class='senders'> </td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -128,12 +98,10 @@ function show_aliases() {
|
|||||||
n.attr('id', '');
|
n.attr('id', '');
|
||||||
|
|
||||||
if (alias.required) n.addClass('alias-required');
|
if (alias.required) n.addClass('alias-required');
|
||||||
n.attr('data-address', alias.address_display); // this is decoded from IDNA, but will get re-coded to IDNA on the backend
|
n.attr('data-email', alias.source_display); // this is decoded from IDNA, but will get re-coded to IDNA on the backend
|
||||||
n.find('td.address').text(alias.address_display)
|
n.find('td.email').text(alias.source_display)
|
||||||
for (var j = 0; j < alias.forwards_to.length; j++)
|
for (var j = 0; j < alias.destination.length; j++)
|
||||||
n.find('td.forwardsTo').append($("<div></div>").text(alias.forwards_to[j]))
|
n.find('td.target').append($("<div></div>").text(alias.destination[j]))
|
||||||
for (var j = 0; j < (alias.permitted_senders ? alias.permitted_senders.length : 0); j++)
|
|
||||||
n.find('td.senders').append($("<div></div>").text(alias.permitted_senders[j]))
|
|
||||||
$('#alias_table tbody').append(n);
|
$('#alias_table tbody').append(n);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,25 +111,25 @@ function show_aliases() {
|
|||||||
$('#alias_type_buttons button').off('click').click(function() {
|
$('#alias_type_buttons button').off('click').click(function() {
|
||||||
$('#alias_type_buttons button').removeClass('active');
|
$('#alias_type_buttons button').removeClass('active');
|
||||||
$(this).addClass('active');
|
$(this).addClass('active');
|
||||||
$('#addalias-form .regularalias, #addalias-form .catchall, #addalias-form .domainalias').addClass('hidden');
|
|
||||||
if ($(this).attr('data-mode') == "regular") {
|
if ($(this).attr('data-mode') == "regular") {
|
||||||
$('#addaliasAddress').attr('type', 'email');
|
$('#addaliasEmail').attr('type', 'email');
|
||||||
$('#addaliasAddress').attr('placeholder', 'you@yourdomain.com (incoming email address)');
|
$('#addaliasEmail').attr('placeholder', 'incoming email address (e.g. you@yourdomain.com)');
|
||||||
$('#addaliasForwardsTo').attr('placeholder', 'one address per line or separated by commas');
|
$('#addaliasTargets').attr('placeholder', 'forward to these email addresses (one per line or separated by commas)');
|
||||||
$('#alias_mode_info').slideUp();
|
$('#alias_mode_info').slideUp();
|
||||||
$('#addalias-form .regularalias').removeClass('hidden');
|
|
||||||
} else if ($(this).attr('data-mode') == "catchall") {
|
} else if ($(this).attr('data-mode') == "catchall") {
|
||||||
$('#addaliasAddress').attr('type', 'text');
|
$('#addaliasEmail').attr('type', 'text');
|
||||||
$('#addaliasAddress').attr('placeholder', '@yourdomain.com (incoming catch-all domain)');
|
$('#addaliasEmail').attr('placeholder', 'incoming catch-all address (e.g. @yourdomain.com)');
|
||||||
$('#addaliasForwardsTo').attr('placeholder', 'one address per line or separated by commas');
|
$('#addaliasTargets').attr('placeholder', 'forward to these email addresses (one per line or separated by commas)');
|
||||||
$('#alias_mode_info').slideDown();
|
$('#alias_mode_info').slideDown();
|
||||||
$('#addalias-form .catchall').removeClass('hidden');
|
$('#alias_mode_info span').addClass('hidden');
|
||||||
|
$('#alias_mode_info span.catchall').removeClass('hidden');
|
||||||
} else if ($(this).attr('data-mode') == "domainalias") {
|
} else if ($(this).attr('data-mode') == "domainalias") {
|
||||||
$('#addaliasAddress').attr('type', 'text');
|
$('#addaliasEmail').attr('type', 'text');
|
||||||
$('#addaliasAddress').attr('placeholder', '@yourdomain.com (incoming catch-all domain)');
|
$('#addaliasEmail').attr('placeholder', 'incoming domain (@yourdomain.com)');
|
||||||
$('#addaliasForwardsTo').attr('placeholder', '@otherdomain.com (forward to other domain)');
|
$('#addaliasTargets').attr('placeholder', 'forward to domain (@yourdomain.com)');
|
||||||
$('#alias_mode_info').slideDown();
|
$('#alias_mode_info').slideDown();
|
||||||
$('#addalias-form .domainalias').removeClass('hidden');
|
$('#alias_mode_info span').addClass('hidden');
|
||||||
|
$('#alias_mode_info span.domainalias').removeClass('hidden');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
$('#alias_type_buttons button[data-mode="regular"]').click(); // init
|
$('#alias_type_buttons button[data-mode="regular"]').click(); // init
|
||||||
@@ -171,21 +139,15 @@ function show_aliases() {
|
|||||||
var is_alias_add_update = false;
|
var is_alias_add_update = false;
|
||||||
function do_add_alias() {
|
function do_add_alias() {
|
||||||
var title = (!is_alias_add_update) ? "Add Alias" : "Update Alias";
|
var title = (!is_alias_add_update) ? "Add Alias" : "Update Alias";
|
||||||
var form_address = $("#addaliasAddress").val();
|
var email = $("#addaliasEmail").val();
|
||||||
var form_forwardsto = $("#addaliasForwardsTo").val();
|
var targets = $("#addaliasTargets").val();
|
||||||
var form_senders = ($('#addaliasForwardsToAdvanced').prop('checked') ? $("#addaliasSenders").val() : '');
|
|
||||||
if ($('#addaliasForwardsToAdvanced').prop('checked') && !/\S/.exec($("#addaliasSenders").val())) {
|
|
||||||
show_modal_error(title, "You did not enter any permitted senders.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
api(
|
api(
|
||||||
"/mail/aliases/add",
|
"/mail/aliases/add",
|
||||||
"POST",
|
"POST",
|
||||||
{
|
{
|
||||||
update_if_exists: is_alias_add_update ? '1' : '0',
|
update_if_exists: is_alias_add_update ? '1' : '0',
|
||||||
address: form_address,
|
source: email,
|
||||||
forwards_to: form_forwardsto,
|
destination: targets
|
||||||
permitted_senders: form_senders
|
|
||||||
},
|
},
|
||||||
function(r) {
|
function(r) {
|
||||||
// Responses are multiple lines of pre-formatted text.
|
// Responses are multiple lines of pre-formatted text.
|
||||||
@@ -200,55 +162,48 @@ function do_add_alias() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function aliases_reset_form() {
|
function aliases_reset_form() {
|
||||||
$("#addaliasAddress").prop('disabled', false);
|
$("#addaliasEmail").prop('disabled', false);
|
||||||
$("#addaliasAddress").val('')
|
$("#addaliasEmail").val('')
|
||||||
$("#addaliasForwardsTo").val('')
|
$("#addaliasTargets").val('')
|
||||||
$("#addaliasSenders").val('')
|
|
||||||
$('#alias-cancel').addClass('hidden');
|
$('#alias-cancel').addClass('hidden');
|
||||||
$('#add-alias-button').text('Add Alias');
|
$('#add-alias-button').text('Add Alias');
|
||||||
is_alias_add_update = false;
|
is_alias_add_update = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function aliases_edit(elem) {
|
function aliases_edit(elem) {
|
||||||
var address = $(elem).parents('tr').attr('data-address');
|
var email = $(elem).parents('tr').attr('data-email');
|
||||||
var receiverdivs = $(elem).parents('tr').find('.forwardsTo div');
|
var targetdivs = $(elem).parents('tr').find('.target div');
|
||||||
var senderdivs = $(elem).parents('tr').find('.senders div');
|
var targets = "";
|
||||||
var forwardsTo = "";
|
for (var i = 0; i < targetdivs.length; i++)
|
||||||
for (var i = 0; i < receiverdivs.length; i++)
|
targets += $(targetdivs[i]).text() + "\n";
|
||||||
forwardsTo += $(receiverdivs[i]).text() + "\n";
|
|
||||||
var senders = "";
|
is_alias_add_update = true;
|
||||||
for (var i = 0; i < senderdivs.length; i++)
|
$('#alias-cancel').removeClass('hidden');
|
||||||
senders += $(senderdivs[i]).text() + "\n";
|
$("#addaliasEmail").prop('disabled', true);
|
||||||
if (address.charAt(0) == '@' && forwardsTo.charAt(0) == '@')
|
$("#addaliasEmail").val(email);
|
||||||
|
$("#addaliasTargets").val(targets);
|
||||||
|
$('#add-alias-button').text('Update');
|
||||||
|
if (email.charAt(0) == '@' && targets.charAt(0) == '@')
|
||||||
$('#alias_type_buttons button[data-mode="domainalias"]').click();
|
$('#alias_type_buttons button[data-mode="domainalias"]').click();
|
||||||
else if (address.charAt(0) == '@')
|
else if (email.charAt(0) == '@')
|
||||||
$('#alias_type_buttons button[data-mode="catchall"]').click();
|
$('#alias_type_buttons button[data-mode="catchall"]').click();
|
||||||
else
|
else
|
||||||
$('#alias_type_buttons button[data-mode="regular"]').click();
|
$('#alias_type_buttons button[data-mode="regular"]').click();
|
||||||
$('#alias-cancel').removeClass('hidden');
|
|
||||||
$("#addaliasAddress").prop('disabled', true);
|
|
||||||
$("#addaliasAddress").val(address);
|
|
||||||
$("#addaliasForwardsTo").val(forwardsTo);
|
|
||||||
$('#addaliasForwardsToAdvanced').prop('checked', senders != "");
|
|
||||||
$('#addaliasForwardsToNotAdvanced').prop('checked', senders == "");
|
|
||||||
$("#addaliasSenders").val(senders);
|
|
||||||
$('#add-alias-button').text('Update');
|
|
||||||
$('body').animate({ scrollTop: 0 })
|
$('body').animate({ scrollTop: 0 })
|
||||||
is_alias_add_update = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function aliases_remove(elem) {
|
function aliases_remove(elem) {
|
||||||
var row_address = $(elem).parents('tr').attr('data-address');
|
var email = $(elem).parents('tr').attr('data-email');
|
||||||
show_modal_confirm(
|
show_modal_confirm(
|
||||||
"Remove Alias",
|
"Remove Alias",
|
||||||
"Remove " + row_address + "?",
|
"Remove " + email + "?",
|
||||||
"Remove",
|
"Remove",
|
||||||
function() {
|
function() {
|
||||||
api(
|
api(
|
||||||
"/mail/aliases/remove",
|
"/mail/aliases/remove",
|
||||||
"POST",
|
"POST",
|
||||||
{
|
{
|
||||||
address: row_address
|
source: email
|
||||||
},
|
},
|
||||||
function(r) {
|
function(r) {
|
||||||
// Responses are multiple lines of pre-formatted text.
|
// Responses are multiple lines of pre-formatted text.
|
||||||
|
|||||||
@@ -67,8 +67,7 @@
|
|||||||
|
|
||||||
<h3>Using a Secondary Nameserver</h3>
|
<h3>Using a Secondary Nameserver</h3>
|
||||||
|
|
||||||
<p>If your TLD requires you to have two separate nameservers, you can either set up <a href="#" onclick="return show_panel('external_dns')">external DNS</a> and ignore the DNS server on this box entirely, or use the DNS server on this box but add a secondary (aka “slave”) nameserver.</p>
|
<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>
|
||||||
<p>If you choose to use a seconday nameserver, you must find a seconday nameserver service provider. Your domain name registrar or virtual cloud provider may provide this service for you. Once you set up the seconday nameserver service, enter the hostname (not the IP address) of <em>their</em> secondary nameserver in the box below.</p>
|
|
||||||
|
|
||||||
<form class="form-horizontal" role="form" onsubmit="do_set_secondary_dns(); return false;">
|
<form class="form-horizontal" role="form" onsubmit="do_set_secondary_dns(); return false;">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -84,11 +83,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="secondarydns-clear-instructions" class="form-group" style="display: none">
|
<div id="secondarydns-clear-instructions" class="form-group" style="display: none">
|
||||||
<div class="col-sm-offset-1 col-sm-11">
|
<div class="col-sm-offset-1 col-sm-11">
|
||||||
<p class="small">
|
<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>
|
||||||
Multiple secondary servers can be separated with commas or spaces (i.e., <code>ns2.hostingcompany.com ns3.hostingcompany.com</code>).
|
|
||||||
To enable zone transfers to additional servers without listing them as secondary nameservers, add <code>xfr:IPADDRESS</code>.
|
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -157,8 +152,8 @@ function show_custom_dns() {
|
|||||||
"GET",
|
"GET",
|
||||||
{ },
|
{ },
|
||||||
function(data) {
|
function(data) {
|
||||||
$('#secondarydnsHostname').val(data.hostnames.join(' '));
|
$('#secondarydnsHostname').val(data.hostname ? data.hostname : '');
|
||||||
$('#secondarydns-clear-instructions').toggle(data.hostnames.length > 0);
|
$('#secondarydns-clear-instructions').toggle(data.hostname != null);
|
||||||
});
|
});
|
||||||
|
|
||||||
api(
|
api(
|
||||||
@@ -215,7 +210,7 @@ function do_set_secondary_dns() {
|
|||||||
"/dns/secondary-nameserver",
|
"/dns/secondary-nameserver",
|
||||||
"POST",
|
"POST",
|
||||||
{
|
{
|
||||||
hostnames: $('#secondarydnsHostname').val()
|
hostname: $('#secondarydnsHostname').val()
|
||||||
},
|
},
|
||||||
function(data) {
|
function(data) {
|
||||||
if (data == "") return; // nothing updated
|
if (data == "") return; // nothing updated
|
||||||
|
|||||||
@@ -34,15 +34,6 @@
|
|||||||
|
|
||||||
<p>If you do so, you are responsible for keeping your DNS entries up to date! If you previously enabled DNSSEC on your domain name by setting a DS record at your registrar, you will likely have to turn it off before changing nameservers.</p>
|
<p>If you do so, you are responsible for keeping your DNS entries up to date! If you previously enabled DNSSEC on your domain name by setting a DS record at your registrar, you will likely have to turn it off before changing nameservers.</p>
|
||||||
|
|
||||||
|
|
||||||
<p class="alert" role="alert">
|
|
||||||
<span class="glyphicon glyphicon-info-sign"></span>
|
|
||||||
You may encounter zone file errors when attempting to create a TXT record with a long string.
|
|
||||||
<a href="http://tools.ietf.org/html/rfc4408#section-3.1.3">RFC 4408</a> states a TXT record is allowed to contain multiple strings, and this technique can be used to construct records that would exceed the 255-byte maximum length.
|
|
||||||
You may need to adopt this technique when adding DomainKeys. Use a tool like <code>named-checkzone</code> to validate your zone file.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
|
|
||||||
<table id="external_dns_settings" class="table">
|
<table id="external_dns_settings" class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -9,10 +9,17 @@
|
|||||||
|
|
||||||
<meta name="robots" content="noindex, nofollow">
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
|
||||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css" integrity="sha256-MfvZlkHCEqatNoGiOXveE8FIwMzZg4W85qfrfIFBfYc=" crossorigin="anonymous">
|
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.0/css/bootstrap.min.css">
|
||||||
<style>
|
<style>
|
||||||
body {
|
@import url(https://fonts.googleapis.com/css?family=Raleway:400,700);
|
||||||
|
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300);
|
||||||
|
|
||||||
|
html {
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
padding-top: 50px;
|
||||||
padding-bottom: 20px;
|
padding-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,7 +28,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4 {
|
h1, h2, h3, h4 {
|
||||||
font-family: sans-serif;
|
font-family: Raleway, sans-serif;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,14 +70,15 @@
|
|||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap-theme.min.css" integrity="sha256-bHQiqcFbnJb1Qhh61RY9cMh6kR0gTuQY6iFOBj1yj00=" crossorigin="anonymous">
|
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.0/css/bootstrap-theme.min.css">
|
||||||
|
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.3.0/css/font-awesome.min.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!--[if lt IE 8]><p>Internet Explorer version 8 or any modern web browser is required to use this website, sorry.<![endif]-->
|
<!--[if lt IE 8]><p>Internet Explorer version 8 or any modern web browser is required to use this website, sorry.<![endif]-->
|
||||||
<!--[if gt IE 7]><!-->
|
<!--[if gt IE 7]><!-->
|
||||||
|
|
||||||
<div class="navbar navbar-inverse" role="navigation">
|
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="navbar-header">
|
<div class="navbar-header">
|
||||||
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target=".navbar-collapse">
|
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target=".navbar-collapse">
|
||||||
@@ -191,8 +199,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.3/jquery.min.js" integrity="sha256-rsPUGdUPBXgalvIj4YKJrrUlmLXbOb6Cp7cdxn1qeUc=" crossorigin="anonymous"></script>
|
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
|
||||||
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/js/bootstrap.min.js" integrity="sha256-Sk3nkD6mLTMOF0EOpNtsIry+s1CsaqQC1rVLTAy+0yc=" crossorigin="anonymous"></script>
|
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.0/js/bootstrap.min.js"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
var global_modal_state = null;
|
var global_modal_state = null;
|
||||||
@@ -202,8 +210,8 @@ $(function() {
|
|||||||
$('#global_modal').on('shown.bs.modal', function (e) {
|
$('#global_modal').on('shown.bs.modal', function (e) {
|
||||||
// set focus to first input in the global modal's body
|
// set focus to first input in the global modal's body
|
||||||
var input = $('#global_modal .modal-body input');
|
var input = $('#global_modal .modal-body input');
|
||||||
if (input.length > 0) $(input[0]).focus();
|
if (input.length > 0) $(input[0]).focus();
|
||||||
})
|
})
|
||||||
$('#global_modal .btn-danger').click(function() {
|
$('#global_modal .btn-danger').click(function() {
|
||||||
// Don't take action now. Wait for the modal to be totally hidden
|
// 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
|
// so that we don't attempt to show another modal while this one
|
||||||
@@ -263,7 +271,7 @@ function show_modal_confirm(title, question, verb, yes_callback, cancel_callback
|
|||||||
}
|
}
|
||||||
|
|
||||||
var ajax_num_executing_requests = 0;
|
var ajax_num_executing_requests = 0;
|
||||||
function ajax_with_indicator(options) {
|
function ajax(options) {
|
||||||
setTimeout("if (ajax_num_executing_requests > 0) $('#ajax_loading_indicator').fadeIn()", 100);
|
setTimeout("if (ajax_num_executing_requests > 0) $('#ajax_loading_indicator').fadeIn()", 100);
|
||||||
function hide_loading_indicator() {
|
function hide_loading_indicator() {
|
||||||
ajax_num_executing_requests--;
|
ajax_num_executing_requests--;
|
||||||
@@ -325,7 +333,7 @@ function api(url, method, data, callback, callback_error) {
|
|||||||
show_modal_error("Error", "Something went wrong, sorry.")
|
show_modal_error("Error", "Something went wrong, sorry.")
|
||||||
}
|
}
|
||||||
|
|
||||||
ajax_with_indicator({
|
ajax({
|
||||||
url: "/admin" + url,
|
url: "/admin" + url,
|
||||||
method: method,
|
method: method,
|
||||||
cache: false,
|
cache: false,
|
||||||
|
|||||||
@@ -57,15 +57,11 @@ sudo tools/mail.py user make-admin me@{{hostname}}</pre>
|
|||||||
<script>
|
<script>
|
||||||
function do_login() {
|
function do_login() {
|
||||||
if ($('#loginEmail').val() == "") {
|
if ($('#loginEmail').val() == "") {
|
||||||
show_modal_error("Login Failed", "Enter your email address.", function() {
|
show_modal_error("Login Failed", "Enter your email address.")
|
||||||
$('#loginEmail').focus();
|
|
||||||
});
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if ($('#loginPassword').val() == "") {
|
if ($('#loginPassword').val() == "") {
|
||||||
show_modal_error("Login Failed", "Enter your email password.", function() {
|
show_modal_error("Login Failed", "Enter your email password.")
|
||||||
$('#loginPassword').focus();
|
|
||||||
});
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,14 +126,4 @@ function do_logout() {
|
|||||||
sessionStorage.removeItem("miab-cp-credentials");
|
sessionStorage.removeItem("miab-cp-credentials");
|
||||||
show_panel('login');
|
show_panel('login');
|
||||||
}
|
}
|
||||||
|
|
||||||
function show_login() {
|
|
||||||
$('#loginEmail,#loginPassword').each(function() {
|
|
||||||
var input = $(this);
|
|
||||||
if (!$.trim(input.val())) {
|
|
||||||
input.focus();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -18,11 +18,11 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<p>A multi-domain or wildcard certificate will be automatically applied to any domains it is valid for.</p>
|
<p>Advanced:<br>Install a multi-domain or wildcard certificate for the <code>{{hostname}}</code> domain to have it automatically applied to any domains it is valid for.</p>
|
||||||
|
|
||||||
<h3 id="ssl_install_header">Install SSL Certificate</h3>
|
<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/security/ssl-certificates/domain-validation.aspx">Namecheap’s $9 certificate</a>, <a href="https://www.startssl.com/">StartSSL’s free express lane</a> or <a href="https://buy.wosign.com/free/">Wosign’s free SSL</a></a>.</p>
|
<p>There are many places where you can get a free or cheap SSL certificate. We recommend <a href="https://www.namecheap.com/security/ssl-certificates/domain-validation.aspx">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>Which domain are you getting an SSL certificate for?</p>
|
||||||
|
|
||||||
|
|||||||
@@ -5,77 +5,17 @@
|
|||||||
|
|
||||||
<h2>Backup Status</h2>
|
<h2>Backup Status</h2>
|
||||||
|
|
||||||
<p>The box makes an incremental backup each night. By default the backup is stored on the machine itself, but you can also have it stored on Amazon S3.</p>
|
<h3>Copying Backup Files</h3>
|
||||||
|
|
||||||
<h3>Configuration</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>
|
||||||
|
|
||||||
<form class="form-horizontal" role="form" onsubmit="set_custom_backup(); return false;">
|
<p>Many cloud providers make this easy by allowing you to take snapshots of the machine's disk.</p>
|
||||||
<div class="form-group">
|
|
||||||
<label for="backup-target-type" class="col-sm-2 control-label">Backup to:</label>
|
|
||||||
<div class="col-sm-2">
|
|
||||||
<select class="form-control" rows="1" id="backup-target-type" onchange="toggle_form()">
|
|
||||||
<option value="off">Nowhere (Disable Backups)</option>
|
|
||||||
<option value="local">{{hostname}}</option>
|
|
||||||
<option value="s3">Amazon S3</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group backup-target-local">
|
|
||||||
<div class="col-sm-10 col-sm-offset-2">
|
|
||||||
<p>Backups are stored on this machine’s own hard disk. You are responsible for periodically using SFTP (FTP over SSH) to copy the backup files from <tt id="backup-location"></tt> to a safe location. These files are encrypted, so they are safe to store anywhere.</p>
|
|
||||||
<p>Separately copy the encryption password from <tt class="backup-encpassword-file"></tt> to a safe and secure location. You will need this file to decrypt backup files.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group backup-target-s3">
|
|
||||||
<div class="col-sm-10 col-sm-offset-2">
|
|
||||||
<p>Backups are stored in an Amazon Web Services S3 bucket. You must have an AWS account already.</p>
|
|
||||||
<p>You MUST manually copy the encryption password from <tt class="backup-encpassword-file"></tt> to a safe and secure location. You will need this file to decrypt backup files. It is NOT stored in your Amazon S3 bucket.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group backup-target-local backup-target-s3">
|
|
||||||
<label for="min-age" class="col-sm-2 control-label">How many days should backups be kept?</label>
|
|
||||||
<div class="col-sm-8">
|
|
||||||
<input type="number" class="form-control" rows="1" id="min-age">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group backup-target-s3">
|
|
||||||
<label for="backup-target-s3-host" class="col-sm-2 control-label">S3 Region</label>
|
|
||||||
<div class="col-sm-8">
|
|
||||||
<select class="form-control" rows="1" id="backup-target-s3-host">
|
|
||||||
{% for name, host in backup_s3_hosts %}
|
|
||||||
<option value="{{host}}">{{name}}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group backup-target-s3">
|
|
||||||
<label for="backup-target-s3-path" class="col-sm-2 control-label">S3 Path</label>
|
|
||||||
<div class="col-sm-8">
|
|
||||||
<input type="text" placeholder="your-bucket-name/backup-directory" class="form-control" rows="1" id="backup-target-s3-path">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group backup-target-s3">
|
|
||||||
<label for="backup-target-user" class="col-sm-2 control-label">S3 Access Key</label>
|
|
||||||
<div class="col-sm-8">
|
|
||||||
<input type="text" class="form-control" rows="1" id="backup-target-user">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group backup-target-s3">
|
|
||||||
<label for="backup-target-pass" class="col-sm-2 control-label">S3 Secret Access Key</label>
|
|
||||||
<div class="col-sm-8">
|
|
||||||
<input type="text" class="form-control" rows="1" id="backup-target-pass">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="col-sm-offset-2 col-sm-10">
|
|
||||||
<button id="set-s3-backup-button" type="submit" class="btn btn-primary">Save</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<h3>Available Backups</h3>
|
<p>You can also use SFTP (FTP over SSH) to copy files from <tt id="backup-location"></tt>. These files are encrypted, 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>
|
||||||
|
|
||||||
<p>The backup location currently contains the backups listed below. The total size of the backups is currently <span id="backup-total-size"></span>.</p>
|
<h3>Current Backups</h3>
|
||||||
|
|
||||||
|
<p>The backup directory currently contains the backups listed below. The total size on disk of the backups is currently <span id="backup-total-size"></span>.</p>
|
||||||
|
|
||||||
<table id="backup-status" class="table" style="width: auto">
|
<table id="backup-status" class="table" style="width: auto">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -87,14 +27,8 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
function toggle_form() {
|
|
||||||
var target_type = $("#backup-target-type").val();
|
|
||||||
$(".backup-target-local, .backup-target-s3").hide();
|
|
||||||
$(".backup-target-" + target_type).show();
|
|
||||||
}
|
|
||||||
|
|
||||||
function nice_size(bytes) {
|
function nice_size(bytes) {
|
||||||
var powers = ['bytes', 'KB', 'MB', 'GB', 'TB'];
|
var powers = ['bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -112,22 +46,19 @@ function nice_size(bytes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function show_system_backup() {
|
function show_system_backup() {
|
||||||
show_custom_backup()
|
|
||||||
|
|
||||||
$('#backup-status tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
$('#backup-status tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
||||||
api(
|
api(
|
||||||
"/system/backup/status",
|
"/system/backup/status",
|
||||||
"GET",
|
"GET",
|
||||||
{ },
|
{ },
|
||||||
function(r) {
|
function(r) {
|
||||||
|
$('#backup-location').text(r.directory);
|
||||||
|
$('#backup-encpassword-file').text(r.encpwfile);
|
||||||
|
|
||||||
$('#backup-status tbody').html("");
|
$('#backup-status tbody').html("");
|
||||||
var total_disk_size = 0;
|
var total_disk_size = 0;
|
||||||
|
|
||||||
if (typeof r.backups == "undefined") {
|
if (r.backups.length == 0) {
|
||||||
var tr = $('<tr><td colspan="3">Backups are turned off.</td></tr>');
|
|
||||||
$('#backup-status tbody').append(tr);
|
|
||||||
return;
|
|
||||||
} else if (r.backups.length == 0) {
|
|
||||||
var tr = $('<tr><td colspan="3">No backups have been made yet.</td></tr>');
|
var tr = $('<tr><td colspan="3">No backups have been made yet.</td></tr>');
|
||||||
$('#backup-status tbody').append(tr);
|
$('#backup-status tbody').append(tr);
|
||||||
}
|
}
|
||||||
@@ -152,63 +83,4 @@ function show_system_backup() {
|
|||||||
$('#backup-total-size').text(nice_size(total_disk_size));
|
$('#backup-total-size').text(nice_size(total_disk_size));
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function show_custom_backup() {
|
|
||||||
$(".backup-target-local, .backup-target-s3").hide();
|
|
||||||
api(
|
|
||||||
"/system/backup/config",
|
|
||||||
"GET",
|
|
||||||
{ },
|
|
||||||
function(r) {
|
|
||||||
if (r.target == "file://" + r.file_target_directory) {
|
|
||||||
$("#backup-target-type").val("local");
|
|
||||||
} else if (r.target == "off") {
|
|
||||||
$("#backup-target-type").val("off");
|
|
||||||
} else if (r.target.substring(0, 5) == "s3://") {
|
|
||||||
$("#backup-target-type").val("s3");
|
|
||||||
var hostpath = r.target.substring(5).split('/');
|
|
||||||
var host = hostpath.shift();
|
|
||||||
$("#backup-target-s3-host").val(host);
|
|
||||||
$("#backup-target-s3-path").val(hostpath.join('/'));
|
|
||||||
}
|
|
||||||
$("#backup-target-user").val(r.target_user);
|
|
||||||
$("#backup-target-pass").val(r.target_pass);
|
|
||||||
$("#min-age").val(r.min_age_in_days);
|
|
||||||
$('#backup-location').text(r.file_target_directory);
|
|
||||||
$('.backup-encpassword-file').text(r.enc_pw_file);
|
|
||||||
toggle_form()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function set_custom_backup() {
|
|
||||||
var target_type = $("#backup-target-type").val();
|
|
||||||
var target_user = $("#backup-target-user").val();
|
|
||||||
var target_pass = $("#backup-target-pass").val();
|
|
||||||
|
|
||||||
var target;
|
|
||||||
if (target_type == "local" || target_type == "off")
|
|
||||||
target = target_type;
|
|
||||||
else if (target_type == "s3")
|
|
||||||
target = "s3://" + $("#backup-target-s3-host").val() + "/" + $("#backup-target-s3-path").val();
|
|
||||||
|
|
||||||
var min_age = $("#min-age").val();
|
|
||||||
api(
|
|
||||||
"/system/backup/config",
|
|
||||||
"POST",
|
|
||||||
{
|
|
||||||
target: target,
|
|
||||||
target_user: target_user,
|
|
||||||
target_pass: target_pass,
|
|
||||||
min_age: min_age
|
|
||||||
},
|
|
||||||
function(r) {
|
|
||||||
// use .text() --- it's a text response, not html
|
|
||||||
show_modal_error("Backup configuration", $("<p/>").text(r), function() { if (r == "OK") show_system_backup(); }); // refresh after modal on success
|
|
||||||
},
|
|
||||||
function(r) {
|
|
||||||
// use .text() --- it's a text response, not html
|
|
||||||
show_modal_error("Backup configuration", $("<p/>").text(r));
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -34,20 +34,8 @@
|
|||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
#system-privacy-setting {
|
|
||||||
float: right;
|
|
||||||
max-width: 20em;
|
|
||||||
margin-bottom: 1em;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div id="system-privacy-setting" style="display: none">
|
|
||||||
<div><a onclick="return enable_privacy(!current_privacy_setting)" href="#"><span>Enable/Disable</span> New-Version Check</a></div>
|
|
||||||
<p style="line-height: 125%"><small>(When enabled, status checks phone-home to check for a new release of Mail-in-a-Box.)</small></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<table id="system-checks" class="table" style="max-width: 60em">
|
<table id="system-checks" class="table" style="max-width: 60em">
|
||||||
<thead>
|
<thead>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -58,18 +46,6 @@
|
|||||||
<script>
|
<script>
|
||||||
function show_system_status() {
|
function show_system_status() {
|
||||||
$('#system-checks tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
$('#system-checks tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
||||||
|
|
||||||
api(
|
|
||||||
"/system/privacy",
|
|
||||||
"GET",
|
|
||||||
{ },
|
|
||||||
function(r) {
|
|
||||||
current_privacy_setting = r;
|
|
||||||
$('#system-privacy-setting').show();
|
|
||||||
$('#system-privacy-setting a span').text(r ? "Enable" : "Disable");
|
|
||||||
$('#system-privacy-setting p').toggle(r);
|
|
||||||
});
|
|
||||||
|
|
||||||
api(
|
api(
|
||||||
"/system/status",
|
"/system/status",
|
||||||
"POST",
|
"POST",
|
||||||
@@ -106,20 +82,5 @@ function show_system_status() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
var current_privacy_setting = null;
|
|
||||||
function enable_privacy(status) {
|
|
||||||
api(
|
|
||||||
"/system/privacy",
|
|
||||||
"POST",
|
|
||||||
{
|
|
||||||
value: (status ? "private" : "off")
|
|
||||||
},
|
|
||||||
function(res) {
|
|
||||||
show_system_status();
|
|
||||||
});
|
|
||||||
return false; // disable link
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -31,10 +31,10 @@
|
|||||||
<button type="submit" class="btn btn-primary">Add User</button>
|
<button type="submit" class="btn btn-primary">Add User</button>
|
||||||
</form>
|
</form>
|
||||||
<ul style="margin-top: 1em; padding-left: 1.5em; font-size: 90%;">
|
<ul style="margin-top: 1em; padding-left: 1.5em; font-size: 90%;">
|
||||||
<li>Passwords must be at least four characters and may not contain spaces. For best results, <a href="#" onclick="return generate_random_password()">generate a random password</a>.</li>
|
<li>Passwords must be at least four characters and may not contain spaces.</li>
|
||||||
<li>Use <a href="#" onclick="return show_panel('aliases')">aliases</a> to create email addresses that forward to existing accounts.</li>
|
<li>Use <a href="javascript:show_panel('aliases')">aliases</a> to create email addresses that forward to existing accounts.</li>
|
||||||
<li>Administrators get access to this control panel.</li>
|
<li>Administrators get access to this control panel.</li>
|
||||||
<li>User accounts cannot contain any international (non-ASCII) characters, but <a href="#" onclick="return show_panel('aliases');">aliases</a> can.</li>
|
<li>User accounts cannot contain any international (non-ASCII) characters, but <a href="javascript:show_panel('aliases')">aliases</a> can.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Existing mail users</h3>
|
<h3>Existing mail users</h3>
|
||||||
@@ -250,13 +250,4 @@ function mod_priv(elem, add_remove) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function generate_random_password() {
|
|
||||||
var pw = "";
|
|
||||||
var charset = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"; // confusable characters skipped
|
|
||||||
for (var i = 0; i < 10; i++)
|
|
||||||
pw += charset.charAt(Math.floor(Math.random() * charset.length));
|
|
||||||
show_modal_error("Random Password", "<p>Here, try this:</p> <p><code style='font-size: 110%'>" + pw + "</code></pr");
|
|
||||||
return false; // cancel click
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import os.path
|
import os.path
|
||||||
|
|
||||||
# DO NOT import non-standard modules. This module is imported by
|
CONF_DIR = os.path.join(os.path.dirname(__file__), "../conf")
|
||||||
# migrate.py which runs on fresh machines before anything is installed
|
|
||||||
# besides Python.
|
|
||||||
|
|
||||||
# THE ENVIRONMENT FILE AT /etc/mailinabox.conf
|
|
||||||
|
|
||||||
def load_environment():
|
def load_environment():
|
||||||
# Load settings from /etc/mailinabox.conf.
|
# Load settings from /etc/mailinabox.conf.
|
||||||
@@ -22,78 +18,38 @@ def save_environment(env):
|
|||||||
for k, v in env.items():
|
for k, v in env.items():
|
||||||
f.write("%s=%s\n" % (k, v))
|
f.write("%s=%s\n" % (k, v))
|
||||||
|
|
||||||
# THE SETTINGS FILE AT STORAGE_ROOT/settings.yaml.
|
|
||||||
|
|
||||||
def write_settings(config, env):
|
|
||||||
import rtyaml
|
|
||||||
fn = os.path.join(env['STORAGE_ROOT'], 'settings.yaml')
|
|
||||||
with open(fn, "w") as f:
|
|
||||||
f.write(rtyaml.dump(config))
|
|
||||||
|
|
||||||
def load_settings(env):
|
|
||||||
import rtyaml
|
|
||||||
fn = os.path.join(env['STORAGE_ROOT'], 'settings.yaml')
|
|
||||||
try:
|
|
||||||
config = rtyaml.load(open(fn, "r"))
|
|
||||||
if not isinstance(config, dict): raise ValueError() # caught below
|
|
||||||
return config
|
|
||||||
except:
|
|
||||||
return { }
|
|
||||||
|
|
||||||
# UTILITIES
|
|
||||||
|
|
||||||
def safe_domain_name(name):
|
def safe_domain_name(name):
|
||||||
# Sanitize a domain name so it is safe to use as a file name on disk.
|
# Sanitize a domain name so it is safe to use as a file name on disk.
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
return urllib.parse.quote(name, safe='')
|
return urllib.parse.quote(name, safe='')
|
||||||
|
|
||||||
def sort_domains(domain_names, env):
|
def sort_domains(domain_names, env):
|
||||||
# Put domain names in a nice sorted order.
|
# Put domain names in a nice sorted order. For web_update, PRIMARY_HOSTNAME
|
||||||
|
# must appear first so it becomes the nginx default server.
|
||||||
# The nice order will group domain names by DNS zone, i.e. the top-most
|
|
||||||
# domain name that we serve that ecompasses a set of subdomains. Map
|
|
||||||
# each of the domain names to the zone that contains them. Walk the domains
|
|
||||||
# from shortest to longest since zones are always shorter than their
|
|
||||||
# subdomains.
|
|
||||||
zones = { }
|
|
||||||
for domain in sorted(domain_names, key=lambda d : len(d)):
|
|
||||||
for z in zones.values():
|
|
||||||
if domain.endswith("." + z):
|
|
||||||
# We found a parent domain already in the list.
|
|
||||||
zones[domain] = z
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
# 'break' did not occur: there is no parent domain, so it is its
|
|
||||||
# own zone.
|
|
||||||
zones[domain] = domain
|
|
||||||
|
|
||||||
# Sort the zones.
|
|
||||||
zone_domains = sorted(zones.values(),
|
|
||||||
key = lambda d : (
|
|
||||||
# PRIMARY_HOSTNAME or the zone that contains it is always first.
|
|
||||||
not (d == env['PRIMARY_HOSTNAME'] or env['PRIMARY_HOSTNAME'].endswith("." + d)),
|
|
||||||
|
|
||||||
# Then just dumb lexicographically.
|
|
||||||
d,
|
|
||||||
))
|
|
||||||
|
|
||||||
# Now sort the domain names that fall within each zone.
|
|
||||||
domain_names = sorted(domain_names,
|
|
||||||
key = lambda d : (
|
|
||||||
# First by zone.
|
|
||||||
zone_domains.index(zones[d]),
|
|
||||||
|
|
||||||
# PRIMARY_HOSTNAME is always first within the zone that contains it.
|
|
||||||
d != env['PRIMARY_HOSTNAME'],
|
|
||||||
|
|
||||||
# Followed by any of its subdomains.
|
|
||||||
not d.endswith("." + env['PRIMARY_HOSTNAME']),
|
|
||||||
|
|
||||||
# Then in right-to-left lexicographic order of the .-separated parts of the name.
|
|
||||||
list(reversed(d.split("."))),
|
|
||||||
))
|
|
||||||
|
|
||||||
return domain_names
|
# First group PRIMARY_HOSTNAME and its subdomains, then parent domains of PRIMARY_HOSTNAME, then other domains.
|
||||||
|
groups = ( [], [], [] )
|
||||||
|
for d in domain_names:
|
||||||
|
if d == env['PRIMARY_HOSTNAME'] or d.endswith("." + env['PRIMARY_HOSTNAME']):
|
||||||
|
groups[0].append(d)
|
||||||
|
elif env['PRIMARY_HOSTNAME'].endswith("." + d):
|
||||||
|
groups[1].append(d)
|
||||||
|
else:
|
||||||
|
groups[2].append(d)
|
||||||
|
|
||||||
|
# Within each group, sort parent domains before subdomains and after that sort lexicographically.
|
||||||
|
def sort_group(group):
|
||||||
|
# Find the top-most domains.
|
||||||
|
top_domains = sorted(d for d in group if len([s for s in group if d.endswith("." + s)]) == 0)
|
||||||
|
ret = []
|
||||||
|
for d in top_domains:
|
||||||
|
ret.append(d)
|
||||||
|
ret.extend( sort_group([s for s in group if s.endswith("." + d)]) )
|
||||||
|
return ret
|
||||||
|
|
||||||
|
groups = [sort_group(g) for g in groups]
|
||||||
|
|
||||||
|
return groups[0] + groups[1] + groups[2]
|
||||||
|
|
||||||
def sort_email_addresses(email_addresses, env):
|
def sort_email_addresses(email_addresses, env):
|
||||||
email_addresses = set(email_addresses)
|
email_addresses = set(email_addresses)
|
||||||
@@ -244,12 +200,3 @@ def wait_for_service(port, public, env, timeout):
|
|||||||
if time.perf_counter() > start+timeout:
|
if time.perf_counter() > start+timeout:
|
||||||
return False
|
return False
|
||||||
time.sleep(min(timeout/4, 1))
|
time.sleep(min(timeout/4, 1))
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
from dns_update import get_dns_domains
|
|
||||||
from web_update import get_web_domains, get_default_www_redirects
|
|
||||||
env = load_environment()
|
|
||||||
domains = get_dns_domains(env) | set(get_web_domains(env) + get_default_www_redirects(env))
|
|
||||||
domains = sort_domains(domains, env)
|
|
||||||
for domain in domains:
|
|
||||||
print(domain)
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ def get_domains_with_a_records(env):
|
|||||||
domains = set()
|
domains = set()
|
||||||
dns = get_custom_dns_config(env)
|
dns = get_custom_dns_config(env)
|
||||||
for domain, rtype, value in dns:
|
for domain, rtype, value in dns:
|
||||||
if rtype == "CNAME" or (rtype in ("A", "AAAA") and value not in ("local", env['PUBLIC_IP'])):
|
if rtype == "CNAME" or (rtype in ("A", "AAAA") and value != "local"):
|
||||||
domains.add(domain)
|
domains.add(domain)
|
||||||
return domains
|
return domains
|
||||||
|
|
||||||
@@ -60,9 +60,6 @@ def get_default_www_redirects(env):
|
|||||||
return sort_domains(www_domains - web_domains - get_domains_with_a_records(env), env)
|
return sort_domains(www_domains - web_domains - get_domains_with_a_records(env), env)
|
||||||
|
|
||||||
def do_web_update(env):
|
def do_web_update(env):
|
||||||
# Pre-load what SSL certificates we will use for each domain.
|
|
||||||
ssl_certificates = get_ssl_certificates(env)
|
|
||||||
|
|
||||||
# Build an nginx configuration file.
|
# Build an nginx configuration file.
|
||||||
nginx_conf = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-top.conf")).read()
|
nginx_conf = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-top.conf")).read()
|
||||||
|
|
||||||
@@ -73,20 +70,20 @@ def do_web_update(env):
|
|||||||
template3 = "\trewrite ^(.*) https://$REDIRECT_DOMAIN$1 permanent;\n"
|
template3 = "\trewrite ^(.*) https://$REDIRECT_DOMAIN$1 permanent;\n"
|
||||||
|
|
||||||
# Add the PRIMARY_HOST configuration first so it becomes nginx's default server.
|
# Add the PRIMARY_HOST configuration first so it becomes nginx's default server.
|
||||||
nginx_conf += make_domain_config(env['PRIMARY_HOSTNAME'], [template0, template1, template2], ssl_certificates, env)
|
nginx_conf += make_domain_config(env['PRIMARY_HOSTNAME'], [template0, template1, template2], env)
|
||||||
|
|
||||||
# Add configuration all other web domains.
|
# Add configuration all other web domains.
|
||||||
has_root_proxy_or_redirect = get_web_domains_with_root_overrides(env)
|
has_root_proxy_or_redirect = get_web_domains_with_root_overrides(env)
|
||||||
for domain in get_web_domains(env):
|
for domain in get_web_domains(env):
|
||||||
if domain == env['PRIMARY_HOSTNAME']: continue # handled above
|
if domain == env['PRIMARY_HOSTNAME']: continue # handled above
|
||||||
if domain not in has_root_proxy_or_redirect:
|
if domain not in has_root_proxy_or_redirect:
|
||||||
nginx_conf += make_domain_config(domain, [template0, template1], ssl_certificates, env)
|
nginx_conf += make_domain_config(domain, [template0, template1], env)
|
||||||
else:
|
else:
|
||||||
nginx_conf += make_domain_config(domain, [template0], ssl_certificates, env)
|
nginx_conf += make_domain_config(domain, [template0], env)
|
||||||
|
|
||||||
# Add default www redirects.
|
# Add default www redirects.
|
||||||
for domain in get_default_www_redirects(env):
|
for domain in get_default_www_redirects(env):
|
||||||
nginx_conf += make_domain_config(domain, [template0, template3], ssl_certificates, env)
|
nginx_conf += make_domain_config(domain, [template0, template3], env)
|
||||||
|
|
||||||
# Did the file change? If not, don't bother writing & restarting nginx.
|
# Did the file change? If not, don't bother writing & restarting nginx.
|
||||||
nginx_conf_fn = "/etc/nginx/conf.d/local.conf"
|
nginx_conf_fn = "/etc/nginx/conf.d/local.conf"
|
||||||
@@ -107,14 +104,18 @@ def do_web_update(env):
|
|||||||
|
|
||||||
return "web updated\n"
|
return "web updated\n"
|
||||||
|
|
||||||
def make_domain_config(domain, templates, ssl_certificates, env):
|
def make_domain_config(domain, templates, env):
|
||||||
# GET SOME VARIABLES
|
# GET SOME VARIABLES
|
||||||
|
|
||||||
# Where will its root directory be for static files?
|
# Where will its root directory be for static files?
|
||||||
root = get_web_root(domain, env)
|
root = get_web_root(domain, env)
|
||||||
|
|
||||||
# What private key and SSL certificate will we use for this domain?
|
# What private key and SSL certificate will we use for this domain?
|
||||||
ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, ssl_certificates, env)
|
ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, env)
|
||||||
|
|
||||||
|
# For hostnames created after the initial setup, ensure we have an SSL certificate
|
||||||
|
# available. Make a self-signed one now if one doesn't exist.
|
||||||
|
ensure_ssl_certificate_exists(domain, ssl_key, ssl_certificate, env)
|
||||||
|
|
||||||
# ADDITIONAL DIRECTIVES.
|
# ADDITIONAL DIRECTIVES.
|
||||||
|
|
||||||
@@ -134,28 +135,16 @@ def make_domain_config(domain, templates, ssl_certificates, env):
|
|||||||
nginx_conf_extra += "# ssl files sha1: %s / %s\n" % (hashfile(ssl_key), hashfile(ssl_certificate))
|
nginx_conf_extra += "# ssl files sha1: %s / %s\n" % (hashfile(ssl_key), hashfile(ssl_certificate))
|
||||||
|
|
||||||
# Add in any user customizations in YAML format.
|
# Add in any user customizations in YAML format.
|
||||||
hsts = "yes"
|
|
||||||
nginx_conf_custom_fn = os.path.join(env["STORAGE_ROOT"], "www/custom.yaml")
|
nginx_conf_custom_fn = os.path.join(env["STORAGE_ROOT"], "www/custom.yaml")
|
||||||
if os.path.exists(nginx_conf_custom_fn):
|
if os.path.exists(nginx_conf_custom_fn):
|
||||||
yaml = rtyaml.load(open(nginx_conf_custom_fn))
|
yaml = rtyaml.load(open(nginx_conf_custom_fn))
|
||||||
if domain in yaml:
|
if domain in yaml:
|
||||||
yaml = yaml[domain]
|
yaml = yaml[domain]
|
||||||
|
|
||||||
# any proxy or redirect here?
|
|
||||||
for path, url in yaml.get("proxies", {}).items():
|
for path, url in yaml.get("proxies", {}).items():
|
||||||
nginx_conf_extra += "\tlocation %s {\n\t\tproxy_pass %s;\n\t}\n" % (path, url)
|
nginx_conf_extra += "\tlocation %s {\n\t\tproxy_pass %s;\n\t}\n" % (path, url)
|
||||||
for path, url in yaml.get("redirects", {}).items():
|
for path, url in yaml.get("redirects", {}).items():
|
||||||
nginx_conf_extra += "\trewrite %s %s permanent;\n" % (path, url)
|
nginx_conf_extra += "\trewrite %s %s permanent;\n" % (path, url)
|
||||||
|
|
||||||
# override the HSTS directive type
|
|
||||||
hsts = yaml.get("hsts", hsts)
|
|
||||||
|
|
||||||
# Add the HSTS header.
|
|
||||||
if hsts == "yes":
|
|
||||||
nginx_conf_extra += "add_header Strict-Transport-Security max-age=31536000;\n"
|
|
||||||
elif hsts == "preload":
|
|
||||||
nginx_conf_extra += "add_header Strict-Transport-Security \"max-age=10886400; includeSubDomains; preload\";\n"
|
|
||||||
|
|
||||||
# Add in any user customizations in the includes/ folder.
|
# Add in any user customizations in the includes/ folder.
|
||||||
nginx_conf_custom_include = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(domain) + ".conf")
|
nginx_conf_custom_include = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(domain) + ".conf")
|
||||||
if os.path.exists(nginx_conf_custom_include):
|
if os.path.exists(nginx_conf_custom_include):
|
||||||
@@ -185,140 +174,77 @@ def get_web_root(domain, env, test_exists=True):
|
|||||||
if os.path.exists(root) or not test_exists: break
|
if os.path.exists(root) or not test_exists: break
|
||||||
return root
|
return root
|
||||||
|
|
||||||
def get_ssl_certificates(env):
|
def get_domain_ssl_files(domain, env, allow_shared_cert=True):
|
||||||
# Scan all of the installed SSL certificates and map every domain
|
# What SSL private key will we use? Allow the user to override this, but
|
||||||
# that the certificates are good for to the best certificate for
|
# in many cases using the same private key for all domains would be fine.
|
||||||
# the domain.
|
# Don't allow the user to override the key for PRIMARY_HOSTNAME because
|
||||||
|
# that's what's in the main file.
|
||||||
|
ssl_key = os.path.join(env["STORAGE_ROOT"], 'ssl/ssl_private_key.pem')
|
||||||
|
ssl_key_is_alt = False
|
||||||
|
alt_key = os.path.join(env["STORAGE_ROOT"], 'ssl/%s/private_key.pem' % safe_domain_name(domain))
|
||||||
|
if domain != env['PRIMARY_HOSTNAME'] and os.path.exists(alt_key):
|
||||||
|
ssl_key = alt_key
|
||||||
|
ssl_key_is_alt = True
|
||||||
|
|
||||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
# What SSL certificate will we use?
|
||||||
from cryptography.x509 import Certificate
|
ssl_certificate_primary = os.path.join(env["STORAGE_ROOT"], 'ssl/ssl_certificate.pem')
|
||||||
|
ssl_via = None
|
||||||
|
if domain == env['PRIMARY_HOSTNAME']:
|
||||||
|
# For PRIMARY_HOSTNAME, use the one we generated at set-up time.
|
||||||
|
ssl_certificate = ssl_certificate_primary
|
||||||
|
else:
|
||||||
|
# For other domains, we'll probably use a certificate in a different path.
|
||||||
|
ssl_certificate = os.path.join(env["STORAGE_ROOT"], 'ssl/%s/ssl_certificate.pem' % safe_domain_name(domain))
|
||||||
|
|
||||||
# The certificates are all stored here:
|
# But we can be smart and reuse the main SSL certificate if is has
|
||||||
ssl_root = os.path.join(env["STORAGE_ROOT"], 'ssl')
|
# 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 and allow_shared_cert:
|
||||||
|
from status_checks import check_certificate
|
||||||
|
if check_certificate(domain, ssl_certificate_primary, None)[0] == "OK":
|
||||||
|
ssl_certificate = ssl_certificate_primary
|
||||||
|
ssl_via = "Using multi/wildcard certificate of %s." % env['PRIMARY_HOSTNAME']
|
||||||
|
|
||||||
# List all of the files in the SSL directory and one level deep.
|
# For a 'www.' domain, see if we can reuse the cert of the parent.
|
||||||
def get_file_list():
|
elif domain.startswith('www.'):
|
||||||
for fn in os.listdir(ssl_root):
|
ssl_certificate_parent = os.path.join(env["STORAGE_ROOT"], 'ssl/%s/ssl_certificate.pem' % safe_domain_name(domain[4:]))
|
||||||
fn = os.path.join(ssl_root, fn)
|
if os.path.exists(ssl_certificate_parent) and check_certificate(domain, ssl_certificate_parent, None)[0] == "OK":
|
||||||
if os.path.isfile(fn):
|
ssl_certificate = ssl_certificate_parent
|
||||||
yield fn
|
ssl_via = "Using multi/wildcard certificate of %s." % domain[4:]
|
||||||
elif os.path.isdir(fn):
|
|
||||||
for fn1 in os.listdir(fn):
|
|
||||||
fn1 = os.path.join(fn, fn1)
|
|
||||||
if os.path.isfile(fn1):
|
|
||||||
yield fn1
|
|
||||||
|
|
||||||
# Remember stuff.
|
return ssl_key, ssl_certificate, ssl_via
|
||||||
private_keys = { }
|
|
||||||
certificates = [ ]
|
|
||||||
|
|
||||||
# Scan each of the files to find private keys and certificates.
|
def ensure_ssl_certificate_exists(domain, ssl_key, ssl_certificate, env):
|
||||||
# We must load all of the private keys first before processing
|
# For domains besides PRIMARY_HOSTNAME, generate a self-signed certificate if
|
||||||
# certificates so that we can check that we have a private key
|
# a certificate doesn't already exist. See setup/mail.sh for documentation.
|
||||||
# available before using a certificate.
|
|
||||||
from status_checks import load_cert_chain, load_pem
|
|
||||||
for fn in get_file_list():
|
|
||||||
try:
|
|
||||||
pem = load_pem(load_cert_chain(fn)[0])
|
|
||||||
except ValueError:
|
|
||||||
# Not a valid PEM format for a PEM type we care about.
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Remember where we got this object.
|
|
||||||
pem._filename = fn
|
|
||||||
|
|
||||||
# Is it a private key?
|
|
||||||
if isinstance(pem, RSAPrivateKey):
|
|
||||||
private_keys[pem.public_key().public_numbers()] = pem
|
|
||||||
|
|
||||||
# Is it a certificate?
|
|
||||||
if isinstance(pem, Certificate):
|
|
||||||
certificates.append(pem)
|
|
||||||
|
|
||||||
# Process the certificates.
|
|
||||||
domains = { }
|
|
||||||
from status_checks import get_certificate_domains
|
|
||||||
for cert in certificates:
|
|
||||||
# What domains is this certificate good for?
|
|
||||||
cert_domains, primary_domain = get_certificate_domains(cert)
|
|
||||||
cert._primary_domain = primary_domain
|
|
||||||
|
|
||||||
# Is there a private key file for this certificate?
|
|
||||||
private_key = private_keys.get(cert.public_key().public_numbers())
|
|
||||||
if not private_key:
|
|
||||||
continue
|
|
||||||
cert._private_key = private_key
|
|
||||||
|
|
||||||
# Add this cert to the list of certs usable for the domains.
|
|
||||||
for domain in cert_domains:
|
|
||||||
domains.setdefault(domain, []).append(cert)
|
|
||||||
|
|
||||||
# Sort the certificates to prefer good ones.
|
|
||||||
import datetime
|
|
||||||
now = datetime.datetime.utcnow()
|
|
||||||
ret = { }
|
|
||||||
for domain, cert_list in domains.items():
|
|
||||||
cert_list.sort(key = lambda cert : (
|
|
||||||
# must be valid NOW
|
|
||||||
cert.not_valid_before <= now <= cert.not_valid_after,
|
|
||||||
|
|
||||||
# prefer one that is not self-signed
|
|
||||||
cert.issuer != cert.subject,
|
|
||||||
|
|
||||||
# prefer one with the expiration furthest into the future so
|
|
||||||
# that we can easily rotate to new certs as we get them
|
|
||||||
cert.not_valid_after,
|
|
||||||
|
|
||||||
# in case a certificate is installed in multiple paths,
|
|
||||||
# prefer the... lexicographically last one?
|
|
||||||
cert._filename,
|
|
||||||
|
|
||||||
), reverse=True)
|
|
||||||
cert = cert_list.pop(0)
|
|
||||||
ret[domain] = {
|
|
||||||
"private-key": cert._private_key._filename,
|
|
||||||
"certificate": cert._filename,
|
|
||||||
"primary-domain": cert._primary_domain,
|
|
||||||
}
|
|
||||||
|
|
||||||
return ret
|
|
||||||
|
|
||||||
def get_domain_ssl_files(domain, ssl_certificates, env, allow_missing_cert=False):
|
|
||||||
# Get the default paths.
|
|
||||||
ssl_private_key = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_private_key.pem'))
|
|
||||||
ssl_certificate = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_certificate.pem'))
|
|
||||||
|
|
||||||
if domain == env['PRIMARY_HOSTNAME']:
|
if domain == env['PRIMARY_HOSTNAME']:
|
||||||
# The primary domain must use the server certificate because
|
return
|
||||||
# it is hard-coded in some service configuration files.
|
|
||||||
return ssl_private_key, ssl_certificate, None
|
|
||||||
|
|
||||||
wildcard_domain = re.sub("^[^\.]+", "*", domain)
|
# Sanity check. Shouldn't happen. A non-primary domain might use this
|
||||||
|
# certificate (see above), but then the certificate should exist anyway.
|
||||||
|
if ssl_certificate == os.path.join(env["STORAGE_ROOT"], 'ssl/ssl_certificate.pem'):
|
||||||
|
return
|
||||||
|
|
||||||
if domain in ssl_certificates:
|
if os.path.exists(ssl_certificate):
|
||||||
cert_info = ssl_certificates[domain]
|
return
|
||||||
cert_type = "multi-domain"
|
|
||||||
elif wildcard_domain in ssl_certificates:
|
|
||||||
cert_info = ssl_certificates[wildcard_domain]
|
|
||||||
cert_type = "wildcard"
|
|
||||||
elif not allow_missing_cert:
|
|
||||||
# No certificate is available for this domain! Return default files.
|
|
||||||
ssl_via = "Using certificate for %s." % env['PRIMARY_HOSTNAME']
|
|
||||||
return ssl_private_key, ssl_certificate, ssl_via
|
|
||||||
else:
|
|
||||||
# No certificate is available - and warn appropriately.
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 'via' is a hint to the user about which certificate is in use for the domain
|
os.makedirs(os.path.dirname(ssl_certificate), exist_ok=True)
|
||||||
if cert_info['certificate'] == os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_certificate.pem'):
|
|
||||||
# Using the server certificate.
|
|
||||||
via = "Using same %s certificate as for %s." % (cert_type, env['PRIMARY_HOSTNAME'])
|
|
||||||
elif cert_info['primary-domain'] != domain and cert_info['primary-domain'] in ssl_certificates and cert_info == ssl_certificates[cert_info['primary-domain']]:
|
|
||||||
via = "Using same %s certificate as for %s." % (cert_type, cert_info['primary-domain'])
|
|
||||||
else:
|
|
||||||
via = None # don't show a hint - show expiration info instead
|
|
||||||
|
|
||||||
return cert_info['private-key'], cert_info['certificate'], via
|
# Generate a new self-signed certificate using the same private key that we already have.
|
||||||
|
|
||||||
|
# Start with a CSR written to a temporary file.
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w") as csr_fp:
|
||||||
|
csr_fp.write(create_csr(domain, ssl_key, env))
|
||||||
|
csr_fp.flush() # since we won't close until after running 'openssl x509', since close triggers delete.
|
||||||
|
|
||||||
|
# And then make the certificate.
|
||||||
|
shell("check_call", [
|
||||||
|
"openssl", "x509", "-req",
|
||||||
|
"-days", "365",
|
||||||
|
"-in", csr_fp.name,
|
||||||
|
"-signkey", ssl_key,
|
||||||
|
"-out", ssl_certificate])
|
||||||
|
|
||||||
def create_csr(domain, ssl_key, env):
|
def create_csr(domain, ssl_key, env):
|
||||||
return shell("check_output", [
|
return shell("check_output", [
|
||||||
@@ -328,7 +254,7 @@ def create_csr(domain, ssl_key, env):
|
|||||||
"-subj", "/C=%s/ST=/L=/O=/CN=%s" % (env["CSR_COUNTRY"], domain)])
|
"-subj", "/C=%s/ST=/L=/O=/CN=%s" % (env["CSR_COUNTRY"], domain)])
|
||||||
|
|
||||||
def install_cert(domain, ssl_cert, ssl_chain, env):
|
def install_cert(domain, ssl_cert, ssl_chain, env):
|
||||||
if domain not in get_web_domains(env) + get_default_www_redirects(env):
|
if domain not in get_web_domains(env):
|
||||||
return "Invalid domain name."
|
return "Invalid domain name."
|
||||||
|
|
||||||
# Write the combined cert+chain to a temporary path and validate that it is OK.
|
# Write the combined cert+chain to a temporary path and validate that it is OK.
|
||||||
@@ -340,8 +266,8 @@ def install_cert(domain, ssl_cert, ssl_chain, env):
|
|||||||
|
|
||||||
# Do validation on the certificate before installing it.
|
# Do validation on the certificate before installing it.
|
||||||
from status_checks import check_certificate
|
from status_checks import check_certificate
|
||||||
ssl_private_key = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_private_key.pem'))
|
ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, env, allow_shared_cert=False)
|
||||||
cert_status, cert_status_details = check_certificate(domain, fn, ssl_private_key)
|
cert_status, cert_status_details = check_certificate(domain, fn, ssl_key)
|
||||||
if cert_status != "OK":
|
if cert_status != "OK":
|
||||||
if cert_status == "SELF-SIGNED":
|
if cert_status == "SELF-SIGNED":
|
||||||
cert_status = "This is a self-signed certificate. I can't install that."
|
cert_status = "This is a self-signed certificate. I can't install that."
|
||||||
@@ -350,24 +276,7 @@ def install_cert(domain, ssl_cert, ssl_chain, env):
|
|||||||
cert_status += " " + cert_status_details
|
cert_status += " " + cert_status_details
|
||||||
return cert_status
|
return cert_status
|
||||||
|
|
||||||
# Where to put it?
|
# Copy the certificate to its expected location.
|
||||||
if domain == env['PRIMARY_HOSTNAME']:
|
|
||||||
ssl_certificate = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_certificate.pem'))
|
|
||||||
else:
|
|
||||||
# Make a unique path for the certificate.
|
|
||||||
from status_checks import load_cert_chain, load_pem, get_certificate_domains
|
|
||||||
from cryptography.hazmat.primitives import hashes
|
|
||||||
from binascii import hexlify
|
|
||||||
cert = load_pem(load_cert_chain(fn)[0])
|
|
||||||
all_domains, cn = get_certificate_domains(cert)
|
|
||||||
path = "%s-%s-%s" % (
|
|
||||||
cn, # common name
|
|
||||||
cert.not_valid_after.date().isoformat().replace("-", ""), # expiration date
|
|
||||||
hexlify(cert.fingerprint(hashes.SHA256())).decode("ascii")[0:8], # fingerprint prefix
|
|
||||||
)
|
|
||||||
ssl_certificate = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', path, 'ssl_certificate.pem'))
|
|
||||||
|
|
||||||
# Install the certificate.
|
|
||||||
os.makedirs(os.path.dirname(ssl_certificate), exist_ok=True)
|
os.makedirs(os.path.dirname(ssl_certificate), exist_ok=True)
|
||||||
shutil.move(fn, ssl_certificate)
|
shutil.move(fn, ssl_certificate)
|
||||||
|
|
||||||
@@ -393,10 +302,9 @@ def get_web_domains_info(env):
|
|||||||
# for the SSL config panel, get cert status
|
# for the SSL config panel, get cert status
|
||||||
def check_cert(domain):
|
def check_cert(domain):
|
||||||
from status_checks import check_certificate
|
from status_checks import check_certificate
|
||||||
ssl_certificates = get_ssl_certificates(env)
|
ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, env)
|
||||||
x = get_domain_ssl_files(domain, ssl_certificates, env, allow_missing_cert=True)
|
if not os.path.exists(ssl_certificate):
|
||||||
if x is None: return ("danger", "No Certificate Installed")
|
return ("danger", "No Certificate Installed")
|
||||||
ssl_key, ssl_certificate, ssl_via = x
|
|
||||||
cert_status, cert_status_details = check_certificate(domain, ssl_certificate, ssl_key)
|
cert_status, cert_status_details = check_certificate(domain, ssl_certificate, ssl_key)
|
||||||
if cert_status == "OK":
|
if cert_status == "OK":
|
||||||
if not ssl_via:
|
if not ssl_via:
|
||||||
|
|||||||
15
security.md
15
security.md
@@ -17,11 +17,8 @@ The primary goal of Mail-in-a-Box is to make deploying a good mail server easy,
|
|||||||
|
|
||||||
On the other hand, we do assume that adversaries are performing passive surveillance and, possibly, active man-in-the-middle attacks. And so:
|
On the other hand, we do assume that adversaries are performing passive surveillance and, possibly, active man-in-the-middle attacks. And so:
|
||||||
|
|
||||||
* User credentials are always sent through SSH/TLS, never in the clear, with modern TLS settings.
|
* User credentials are always sent through SSH/TLS, never in the clear.
|
||||||
* Outbound mail is sent with the highest level of TLS possible.
|
* Outbound mail is sent with the highest level of TLS possible (more on that below).
|
||||||
* The box advertises its support for [DANE TLSA](https://en.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities), when DNSSEC is enabled at the domain name registrar, so that inbound mail is more likely to be transmitted securely.
|
|
||||||
|
|
||||||
Additional details follow.
|
|
||||||
|
|
||||||
User Credentials
|
User Credentials
|
||||||
----------------
|
----------------
|
||||||
@@ -88,13 +85,7 @@ If the recipient's domain name supports DNSSEC and has published a [DANE TLSA](h
|
|||||||
|
|
||||||
### Domain Policy Records
|
### Domain Policy Records
|
||||||
|
|
||||||
Domain policy records allow recipient MTAs to detect when the _domain_ part of of the sender address in incoming mail has been spoofed. All outbound mail is signed with [DKIM](https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail) and "quarantine" [DMARC](https://en.wikipedia.org/wiki/DMARC) records are automatically set in DNS. Receiving MTAs that implement DMARC will automatically quarantine mail that is "From:" a domain hosted by the box but which was not sent by the box. (Strong [SPF](https://en.wikipedia.org/wiki/Sender_Policy_Framework) records are also automatically set in DNS.) ([source](management/dns_update.py))
|
Domain policy records allow recipient MTAs to detect when the _domain_ part of incoming mail has been spoofed. All outbound mail is signed with [DKIM](https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail) and "quarantine" [DMARC](https://en.wikipedia.org/wiki/DMARC) records are automatically set in DNS. Receiving MTAs that implement DMARC will automatically quarantine mail that is "From:" a domain hosted by the box but which was not sent by the box. (Strong [SPF](https://en.wikipedia.org/wiki/Sender_Policy_Framework) records are also automatically set in DNS.) ([source](management/dns_update.py))
|
||||||
|
|
||||||
### User Policy
|
|
||||||
|
|
||||||
While domain policy records prevent other servers from sending mail with a "From:" header that matches a domain hosted on the box (see above), those policy records do not guarnatee that the user portion of the sender email address matches the actual sender. In enterprise environments where the box may host the mail of untrusted users, it is important to guard against users impersonating other users.
|
|
||||||
|
|
||||||
The box restricts the envelope sender address (also called the return path or MAIL FROM address --- this is different from the "From:" header) that users may put into outbound mail. The envelope sender address must be either their own email address (their SMTP login username) or any alias that they are listed as a permitted sender of. (There is currently no restriction on the contents of the "From:" header.)
|
|
||||||
|
|
||||||
Incoming Mail
|
Incoming Mail
|
||||||
-------------
|
-------------
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
#########################################################
|
#########################################################
|
||||||
|
|
||||||
if [ -z "$TAG" ]; then
|
if [ -z "$TAG" ]; then
|
||||||
TAG=v0.14
|
TAG=v0.10
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Are we running as root?
|
# Are we running as root?
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ source setup/functions.sh # load our functions
|
|||||||
source /etc/mailinabox.conf # load global vars
|
source /etc/mailinabox.conf # load global vars
|
||||||
|
|
||||||
# Install DKIM...
|
# Install DKIM...
|
||||||
echo Installing OpenDKIM/OpenDMARC...
|
|
||||||
apt_install opendkim opendkim-tools opendmarc
|
apt_install opendkim opendkim-tools opendmarc
|
||||||
|
|
||||||
# Make sure configuration directories exist.
|
# Make sure configuration directories exist.
|
||||||
@@ -36,18 +35,13 @@ RequireSafeKeys false
|
|||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create a new DKIM key. This creates mail.private and mail.txt
|
# Create a new DKIM key. This creates
|
||||||
# in $STORAGE_ROOT/mail/dkim. The former is the private key and
|
# mail.private and mail.txt in $STORAGE_ROOT/mail/dkim. The former
|
||||||
# the latter is the suggested DNS TXT entry which we'll include
|
# is the actual private key and the latter is the suggested DNS TXT
|
||||||
# in our DNS setup. Note tha the files are named after the
|
# entry which we'll want to include in our DNS setup.
|
||||||
# 'selector' of the key, which we can change later on to support
|
|
||||||
# key rotation.
|
|
||||||
#
|
|
||||||
# A 1024-bit key is seen as a minimum standard by several providers
|
|
||||||
# such as Google. But they and others use a 2048 bit key, so we'll
|
|
||||||
# do the same. Keys beyond 2048 bits may exceed DNS record limits.
|
|
||||||
if [ ! -f "$STORAGE_ROOT/mail/dkim/mail.private" ]; then
|
if [ ! -f "$STORAGE_ROOT/mail/dkim/mail.private" ]; then
|
||||||
opendkim-genkey -b 2048 -r -s mail -D $STORAGE_ROOT/mail/dkim
|
# Should we specify -h rsa-sha256?
|
||||||
|
opendkim-genkey -r -s mail -D $STORAGE_ROOT/mail/dkim
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Ensure files are owned by the opendkim user and are private otherwise.
|
# Ensure files are owned by the opendkim user and are private otherwise.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ source /etc/mailinabox.conf # load global vars
|
|||||||
# * nsd: The non-recursive nameserver that publishes our DNS records.
|
# * nsd: The non-recursive nameserver that publishes our DNS records.
|
||||||
# * ldnsutils: Helper utilities for signing DNSSEC zones.
|
# * ldnsutils: Helper utilities for signing DNSSEC zones.
|
||||||
# * openssh-client: Provides ssh-keyscan which we use to create SSHFP records.
|
# * openssh-client: Provides ssh-keyscan which we use to create SSHFP records.
|
||||||
echo "Installing nsd (DNS server)..."
|
|
||||||
apt_install nsd ldnsutils openssh-client
|
apt_install nsd ldnsutils openssh-client
|
||||||
|
|
||||||
# Prepare nsd's configuration.
|
# Prepare nsd's configuration.
|
||||||
@@ -127,6 +127,8 @@ EOF
|
|||||||
chmod +x /etc/cron.daily/mailinabox-dnssec
|
chmod +x /etc/cron.daily/mailinabox-dnssec
|
||||||
|
|
||||||
# Permit DNS queries on TCP/UDP in the firewall.
|
# Permit DNS queries on TCP/UDP in the firewall.
|
||||||
|
|
||||||
ufw_allow domain
|
ufw_allow domain
|
||||||
|
|
||||||
|
# Start nsd. None of the zones are configured until the management daemon is
|
||||||
|
# run later, though.
|
||||||
|
restart_service nsd
|
||||||
|
|||||||
@@ -53,5 +53,5 @@ if [ -z "`tools/mail.py user`" ]; then
|
|||||||
hide_output tools/mail.py user make-admin $EMAIL_ADDR
|
hide_output tools/mail.py user make-admin $EMAIL_ADDR
|
||||||
|
|
||||||
# Create an alias to which we'll direct all automatically-created administrative aliases.
|
# Create an alias to which we'll direct all automatically-created administrative aliases.
|
||||||
tools/mail.py alias add administrator@$PRIMARY_HOSTNAME $EMAIL_ADDR > /dev/null
|
tools/mail.py alias add administrator@$PRIMARY_HOSTNAME $EMAIL_ADDR
|
||||||
fi
|
fi
|
||||||
@@ -39,12 +39,38 @@ function apt_get_quiet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function apt_install {
|
function apt_install {
|
||||||
# Install a bunch of packages. We used to report which packages were already
|
|
||||||
# installed and which needed installing, before just running an 'apt-get
|
|
||||||
# install' for all of the packages. Calling `dpkg` on each package is slow,
|
|
||||||
# and doesn't affect what we actually do, except in the messages, so let's
|
|
||||||
# not do that anymore.
|
|
||||||
PACKAGES=$@
|
PACKAGES=$@
|
||||||
|
|
||||||
|
if [ ! -z "$IS_DOCKER" ]; then
|
||||||
|
# Speed things up because packages are already installed by the image.
|
||||||
|
PACKAGES=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Report any packages already installed.
|
||||||
|
TO_INSTALL=""
|
||||||
|
ALREADY_INSTALLED=""
|
||||||
|
for pkg in $PACKAGES; do
|
||||||
|
if dpkg -s $pkg 2>/dev/null | grep "^Status: install ok installed" > /dev/null; then
|
||||||
|
if [[ ! -z "$ALREADY_INSTALLED" ]]; then ALREADY_INSTALLED="$ALREADY_INSTALLED, "; fi
|
||||||
|
ALREADY_INSTALLED="$ALREADY_INSTALLED$pkg (`dpkg -s $pkg | grep ^Version: | sed -e 's/.*: //'`)"
|
||||||
|
else
|
||||||
|
TO_INSTALL="$TO_INSTALL""$pkg "
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# List the packages already installed.
|
||||||
|
if [[ ! -z "$ALREADY_INSTALLED" ]]; then
|
||||||
|
echo already installed: $ALREADY_INSTALLED
|
||||||
|
fi
|
||||||
|
|
||||||
|
# List the packages about to be installed.
|
||||||
|
if [[ ! -z "$TO_INSTALL" ]]; then
|
||||||
|
echo installing $TO_INSTALL...
|
||||||
|
fi
|
||||||
|
|
||||||
|
# We still include the whole original package list in the apt-get command in
|
||||||
|
# case it wants to upgrade anything, I guess? Maybe we can remove it. Doesn't normally make
|
||||||
|
# a difference.
|
||||||
apt_get_quiet install $PACKAGES
|
apt_get_quiet install $PACKAGES
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,12 +78,20 @@ function get_default_hostname {
|
|||||||
# Guess the machine's hostname. It should be a fully qualified
|
# Guess the machine's hostname. It should be a fully qualified
|
||||||
# domain name suitable for DNS. None of these calls may provide
|
# domain name suitable for DNS. None of these calls may provide
|
||||||
# the right value, but it's the best guess we can make.
|
# the right value, but it's the best guess we can make.
|
||||||
set -- $(hostname --fqdn 2>/dev/null ||
|
set -- $(
|
||||||
hostname --all-fqdns 2>/dev/null ||
|
get_hostname_from_reversedns ||
|
||||||
hostname 2>/dev/null)
|
hostname --fqdn 2>/dev/null ||
|
||||||
|
hostname --all-fqdns 2>/dev/null ||
|
||||||
|
hostname 2>/dev/null)
|
||||||
printf '%s\n' "$1" # return this value
|
printf '%s\n' "$1" # return this value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function get_hostname_from_reversedns {
|
||||||
|
# Do a reverse DNS lookup on our public IPv4 address. The output of
|
||||||
|
# `host` is complex -- use sed to get the FDQN.
|
||||||
|
host $(get_publicip_from_web_service 4) | sed "s/.*pointer \(.*\)\./\1/"
|
||||||
|
}
|
||||||
|
|
||||||
function get_publicip_from_web_service {
|
function get_publicip_from_web_service {
|
||||||
# This seems to be the most reliable way to determine the
|
# This seems to be the most reliable way to determine the
|
||||||
# machine's public IP address: asking a very nice web API
|
# machine's public IP address: asking a very nice web API
|
||||||
@@ -133,7 +167,17 @@ function ufw_allow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function restart_service {
|
function restart_service {
|
||||||
hide_output service $1 restart
|
if [ -z "$IS_DOCKER" ]; then
|
||||||
|
# Restart the service.
|
||||||
|
hide_output service $1 restart
|
||||||
|
|
||||||
|
else
|
||||||
|
# In Docker, make sure the service is not disabled by a down file.
|
||||||
|
if [ -f /etc/service/$1/down ]; then
|
||||||
|
rm /etc/service/$1/down
|
||||||
|
fi
|
||||||
|
sv restart $1
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
## Dialog Functions ##
|
## Dialog Functions ##
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ source /etc/mailinabox.conf # load global vars
|
|||||||
# but dovecot-lucene is packaged by *us* in the Mail-in-a-Box PPA,
|
# but dovecot-lucene is packaged by *us* in the Mail-in-a-Box PPA,
|
||||||
# not by Ubuntu.
|
# not by Ubuntu.
|
||||||
|
|
||||||
echo "Installing Dovecot (IMAP server)..."
|
|
||||||
apt_install \
|
apt_install \
|
||||||
dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-sqlite sqlite3 \
|
dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-sqlite sqlite3 \
|
||||||
dovecot-sieve dovecot-managesieved dovecot-lucene
|
dovecot-sieve dovecot-managesieved dovecot-lucene
|
||||||
@@ -56,9 +55,6 @@ tools/editconf.py /etc/dovecot/conf.d/10-mail.conf \
|
|||||||
mail_privileged_group=mail \
|
mail_privileged_group=mail \
|
||||||
first_valid_uid=0
|
first_valid_uid=0
|
||||||
|
|
||||||
# Create, subscribe, and mark as special folders: INBOX, Drafts, Sent, Trash, Spam and Archive.
|
|
||||||
cp conf/dovecot-mailboxes.conf /etc/dovecot/conf.d/15-mailboxes.conf
|
|
||||||
|
|
||||||
# ### IMAP/POP
|
# ### IMAP/POP
|
||||||
|
|
||||||
# Require that passwords are sent over SSL only, and allow the usual IMAP authentication mechanisms.
|
# Require that passwords are sent over SSL only, and allow the usual IMAP authentication mechanisms.
|
||||||
@@ -154,12 +150,6 @@ sed -i "s/#mail_plugins = .*/mail_plugins = \$mail_plugins sieve/" /etc/dovecot/
|
|||||||
#
|
#
|
||||||
# * `sieve_before`: The path to our global sieve which handles moving spam to the Spam folder.
|
# * `sieve_before`: The path to our global sieve which handles moving spam to the Spam folder.
|
||||||
#
|
#
|
||||||
# * `sieve_before2`: The path to our global sieve directory for sieve which can contain .sieve files
|
|
||||||
# to run globally for every user before their own sieve files run.
|
|
||||||
#
|
|
||||||
# * `sieve_after`: The path to our global sieve directory which can contain .sieve files
|
|
||||||
# to run globally for every user after their own sieve files run.
|
|
||||||
#
|
|
||||||
# * `sieve`: The path to the user's main active script. ManageSieve will create a symbolic
|
# * `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
|
# 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 it might appear as a folder) and it should not be in the sieve_dir
|
||||||
@@ -169,8 +159,6 @@ sed -i "s/#mail_plugins = .*/mail_plugins = \$mail_plugins sieve/" /etc/dovecot/
|
|||||||
cat > /etc/dovecot/conf.d/99-local-sieve.conf << EOF;
|
cat > /etc/dovecot/conf.d/99-local-sieve.conf << EOF;
|
||||||
plugin {
|
plugin {
|
||||||
sieve_before = /etc/dovecot/sieve-spam.sieve
|
sieve_before = /etc/dovecot/sieve-spam.sieve
|
||||||
sieve_before2 = $STORAGE_ROOT/mail/sieve/global_before
|
|
||||||
sieve_after = $STORAGE_ROOT/mail/sieve/global_after
|
|
||||||
sieve = $STORAGE_ROOT/mail/sieve/%d/%n.sieve
|
sieve = $STORAGE_ROOT/mail/sieve/%d/%n.sieve
|
||||||
sieve_dir = $STORAGE_ROOT/mail/sieve/%d/%n
|
sieve_dir = $STORAGE_ROOT/mail/sieve/%d/%n
|
||||||
}
|
}
|
||||||
@@ -194,8 +182,6 @@ chown -R mail.mail $STORAGE_ROOT/mail/mailboxes
|
|||||||
|
|
||||||
# Same for the sieve scripts.
|
# Same for the sieve scripts.
|
||||||
mkdir -p $STORAGE_ROOT/mail/sieve
|
mkdir -p $STORAGE_ROOT/mail/sieve
|
||||||
mkdir -p $STORAGE_ROOT/mail/sieve/global_before
|
|
||||||
mkdir -p $STORAGE_ROOT/mail/sieve/global_after
|
|
||||||
chown -R mail.mail $STORAGE_ROOT/mail/sieve
|
chown -R mail.mail $STORAGE_ROOT/mail/sieve
|
||||||
|
|
||||||
# Allow the IMAP/POP ports in the firewall.
|
# Allow the IMAP/POP ports in the firewall.
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ source /etc/mailinabox.conf # load global vars
|
|||||||
# > infrastructure and every commercial vendor of dnswl.org data (eg through
|
# > infrastructure and every commercial vendor of dnswl.org data (eg through
|
||||||
# > anti-spam solutions) must register with dnswl.org and purchase a subscription.
|
# > anti-spam solutions) must register with dnswl.org and purchase a subscription.
|
||||||
|
|
||||||
echo "Installing Postfix (SMTP server)..."
|
|
||||||
apt_install postfix postfix-pcre postgrey ca-certificates
|
apt_install postfix postfix-pcre postgrey ca-certificates
|
||||||
|
|
||||||
# ### Basic Settings
|
# ### Basic Settings
|
||||||
@@ -94,12 +93,6 @@ tools/editconf.py /etc/postfix/master.cf -s -w \
|
|||||||
# Install the `outgoing_mail_header_filters` file required by the new 'authclean' service.
|
# 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
|
cp conf/postfix_outgoing_mail_header_filters /etc/postfix/outgoing_mail_header_filters
|
||||||
|
|
||||||
# Modify the `outgoing_mail_header_filters` file to use the local machine name and ip
|
|
||||||
# on the first received header line. This may help reduce the spam score of email by
|
|
||||||
# removing the 127.0.0.1 reference.
|
|
||||||
sed -i "s/PRIMARY_HOSTNAME/$PRIMARY_HOSTNAME/" /etc/postfix/outgoing_mail_header_filters
|
|
||||||
sed -i "s/PUBLIC_IP/$PUBLIC_IP/" /etc/postfix/outgoing_mail_header_filters
|
|
||||||
|
|
||||||
# Enable TLS on these and all other 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
|
# require TLS before a user is allowed to authenticate. This also makes
|
||||||
# opportunistic TLS available on *incoming* mail.
|
# opportunistic TLS available on *incoming* mail.
|
||||||
@@ -167,7 +160,6 @@ tools/editconf.py /etc/postfix/main.cf virtual_transport=lmtp:[127.0.0.1]:10025
|
|||||||
#
|
#
|
||||||
# * `reject_non_fqdn_sender`: Reject not-nice-looking return paths.
|
# * `reject_non_fqdn_sender`: Reject not-nice-looking return paths.
|
||||||
# * `reject_unknown_sender_domain`: Reject return paths with invalid domains.
|
# * `reject_unknown_sender_domain`: Reject return paths with invalid domains.
|
||||||
# * `reject_authenticated_sender_login_mismatch`: Reject if mail FROM address does not match the client SASL login
|
|
||||||
# * `reject_rhsbl_sender`: Reject return paths that use blacklisted 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_sasl_authenticated`: Authenticated users (i.e. on port 587) can skip further checks.
|
||||||
# * `permit_mynetworks`: Mail that originates locally can skip further checks.
|
# * `permit_mynetworks`: Mail that originates locally can skip further checks.
|
||||||
@@ -181,7 +173,7 @@ tools/editconf.py /etc/postfix/main.cf virtual_transport=lmtp:[127.0.0.1]:10025
|
|||||||
# whitelisted) then postfix does a DEFER_IF_REJECT, which results in all "unknown user" sorts of messages turning into #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
|
# "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 \
|
tools/editconf.py /etc/postfix/main.cf \
|
||||||
smtpd_sender_restrictions="reject_non_fqdn_sender,reject_unknown_sender_domain,reject_authenticated_sender_login_mismatch,reject_rhsbl_sender dbl.spamhaus.org" \
|
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"
|
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"
|
||||||
|
|
||||||
# Postfix connects to Postgrey on the 127.0.0.1 interface specifically. Ensure that
|
# Postfix connects to Postgrey on the 127.0.0.1 interface specifically. Ensure that
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
#
|
#
|
||||||
# This script configures user authentication for Dovecot
|
# This script configures user authentication for Dovecot
|
||||||
# and Postfix (which relies on Dovecot) and destination
|
# and Postfix (which relies on Dovecot) and destination
|
||||||
# validation by quering an Sqlite3 database of mail users.
|
# validation by quering an Sqlite3 database of mail users.
|
||||||
|
|
||||||
source setup/functions.sh # load our functions
|
source setup/functions.sh # load our functions
|
||||||
source /etc/mailinabox.conf # load global vars
|
source /etc/mailinabox.conf # load global vars
|
||||||
@@ -21,7 +21,7 @@ db_path=$STORAGE_ROOT/mail/users.sqlite
|
|||||||
if [ ! -f $db_path ]; then
|
if [ ! -f $db_path ]; then
|
||||||
echo Creating new user database: $db_path;
|
echo Creating new user database: $db_path;
|
||||||
echo "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra, privileges TEXT NOT NULL DEFAULT '');" | sqlite3 $db_path;
|
echo "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra, privileges TEXT NOT NULL DEFAULT '');" | sqlite3 $db_path;
|
||||||
echo "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);" | sqlite3 $db_path;
|
echo "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL);" | sqlite3 $db_path;
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ### User Authentication
|
# ### User Authentication
|
||||||
@@ -69,27 +69,6 @@ tools/editconf.py /etc/postfix/main.cf \
|
|||||||
smtpd_sasl_path=private/auth \
|
smtpd_sasl_path=private/auth \
|
||||||
smtpd_sasl_auth_enable=yes
|
smtpd_sasl_auth_enable=yes
|
||||||
|
|
||||||
# ### Sender Validation
|
|
||||||
|
|
||||||
# We use Postfix's reject_authenticated_sender_login_mismatch filter to
|
|
||||||
# prevent intra-domain spoofing by logged in but untrusted users in outbound
|
|
||||||
# email. In all outbound mail (the sender has authenticated), the MAIL FROM
|
|
||||||
# address (aka envelope or return path address) must be "owned" by the user
|
|
||||||
# who authenticated. An SQL query will find who are the owners of any given
|
|
||||||
# address.
|
|
||||||
tools/editconf.py /etc/postfix/main.cf \
|
|
||||||
smtpd_sender_login_maps=sqlite:/etc/postfix/sender-login-maps.cf
|
|
||||||
|
|
||||||
# Postfix will query the exact address first, where the priority will be alias
|
|
||||||
# records first, then user records. If there are no matches for the exact
|
|
||||||
# address, then Postfix will query just the domain part, which we call
|
|
||||||
# catch-alls and domain aliases. A NULL permitted_senders column means to
|
|
||||||
# take the value from the destination column.
|
|
||||||
cat > /etc/postfix/sender-login-maps.cf << EOF;
|
|
||||||
dbpath=$db_path
|
|
||||||
query = SELECT permitted_senders FROM (SELECT permitted_senders, 0 AS priority FROM aliases WHERE source='%s' AND permitted_senders IS NOT NULL UNION SELECT destination AS permitted_senders, 1 AS priority FROM aliases WHERE source='%s' AND permitted_senders IS NULL UNION SELECT email as permitted_senders, 2 AS priority FROM users WHERE email='%s') ORDER BY priority LIMIT 1;
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# ### Destination Validation
|
# ### Destination Validation
|
||||||
|
|
||||||
# Use a Sqlite3 database to check whether a destination email address exists,
|
# Use a Sqlite3 database to check whether a destination email address exists,
|
||||||
@@ -100,45 +79,29 @@ tools/editconf.py /etc/postfix/main.cf \
|
|||||||
virtual_alias_maps=sqlite:/etc/postfix/virtual-alias-maps.cf \
|
virtual_alias_maps=sqlite:/etc/postfix/virtual-alias-maps.cf \
|
||||||
local_recipient_maps=\$virtual_mailbox_maps
|
local_recipient_maps=\$virtual_mailbox_maps
|
||||||
|
|
||||||
# SQL statement to check if we handle incoming mail for a domain, either for users or aliases.
|
# SQL statement to check if we handle mail for a domain, either for users or aliases.
|
||||||
cat > /etc/postfix/virtual-mailbox-domains.cf << EOF;
|
cat > /etc/postfix/virtual-mailbox-domains.cf << EOF;
|
||||||
dbpath=$db_path
|
dbpath=$db_path
|
||||||
query = SELECT 1 FROM users WHERE email LIKE '%%@%s' UNION SELECT 1 FROM aliases WHERE source LIKE '%%@%s'
|
query = SELECT 1 FROM users WHERE email LIKE '%%@%s' UNION SELECT 1 FROM aliases WHERE source LIKE '%%@%s'
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# SQL statement to check if we handle incoming mail for a user.
|
# SQL statement to check if we handle mail for a user.
|
||||||
cat > /etc/postfix/virtual-mailbox-maps.cf << EOF;
|
cat > /etc/postfix/virtual-mailbox-maps.cf << EOF;
|
||||||
dbpath=$db_path
|
dbpath=$db_path
|
||||||
query = SELECT 1 FROM users WHERE email='%s'
|
query = SELECT 1 FROM users WHERE email='%s'
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# SQL statement to rewrite an email address if an alias is present.
|
# SQL statement to rewrite an email address if an alias is present.
|
||||||
#
|
# Aliases have precedence over users, but that's counter-intuitive for
|
||||||
# Postfix makes multiple queries for each incoming mail. It first
|
# catch-all aliases ("@domain.com") which should *not* catch mail users.
|
||||||
# queries the whole email address, then just the user part in certain
|
# To fix this, not only query the aliases table but also the users
|
||||||
# locally-directed cases (but we don't use this), then just `@`+the
|
# table, i.e. turn users into aliases from themselves to themselves.
|
||||||
# domain part. The first query that returns something wins. See
|
|
||||||
# http://www.postfix.org/virtual.5.html.
|
|
||||||
#
|
|
||||||
# virtual-alias-maps has precedence over virtual-mailbox-maps, but
|
|
||||||
# we don't want catch-alls and domain aliases to catch mail for users
|
|
||||||
# that have been defined on those domains. To fix this, we not only
|
|
||||||
# query the aliases table but also the users table when resolving
|
|
||||||
# aliases, i.e. we turn users into aliases from themselves to
|
|
||||||
# themselves. That means users will match in postfix's first query
|
|
||||||
# before postfix gets to the third query for catch-alls/domain alises.
|
|
||||||
#
|
|
||||||
# If there is both an alias and a user for the same address either
|
# 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
|
# might be returned by the UNION, so the whole query is wrapped in
|
||||||
# another select that prioritizes the alias definition to preserve
|
# another select that prioritizes the alias definition.
|
||||||
# postfix's preference for aliases for whole email addresses.
|
|
||||||
#
|
|
||||||
# Since we might have alias records with an empty destination because
|
|
||||||
# it might have just permitted_senders, skip any records with an
|
|
||||||
# empty destination here so that other lower priority rules might match.
|
|
||||||
cat > /etc/postfix/virtual-alias-maps.cf << EOF;
|
cat > /etc/postfix/virtual-alias-maps.cf << EOF;
|
||||||
dbpath=$db_path
|
dbpath=$db_path
|
||||||
query = SELECT destination from (SELECT destination, 0 as priority FROM aliases WHERE source='%s' AND destination<>'' UNION SELECT email as destination, 1 as priority FROM users WHERE email='%s') ORDER BY priority LIMIT 1;
|
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
|
EOF
|
||||||
|
|
||||||
# Restart Services
|
# Restart Services
|
||||||
|
|||||||
@@ -2,17 +2,9 @@
|
|||||||
|
|
||||||
source setup/functions.sh
|
source setup/functions.sh
|
||||||
|
|
||||||
echo "Installing Mail-in-a-Box system management daemon..."
|
apt_install python3-flask links duplicity libyaml-dev python3-dnspython python3-dateutil
|
||||||
|
hide_output pip3 install rtyaml "email_validator==0.1.0-rc5"
|
||||||
# build-essential libssl-dev libffi-dev python3-dev: Required to pip install cryptography.
|
# email_validator is repeated in setup/questions.sh
|
||||||
apt_install python3-flask links duplicity libyaml-dev python3-dnspython python3-dateutil \
|
|
||||||
build-essential libssl-dev libffi-dev python3-dev python-pip
|
|
||||||
hide_output pip3 install --upgrade rtyaml email_validator>=1.0.0 idna>=2.0.0 cryptography>=1.0.2 boto
|
|
||||||
|
|
||||||
# duplicity uses python 2 so we need to use the python 2 package of boto
|
|
||||||
hide_output pip install --upgrade boto
|
|
||||||
|
|
||||||
# email_validator is repeated in setup/questions.sh
|
|
||||||
|
|
||||||
# Create a backup directory and a random key for encrypting backups.
|
# Create a backup directory and a random key for encrypting backups.
|
||||||
mkdir -p $STORAGE_ROOT/backup
|
mkdir -p $STORAGE_ROOT/backup
|
||||||
|
|||||||
@@ -95,21 +95,6 @@ def migration_7(env):
|
|||||||
# Save.
|
# Save.
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
def migration_8(env):
|
|
||||||
# Delete DKIM keys. We had generated 1024-bit DKIM keys.
|
|
||||||
# By deleting the key file we'll automatically generate
|
|
||||||
# a new key, which will be 2048 bits.
|
|
||||||
os.unlink(os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.private'))
|
|
||||||
|
|
||||||
def migration_9(env):
|
|
||||||
# Add a column to the aliases table to store permitted_senders,
|
|
||||||
# which is a list of user account email addresses that are
|
|
||||||
# permitted to send mail using this alias instead of their own
|
|
||||||
# address. This was motivated by the addition of #427 ("Reject
|
|
||||||
# outgoing mail if FROM does not match Login") - which introduced
|
|
||||||
# the notion of outbound permitted-senders.
|
|
||||||
db = os.path.join(env["STORAGE_ROOT"], 'mail/users.sqlite')
|
|
||||||
shell("check_call", ["sqlite3", db, "ALTER TABLE aliases ADD permitted_senders TEXT"])
|
|
||||||
|
|
||||||
def get_current_migration():
|
def get_current_migration():
|
||||||
ver = 0
|
ver = 0
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ source setup/functions.sh # load our functions
|
|||||||
source /etc/mailinabox.conf # load global vars
|
source /etc/mailinabox.conf # load global vars
|
||||||
|
|
||||||
# install Munin
|
# install Munin
|
||||||
echo "Installing Munin (system monitoring)..."
|
|
||||||
apt_install munin munin-node
|
apt_install munin munin-node
|
||||||
|
|
||||||
# edit config
|
# edit config
|
||||||
@@ -29,33 +28,5 @@ contact.admin.command mail -s "Munin notification ${var:host}" administrator@$PR
|
|||||||
contact.admin.always_send warning critical
|
contact.admin.always_send warning critical
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# ensure munin-node knows the name of this machine
|
|
||||||
tools/editconf.py /etc/munin/munin-node.conf -s \
|
|
||||||
host_name=$PRIMARY_HOSTNAME
|
|
||||||
|
|
||||||
# Update the activated plugins through munin's autoconfiguration.
|
|
||||||
munin-node-configure --shell --remove-also 2>/dev/null | sh
|
|
||||||
|
|
||||||
# Deactivate monitoring of NTP peers. Not sure why anyone would want to monitor a NTP peer. The addresses seem to change
|
|
||||||
# (which is taken care of my munin-node-configure, but only when we re-run it.)
|
|
||||||
find /etc/munin/plugins/ -lname /usr/share/munin/plugins/ntp_ -print0 | xargs -0 /bin/rm -f
|
|
||||||
|
|
||||||
# Deactivate monitoring of network interfaces that are not up. Otherwise we can get a lot of empty charts.
|
|
||||||
for f in $(find /etc/munin/plugins/ \( -lname /usr/share/munin/plugins/if_ -o -lname /usr/share/munin/plugins/if_err_ -o -lname /usr/share/munin/plugins/bonding_err_ \)); do
|
|
||||||
IF=$(echo $f | sed s/.*_//);
|
|
||||||
if ! ifquery $IF >/dev/null 2>/dev/null; then
|
|
||||||
rm $f;
|
|
||||||
fi;
|
|
||||||
done
|
|
||||||
|
|
||||||
# Create a 'state' directory. Not sure why we need to do this manually.
|
|
||||||
mkdir -p /var/lib/munin-node/plugin-state/
|
|
||||||
|
|
||||||
# Restart services.
|
|
||||||
restart_service munin
|
|
||||||
restart_service munin-node
|
|
||||||
|
|
||||||
# generate initial statistics so the directory isn't empty
|
# generate initial statistics so the directory isn't empty
|
||||||
# (We get "Pango-WARNING **: error opening config file '/root/.config/pango/pangorc': Permission denied"
|
sudo -u munin munin-cron
|
||||||
# if we don't explicitly set the HOME directory when sudo'ing.)
|
|
||||||
sudo -H -u munin munin-cron
|
|
||||||
|
|||||||
@@ -7,18 +7,16 @@ source /etc/mailinabox.conf # load global vars
|
|||||||
|
|
||||||
# ### Installing ownCloud
|
# ### Installing ownCloud
|
||||||
|
|
||||||
echo "Installing ownCloud (contacts/calendar)..."
|
|
||||||
|
|
||||||
apt_install \
|
apt_install \
|
||||||
dbconfig-common \
|
dbconfig-common \
|
||||||
php5-cli php5-sqlite php5-gd php5-imap php5-curl php-pear php-apc curl libapr1 libtool libcurl4-openssl-dev php-xml-parser \
|
php5-cli php5-sqlite php5-gd php5-imap php5-curl php-pear php-apc curl libapr1 libtool libcurl4-openssl-dev php-xml-parser \
|
||||||
php5 php5-dev php5-gd php5-fpm memcached php5-memcached unzip
|
php5 php5-dev php5-gd php5-fpm memcached php5-memcache unzip
|
||||||
|
|
||||||
apt-get purge -qq -y owncloud*
|
apt-get purge -qq -y owncloud*
|
||||||
|
|
||||||
# Install ownCloud from source of this version:
|
# Install ownCloud from source of this version:
|
||||||
owncloud_ver=8.1.1
|
owncloud_ver=8.0.4
|
||||||
owncloud_hash=34077e78575a3e689825a00964ee37fbf83fbdda
|
owncloud_hash=625b1c561ea51426047a3e79eda51ca05e9f978a
|
||||||
|
|
||||||
# Migrate <= v0.10 setups that stored the ownCloud config.php in /usr/local rather than
|
# Migrate <= v0.10 setups that stored the ownCloud config.php in /usr/local rather than
|
||||||
# in STORAGE_ROOT. Move the file to STORAGE_ROOT.
|
# in STORAGE_ROOT. Move the file to STORAGE_ROOT.
|
||||||
@@ -36,24 +34,24 @@ fi
|
|||||||
if [ ! -d /usr/local/lib/owncloud/ ] \
|
if [ ! -d /usr/local/lib/owncloud/ ] \
|
||||||
|| ! grep -q $owncloud_ver /usr/local/lib/owncloud/version.php; then
|
|| ! grep -q $owncloud_ver /usr/local/lib/owncloud/version.php; then
|
||||||
|
|
||||||
# Download and verify
|
|
||||||
wget_verify https://download.owncloud.org/community/owncloud-$owncloud_ver.zip $owncloud_hash /tmp/owncloud.zip
|
|
||||||
|
|
||||||
# Clear out the existing ownCloud.
|
# Clear out the existing ownCloud.
|
||||||
if [ -d /usr/local/lib/owncloud/ ]; then
|
if [ ! -d /usr/local/lib/owncloud/ ]; then
|
||||||
|
echo installing ownCloud...
|
||||||
|
else
|
||||||
echo "upgrading ownCloud to $owncloud_ver (backing up existing ownCloud directory to /tmp/owncloud-backup-$$)..."
|
echo "upgrading ownCloud to $owncloud_ver (backing up existing ownCloud directory to /tmp/owncloud-backup-$$)..."
|
||||||
mv /usr/local/lib/owncloud /tmp/owncloud-backup-$$
|
mv /usr/local/lib/owncloud /tmp/owncloud-backup-$$
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Extract ownCloud
|
# Download and extract ownCloud.
|
||||||
|
wget_verify https://download.owncloud.org/community/owncloud-$owncloud_ver.zip $owncloud_hash /tmp/owncloud.zip
|
||||||
unzip -u -o -q /tmp/owncloud.zip -d /usr/local/lib #either extracts new or replaces current files
|
unzip -u -o -q /tmp/owncloud.zip -d /usr/local/lib #either extracts new or replaces current files
|
||||||
rm -f /tmp/owncloud.zip
|
rm -f /tmp/owncloud.zip
|
||||||
|
|
||||||
# The two apps we actually want are not in ownCloud core. Clone them from
|
# The two apps we actually want are not in ownCloud core. Clone them from
|
||||||
# their github repositories.
|
# their github repositories.
|
||||||
mkdir -p /usr/local/lib/owncloud/apps
|
mkdir -p /usr/local/lib/owncloud/apps
|
||||||
git_clone https://github.com/owncloud/contacts 4ff855e7c2075309041bead09fbb9eb7df678244 '' /usr/local/lib/owncloud/apps/contacts
|
git_clone https://github.com/owncloud/contacts v$owncloud_ver '' /usr/local/lib/owncloud/apps/contacts
|
||||||
git_clone https://github.com/owncloud/calendar ec53139b144c0f842c33813305612e8006c42ea5 '' /usr/local/lib/owncloud/apps/calendar
|
git_clone https://github.com/owncloud/calendar v$owncloud_ver '' /usr/local/lib/owncloud/apps/calendar
|
||||||
|
|
||||||
# Fix weird permissions.
|
# Fix weird permissions.
|
||||||
chmod 750 /usr/local/lib/owncloud/{apps,config}
|
chmod 750 /usr/local/lib/owncloud/{apps,config}
|
||||||
@@ -67,20 +65,8 @@ if [ ! -d /usr/local/lib/owncloud/ ] \
|
|||||||
# that error.
|
# that error.
|
||||||
chown -f -R www-data.www-data $STORAGE_ROOT/owncloud /usr/local/lib/owncloud
|
chown -f -R www-data.www-data $STORAGE_ROOT/owncloud /usr/local/lib/owncloud
|
||||||
|
|
||||||
# If this isn't a new installation, immediately run the upgrade script.
|
# Run the upgrade script (if ownCloud is already up-to-date it wont matter).
|
||||||
# Then check for success (0=ok and 3=no upgrade needed, both are success).
|
hide_output sudo -u www-data php /usr/local/lib/owncloud/occ upgrade
|
||||||
if [ -f $STORAGE_ROOT/owncloud/owncloud.db ]; then
|
|
||||||
# ownCloud 8.1.1 broke upgrades. It may fail on the first attempt, but
|
|
||||||
# that can be OK.
|
|
||||||
sudo -u www-data php /usr/local/lib/owncloud/occ upgrade
|
|
||||||
if [ \( $? -ne 0 \) -a \( $? -ne 3 \) ]; then
|
|
||||||
echo "Trying ownCloud upgrade again to work around ownCloud upgrade bug..."
|
|
||||||
sudo -u www-data php /usr/local/lib/owncloud/occ upgrade
|
|
||||||
if [ \( $? -ne 0 \) -a \( $? -ne 3 \) ]; then exit 1; fi
|
|
||||||
sudo -u www-data php /usr/local/lib/owncloud/occ maintenance:mode --off
|
|
||||||
echo "...which seemed to work."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ### Configuring ownCloud
|
# ### Configuring ownCloud
|
||||||
@@ -91,7 +77,7 @@ if [ ! -f $STORAGE_ROOT/owncloud/owncloud.db ]; then
|
|||||||
# Create user data directory
|
# Create user data directory
|
||||||
mkdir -p $STORAGE_ROOT/owncloud
|
mkdir -p $STORAGE_ROOT/owncloud
|
||||||
|
|
||||||
# Create an initial configuration file.
|
# Create a configuration file.
|
||||||
TIMEZONE=$(cat /etc/timezone)
|
TIMEZONE=$(cat /etc/timezone)
|
||||||
instanceid=oc$(echo $PRIMARY_HOSTNAME | sha1sum | fold -w 10 | head -n 1)
|
instanceid=oc$(echo $PRIMARY_HOSTNAME | sha1sum | fold -w 10 | head -n 1)
|
||||||
cat > $STORAGE_ROOT/owncloud/config.php <<EOF;
|
cat > $STORAGE_ROOT/owncloud/config.php <<EOF;
|
||||||
@@ -101,17 +87,19 @@ if [ ! -f $STORAGE_ROOT/owncloud/owncloud.db ]; then
|
|||||||
|
|
||||||
'instanceid' => '$instanceid',
|
'instanceid' => '$instanceid',
|
||||||
|
|
||||||
|
'trusted_domains' =>
|
||||||
|
array (
|
||||||
|
0 => '$PRIMARY_HOSTNAME',
|
||||||
|
),
|
||||||
'forcessl' => true, # if unset/false, ownCloud sends a HSTS=0 header, which conflicts with nginx config
|
'forcessl' => true, # if unset/false, ownCloud sends a HSTS=0 header, which conflicts with nginx config
|
||||||
|
|
||||||
'overwritewebroot' => '/cloud',
|
'overwritewebroot' => '/cloud',
|
||||||
'overwrite.cli.url' => '/cloud',
|
|
||||||
'user_backends' => array(
|
'user_backends' => array(
|
||||||
array(
|
array(
|
||||||
'class'=>'OC_User_IMAP',
|
'class'=>'OC_User_IMAP',
|
||||||
'arguments'=>array('{localhost:993/imap/ssl/novalidate-cert}')
|
'arguments'=>array('{localhost:993/imap/ssl/novalidate-cert}')
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
'memcache.local' => '\\OC\\Memcache\\Memcached',
|
|
||||||
"memcached_servers" => array (
|
"memcached_servers" => array (
|
||||||
array('localhost', 11211),
|
array('localhost', 11211),
|
||||||
),
|
),
|
||||||
@@ -158,29 +146,6 @@ EOF
|
|||||||
(cd /usr/local/lib/owncloud; sudo -u www-data php /usr/local/lib/owncloud/index.php;)
|
(cd /usr/local/lib/owncloud; sudo -u www-data php /usr/local/lib/owncloud/index.php;)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Update config.php.
|
|
||||||
# * trusted_domains is reset to localhost by autoconfig starting with ownCloud 8.1.1,
|
|
||||||
# so set it here. It also can change if the box's PRIMARY_HOSTNAME changes, so
|
|
||||||
# this will make sure it has the right value.
|
|
||||||
# * Some settings weren't included in previous versions of Mail-in-a-Box.
|
|
||||||
# Use PHP to read the settings file, modify it, and write out the new settings array.
|
|
||||||
CONFIG_TEMP=$(/bin/mktemp)
|
|
||||||
php <<EOF > $CONFIG_TEMP && mv $CONFIG_TEMP $STORAGE_ROOT/owncloud/config.php;
|
|
||||||
<?php
|
|
||||||
include("$STORAGE_ROOT/owncloud/config.php");
|
|
||||||
|
|
||||||
\$CONFIG['trusted_domains'] = array('$PRIMARY_HOSTNAME');
|
|
||||||
|
|
||||||
\$CONFIG['memcache.local'] = '\\OC\\Memcache\\Memcached';
|
|
||||||
\$CONFIG['overwrite.cli.url'] = '/cloud';
|
|
||||||
|
|
||||||
echo "<?php\n\\\$CONFIG = ";
|
|
||||||
var_export(\$CONFIG);
|
|
||||||
echo ";";
|
|
||||||
?>
|
|
||||||
EOF
|
|
||||||
chown www-data.www-data $STORAGE_ROOT/owncloud/config.php
|
|
||||||
|
|
||||||
# Enable/disable apps. Note that this must be done after the ownCloud setup.
|
# Enable/disable apps. Note that this must be done after the ownCloud setup.
|
||||||
# The firstrunwizard gave Josh all sorts of problems, so disabling that.
|
# The firstrunwizard gave Josh all sorts of problems, so disabling that.
|
||||||
# user_external is what allows ownCloud to use IMAP for login. The contacts
|
# user_external is what allows ownCloud to use IMAP for login. The contacts
|
||||||
@@ -190,12 +155,6 @@ hide_output sudo -u www-data php /usr/local/lib/owncloud/console.php app:enable
|
|||||||
hide_output sudo -u www-data php /usr/local/lib/owncloud/console.php app:enable contacts
|
hide_output sudo -u www-data php /usr/local/lib/owncloud/console.php app:enable contacts
|
||||||
hide_output sudo -u www-data php /usr/local/lib/owncloud/console.php app:enable calendar
|
hide_output sudo -u www-data php /usr/local/lib/owncloud/console.php app:enable calendar
|
||||||
|
|
||||||
# When upgrading, run the upgrade script again now that apps are enabled. It seems like
|
|
||||||
# the first upgrade at the top won't work because apps may be disabled during upgrade?
|
|
||||||
# Check for success (0=ok, 3=no upgrade needed).
|
|
||||||
sudo -u www-data php /usr/local/lib/owncloud/occ upgrade
|
|
||||||
if [ \( $? -ne 0 \) -a \( $? -ne 3 \) ]; then exit 1; fi
|
|
||||||
|
|
||||||
# Set PHP FPM values to support large file uploads
|
# Set PHP FPM values to support large file uploads
|
||||||
# (semicolon is the comment character in this file, hashes produce deprecation warnings)
|
# (semicolon is the comment character in this file, hashes produce deprecation warnings)
|
||||||
tools/editconf.py /etc/php5/fpm/php.ini -c ';' \
|
tools/editconf.py /etc/php5/fpm/php.ini -c ';' \
|
||||||
@@ -225,4 +184,5 @@ chmod +x /etc/cron.hourly/mailinabox-owncloud
|
|||||||
|
|
||||||
# Enable PHP modules and restart PHP.
|
# Enable PHP modules and restart PHP.
|
||||||
php5enmod imap
|
php5enmod imap
|
||||||
|
restart_service memcached
|
||||||
restart_service php5-fpm
|
restart_service php5-fpm
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ if [[ $EUID -ne 0 ]]; then
|
|||||||
echo
|
echo
|
||||||
echo "sudo $0"
|
echo "sudo $0"
|
||||||
echo
|
echo
|
||||||
exit
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check that we are running on Ubuntu 14.04 LTS (or 14.04.xx).
|
# Check that we are running on Ubuntu 14.04 LTS (or 14.04.xx).
|
||||||
@@ -14,12 +14,12 @@ if [ "`lsb_release -d | sed 's/.*:\s*//' | sed 's/14\.04\.[0-9]/14.04/' `" != "U
|
|||||||
lsb_release -d | sed 's/.*:\s*//'
|
lsb_release -d | sed 's/.*:\s*//'
|
||||||
echo
|
echo
|
||||||
echo "We can't write scripts that run on every possible setup, sorry."
|
echo "We can't write scripts that run on every possible setup, sorry."
|
||||||
exit
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check that we have enough memory.
|
# Check that we have enough memory.
|
||||||
#
|
#
|
||||||
# /proc/meminfo reports free memory in kibibytes. Our baseline will be 768 MB,
|
# /proc/meminfo reports free memory in kibibytes. Our baseline will be 768 KB,
|
||||||
# which is 750000 kibibytes.
|
# which is 750000 kibibytes.
|
||||||
#
|
#
|
||||||
# Skip the check if we appear to be running inside of Vagrant, because that's really just for testing.
|
# Skip the check if we appear to be running inside of Vagrant, because that's really just for testing.
|
||||||
@@ -30,6 +30,6 @@ if [ ! -d /vagrant ]; then
|
|||||||
echo "Your Mail-in-a-Box needs more memory (RAM) to function properly."
|
echo "Your Mail-in-a-Box needs more memory (RAM) to function properly."
|
||||||
echo "Please provision a machine with at least 768 MB, 1 GB recommended."
|
echo "Please provision a machine with at least 768 MB, 1 GB recommended."
|
||||||
echo "This machine has $TOTAL_PHYSICAL_MEM MB memory."
|
echo "This machine has $TOTAL_PHYSICAL_MEM MB memory."
|
||||||
exit
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ if [ -z "$NONINTERACTIVE" ]; then
|
|||||||
apt_get_quiet install dialog python3 python3-pip || exit 1
|
apt_get_quiet install dialog python3 python3-pip || exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -z "$IS_DOCKER" ]; then
|
||||||
# email_validator is repeated in setup/management.sh
|
# email_validator is repeated in setup/management.sh
|
||||||
hide_output pip3 install email_validator==1.0.0 || exit 1
|
hide_output pip3 install "email_validator==0.1.0-rc5" || exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
message_box "Mail-in-a-Box Installation" \
|
message_box "Mail-in-a-Box Installation" \
|
||||||
"Hello and thanks for deploying a Mail-in-a-Box!
|
"Hello and thanks for deploying a Mail-in-a-Box!
|
||||||
|
|||||||
@@ -16,40 +16,23 @@ source setup/functions.sh # load our functions
|
|||||||
# ----------------------------------------
|
# ----------------------------------------
|
||||||
|
|
||||||
# Install packages.
|
# Install packages.
|
||||||
# libmail-dkim-perl is needed to make the spamassassin DKIM module work.
|
apt_install spampd razor pyzor dovecot-antispam
|
||||||
# For more information see Debian Bug #689414:
|
|
||||||
# https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=689414
|
|
||||||
echo "Installing SpamAssassin..."
|
|
||||||
apt_install spampd razor pyzor dovecot-antispam libmail-dkim-perl
|
|
||||||
|
|
||||||
# Allow spamassassin to download new rules.
|
# Allow spamassassin to download new rules.
|
||||||
tools/editconf.py /etc/default/spamassassin \
|
tools/editconf.py /etc/default/spamassassin \
|
||||||
CRON=1
|
CRON=1
|
||||||
|
|
||||||
# Configure pyzor, which is a client to a live database of hashes of
|
# Configure pyzor.
|
||||||
# spam emails. Set the pyzor configuration directory to something sane.
|
hide_output pyzor discover
|
||||||
# The default is ~/.pyzor. We used to use that, so we'll kill that old
|
|
||||||
# directory. Then write the public pyzor server to its servers file.
|
|
||||||
# That will prevent an automatic download on first use, and also means
|
|
||||||
# we can skip 'pyzor discover', both of which are currently broken by
|
|
||||||
# something happening on Sourceforge (#496).
|
|
||||||
rm -rf ~/.pyzor
|
|
||||||
tools/editconf.py /etc/spamassassin/local.cf -s \
|
|
||||||
pyzor_options="--homedir /etc/spamassassin/pyzor"
|
|
||||||
mkdir -p /etc/spamassassin/pyzor
|
|
||||||
echo "public.pyzor.org:24441" > /etc/spamassassin/pyzor/servers
|
|
||||||
# check with: pyzor --homedir /etc/mail/spamassassin/pyzor ping
|
|
||||||
|
|
||||||
# Configure spampd:
|
# Configure spampd:
|
||||||
# * Pass messages on to docevot on port 10026. This is actually the default setting but we don't
|
# * Pass messages on to docevot on port 10026. This is actually the default setting but we don't
|
||||||
# want to lose track of it. (We've configured Dovecot to listen on this port elsewhere.)
|
# want to lose track of it. (We've configured Dovecot to listen on this port elsewhere.)
|
||||||
# * Increase the maximum message size of scanned messages from the default of 64KB to 500KB, which
|
# * Increase the maximum message size of scanned messages from the default of 64KB to 500KB, which
|
||||||
# is Spamassassin (spamc)'s own default. Specified in KBytes.
|
# is Spamassassin (spamc)'s own default. Specified in KBytes.
|
||||||
# * Disable localmode so Pyzor, DKIM and DNS checks can be used.
|
|
||||||
tools/editconf.py /etc/default/spampd \
|
tools/editconf.py /etc/default/spampd \
|
||||||
DESTPORT=10026 \
|
DESTPORT=10026 \
|
||||||
ADDOPTS="\"--maxsize=500\"" \
|
ADDOPTS="\"--maxsize=500\""
|
||||||
LOCALONLY=0
|
|
||||||
|
|
||||||
# Spamassassin normally wraps spam as an attachment inside a fresh
|
# Spamassassin normally wraps spam as an attachment inside a fresh
|
||||||
# email with a report about the message. This also protects the user
|
# email with a report about the message. This also protects the user
|
||||||
@@ -99,7 +82,6 @@ cat > /etc/dovecot/conf.d/99-local-spampd.conf << EOF;
|
|||||||
plugin {
|
plugin {
|
||||||
antispam_backend = pipe
|
antispam_backend = pipe
|
||||||
antispam_spam_pattern_ignorecase = SPAM
|
antispam_spam_pattern_ignorecase = SPAM
|
||||||
antispam_trash_pattern_ignorecase = trash;Deleted *
|
|
||||||
antispam_allow_append_to_spam = yes
|
antispam_allow_append_to_spam = yes
|
||||||
antispam_pipe_program_spam_args = /usr/local/bin/sa-learn-pipe.sh;--spam
|
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_notspam_args = /usr/local/bin/sa-learn-pipe.sh;--ham
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
source setup/functions.sh # load our functions
|
source setup/functions.sh # load our functions
|
||||||
source /etc/mailinabox.conf # load global vars
|
source /etc/mailinabox.conf # load global vars
|
||||||
|
|
||||||
echo "Creating initial SSL certificate and perfect forward secrecy Diffie-Hellman parameters..."
|
|
||||||
apt_install openssl
|
apt_install openssl
|
||||||
|
|
||||||
mkdir -p $STORAGE_ROOT/ssl
|
mkdir -p $STORAGE_ROOT/ssl
|
||||||
|
|||||||
@@ -52,13 +52,9 @@ chmod +x /usr/local/bin/mailinabox
|
|||||||
source setup/questions.sh
|
source setup/questions.sh
|
||||||
|
|
||||||
# Run some network checks to make sure setup on this machine makes sense.
|
# Run some network checks to make sure setup on this machine makes sense.
|
||||||
# Skip on existing installs since we don't want this to block the ability to
|
|
||||||
# upgrade, and these checks are also in the control panel status checks.
|
|
||||||
if [ -z "$DEFAULT_PRIMARY_HOSTNAME" ]; then
|
|
||||||
if [ -z "$SKIP_NETWORK_CHECKS" ]; then
|
if [ -z "$SKIP_NETWORK_CHECKS" ]; then
|
||||||
source setup/network-checks.sh
|
source setup/network-checks.sh
|
||||||
fi
|
fi
|
||||||
fi
|
|
||||||
|
|
||||||
# Create the STORAGE_USER and STORAGE_ROOT directory if they don't already exist.
|
# Create the STORAGE_USER and STORAGE_ROOT directory if they don't already exist.
|
||||||
# If the STORAGE_ROOT is missing the mailinabox.version file that lists a
|
# If the STORAGE_ROOT is missing the mailinabox.version file that lists a
|
||||||
@@ -73,7 +69,7 @@ if [ ! -d $STORAGE_ROOT ]; then
|
|||||||
fi
|
fi
|
||||||
if [ ! -f $STORAGE_ROOT/mailinabox.version ]; then
|
if [ ! -f $STORAGE_ROOT/mailinabox.version ]; then
|
||||||
echo $(setup/migrate.py --current) > $STORAGE_ROOT/mailinabox.version
|
echo $(setup/migrate.py --current) > $STORAGE_ROOT/mailinabox.version
|
||||||
chown $STORAGE_USER.$STORAGE_USER $STORAGE_ROOT/mailinabox.version
|
chown $STORAGE_USER:$STORAGE_USER $STORAGE_ROOT/mailinabox.version
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
@@ -130,19 +126,17 @@ if management/status_checks.py --check-primary-hostname; then
|
|||||||
# Show the nice URL if it appears to be resolving and has a valid certificate.
|
# Show the nice URL if it appears to be resolving and has a valid certificate.
|
||||||
echo https://$PRIMARY_HOSTNAME/admin
|
echo https://$PRIMARY_HOSTNAME/admin
|
||||||
echo
|
echo
|
||||||
echo "If you have a DNS problem put the box's IP address in the URL"
|
echo If you have a DNS problem use the box\'s IP address and check the SSL fingerprint:
|
||||||
echo "(https://$PUBLIC_IP/admin) but then check the SSL fingerprint:"
|
echo https://$PUBLIC_IP/admin
|
||||||
openssl x509 -in $STORAGE_ROOT/ssl/ssl_certificate.pem -noout -fingerprint \
|
|
||||||
| sed "s/SHA1 Fingerprint=//"
|
|
||||||
else
|
else
|
||||||
echo https://$PUBLIC_IP/admin
|
echo https://$PUBLIC_IP/admin
|
||||||
echo
|
echo
|
||||||
echo You will be alerted that the website has an invalid certificate. Check that
|
echo You will be alerted that the website has an invalid certificate. Check that
|
||||||
echo the certificate fingerprint matches:
|
echo the certificate fingerprint matches:
|
||||||
echo
|
echo
|
||||||
openssl x509 -in $STORAGE_ROOT/ssl/ssl_certificate.pem -noout -fingerprint \
|
|
||||||
| sed "s/SHA1 Fingerprint=//"
|
|
||||||
echo
|
|
||||||
echo Then you can confirm the security exception and continue.
|
|
||||||
echo
|
|
||||||
fi
|
fi
|
||||||
|
openssl x509 -in $STORAGE_ROOT/ssl/ssl_certificate.pem -noout -fingerprint \
|
||||||
|
| sed "s/SHA1 Fingerprint=//"
|
||||||
|
echo
|
||||||
|
echo Then you can confirm the security exception and continue.
|
||||||
|
echo
|
||||||
@@ -11,21 +11,12 @@ source setup/functions.sh # load our functions
|
|||||||
# text search plugin for (and by) dovecot, which is not available in
|
# text search plugin for (and by) dovecot, which is not available in
|
||||||
# Ubuntu currently.
|
# Ubuntu currently.
|
||||||
#
|
#
|
||||||
# Add that to the system's list of repositories using add-apt-repository.
|
# Add that to the system's list of repositories:
|
||||||
# But add-apt-repository may not be installed. If it's not available,
|
|
||||||
# then install it. But we have to run apt-get update before we try to
|
|
||||||
# install anything so the package index is up to date. After adding the
|
|
||||||
# PPA, we have to run apt-get update *again* to load the PPA's index,
|
|
||||||
# so this must precede the apt-get update line below.
|
|
||||||
|
|
||||||
if [ ! -f /usr/bin/add-apt-repository ]; then
|
|
||||||
echo "Installing add-apt-repository..."
|
|
||||||
hide_output apt-get update
|
|
||||||
apt_install software-properties-common
|
|
||||||
fi
|
|
||||||
|
|
||||||
hide_output add-apt-repository -y ppa:mail-in-a-box/ppa
|
hide_output add-apt-repository -y ppa:mail-in-a-box/ppa
|
||||||
|
|
||||||
|
# The apt-get update in the next step will pull in the PPA's index.
|
||||||
|
|
||||||
# ### Update Packages
|
# ### Update Packages
|
||||||
|
|
||||||
# Update system packages to make sure we have the latest upstream versions of things from Ubuntu.
|
# Update system packages to make sure we have the latest upstream versions of things from Ubuntu.
|
||||||
@@ -48,10 +39,9 @@ apt_get_quiet upgrade
|
|||||||
# * netcat-openbsd: `nc` command line networking tool
|
# * netcat-openbsd: `nc` command line networking tool
|
||||||
# * git: we install some things directly from github
|
# * git: we install some things directly from github
|
||||||
# * sudo: allows privileged users to execute commands as root without being root
|
# * sudo: allows privileged users to execute commands as root without being root
|
||||||
# * coreutils: includes `nproc` tool to report number of processors, mktemp
|
# * coreutils: includes `nproc` tool to report number of processors
|
||||||
# * bc: allows us to do math to compute sane defaults
|
# * bc: allows us to do math to compute sane defaults
|
||||||
|
|
||||||
echo Installing system packages...
|
|
||||||
apt_install python3 python3-dev python3-pip \
|
apt_install python3 python3-dev python3-pip \
|
||||||
netcat-openbsd wget curl git sudo coreutils bc \
|
netcat-openbsd wget curl git sudo coreutils bc \
|
||||||
haveged unattended-upgrades cron ntp fail2ban
|
haveged unattended-upgrades cron ntp fail2ban
|
||||||
|
|||||||
17
setup/web.sh
17
setup/web.sh
@@ -17,7 +17,6 @@ fi
|
|||||||
#
|
#
|
||||||
# Turn off nginx's default website.
|
# Turn off nginx's default website.
|
||||||
|
|
||||||
echo "Installing Nginx (web server)..."
|
|
||||||
apt_install nginx php5-fpm
|
apt_install nginx php5-fpm
|
||||||
|
|
||||||
rm -f /etc/nginx/sites-enabled/default
|
rm -f /etc/nginx/sites-enabled/default
|
||||||
@@ -25,28 +24,18 @@ rm -f /etc/nginx/sites-enabled/default
|
|||||||
# Copy in a nginx configuration file for common and best-practices
|
# Copy in a nginx configuration file for common and best-practices
|
||||||
# SSL settings from @konklone. Replace STORAGE_ROOT so it can find
|
# SSL settings from @konklone. Replace STORAGE_ROOT so it can find
|
||||||
# the DH params.
|
# the DH params.
|
||||||
rm -f /etc/nginx/nginx-ssl.conf # we used to put it here
|
|
||||||
sed "s#STORAGE_ROOT#$STORAGE_ROOT#" \
|
sed "s#STORAGE_ROOT#$STORAGE_ROOT#" \
|
||||||
conf/nginx-ssl.conf > /etc/nginx/conf.d/ssl.conf
|
conf/nginx-ssl.conf > /etc/nginx/nginx-ssl.conf
|
||||||
|
|
||||||
# Fix some nginx defaults.
|
# Fix some nginx defaults.
|
||||||
# The server_names_hash_bucket_size seems to prevent long domain names!
|
# The server_names_hash_bucket_size seems to prevent long domain names?
|
||||||
# The default, according to nginx's docs, depends on "the size of the
|
|
||||||
# processor’s cache line." It could be as low as 32. We fixed it at
|
|
||||||
# 64 in 2014 to accommodate a long domain name (20 characters?). But
|
|
||||||
# even at 64, a 58-character domain name won't work (#93), so now
|
|
||||||
# we're going up to 128.
|
|
||||||
tools/editconf.py /etc/nginx/nginx.conf -s \
|
tools/editconf.py /etc/nginx/nginx.conf -s \
|
||||||
server_names_hash_bucket_size="128;"
|
server_names_hash_bucket_size="64;"
|
||||||
|
|
||||||
# Tell PHP not to expose its version number in the X-Powered-By header.
|
# Tell PHP not to expose its version number in the X-Powered-By header.
|
||||||
tools/editconf.py /etc/php5/fpm/php.ini -c ';' \
|
tools/editconf.py /etc/php5/fpm/php.ini -c ';' \
|
||||||
expose_php=Off
|
expose_php=Off
|
||||||
|
|
||||||
# Set PHPs default charset to UTF-8, since we use it. See #367.
|
|
||||||
tools/editconf.py /etc/php5/fpm/php.ini -c ';' \
|
|
||||||
default_charset="UTF-8"
|
|
||||||
|
|
||||||
# Bump up PHP's max_children to support more concurrent connections
|
# Bump up PHP's max_children to support more concurrent connections
|
||||||
tools/editconf.py /etc/php5/fpm/pool.d/www.conf -c ';' \
|
tools/editconf.py /etc/php5/fpm/pool.d/www.conf -c ';' \
|
||||||
pm.max_children=8
|
pm.max_children=8
|
||||||
|
|||||||
@@ -19,10 +19,9 @@ source /etc/mailinabox.conf # load global vars
|
|||||||
# and then we'll manually install roundcube from source.
|
# 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`.
|
||||||
echo "Installing Roundcube (webmail)..."
|
|
||||||
apt_install \
|
apt_install \
|
||||||
dbconfig-common \
|
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-mail-mimedecode php-crypt-gpg php5-gd php5-pspell \
|
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
|
tinymce libjs-jquery libjs-jquery-mousewheel libmagic1
|
||||||
|
|
||||||
# We used to install Roundcube from Ubuntu, without triggering the dependencies #NODOC
|
# We used to install Roundcube from Ubuntu, without triggering the dependencies #NODOC
|
||||||
@@ -37,8 +36,7 @@ VERSION=1.1.2
|
|||||||
HASH=df88deae691da3ecf3e9f0aee674c1f3042ea1eb
|
HASH=df88deae691da3ecf3e9f0aee674c1f3042ea1eb
|
||||||
VACATION_SIEVE_VERSION=91ea6f52216390073d1f5b70b5f6bea0bfaee7e5
|
VACATION_SIEVE_VERSION=91ea6f52216390073d1f5b70b5f6bea0bfaee7e5
|
||||||
PERSISTENT_LOGIN_VERSION=117fbd8f93b56b2bf72ad055193464803ef3bc36
|
PERSISTENT_LOGIN_VERSION=117fbd8f93b56b2bf72ad055193464803ef3bc36
|
||||||
HTML5_NOTIFIER_VERSION=046eb388dd63b1ec77a3ee485757fc25ae9e684d
|
UPDATE_KEY=$VERSION:$VACATION_SIEVE_VERSION:$PERSISTENT_LOGIN_VERSION
|
||||||
UPDATE_KEY=$VERSION:$VACATION_SIEVE_VERSION:$PERSISTENT_LOGIN_VERSION:$HTML5_NOTIFIER_VERSION
|
|
||||||
needs_update=0 #NODOC
|
needs_update=0 #NODOC
|
||||||
if [ ! -f /usr/local/lib/roundcubemail/version ]; then
|
if [ ! -f /usr/local/lib/roundcubemail/version ]; then
|
||||||
# not installed yet #NODOC
|
# not installed yet #NODOC
|
||||||
@@ -49,8 +47,9 @@ elif [[ "$UPDATE_KEY" != `cat /usr/local/lib/roundcubemail/version` ]]; then
|
|||||||
fi
|
fi
|
||||||
if [ $needs_update == 1 ]; then
|
if [ $needs_update == 1 ]; then
|
||||||
# install roundcube
|
# install roundcube
|
||||||
|
echo installing Roundcube webmail $VERSION...
|
||||||
wget_verify \
|
wget_verify \
|
||||||
https://mailinabox.email/mirror/roundcubemail-$VERSION.tar.gz \
|
http://downloads.sourceforge.net/project/roundcubemail/roundcubemail/$VERSION/roundcubemail-$VERSION.tar.gz \
|
||||||
$HASH \
|
$HASH \
|
||||||
/tmp/roundcube.tgz
|
/tmp/roundcube.tgz
|
||||||
tar -C /usr/local/lib -zxf /tmp/roundcube.tgz
|
tar -C /usr/local/lib -zxf /tmp/roundcube.tgz
|
||||||
@@ -64,9 +63,6 @@ if [ $needs_update == 1 ]; then
|
|||||||
# install roundcube persistent_login plugin
|
# install roundcube persistent_login plugin
|
||||||
git_clone https://github.com/mfreiholz/Roundcube-Persistent-Login-Plugin.git $PERSISTENT_LOGIN_VERSION '' /usr/local/lib/roundcubemail/plugins/persistent_login
|
git_clone https://github.com/mfreiholz/Roundcube-Persistent-Login-Plugin.git $PERSISTENT_LOGIN_VERSION '' /usr/local/lib/roundcubemail/plugins/persistent_login
|
||||||
|
|
||||||
# install roundcube html5_notifier plugin
|
|
||||||
git_clone https://github.com/kitist/html5_notifier.git $HTML5_NOTIFIER_VERSION '' /usr/local/lib/roundcubemail/plugins/html5_notifier
|
|
||||||
|
|
||||||
# record the version we've installed
|
# record the version we've installed
|
||||||
echo $UPDATE_KEY > /usr/local/lib/roundcubemail/version
|
echo $UPDATE_KEY > /usr/local/lib/roundcubemail/version
|
||||||
fi
|
fi
|
||||||
@@ -100,7 +96,7 @@ cat > /usr/local/lib/roundcubemail/config/config.inc.php <<EOF;
|
|||||||
\$config['support_url'] = 'https://mailinabox.email/';
|
\$config['support_url'] = 'https://mailinabox.email/';
|
||||||
\$config['product_name'] = 'Mail-in-a-Box/Roundcube Webmail';
|
\$config['product_name'] = 'Mail-in-a-Box/Roundcube Webmail';
|
||||||
\$config['des_key'] = '$SECRET_KEY';
|
\$config['des_key'] = '$SECRET_KEY';
|
||||||
\$config['plugins'] = array('html5_notifier', 'archive', 'zipdownload', 'password', 'managesieve', 'jqueryui', 'vacation_sieve', 'persistent_login');
|
\$config['plugins'] = array('archive', 'zipdownload', 'password', 'managesieve', 'jqueryui', 'vacation_sieve', 'persistent_login');
|
||||||
\$config['skin'] = 'classic';
|
\$config['skin'] = 'classic';
|
||||||
\$config['login_autocomplete'] = 2;
|
\$config['login_autocomplete'] = 2;
|
||||||
\$config['password_charset'] = 'UTF-8';
|
\$config['password_charset'] = 'UTF-8';
|
||||||
@@ -130,7 +126,7 @@ EOF
|
|||||||
|
|
||||||
# Create writable directories.
|
# Create writable directories.
|
||||||
mkdir -p /var/log/roundcubemail /tmp/roundcubemail $STORAGE_ROOT/mail/roundcube
|
mkdir -p /var/log/roundcubemail /tmp/roundcubemail $STORAGE_ROOT/mail/roundcube
|
||||||
chown -R www-data.www-data /var/log/roundcubemail /tmp/roundcubemail $STORAGE_ROOT/mail/roundcube
|
chown -R www-data:www-data /var/log/roundcubemail /tmp/roundcubemail $STORAGE_ROOT/mail/roundcube
|
||||||
|
|
||||||
# Password changing plugin settings
|
# Password changing plugin settings
|
||||||
# The config comes empty by default, so we need the settings
|
# The config comes empty by default, so we need the settings
|
||||||
@@ -151,9 +147,9 @@ usermod -a -G dovecot www-data
|
|||||||
|
|
||||||
# set permissions so that PHP can use users.sqlite
|
# set permissions so that PHP can use users.sqlite
|
||||||
# could use dovecot instead of www-data, but not sure it matters
|
# could use dovecot instead of www-data, but not sure it matters
|
||||||
chown root.www-data $STORAGE_ROOT/mail
|
chown root:www-data $STORAGE_ROOT/mail
|
||||||
chmod 775 $STORAGE_ROOT/mail
|
chmod 775 $STORAGE_ROOT/mail
|
||||||
chown root.www-data $STORAGE_ROOT/mail/users.sqlite
|
chown root:www-data $STORAGE_ROOT/mail/users.sqlite
|
||||||
chmod 664 $STORAGE_ROOT/mail/users.sqlite
|
chmod 664 $STORAGE_ROOT/mail/users.sqlite
|
||||||
|
|
||||||
# Enable PHP modules.
|
# Enable PHP modules.
|
||||||
|
|||||||
@@ -15,14 +15,13 @@ source /etc/mailinabox.conf # load global vars
|
|||||||
|
|
||||||
# Prereqs.
|
# Prereqs.
|
||||||
|
|
||||||
echo "Installing Z-Push (Exchange/ActiveSync server)..."
|
|
||||||
apt_install \
|
apt_install \
|
||||||
php-soap php5-imap libawl-php php5-xsl
|
php-soap php5-imap libawl-php php5-xsl
|
||||||
|
|
||||||
php5enmod imap
|
php5enmod imap
|
||||||
|
|
||||||
# Copy Z-Push into place.
|
# Copy Z-Push into place.
|
||||||
TARGETHASH=80cbe53de4ab8dd598d1f2af6f0a23fa396c529a
|
TARGETHASH=d0cd5a47c53afac5c3b287006dc8a48a1c4ffcd5
|
||||||
needs_update=0 #NODOC
|
needs_update=0 #NODOC
|
||||||
if [ ! -f /usr/local/lib/z-push/version ]; then
|
if [ ! -f /usr/local/lib/z-push/version ]; then
|
||||||
needs_update=1 #NODOC
|
needs_update=1 #NODOC
|
||||||
@@ -31,6 +30,7 @@ elif [[ $TARGETHASH != `cat /usr/local/lib/z-push/version` ]]; then
|
|||||||
needs_update=1 #NODOC
|
needs_update=1 #NODOC
|
||||||
fi
|
fi
|
||||||
if [ $needs_update == 1 ]; then
|
if [ $needs_update == 1 ]; then
|
||||||
|
echo installing z-push \(fmbiete fork\)...
|
||||||
git_clone https://github.com/fmbiete/Z-Push-contrib $TARGETHASH '' /usr/local/lib/z-push
|
git_clone https://github.com/fmbiete/Z-Push-contrib $TARGETHASH '' /usr/local/lib/z-push
|
||||||
rm -f /usr/sbin/z-push-{admin,top}
|
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-admin.php /usr/sbin/z-push-admin
|
||||||
@@ -42,8 +42,6 @@ fi
|
|||||||
sed -i "s^define('TIMEZONE', .*^define('TIMEZONE', '$(cat /etc/timezone)');^" /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('BACKEND_PROVIDER', .*/define('BACKEND_PROVIDER', 'BackendCombined');/" /usr/local/lib/z-push/config.php
|
||||||
sed -i "s/define('USE_FULLEMAIL_FOR_LOGIN', .*/define('USE_FULLEMAIL_FOR_LOGIN', true);/" /usr/local/lib/z-push/config.php
|
sed -i "s/define('USE_FULLEMAIL_FOR_LOGIN', .*/define('USE_FULLEMAIL_FOR_LOGIN', true);/" /usr/local/lib/z-push/config.php
|
||||||
sed -i "s/define('LOG_MEMORY_PROFILER', .*/define('LOG_MEMORY_PROFILER', false);/" /usr/local/lib/z-push/config.php
|
|
||||||
sed -i "s/define('BUG68532FIXED', .*/define('BUG68532FIXED', false);/" /usr/local/lib/z-push/config.php
|
|
||||||
|
|
||||||
# Configure BACKEND
|
# Configure BACKEND
|
||||||
rm -f /usr/local/lib/z-push/backend/combined/config.php
|
rm -f /usr/local/lib/z-push/backend/combined/config.php
|
||||||
|
|||||||
@@ -14,8 +14,6 @@
|
|||||||
#
|
#
|
||||||
# NAME VALUE
|
# NAME VALUE
|
||||||
#
|
#
|
||||||
# If the -c option is given, then the supplied character becomes the comment character
|
|
||||||
#
|
|
||||||
# If the -w option is given, then setting lines continue onto following
|
# If the -w option is given, then setting lines continue onto following
|
||||||
# lines while the lines start with whitespace, e.g.:
|
# lines while the lines start with whitespace, e.g.:
|
||||||
#
|
#
|
||||||
@@ -26,7 +24,7 @@ import sys, re
|
|||||||
|
|
||||||
# sanity check
|
# sanity check
|
||||||
if len(sys.argv) < 3:
|
if len(sys.argv) < 3:
|
||||||
print("usage: python3 editconf.py /etc/file.conf [-s] [-w] [-c <CHARACTER>] [-t] NAME=VAL [NAME=VAL ...]")
|
print("usage: python3 editconf.py /etc/file.conf [-s] [-w] [-t] NAME=VAL [NAME=VAL ...]")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# parse command line arguments
|
# parse command line arguments
|
||||||
|
|||||||
23
tools/list_all_packages.py
Normal file
23
tools/list_all_packages.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
import os.path, glob, re
|
||||||
|
|
||||||
|
packages = set()
|
||||||
|
|
||||||
|
def add(line):
|
||||||
|
global packages
|
||||||
|
if line.endswith("\\"): line = line[:-1]
|
||||||
|
packages |= set(p for p in line.split(" ") if p not in("", "apt_install"))
|
||||||
|
|
||||||
|
for fn in glob.glob(os.path.join(os.path.dirname(__file__), "../setup/*.sh")):
|
||||||
|
with open(fn) as f:
|
||||||
|
in_apt_install = False
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith("apt_install "):
|
||||||
|
in_apt_install = True
|
||||||
|
if in_apt_install:
|
||||||
|
add(line)
|
||||||
|
in_apt_install = in_apt_install and line.endswith("\\")
|
||||||
|
|
||||||
|
print("\n".join(sorted(packages)))
|
||||||
@@ -120,10 +120,10 @@ elif sys.argv[1] == "alias" and len(sys.argv) == 2:
|
|||||||
print(mgmt("/mail/aliases"))
|
print(mgmt("/mail/aliases"))
|
||||||
|
|
||||||
elif sys.argv[1] == "alias" and sys.argv[2] == "add" and len(sys.argv) == 5:
|
elif sys.argv[1] == "alias" and sys.argv[2] == "add" and len(sys.argv) == 5:
|
||||||
print(mgmt("/mail/aliases/add", { "address": sys.argv[3], "forwards_to": sys.argv[4] }))
|
print(mgmt("/mail/aliases/add", { "source": sys.argv[3], "destination": sys.argv[4] }))
|
||||||
|
|
||||||
elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4:
|
elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4:
|
||||||
print(mgmt("/mail/aliases/remove", { "address": sys.argv[3] }))
|
print(mgmt("/mail/aliases/remove", { "source": sys.argv[3] }))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
print("Invalid command-line arguments.")
|
print("Invalid command-line arguments.")
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ for fn in glob.glob("/var/log/nginx/access.log*"):
|
|||||||
with f:
|
with f:
|
||||||
for line in f:
|
for line in f:
|
||||||
# Find lines that are GETs on /bootstrap.sh by either curl or wget.
|
# Find lines that are GETs on /bootstrap.sh by either curl or wget.
|
||||||
# (Note that we purposely skip ...?ping=1 requests which is the admin panel querying us for updates.)
|
|
||||||
m = re.match(rb"(?P<ip>\S+) - - \[(?P<date>.*?)\] \"GET /bootstrap.sh HTTP/.*\" 200 \d+ .* \"(?:curl|wget)", line, re.I)
|
m = re.match(rb"(?P<ip>\S+) - - \[(?P<date>.*?)\] \"GET /bootstrap.sh HTTP/.*\" 200 \d+ .* \"(?:curl|wget)", line, re.I)
|
||||||
if m:
|
if m:
|
||||||
date, time = m.group("date").decode("ascii").split(":", 1)
|
date, time = m.group("date").decode("ascii").split(":", 1)
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
#!/usr/bin/python3
|
|
||||||
# Updates subresource integrity attributes in management/templates/index.html
|
|
||||||
# to prevent CDN-hosted resources from being used as an attack vector. Run this
|
|
||||||
# after updating the Bootstrap and jQuery <link> and <script> to compute the
|
|
||||||
# appropriate hash and insert it into the template.
|
|
||||||
|
|
||||||
import re, urllib.request, hashlib, base64
|
|
||||||
|
|
||||||
fn = "management/templates/index.html"
|
|
||||||
|
|
||||||
with open(fn, 'r') as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
def make_integrity(url):
|
|
||||||
resource = urllib.request.urlopen(url).read()
|
|
||||||
return "sha256-" + base64.b64encode(hashlib.sha256(resource).digest()).decode('ascii')
|
|
||||||
|
|
||||||
content = re.sub(
|
|
||||||
r'<(link rel="stylesheet" href|script src)="(.*?)" integrity="(.*?)"',
|
|
||||||
lambda m : '<' + m.group(1) + '="' + m.group(2) + '" integrity="' + make_integrity(m.group(2)) + '"',
|
|
||||||
content)
|
|
||||||
|
|
||||||
with open(fn, 'w') as f:
|
|
||||||
f.write(content)
|
|
||||||
Reference in New Issue
Block a user