mirror of
https://github.com/mail-in-a-box/mailinabox.git
synced 2026-03-12 17:07:23 +01:00
Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
289936db7a | ||
|
|
6c71abbb09 | ||
|
|
841181ed55 | ||
|
|
c3821e9165 | ||
|
|
6b35d8402c | ||
|
|
3b6f7250ee | ||
|
|
c6bf061b26 | ||
|
|
a8074ae3e4 | ||
|
|
cfc4e6b48b | ||
|
|
73fbcd7fa3 | ||
|
|
79317ed67e | ||
|
|
1b26632bb4 | ||
|
|
5b415c6895 | ||
|
|
078f3bff70 | ||
|
|
d02f800d1b | ||
|
|
83d3643e8f | ||
|
|
6d35ae56d3 | ||
|
|
39270a8e35 | ||
|
|
8c08f957cd | ||
|
|
5924d0fe0d | ||
|
|
c23a34d475 | ||
|
|
1bd3a725ca | ||
|
|
848dea83ab | ||
|
|
bded529535 | ||
|
|
ec665a97db | ||
|
|
fc536b6ff3 | ||
|
|
f96bef43cc | ||
|
|
f4e8ee0af9 | ||
|
|
c2e5f5cb2b | ||
|
|
9ca116d545 | ||
|
|
cdd3a64638 | ||
|
|
99e51f8a52 | ||
|
|
3b4b57c081 | ||
|
|
c7f8ead496 | ||
|
|
3f15879578 | ||
|
|
1cdd205eb7 | ||
|
|
77099b3bce | ||
|
|
0d8a4099c1 | ||
|
|
606cf6a941 | ||
|
|
ba9065cada | ||
|
|
e693802091 | ||
|
|
fa0dd684da | ||
|
|
43fb7fe635 | ||
|
|
91e4ea6e2f | ||
|
|
1e3e34f15f | ||
|
|
96fb0f78f7 | ||
|
|
2e6c410336 | ||
|
|
0293e04311 | ||
|
|
8c066764d9 | ||
|
|
1900e512f2 | ||
|
|
d0ccde7b48 | ||
|
|
1e261e347a | ||
|
|
2cb4cdc645 | ||
|
|
123ac4fd33 | ||
|
|
423bb8e317 | ||
|
|
e6ff280984 | ||
|
|
3fdfad27cd | ||
|
|
d3bbc0ec95 | ||
|
|
e54608c282 | ||
|
|
9b9a40ddd7 | ||
|
|
40a5fa46d1 | ||
|
|
05e33edb0d | ||
|
|
76dba1a521 | ||
|
|
f7298a45bd | ||
|
|
3f606feea3 | ||
|
|
541d9252f6 | ||
|
|
cbbbb117e0 | ||
|
|
415f95b792 | ||
|
|
5f17abc856 | ||
|
|
a91995f0a7 | ||
|
|
216acb0eeb | ||
|
|
5dd5fc4a1c | ||
|
|
09133c8f59 | ||
|
|
d08a3095a9 | ||
|
|
e591d9082f | ||
|
|
b6f26c0f1e | ||
|
|
07beef3db2 |
50
CHANGELOG.md
50
CHANGELOG.md
@@ -1,15 +1,52 @@
|
|||||||
CHANGELOG
|
CHANGELOG
|
||||||
=========
|
=========
|
||||||
|
|
||||||
|
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)
|
v0.12 (July 3, 2015)
|
||||||
--------------------
|
--------------------
|
||||||
|
|
||||||
This is a minor update to v0.11, which was a major update. Please read v0.11's advisories.
|
This is a minor update to v0.11, which was a major update. Please read v0.11's advisories.
|
||||||
|
|
||||||
* v0.12b was posted shortly after the initial posting of v0.12 correcting a minor regression in v0.12 related to creating aliases targetting multiple addresses.
|
|
||||||
|
|
||||||
* v0.12c was posted on July 19, 2015 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.
|
|
||||||
|
|
||||||
* 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.
|
* 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).
|
* Intrusion detection rules (fail2ban) are relaxed (i.e. less is blocked).
|
||||||
* SSL certificates could not be installed for the new automatic 'www.' redirect domains.
|
* SSL certificates could not be installed for the new automatic 'www.' redirect domains.
|
||||||
@@ -18,6 +55,10 @@ This is a minor update to v0.11, which was a major update. Please read v0.11's a
|
|||||||
* SSL certificates with SAN domains with IDNA encoding were broken in v0.11.
|
* SSL certificates with SAN domains with IDNA encoding were broken in v0.11.
|
||||||
* Some IDNA functionality was using IDNA 2003 rather than IDNA 2008.
|
* 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)
|
v0.11 (June 29, 2015)
|
||||||
---------------------
|
---------------------
|
||||||
@@ -26,7 +67,6 @@ 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.
|
* 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.
|
* 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.
|
* 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.
|
||||||
* v0.11b was posted shortly after the initial posting of v0.11 correcting a missing dependency for the new PPA.
|
|
||||||
|
|
||||||
Mail:
|
Mail:
|
||||||
* Greylisting will now let some reputable senders pass through immediately.
|
* Greylisting will now let some reputable senders pass through immediately.
|
||||||
|
|||||||
41
README.md
41
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 (see the [setup guide](https://mailinabox.email/guide.html)). There are no user-configurable setup options. It "just works".
|
It is a one-click email appliance. There are no user-configurable setup options. It "just works".
|
||||||
|
|
||||||
The components installed are:
|
The components installed are:
|
||||||
|
|
||||||
@@ -32,42 +32,53 @@ 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
|
||||||
* 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/))
|
* 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/))
|
||||||
|
|
||||||
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 and dovecot-lucene 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 (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.
|
||||||
|
|
||||||
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).
|
||||||
|
|
||||||
The Security
|
Installation
|
||||||
------------
|
------------
|
||||||
|
|
||||||
See the [security guide](security.md) for more information about the box's security configuration (TLS, password storage, etc).
|
See the [setup guide](https://mailinabox.email/guide.html) for detailed, user-friendly instructions.
|
||||||
|
|
||||||
I sign the release tags on git. To verify that a tag is signed by me, you can perform the following steps:
|
For experts, start with a completely fresh (really, I mean it) Ubuntu 14.04 LTS 64-bit machine. On the machine...
|
||||||
|
|
||||||
# Download my PGP key.
|
Clone this repository:
|
||||||
$ curl -s https://keybase.io/joshdata/key.asc | gpg --import
|
|
||||||
gpg: key C10BDD81: public key "Joshua Tauberer <jt@occams.info>" imported
|
|
||||||
|
|
||||||
# Clone this repository.
|
|
||||||
$ git clone https://github.com/mail-in-a-box/mailinabox
|
$ git clone https://github.com/mail-in-a-box/mailinabox
|
||||||
$ cd mailinabox
|
$ cd mailinabox
|
||||||
|
|
||||||
# Verify the tag.
|
_Optional:_ Download my PGP key and then verify that the sources were signed
|
||||||
$ git verify-tag v0.12c
|
by me. You'll get a lot of warnings, but the fingerprint should match the
|
||||||
|
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 anyway.)
|
||||||
|
|
||||||
|
$ curl -s https://keybase.io/joshdata/key.asc | gpg --import
|
||||||
|
gpg: key C10BDD81: public key "Joshua Tauberer <jt@occams.info>" imported
|
||||||
|
|
||||||
|
$ git verify-tag v0.13
|
||||||
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
|
||||||
|
|
||||||
# Check out the tag.
|
Checkout the tag corresponding to the most recent release:
|
||||||
$ git checkout v0.12c
|
|
||||||
|
|
||||||
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/).
|
$ git checkout v0.13
|
||||||
|
|
||||||
|
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
|
||||||
--------------------
|
--------------------
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
# Fail2Ban configuration file for Mail-in-a-Box
|
# Fail2Ban configuration file for Mail-in-a-Box
|
||||||
|
|
||||||
[DEFAULT]
|
|
||||||
|
|
||||||
# This should ban dumb brute-force attacks, not oblivious users.
|
|
||||||
findtime = 30
|
|
||||||
maxretry = 20
|
|
||||||
|
|
||||||
# JAILS
|
# JAILS
|
||||||
|
|
||||||
[ssh-ddos]
|
[ssh-ddos]
|
||||||
@@ -17,3 +11,5 @@ enabled = true
|
|||||||
[dovecot]
|
[dovecot]
|
||||||
enabled = true
|
enabled = true
|
||||||
filter = dovecotimap
|
filter = dovecotimap
|
||||||
|
findtime = 30
|
||||||
|
maxretry = 20
|
||||||
|
|||||||
@@ -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 (unknown [127.0.0.1])$1
|
/^\s*Received:[^\n]*(.*)/ REPLACE Received: from authenticated-user (PRIMARY_HOSTNAME [PUBLIC_IP])$1
|
||||||
|
|
||||||
# Remove other typically private information.
|
# Remove other typically private information.
|
||||||
/^\s*User-Agent:/ IGNORE
|
/^\s*User-Agent:/ IGNORE
|
||||||
|
|||||||
@@ -10,20 +10,27 @@
|
|||||||
|
|
||||||
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):
|
||||||
# What is the current status of backups?
|
# Root folder
|
||||||
# Loop through all of the files in STORAGE_ROOT/backup/encrypted to
|
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||||
# get a list of all of the backups taken and sum up file sizes to
|
|
||||||
# see how large the storage is.
|
|
||||||
|
|
||||||
|
# What is the current status of backups?
|
||||||
|
# Query duplicity to get a list of all backups.
|
||||||
|
# Use the number of volumes to estimate the size.
|
||||||
|
config = get_backup_config(env)
|
||||||
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)
|
||||||
@@ -34,27 +41,43 @@ 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)
|
||||||
|
|
||||||
backups = { }
|
# Get duplicity collection status and parse for a list of backups.
|
||||||
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
def parse_line(line):
|
||||||
backup_dir = os.path.join(backup_root, 'encrypted')
|
keys = line.strip().split()
|
||||||
os.makedirs(backup_dir, exist_ok=True) # os.listdir fails if directory does not exist
|
date = dateutil.parser.parse(keys[1])
|
||||||
for fn in os.listdir(backup_dir):
|
return {
|
||||||
|
"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: raise ValueError(fn)
|
if not m: continue # not a part of a current backup chain
|
||||||
|
|
||||||
key = m.group("date")
|
key = m.group("date")
|
||||||
if key not in backups:
|
backups[key]["size"] += size
|
||||||
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.
|
||||||
@@ -79,11 +102,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(keep_backups_for_days + (.5 * first_full_size - incremental_size) / (incremental_size/incremental_count) + .5)
|
deleted_in = "approx. %d days" % round(config["min_age_in_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=keep_backups_for_days)
|
days_ago = now - datetime.timedelta(days=config["min_age_in_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
|
||||||
@@ -99,8 +122,6 @@ 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,
|
||||||
}
|
}
|
||||||
@@ -124,15 +145,48 @@ 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
|
||||||
@@ -169,17 +223,6 @@ 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.
|
||||||
@@ -192,10 +235,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"],
|
||||||
"file://" + backup_dir,
|
config["target"],
|
||||||
"--allow-source-mismatch"
|
"--allow-source-mismatch"
|
||||||
],
|
],
|
||||||
env_with_passphrase)
|
get_env(env))
|
||||||
finally:
|
finally:
|
||||||
# Start services again.
|
# Start services again.
|
||||||
shell('check_call', ["/usr/sbin/service", "dovecot", "start"])
|
shell('check_call', ["/usr/sbin/service", "dovecot", "start"])
|
||||||
@@ -210,12 +253,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" % keep_backups_for_days,
|
"%dD" % config["min_age_in_days"],
|
||||||
"--archive-dir", backup_cache_dir,
|
"--archive-dir", backup_cache_dir,
|
||||||
"--force",
|
"--force",
|
||||||
"file://" + backup_dir
|
config["target"]
|
||||||
],
|
],
|
||||||
env_with_passphrase)
|
get_env(env))
|
||||||
|
|
||||||
# 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
|
||||||
@@ -227,13 +270,14 @@ def perform_backup(full_backup):
|
|||||||
"cleanup",
|
"cleanup",
|
||||||
"--archive-dir", backup_cache_dir,
|
"--archive-dir", backup_cache_dir,
|
||||||
"--force",
|
"--force",
|
||||||
"file://" + backup_dir
|
config["target"]
|
||||||
],
|
],
|
||||||
env_with_passphrase)
|
get_env(env))
|
||||||
|
|
||||||
# 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.
|
||||||
shell('check_call', ["/bin/chown", "-R", env["STORAGE_USER"], backup_dir])
|
if get_target_type(config) == 'file':
|
||||||
|
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
|
||||||
@@ -241,7 +285,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],
|
['su', env['STORAGE_USER'], '-c', post_script, config["target"]],
|
||||||
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
|
||||||
@@ -254,9 +298,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",
|
||||||
@@ -264,9 +308,118 @@ def run_duplicity_verification():
|
|||||||
"--compare-data",
|
"--compare-data",
|
||||||
"--archive-dir", backup_cache_dir,
|
"--archive-dir", backup_cache_dir,
|
||||||
"--exclude", backup_root,
|
"--exclude", backup_root,
|
||||||
"file://" + backup_dir,
|
config["target"],
|
||||||
env["STORAGE_ROOT"],
|
env["STORAGE_ROOT"],
|
||||||
], env_with_passphrase)
|
], get_env(env))
|
||||||
|
|
||||||
|
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 "Updated backup config"
|
||||||
|
|
||||||
|
def get_backup_config(env, for_save=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
|
||||||
|
|
||||||
|
# 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
|
||||||
@@ -274,6 +427,7 @@ 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,13 +90,19 @@ 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')
|
||||||
@@ -179,14 +185,15 @@ 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(x+"\t"+y+"\n" for x, y in get_mail_aliases(env))
|
return "".join(address+"\t"+receivers+"\t"+(senders or "")+"\n" for address, receivers, senders 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('source', ''),
|
request.form.get('address', ''),
|
||||||
request.form.get('destination', ''),
|
request.form.get('forwards_to', ''),
|
||||||
|
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')
|
||||||
)
|
)
|
||||||
@@ -194,7 +201,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('source', ''), env)
|
return remove_mail_alias(request.form.get('address', ''), env)
|
||||||
|
|
||||||
@app.route('/mail/domains')
|
@app.route('/mail/domains')
|
||||||
@authorized_personnel_only
|
@authorized_personnel_only
|
||||||
@@ -222,14 +229,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({ "hostname": get_secondary_dns(get_custom_dns_config(env)) })
|
return json_response({ "hostnames": get_secondary_dns(get_custom_dns_config(env), mode=None) })
|
||||||
|
|
||||||
@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(request.form.get('hostname'), env)
|
return set_secondary_dns([ns.strip() for ns in re.split(r"[, ]+", request.form.get('hostnames') or "") if ns.strip() != ""], env)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return (str(e), 400)
|
return (str(e), 400)
|
||||||
|
|
||||||
@@ -283,7 +290,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.
|
||||||
@@ -402,6 +409,23 @@ 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))
|
||||||
|
|
||||||
|
@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', '')
|
||||||
|
))
|
||||||
|
|
||||||
# MUNIN
|
# MUNIN
|
||||||
|
|
||||||
@app.route('/munin/')
|
@app.route('/munin/')
|
||||||
@@ -432,4 +456,3 @@ 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)
|
||||||
|
|
||||||
|
|||||||
@@ -144,8 +144,11 @@ 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.
|
||||||
secondary_ns = get_secondary_dns(additional_records) or ("ns2." + env["PRIMARY_HOSTNAME"])
|
# User may provide one or more additional nameservers
|
||||||
records.append((None, "NS", secondary_ns+'.', False))
|
secondary_ns_list = get_secondary_dns(additional_records, mode="NS") \
|
||||||
|
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...
|
||||||
@@ -167,6 +170,9 @@ 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."))
|
||||||
@@ -462,17 +468,10 @@ zone:
|
|||||||
zonefile: %s
|
zonefile: %s
|
||||||
""" % (domain, zonefile)
|
""" % (domain, zonefile)
|
||||||
|
|
||||||
# If a custom secondary nameserver has been set, allow zone transfers
|
# If custom secondary nameservers have been set, allow zone transfers
|
||||||
# and notifies to that nameserver.
|
# and notifies to them.
|
||||||
if get_secondary_dns(additional_records):
|
for ipaddr in get_secondary_dns(additional_records, mode="xfr"):
|
||||||
# Get the IP address of the nameserver by resolving it.
|
nsdconf += "\n\tnotify: %s NOKEY\n\tprovide-xfr: %s NOKEY\n" % (ipaddr, ipaddr)
|
||||||
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.
|
||||||
@@ -785,33 +784,64 @@ 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):
|
def get_secondary_dns(custom_dns, mode=None):
|
||||||
|
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":
|
if qname != '_secondary_nameserver': continue
|
||||||
return value
|
for hostname in value.split(" "):
|
||||||
return None
|
hostname = hostname.strip()
|
||||||
|
if mode == None:
|
||||||
|
# Just return the setting.
|
||||||
|
values.append(hostname)
|
||||||
|
continue
|
||||||
|
|
||||||
def set_secondary_dns(hostname, env):
|
# This is a hostname. Before including in zone xfr lines,
|
||||||
|
# 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)
|
||||||
|
|
||||||
if hostname in (None, ""):
|
# This is a zone-xfer-only IP address. Do not return if
|
||||||
# Clear.
|
# we're querying for NS record hostnames. Only return if
|
||||||
set_custom_dns_record("_secondary_nameserver", "A", None, "set", env)
|
# we're querying for zone xfer IP addresses - return the
|
||||||
else:
|
# IP address.
|
||||||
# Validate.
|
elif mode == "xfr":
|
||||||
hostname = hostname.strip().lower()
|
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()
|
resolver = dns.resolver.get_default_resolver()
|
||||||
try:
|
for item in hostnames:
|
||||||
response = dns.resolver.query(hostname, "A")
|
if not item.startswith("xfr:"):
|
||||||
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
# Resolve hostname.
|
||||||
raise ValueError("Could not resolve the IP address of %s." % 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.
|
||||||
set_custom_dns_record("_secondary_nameserver", "A", hostname, "set", env)
|
set_custom_dns_record("_secondary_nameserver", "A", " ".join(hostnames), "set", env)
|
||||||
|
else:
|
||||||
|
# Clear.
|
||||||
|
set_custom_dns_record("_secondary_nameserver", "A", None, "set", env)
|
||||||
|
|
||||||
# Apply.
|
# Apply.
|
||||||
return do_dns_update(env)
|
return do_dns_update(env)
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ def sanitize_idn_email_address(email):
|
|||||||
except (ValueError, idna.IDNAError):
|
except (ValueError, idna.IDNAError):
|
||||||
# ValueError: String does not have a single @-sign, so it is not
|
# ValueError: String does not have a single @-sign, so it is not
|
||||||
# a valid email address. IDNAError: Domain part is not IDNA-valid.
|
# a valid email address. IDNAError: Domain part is not IDNA-valid.
|
||||||
# Validation is not this function's job, so return value unchanged.
|
# Validation is not this function's job, so return value unchanged.
|
||||||
# If there are non-ASCII characters it will be filtered out by
|
# If there are non-ASCII characters it will be filtered out by
|
||||||
# validate_email.
|
# validate_email.
|
||||||
return email
|
return email
|
||||||
@@ -181,13 +181,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 (alias, forward-to string).
|
# Returns a sorted list of tuples of (address, forward-tos, permitted-senders).
|
||||||
c = open_database(env)
|
c = open_database(env)
|
||||||
c.execute('SELECT source, destination FROM aliases')
|
c.execute('SELECT source, destination, permitted_senders FROM aliases')
|
||||||
aliases = { row[0]: row[1] for row in c.fetchall() } # make dict
|
aliases = { row[0]: row 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 = [ (source, aliases[source]) for source in utils.sort_email_addresses(aliases.keys(), env) ]
|
aliases = [ aliases[address] for address 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,9 +199,10 @@ def get_mail_aliases_ex(env):
|
|||||||
# domain: "domain.tld",
|
# domain: "domain.tld",
|
||||||
# alias: [
|
# alias: [
|
||||||
# {
|
# {
|
||||||
# source: "name@domain.tld", # IDNA-encoded
|
# address: "name@domain.tld", # IDNA-encoded
|
||||||
# source_display: "name@domain.tld", # full Unicode
|
# address_display: "name@domain.tld", # full Unicode
|
||||||
# destination: ["target1@domain.com", "target2@domain.com", ...],
|
# forwards_to: ["user1@domain.com", "receiver-only1@domain.com", ...],
|
||||||
|
# permitted_senders: ["user1@domain.com", "sender-only1@domain.com", ...] OR null,
|
||||||
# required: True|False
|
# required: True|False
|
||||||
# },
|
# },
|
||||||
# ...
|
# ...
|
||||||
@@ -212,10 +213,10 @@ def get_mail_aliases_ex(env):
|
|||||||
|
|
||||||
required_aliases = get_required_aliases(env)
|
required_aliases = get_required_aliases(env)
|
||||||
domains = {}
|
domains = {}
|
||||||
for source, destination in get_mail_aliases(env):
|
for address, forwards_to, permitted_senders in get_mail_aliases(env):
|
||||||
# get alias info
|
# get alias info
|
||||||
domain = get_domain(source)
|
domain = get_domain(address)
|
||||||
required = (source in required_aliases)
|
required = (address in required_aliases)
|
||||||
|
|
||||||
# add to list
|
# add to list
|
||||||
if not domain in domains:
|
if not domain in domains:
|
||||||
@@ -224,18 +225,19 @@ def get_mail_aliases_ex(env):
|
|||||||
"aliases": [],
|
"aliases": [],
|
||||||
}
|
}
|
||||||
domains[domain]["aliases"].append({
|
domains[domain]["aliases"].append({
|
||||||
"source": source,
|
"address": address,
|
||||||
"source_display": prettify_idn_email_address(source),
|
"address_display": prettify_idn_email_address(address),
|
||||||
"destination": [prettify_idn_email_address(d.strip()) for d in destination.split(",")],
|
"forwards_to": [prettify_idn_email_address(r.strip()) for r in forwards_to.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 source address.
|
# Sort aliases within each domain first by required-ness then lexicographically by address.
|
||||||
for domain in domains:
|
for domain in domains:
|
||||||
domain["aliases"].sort(key = lambda alias : (alias["required"], alias["source"]))
|
domain["aliases"].sort(key = lambda alias : (alias["required"], alias["address"]))
|
||||||
return domains
|
return domains
|
||||||
|
|
||||||
def get_domain(emailaddr, as_unicode=True):
|
def get_domain(emailaddr, as_unicode=True):
|
||||||
@@ -249,8 +251,8 @@ 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(addr, as_unicode=False) for addr in get_mail_users(env)]
|
[get_domain(login, as_unicode=False) for login in get_mail_users(env)]
|
||||||
+ [get_domain(source, as_unicode=False) for source, target in get_mail_aliases(env) if filter_aliases((source, target)) ]
|
+ [get_domain(address, as_unicode=False) for address, *_ in get_mail_aliases(env) if filter_aliases(address) ]
|
||||||
)
|
)
|
||||||
|
|
||||||
def add_mail_user(email, pw, privs, env):
|
def add_mail_user(email, pw, privs, env):
|
||||||
@@ -406,67 +408,91 @@ def add_remove_mail_user_privilege(email, priv, action, env):
|
|||||||
|
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
def add_mail_alias(source, destination, env, update_if_exists=False, do_kick=True):
|
def add_mail_alias(address, forwards_to, permitted_senders, env, update_if_exists=False, do_kick=True):
|
||||||
# convert Unicode domain to IDNA
|
# convert Unicode domain to IDNA
|
||||||
source = sanitize_idn_email_address(source)
|
address = sanitize_idn_email_address(address)
|
||||||
|
|
||||||
# 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.
|
||||||
source = source.lower()
|
address = address.lower()
|
||||||
|
|
||||||
# validate source
|
# validate address
|
||||||
source = source.strip()
|
address = address.strip()
|
||||||
if source == "":
|
if address == "":
|
||||||
return ("No incoming email address provided.", 400)
|
return ("No email address provided.", 400)
|
||||||
if not validate_email(source, mode='alias'):
|
if not validate_email(address, mode='alias'):
|
||||||
return ("Invalid incoming email address (%s)." % source, 400)
|
return ("Invalid email address (%s)." % address, 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(source)
|
is_dcv_source = is_dcv_address(address)
|
||||||
|
|
||||||
# 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.
|
||||||
d1 = sanitize_idn_email_address(destination)
|
r1 = sanitize_idn_email_address(forwards_to)
|
||||||
if validate_email(d1, mode='alias') and not is_dcv_source:
|
if validate_email(r1, mode='alias') and not is_dcv_source:
|
||||||
dests.append(d1)
|
validated_forwards_to.append(r1)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Parse comma and \n-separated destination emails & validate. In this
|
# Parse comma and \n-separated destination emails & validate. In this
|
||||||
# case, the recipients must be complete email addresses.
|
# case, the forwards_to must be complete email addresses.
|
||||||
for line in destination.split("\n"):
|
for line in forwards_to.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 destination email address (%s)." % email, 400)
|
return ("Invalid receiver 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)
|
||||||
dests.append(email)
|
validated_forwards_to.append(email)
|
||||||
if len(destination) == 0:
|
|
||||||
return ("No destination email address(es) provided.", 400)
|
# validate permitted_senders
|
||||||
destination = ",".join(dests)
|
valid_logins = get_mail_users(env)
|
||||||
|
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) VALUES (?, ?)", (source, destination))
|
c.execute("INSERT INTO aliases (source, destination, permitted_senders) VALUES (?, ?, ?)", (address, forwards_to, permitted_senders))
|
||||||
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)." % source, 400)
|
return ("Alias already exists (%s)." % address, 400)
|
||||||
else:
|
else:
|
||||||
c.execute("UPDATE aliases SET destination = ? WHERE source = ?", (destination, source))
|
c.execute("UPDATE aliases SET destination = ?, permitted_senders = ? WHERE source = ?", (forwards_to, permitted_senders, address))
|
||||||
return_status = "alias updated"
|
return_status = "alias updated"
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -475,15 +501,15 @@ def add_mail_alias(source, destination, env, update_if_exists=False, do_kick=Tru
|
|||||||
# 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(source, env, do_kick=True):
|
def remove_mail_alias(address, env, do_kick=True):
|
||||||
# convert Unicode domain to IDNA
|
# convert Unicode domain to IDNA
|
||||||
source = sanitize_idn_email_address(source)
|
address = sanitize_idn_email_address(address)
|
||||||
|
|
||||||
# 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=?", (source,))
|
c.execute("DELETE FROM aliases WHERE source=?", (address,))
|
||||||
if c.rowcount != 1:
|
if c.rowcount != 1:
|
||||||
return ("That's not an alias (%s)." % source, 400)
|
return ("That's not an alias (%s)." % address, 400)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
if do_kick:
|
if do_kick:
|
||||||
@@ -507,8 +533,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[0].startswith("postmaster@") and not alias[0].startswith("admin@")
|
not alias.startswith("postmaster@") and not alias.startswith("admin@")
|
||||||
and not alias[0].startswith("@")
|
and not alias.startswith("@")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create postmaster@ and admin@ for all domains we serve mail on.
|
# Create postmaster@ and admin@ for all domains we serve mail on.
|
||||||
@@ -532,37 +558,38 @@ 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_aliases = get_mail_aliases(env)
|
existing_alias_records = 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(source):
|
def ensure_admin_alias_exists(address):
|
||||||
# If a user account exists with that address, we're good.
|
# If a user account exists with that address, we're good.
|
||||||
if source in existing_users:
|
if address in existing_users:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Does this alias exists?
|
# If the alias already exists, we're good.
|
||||||
for s, t in existing_aliases:
|
if address in existing_aliases:
|
||||||
if s == source:
|
return
|
||||||
return
|
|
||||||
|
|
||||||
# Doesn't exist.
|
# Doesn't exist.
|
||||||
administrator = get_system_administrator(env)
|
administrator = get_system_administrator(env)
|
||||||
if source == administrator: return # don't make an alias from the administrator to itself --- this alias must be created manually
|
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 alias in required_aliases:
|
for address in required_aliases:
|
||||||
ensure_admin_alias_exists(alias)
|
ensure_admin_alias_exists(address)
|
||||||
|
|
||||||
# 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 source, target in existing_aliases:
|
for address, forwards_to, *_ in existing_alias_records:
|
||||||
user, domain = source.split("@")
|
user, domain = address.split("@")
|
||||||
if user in ("postmaster", "admin") \
|
if user in ("postmaster", "admin") \
|
||||||
and source not in required_aliases \
|
and address not in required_aliases \
|
||||||
and target == get_system_administrator(env):
|
and forwards_to == get_system_administrator(env):
|
||||||
remove_mail_alias(source, env, do_kick=False)
|
remove_mail_alias(address, env, do_kick=False)
|
||||||
results.append("removed alias %s (was to %s; domain no longer used for email)\n" % (source, target))
|
results.append("removed alias %s (was to %s; domain no longer used for email)\n" % (address, forwards_to))
|
||||||
|
|
||||||
# Update DNS and nginx in case any domains are added/removed.
|
# Update DNS and nginx in case any domains are added/removed.
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,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
|
||||||
@@ -264,10 +264,10 @@ def run_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zone
|
|||||||
|
|
||||||
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)
|
||||||
|
|
||||||
@@ -351,11 +351,14 @@ 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_alises = dict(get_mail_aliases(env))
|
mail_aliases = dict([(address, receivers) for address, receivers, *_ in get_mail_aliases(env)])
|
||||||
if alias in mail_alises:
|
if alias in mail_aliases:
|
||||||
output.print_ok("%s exists as a mail alias. [%s ↦ %s]" % (alias_name, alias, mail_alises[alias]))
|
if mail_aliases[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 and direct email to you or another administrator.""" % alias)
|
output.print_error("""You must add a mail alias for %s which directs 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.
|
||||||
@@ -370,12 +373,9 @@ def check_dns_zone(domain, env, output, dns_zonefiles):
|
|||||||
# the TLD, and so we're not actually checking the TLD. For that we'd need
|
# the TLD, and so we're not actually checking the TLD. For that we'd need
|
||||||
# to do a DNS trace.
|
# to do a DNS trace.
|
||||||
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']
|
secondary_ns = get_secondary_dns(get_custom_dns_config(env), mode="NS") or ["ns2." + env['PRIMARY_HOSTNAME']]
|
||||||
existing_ns = query_dns(domain, "NS")
|
existing_ns = query_dns(domain, "NS")
|
||||||
correct_ns = "; ".join(sorted([
|
correct_ns = "; ".join(sorted(["ns1." + env['PRIMARY_HOSTNAME']] + secondary_ns))
|
||||||
"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 == env['PUBLIC_IP']:
|
elif ip == env['PUBLIC_IP']:
|
||||||
@@ -495,7 +495,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 dict(get_mail_aliases(env)):
|
if "@" + domain not in [address for address, *_ in 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.
|
||||||
@@ -647,7 +647,7 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
|
|||||||
return "*." + idna.encode(dns_name[2:]).decode('ascii')
|
return "*." + idna.encode(dns_name[2:]).decode('ascii')
|
||||||
else:
|
else:
|
||||||
return idna.encode(dns_name).decode('ascii')
|
return idna.encode(dns_name).decode('ascii')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sans = cert.extensions.get_extension_for_oid(OID_SUBJECT_ALTERNATIVE_NAME).value.get_values_for_type(DNSName)
|
sans = cert.extensions.get_extension_for_oid(OID_SUBJECT_ALTERNATIVE_NAME).value.get_values_for_type(DNSName)
|
||||||
for san in sans:
|
for san in sans:
|
||||||
@@ -708,7 +708,7 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
|
|||||||
"openssl",
|
"openssl",
|
||||||
"verify", "-verbose",
|
"verify", "-verbose",
|
||||||
"-purpose", "sslserver", "-policy_check",]
|
"-purpose", "sslserver", "-policy_check",]
|
||||||
+ ([] if len(ssl_cert_chain) == 1 else ["-untrusted", "/dev/stdin"])
|
+ ([] if len(ssl_cert_chain) == 1 else ["-untrusted", "/proc/self/fd/0"])
|
||||||
+ [ssl_certificate],
|
+ [ssl_certificate],
|
||||||
input=b"\n\n".join(ssl_cert_chain[1:]),
|
input=b"\n\n".join(ssl_cert_chain[1:]),
|
||||||
trap=True)
|
trap=True)
|
||||||
@@ -749,7 +749,7 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
|
|||||||
def load_cert_chain(pemfile):
|
def load_cert_chain(pemfile):
|
||||||
# A certificate .pem file may contain a chain of certificates.
|
# A certificate .pem file may contain a chain of certificates.
|
||||||
# Load the file and split them apart.
|
# Load the file and split them apart.
|
||||||
re_pem = rb"(-+BEGIN (?:.+)-+[\r\n](?:[A-Za-z0-9+/=]{1,64}[\r\n])+-+END (?:.+)-+[\r\n])"
|
re_pem = rb"(-+BEGIN (?:.+)-+[\r\n]+(?:[A-Za-z0-9+/=]{1,64}[\r\n]+)+-+END (?:.+)-+[\r\n]+)"
|
||||||
with open(pemfile, "rb") as f:
|
with open(pemfile, "rb") as f:
|
||||||
pem = f.read() + b"\n" # ensure trailing newline
|
pem = f.read() + b"\n" # ensure trailing newline
|
||||||
pemblocks = re.findall(re_pem, pem)
|
pemblocks = re.findall(re_pem, pem)
|
||||||
@@ -763,7 +763,7 @@ def load_pem(pem):
|
|||||||
from cryptography.x509 import load_pem_x509_certificate
|
from cryptography.x509 import load_pem_x509_certificate
|
||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
from cryptography.hazmat.backends import default_backend
|
from cryptography.hazmat.backends import default_backend
|
||||||
pem_type = re.match(b"-+BEGIN (.*?)-+\n", pem)
|
pem_type = re.match(b"-+BEGIN (.*?)-+[\r\n]", pem)
|
||||||
if pem_type is None:
|
if pem_type is None:
|
||||||
raise ValueError("File is not a valid PEM-formatted file.")
|
raise ValueError("File is not a valid PEM-formatted file.")
|
||||||
pem_type = pem_type.group(1)
|
pem_type = pem_type.group(1)
|
||||||
@@ -887,7 +887,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()
|
||||||
@@ -899,7 +899,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)
|
||||||
@@ -909,7 +909,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:
|
||||||
|
|||||||
@@ -7,33 +7,61 @@
|
|||||||
|
|
||||||
<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="javascript: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="#" onclick="return show_panel('users')">mail user</a> or to any email address.</p>
|
||||||
|
|
||||||
<form class="form-horizontal" role="form" onsubmit="do_add_alias(); return false;">
|
<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 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 active" data-mode="regular">Regular</button>
|
<button type="button" class="btn btn-default" 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. Enter just a part of an email address starting with the @-sign.</span>
|
<span class="catchall hidden">A catch-all alias captures all otherwise unmatched email to a domain.</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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="addaliasEmail" class="col-sm-1 control-label">Alias</label>
|
<label for="addaliasAddress" 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="addaliasEmail">
|
<input type="email" class="form-control" id="addaliasAddress">
|
||||||
<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>
|
<div style="margin-top: 3px; padding-left: 3px; font-size: 90%" class="text-muted">
|
||||||
|
<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="addaliasTargets" class="col-sm-1 control-label">Forward To</label>
|
<label for="addaliasForwardsTo" class="col-sm-1 control-label">Forwards To</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
<textarea class="form-control" rows="3" id="addaliasTargets"></textarea>
|
<textarea class="form-control" rows="3" id="addaliasForwardsTo"></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">
|
||||||
@@ -51,6 +79,7 @@
|
|||||||
<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>
|
||||||
@@ -70,8 +99,9 @@
|
|||||||
<span class="glyphicon glyphicon-trash"></span>
|
<span class="glyphicon glyphicon-trash"></span>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td class='email'> </td>
|
<td class='address'> </td>
|
||||||
<td class='target'> </td>
|
<td class='forwardsTo'> </td>
|
||||||
|
<td class='senders'> </td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,10 +128,12 @@ 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-email', alias.source_display); // this is decoded from IDNA, but will get re-coded to IDNA on the backend
|
n.attr('data-address', alias.address_display); // this is decoded from IDNA, but will get re-coded to IDNA on the backend
|
||||||
n.find('td.email').text(alias.source_display)
|
n.find('td.address').text(alias.address_display)
|
||||||
for (var j = 0; j < alias.destination.length; j++)
|
for (var j = 0; j < alias.forwards_to.length; j++)
|
||||||
n.find('td.target').append($("<div></div>").text(alias.destination[j]))
|
n.find('td.forwardsTo').append($("<div></div>").text(alias.forwards_to[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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,25 +143,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") {
|
||||||
$('#addaliasEmail').attr('type', 'email');
|
$('#addaliasAddress').attr('type', 'email');
|
||||||
$('#addaliasEmail').attr('placeholder', 'incoming email address (e.g. you@yourdomain.com)');
|
$('#addaliasAddress').attr('placeholder', 'you@yourdomain.com (incoming email address)');
|
||||||
$('#addaliasTargets').attr('placeholder', 'forward to these email addresses (one per line or separated by commas)');
|
$('#addaliasForwardsTo').attr('placeholder', 'one address 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") {
|
||||||
$('#addaliasEmail').attr('type', 'text');
|
$('#addaliasAddress').attr('type', 'text');
|
||||||
$('#addaliasEmail').attr('placeholder', 'incoming catch-all address (e.g. @yourdomain.com)');
|
$('#addaliasAddress').attr('placeholder', '@yourdomain.com (incoming catch-all domain)');
|
||||||
$('#addaliasTargets').attr('placeholder', 'forward to these email addresses (one per line or separated by commas)');
|
$('#addaliasForwardsTo').attr('placeholder', 'one address per line or separated by commas');
|
||||||
$('#alias_mode_info').slideDown();
|
$('#alias_mode_info').slideDown();
|
||||||
$('#alias_mode_info span').addClass('hidden');
|
$('#addalias-form .catchall').removeClass('hidden');
|
||||||
$('#alias_mode_info span.catchall').removeClass('hidden');
|
|
||||||
} else if ($(this).attr('data-mode') == "domainalias") {
|
} else if ($(this).attr('data-mode') == "domainalias") {
|
||||||
$('#addaliasEmail').attr('type', 'text');
|
$('#addaliasAddress').attr('type', 'text');
|
||||||
$('#addaliasEmail').attr('placeholder', 'incoming domain (@yourdomain.com)');
|
$('#addaliasAddress').attr('placeholder', '@yourdomain.com (incoming catch-all domain)');
|
||||||
$('#addaliasTargets').attr('placeholder', 'forward to domain (@yourdomain.com)');
|
$('#addaliasForwardsTo').attr('placeholder', '@otherdomain.com (forward to other domain)');
|
||||||
$('#alias_mode_info').slideDown();
|
$('#alias_mode_info').slideDown();
|
||||||
$('#alias_mode_info span').addClass('hidden');
|
$('#addalias-form .domainalias').removeClass('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
|
||||||
@@ -139,15 +171,21 @@ 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 email = $("#addaliasEmail").val();
|
var form_address = $("#addaliasAddress").val();
|
||||||
var targets = $("#addaliasTargets").val();
|
var form_forwardsto = $("#addaliasForwardsTo").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',
|
||||||
source: email,
|
address: form_address,
|
||||||
destination: targets
|
forwards_to: form_forwardsto,
|
||||||
|
permitted_senders: form_senders
|
||||||
},
|
},
|
||||||
function(r) {
|
function(r) {
|
||||||
// Responses are multiple lines of pre-formatted text.
|
// Responses are multiple lines of pre-formatted text.
|
||||||
@@ -162,48 +200,55 @@ function do_add_alias() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function aliases_reset_form() {
|
function aliases_reset_form() {
|
||||||
$("#addaliasEmail").prop('disabled', false);
|
$("#addaliasAddress").prop('disabled', false);
|
||||||
$("#addaliasEmail").val('')
|
$("#addaliasAddress").val('')
|
||||||
$("#addaliasTargets").val('')
|
$("#addaliasForwardsTo").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 email = $(elem).parents('tr').attr('data-email');
|
var address = $(elem).parents('tr').attr('data-address');
|
||||||
var targetdivs = $(elem).parents('tr').find('.target div');
|
var receiverdivs = $(elem).parents('tr').find('.forwardsTo div');
|
||||||
var targets = "";
|
var senderdivs = $(elem).parents('tr').find('.senders div');
|
||||||
for (var i = 0; i < targetdivs.length; i++)
|
var forwardsTo = "";
|
||||||
targets += $(targetdivs[i]).text() + "\n";
|
for (var i = 0; i < receiverdivs.length; i++)
|
||||||
|
forwardsTo += $(receiverdivs[i]).text() + "\n";
|
||||||
is_alias_add_update = true;
|
var senders = "";
|
||||||
$('#alias-cancel').removeClass('hidden');
|
for (var i = 0; i < senderdivs.length; i++)
|
||||||
$("#addaliasEmail").prop('disabled', true);
|
senders += $(senderdivs[i]).text() + "\n";
|
||||||
$("#addaliasEmail").val(email);
|
if (address.charAt(0) == '@' && forwardsTo.charAt(0) == '@')
|
||||||
$("#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 (email.charAt(0) == '@')
|
else if (address.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 email = $(elem).parents('tr').attr('data-email');
|
var row_address = $(elem).parents('tr').attr('data-address');
|
||||||
show_modal_confirm(
|
show_modal_confirm(
|
||||||
"Remove Alias",
|
"Remove Alias",
|
||||||
"Remove " + email + "?",
|
"Remove " + row_address + "?",
|
||||||
"Remove",
|
"Remove",
|
||||||
function() {
|
function() {
|
||||||
api(
|
api(
|
||||||
"/mail/aliases/remove",
|
"/mail/aliases/remove",
|
||||||
"POST",
|
"POST",
|
||||||
{
|
{
|
||||||
source: email
|
address: row_address
|
||||||
},
|
},
|
||||||
function(r) {
|
function(r) {
|
||||||
// Responses are multiple lines of pre-formatted text.
|
// Responses are multiple lines of pre-formatted text.
|
||||||
|
|||||||
@@ -67,7 +67,8 @@
|
|||||||
|
|
||||||
<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 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 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 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">
|
||||||
@@ -83,7 +84,11 @@
|
|||||||
</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">Clear the input field above and click Update to use this machine itself as secondary DNS, which is the default/normal setup.</p>
|
<p class="small">
|
||||||
|
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>
|
||||||
@@ -152,8 +157,8 @@ function show_custom_dns() {
|
|||||||
"GET",
|
"GET",
|
||||||
{ },
|
{ },
|
||||||
function(data) {
|
function(data) {
|
||||||
$('#secondarydnsHostname').val(data.hostname ? data.hostname : '');
|
$('#secondarydnsHostname').val(data.hostnames.join(' '));
|
||||||
$('#secondarydns-clear-instructions').toggle(data.hostname != null);
|
$('#secondarydns-clear-instructions').toggle(data.hostnames.length > 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
api(
|
api(
|
||||||
@@ -210,7 +215,7 @@ function do_set_secondary_dns() {
|
|||||||
"/dns/secondary-nameserver",
|
"/dns/secondary-nameserver",
|
||||||
"POST",
|
"POST",
|
||||||
{
|
{
|
||||||
hostname: $('#secondarydnsHostname').val()
|
hostnames: $('#secondarydnsHostname').val()
|
||||||
},
|
},
|
||||||
function(data) {
|
function(data) {
|
||||||
if (data == "") return; // nothing updated
|
if (data == "") return; // nothing updated
|
||||||
|
|||||||
@@ -5,17 +5,77 @@
|
|||||||
|
|
||||||
<h2>Backup Status</h2>
|
<h2>Backup Status</h2>
|
||||||
|
|
||||||
<h3>Copying Backup Files</h3>
|
<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>
|
||||||
|
|
||||||
<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>
|
<h3>Configuration</h3>
|
||||||
|
|
||||||
<p>Many cloud providers make this easy by allowing you to take snapshots of the machine's disk.</p>
|
<form class="form-horizontal" role="form" onsubmit="set_custom_backup(); return false;">
|
||||||
|
<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">
|
||||||
|
<div>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.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group backup-target-s3">
|
||||||
|
<div class="col-sm-10 col-sm-offset-2">
|
||||||
|
<div>Backups are stored in an Amazon Web Services S3 bucket. You must have an AWS account already.</div>
|
||||||
|
</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>
|
||||||
|
|
||||||
<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>Copy the encryption password from <tt id="backup-encpassword-file"></tt> to a safe and secure location. You will need this file to decrypt backup files.</p>
|
||||||
|
|
||||||
<h3>Current Backups</h3>
|
<h3>Available 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>
|
<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>
|
||||||
|
|
||||||
<table id="backup-status" class="table" style="width: auto">
|
<table id="backup-status" class="table" style="width: auto">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -27,8 +87,14 @@
|
|||||||
<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) {
|
||||||
@@ -46,19 +112,22 @@ 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 (r.backups.length == 0) {
|
if (typeof r.backups == "undefined") {
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
@@ -83,4 +152,62 @@ 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) {
|
||||||
|
// Responses are multiple lines of pre-formatted text.
|
||||||
|
show_modal_error("Backup configuration", $("<pre/>").text(r), function() { show_system_backup(); }); // refresh after modal
|
||||||
|
},
|
||||||
|
function(r) {
|
||||||
|
show_modal_error("Backup configuration (error)", r);
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -32,9 +32,9 @@
|
|||||||
</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.</li>
|
<li>Passwords must be at least four characters and may not contain spaces.</li>
|
||||||
<li>Use <a href="javascript:show_panel('aliases')">aliases</a> to create email addresses that forward to existing accounts.</li>
|
<li>Use <a href="#" onclick="return show_panel('aliases')">aliases</a> to create email addresses that forward to existing accounts.</li>
|
||||||
<li>Administrators get access to this control panel.</li>
|
<li>Administrators get access to this control panel.</li>
|
||||||
<li>User accounts cannot contain any international (non-ASCII) characters, but <a href="javascript:show_panel('aliases')">aliases</a> can.</li>
|
<li>User accounts cannot contain any international (non-ASCII) characters, but <a href="#" onclick="return show_panel('aliases');">aliases</a> can.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Existing mail users</h3>
|
<h3>Existing mail users</h3>
|
||||||
|
|||||||
@@ -24,32 +24,52 @@ def safe_domain_name(name):
|
|||||||
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. For web_update, PRIMARY_HOSTNAME
|
# Put domain names in a nice sorted order.
|
||||||
# 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
|
||||||
# First group PRIMARY_HOSTNAME and its subdomains, then parent domains of PRIMARY_HOSTNAME, then other domains.
|
# domain name that we serve that ecompasses a set of subdomains. Map
|
||||||
groups = ( [], [], [] )
|
# each of the domain names to the zone that contains them. Walk the domains
|
||||||
for d in domain_names:
|
# from shortest to longest since zones are always shorter than their
|
||||||
if d == env['PRIMARY_HOSTNAME'] or d.endswith("." + env['PRIMARY_HOSTNAME']):
|
# subdomains.
|
||||||
groups[0].append(d)
|
zones = { }
|
||||||
elif env['PRIMARY_HOSTNAME'].endswith("." + d):
|
for domain in sorted(domain_names, key=lambda d : len(d)):
|
||||||
groups[1].append(d)
|
for z in zones.values():
|
||||||
|
if domain.endswith("." + z):
|
||||||
|
# We found a parent domain already in the list.
|
||||||
|
zones[domain] = z
|
||||||
|
break
|
||||||
else:
|
else:
|
||||||
groups[2].append(d)
|
# 'break' did not occur: there is no parent domain, so it is its
|
||||||
|
# own zone.
|
||||||
|
zones[domain] = domain
|
||||||
|
|
||||||
# Within each group, sort parent domains before subdomains and after that sort lexicographically.
|
# Sort the zones.
|
||||||
def sort_group(group):
|
zone_domains = sorted(zones.values(),
|
||||||
# Find the top-most domains.
|
key = lambda d : (
|
||||||
top_domains = sorted(d for d in group if len([s for s in group if d.endswith("." + s)]) == 0)
|
# PRIMARY_HOSTNAME or the zone that contains it is always first.
|
||||||
ret = []
|
not (d == env['PRIMARY_HOSTNAME'] or env['PRIMARY_HOSTNAME'].endswith("." + d)),
|
||||||
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]
|
# 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
|
||||||
|
|
||||||
def sort_email_addresses(email_addresses, env):
|
def sort_email_addresses(email_addresses, env):
|
||||||
email_addresses = set(email_addresses)
|
email_addresses = set(email_addresses)
|
||||||
@@ -200,3 +220,12 @@ 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)
|
||||||
|
|||||||
11
security.md
11
security.md
@@ -17,8 +17,11 @@ 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.
|
* User credentials are always sent through SSH/TLS, never in the clear, with modern TLS settings.
|
||||||
* Outbound mail is sent with the highest level of TLS possible (more on that below).
|
* Outbound mail is sent with the highest level of TLS possible.
|
||||||
|
* 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
|
||||||
----------------
|
----------------
|
||||||
@@ -89,7 +92,9 @@ Domain policy records allow recipient MTAs to detect when the _domain_ part of o
|
|||||||
|
|
||||||
### User Policy
|
### User Policy
|
||||||
|
|
||||||
While domain policy records prevent other servers from sending mail with a "From:" header that matches a domain hosted on the box (see above), those policy records do not guarnatee that the user portion of the sender email address matches the actual sender. In enterprise environments where the box may host the mail of untrusted users, it is important to guard against users impersonating other users. The box restricts the envelope sender address that users may put into outbound mail to either a) their own email address (their SMTP login username) or b) any alias that they are listed as a direct recipient of. Note that the envelope sender address is not the same as the "From:" header.
|
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.12c
|
TAG=v0.13a
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Are we running as root?
|
# Are we running as root?
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
tools/mail.py alias add administrator@$PRIMARY_HOSTNAME $EMAIL_ADDR > /dev/null
|
||||||
fi
|
fi
|
||||||
@@ -39,32 +39,12 @@ function apt_get_quiet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function apt_install {
|
function apt_install {
|
||||||
# Report any packages already installed.
|
# 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=$@
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ 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
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ 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
|
||||||
@@ -93,6 +94,12 @@ 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.
|
||||||
|
|||||||
@@ -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);" | 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;
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ### User Authentication
|
# ### User Authentication
|
||||||
@@ -71,18 +71,23 @@ tools/editconf.py /etc/postfix/main.cf \
|
|||||||
|
|
||||||
# ### Sender Validation
|
# ### Sender Validation
|
||||||
|
|
||||||
# Use a Sqlite3 database to set login maps. This is used with
|
# We use Postfix's reject_authenticated_sender_login_mismatch filter to
|
||||||
# reject_authenticated_sender_login_mismatch to see if user is
|
# prevent intra-domain spoofing by logged in but untrusted users in outbound
|
||||||
# allowed to send mail using FROM field specified in the request.
|
# 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 \
|
tools/editconf.py /etc/postfix/main.cf \
|
||||||
smtpd_sender_login_maps=sqlite:/etc/postfix/sender-login-maps.cf
|
smtpd_sender_login_maps=sqlite:/etc/postfix/sender-login-maps.cf
|
||||||
|
|
||||||
# SQL statement to set login map which includes the case when user is
|
# Postfix will query the exact address first, where the priority will be alias
|
||||||
# sending email using a valid alias.
|
# records first, then user records. If there are no matches for the exact
|
||||||
# This is the same as virtual-alias-maps.cf, See below
|
# 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;
|
cat > /etc/postfix/sender-login-maps.cf << EOF;
|
||||||
dbpath=$db_path
|
dbpath=$db_path
|
||||||
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;
|
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
|
EOF
|
||||||
|
|
||||||
# ### Destination Validation
|
# ### Destination Validation
|
||||||
@@ -95,13 +100,13 @@ 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 mail for a domain, either for users or aliases.
|
# SQL statement to check if we handle incoming 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 mail for a user.
|
# SQL statement to check if we handle incoming 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'
|
||||||
@@ -127,9 +132,13 @@ EOF
|
|||||||
# 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 to preserve
|
||||||
# postfix's preference for aliases for whole email addresses.
|
# 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' 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' AND destination<>'' 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,11 +2,17 @@
|
|||||||
|
|
||||||
source setup/functions.sh
|
source setup/functions.sh
|
||||||
|
|
||||||
|
echo "Installing Mail-in-a-Box system management daemon..."
|
||||||
|
|
||||||
# build-essential libssl-dev libffi-dev python3-dev: Required to pip install cryptography.
|
# build-essential libssl-dev libffi-dev python3-dev: Required to pip install cryptography.
|
||||||
apt_install python3-flask links duplicity libyaml-dev python3-dnspython python3-dateutil \
|
apt_install python3-flask links duplicity libyaml-dev python3-dnspython python3-dateutil \
|
||||||
build-essential libssl-dev libffi-dev python3-dev
|
build-essential libssl-dev libffi-dev python3-dev python-pip
|
||||||
hide_output pip3 install --upgrade rtyaml email_validator idna cryptography
|
hide_output pip3 install --upgrade rtyaml email_validator idna cryptography boto
|
||||||
# email_validator is repeated in setup/questions.sh
|
|
||||||
|
# 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
|
||||||
|
|||||||
@@ -101,6 +101,16 @@ def migration_8(env):
|
|||||||
# a new key, which will be 2048 bits.
|
# a new key, which will be 2048 bits.
|
||||||
os.unlink(os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.private'))
|
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
|
||||||
while True:
|
while True:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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
|
||||||
@@ -28,5 +29,33 @@ 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
|
||||||
|
|
||||||
|
# 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
|
||||||
sudo -u munin munin-cron
|
# (We get "Pango-WARNING **: error opening config file '/root/.config/pango/pangorc': Permission denied"
|
||||||
|
# if we don't explicitly set the HOME directory when sudo'ing.)
|
||||||
|
sudo -H -u munin munin-cron
|
||||||
|
|||||||
@@ -7,16 +7,18 @@ 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-memcache unzip
|
php5 php5-dev php5-gd php5-fpm memcached php5-memcached 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.0.4
|
owncloud_ver=8.1.1
|
||||||
owncloud_hash=625b1c561ea51426047a3e79eda51ca05e9f978a
|
owncloud_hash=34077e78575a3e689825a00964ee37fbf83fbdda
|
||||||
|
|
||||||
# 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.
|
||||||
@@ -35,7 +37,6 @@ 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
|
# Download and verify
|
||||||
echo "installing ownCloud..."
|
|
||||||
wget_verify https://download.owncloud.org/community/owncloud-$owncloud_ver.zip $owncloud_hash /tmp/owncloud.zip
|
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.
|
||||||
@@ -51,8 +52,8 @@ if [ ! -d /usr/local/lib/owncloud/ ] \
|
|||||||
# 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 v$owncloud_ver '' /usr/local/lib/owncloud/apps/contacts
|
git_clone https://github.com/owncloud/contacts 4ff855e7c2075309041bead09fbb9eb7df678244 '' /usr/local/lib/owncloud/apps/contacts
|
||||||
git_clone https://github.com/owncloud/calendar v$owncloud_ver '' /usr/local/lib/owncloud/apps/calendar
|
git_clone https://github.com/owncloud/calendar ec53139b144c0f842c33813305612e8006c42ea5 '' /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}
|
||||||
@@ -66,8 +67,20 @@ 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
|
||||||
|
|
||||||
# Run the upgrade script (if ownCloud is already up-to-date it wont matter).
|
# If this isn't a new installation, immediately run the upgrade script.
|
||||||
hide_output sudo -u www-data php /usr/local/lib/owncloud/occ upgrade
|
# Then check for success (0=ok and 3=no upgrade needed, both are success).
|
||||||
|
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
|
||||||
@@ -95,12 +108,14 @@ if [ ! -f $STORAGE_ROOT/owncloud/owncloud.db ]; then
|
|||||||
'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),
|
||||||
),
|
),
|
||||||
@@ -147,6 +162,22 @@ 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 existing configuration files with changed settings that 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['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
|
||||||
@@ -156,6 +187,12 @@ 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 ';' \
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ source setup/functions.sh # load our functions
|
|||||||
# ----------------------------------------
|
# ----------------------------------------
|
||||||
|
|
||||||
# Install packages.
|
# Install packages.
|
||||||
|
echo "Installing SpamAssassin..."
|
||||||
apt_install spampd razor pyzor dovecot-antispam
|
apt_install spampd razor pyzor dovecot-antispam
|
||||||
|
|
||||||
# Allow spamassassin to download new rules.
|
# Allow spamassassin to download new rules.
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
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,9 +52,13 @@ 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
|
||||||
|
|||||||
@@ -48,9 +48,10 @@ 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
|
# * coreutils: includes `nproc` tool to report number of processors, mktemp
|
||||||
# * bc: allows us to do math to compute sane defaults
|
# * bc: allows us to do math to compute sane defaults
|
||||||
|
|
||||||
|
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,6 +17,7 @@ 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
|
||||||
|
|||||||
@@ -19,9 +19,10 @@ 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-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-mail-mimedecode 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
|
||||||
@@ -47,7 +48,6 @@ 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 \
|
https://mailinabox.email/mirror/roundcubemail-$VERSION.tar.gz \
|
||||||
$HASH \
|
$HASH \
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ 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
|
||||||
|
|
||||||
@@ -30,7 +31,6 @@ 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
|
||||||
|
|||||||
@@ -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", { "source": sys.argv[3], "destination": sys.argv[4] }))
|
print(mgmt("/mail/aliases/add", { "address": sys.argv[3], "forwards_to": sys.argv[4] }))
|
||||||
|
|
||||||
elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4:
|
elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4:
|
||||||
print(mgmt("/mail/aliases/remove", { "source": sys.argv[3] }))
|
print(mgmt("/mail/aliases/remove", { "address": sys.argv[3] }))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
print("Invalid command-line arguments.")
|
print("Invalid command-line arguments.")
|
||||||
|
|||||||
Reference in New Issue
Block a user