diff --git a/README.md b/README.md index ec9cdb65..fa4bc933 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,8 @@ The components installed are: * Webmail ([Roundcube](http://roundcube.net/)), static website hosting ([nginx](http://nginx.org/)) * Spam filtering ([spamassassin](https://spamassassin.apache.org/)), greylisting ([postgrey](http://postgrey.schweikert.ch/)) * DNS ([nsd4](http://www.nlnetlabs.nl/projects/nsd/)) with [SPF](https://en.wikipedia.org/wiki/Sender_Policy_Framework), DKIM ([OpenDKIM](http://www.opendkim.org/)), [DMARC](https://en.wikipedia.org/wiki/DMARC), [DNSSEC](https://en.wikipedia.org/wiki/DNSSEC), [DANE TLSA](https://en.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities), and [SSHFP](https://tools.ietf.org/html/rfc4255) records automatically set -* Firewall ([ufw](https://launchpad.net/ufw)), intrusion protection ([fail2ban](http://www.fail2ban.org/wiki/index.php/Main_Page)) -* A control panel and API for adding/removing mail users, aliases, custom DNS records, etc. and system monitoring. +* 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/)) +* A control panel and API for adding/removing mail users, aliases, custom DNS records, etc. and detailed system monitoring. For more information on how Mail-in-a-Box handles your privacy, see the [security details page](security.md). diff --git a/conf/nginx-alldomains.conf b/conf/nginx-alldomains.conf new file mode 100644 index 00000000..896e9b00 --- /dev/null +++ b/conf/nginx-alldomains.conf @@ -0,0 +1,79 @@ + # Expose this directory as static files. + root $ROOT; + index index.html index.htm; + + location = /robots.txt { + log_not_found off; + access_log off; + } + + location = /favicon.ico { + log_not_found off; + access_log off; + } + + location = /mailinabox.mobileconfig { + alias /var/lib/mailinabox/mobileconfig.xml; + } + location = /.well-known/autoconfig/mail/config-v1.1.xml { + alias /var/lib/mailinabox/mozilla-autoconfig.xml; + } + + # Roundcube Webmail configuration. + rewrite ^/mail$ /mail/ redirect; + rewrite ^/mail/$ /mail/index.php; + location /mail/ { + index index.php; + alias /usr/local/lib/roundcubemail/; + } + location ~ /mail/config/.* { + # A ~-style location is needed to give this precedence over the next block. + return 403; + } + location ~ /mail/.*\.php { + # note: ~ has precendence over a regular location block + include fastcgi_params; + fastcgi_split_path_info ^/mail(/.*)()$; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME /usr/local/lib/roundcubemail/$fastcgi_script_name; + fastcgi_pass php-fpm; + + # Outgoing mail also goes through this endpoint, so increase the maximum + # file upload limit to match the corresponding Postfix limit. + client_max_body_size 128M; + } + + # Z-Push (Microsoft Exchange ActiveSync) + location /Microsoft-Server-ActiveSync { + include /etc/nginx/fastcgi_params; + fastcgi_param SCRIPT_FILENAME /usr/local/lib/z-push/index.php; + fastcgi_param PHP_VALUE "include_path=.:/usr/share/php:/usr/share/pear:/usr/share/awl/inc"; + fastcgi_read_timeout 630; + fastcgi_pass php-fpm; + + # Outgoing mail also goes through this endpoint, so increase the maximum + # file upload limit to match the corresponding Postfix limit. + client_max_body_size 128M; + } + location /autodiscover/autodiscover.xml { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME /usr/local/lib/z-push/autodiscover/autodiscover.php; + fastcgi_param PHP_VALUE "include_path=.:/usr/share/php:/usr/share/pear:/usr/share/awl/inc"; + fastcgi_pass php-fpm; + } + + + # ADDITIONAL DIRECTIVES HERE + + # Disable viewing dotfiles (.htaccess, .svn, .git, etc.) + # This block is placed at the end. Nginx's precedence rules means this block + # takes precedence over all non-regex matches and only regex matches that + # come after it (i.e. none of those, since this is the last one.) That means + # we're blocking dotfiles in the static hosted sites but not the FastCGI- + # handled locations for ownCloud (which serves user-uploaded files that might + # have this pattern, see #414) or some of the other services. + location ~ /\.(ht|svn|git|hg|bzr) { + log_not_found off; + access_log off; + deny all; + } diff --git a/conf/nginx-primaryonly.conf b/conf/nginx-primaryonly.conf index 2ecd0716..3826211c 100644 --- a/conf/nginx-primaryonly.conf +++ b/conf/nginx-primaryonly.conf @@ -2,6 +2,7 @@ # Proxy /admin to our Python based control panel daemon. It is # listening on IPv4 only so use an IP address and not 'localhost'. rewrite ^/admin$ /admin/; + rewrite ^/admin/munin$ /admin/munin redirect; location /admin/ { proxy_pass http://127.0.0.1:10222/; proxy_set_header X-Forwarded-For $remote_addr; @@ -58,3 +59,4 @@ rewrite ^/.well-known/carddav /cloud/remote.php/carddav/ redirect; rewrite ^/.well-known/caldav /cloud/remote.php/caldav/ redirect; + # ADDITIONAL DIRECTIVES HERE diff --git a/conf/nginx.conf b/conf/nginx.conf index 8c480ec3..03d07375 100644 --- a/conf/nginx.conf +++ b/conf/nginx.conf @@ -33,84 +33,5 @@ server { ssl_certificate_key $SSL_KEY; include /etc/nginx/nginx-ssl.conf; - # Expose this directory as static files. - root $ROOT; - index index.html index.htm; - - location = /robots.txt { - log_not_found off; - access_log off; - } - - location = /favicon.ico { - log_not_found off; - access_log off; - } - - location = /mailinabox.mobileconfig { - alias /var/lib/mailinabox/mobileconfig.xml; - } - location = /.well-known/autoconfig/mail/config-v1.1.xml { - alias /var/lib/mailinabox/mozilla-autoconfig.xml; - } - - # Roundcube Webmail configuration. - rewrite ^/mail$ /mail/ redirect; - rewrite ^/mail/$ /mail/index.php; - location /mail/ { - index index.php; - alias /usr/local/lib/roundcubemail/; - } - location ~ /mail/config/.* { - # A ~-style location is needed to give this precedence over the next block. - return 403; - } - location ~ /mail/.*\.php { - # note: ~ has precendence over a regular location block - include fastcgi_params; - fastcgi_split_path_info ^/mail(/.*)()$; - fastcgi_index index.php; - fastcgi_param SCRIPT_FILENAME /usr/local/lib/roundcubemail/$fastcgi_script_name; - fastcgi_pass php-fpm; - - # Outgoing mail also goes through this endpoint, so increase the maximum - # file upload limit to match the corresponding Postfix limit. - client_max_body_size 128M; - } - - # Z-Push (Microsoft Exchange ActiveSync) - location /Microsoft-Server-ActiveSync { - include /etc/nginx/fastcgi_params; - fastcgi_param SCRIPT_FILENAME /usr/local/lib/z-push/index.php; - fastcgi_param PHP_VALUE "include_path=.:/usr/share/php:/usr/share/pear:/usr/share/awl/inc"; - fastcgi_read_timeout 630; - fastcgi_pass php-fpm; - - # Outgoing mail also goes through this endpoint, so increase the maximum - # file upload limit to match the corresponding Postfix limit. - client_max_body_size 128M; - } - location /autodiscover/autodiscover.xml { - include fastcgi_params; - fastcgi_param SCRIPT_FILENAME /usr/local/lib/z-push/autodiscover/autodiscover.php; - fastcgi_param PHP_VALUE "include_path=.:/usr/share/php:/usr/share/pear:/usr/share/awl/inc"; - fastcgi_pass php-fpm; - } - - # ADDITIONAL DIRECTIVES HERE - - # Disable viewing dotfiles (.htaccess, .svn, .git, etc.) - # This block is placed at the end. Nginx's precedence rules means this block - # takes precedence over all non-regex matches and only regex matches that - # come after it (i.e. none of those, since this is the last one.) That means - # we're blocking dotfiles in the static hosted sites but not the FastCGI- - # handled locations for ownCloud (which serves user-uploaded files that might - # have this pattern, see #414) or some of the other services. - location ~ /\.(ht|svn|git|hg|bzr) { - log_not_found off; - access_log off; - deny all; - } - } diff --git a/management/auth.py b/management/auth.py index 04e605c3..55f59664 100644 --- a/management/auth.py +++ b/management/auth.py @@ -88,8 +88,9 @@ class KeyAuthService: if email == "" or pw == "": raise ValueError("Enter an email address and password.") - # The password might be a user-specific API key. - if hmac.compare_digest(self.create_user_key(email), pw): + # The password might be a user-specific API key. create_user_key raises + # a ValueError if the user does not exist. + if hmac.compare_digest(self.create_user_key(email, env), pw): # OK. pass else: @@ -111,18 +112,26 @@ class KeyAuthService: # Login failed. raise ValueError("Invalid password.") - # Get privileges for authorization. This call should never fail on a valid user, - # but if the caller passed a user-specific API key then the user may no longer - # exist --- in that case, get_mail_user_privileges will return a tuple of an - # error message and an HTTP status code. + # Get privileges for authorization. This call should never fail because by this + # point we know the email address is a valid user. But on error the call will + # return a tuple of an error message and an HTTP status code. privs = get_mail_user_privileges(email, env) if isinstance(privs, tuple): raise ValueError(privs[0]) # Return a list of privileges. return privs - def create_user_key(self, email): - return hmac.new(self.key.encode('ascii'), b"AUTH:" + email.encode("utf8"), digestmod="sha1").hexdigest() + def create_user_key(self, email, env): + # Store an HMAC with the client. The hashed message of the HMAC will be the user's + # email address & hashed password and the key will be the master API key. The user of + # course has their own email address and password. We assume they do not have the master + # API key (unless they are trusted anyway). The HMAC proves that they authenticated + # with us in some other way to get the HMAC. Including the password means that when + # a user's password is reset, the HMAC changes and they will correctly need to log + # in to the control panel again. This method raises a ValueError if the user does + # not exist, due to get_mail_password. + msg = b"AUTH:" + email.encode("utf8") + b" " + get_mail_password(email, env).encode("utf8") + return hmac.new(self.key.encode('ascii'), msg, digestmod="sha256").hexdigest() def _generate_key(self): raw_key = os.urandom(32) diff --git a/management/daemon.py b/management/daemon.py index 71159672..e821ce36 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -4,7 +4,7 @@ import os, os.path, re, json from functools import wraps -from flask import Flask, request, render_template, abort, Response +from flask import Flask, request, render_template, abort, Response, send_from_directory import auth, utils from mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, remove_mail_user @@ -118,7 +118,7 @@ def me(): # Is authorized as admin? Return an API key for future use. if "admin" in privs: - resp["api_key"] = auth_service.create_user_key(email) + resp["api_key"] = auth_service.create_user_key(email, env) # Return. return json_response(resp) @@ -384,6 +384,17 @@ def backup_status(): from backup import backup_status return json_response(backup_status(env)) +# MUNIN + +@app.route('/munin/') +@app.route('/munin/') +@authorized_personnel_only +def munin(filename=""): + # Checks administrative access (@authorized_personnel_only) and then just proxies + # the request to static files. + if filename == "": filename = "index.html" + return send_from_directory("/var/cache/munin/www", filename) + # APP if __name__ == '__main__': diff --git a/management/dns_update.py b/management/dns_update.py index cad204d7..5530be27 100755 --- a/management/dns_update.py +++ b/management/dns_update.py @@ -57,13 +57,15 @@ def do_dns_update(env, force=False): # Custom records to add to zones. additional_records = list(get_custom_dns_config(env)) + from web_update import get_default_www_redirects + www_redirect_domains = get_default_www_redirects(env) # Write zone files. os.makedirs('/etc/nsd/zones', exist_ok=True) updated_domains = [] for i, (domain, zonefile) in enumerate(zonefiles): # Build the records to put in the zone. - records = build_zone(domain, domains, additional_records, env) + records = build_zone(domain, domains, additional_records, www_redirect_domains, env) # See if the zone has changed, and if so update the serial number # and write the zone file. @@ -126,7 +128,7 @@ def do_dns_update(env, force=False): ######################################################################## -def build_zone(domain, all_domains, additional_records, env, is_zone=True): +def build_zone(domain, all_domains, additional_records, www_redirect_domains, env, is_zone=True): records = [] # For top-level zones, define the authoritative name servers. @@ -177,7 +179,7 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True): subdomains = [d for d in all_domains if d.endswith("." + domain)] for subdomain in subdomains: subdomain_qname = subdomain[0:-len("." + domain)] - subzone = build_zone(subdomain, [], additional_records, env, is_zone=False) + subzone = build_zone(subdomain, [], additional_records, www_redirect_domains, env, is_zone=False) for child_qname, child_rtype, child_value, child_explanation in subzone: if child_qname == None: child_qname = subdomain_qname @@ -215,10 +217,13 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True): has_rec_base = records defaults = [ (None, "A", env["PUBLIC_IP"], "Required. May have a different value. Sets the IP address that %s resolves to for web hosting and other services besides mail. The A record must be present but its value does not affect mail delivery." % domain), - ("www", "A", env["PUBLIC_IP"], "Optional. Sets the IP address that www.%s resolves to, e.g. for web hosting." % domain), (None, "AAAA", env.get('PUBLIC_IPV6'), "Optional. Sets the IPv6 address that %s resolves to, e.g. for web hosting. (It is not necessary for receiving mail on this domain.)" % domain), - ("www", "AAAA", env.get('PUBLIC_IPV6'), "Optional. Sets the IPv6 address that www.%s resolves to, e.g. for web hosting." % domain), ] + if "www." + domain in www_redirect_domains: + defaults += [ + ("www", "A", env["PUBLIC_IP"], "Optional. Sets the IP address that www.%s resolves to so that the box can provide a redirect to the parent domain." % domain), + ("www", "AAAA", env.get('PUBLIC_IPV6'), "Optional. Sets the IPv6 address that www.%s resolves to so that the box can provide a redirect to the parent domain." % domain), + ] for qname, rtype, value, explanation in defaults: if value is None or value.strip() == "": continue # skip IPV6 if not set if not is_zone and qname == "www": continue # don't create any default 'www' subdomains on what are themselves subdomains @@ -847,8 +852,10 @@ def build_recommended_dns(env): domains = get_dns_domains(env) zonefiles = get_dns_zones(env) additional_records = list(get_custom_dns_config(env)) + from web_update import get_default_www_redirects + www_redirect_domains = get_default_www_redirects(env) for domain, zonefile in zonefiles: - records = build_zone(domain, domains, additional_records, env) + records = build_zone(domain, domains, additional_records, www_redirect_domains, env) # remove records that we don't dislay records = [r for r in records if r[3] is not False] diff --git a/management/mailconfig.py b/management/mailconfig.py index e7419e75..b4d51ff0 100755 --- a/management/mailconfig.py +++ b/management/mailconfig.py @@ -211,7 +211,7 @@ def get_mail_aliases_ex(env): for source, destination in get_mail_aliases(env): # get alias info domain = get_domain(source) - required = ((source in required_aliases) or (source == get_system_administrator(env))) + required = (source in required_aliases) # add to list if not domain in domains: @@ -493,15 +493,17 @@ def get_required_aliases(env): # These are the aliases that must exist. aliases = set() + # The system administrator alias is required. + aliases.add(get_system_administrator(env)) + # The hostmaster alias is exposed in the DNS SOA for each zone. aliases.add("hostmaster@" + env['PRIMARY_HOSTNAME']) # Get a list of domains we serve mail for, except ones for which the only - # email on that domain is a postmaster/admin alias to the administrator - # or a wildcard alias (since it will forward postmaster/admin). + # email on that domain are the required aliases or a catch-all/domain-forwarder. real_mail_domains = get_mail_domains(env, filter_aliases = lambda alias : - ((not alias[0].startswith("postmaster@") and not alias[0].startswith("admin@")) or alias[1] != get_system_administrator(env)) + not alias[0].startswith("postmaster@") and not alias[0].startswith("admin@") and not alias[0].startswith("@") ) @@ -538,12 +540,12 @@ def kick(env, mail_result=None): for s, t in existing_aliases: if s == source: return + # Doesn't exist. administrator = get_system_administrator(env) add_mail_alias(source, administrator, env, do_kick=False) results.append("added alias %s (=> %s)\n" % (source, administrator)) - for alias in required_aliases: ensure_admin_alias_exists(alias) diff --git a/management/status_checks.py b/management/status_checks.py index 27fd26aa..cb740437 100755 --- a/management/status_checks.py +++ b/management/status_checks.py @@ -12,7 +12,7 @@ import dns.reversename, dns.resolver import dateutil.parser, dateutil.tz from dns_update import get_dns_zones, build_tlsa_record, get_custom_dns_config, get_secondary_dns -from web_update import get_web_domains, get_domain_ssl_files +from web_update import get_web_domains, get_default_www_redirects, get_domain_ssl_files from mailconfig import get_mail_domains, get_mail_aliases from utils import shell, sort_domains, load_env_vars_from_file @@ -227,7 +227,7 @@ def run_domain_checks(rounded_time, env, output, pool): dns_domains = set(dns_zonefiles) # Get the list of domains we serve HTTPS for. - web_domains = set(get_web_domains(env)) + web_domains = set(get_web_domains(env) + get_default_www_redirects(env)) domains_to_check = mail_domains | dns_domains | web_domains diff --git a/management/templates/index.html b/management/templates/index.html index 4eceb2ed..78e2365f 100644 --- a/management/templates/index.html +++ b/management/templates/index.html @@ -98,9 +98,10 @@
  • SSL Certificates
  • Backup Status
  • - +
  • Custom DNS
  • External DNS
  • +
  • Munin Monitoring