1
0
mirror of https://github.com/mail-in-a-box/mailinabox.git synced 2026-03-14 17:27:23 +01:00

Compare commits

...

29 Commits
v0.07 ... v0.08

Author SHA1 Message Date
Joshua Tauberer
f3ad6b4acc Version 0.08
CHANGELOG
=========

v0.08 (April 1, 2015)
---------------------

Mail:

* The Roundcube vacation_sieve plugin by @arodier is now installed to make it easier to set vacation auto-reply messages from within Roundcube.
* Authentication-Results headers for DMARC, added in v0.07, were mistakenly added for outbound mail --- that's now removed.
* The Trash folder is now created automatically for new mail accounts, addressing a Roundcube error.

DNS:

* Custom DNS TXT records were not always working and they can now override the default SPF, DKIM, and DMARC records.

System:

* ownCloud updated to version 8.0.2.
* Brute-force SSH and IMAP login attempts are now prevented by properly configuring fail2ban.
* Status checks are run each night and any changes from night to night are emailed to the box administrator (the first user account).

Control panel:

* The new check that system services are running mistakenly checked that the Dovecot Managesieve service is publicly accessible. Although the service binds to the public network interface we don't open the port in ufw. On some machines it seems that ufw blocks the connection from the status checks (which seems correct) and on some machines (mine) it doesn't, which is why I didn't notice the problem.
* The current backup chain will now try to predict how many days until it is deleted (always at least 3 days after the next full backup).
* The list of aliases that forward to a user are removed from the Mail Users page because when there are many alises it is slow and times-out.
* Some status check errors are turned into warnings, especially those that might not apply if External DNS is used.
2015-04-01 10:14:34 -04:00
Joshua Tauberer
ec039719de prevent caching of ajax responses in the control panel
GET requests might be cached. Definitely happens on Internet Explorer. Makes it look like the user is getting unauthorized access.

See https://discourse.mailinabox.email/t/fresh-install-can-login-to-webmail-but-not-admin/394/4.
2015-03-31 14:52:11 +00:00
Joshua Tauberer
14b16b2f36 allow custom DNS TXT records for SPF, DKIM, and DMARC to override the ones we want to set
fixes #323
fixes #324
2015-03-30 01:20:03 +00:00
Joshua Tauberer
cbc7e280d6 set the SPF record after custom DNS records so that the SPF record doesn't prevent all custom TXT records from coming in 2015-03-30 01:18:05 +00:00
Joshua Tauberer
f4fa9c93a0 Merge pull request #366 from hnk/hnk-patch-read_password
Change read_password() logic to catch short passwords
2015-03-29 14:12:07 -04:00
Hnk Reno
6c64723d7c Change read_password() logic to better catch improper passwords
Currently read_password does not verify password length. But further down the chain, passwords are checked to make sure they are longer than four characters.

If during initial setup, the user enters a password that is shorter than four characters, this will not be caught here, but when the script actually calls management/mailconfig.py to add the user, it will fail without a chance to correct the short password.

The setup script will then continue without an inital user being created and this will confuse users.
2015-03-29 18:54:37 +02:00
Joshua Tauberer
3d21f2223e status checks: turn missing DNSSEC into a warning instead of an error; omit an error about missing TLSA if DNSSEC isn't in use; if DNSSEC is in use, make a missing TLSA record a warning instead of an error 2015-03-28 11:24:05 -04:00
Joshua Tauberer
710a69b812 turn some nameserver status check errors into warnings if the domain resolves correctly since the user might be using External DNS, closes #330 2015-03-28 11:23:59 -04:00
Joshua Tauberer
dd6a8d9998 upgrade to ownCloud 8.0.2
The contacts and calendar apps are now maintained outside of ownCloud core, so we now pull them in from github tags and must enable them explicitly.
2015-03-28 11:08:57 -04:00
Joshua Tauberer
9f32e5af0a the install of roundcube vacation_sieve requires that we install git
see a8669197dd
2015-03-28 09:54:52 -04:00
Joshua Tauberer
298e19598b small bug in the new system status checks show-changes command
see 4d22fb9b2a

fixes #360
2015-03-22 14:03:12 +00:00
Joshua Tauberer
680191d7cb drop the list of aliases from the users control panel page because with more than 50 aliases it seems to be so slow it times out
see https://discourse.mailinabox.email/t/small-bug-in-admin-panel-when-49-aliases/378
2015-03-22 13:59:05 +00:00
Joshua Tauberer
81d6d69b85 update CHANGELOG 2015-03-22 13:58:24 +00:00
Joshua Tauberer
6df72bf4ac create the Trash folder on new user creation (fixes #359) 2015-03-22 13:33:17 +00:00
Joshua Tauberer
01f2451349 provide a better error message when creating a user account with non-ASCII characters 2015-03-22 12:33:06 +00:00
Joshua Tauberer
dcd971d079 the opendmarc miter should run on incoming mail only
I added OpenDMARC's milter in fba4d4702e. But this started
setting Authentication-Results headers on outbound mail with failures. Not sure why it
fails at that point, but it shouldn't be set at all. The failure might cause recipients
to junk the mail. See #358.

This commit removes the milter from the SMTP submission (port 587) listener.
2015-03-21 16:14:01 +00:00
Joshua Tauberer
4d22fb9b2a run status checks each night and email the administrator with the changes from the previous day's results 2015-03-21 16:02:42 +00:00
Joshua Tauberer
c18d58b13f backups: predict when the next backup will occur 2015-03-21 15:22:45 +00:00
Joshua Tauberer
b539c2df70 Merge pull request #347 from Toilal/feat/start-enhancements
If the migration file is missing but the storage directory exists, assume this is a fresh directory -- don't bother trying to migrate, and do write the migration file with the current migration ID.
2015-03-19 11:57:24 -04:00
Toilal
64fdb4ddc1 Behave nicely when mailinabox.version file is missing 2015-03-09 08:54:32 +01:00
Joshua Tauberer
a8669197dd added Roundcube plugin vacation_sieve
Merge branch 'master' of https://github.com/zealot128-os/mailinabox

Closes #334
2015-03-08 19:15:20 +00:00
Joshua Tauberer
2412c92772 enable fail2ban for ssh and dovecot
Merge branch 'master' of https://github.com/h8h/mailinabox

see #353, #319
2015-03-08 18:40:17 +00:00
Joshua Tauberer
7c0ca42145 status checks: don't check that dovecot-sieve is publicly accessible 2015-03-08 18:35:33 +00:00
H8H
c443524ee2 Configure fail2ban jails to prevent dumb brute-force attacks against postfix, dovecot and ssh. See #319 2015-03-08 01:13:55 +01:00
Joshua Tauberer
e2fa01e0cf Merge pull request #348 from benschumacher/master
Update MX records using DNS Update API / Management UI
2015-03-04 13:42:02 -05:00
Ben Schumacher
6558f05d1d Give the DNS update tool the ability to customize MX records. Useful if you want a subdomain to send mail to another host. 2015-03-04 13:32:35 -05:00
Stefan Wienert
ba8123f08a reduced diff noise 2015-02-21 16:06:56 +01:00
Stefan Wienert
e2879a8eb1 made the setup repeatable 2015-02-21 16:05:47 +01:00
Stefan Wienert
eab8652225 added vacation_sieve plugin for Roundcube 2015-02-21 16:01:27 +01:00
24 changed files with 468 additions and 138 deletions

View File

@@ -1,6 +1,32 @@
CHANGELOG CHANGELOG
========= =========
v0.08 (April 1, 2015)
---------------------
Mail:
* The Roundcube vacation_sieve plugin by @arodier is now installed to make it easier to set vacation auto-reply messages from within Roundcube.
* Authentication-Results headers for DMARC, added in v0.07, were mistakenly added for outbound mail --- that's now removed.
* The Trash folder is now created automatically for new mail accounts, addressing a Roundcube error.
DNS:
* Custom DNS TXT records were not always working and they can now override the default SPF, DKIM, and DMARC records.
System:
* ownCloud updated to version 8.0.2.
* Brute-force SSH and IMAP login attempts are now prevented by properly configuring fail2ban.
* Status checks are run each night and any changes from night to night are emailed to the box administrator (the first user account).
Control panel:
* The new check that system services are running mistakenly checked that the Dovecot Managesieve service is publicly accessible. Although the service binds to the public network interface we don't open the port in ufw. On some machines it seems that ufw blocks the connection from the status checks (which seems correct) and on some machines (mine) it doesn't, which is why I didn't notice the problem.
* The current backup chain will now try to predict how many days until it is deleted (always at least 3 days after the next full backup).
* The list of aliases that forward to a user are removed from the Mail Users page because when there are many alises it is slow and times-out.
* Some status check errors are turned into warnings, especially those that might not apply if External DNS is used.
v0.07 (February 28, 2015) v0.07 (February 28, 2015)
------------------------- -------------------------

View File

@@ -0,0 +1,22 @@
# Fail2Ban filter Dovecot authentication and pop3/imap server
# For Mail-in-a-Box
[INCLUDES]
before = common.conf
[Definition]
_daemon = (auth|dovecot(-auth)?|auth-worker)
failregex = ^%(__prefix_line)s(pop3|imap)-login: (Info: )?(Aborted login|Disconnected)(: Inactivity)? \(((no auth attempts|auth failed, \d+ attempts)( in \d+ secs)?|tried to use (disabled|disallowed) \S+ auth)\):( user=<\S*>,)?( method=\S+,)? rip=<HOST>, lip=(\d{1,3}\.){3}\d{1,3}(, TLS( handshaking)?(: Disconnected)?)?(, session=<\S+>)?\s*$
ignoreregex =
# DEV Notes:
# * the first regex is essentially a copy of pam-generic.conf
# * Probably doesn't do dovecot sql/ldap backends properly
#
# Author: Martin Waschbuesch
# Daniel Black (rewrote with begin and end anchors)
# Mail-in-a-Box (swapped session=...)

34
conf/fail2ban/jail.local Normal file
View File

@@ -0,0 +1,34 @@
# Fail2Ban configuration file.
# For Mail-in-a-Box
[DEFAULT]
# bantime in seconds
bantime = 60
# This should ban dumb brute-force attacks, not oblivious users.
findtime = 30
maxretry = 20
#
# JAILS
#
[ssh]
enabled = true
logpath = /var/log/auth.log
maxretry = 20
[ssh-ddos]
enabled = true
maxretry = 20
[sasl]
enabled = true
[dovecot]
enabled = true
filter = dovecotimap

View File

@@ -67,9 +67,29 @@ def backup_status(env):
# This is relied on by should_force_full() and the next step. # This is relied on by should_force_full() and the next step.
backups = sorted(backups.values(), key = lambda b : b["date"], reverse=True) backups = sorted(backups.values(), key = lambda b : b["date"], reverse=True)
# Get the average size of incremental backups and the size of the
# most recent full backup.
incremental_count = 0
incremental_size = 0
first_full_size = None
for bak in backups:
if bak["full"]:
first_full_size = bak["size"]
break
incremental_count += 1
incremental_size += bak["size"]
# Predict how many more increments until the next full backup,
# and add to that the time we hold onto backups, to predict
# how long the most recent full backup+increments will be held
# onto. Round up since the backup occurs on the night following
# when the threshold is met.
deleted_in = 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)
# When will a backup be deleted? # When will a backup be deleted?
saw_full = False saw_full = False
deleted_in = None
days_ago = now - datetime.timedelta(days=keep_backups_for_days) days_ago = now - datetime.timedelta(days=keep_backups_for_days)
for bak in backups: for bak in backups:
if deleted_in: if deleted_in:

