Merge remote-tracking branch 'up/master'

This commit is contained in:
David Duque 2020-06-21 15:52:31 +01:00
commit 022a11e159
18 changed files with 278 additions and 55 deletions

View file

@ -4,6 +4,29 @@ CHANGELOG
In Development In Development
-------------- --------------
Mail:
* An MTA-STS policy for incoming mail is now published (in DNS and over HTTPS) when the primary hostname and email address domain both have a signed TLS certificate installed.
* MTA-STS reporting is enabled with reports sent to administrator@ the primary hostname.
DNS:
* autoconfig and autodiscover subdomains and CalDAV/CardDAV SRV records are no longer generated for domains that don't have user accounts since they are unnecessary.
v0.46 (June 11, 2020)
---------------------
Security fixes:
* Roundcube is updated to version 1.4.6 (https://roundcube.net/news/2020/06/02/security-updates-1.4.5-and-1.3.12).
v0.45 (May 16, 2020)
--------------------
Security fixes:
* Fix missing brute force login protection for Roundcube logins.
Software updates: Software updates:
* Upgraded Roundcube from 1.4.2 to 1.4.4. * Upgraded Roundcube from 1.4.2 to 1.4.4.

View file

@ -80,15 +80,20 @@ It is a one-click email appliance. There are no user-configurable setup options.
The components installed are: The components installed are:
* SMTP ([postfix](http://www.postfix.org/)), IMAP ([dovecot](http://dovecot.org/)), CardDAV/CalDAV ([Nextcloud](https://nextcloud.com/)), Exchange ActiveSync ([z-push](http://z-push.org/)) * SMTP ([postfix](http://www.postfix.org/)), IMAP ([dovecot](http://dovecot.org/)), CardDAV/CalDAV ([Nextcloud](https://nextcloud.com/)), and Exchange ActiveSync ([z-push](http://z-push.org/)) servers
* Webmail ([Roundcube](http://roundcube.net/)), static website hosting ([nginx](http://nginx.org/)) * Webmail ([Roundcube](http://roundcube.net/)), mail filter rules (also using dovecot), and email client autoconfig settings (served by [nginx](http://nginx.org/))
* Spam filtering ([spamassassin](https://spamassassin.apache.org/)), greylisting ([postgrey](http://postgrey.schweikert.ch/)) * Spam filtering ([spamassassin](https://spamassassin.apache.org/)) and greylisting ([postgrey](http://postgrey.schweikert.ch/))
* DNS ([nsd4](https://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](https://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), [MTA-STS](https://tools.ietf.org/html/rfc8461), and [SSHFP](https://tools.ietf.org/html/rfc4255) policy records automatically set
* Backups ([duplicity](http://duplicity.nongnu.org/)), firewall ([ufw](https://launchpad.net/ufw)), intrusion protection ([fail2ban](http://www.fail2ban.org/wiki/index.php/Main_Page)), system monitoring ([munin](http://munin-monitoring.org/)) * HTTPS TLS certificates are automatically provisioned using [Let's Encrypt](https://letsencrypt.org/) (needed for webmail, CardDAV/CalDAV, ActiveSync, MTA-STS policy, etc.).
* Backups ([duplicity](http://duplicity.nongnu.org/)), firewall ([ufw](https://launchpad.net/ufw)), intrusion protection ([fail2ban](http://www.fail2ban.org/wiki/index.php/Main_Page)), and basic system monitoring ([munin](http://munin-monitoring.org/))
It also includes: It also includes system management tools:
* A control panel and API for adding/removing mail users, aliases, custom DNS records, etc. and detailed system monitoring. * Comprehensive health monitoring that checks each day that services are running, ports are open, TLS certificates are valid, and DNS records are correct
* A control panel for adding/removing mail users, aliases, custom DNS records, configuring backups, etc.
* An API for all of the actions on the control panel
It also supports static website hosting since the box is serving HTTPS anyway.
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).
@ -110,7 +115,7 @@ by him:
$ curl -s https://keybase.io/joshdata/key.asc | gpg --import $ curl -s https://keybase.io/joshdata/key.asc | gpg --import
gpg: key C10BDD81: public key "Joshua Tauberer <jt@occams.info>" imported gpg: key C10BDD81: public key "Joshua Tauberer <jt@occams.info>" imported
$ git verify-tag v0.44 $ git verify-tag v0.46
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!
@ -123,7 +128,7 @@ and on his [personal homepage](https://razor.occams.info/). (Of course, if this
Checkout the tag corresponding to the most recent release: Checkout the tag corresponding to the most recent release:
$ git checkout v0.44 $ git checkout v0.46
Begin the installation. Begin the installation.

4
conf/mta-sts.txt Normal file
View file

@ -0,0 +1,4 @@
version: STSv1
mode: MODE
mx: PRIMARY_HOSTNAME
max_age: 86400

View file

@ -21,6 +21,9 @@
location = /mail/config-v1.1.xml { location = /mail/config-v1.1.xml {
alias /var/lib/mailinabox/mozilla-autoconfig.xml; alias /var/lib/mailinabox/mozilla-autoconfig.xml;
} }
location = /.well-known/mta-sts.txt {
alias /var/lib/mailinabox/mta-sts.txt;
}
# Roundcube Webmail configuration. # Roundcube Webmail configuration.
rewrite ^/mail$ /mail/ redirect; rewrite ^/mail$ /mail/ redirect;

View file

@ -16,10 +16,10 @@ if [ `date "+%u"` -eq 1 ]; then
fi fi
# Take a backup. # Take a backup.
management/backup.py | management/email_administrator.py "Backup Status" management/backup.py 2>&1 | management/email_administrator.py "Backup Status"
# Provision any new certificates for new domains or domains with expiring certificates. # Provision any new certificates for new domains or domains with expiring certificates.
management/ssl_certificates.py -q | management/email_administrator.py "TLS Certificate Provisioning Result" management/ssl_certificates.py -q 2>&1 | management/email_administrator.py "TLS Certificate Provisioning Result"
# Run status checks and email the administrator if anything changed. # Run status checks and email the administrator if anything changed.
management/status_checks.py --show-changes | management/email_administrator.py "Status Checks Change Notice" management/status_checks.py --show-changes 2>&1 | management/email_administrator.py "Status Checks Change Notice"

View file

@ -9,8 +9,9 @@ import ipaddress
import rtyaml import rtyaml
import dns.resolver import dns.resolver
from mailconfig import get_mail_domains from mailconfig import get_mail_domains, get_mail_aliases
from utils import shell, load_env_vars_from_file, safe_domain_name, sort_domains from utils import shell, load_env_vars_from_file, safe_domain_name, sort_domains
from ssl_certificates import get_ssl_certificates, check_certificate
# From https://stackoverflow.com/questions/3026957/how-to-validate-a-domain-name-using-regex-php/16491074#16491074 # From https://stackoverflow.com/questions/3026957/how-to-validate-a-domain-name-using-regex-php/16491074#16491074
# This regular expression matches domain names according to RFCs, it also accepts fqdn with an leading dot, # This regular expression matches domain names according to RFCs, it also accepts fqdn with an leading dot,
@ -280,25 +281,85 @@ def build_zone(domain, all_domains, additional_records, www_redirect_domains, en
if not has_rec(dmarc_qname, "TXT", prefix="v=DMARC1; "): if not has_rec(dmarc_qname, "TXT", prefix="v=DMARC1; "):
records.append((dmarc_qname, "TXT", 'v=DMARC1; p=reject', "Recommended. Prevents use of this domain name for outbound mail by specifying that the SPF rule should be honoured for mail from @%s." % (qname + "." + domain))) records.append((dmarc_qname, "TXT", 'v=DMARC1; p=reject', "Recommended. Prevents use of this domain name for outbound mail by specifying that the SPF rule should be honoured for mail from @%s." % (qname + "." + domain)))
# Add CardDAV/CalDAV SRV records on the non-primary hostname that points to the primary hostname. # Add CardDAV/CalDAV SRV records on the non-primary hostname that points to the primary hostname
# for autoconfiguration of mail clients (so only domains hosting user accounts need it).
# The SRV record format is priority (0, whatever), weight (0, whatever), port, service provider hostname (w/ trailing dot). # The SRV record format is priority (0, whatever), weight (0, whatever), port, service provider hostname (w/ trailing dot).
if domain != env["PRIMARY_HOSTNAME"]: if domain != env["PRIMARY_HOSTNAME"] and domain in get_mail_domains(env, users_only=True):
for dav in ("card", "cal"): for dav in ("card", "cal"):
qname = "_" + dav + "davs._tcp" qname = "_" + dav + "davs._tcp"
if not has_rec(qname, "SRV"): if not has_rec(qname, "SRV"):
records.append((qname, "SRV", "0 0 443 " + env["PRIMARY_HOSTNAME"] + ".", "Recommended. Specifies the hostname of the server that handles CardDAV/CalDAV services for email addresses on this domain.")) records.append((qname, "SRV", "0 0 443 " + env["PRIMARY_HOSTNAME"] + ".", "Recommended. Specifies the hostname of the server that handles CardDAV/CalDAV services for email addresses on this domain."))
# Adds autoconfiguration A records for all domains. # Adds autoconfiguration A records for all domains that there are user accounts at.
# This allows the following clients to automatically configure email addresses in the respective applications. # This allows the following clients to automatically configure email addresses in the respective applications.
# autodiscover.* - Z-Push ActiveSync Autodiscover # autodiscover.* - Z-Push ActiveSync Autodiscover
# autoconfig.* - Thunderbird Autoconfig # autoconfig.* - Thunderbird Autoconfig
autodiscover_records = [ if domain in get_mail_domains(env, users_only=True):
("autodiscover", "A", env["PUBLIC_IP"], "Provides email configuration autodiscovery support for Z-Push ActiveSync Autodiscover."), autodiscover_records = [
("autodiscover", "AAAA", env["PUBLIC_IPV6"], "Provides email configuration autodiscovery support for Z-Push ActiveSync Autodiscover."), ("autodiscover", "A", env["PUBLIC_IP"], "Provides email configuration autodiscovery support for Z-Push ActiveSync Autodiscover."),
("autoconfig", "A", env["PUBLIC_IP"], "Provides email configuration autodiscovery support for Thunderbird Autoconfig."), ("autodiscover", "AAAA", env["PUBLIC_IPV6"], "Provides email configuration autodiscovery support for Z-Push ActiveSync Autodiscover."),
("autoconfig", "AAAA", env["PUBLIC_IPV6"], "Provides email configuration autodiscovery support for Thunderbird Autoconfig.") ("autoconfig", "A", env["PUBLIC_IP"], "Provides email configuration autodiscovery support for Thunderbird Autoconfig."),
("autoconfig", "AAAA", env["PUBLIC_IPV6"], "Provides email configuration autodiscovery support for Thunderbird Autoconfig.")
]
for qname, rtype, value, explanation in autodiscover_records:
if value is None or value.strip() == "": continue # skip IPV6 if not set
if not has_rec(qname, rtype):
records.append((qname, rtype, value, explanation))
# If this is a domain name that there are email addresses configured for, i.e. "something@"
# this domain name, then the domain name is a MTA-STS (https://tools.ietf.org/html/rfc8461)
# Policy Domain.
#
# A "_mta-sts" TXT record signals the presence of a MTA-STS policy. The id field helps clients
# cache the policy. It should be stable so we don't update DNS unnecessarily but change when
# the policy changes. It must be at most 32 letters and numbers, so we compute a hash of the
# policy file.
#
# The policy itself is served at the "mta-sts" (no underscore) subdomain over HTTPS. Therefore
# the TLS certificate used by Postfix for STARTTLS must be a valid certificate for the MX
# domain name (PRIMARY_HOSTNAME) *and* the TLS certificate used by nginx for HTTPS on the mta-sts
# subdomain must be valid certificate for that domain. Do not set an MTA-STS policy if either
# certificate in use is not valid (e.g. because it is self-signed and a valid certificate has not
# yet been provisioned). Since we cannot provision a certificate without A/AAAA records, we
# always set them --- only the TXT records depend on there being valid certificates.
mta_sts_enabled = False
mta_sts_records = [
("mta-sts", "A", env["PUBLIC_IP"], "Optional. MTA-STS Policy Host serving /.well-known/mta-sts.txt."),
("mta-sts", "AAAA", env.get('PUBLIC_IPV6'), "Optional. MTA-STS Policy Host serving /.well-known/mta-sts.txt."),
] ]
for qname, rtype, value, explanation in autodiscover_records: if domain in get_mail_domains(env):
# Check that PRIMARY_HOSTNAME and the mta_sts domain both have valid certificates.
for d in (env['PRIMARY_HOSTNAME'], "mta-sts." + domain):
cert = get_ssl_certificates(env).get(d)
if not cert:
break # no certificate provisioned for this domain
cert_status = check_certificate(d, cert['certificate'], cert['private-key'])
if cert_status[0] != 'OK':
break # certificate is not valid
else:
# 'break' was not encountered above, so both domains are good
mta_sts_enabled = True
if mta_sts_enabled:
# Compute an up-to-32-character hash of the policy file. We'll take a SHA-1 hash of the policy
# file (20 bytes) and encode it as base-64 (28 bytes, using alphanumeric alternate characters
# instead of '+' and '/' which are not allowed in an MTA-STS policy id) but then just take its
# first 20 characters, which is more than sufficient to change whenever the policy file changes
# (and ensures any '=' padding at the end of the base64 encoding is dropped).
with open("/var/lib/mailinabox/mta-sts.txt", "rb") as f:
mta_sts_policy_id = base64.b64encode(hashlib.sha1(f.read()).digest(), altchars=b"AA").decode("ascii")[0:20]
mta_sts_records.extend([
("_mta-sts", "TXT", "v=STSv1; id=" + mta_sts_policy_id, "Optional. Part of the MTA-STS policy for incoming mail. If set, a MTA-STS policy must also be published.")
])
# Rules can be custom configured accoring to https://tools.ietf.org/html/rfc8460.
# Skip if the rules below if the user has set a custom _smtp._tls record.
if not has_rec("_smtp._tls", "TXT", prefix="v=TLSRPTv1;"):
tls_rpt_string = ""
tls_rpt_email = env.get("MTA_STS_TLSRPT_EMAIL", "postmaster@%s" % env['PRIMARY_HOSTNAME'])
if tls_rpt_email: # if a reporting address is not cleared
tls_rpt_string = " rua=mailto:%s" % tls_rpt_email
mta_sts_records.append(("_smtp._tls", "TXT", "v=TLSRPTv1;%s" % tls_rpt_string, "Optional. Enables MTA-STS reporting."))
for qname, rtype, value, explanation in mta_sts_records:
if value is None or value.strip() == "": continue # skip IPV6 if not set if value is None or value.strip() == "": continue # skip IPV6 if not set
if not has_rec(qname, rtype): if not has_rec(qname, rtype):
records.append((qname, rtype, value, explanation)) records.append((qname, rtype, value, explanation))

View file

@ -258,13 +258,15 @@ def get_domain(emailaddr, as_unicode=True):
pass pass
return ret return ret
def get_mail_domains(env, filter_aliases=lambda alias : True): def get_mail_domains(env, filter_aliases=lambda alias : True, users_only=False):
# 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. If users_only is True, only return domains
return set( # with email addresses that correspond to user accounts.
[get_domain(login, as_unicode=False) for login in get_mail_users(env)] domains = []
+ [get_domain(address, as_unicode=False) for address, *_ in get_mail_aliases(env) if filter_aliases(address) ] domains.extend([get_domain(login, as_unicode=False) for login in get_mail_users(env)])
) if not users_only:
domains.extend([get_domain(address, as_unicode=False) for address, *_ in get_mail_aliases(env) if filter_aliases(address) ])
return set(domains)
def add_mail_user(email, pw, privs, env): def add_mail_user(email, pw, privs, env):
# validate email # validate email

View file

@ -180,7 +180,7 @@ def get_certificates_to_provision(env, limit_domains=None, show_valid_certs=True
# for and subtract: # for and subtract:
# * domains not in limit_domains if limit_domains is not empty # * domains not in limit_domains if limit_domains is not empty
# * domains with custom "A" records, i.e. they are hosted elsewhere # * domains with custom "A" records, i.e. they are hosted elsewhere
# * domains with actual "A" records that point elsewhere # * domains with actual "A" records that point elsewhere (misconfiguration)
# * domains that already have certificates that will be valid for a while # * domains that already have certificates that will be valid for a while
from web_update import get_web_domains from web_update import get_web_domains
@ -256,15 +256,41 @@ def provision_certificates(env, limit_domains):
"result": "skipped", "result": "skipped",
}) })
# Break into groups by DNS zone: Group every domain with its parent domain, if
# its parent domain is in the list of domains to request a certificate for.
# Start with the zones so that if the zone doesn't need a certificate itself,
# its children will still be grouped together. Sort the provision domains to
# put parents ahead of children.
# Since Let's Encrypt requests are limited to 100 domains at a time,
# we'll create a list of lists of domains where the inner lists have
# at most 100 items. By sorting we also get the DNS zone domain as the first
# entry in each list (unless we overflow beyond 100) which ends up as the
# primary domain listed in each certificate.
from dns_update import get_dns_zones
certs = { }
for zone, zonefile in get_dns_zones(env):
certs[zone] = [[]]
for domain in sort_domains(domains, env):
# Does the domain end with any domain we've seen so far.
for parent in certs.keys():
if domain.endswith("." + parent):
# Add this to the parent's list of domains.
# Start a new group if the list already has
# 100 items.
if len(certs[parent][-1]) == 100:
certs[parent].append([])
certs[parent][-1].append(domain)
break
else:
# This domain is not a child of any domain we've seen yet, so
# start a new group. This shouldn't happen since every zone
# was already added.
certs[domain] = [[domain]]
# Break into groups of up to 100 certificates at a time, which is Let's Encrypt's # Flatten to a list of lists of domains (from a mapping). Remove empty
# limit for a single certificate. We'll sort to put related domains together. # lists (zones with no domains that need certs).
max_domains_per_group = 100 certs = sum(certs.values(), [])
domains = sort_domains(domains, env) certs = [_ for _ in certs if len(_) > 0]
certs = []
while len(domains) > 0:
certs.append( domains[:max_domains_per_group] )
domains = domains[max_domains_per_group:]
# Prepare to provision. # Prepare to provision.

View file

@ -5,11 +5,13 @@
# what to do next. # what to do next.
import sys, os, os.path, re, subprocess, datetime, multiprocessing.pool import sys, os, os.path, re, subprocess, datetime, multiprocessing.pool
import asyncio
import dns.reversename, dns.resolver import dns.reversename, dns.resolver
import dateutil.parser, dateutil.tz import dateutil.parser, dateutil.tz
import idna import idna
import psutil import psutil
import postfix_mta_sts_resolver.resolver
from dns_update import get_dns_zones, build_tlsa_record, get_custom_dns_config, get_secondary_dns, get_custom_dns_records from dns_update import get_dns_zones, build_tlsa_record, get_custom_dns_config, get_secondary_dns, get_custom_dns_records
from web_update import get_web_domains, get_domains_with_a_records from web_update import get_web_domains, get_domains_with_a_records
@ -322,6 +324,17 @@ def run_domain_checks(rounded_time, env, output, pool):
domains_to_check = mail_domains | dns_domains | web_domains domains_to_check = mail_domains | dns_domains | web_domains
# Remove "www", "autoconfig", "autodiscover", and "mta-sts" subdomains, which we group with their parent,
# if their parent is in the domains to check list.
domains_to_check = [
d for d in domains_to_check
if not (
d.split(".", 1)[0] in ("www", "autoconfig", "autodiscover", "mta-sts")
and len(d.split(".", 1)) == 2
and d.split(".", 1)[1] in domains_to_check
)
]
# Get the list of domains that we don't serve web for because of a custom CNAME/A record. # Get the list of domains that we don't serve web for because of a custom CNAME/A record.
domains_with_a_records = get_domains_with_a_records(env) domains_with_a_records = get_domains_with_a_records(env)
@ -340,6 +353,11 @@ def run_domain_checks(rounded_time, env, output, pool):
def run_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains, domains_with_a_records): def run_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains, domains_with_a_records):
output = BufferedOutput() output = BufferedOutput()
# When running inside Flask, the worker threads don't get a thread pool automatically.
# Also this method is called in a forked worker pool, so creating a new loop is probably
# a good idea.
asyncio.set_event_loop(asyncio.new_event_loop())
# we'd move this up, but this returns non-pickleable values # we'd move this up, but this returns non-pickleable values
ssl_certificates = get_ssl_certificates(env) ssl_certificates = get_ssl_certificates(env)
@ -367,6 +385,26 @@ def run_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zone
if domain in dns_domains: if domain in dns_domains:
check_dns_zone_suggestions(domain, env, output, dns_zonefiles, domains_with_a_records) check_dns_zone_suggestions(domain, env, output, dns_zonefiles, domains_with_a_records)
# Check auto-configured subdomains. See run_domain_checks.
# Skip mta-sts because we check the policy directly.
for label in ("www", "autoconfig", "autodiscover"):
subdomain = label + "." + domain
if subdomain in web_domains or subdomain in mail_domains:
# Run checks.
subdomain_output = run_domain_checks_on_domain(subdomain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains, domains_with_a_records)
# Prepend the domain name to the start of each check line, and then add to the
# checks for this domain.
for attr, args, kwargs in subdomain_output[1].buf:
if attr == "add_heading":
# Drop the heading, but use its text as the subdomain name in
# each line since it is in Unicode form.
subdomain = args[0]
continue
if len(args) == 1 and isinstance(args[0], str):
args = [ subdomain + ": " + args[0] ]
getattr(output, attr)(*args, **kwargs)
return (domain, output) return (domain, output)
def check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles): def check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles):
@ -624,6 +662,19 @@ def check_mail_domain(domain, env, output):
if mx != recommended_mx: if mx != recommended_mx:
good_news += " This configuration is non-standard. The recommended configuration is '%s'." % (recommended_mx,) good_news += " This configuration is non-standard. The recommended configuration is '%s'." % (recommended_mx,)
output.print_ok(good_news) output.print_ok(good_news)
# Check MTA-STS policy.
loop = asyncio.get_event_loop()
sts_resolver = postfix_mta_sts_resolver.resolver.STSResolver(loop=loop)
valid, policy = loop.run_until_complete(sts_resolver.resolve(domain))
if valid == postfix_mta_sts_resolver.resolver.STSFetchResult.VALID:
if policy[1].get("mx") == [env['PRIMARY_HOSTNAME']] and policy[1].get("mode") == "enforce": # policy[0] is the policyid
output.print_ok("MTA-STS policy is present.")
else:
output.print_error("MTA-STS policy is present but has unexpected settings. [{}]".format(policy[1]))
else:
output.print_error("MTA-STS policy is missing: {}".format(valid))
else: else:
output.print_error("""This domain's DNS MX record is incorrect. It is currently set to '%s' but should be '%s'. Mail will not output.print_error("""This domain's DNS MX record is incorrect. It is currently set to '%s' but should be '%s'. Mail will not
be delivered to this box. It may take several hours for public DNS to update after a change. This problem may result from be delivered to this box. It may take several hours for public DNS to update after a change. This problem may result from

View file

@ -24,11 +24,14 @@ def get_web_domains(env, include_www_redirects=True, exclude_dns_elsewhere=True)
# the topmost of each domain we serve. # the topmost of each domain we serve.
domains |= set('www.' + zone for zone, zonefile in get_dns_zones(env)) domains |= set('www.' + zone for zone, zonefile in get_dns_zones(env))
# Add Autoconfiguration domains, allowing us to serve correct SSL certs. # Add Autoconfiguration domains for domains that there are user accounts at:
# 'autoconfig.' for Mozilla Thunderbird auto setup. # 'autoconfig.' for Mozilla Thunderbird auto setup.
# 'autodiscover.' for Activesync autodiscovery. # 'autodiscover.' for Activesync autodiscovery.
domains |= set('autoconfig.' + maildomain for maildomain in get_mail_domains(env)) domains |= set('autoconfig.' + maildomain for maildomain in get_mail_domains(env, users_only=True))
domains |= set('autodiscover.' + maildomain for maildomain in get_mail_domains(env)) domains |= set('autodiscover.' + maildomain for maildomain in get_mail_domains(env, users_only=True))
# 'mta-sts.' for MTA-STS support for all domains that have email addresses.
domains |= set('mta-sts.' + maildomain for maildomain in get_mail_domains(env))
if exclude_dns_elsewhere: if exclude_dns_elsewhere:
# ...Unless the domain has an A/AAAA record that maps it to a different # ...Unless the domain has an A/AAAA record that maps it to a different
@ -173,9 +176,23 @@ def make_domain_config(domain, templates, ssl_certificates, env):
# any proxy or redirect here? # any proxy or redirect here?
for path, url in yaml.get("proxies", {}).items(): for path, url in yaml.get("proxies", {}).items():
# Parse some flags in the fragment of the URL.
pass_http_host_header = False
m = re.search("#(.*)$", url)
if m:
for flag in m.group(1).split(","):
if flag == "pass-http-host":
pass_http_host_header = True
url = re.sub("#(.*)$", "", url)
nginx_conf_extra += "\tlocation %s {" % path nginx_conf_extra += "\tlocation %s {" % path
nginx_conf_extra += "\n\t\tproxy_pass %s;" % url nginx_conf_extra += "\n\t\tproxy_pass %s;" % url
if pass_http_host_header:
nginx_conf_extra += "\n\t\tproxy_set_header Host $http_host;"
nginx_conf_extra += "\n\t\tproxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;" nginx_conf_extra += "\n\t\tproxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;"
nginx_conf_extra += "\n\t\tproxy_set_header X-Forwarded-Host $http_host;"
nginx_conf_extra += "\n\t\tproxy_set_header X-Forwarded-Proto $scheme;"
nginx_conf_extra += "\n\t\tproxy_set_header X-Real-IP $remote_addr;"
nginx_conf_extra += "\n\t}\n" nginx_conf_extra += "\n\t}\n"
for path, alias in yaml.get("aliases", {}).items(): for path, alias in yaml.get("aliases", {}).items():
nginx_conf_extra += "\tlocation %s {" % path nginx_conf_extra += "\tlocation %s {" % path

View file

@ -101,9 +101,13 @@ The box restricts the envelope sender address (also called the return path or MA
Incoming Mail Incoming Mail
------------- -------------
### Encryption ### Encryption Settings
As discussed above, there is no way to require on-the-wire encryption of mail. When the box receives an incoming email (SMTP on port 25), it offers encryption (STARTTLS) but cannot require that senders use it because some senders may not support STARTTLS at all and other senders may support STARTTLS but not with the latest protocols/ciphers. To give senders the best chance at making use of encryption, the box offers protocols back to TLSv1 and ciphers with key lengths as low as 112 bits. Modern clients (senders) will make use of the 256-bit ciphers and Diffie-Hellman ciphers with a 2048-bit key for perfect forward secrecy, however. ([source](setup/mail-postfix.sh)) As with outbound email, there is no way to require on-the-wire encryption of incoming mail from all senders. When the box receives an incoming email (SMTP on port 25), it offers encryption (STARTTLS) but cannot require that senders use it because some senders may not support STARTTLS at all and other senders may support STARTTLS but not with the latest protocols/ciphers. To give senders the best chance at making use of encryption, the box offers protocols back to TLSv1 and ciphers with key lengths as low as 112 bits. Modern clients (senders) will make use of the 256-bit ciphers and Diffie-Hellman ciphers with a 2048-bit key for perfect forward secrecy, however. ([source](setup/mail-postfix.sh))
### MTA-STS
The box publishes a SMTP MTA Strict Transport Security ([SMTP MTA-STS](https://en.wikipedia.org/wiki/Simple_Mail_Transfer_Protocol#SMTP_MTA_Strict_Transport_Security)) policy (via DNS and HTTPS) in "enforce" mode. Senders that support MTA-STS will use a secure SMTP connection. (MTA-STS tells senders to connect and expect a signed TLS certificate for the "MX" domain without permitting a fallback to an unencrypted connection.)
### DANE ### DANE

View file

@ -136,7 +136,14 @@ function get_default_privateip {
function ufw_allow { function ufw_allow {
if [ -z "${DISABLE_FIREWALL:-}" ]; then if [ -z "${DISABLE_FIREWALL:-}" ]; then
# ufw has completely unhelpful output # ufw has completely unhelpful output
ufw allow $1 > /dev/null; ufw allow "$1" > /dev/null;
fi
}
function ufw_limit {
if [ -z "${DISABLE_FIREWALL:-}" ]; then
# ufw has completely unhelpful output
ufw limit "$1" > /dev/null;
fi fi
} }

View file

@ -50,7 +50,7 @@ hide_output $venv/bin/pip install --upgrade pip
hide_output $venv/bin/pip install --upgrade \ hide_output $venv/bin/pip install --upgrade \
rtyaml "email_validator>=1.0.0" "exclusiveprocess" \ rtyaml "email_validator>=1.0.0" "exclusiveprocess" \
flask dnspython python-dateutil \ flask dnspython python-dateutil \
"idna>=2.0.0" "cryptography==2.2.2" boto psutil "idna>=2.0.0" "cryptography==2.2.2" boto psutil postfix-mta-sts-resolver
# CONFIGURATION # CONFIGURATION

View file

@ -82,9 +82,10 @@ if [ ! -f $STORAGE_ROOT/mailinabox.version ]; then
chown $STORAGE_USER.$STORAGE_USER $STORAGE_ROOT/mailinabox.version chown $STORAGE_USER.$STORAGE_USER $STORAGE_ROOT/mailinabox.version
fi fi
# Save the global options in /etc/mailinabox.conf so that standalone # Save the global options in /etc/mailinabox.conf so that standalone
# tools know where to look for data. # tools know where to look for data. The default MTA_STS_MODE setting
# is blank unless set by an environment variable, but see web.sh for
# how that is interpreted.
cat > /etc/mailinabox.conf << EOF; cat > /etc/mailinabox.conf << EOF;
STORAGE_USER=$STORAGE_USER STORAGE_USER=$STORAGE_USER
STORAGE_ROOT=$STORAGE_ROOT STORAGE_ROOT=$STORAGE_ROOT
@ -93,6 +94,7 @@ PUBLIC_IP=$PUBLIC_IP
PUBLIC_IPV6=$PUBLIC_IPV6 PUBLIC_IPV6=$PUBLIC_IPV6
PRIVATE_IP=$PRIVATE_IP PRIVATE_IP=$PRIVATE_IP
PRIVATE_IPV6=$PRIVATE_IPV6 PRIVATE_IPV6=$PRIVATE_IPV6
MTA_STS_MODE=${MTA_STS_MODE-}
EOF EOF
# Start service configuration. # Start service configuration.

View file

@ -240,7 +240,7 @@ if [ -z "${DISABLE_FIREWALL:-}" ]; then
apt_install ufw apt_install ufw
# Allow incoming connections to SSH. # Allow incoming connections to SSH.
ufw_allow ssh; ufw_limit ssh;
# ssh might be running on an alternate port. Use sshd -T to dump sshd's #NODOC # ssh might be running on an alternate port. Use sshd -T to dump sshd's #NODOC
# settings, find the port it is supposedly running on, and open that port #NODOC # settings, find the port it is supposedly running on, and open that port #NODOC
@ -250,7 +250,7 @@ if [ -z "${DISABLE_FIREWALL:-}" ]; then
if [ "$SSH_PORT" != "22" ]; then if [ "$SSH_PORT" != "22" ]; then
echo Opening alternate SSH port $SSH_PORT. #NODOC echo Opening alternate SSH port $SSH_PORT. #NODOC
ufw_allow $SSH_PORT #NODOC ufw_limit $SSH_PORT #NODOC
fi fi
fi fi

View file

@ -19,7 +19,7 @@ fi
echo "Installing Nginx (web server)..." echo "Installing Nginx (web server)..."
apt_install nginx php-cli php-fpm apt_install nginx php-cli php-fpm idn2
rm -f /etc/nginx/sites-enabled/default rm -f /etc/nginx/sites-enabled/default
@ -122,6 +122,21 @@ cat conf/mozilla-autoconfig.xml \
> /var/lib/mailinabox/mozilla-autoconfig.xml > /var/lib/mailinabox/mozilla-autoconfig.xml
chmod a+r /var/lib/mailinabox/mozilla-autoconfig.xml chmod a+r /var/lib/mailinabox/mozilla-autoconfig.xml
# Create a generic mta-sts.txt file which is exposed via the
# nginx configuration at /.well-known/mta-sts.txt
# more documentation is available on:
# https://www.uriports.com/blog/mta-sts-explained/
# default mode is "enforce". Change to "testing" which means
# "Messages will be delivered as though there was no failure
# but a report will be sent if TLS-RPT is configured" if you
# are not sure you want this yet. Or "none".
PUNY_PRIMARY_HOSTNAME=$(echo "$PRIMARY_HOSTNAME" | idn2)
cat conf/mta-sts.txt \
| sed "s/MODE/${MTA_STS_MODE:-enforce}/" \
| sed "s/PRIMARY_HOSTNAME/$PUNY_PRIMARY_HOSTNAME/" \
> /var/lib/mailinabox/mta-sts.txt
chmod a+r /var/lib/mailinabox/mta-sts.txt
# make a default homepage # make a default homepage
if [ -d $STORAGE_ROOT/www/static ]; then mv $STORAGE_ROOT/www/static $STORAGE_ROOT/www/default; fi # migration #NODOC if [ -d $STORAGE_ROOT/www/static ]; then mv $STORAGE_ROOT/www/static $STORAGE_ROOT/www/default; fi # migration #NODOC
mkdir -p $STORAGE_ROOT/www/default mkdir -p $STORAGE_ROOT/www/default
@ -137,4 +152,3 @@ restart_service php$PHP_VERSION-fpm
# Open ports. # Open ports.
ufw_allow http ufw_allow http
ufw_allow https ufw_allow https

View file

@ -28,8 +28,8 @@ apt_install \
# 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 plugins to track # Combine the Roundcube version number with the commit hash of plugins to track
# whether we have the latest version of everything. # whether we have the latest version of everything.
VERSION=1.4.4 VERSION=1.4.6
HASH=4e425263f5bec27d39c07bde524f421bda205c07 HASH=44961ef62bb9c9875141ca34704bbc7d6f36373d
PERSISTENT_LOGIN_VERSION=6b3fc450cae23ccb2f393d0ef67aa319e877e435 PERSISTENT_LOGIN_VERSION=6b3fc450cae23ccb2f393d0ef67aa319e877e435
HTML5_NOTIFIER_VERSION=4b370e3cd60dabd2f428a26f45b677ad1b7118d5 HTML5_NOTIFIER_VERSION=4b370e3cd60dabd2f428a26f45b677ad1b7118d5
CARDDAV_VERSION=3.0.3 CARDDAV_VERSION=3.0.3
@ -160,7 +160,7 @@ mkdir -p /var/log/roundcubemail /var/tmp/roundcubemail $STORAGE_ROOT/mail/roundc
chown -R www-data.www-data /var/log/roundcubemail /var/tmp/roundcubemail $STORAGE_ROOT/mail/roundcube chown -R www-data.www-data /var/log/roundcubemail /var/tmp/roundcubemail $STORAGE_ROOT/mail/roundcube
# Ensure the log file monitored by fail2ban exists, or else fail2ban can't start. # Ensure the log file monitored by fail2ban exists, or else fail2ban can't start.
sudo -u www-data touch /var/log/roundcubemail/errors sudo -u www-data touch /var/log/roundcubemail/errors.log
# Password changing plugin settings # Password changing plugin settings
# The config comes empty by default, so we need the settings # The config comes empty by default, so we need the settings

View file

@ -58,7 +58,7 @@ def generate_documentation():
} }
.prose { .prose {
padding-top: 1em; padding-top: 1em;
padding-bottom: 1em; padding-bottom: 1em;
} }
.terminal { .terminal {
@ -261,6 +261,10 @@ class UfwAllow(Grammar):
grammar = (ZERO_OR_MORE(SPACE), L("ufw_allow "), REST_OF_LINE, EOL) grammar = (ZERO_OR_MORE(SPACE), L("ufw_allow "), REST_OF_LINE, EOL)
def value(self): def value(self):
return shell_line("ufw allow " + self[2].string) return shell_line("ufw allow " + self[2].string)
class UfwLimit(Grammar):
grammar = (ZERO_OR_MORE(SPACE), L("ufw_limit "), REST_OF_LINE, EOL)
def value(self):
return shell_line("ufw limit " + self[2].string)
class RestartService(Grammar): class RestartService(Grammar):
grammar = (ZERO_OR_MORE(SPACE), L("restart_service "), REST_OF_LINE, EOL) grammar = (ZERO_OR_MORE(SPACE), L("restart_service "), REST_OF_LINE, EOL)
def value(self): def value(self):
@ -275,7 +279,7 @@ class OtherLine(Grammar):
return "<pre class='shell'><div>" + recode_bash(self.string.strip()) + "</div></pre>\n" return "<pre class='shell'><div>" + recode_bash(self.string.strip()) + "</div></pre>\n"
class BashElement(Grammar): class BashElement(Grammar):
grammar = Comment | CatEOF | EchoPipe | EchoLine | HideOutput | EditConf | SedReplace | AptGet | UfwAllow | RestartService | OtherLine grammar = Comment | CatEOF | EchoPipe | EchoLine | HideOutput | EditConf | SedReplace | AptGet | UfwAllow | UfwLimit | RestartService | OtherLine
def value(self): def value(self):
return self[0].value() return self[0].value()