View File

@@ -324,7 +324,7 @@ def system_status():
def print_line(self, message, monospace=False): def print_line(self, message, monospace=False):
self.items[-1]["extra"].append({ "text": message, "monospace": monospace }) self.items[-1]["extra"].append({ "text": message, "monospace": monospace })
output = WebOutput() output = WebOutput()
run_checks(env, output, pool) run_checks(False, env, output, pool)
return json_response(output.items) return json_response(output.items)
@app.route('/system/updates') @app.route('/system/updates')

View File

@@ -183,10 +183,6 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
# The MX record says where email for the domain should be delivered: Here! # The MX record says where email for the domain should be delivered: Here!
records.append((None, "MX", "10 %s." % env["PRIMARY_HOSTNAME"], "Required. Specifies the hostname (and priority) of the machine that handles @%s mail." % domain)) records.append((None, "MX", "10 %s." % env["PRIMARY_HOSTNAME"], "Required. Specifies the hostname (and priority) of the machine that handles @%s mail." % domain))
# SPF record: Permit the box ('mx', see above) to send mail on behalf of
# the domain, and no one else.
records.append((None, "TXT", 'v=spf1 mx -all', "Recommended. Specifies that only the box is permitted to send @%s mail." % domain))
# Add DNS records for any subdomains of this domain. We should not have a zone for # Add DNS records for any subdomains of this domain. We should not have a zone for
# both a domain and one of its subdomains. # both a domain and one of its subdomains.
subdomains = [d for d in all_domains if d.endswith("." + domain)] subdomains = [d for d in all_domains if d.endswith("." + domain)]
@@ -207,6 +203,7 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
return False return False
# The user may set other records that don't conflict with our settings. # The user may set other records that don't conflict with our settings.
# Don't put any TXT records above this line, or it'll prevent any custom TXT records.
for qname, rtype, value in get_custom_records(domain, additional_records, env): for qname, rtype, value in get_custom_records(domain, additional_records, env):
if has_rec(qname, rtype): continue if has_rec(qname, rtype): continue
records.append((qname, rtype, value, "(Set by user.)")) records.append((qname, rtype, value, "(Set by user.)"))
@@ -229,14 +226,24 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
if not has_rec(qname, rtype) and not has_rec(qname, "CNAME") and not has_rec(qname, "A"): if not has_rec(qname, rtype) and not has_rec(qname, "CNAME") and not has_rec(qname, "A"):
records.append((qname, rtype, value, explanation)) records.append((qname, rtype, value, explanation))
# SPF record: Permit the box ('mx', see above) to send mail on behalf of
# the domain, and no one else.
# Skip if the user has set a custom SPF record.
if not has_rec(None, "TXT", prefix="v=spf1 "):
records.append((None, "TXT", 'v=spf1 mx -all', "Recommended. Specifies that only the box is permitted to send @%s mail." % domain))
# Append the DKIM TXT record to the zone as generated by OpenDKIM. # Append the DKIM TXT record to the zone as generated by OpenDKIM.
# Skip if the user has set a DKIM record already.
opendkim_record_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.txt') opendkim_record_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.txt')
with open(opendkim_record_file) as orf: with open(opendkim_record_file) as orf:
m = re.match(r'(\S+)\s+IN\s+TXT\s+\( "([^"]+)"\s+"([^"]+)"\s*\)', orf.read(), re.S) m = re.match(r'(\S+)\s+IN\s+TXT\s+\( "([^"]+)"\s+"([^"]+)"\s*\)', orf.read(), re.S)
val = m.group(2) + m.group(3) val = m.group(2) + m.group(3)
if not has_rec(m.group(1), "TXT", prefix="v=DKIM1; "):
records.append((m.group(1), "TXT", val, "Recommended. Provides a way for recipients to verify that this machine sent @%s mail." % domain)) records.append((m.group(1), "TXT", val, "Recommended. Provides a way for recipients to verify that this machine sent @%s mail." % domain))
# Append a DMARC record. # Append a DMARC record.
# Skip if the user has set a DMARC record already.
if not has_rec("_dmarc", "TXT", prefix="v=DMARC1; "):
records.append(("_dmarc", "TXT", 'v=DMARC1; p=quarantine', "Optional. Specifies that mail that does not originate from the box but claims to be from @%s is suspect and should be quarantined by the recipient's mail system." % domain)) records.append(("_dmarc", "TXT", 'v=DMARC1; p=quarantine', "Optional. Specifies that mail that does not originate from the box but claims to be from @%s is suspect and should be quarantined by the recipient's mail system." % domain))
# For any subdomain with an A record but no SPF or DMARC record, add strict policy records. # For any subdomain with an A record but no SPF or DMARC record, add strict policy records.
@@ -691,7 +698,7 @@ def set_custom_dns_record(qname, rtype, value, env):
v = ipaddress.ip_address(value) v = ipaddress.ip_address(value)
if rtype == "A" and not isinstance(v, ipaddress.IPv4Address): raise ValueError("That's an IPv6 address.") if rtype == "A" and not isinstance(v, ipaddress.IPv4Address): raise ValueError("That's an IPv6 address.")
if rtype == "AAAA" and not isinstance(v, ipaddress.IPv6Address): raise ValueError("That's an IPv4 address.") if rtype == "AAAA" and not isinstance(v, ipaddress.IPv6Address): raise ValueError("That's an IPv4 address.")
elif rtype in ("CNAME", "TXT", "SRV"): elif rtype in ("CNAME", "TXT", "SRV", "MX"):
# anything goes # anything goes
pass pass
else: else:

View File

@@ -91,10 +91,6 @@ def get_mail_users_ex(env, with_archived=False, with_slow_info=False):
# email: "name@domain.tld", # email: "name@domain.tld",
# privileges: [ "priv1", "priv2", ... ], # privileges: [ "priv1", "priv2", ... ],
# status: "active", # status: "active",
# aliases: [
# ("alias@domain.tld", ["indirect.alias@domain.tld", ...]),
# ...
# ]
# }, # },
# ... # ...
# ] # ]
@@ -102,9 +98,6 @@ def get_mail_users_ex(env, with_archived=False, with_slow_info=False):
# ... # ...
# ] # ]
# Pre-load all aliases.
aliases = get_mail_alias_map(env)
# Get users and their privileges. # Get users and their privileges.
users = [] users = []
active_accounts = set() active_accounts = set()
@@ -121,10 +114,6 @@ def get_mail_users_ex(env, with_archived=False, with_slow_info=False):
users.append(user) users.append(user)
if with_slow_info: if with_slow_info:
user["aliases"] = [
(alias, sorted(evaluate_mail_alias_map(alias, aliases, env)))
for alias in aliases.get(email.lower(), [])
]
user["mailbox_size"] = utils.du(os.path.join(env['STORAGE_ROOT'], 'mail/mailboxes', *reversed(email.split("@")))) user["mailbox_size"] = utils.du(os.path.join(env['STORAGE_ROOT'], 'mail/mailboxes', *reversed(email.split("@"))))
# Add in archived accounts. # Add in archived accounts.
@@ -230,21 +219,6 @@ def get_mail_aliases_ex(env):
domain["aliases"].sort(key = lambda alias : (alias["required"], alias["source"])) domain["aliases"].sort(key = lambda alias : (alias["required"], alias["source"]))
return domains return domains
def get_mail_alias_map(env):
aliases = { }
for alias, targets in get_mail_aliases(env):
for em in targets.split(","):
em = em.strip().lower()
aliases.setdefault(em, []).append(alias)
return aliases
def evaluate_mail_alias_map(email, aliases, env):
ret = set()
for alias in aliases.get(email.lower(), []):
ret.add(alias)
ret |= evaluate_mail_alias_map(alias, aliases, env)
return ret
def get_domain(emailaddr): def get_domain(emailaddr):
return emailaddr.split('@', 1)[1] return emailaddr.split('@', 1)[1]
@@ -261,8 +235,10 @@ def add_mail_user(email, pw, privs, env):
# validate email # validate email
if email.strip() == "": if email.strip() == "":
return ("No email address provided.", 400) return ("No email address provided.", 400)
if not validate_email(email, mode='user'): elif not validate_email(email):
return ("Invalid email address.", 400) return ("Invalid email address.", 400)
elif not validate_email(email, mode='user'):
return ("User account email addresses may only use the ASCII letters A-Z, the digits 0-9, underscore (_), hyphen (-), and period (.).", 400)
validate_password(pw) validate_password(pw)
@@ -291,8 +267,10 @@ def add_mail_user(email, pw, privs, env):
# write databasebefore next step # write databasebefore next step
conn.commit() conn.commit()
# Create the user's INBOX, Spam, and Drafts folders, and subscribe them. # Create & subscribe the user's INBOX, Trash, Spam, and Drafts folders.
# K-9 mail will poll every 90 seconds if a Drafts folder does not exist, so create it # * Our sieve rule for spam expects that the Spam folder exists.
# * Roundcube will show an error if the user tries to delete a message before the Trash folder exists (#359).
# * K-9 mail will poll every 90 seconds if a Drafts folder does not exist, so create it
# to avoid unnecessary polling. # to avoid unnecessary polling.
# Check if the mailboxes exist before creating them. When creating a user that had previously # Check if the mailboxes exist before creating them. When creating a user that had previously
@@ -304,7 +282,7 @@ def add_mail_user(email, pw, privs, env):
conn.commit() conn.commit()
return ("Failed to initialize the user: " + e.output.decode("utf8"), 400) return ("Failed to initialize the user: " + e.output.decode("utf8"), 400)
for folder in ("INBOX", "Spam", "Drafts"): for folder in ("INBOX", "Trash", "Spam", "Drafts"):
if folder not in existing_mboxes: if folder not in existing_mboxes:
utils.shell('check_call', ["doveadm", "mailbox", "create", "-u", email, "-s", folder]) utils.shell('check_call', ["doveadm", "mailbox", "create", "-u", email, "-s", folder])

View File

@@ -6,7 +6,7 @@
__ALL__ = ['check_certificate'] __ALL__ = ['check_certificate']
import os, os.path, re, subprocess, datetime, multiprocessing.pool import sys, os, os.path, re, subprocess, datetime, multiprocessing.pool
import dns.reversename, dns.resolver import dns.reversename, dns.resolver
import dateutil.parser, dateutil.tz import dateutil.parser, dateutil.tz
@@ -17,7 +17,7 @@ from mailconfig import get_mail_domains, get_mail_aliases
from utils import shell, sort_domains, load_env_vars_from_file from utils import shell, sort_domains, load_env_vars_from_file
def run_checks(env, output, pool): def run_checks(rounded_values, env, output, pool):
# run systems checks # run systems checks
output.add_heading("System") output.add_heading("System")
@@ -33,12 +33,12 @@ def run_checks(env, output, pool):
# 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(env, output) run_system_checks(rounded_values, env, output)
# perform other checks asynchronously # perform other checks asynchronously
run_network_checks(env, output) run_network_checks(env, output)
run_domain_checks(env, output, pool) run_domain_checks(rounded_values, env, output, pool)
def get_ssh_port(): def get_ssh_port():
# Returns ssh port # Returns ssh port
@@ -64,7 +64,7 @@ def run_services_checks(env, output, pool):
{ "name": "OpenDKIM", "port": 8891, "public": False, }, { "name": "OpenDKIM", "port": 8891, "public": False, },
{ "name": "OpenDMARC", "port": 8893, "public": False, }, { "name": "OpenDMARC", "port": 8893, "public": False, },
{ "name": "Memcached", "port": 11211, "public": False, }, { "name": "Memcached", "port": 11211, "public": False, },
{ "name": "Sieve (dovecot)", "port": 4190, "public": True, }, { "name": "Sieve (dovecot)", "port": 4190, "public": False, },
{ "name": "Mail-in-a-Box Management Daemon", "port": 10222, "public": False, }, { "name": "Mail-in-a-Box Management Daemon", "port": 10222, "public": False, },
{ "name": "SSH Login (ssh)", "port": get_ssh_port(), "public": True, }, { "name": "SSH Login (ssh)", "port": get_ssh_port(), "public": True, },
@@ -133,11 +133,11 @@ def check_service(i, service, env):
return (i, running, fatal, output) return (i, running, fatal, output)
def run_system_checks(env, output): def run_system_checks(rounded_values, env, output):
check_ssh_password(env, output) check_ssh_password(env, output)
check_software_updates(env, output) check_software_updates(env, output)
check_system_aliases(env, output) check_system_aliases(env, output)
check_free_disk_space(env, output) check_free_disk_space(rounded_values, env, output)
def check_ssh_password(env, output): def check_ssh_password(env, output):
# Check that SSH login with password is disabled. The openssh-server # Check that SSH login with password is disabled. The openssh-server
@@ -172,12 +172,15 @@ def check_system_aliases(env, output):
# admin email is automatically directed. # admin email is automatically directed.
check_alias_exists("administrator@" + env['PRIMARY_HOSTNAME'], env, output) check_alias_exists("administrator@" + env['PRIMARY_HOSTNAME'], env, output)
def check_free_disk_space(env, output): def check_free_disk_space(rounded_values, env, output):
# Check free disk space. # Check free disk space.
st = os.statvfs(env['STORAGE_ROOT']) st = os.statvfs(env['STORAGE_ROOT'])
bytes_total = st.f_blocks * st.f_frsize bytes_total = st.f_blocks * st.f_frsize
bytes_free = st.f_bavail * st.f_frsize bytes_free = st.f_bavail * st.f_frsize
disk_msg = "The disk has %s GB space remaining." % str(round(bytes_free/1024.0/1024.0/1024.0*10.0)/10.0) if not rounded_values:
disk_msg = "The disk has %s GB space remaining." % str(round(bytes_free/1024.0/1024.0/1024.0*10.0)/10)
else:
disk_msg = "The disk has less than %s%% space left." % str(round(bytes_free/bytes_total/10 + .5)*10)
if bytes_free > .3 * bytes_total: if bytes_free > .3 * bytes_total:
output.print_ok(disk_msg) output.print_ok(disk_msg)
elif bytes_free > .15 * bytes_total: elif bytes_free > .15 * bytes_total:
@@ -215,7 +218,7 @@ def run_network_checks(env, output):
which may prevent recipients from receiving your email. See http://www.spamhaus.org/query/ip/%s.""" which may prevent recipients from receiving your email. See http://www.spamhaus.org/query/ip/%s."""
% (env['PUBLIC_IP'], zen, env['PUBLIC_IP'])) % (env['PUBLIC_IP'], zen, env['PUBLIC_IP']))
def run_domain_checks(env, output, pool): def run_domain_checks(rounded_time, env, output, pool):
# Get the list of domains we handle mail for. # Get the list of domains we handle mail for.
mail_domains = get_mail_domains(env) mail_domains = get_mail_domains(env)
@@ -230,17 +233,17 @@ def run_domain_checks(env, output, pool):
# Serial version: # Serial version:
#for domain in sort_domains(domains_to_check, env): #for domain in sort_domains(domains_to_check, env):
# run_domain_checks_on_domain(domain, env, dns_domains, dns_zonefiles, mail_domains, web_domains) # run_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains)
# Parallelize the checks across a worker pool. # Parallelize the checks across a worker pool.
args = ((domain, env, dns_domains, dns_zonefiles, mail_domains, web_domains) args = ((domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains)
for domain in domains_to_check) for domain in domains_to_check)
ret = pool.starmap(run_domain_checks_on_domain, args, chunksize=1) ret = pool.starmap(run_domain_checks_on_domain, args, chunksize=1)
ret = dict(ret) # (domain, output) => { domain: output } ret = dict(ret) # (domain, output) => { domain: output }
for domain in sort_domains(ret, env): for domain in sort_domains(ret, env):
ret[domain].playback(output) ret[domain].playback(output)
def run_domain_checks_on_domain(domain, env, dns_domains, dns_zonefiles, mail_domains, web_domains): def run_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains):
output = BufferedOutput() output = BufferedOutput()
output.add_heading(domain) output.add_heading(domain)
@@ -255,7 +258,7 @@ def run_domain_checks_on_domain(domain, env, dns_domains, dns_zonefiles, mail_do
check_mail_domain(domain, env, output) check_mail_domain(domain, env, output)
if domain in web_domains: if domain in web_domains:
check_web_domain(domain, env, output) check_web_domain(domain, rounded_time, env, output)
if domain in dns_domains: if domain in dns_domains:
check_dns_zone_suggestions(domain, env, output, dns_zonefiles) check_dns_zone_suggestions(domain, env, output, dns_zonefiles)
@@ -264,27 +267,38 @@ def run_domain_checks_on_domain(domain, env, dns_domains, dns_zonefiles, mail_do
def check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles): def check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles):
# If a DS record is set on the zone containing this domain, check DNSSEC now. # If a DS record is set on the zone containing this domain, check DNSSEC now.
has_dnssec = False
for zone in dns_domains: for zone in dns_domains:
if zone == domain or domain.endswith("." + zone): if zone == domain or domain.endswith("." + zone):
if query_dns(zone, "DS", nxdomain=None) is not None: if query_dns(zone, "DS", nxdomain=None) is not None:
has_dnssec = True
check_dnssec(zone, env, output, dns_zonefiles, is_checking_primary=True) check_dnssec(zone, env, output, dns_zonefiles, is_checking_primary=True)
ip = query_dns(domain, "A")
ns_ips = query_dns("ns1." + domain, "A") + '/' + query_dns("ns2." + domain, "A")
# Check that the ns1/ns2 hostnames resolve to A records. This information probably # Check that the ns1/ns2 hostnames resolve to A records. This information probably
# comes from the TLD since the information is set at the registrar as glue records. # comes from the TLD since the information is set at the registrar as glue records.
# We're probably not actually checking that here but instead checking that we, as # We're probably not actually checking that here but instead checking that we, as
# the nameserver, are reporting the right info --- but if the glue is incorrect this # the nameserver, are reporting the right info --- but if the glue is incorrect this
# will probably fail. # will probably fail.
ip = query_dns("ns1." + domain, "A") + '/' + query_dns("ns2." + domain, "A") if ns_ips == env['PUBLIC_IP'] + '/' + env['PUBLIC_IP']:
if ip == env['PUBLIC_IP'] + '/' + env['PUBLIC_IP']:
output.print_ok("Nameserver glue records are correct at registrar. [ns1/ns2.%s => %s]" % (env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'])) output.print_ok("Nameserver glue records are correct at registrar. [ns1/ns2.%s => %s]" % (env['PRIMARY_HOSTNAME'], env['PUBLIC_IP']))
elif ip == env['PUBLIC_IP']:
# The NS records are not what we expect, but the domain resolves correctly, so
# the user may have set up external DNS. List this discrepancy as a warning.
output.print_warning("""Nameserver glue records (ns1.%s and ns2.%s) should be configured at your domain name
registrar as having the IP address of this box (%s). They currently report addresses of %s. If you have set up External DNS, this may be OK."""
% (env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'], ns_ips))
else: else:
output.print_error("""Nameserver glue records are incorrect. The ns1.%s and ns2.%s nameservers must be configured at your domain name output.print_error("""Nameserver glue records are incorrect. The ns1.%s and ns2.%s nameservers must be configured at your domain name
registrar as having the IP address %s. They currently report addresses of %s. It may take several hours for registrar as having the IP address %s. They currently report addresses of %s. It may take several hours for
public DNS to update after a change.""" public DNS to update after a change."""
% (env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'], ip)) % (env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'], ns_ips))
# Check that PRIMARY_HOSTNAME resolves to PUBLIC_IP in public DNS. # Check that PRIMARY_HOSTNAME resolves to PUBLIC_IP in public DNS.
ip = query_dns(domain, "A")
if ip == env['PUBLIC_IP']: if ip == env['PUBLIC_IP']:
output.print_ok("Domain resolves to box's IP address. [%s => %s]" % (env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'])) output.print_ok("Domain resolves to box's IP address. [%s => %s]" % (env['PRIMARY_HOSTNAME'], env['PUBLIC_IP']))
else: else:
@@ -310,7 +324,10 @@ def check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles):
if tlsa25 == tlsa25_expected: if tlsa25 == tlsa25_expected:
output.print_ok("""The DANE TLSA record for incoming mail is correct (%s).""" % tlsa_qname,) output.print_ok("""The DANE TLSA record for incoming mail is correct (%s).""" % tlsa_qname,)
elif tlsa25 is None: elif tlsa25 is None:
output.print_error("""The DANE TLSA record for incoming mail is not set. This is optional.""") if has_dnssec:
# Omit a warning about it not being set if DNSSEC isn't enabled,
# since TLSA shouldn't be used without DNSSEC.
output.print_warning("""The DANE TLSA record for incoming mail is not set. This is optional.""")
else: else:
output.print_error("""The DANE TLSA record for incoming mail (%s) is not correct. It is '%s' but it should be '%s'. output.print_error("""The DANE TLSA record for incoming mail (%s) is not correct. It is '%s' but it should be '%s'.
It may take several hours for public DNS to update after a change.""" It may take several hours for public DNS to update after a change."""
@@ -338,6 +355,7 @@ def check_dns_zone(domain, env, output, dns_zonefiles):
# whois information -- we may be getting the NS records from us rather than # whois information -- we may be getting the NS records from us rather than
# the TLD, and so we're not actually checking the TLD. For that we'd need # 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")
custom_dns = get_custom_dns_config(env) custom_dns = get_custom_dns_config(env)
existing_ns = query_dns(domain, "NS") existing_ns = query_dns(domain, "NS")
correct_ns = "; ".join(sorted([ correct_ns = "; ".join(sorted([
@@ -346,6 +364,11 @@ def check_dns_zone(domain, env, output, dns_zonefiles):
])) ]))
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']:
# The domain resolves correctly, so maybe the user is using External DNS.
output.print_warning("""The nameservers set on this domain at your domain name registrar should be %s. They are currently %s.
If you are using External DNS, this may be OK."""
% (correct_ns, existing_ns) )
else: else:
output.print_error("""The nameservers set on this domain are incorrect. They are currently %s. Use your domain name registrar's output.print_error("""The nameservers set on this domain are incorrect. They are currently %s. Use your domain name registrar's
control panel to set the nameservers to %s.""" control panel to set the nameservers to %s."""
@@ -384,7 +407,7 @@ def check_dnssec(domain, env, output, dns_zonefiles, is_checking_primary=False):
else: else:
if ds == None: if ds == None:
if is_checking_primary: return if is_checking_primary: return
output.print_error("""This domain's DNSSEC DS record is not set. The DS record is optional. The DS record activates DNSSEC. output.print_warning("""This domain's DNSSEC DS record is not set. The DS record is optional. The DS record activates DNSSEC.
To set a DS record, you must follow the instructions provided by your domain name registrar and provide to them this information:""") To set a DS record, you must follow the instructions provided by your domain name registrar and provide to them this information:""")
else: else:
if is_checking_primary: if is_checking_primary:
@@ -472,7 +495,7 @@ def check_mail_domain(domain, env, output):
which may prevent recipients from receiving your mail. which may prevent recipients from receiving your mail.
See http://www.spamhaus.org/dbl/ and http://www.spamhaus.org/query/domain/%s.""" % (dbl, domain)) See http://www.spamhaus.org/dbl/ and http://www.spamhaus.org/query/domain/%s.""" % (dbl, domain))
def check_web_domain(domain, env, output): def check_web_domain(domain, rounded_time, env, output):
# See if the domain's A record resolves to our PUBLIC_IP. This is already checked # See if the domain's A record resolves to our PUBLIC_IP. This is already checked
# for PRIMARY_HOSTNAME, for which it is required for mail specifically. For it and # for PRIMARY_HOSTNAME, for which it is required for mail specifically. For it and
# other domains, it is required to access its website. # other domains, it is required to access its website.
@@ -488,7 +511,7 @@ def check_web_domain(domain, env, output):
# We need a SSL certificate for PRIMARY_HOSTNAME because that's where the # We need a SSL certificate for PRIMARY_HOSTNAME because that's where the
# user will log in with IMAP or webmail. Any other domain we serve a # user will log in with IMAP or webmail. Any other domain we serve a
# website for also needs a signed certificate. # website for also needs a signed certificate.
check_ssl_cert(domain, env, output) check_ssl_cert(domain, rounded_time, env, output)
def query_dns(qname, rtype, nxdomain='[Not Set]'): def query_dns(qname, rtype, nxdomain='[Not Set]'):
# Make the qname absolute by appending a period. Without this, dns.resolver.query # Make the qname absolute by appending a period. Without this, dns.resolver.query
@@ -515,7 +538,7 @@ def query_dns(qname, rtype, nxdomain='[Not Set]'):
# can compare to a well known order. # can compare to a well known order.
return "; ".join(sorted(str(r).rstrip('.') for r in response)) return "; ".join(sorted(str(r).rstrip('.') for r in response))
def check_ssl_cert(domain, env, output): def check_ssl_cert(domain, rounded_time, env, output):
# Check that SSL certificate is signed. # Check that SSL certificate is signed.
# Skip the check if the A record is not pointed here. # Skip the check if the A record is not pointed here.
@@ -530,7 +553,7 @@ def check_ssl_cert(domain, env, output):
# Check that the certificate is good. # Check that the certificate is good.
cert_status, cert_status_details = check_certificate(domain, ssl_certificate, ssl_key) cert_status, cert_status_details = check_certificate(domain, ssl_certificate, ssl_key, rounded_time=rounded_time)
if cert_status == "OK": if cert_status == "OK":
# The certificate is ok. The details has expiry info. # The certificate is ok. The details has expiry info.
@@ -569,7 +592,7 @@ def check_ssl_cert(domain, env, output):
output.print_line(cert_status_details) output.print_line(cert_status_details)
output.print_line("") output.print_line("")
def check_certificate(domain, ssl_certificate, ssl_private_key): def check_certificate(domain, ssl_certificate, ssl_private_key, rounded_time=False):
# Use openssl verify to check the status of a certificate. # Use openssl verify to check the status of a certificate.
# First check that the certificate is for the right domain. The domain # First check that the certificate is for the right domain. The domain
@@ -680,7 +703,15 @@ def check_certificate(domain, ssl_certificate, ssl_private_key):
# But is it expiring soon? # But is it expiring soon?
now = datetime.datetime.now(dateutil.tz.tzlocal()) now = datetime.datetime.now(dateutil.tz.tzlocal())
ndays = (cert_expiration_date-now).days ndays = (cert_expiration_date-now).days
if not rounded_time or ndays < 7:
expiry_info = "The certificate expires in %d days on %s." % (ndays, cert_expiration_date.strftime("%x")) expiry_info = "The certificate expires in %d days on %s." % (ndays, cert_expiration_date.strftime("%x"))
elif ndays <= 14:
expiry_info = "The certificate expires in less than two weeks, on %s." % cert_expiration_date.strftime("%x")
elif ndays <= 31:
expiry_info = "The certificate expires in less than a month, on %s." % cert_expiration_date.strftime("%x")
else:
expiry_info = "The certificate expires on %s." % cert_expiration_date.strftime("%x")
if ndays <= 31: if ndays <= 31:
return ("The certificate is expiring soon: " + expiry_info, None) return ("The certificate is expiring soon: " + expiry_info, None)
@@ -721,17 +752,109 @@ def list_apt_updates(apt_update=True):
return pkgs return pkgs
def run_and_output_changes(env, pool, send_via_email):
import json
from difflib import SequenceMatcher
class ConsoleOutput: if not send_via_email:
try: out = ConsoleOutput()
terminal_columns = int(shell('check_output', ['stty', 'size']).split()[1]) else:
except: import io
terminal_columns = 76 out = FileOutput(io.StringIO(""), 70)
# Run status checks.
cur = BufferedOutput()
run_checks(True, env, cur, pool)
# Load previously saved status checks.
cache_fn = "/var/cache/mailinabox/status_checks.json"
if os.path.exists(cache_fn):
prev = json.load(open(cache_fn))
# Group the serial output into categories by the headings.
def group_by_heading(lines):
from collections import OrderedDict
ret = OrderedDict()
k = []
ret["No Category"] = k
for line_type, line_args, line_kwargs in lines:
if line_type == "add_heading":
k = []
ret[line_args[0]] = k
else:
k.append((line_type, line_args, line_kwargs))
return ret
prev_status = group_by_heading(prev)
cur_status = group_by_heading(cur.buf)
# Compare the previous to the current status checks
# category by category.
for category, cur_lines in cur_status.items():
if category not in prev_status:
out.add_heading(category + " -- Added")
BufferedOutput(with_lines=cur_lines).playback(out)
else:
# Actual comparison starts here...
prev_lines = prev_status[category]
def stringify(lines):
return [json.dumps(line) for line in lines]
diff = SequenceMatcher(None, stringify(prev_lines), stringify(cur_lines)).get_opcodes()
for op, i1, i2, j1, j2 in diff:
if op == "replace":
out.add_heading(category + " -- Previously:")
elif op == "delete":
out.add_heading(category + " -- Removed")
if op in ("replace", "delete"):
BufferedOutput(with_lines=prev_lines[i1:i2]).playback(out)
if op == "replace":
out.add_heading(category + " -- Currently:")
elif op == "insert":
out.add_heading(category + " -- Added")
if op in ("replace", "insert"):
BufferedOutput(with_lines=cur_lines[j1:j2]).playback(out)
for category, prev_lines in prev_status.items():
if category not in cur_status:
out.add_heading(category)
out.print_warning("This section was removed.")
if send_via_email:
# If there were changes, send off an email.
buf = out.buf.getvalue()
if len(buf) > 0:
# create MIME message
from email.message import Message
msg = Message()
msg['From'] = "\"%s\" <administrator@%s>" % (env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'])
msg['To'] = "administrator@%s" % env['PRIMARY_HOSTNAME']
msg['Subject'] = "[%s] Status Checks Change Notice" % env['PRIMARY_HOSTNAME']
msg.set_payload(buf, "UTF-8")
# send to administrator@
import smtplib
mailserver = smtplib.SMTP('localhost', 25)
mailserver.ehlo()
mailserver.sendmail(
"administrator@%s" % env['PRIMARY_HOSTNAME'], # MAIL FROM
"administrator@%s" % env['PRIMARY_HOSTNAME'], # RCPT TO
msg.as_string())
mailserver.quit()
# Store the current status checks output for next time.
os.makedirs(os.path.dirname(cache_fn), exist_ok=True)
with open(cache_fn, "w") as f:
json.dump(cur.buf, f, indent=True)
class FileOutput:
def __init__(self, buf, width):
self.buf = buf
self.width = width
def add_heading(self, heading): def add_heading(self, heading):
print() print(file=self.buf)
print(heading) print(heading, file=self.buf)
print("=" * len(heading)) print("=" * len(heading), file=self.buf)
def print_ok(self, message): def print_ok(self, message):
self.print_block(message, first_line="") self.print_block(message, first_line="")
@@ -743,28 +866,36 @@ class ConsoleOutput:
self.print_block(message, first_line="? ") self.print_block(message, first_line="? ")
def print_block(self, message, first_line=" "): def print_block(self, message, first_line=" "):
print(first_line, end='') print(first_line, end='', file=self.buf)
message = re.sub("\n\s*", " ", message) message = re.sub("\n\s*", " ", message)
words = re.split("(\s+)", message) words = re.split("(\s+)", message)
linelen = 0 linelen = 0
for w in words: for w in words:
if linelen + len(w) > self.terminal_columns-1-len(first_line): if linelen + len(w) > self.width-1-len(first_line):
print() print(file=self.buf)
print(" ", end="") print(" ", end="", file=self.buf)
linelen = 0 linelen = 0
if linelen == 0 and w.strip() == "": continue if linelen == 0 and w.strip() == "": continue
print(w, end="") print(w, end="", file=self.buf)
linelen += len(w) linelen += len(w)
print() print(file=self.buf)
def print_line(self, message, monospace=False): def print_line(self, message, monospace=False):
for line in message.split("\n"): for line in message.split("\n"):
self.print_block(line) self.print_block(line)
class ConsoleOutput(FileOutput):
def __init__(self):
self.buf = sys.stdout
try:
self.width = int(shell('check_output', ['stty', 'size']).split()[1])
except:
self.width = 76
class BufferedOutput: class BufferedOutput:
# Record all of the instance method calls so we can play them back later. # Record all of the instance method calls so we can play them back later.
def __init__(self): def __init__(self, with_lines=None):
self.buf = [] self.buf = [] if not with_lines else with_lines
def __getattr__(self, attr): def __getattr__(self, attr):
if attr not in ("add_heading", "print_ok", "print_error", "print_warning", "print_block", "print_line"): if attr not in ("add_heading", "print_ok", "print_error", "print_warning", "print_block", "print_line"):
raise AttributeError raise AttributeError
@@ -778,12 +909,17 @@ class BufferedOutput:
if __name__ == "__main__": if __name__ == "__main__":
import sys
from utils import load_environment from utils import load_environment
env = load_environment() env = load_environment()
if len(sys.argv) == 1:
pool = multiprocessing.pool.Pool(processes=10) pool = multiprocessing.pool.Pool(processes=10)
run_checks(env, ConsoleOutput(), pool)
if len(sys.argv) == 1:
run_checks(False, env, ConsoleOutput(), pool)
elif sys.argv[1] == "--show-changes":
run_and_output_changes(env, pool, sys.argv[-1] == "--smtp")
elif sys.argv[1] == "--check-primary-hostname": elif sys.argv[1] == "--check-primary-hostname":
# See if the primary hostname appears resolvable and has a signed certificate. # See if the primary hostname appears resolvable and has a signed certificate.
domain = env['PRIMARY_HOSTNAME'] domain = env['PRIMARY_HOSTNAME']
@@ -796,5 +932,3 @@ if __name__ == "__main__":
if cert_status != "OK": if cert_status != "OK":
sys.exit(1) sys.exit(1)
sys.exit(0) sys.exit(0)

View File

@@ -35,6 +35,7 @@
<option value="AAAA" data-hint="Enter an IPv6 address.">AAAA (IPv6 address)</option> <option value="AAAA" data-hint="Enter an IPv6 address.">AAAA (IPv6 address)</option>
<option value="CNAME" data-hint="Enter another domain name followed by a period at the end (e.g. mypage.github.io.).">CNAME (DNS forwarding)</option> <option value="CNAME" data-hint="Enter another domain name followed by a period at the end (e.g. mypage.github.io.).">CNAME (DNS forwarding)</option>
<option value="TXT" data-hint="Enter arbitrary text.">TXT (text record)</option> <option value="TXT" data-hint="Enter arbitrary text.">TXT (text record)</option>
<option value="MX" data-hint="Enter record in the form of PRIORIY DOMAIN., including trailing period (e.g. 20 mx.example.com.).">MX (mail exchanger)</option>
</select> </select>
</div> </div>
</div> </div>

View File

@@ -329,6 +329,7 @@ function api(url, method, data, callback, callback_error) {
ajax({ ajax({
url: "/admin" + url, url: "/admin" + url,
method: method, method: method,
cache: false,
data: data, data: data,
beforeSend: function(xhr) { beforeSend: function(xhr) {
// We don't store user credentials in a cookie to avoid the hassle of CSRF // We don't store user credentials in a cookie to avoid the hassle of CSRF

View File

@@ -15,7 +15,7 @@
<h3>Current Backups</h3> <h3>Current Backups</h3>
<p>The backup directory currently contains the backups listed below. The total size on disk of the backups is <span id="backup-total-size"></span>.</p> <p>The backup directory currently contains the backups listed below. The total size on disk of the backups is currently <span id="backup-total-size"></span>.</p>
<table id="backup-status" class="table" style="width: auto"> <table id="backup-status" class="table" style="width: auto">
<thead> <thead>
@@ -76,7 +76,7 @@ function show_system_backup() {
if (b.deleted_in) if (b.deleted_in)
tr.append( $('<td/>').text(b.deleted_in) ); tr.append( $('<td/>').text(b.deleted_in) );
else else
tr.append( $('<td class="text-muted">n/a</td>') ); tr.append( $('<td class="text-muted">unknown</td>') );
$('#backup-status tbody').append(tr); $('#backup-status tbody').append(tr);
total_disk_size += b.size; total_disk_size += b.size;

View File

@@ -1,13 +1,12 @@
<h2>Users</h2> <h2>Users</h2>
<style> <style>
#user_table h4 { margin: 1em 0 0 0; }
#user_table tr.account_inactive td.address { color: #888; text-decoration: line-through; } #user_table tr.account_inactive td.address { color: #888; text-decoration: line-through; }
#user_table .aliases { font-size: 90%; }
#user_table .aliases div:before { content: "⇖ "; }
#user_table .aliases div { }
#user_table .actions { margin-top: .33em; font-size: 95%; } #user_table .actions { margin-top: .33em; font-size: 95%; }
#user_table .account_inactive .if_active { display: none; } #user_table .account_inactive .if_active { display: none; }
#user_table .account_active .if_inactive { display: none; } #user_table .account_active .if_inactive { display: none; }
#user_table .account_active.if_inactive { display: none; }
</style> </style>
<h3>Add a mail user</h3> <h3>Add a mail user</h3>
@@ -77,11 +76,9 @@
<td class='mailboxsize'> <td class='mailboxsize'>
</td> </td>
</tr> </tr>
<tr id="user-extra-template"> <tr id="user-extra-template" class="if_inactive">
<td colspan="3" style="border-top: 0; padding-top: 0"> <td colspan="3" style="border: 0; padding-top: 0">
<div class='if_inactive restore_info' style='color: #888; font-size: 90%'>To restore account, create a new account with this email address. Or to permanently delete the mailbox, delete the directory <tt></tt> on the machine.</div> <div class='restore_info' style='color: #888; font-size: 90%'>To restore account, create a new account with this email address. Or to permanently delete the mailbox, delete the directory <tt></tt> on the machine.</div>
<div class='aliases' style='display: none'> </div>
</td> </td>
</tr> </tr>
</table> </table>
@@ -98,7 +95,7 @@ function show_users() {
function(r) { function(r) {
$('#user_table tbody').html(""); $('#user_table tbody').html("");
for (var i = 0; i < r.length; i++) { for (var i = 0; i < r.length; i++) {
var hdr = $("<tr><td><h4/></td></tr>"); var hdr = $("<tr><td colspan='3'><h4/></td></tr>");
hdr.find('h4').text(r[i].domain); hdr.find('h4').text(r[i].domain);
$('#user_table tbody').append(hdr); $('#user_table tbody').append(hdr);
@@ -137,16 +134,6 @@ function show_users() {
p.find('span.name').text(add_privs[j]); p.find('span.name').text(add_privs[j]);
n.find('.add-privs').append(p); n.find('.add-privs').append(p);
} }
if (user.aliases && user.aliases.length > 0) {
n2.find('.aliases').show();
for (var j = 0; j < user.aliases.length; j++) {
n2.find('.aliases').append($("<div/>").text(
user.aliases[j][0]
+ (user.aliases[j][1].length > 0 ? " ⇐ " + user.aliases[j][1].join(", ") : "")
))
}
}
} }
} }
}) })

View File

@@ -7,7 +7,7 @@
######################################################### #########################################################
if [ -z "$TAG" ]; then if [ -z "$TAG" ]; then
TAG=v0.07 TAG=v0.08
fi fi
# Are we running as root? # Are we running as root?

View File

@@ -60,6 +60,10 @@ tools/editconf.py /etc/opendmarc.conf -s \
# #
# Be careful. If we add other milters later, this needs to be concatenated # Be careful. If we add other milters later, this needs to be concatenated
# on the smtpd_milters line. # on the smtpd_milters line.
#
# The OpenDMARC milter is skipped in the SMTP submission listener by
# configuring smtpd_milters there to only list the OpenDKIM milter
# (see mail-postfix.sh).
tools/editconf.py /etc/postfix/main.cf \ tools/editconf.py /etc/postfix/main.cf \
"smtpd_milters=inet:127.0.0.1:8891 inet:127.0.0.1:8893"\ "smtpd_milters=inet:127.0.0.1:8891 inet:127.0.0.1:8893"\
non_smtpd_milters=\$smtpd_milters \ non_smtpd_milters=\$smtpd_milters \

View File

@@ -179,3 +179,21 @@ function input_menu {
result=$(dialog --stdout --title "$1" --menu "$2" 0 0 0 $3) result=$(dialog --stdout --title "$1" --menu "$2" 0 0 0 $3)
result_code=$? result_code=$?
} }
function git_clone {
# Clones a git repository, checks out a particular commit or tag,
# and moves the repository (or a subdirectory in it) to some path.
# We use separate clone and checkout because -b only supports tags
# and branches, but we sometimes want to reference a commit hash
# directly when the repo doesn't provide a tag.
REPO=$1
TREEISH=$2
SUBDIR=$3
TARGETPATH=$4
TMPPATH=/tmp/git-clone-$$
rm -rf $TMPPATH $TARGETPATH
git clone -q $REPO $TMPPATH || exit 1
(cd $TMPPATH; git checkout -q $TREEISH;) || exit 1
mv $TMPPATH/$SUBDIR $TARGETPATH
rm -rf $TMPPATH
}

View File

@@ -62,6 +62,9 @@ tools/editconf.py /etc/postfix/main.cf \
# Enable the 'submission' port 587 smtpd server and tweak its settings. # Enable the 'submission' port 587 smtpd server and tweak its settings.
# #
# * Do not add the OpenDMAC Authentication-Results header. That should only be added
# on incoming mail. Omit the OpenDMARC milter by re-setting smtpd_milters to the
# OpenDKIM milter only. See dkim.sh.
# * Require the best ciphers for incoming connections per http://baldric.net/2013/12/07/tls-ciphers-in-postfix-and-dovecot/. # * Require the best ciphers for incoming connections per http://baldric.net/2013/12/07/tls-ciphers-in-postfix-and-dovecot/.
# By putting this setting here we leave opportunistic TLS on incoming mail at default cipher settings (any cipher is better than none). # By putting this setting here we leave opportunistic TLS on incoming mail at default cipher settings (any cipher is better than none).
# * Give it a different name in syslog to distinguish it from the port 25 smtpd server. # * Give it a different name in syslog to distinguish it from the port 25 smtpd server.
@@ -71,6 +74,7 @@ tools/editconf.py /etc/postfix/main.cf \
tools/editconf.py /etc/postfix/master.cf -s -w \ tools/editconf.py /etc/postfix/master.cf -s -w \
"submission=inet n - - - - smtpd "submission=inet n - - - - smtpd
-o syslog_name=postfix/submission -o syslog_name=postfix/submission
-o smtpd_milters=inet:127.0.0.1:8891
-o smtpd_tls_ciphers=high -o smtpd_tls_protocols=!SSLv2,!SSLv3 -o smtpd_tls_ciphers=high -o smtpd_tls_protocols=!SSLv2,!SSLv3
-o cleanup_service_name=authclean" \ -o cleanup_service_name=authclean" \
"authclean=unix n - - - 0 cleanup "authclean=unix n - - - 0 cleanup

View File

@@ -30,6 +30,17 @@ $(pwd)/management/backup.py
EOF EOF
chmod +x /etc/cron.daily/mailinabox-backup chmod +x /etc/cron.daily/mailinabox-backup
# Perform daily status checks. Compare each day to the previous
# for changes and mail the changes to the administrator.
cat > /etc/cron.daily/mailinabox-statuschecks << EOF;
#!/bin/bash
# Mail-in-a-Box --- Do not edit / will be overwritten on update.
# Run status checks.
$(pwd)/management/status_checks.py --show-changes --smtp
EOF
chmod +x /etc/cron.daily/mailinabox-statuschecks
# Start it. Remove the api key file first so that start.sh # Start it. Remove the api key file first so that start.sh
# can wait for it to be created to know that the management # can wait for it to be created to know that the management
# server is ready. # server is ready.

View File

@@ -84,13 +84,22 @@ def run_migrations():
env = load_environment() env = load_environment()
migration_id_file = os.path.join(env['STORAGE_ROOT'], 'mailinabox.version') migration_id_file = os.path.join(env['STORAGE_ROOT'], 'mailinabox.version')
migration_id = None
if os.path.exists(migration_id_file): if os.path.exists(migration_id_file):
with open(migration_id_file) as f: with open(migration_id_file) as f:
ourver = int(f.read().strip()) migration_id = f.read().strip();
else:
if migration_id is None:
# Load the legacy location of the migration ID. We'll drop support # Load the legacy location of the migration ID. We'll drop support
# for this eventually. # for this eventually.
ourver = int(env.get("MIGRATIONID", "0")) migration_id = env.get("MIGRATIONID")
if migration_id is None:
print()
print("%s file doesn't exists. Skipping migration..." % (migration_id_file,))
return
ourver = int(migration_id)
while True: while True:
next_ver = (ourver + 1) next_ver = (ourver + 1)

View File

@@ -15,18 +15,49 @@ apt_install \
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=7.0.4 owncloud_ver=8.0.2
# Check if ownCloud dir exist, and check if version matches owncloud_ver (if either doesn't - install/upgrade) # Check if ownCloud dir exist, and check if version matches owncloud_ver (if either doesn't - install/upgrade)
if [ ! -d /usr/local/lib/owncloud/ ] \ if [ ! -d /usr/local/lib/owncloud/ ] \
|| ! grep -q $owncloud_ver /usr/local/lib/owncloud/version.php; then || ! grep -q $owncloud_ver /usr/local/lib/owncloud/version.php; then
# Clear out the existing ownCloud.
rm -f /tmp/owncloud-config.php
if [ ! -d /usr/local/lib/owncloud/ ]; then
echo installing ownCloud... echo installing ownCloud...
else
echo "upgrading ownCloud to $owncloud_ver (backing up existing ownCloud directory to /tmp/owncloud-backup-$$)..."
cp /usr/local/lib/owncloud/config/config.php /tmp/owncloud-config.php
mv /usr/local/lib/owncloud /tmp/owncloud-backup-$$
fi
# Download and extract ownCloud.
rm -f /tmp/owncloud.zip rm -f /tmp/owncloud.zip
wget -qO /tmp/owncloud.zip https://download.owncloud.org/community/owncloud-$owncloud_ver.zip wget -qO /tmp/owncloud.zip https://download.owncloud.org/community/owncloud-$owncloud_ver.zip
unzip -u -o -q /tmp/owncloud.zip -d /usr/local/lib #either extracts new or replaces current files unzip -u -o -q /tmp/owncloud.zip -d /usr/local/lib #either extracts new or replaces current files
hide_output php /usr/local/lib/owncloud/occ upgrade #if OC is up-to-date it wont matter
rm -f /tmp/owncloud.zip rm -f /tmp/owncloud.zip
# The two apps we actually want are not in ownCloud core. Clone them from
# their github repositories.
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/calendar v$owncloud_ver '' /usr/local/lib/owncloud/apps/calendar
# Fix weird permissions.
chmod 750 /usr/local/lib/owncloud/{apps,config}
# Restore configuration file if we're doing an upgrade.
if [ -f /tmp/owncloud-config.php ]; then
mv /tmp/owncloud-config.php /usr/local/lib/owncloud/config/config.php
fi
# Make sure permissions are correct or the upgrade step won't run.
# $STORAGE_ROOT/owncloud may not yet exist, so use -f to suppress
# that error.
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).
hide_output sudo -u www-data php /usr/local/lib/owncloud/occ upgrade
fi fi
# ### Configuring ownCloud # ### Configuring ownCloud
@@ -105,9 +136,12 @@ fi
# 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. # user_external is what allows ownCloud to use IMAP for login. The contacts
hide_output php /usr/local/lib/owncloud/console.php app:disable firstrunwizard # and calendar apps are the extensions we really care about here.
hide_output php /usr/local/lib/owncloud/console.php app:enable user_external hide_output sudo -u www-data php /usr/local/lib/owncloud/console.php app:disable firstrunwizard
hide_output sudo -u www-data php /usr/local/lib/owncloud/console.php app:enable user_external
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
# 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)

View File

@@ -77,7 +77,7 @@ fi
if [ "$PRIVATE_IPV6" != "$PUBLIC_IPV6" ]; then if [ "$PRIVATE_IPV6" != "$PUBLIC_IPV6" ]; then
echo "Private IPv6 Address: $PRIVATE_IPV6" echo "Private IPv6 Address: $PRIVATE_IPV6"
fi fi
if [ -f /usr/bin/git ]; then if [ -f .git ]; then
echo "Mail-in-a-Box Version: " $(git describe) echo "Mail-in-a-Box Version: " $(git describe)
fi fi
echo echo
@@ -109,6 +109,10 @@ fi
# Create the STORAGE_ROOT if it not exists # Create the STORAGE_ROOT if it not exists
if [ ! -d $STORAGE_ROOT ]; then if [ ! -d $STORAGE_ROOT ]; then
mkdir -p $STORAGE_ROOT mkdir -p $STORAGE_ROOT
fi
# Create mailinabox.version file if not exists
if [ ! -f $STORAGE_ROOT/mailinabox.version ]; then
echo $(setup/migrate.py --current) > $STORAGE_ROOT/mailinabox.version echo $(setup/migrate.py --current) > $STORAGE_ROOT/mailinabox.version
chown $STORAGE_USER.$STORAGE_USER $STORAGE_ROOT/mailinabox.version chown $STORAGE_USER.$STORAGE_USER $STORAGE_ROOT/mailinabox.version
fi fi

View File

@@ -20,12 +20,13 @@ apt_get_quiet upgrade
# * cron: Runs background processes periodically. # * cron: Runs background processes periodically.
# * ntp: keeps the system time correct # * ntp: keeps the system time correct
# * fail2ban: scans log files for repeated failed login attempts and blocks the remote IP at the firewall # * fail2ban: scans log files for repeated failed login attempts and blocks the remote IP at the firewall
# * 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
# * bc: allows us to do math to compute sane defaults # * bc: allows us to do math to compute sane defaults
apt_install python3 python3-dev python3-pip \ apt_install python3 python3-dev python3-pip \
wget curl sudo coreutils bc \ wget curl git sudo coreutils bc \
haveged unattended-upgrades cron ntp fail2ban haveged unattended-upgrades cron ntp fail2ban
# Allow apt to install system updates automatically every day. # Allow apt to install system updates automatically every day.
@@ -106,3 +107,11 @@ fi
restart_service bind9 restart_service bind9
restart_service resolvconf restart_service resolvconf
# ### Fail2Ban Service
# Configure the Fail2Ban installation to prevent dumb bruce-force attacks against dovecot, postfix and ssh
cp conf/fail2ban/jail.local /etc/fail2ban/jail.local
cp conf/fail2ban/dovecotimap.conf /etc/fail2ban/filter.d/dovecotimap.conf
restart_service fail2ban

View File

@@ -30,24 +30,33 @@ apt_install \
apt-get purge -qq -y roundcube* #NODOC apt-get purge -qq -y roundcube* #NODOC
# Install Roundcube from source if it is not already present or if it is out of date. # Install Roundcube from source if it is not already present or if it is out of date.
# Combine the Roundcube version number with the commit hash of vacation_sieve to track
# whether we have the latest version.
VERSION=1.1.0 VERSION=1.1.0
VACATION_SIEVE_VERSION=06a20e9d44db62259ae41fd8451f3c937d3ab4f3
needs_update=0 #NODOC needs_update=0 #NODOC
if [ ! -f /usr/local/lib/roundcubemail/version ]; then if [ ! -f /usr/local/lib/roundcubemail/version ]; then
# not installed yet #NODOC # not installed yet #NODOC
needs_update=1 #NODOC needs_update=1 #NODOC
elif [[ $VERSION != `cat /usr/local/lib/roundcubemail/version` ]]; then elif [[ "$VERSION:$VACATION_SIEVE_VERSION" != `cat /usr/local/lib/roundcubemail/version` ]]; then
# checks if the version is what we want # checks if the version is what we want
needs_update=1 #NODOC needs_update=1 #NODOC
fi fi
if [ $needs_update == 1 ]; then if [ $needs_update == 1 ]; then
echo installing roundcube webmail $VERSION... # install roundcube
echo installing Roundcube webmail $VERSION...
rm -f /tmp/roundcube.tgz rm -f /tmp/roundcube.tgz
wget -qO /tmp/roundcube.tgz http://downloads.sourceforge.net/project/roundcubemail/roundcubemail/$VERSION/roundcubemail-$VERSION.tar.gz wget -qO /tmp/roundcube.tgz http://downloads.sourceforge.net/project/roundcubemail/roundcubemail/$VERSION/roundcubemail-$VERSION.tar.gz
tar -C /usr/local/lib -zxf /tmp/roundcube.tgz tar -C /usr/local/lib -zxf /tmp/roundcube.tgz
rm -rf /usr/local/lib/roundcubemail rm -rf /usr/local/lib/roundcubemail
mv /usr/local/lib/roundcubemail-$VERSION/ /usr/local/lib/roundcubemail mv /usr/local/lib/roundcubemail-$VERSION/ /usr/local/lib/roundcubemail
rm -f /tmp/roundcube.tgz rm -f /tmp/roundcube.tgz
echo $VERSION > /usr/local/lib/roundcubemail/version
# install roundcube autoreply/vacation plugin
git_clone https://github.com/arodier/Roundcube-Plugins.git $VACATION_SIEVE_VERSION plugins/vacation_sieve /usr/local/lib/roundcubemail/plugins/vacation_sieve
# record the version we've installed
echo $VERSION:$VACATION_SIEVE_VERSION > /usr/local/lib/roundcubemail/version
fi fi
# ### Configuring Roundcube # ### Configuring Roundcube
@@ -79,7 +88,7 @@ cat > /usr/local/lib/roundcubemail/config/config.inc.php <<EOF;
\$config['support_url'] = 'https://mailinabox.email/'; \$config['support_url'] = 'https://mailinabox.email/';
\$config['product_name'] = 'Mail-in-a-Box/Roundcube Webmail'; \$config['product_name'] = 'Mail-in-a-Box/Roundcube Webmail';
\$config['des_key'] = '$SECRET_KEY'; \$config['des_key'] = '$SECRET_KEY';
\$config['plugins'] = array('archive', 'zipdownload', 'password', 'managesieve'); \$config['plugins'] = array('archive', 'zipdownload', 'password', 'managesieve', 'jqueryui', 'vacation_sieve');
\$config['skin'] = 'classic'; \$config['skin'] = 'classic';
\$config['login_autocomplete'] = 2; \$config['login_autocomplete'] = 2;
\$config['password_charset'] = 'UTF-8'; \$config['password_charset'] = 'UTF-8';
@@ -87,6 +96,26 @@ cat > /usr/local/lib/roundcubemail/config/config.inc.php <<EOF;
?> ?>
EOF EOF
# Configure vaction_sieve.
cat > /usr/local/lib/roundcubemail/plugins/vacation_sieve/config.inc.php <<EOF;
<?php
/* Do not edit. Written by Mail-in-a-Box. Regenerated on updates. */
\$rcmail_config['vacation_sieve'] = array(
'date_format' => 'd/m/Y',
'working_hours' => array(8,18),
'msg_format' => 'text',
'logon_transform' => array('#([a-z])[a-z]+(\.|\s)([a-z])#i', '\$1\$3'),
'transfer' => array(
'mode' => 'managesieve',
'ms_activate_script' => true,
'host' => 'localhost',
'port' => '4190',
'usetls' => false,
'path' => 'vacation',
)
);
EOF
# Create writable directories. # Create writable directories.
mkdir -p /var/log/roundcubemail /tmp/roundcubemail $STORAGE_ROOT/mail/roundcube mkdir -p /var/log/roundcubemail /tmp/roundcubemail $STORAGE_ROOT/mail/roundcube
chown -R www-data.www-data /var/log/roundcubemail /tmp/roundcubemail $STORAGE_ROOT/mail/roundcube chown -R www-data.www-data /var/log/roundcubemail /tmp/roundcubemail $STORAGE_ROOT/mail/roundcube

View File

@@ -30,17 +30,11 @@ 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
rm -rf /usr/local/lib/z-push
rm -f /tmp/zpush-repo
echo installing z-push \(fmbiete fork\)... echo installing z-push \(fmbiete fork\)...
git clone -q https://github.com/fmbiete/Z-Push-contrib /tmp/zpush-repo git_clone https://github.com/fmbiete/Z-Push-contrib $TARGETHASH '' /usr/local/lib/z-push
(cd /tmp/zpush-repo/; git checkout -q $TARGETHASH;)
rm -rf /tmp/zpush-repo/.git
mv /tmp/zpush-repo /usr/local/lib/z-push
rm -f /usr/sbin/z-push-{admin,top} 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
ln -s /usr/local/lib/z-push/z-push-top.php /usr/sbin/z-push-top ln -s /usr/local/lib/z-push/z-push-top.php /usr/sbin/z-push-top
rm -f /tmp/zpush-repo
echo $TARGETHASH > /usr/local/lib/z-push/version echo $TARGETHASH > /usr/local/lib/z-push/version
fi fi

View File

@@ -28,12 +28,16 @@ def mgmt(cmd, data=None, is_json=False):
return resp return resp
def read_password(): def read_password():
while True:
first = getpass.getpass('password: ') first = getpass.getpass('password: ')
if len(first) < 4:
print('Passwords must be at least four characters.')
continue
second = getpass.getpass(' (again): ') second = getpass.getpass(' (again): ')
while first != second: if first != second:
print('Passwords not the same. Try again.') print('Passwords not the same. Try again.')
first = getpass.getpass('password: ') continue
second = getpass.getpass(' (again): ') break
return first return first
def setup_key_auth(mgmt_uri): def setup_key_auth(mgmt_uri):