From b30d7ad80a34deceb220ca1a6b4512114068f78d Mon Sep 17 00:00:00 2001 From: Joshua Tauberer Date: Sun, 17 Aug 2014 22:43:57 +0000 Subject: [PATCH] web-based administrative UI closes #19 --- README.md | 6 +- conf/nginx.conf | 6 + management/auth.py | 71 +++- management/daemon.py | 140 ++++++- management/dns_update.py | 59 +-- management/mailconfig.py | 203 ++++++++-- management/templates/aliases.html | 159 ++++++++ management/templates/index.html | 350 +++++++++++++++++- management/templates/login.html | 102 +++++ management/templates/mail-guide.html | 41 ++ management/templates/system-external-dns.html | 81 ++++ management/templates/system-status.html | 79 ++++ management/templates/users.html | 196 ++++++++++ management/utils.py | 15 + management/web_update.py | 7 +- management/whats_next.py | 188 +++++----- setup/ssl.sh | 5 - setup/start.sh | 25 ++ tools/mail.py | 4 + 19 files changed, 1527 insertions(+), 210 deletions(-) create mode 100644 management/templates/aliases.html create mode 100644 management/templates/login.html create mode 100644 management/templates/mail-guide.html create mode 100644 management/templates/system-external-dns.html create mode 100644 management/templates/system-status.html create mode 100644 management/templates/users.html diff --git a/README.md b/README.md index 3599d378..62b04819 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,7 @@ In short, it's like this: cd mailinabox sudo setup/start.sh -Then run the post-install checklist command to see what you need to do next: - - sudo management/whats_next.py - -Congratulations! You should now have a working setup. Feel free to login with your mail credentials created earlier in the setup +Congratulations! You should now have a working setup. You'll be given the address of the administrative interface for further instructions. **Status**: This is a work in progress. It works for what it is, but it is missing such things as quotas, backup/restore, etc. diff --git a/conf/nginx.conf b/conf/nginx.conf index 4f343c5a..a36e4eeb 100644 --- a/conf/nginx.conf +++ b/conf/nginx.conf @@ -24,6 +24,12 @@ server { root $ROOT; index index.html index.htm; + # Control Panel + rewrite ^/admin$ /admin/; + location /admin/ { + proxy_pass http://localhost:10222/; + } + # Roundcube Webmail configuration. rewrite ^/mail$ /mail/ redirect; rewrite ^/mail/$ /mail/index.php; diff --git a/management/auth.py b/management/auth.py index 01b2f8c0..dee8c7e5 100644 --- a/management/auth.py +++ b/management/auth.py @@ -2,6 +2,9 @@ import base64, os, os.path from flask import make_response +import utils +from mailconfig import get_mail_user_privileges + DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key' DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server' @@ -37,37 +40,69 @@ class KeyAuthService: with create_file_with_mode(self.key_path, 0o640) as key_file: key_file.write(self.key + '\n') - def is_authenticated(self, request): - """Test if the client key passed in HTTP header matches the service key""" + def is_authenticated(self, request, env): + """Test if the client key passed in HTTP Authorization header matches the service key + or if the or username/password passed in the header matches an administrator user. + Returns 'OK' if the key is good or the user is an administrator, otherwise an error message.""" def decode(s): - return base64.b64decode(s.encode('utf-8')).decode('ascii') - - def parse_api_key(header): - if header is None: - return + return base64.b64decode(s.encode('ascii')).decode('ascii') + def parse_basic_auth(header): if " " not in header: - return + return None, None scheme, credentials = header.split(maxsplit=1) if scheme != 'Basic': - return + return None, None credentials = decode(credentials) if ":" not in credentials: - return + return None, None username, password = credentials.split(':', maxsplit=1) - return username + return username, password - request_key = parse_api_key(request.headers.get('Authorization')) + header = request.headers.get('Authorization') + if not header: + return "No authorization header provided." - return request_key == self.key + username, password = parse_basic_auth(header) - def make_unauthorized_response(self): - return make_response( - 'You must pass the API key from "{0}" as the username\n'.format(self.key_path), - 401, - { 'WWW-Authenticate': 'Basic realm="{0}"'.format(self.auth_realm) }) + if username in (None, ""): + return "Authorization header invalid." + elif username == self.key: + return "OK" + else: + return self.check_imap_login( username, password, env) + + def check_imap_login(self, email, pw, env): + # Validate a user's credentials. + + # Sanity check. + if email == "" or pw == "": + return "Enter an email address and password." + + # Authenticate. + try: + # Use doveadm to check credentials. doveadm will return + # a non-zero exit status if the credentials are no good, + # and check_call will raise an exception in that case. + utils.shell('check_call', [ + "/usr/bin/doveadm", + "auth", "test", + email, pw + ]) + except: + # Login failed. + return "Invalid email address or password." + + # Authorize. + # (This call should never fail on a valid user.) + privs = get_mail_user_privileges(email, env) + if isinstance(privs, tuple): raise Exception("Error getting privileges.") + if "admin" not in privs: + return "You are not an administrator for this system." + + return "OK" def _generate_key(self): raw_key = os.urandom(32) diff --git a/management/daemon.py b/management/daemon.py index a6be4335..32fbd3c9 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -2,11 +2,12 @@ import os, os.path, re, json +from functools import wraps + from flask import Flask, request, render_template, abort, Response -app = Flask(__name__) import auth, utils -from mailconfig import get_mail_users, add_mail_user, set_mail_password, remove_mail_user +from mailconfig import get_mail_users, add_mail_user, set_mail_password, remove_mail_user, get_archived_mail_users from mailconfig import get_mail_user_privileges, add_remove_mail_user_privilege from mailconfig import get_mail_aliases, get_mail_domains, add_mail_alias, remove_mail_alias @@ -14,76 +15,156 @@ env = utils.load_environment() auth_service = auth.KeyAuthService() -@app.before_request -def require_auth_key(): - if not auth_service.is_authenticated(request): - abort(401) +# We may deploy via a symbolic link, which confuses flask's template finding. +me = __file__ +try: + me = os.readlink(__file__) +except OSError: + pass + +app = Flask(__name__, template_folder=os.path.abspath(os.path.join(os.path.dirname(me), "templates"))) + +# Decorator to protect views that require authentication. +def authorized_personnel_only(viewfunc): + @wraps(viewfunc) + def newview(*args, **kwargs): + # Check if the user is authorized. + authorized_status = auth_service.is_authenticated(request, env) + if authorized_status == "OK": + # Authorized. Call view func. + return viewfunc(*args, **kwargs) + + # Not authorized. Return a 401 (send auth) and a prompt to authorize by default. + status = 401 + headers = { 'WWW-Authenticate': 'Basic realm="{0}"'.format(auth_service.auth_realm) } + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + # Don't issue a 401 to an AJAX request because the user will + # be prompted for credentials, which is not helpful. + status = 403 + headers = None + + if request.headers.get('Accept') in (None, "", "*/*"): + # Return plain text output. + return Response(authorized_status+"\n", status=status, mimetype='text/plain', headers=headers) + else: + # Return JSON output. + return Response(json.dumps({ + "status": "error", + "reason": authorized_status + }+"\n"), status=status, mimetype='application/json', headers=headers) + + return newview @app.errorhandler(401) def unauthorized(error): return auth_service.make_unauthorized_response() +def json_response(data): + return Response(json.dumps(data), status=200, mimetype='application/json') + +################################### + +# Control Panel (unauthenticated views) + @app.route('/') def index(): - return render_template('index.html') + # Render the control panel. This route does not require user authentication + # so it must be safe! + return render_template('index.html', + hostname=env['PRIMARY_HOSTNAME'], + ) + +@app.route('/me') +def me(): + # Is the caller authorized? + authorized_status = auth_service.is_authenticated(request, env) + if authorized_status != "OK": + return json_response({ + "status": "not-authorized", + "reason": authorized_status, + }) + return json_response({ + "status": "authorized", + "api_key": auth_service.key, + }) # MAIL @app.route('/mail/users') +@authorized_personnel_only def mail_users(): if request.args.get("format", "") == "json": - users = get_mail_users(env, as_json=True) - return Response(json.dumps(users), status=200, mimetype='application/json') + return json_response(get_mail_users(env, as_json=True) + get_archived_mail_users(env)) else: return "".join(x+"\n" for x in get_mail_users(env)) @app.route('/mail/users/add', methods=['POST']) +@authorized_personnel_only def mail_users_add(): - return add_mail_user(request.form.get('email', ''), request.form.get('password', ''), env) + return add_mail_user(request.form.get('email', ''), request.form.get('password', ''), request.form.get('privileges', ''), env) @app.route('/mail/users/password', methods=['POST']) +@authorized_personnel_only def mail_users_password(): return set_mail_password(request.form.get('email', ''), request.form.get('password', ''), env) @app.route('/mail/users/remove', methods=['POST']) +@authorized_personnel_only def mail_users_remove(): return remove_mail_user(request.form.get('email', ''), env) @app.route('/mail/users/privileges') +@authorized_personnel_only def mail_user_privs(): privs = get_mail_user_privileges(request.args.get('email', ''), env) if isinstance(privs, tuple): return privs # error return "\n".join(privs) @app.route('/mail/users/privileges/add', methods=['POST']) +@authorized_personnel_only def mail_user_privs_add(): return add_remove_mail_user_privilege(request.form.get('email', ''), request.form.get('privilege', ''), "add", env) @app.route('/mail/users/privileges/remove', methods=['POST']) +@authorized_personnel_only def mail_user_privs_remove(): return add_remove_mail_user_privilege(request.form.get('email', ''), request.form.get('privilege', ''), "remove", env) @app.route('/mail/aliases') +@authorized_personnel_only def mail_aliases(): - return "".join(x+"\t"+y+"\n" for x, y in get_mail_aliases(env)) + if request.args.get("format", "") == "json": + return json_response(get_mail_aliases(env, as_json=True)) + else: + return "".join(x+"\t"+y+"\n" for x, y in get_mail_aliases(env)) @app.route('/mail/aliases/add', methods=['POST']) +@authorized_personnel_only def mail_aliases_add(): - return add_mail_alias(request.form.get('source', ''), request.form.get('destination', ''), env) + return add_mail_alias( + request.form.get('source', ''), + request.form.get('destination', ''), + env, + update_if_exists=(request.form.get('update_if_exists', '') == '1') + ) @app.route('/mail/aliases/remove', methods=['POST']) +@authorized_personnel_only def mail_aliases_remove(): return remove_mail_alias(request.form.get('source', ''), env) @app.route('/mail/domains') +@authorized_personnel_only def mail_domains(): return "".join(x+"\n" for x in get_mail_domains(env)) # DNS @app.route('/dns/update', methods=['POST']) +@authorized_personnel_only def dns_update(): from dns_update import do_dns_update try: @@ -91,24 +172,43 @@ def dns_update(): except Exception as e: return (str(e), 500) -@app.route('/dns/ds') -def dns_get_ds_records(): - from dns_update import get_ds_records - try: - return get_ds_records(env).replace("\t", " ") # tabs confuse godaddy - except Exception as e: - return (str(e), 500) +@app.route('/dns/dump') +@authorized_personnel_only +def dns_get_dump(): + from dns_update import build_recommended_dns + return json_response(build_recommended_dns(env)) # WEB @app.route('/web/update', methods=['POST']) +@authorized_personnel_only def web_update(): from web_update import do_web_update return do_web_update(env) # System +@app.route('/system/status', methods=["POST"]) +@authorized_personnel_only +def system_status(): + from whats_next import run_checks + class WebOutput: + def __init__(self): + self.items = [] + def add_heading(self, heading): + self.items.append({ "type": "heading", "text": heading, "extra": [] }) + def print_ok(self, message): + self.items.append({ "type": "ok", "text": message, "extra": [] }) + def print_error(self, message): + self.items.append({ "type": "error", "text": message, "extra": [] }) + def print_line(self, message, monospace=False): + self.items[-1]["extra"].append({ "text": message, "monospace": monospace }) + output = WebOutput() + run_checks(env, output) + return json_response(output.items) + @app.route('/system/updates') +@authorized_personnel_only def show_updates(): utils.shell("check_call", ["/usr/bin/apt-get", "-qq", "update"]) simulated_install = utils.shell("check_output", ["/usr/bin/apt-get", "-qq", "-s", "upgrade"]) @@ -120,6 +220,7 @@ def show_updates(): return "\n".join(pkgs) @app.route('/system/update-packages', methods=["POST"]) +@authorized_personnel_only def do_updates(): utils.shell("check_call", ["/usr/bin/apt-get", "-qq", "update"]) return utils.shell("check_output", ["/usr/bin/apt-get", "-y", "upgrade"], env={ @@ -130,6 +231,7 @@ def do_updates(): if __name__ == '__main__': if "DEBUG" in os.environ: app.debug = True + if "APIKEY" in os.environ: auth_service.key = os.environ["APIKEY"] if not app.debug: app.logger.addHandler(utils.create_syslog_handler()) diff --git a/management/dns_update.py b/management/dns_update.py index bc042e8c..24e12e35 100755 --- a/management/dns_update.py +++ b/management/dns_update.py @@ -160,11 +160,11 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True): records.append(("_25._tcp", "TLSA", build_tlsa_record(env), "Recommended when DNSSEC is enabled. Advertises to mail servers connecting to the box that mandatory encryption should be used.")) # 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 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"', "Recomended. Specifies that only the box is permitted to send @%s mail." % domain)) + 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 # both a domain and one of its subdomains. @@ -192,9 +192,9 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True): # Add defaults if not overridden by the user's custom settings. defaults = [ - (None, "A", env["PUBLIC_IP"], "Optional. Sets the IP address that %s resolves to, e.g. for web hosting." % domain), + (None, "A", env["PUBLIC_IP"], "Optional. Sets the IP address that %s resolves to, e.g. for web hosting. (It is not necessary for receiving mail on this domain.)" % 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." % 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), ] for qname, rtype, value, explanation in defaults: @@ -209,7 +209,7 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True): # Append the DKIM TXT record to the zone as generated by OpenDKIM, after string formatting above. with open(opendkim_record_file) as orf: m = re.match(r"(\S+)\s+IN\s+TXT\s+(\(.*\))\s*;", orf.read(), re.S) - records.append((m.group(1), "TXT", m.group(2), "Recommended. Specifies that only the box is permitted to send mail at this domain.")) + records.append((m.group(1), "TXT", m.group(2), "Recommended. Provides a way for recipients to verify that this machine sent @%s mail." % domain)) # Append a DMARC record. 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)) @@ -496,19 +496,6 @@ def sign_zone(domain, zonefile, env): # Remove our temporary file. for fn in files_to_kill: os.unlink(fn) - -######################################################################## - -def get_ds_records(env): - zonefiles = get_dns_zones(env) - ret = "" - for domain, zonefile in zonefiles: - fn = "/etc/nsd/zones/" + zonefile + ".ds" - if os.path.exists(fn): - with open(fn, "r") as fr: - ret += fr.read().strip() + "\n" - return ret - ######################################################################## @@ -605,9 +592,8 @@ def justtestingdotemail(domain, records): ######################################################################## -if __name__ == "__main__": - from utils import load_environment - env = load_environment() +def build_recommended_dns(env): + ret = [] domains = get_dns_domains(env) zonefiles = get_dns_zones(env) for domain, zonefile in zonefiles: @@ -616,15 +602,32 @@ if __name__ == "__main__": # remove records that we don't dislay records = [r for r in records if r[3] is not False] - # put Required at the top + # put Required at the top, then Recommended, then everythiing else records.sort(key = lambda r : 0 if r[3].startswith("Required.") else (1 if r[3].startswith("Recommended.") else 2)) - # print - for qname, rtype, value, explanation in records: - print("; " + explanation) - if qname == None: + # expand qnames + for i in range(len(records)): + if records[i][0] == None: qname = domain else: - qname = qname + "." + domain - print(qname, rtype, value) + qname = records[i][0] + "." + domain + + records[i] = { + "qname": qname, + "rtype": records[i][1], + "value": records[i][2], + "explanation": records[i][3], + } + + # return + ret.append((domain, records)) + return ret + +if __name__ == "__main__": + from utils import load_environment + env = load_environment() + for zone, records in build_recommended_dns(env): + for record in records: + print("; " + record['explanation']) + print(record['qname'], record['rtype'], record['value'], sep="\t") print() diff --git a/management/mailconfig.py b/management/mailconfig.py index 1ad7c57c..1eba302e 100755 --- a/management/mailconfig.py +++ b/management/mailconfig.py @@ -49,18 +49,80 @@ def open_database(env, with_connection=False): def get_mail_users(env, as_json=False): c = open_database(env) c.execute('SELECT email, privileges FROM users') + + # turn into a list of tuples, but sorted by domain & email address + users = { row[0]: row[1] for row in c.fetchall() } # make dict + users = [ (email, users[email]) for email in utils.sort_email_addresses(users.keys(), env) ] + if not as_json: - return [row[0] for row in c.fetchall()] + return [email for email, privileges in users] else: + aliases = get_mail_alias_map(env) return [ - { "email": row[0], "privileges": parse_privs(row[1]) } - for row in c.fetchall() + { + "email": email, + "privileges": parse_privs(privileges), + "status": "active", + "aliases": [ + (alias, sorted(evaluate_mail_alias_map(alias, aliases, env))) + for alias in aliases.get(email.lower(), []) + ] + } + for email, privileges in users ] -def get_mail_aliases(env): +def get_archived_mail_users(env): + real_users = set(get_mail_users(env)) + root = os.path.join(env['STORAGE_ROOT'], 'mail/mailboxes') + ret = [] + for domain_enc in os.listdir(root): + for user_enc in os.listdir(os.path.join(root, domain_enc)): + email = utils.unsafe_domain_name(user_enc) + "@" + utils.unsafe_domain_name(domain_enc) + if email in real_users: continue + ret.append({ + "email": email, + "privileges": "", + "status": "inactive" + }) + return ret + +def get_mail_aliases(env, as_json=False): c = open_database(env) c.execute('SELECT source, destination FROM aliases') - return [(row[0], row[1]) for row in c.fetchall()] + aliases = { row[0]: row[1] for row in c.fetchall() } # make dict + + # put in a canonical order: sort by domain, then by email address lexicographically + aliases = [ (source, aliases[source]) for source in utils.sort_email_addresses(aliases.keys(), env) ] # sort + + # but put automatic aliases to administrator@ last + aliases.sort(key = lambda x : x[1] == get_system_administrator(env)) + + if as_json: + required_aliases = get_required_aliases(env) + aliases = [ + { + "source": alias[0], + "destination": [d.strip() for d in alias[1].split(",")], + "required": alias[0] in required_aliases or alias[0] == get_system_administrator(env), + } + for alias in aliases + ] + return aliases + +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_mail_domains(env, filter_aliases=lambda alias : True): def get_domain(emailaddr): @@ -70,10 +132,30 @@ def get_mail_domains(env, filter_aliases=lambda alias : True): + [get_domain(source) for source, target in get_mail_aliases(env) if filter_aliases((source, target)) ] ) -def add_mail_user(email, pw, env): +def add_mail_user(email, pw, privs, env): + # validate email + if email.strip() == "": + return ("No email address provided.", 400) if not validate_email(email, mode='user'): return ("Invalid email address.", 400) + # validate password + if pw.strip() == "": + return ("No password provided.", 400) + if re.search(r"[\s]", pw): + return ("Passwords cannot contain spaces.", 400) + if len(pw) < 4: + return ("Passwords must be at least four characters.", 400) + + # validate privileges + if privs is None or privs.strip() == "": + privs = [] + else: + privs = privs.split("\n") + for p in privs: + validation = validate_privilege(p) + if validation: return validation + # get the database conn, c = open_database(env, with_connection=True) @@ -82,7 +164,8 @@ def add_mail_user(email, pw, env): # add the user to the database try: - c.execute("INSERT INTO users (email, password) VALUES (?, ?)", (email, pw)) + c.execute("INSERT INTO users (email, password, privileges) VALUES (?, ?, ?)", + (email, pw, "\n".join(privs))) except sqlite3.IntegrityError: return ("User already exists.", 400) @@ -142,13 +225,21 @@ def get_mail_user_privileges(email, env): return ("That's not a user (%s)." % email, 400) return parse_privs(rows[0][0]) -def add_remove_mail_user_privilege(email, priv, action, env): +def validate_privilege(priv): if "\n" in priv or priv.strip() == "": return ("That's not a valid privilege (%s)." % priv, 400) + return None +def add_remove_mail_user_privilege(email, priv, action, env): + # validate + validation = validate_privilege(priv) + if validation: return validation + + # get existing privs, but may fail privs = get_mail_user_privileges(email, env) if isinstance(privs, tuple): return privs # error + # update privs set if action == "add": if priv not in privs: privs.append(priv) @@ -157,6 +248,7 @@ def add_remove_mail_user_privilege(email, priv, action, env): else: return ("Invalid action.", 400) + # commit to database conn, c = open_database(env, with_connection=True) c.execute("UPDATE users SET privileges=? WHERE email=?", ("\n".join(privs), email)) if c.rowcount != 1: @@ -165,20 +257,42 @@ def add_remove_mail_user_privilege(email, priv, action, env): return "OK" -def add_mail_alias(source, destination, env, do_kick=True): +def add_mail_alias(source, destination, env, update_if_exists=False, do_kick=True): + # validate source + if source.strip() == "": + return ("No incoming email address provided.", 400) if not validate_email(source, mode='alias'): - return ("Invalid email address.", 400) + return ("Invalid incoming email address (%s)." % source, 400) + + # parse comma and \n-separated destination emails & validate + dests = [] + for line in destination.split("\n"): + for email in line.split(","): + email = email.strip() + if email == "": continue + if not validate_email(email, mode='alias'): + return ("Invalid destination email address (%s)." % email, 400) + dests.append(email) + if len(destination) == 0: + return ("No destination email address(es) provided.", 400) + destination = ",".join(dests) conn, c = open_database(env, with_connection=True) try: c.execute("INSERT INTO aliases (source, destination) VALUES (?, ?)", (source, destination)) + return_status = "alias added" except sqlite3.IntegrityError: - return ("Alias already exists (%s)." % source, 400) + if not update_if_exists: + return ("Alias already exists (%s)." % source, 400) + else: + c.execute("UPDATE aliases SET destination = ? WHERE source = ?", (destination, source)) + return_status = "alias updated" + conn.commit() if do_kick: # Update things in case any new domains are added. - return kick(env, "alias added") + return kick(env, return_status) def remove_mail_alias(source, env, do_kick=True): conn, c = open_database(env, with_connection=True) @@ -191,6 +305,35 @@ def remove_mail_alias(source, env, do_kick=True): # Update things in case any domains are removed. return kick(env, "alias removed") +def get_system_administrator(env): + return "administrator@" + env['PRIMARY_HOSTNAME'] + +def get_required_aliases(env): + # These are the aliases that must exist. + aliases = set() + + # The hostmaster aliase 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. + 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) \ + ) + + # Create postmaster@ and admin@ for all domains we serve mail on. + # postmaster@ is assumed to exist by our Postfix configuration. admin@ + # isn't anything, but it might save the user some trouble e.g. when + # buying an SSL certificate. + for domain in real_mail_domains: + aliases.add("postmaster@" + domain) + aliases.add("admin@" + domain) + + return aliases + def kick(env, mail_result=None): results = [] @@ -199,50 +342,32 @@ def kick(env, mail_result=None): if mail_result is not None: results.append(mail_result + "\n") - # Create hostmaster@ for the primary domain if it does not already exist. - # Default the target to administrator@ which the user is responsible for - # setting and keeping up to date. + # Ensure every required alias exists. existing_aliases = get_mail_aliases(env) - - administrator = "administrator@" + env['PRIMARY_HOSTNAME'] + required_aliases = get_required_aliases(env) def ensure_admin_alias_exists(source): # Does this alias exists? 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)) - ensure_admin_alias_exists("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. + for alias in required_aliases: + ensure_admin_alias_exists(alias) - 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] != administrator \ - ) - - # Create postmaster@ and admin@ for all domains we serve mail on. - # postmaster@ is assumed to exist by our Postfix configuration. admin@ - # isn't anything, but it might save the user some trouble e.g. when - # buying an SSL certificate. - for domain in real_mail_domains: - ensure_admin_alias_exists("postmaster@" + domain) - ensure_admin_alias_exists("admin@" + domain) - - # Remove auto-generated hostmaster/postmaster/admin on domains we no + # Remove auto-generated postmaster/admin on domains we no # longer have any other email addresses for. for source, target in existing_aliases: user, domain = source.split("@") - if user in ("postmaster", "admin") and domain not in real_mail_domains \ - and target == administrator: + if user in ("postmaster", "admin") \ + and source not in required_aliases \ + and target == get_system_administrator(env): remove_mail_alias(source, env, do_kick=False) results.append("removed alias %s (was to %s; domain no longer used for email)\n" % (source, target)) diff --git a/management/templates/aliases.html b/management/templates/aliases.html new file mode 100644 index 00000000..571909fe --- /dev/null +++ b/management/templates/aliases.html @@ -0,0 +1,159 @@ + + +

Aliases

+ +

Add a mail alias

+ +

Aliases are email forwarders. An alias can forward email to a mail user or to any email address.

+ +
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ + +
+
+
+ +

Existing mail aliases

+ + + + + + + + + + +
Email Address
Forwards To
+ +

Hostmaster@, postmaster@, and admin@ email addresses are required on some domains.

+ +
+ + + + + + +
+ + + + + + +
+
+ + + \ No newline at end of file diff --git a/management/templates/index.html b/management/templates/index.html index 925201e3..def1a387 100644 --- a/management/templates/index.html +++ b/management/templates/index.html @@ -1,11 +1,341 @@ - - - - Mail-in-a-Box Management Server - - -

Mail-in-a-Box Management Server

+ + + + + + + + + -

Use this server to issue commands to the Mail-in-a-Box management daemon.

- - \ No newline at end of file + {{hostname}} - Mail-in-a-Box Control Panel + + + + + + + + + + + + +
+
+ {% include "system-status.html" %} +
+ +
+ {% include "system-external-dns.html" %} +
+ +
+ {% include "login.html" %} +
+ +
+ {% include "mail-guide.html" %} +
+ +
+ {% include "users.html" %} +
+ +
+ {% include "aliases.html" %} +
+ +
+ + +
+ + + + + + + + + + + diff --git a/management/templates/login.html b/management/templates/login.html new file mode 100644 index 00000000..bfeb13f4 --- /dev/null +++ b/management/templates/login.html @@ -0,0 +1,102 @@ +
+
+
+

{{hostname}}

+

Log in here for your Mail-in-a-Box control panel.

+
+ +
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ + diff --git a/management/templates/mail-guide.html b/management/templates/mail-guide.html new file mode 100644 index 00000000..9116f0a0 --- /dev/null +++ b/management/templates/mail-guide.html @@ -0,0 +1,41 @@ +
+

Checking and Sending Mail

+ +

App Configuration

+ +

You can access your email using webmail, desktop mail clients, or mobile apps.

+ +

Here is what you need to know for webmail:

+ + + + + + + +
Webmail Address: https://{{hostname}}/mail
Username: Your whole email address.
Password: Your mail password.
+ +

On mobile devices you might need to install a “mail client” app. We recommend K-9 Mail. On a desktop you could try Mozilla Thunderbird.

+ +

Configure your device or desktop mail client as follows:

+ + + + + +
Server Name: {{hostname}}
Username: Your whole email address.
Password: Your mail password.
+ + + + + + + +
Protocol Port Options
IMAP 993 SSL
SMTP 587 STARTTLS
Exchange ActiveSync n/a Secure Connection
+ +

Depending on your mail program, you will use either IMAP & SMTP or Exchange ActiveSync. See this list of compatible devices for Exchange ActiveSync.

+ +

Notes

+ +

Mail-in-a-Box uses greylisting to cut down on spam. The first time you receive an email from a recipient, it may be delayed for ten minutes.

+
diff --git a/management/templates/system-external-dns.html b/management/templates/system-external-dns.html new file mode 100644 index 00000000..befb77d3 --- /dev/null +++ b/management/templates/system-external-dns.html @@ -0,0 +1,81 @@ + + +

External DNS

+ +

This is for advanced configurations.

+ +

Overview

+ +

Although your box is configured to serve its own DNS, it is possible to host your DNS elsewhere. We do not recommend this.

+ +

If you do so, you are responsible for keeping your DNS entries up to date. In particular DNSSEC entries must be re-signed periodically. Do not set a DS record at your registrar or publish DNSSEC entries in your DNS zones if you do not intend to keep them up to date.

+ +

DNS Settings

+ +

Enter the following DNS entries at your DNS provider:

+ + + + + + + + + + + +
QNameTypeValue
+ + diff --git a/management/templates/system-status.html b/management/templates/system-status.html new file mode 100644 index 00000000..ed7404b1 --- /dev/null +++ b/management/templates/system-status.html @@ -0,0 +1,79 @@ +

System Status Checks

+ + + + + + + + +
+ + \ No newline at end of file diff --git a/management/templates/users.html b/management/templates/users.html new file mode 100644 index 00000000..aa23fb6d --- /dev/null +++ b/management/templates/users.html @@ -0,0 +1,196 @@ +

Users

+ + + +

Add a mail user

+ +

Add an email address to this system. This will create a new login username/password. (Use aliases to create email addresses that forward to existing accounts.)

+ +
+
+ + +
+
+ + +
+
+ +
+ +
+

+ Passwords must be at least four characters and may not contain spaces. + Administrators get access to this control panel. +

+ +

Existing mail users

+ + + + + + + + + + +
Email Address
(Also the user’s login username.)
Privileges
+ +
+ + + + + + +
+ + + +
+
+ + + \ No newline at end of file diff --git a/management/utils.py b/management/utils.py index 469330f4..ffe49734 100644 --- a/management/utils.py +++ b/management/utils.py @@ -23,6 +23,10 @@ def safe_domain_name(name): import urllib.parse return urllib.parse.quote(name, safe='') +def unsafe_domain_name(name_encoded): + import urllib.parse + return urllib.parse.unquote(name_encoded) + def sort_domains(domain_names, env): # Put domain names in a nice sorted order. For web_update, PRIMARY_HOSTNAME # must appear first so it becomes the nginx default server. @@ -51,6 +55,17 @@ def sort_domains(domain_names, env): return groups[0] + groups[1] + groups[2] +def sort_email_addresses(email_addresses, env): + email_addresses = set(email_addresses) + domains = set(email.split("@", 1)[1] for email in email_addresses if "@" in email) + ret = [] + for domain in sort_domains(domains, env): + domain_emails = set(email for email in email_addresses if email.endswith("@" + domain)) + ret.extend(sorted(domain_emails)) + email_addresses -= domain_emails + ret.extend(sorted(email_addresses)) # whatever is left + return ret + def exclusive_process(name): # Ensure that a process named `name` does not execute multiple # times concurrently. diff --git a/management/web_update.py b/management/web_update.py index 204d0bb8..4833adb5 100644 --- a/management/web_update.py +++ b/management/web_update.py @@ -59,8 +59,11 @@ def do_web_update(env): with open(nginx_conf_fn, "w") as f: f.write(nginx_conf) - # Kick nginx. - shell('check_call', ["/usr/sbin/service", "nginx", "restart"]) + # Kick nginx. Since this might be called from the web admin + # don't do a 'restart'. That would kill the connection before + # the API returns its response. A 'reload' should be good + # enough and doesn't break any open connections. + shell('check_call', ["/usr/sbin/service", "nginx", "reload"]) return "web updated\n" diff --git a/management/whats_next.py b/management/whats_next.py index 8d59127d..257cb033 100755 --- a/management/whats_next.py +++ b/management/whats_next.py @@ -16,31 +16,29 @@ from mailconfig import get_mail_domains, get_mail_aliases from utils import shell, sort_domains, load_env_vars_from_file -def run_checks(env): +def run_checks(env, output): + env["out"] = output run_system_checks(env) run_domain_checks(env) def run_system_checks(env): - print("System") - print("======") + env["out"].add_heading("System") # Check that SSH login with password is disabled. sshd = open("/etc/ssh/sshd_config").read() if re.search("\nPasswordAuthentication\s+yes", sshd) \ or not re.search("\nPasswordAuthentication\s+no", sshd): - print_error("""The SSH server on this machine permits password-based login. A more secure + env['out'].print_error("""The SSH server on this machine permits password-based login. A more secure way to log in is using a public key. Add your SSH public key to $HOME/.ssh/authorized_keys, check that you can log in without a password, set the option 'PasswordAuthentication no' in /etc/ssh/sshd_config, and then restart the openssh via 'sudo service ssh restart'.""") else: - print_ok("SSH disallows password-based login.") + env['out'].print_ok("SSH disallows password-based login.") # Check that the administrator alias exists since that's where all # admin email is automatically directed. check_alias_exists("administrator@" + env['PRIMARY_HOSTNAME'], env) - print() - def run_domain_checks(env): # Get the list of domains we handle mail for. mail_domains = get_mail_domains(env) @@ -54,8 +52,7 @@ def run_domain_checks(env): # Check the domains. for domain in sort_domains(mail_domains | dns_domains | web_domains, env): - print(domain) - print("=" * len(domain)) + env["out"].add_heading(domain) if domain == env["PRIMARY_HOSTNAME"]: check_primary_hostname_dns(domain, env) @@ -69,16 +66,14 @@ def run_domain_checks(env): if domain in web_domains: check_web_domain(domain, env) - print() - def check_primary_hostname_dns(domain, env): # 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. ip = query_dns("ns1." + domain, "A") + '/' + query_dns("ns2." + domain, "A") if ip == env['PUBLIC_IP'] + '/' + env['PUBLIC_IP']: - print_ok("Nameserver IPs are correct at registrar. [ns1/ns2.%s => %s]" % (env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'])) + env['out'].print_ok("Nameserver glue records are correct at registrar. [ns1/ns2.%s => %s]" % (env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'])) else: - print_error("""Nameserver IP addresses are incorrect. The ns1.%s and ns2.%s nameservers must be configured at your domain name + env['out'].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 public DNS to update after a change.""" % (env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'], ip)) @@ -86,9 +81,9 @@ def check_primary_hostname_dns(domain, env): # Check that PRIMARY_HOSTNAME resolves to PUBLIC_IP in public DNS. ip = query_dns(domain, "A") if ip == env['PUBLIC_IP']: - print_ok("Domain resolves to box's IP address. [%s => %s]" % (env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'])) + env['out'].print_ok("Domain resolves to box's IP address. [%s => %s]" % (env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'])) else: - print_error("""This domain must resolve to your box's IP address (%s) in public DNS but it currently resolves + env['out'].print_error("""This domain must resolve to your box's IP address (%s) in public DNS but it currently resolves to %s. It may take several hours for public DNS to update after a change. This problem may result from other issues listed here.""" % (env['PUBLIC_IP'], ip)) @@ -98,9 +93,9 @@ def check_primary_hostname_dns(domain, env): ipaddr_rev = dns.reversename.from_address(env['PUBLIC_IP']) existing_rdns = query_dns(ipaddr_rev, "PTR") if existing_rdns == domain: - print_ok("Reverse DNS is set correctly at ISP. [%s => %s]" % (env['PUBLIC_IP'], env['PRIMARY_HOSTNAME'])) + env['out'].print_ok("Reverse DNS is set correctly at ISP. [%s => %s]" % (env['PUBLIC_IP'], env['PRIMARY_HOSTNAME'])) else: - print_error("""Your box's reverse DNS is currently %s, but it should be %s. Your ISP or cloud provider will have instructions + env['out'].print_error("""Your box's reverse DNS is currently %s, but it should be %s. Your ISP or cloud provider will have instructions on setting up reverse DNS for your box at %s.""" % (existing_rdns, domain, env['PUBLIC_IP']) ) # Check the TLSA record. @@ -108,11 +103,11 @@ def check_primary_hostname_dns(domain, env): tlsa25 = query_dns(tlsa_qname, "TLSA", nxdomain=None) tlsa25_expected = build_tlsa_record(env) if tlsa25 == tlsa25_expected: - print_ok("""The DANE TLSA record for incoming mail is correct (%s).""" % tlsa_qname,) + env['out'].print_ok("""The DANE TLSA record for incoming mail is correct (%s).""" % tlsa_qname,) elif tlsa25 is None: - print_error("""The DANE TLSA record for incoming mail is not set. This is optional.""") + env['out'].print_error("""The DANE TLSA record for incoming mail is not set. This is optional.""") else: - print_error("""The DANE TLSA record for incoming mail (%s) is not correct. It is '%s' but it should be '%s'. Try running tools/dns_update to + env['out'].print_error("""The DANE TLSA record for incoming mail (%s) is not correct. It is '%s' but it should be '%s'. Try running tools/dns_update to regenerate the record. It may take several hours for public DNS to update after a change.""" % (tlsa_qname, tlsa25, tlsa25_expected)) @@ -123,9 +118,9 @@ def check_primary_hostname_dns(domain, env): def check_alias_exists(alias, env): mail_alises = dict(get_mail_aliases(env)) if alias in mail_alises: - print_ok("%s exists as a mail alias [=> %s]" % (alias, mail_alises[alias])) + env['out'].print_ok("%s exists as a mail alias [=> %s]" % (alias, mail_alises[alias])) else: - print_error("""You must add a mail alias for %s and direct email to you or another administrator.""" % alias) + env['out'].print_error("""You must add a mail alias for %s and direct email to you or another administrator.""" % alias) def check_dns_zone(domain, env, dns_zonefiles): # We provide a DNS zone for the domain. It should have NS records set up @@ -133,9 +128,9 @@ def check_dns_zone(domain, env, dns_zonefiles): existing_ns = query_dns(domain, "NS") correct_ns = "ns1.BOX; ns2.BOX".replace("BOX", env['PRIMARY_HOSTNAME']) if existing_ns == correct_ns: - print_ok("Nameservers are set correctly at registrar. [%s]" % correct_ns) + env['out'].print_ok("Nameservers are set correctly at registrar. [%s]" % correct_ns) else: - print_error("""The nameservers set on this domain are incorrect. They are currently %s. Use your domain name registar's + env['out'].print_error("""The nameservers set on this domain are incorrect. They are currently %s. Use your domain name registar's control panel to set the nameservers to %s.""" % (existing_ns, correct_ns) ) @@ -158,29 +153,30 @@ def check_dns_zone(domain, env, dns_zonefiles): ds_looks_valid = ds and len(ds.split(" ")) == 4 if ds_looks_valid: ds = ds.split(" ") if ds_looks_valid and ds[0] == ds_keytag and ds[1] == '7' and ds[3] == digests.get(ds[2]): - print_ok("DNS 'DS' record is set correctly at registrar.") + env['out'].print_ok("DNS 'DS' record is set correctly at registrar.") else: if ds == None: - print_error("""This domain's DNS DS record is not set. The DS record is optional. The DS record activates DNSSEC. + env['out'].print_error("""This domain's DNS 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:""") else: - print_error("""This domain's DNS DS record is incorrect. The chain of trust is broken between the public DNS system + env['out'].print_error("""This domain's DNS DS record is incorrect. The chain of trust is broken between the public DNS system and this machine's DNS server. It may take several hours for public DNS to update after a change. If you did not recently make a change, you must resolve this immediately by following the instructions provided by your domain name registrar and provide to them this information:""") - print() - print("\tKey Tag: " + ds_keytag + ("" if not ds_looks_valid or ds[0] == ds_keytag else " (Got '%s')" % ds[0])) - print("\tKey Flags: KSK") - print("\tAlgorithm: 7 / RSASHA1-NSEC3-SHA1" + ("" if not ds_looks_valid or ds[1] == '7' else " (Got '%s')" % ds[1])) - print("\tDigest Type: 2 / SHA-256") - print("\tDigest: " + digests['2']) + env['out'].print_line("") + env['out'].print_line("Key Tag: " + ds_keytag + ("" if not ds_looks_valid or ds[0] == ds_keytag else " (Got '%s')" % ds[0])) + env['out'].print_line("Key Flags: KSK") + env['out'].print_line("Algorithm: 7 / RSASHA1-NSEC3-SHA1" + ("" if not ds_looks_valid or ds[1] == '7' else " (Got '%s')" % ds[1])) + env['out'].print_line("Digest Type: 2 / SHA-256") + env['out'].print_line("Digest: " + digests['2']) if ds_looks_valid and ds[3] != digests.get(ds[2]): - print("\t(Got digest type %s and digest %s which do not match.)" % (ds[2], ds[3])) - print("\tPublic Key: " + dnsssec_pubkey) - print() - print("\tBulk/Record Format:") - print("\t" + ds_correct[0]) - print("") + env['out'].print_line("(Got digest type %s and digest %s which do not match.)" % (ds[2], ds[3])) + env['out'].print_line("Public Key: ") + env['out'].print_line(dnsssec_pubkey, monospace=True) + env['out'].print_line("") + env['out'].print_line("Bulk/Record Format:") + env['out'].print_line("" + ds_correct[0]) + env['out'].print_line("") def check_mail_domain(domain, env): # Check the MX record. @@ -189,14 +185,14 @@ def check_mail_domain(domain, env): expected_mx = "10 " + env['PRIMARY_HOSTNAME'] if mx == expected_mx: - print_ok("Domain's email is directed to this domain. [%s => %s]" % (domain, mx)) + env['out'].print_ok("Domain's email is directed to this domain. [%s => %s]" % (domain, mx)) elif mx == None: # A missing MX record is okay on the primary hostname because # the primary hostname's A record (the MX fallback) is... itself, # which is what we want the MX to be. if domain == env['PRIMARY_HOSTNAME']: - print_ok("Domain's email is directed to this domain. [%s has no MX record, which is ok]" % (domain,)) + env['out'].print_ok("Domain's email is directed to this domain. [%s has no MX record, which is ok]" % (domain,)) # And a missing MX record is okay on other domains if the A record # matches the A record of the PRIMARY_HOSTNAME. Actually this will @@ -205,14 +201,14 @@ def check_mail_domain(domain, env): domain_a = query_dns(domain, "A", nxdomain=None) primary_a = query_dns(env['PRIMARY_HOSTNAME'], "A", nxdomain=None) if domain_a != None and domain_a == primary_a: - print_ok("Domain's email is directed to this domain. [%s has no MX record but its A record is OK]" % (domain,)) + env['out'].print_ok("Domain's email is directed to this domain. [%s has no MX record but its A record is OK]" % (domain,)) else: - print_error("""This domain's DNS MX record is not set. It should be '%s'. Mail will not + env['out'].print_error("""This domain's DNS MX record is not set. It 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 other issues listed here.""" % (expected_mx,)) else: - print_error("""This domain's DNS MX record is incorrect. It is currently set to '%s' but should be '%s'. Mail will not + env['out'].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 other issues listed here.""" % (mx, expected_mx)) @@ -226,9 +222,9 @@ def check_web_domain(domain, env): if domain != env['PRIMARY_HOSTNAME']: ip = query_dns(domain, "A") if ip == env['PUBLIC_IP']: - print_ok("Domain resolves to this box's IP address. [%s => %s]" % (domain, env['PUBLIC_IP'])) + env['out'].print_ok("Domain resolves to this box's IP address. [%s => %s]" % (domain, env['PUBLIC_IP'])) else: - print_error("""This domain should resolve to your box's IP address (%s) if you would like the box to serve + env['out'].print_error("""This domain should resolve to your box's IP address (%s) if you would like the box to serve webmail or a website on this domain. The domain currently resolves to %s in public DNS. It may take several hours for public DNS to update after a change. This problem may result from other issues listed here.""" % (env['PUBLIC_IP'], ip)) @@ -256,13 +252,13 @@ def check_ssl_cert(domain, env): # Check that SSL certificate is signed. # Skip the check if the A record is not pointed here. - if query_dns(domain, "A") != env['PUBLIC_IP']: return + if query_dns(domain, "A", None) not in (env['PUBLIC_IP'], None): return # Where is the SSL stored? ssl_key, ssl_certificate, ssl_csr_path = get_domain_ssl_files(domain, env) if not os.path.exists(ssl_certificate): - print_error("The SSL certificate file for this domain is missing.") + env['out'].print_error("The SSL certificate file for this domain is missing.") return # Check that the certificate is good. @@ -280,34 +276,34 @@ def check_ssl_cert(domain, env): fingerprint = re.sub(".*Fingerprint=", "", fingerprint).strip() if domain == env['PRIMARY_HOSTNAME']: - print_error("""The SSL certificate for this domain is currently self-signed. You will get a security + env['out'].print_error("""The SSL certificate for this domain is currently self-signed. You will get a security warning when you check or send email and when visiting this domain in a web browser (for webmail or static site hosting). You may choose to confirm the security exception, but check that the certificate fingerprint matches the following:""") - print() - print(" " + fingerprint) + env['out'].print_line("") + env['out'].print_line(" " + fingerprint, monospace=True) else: - print_error("""The SSL certificate for this domain is currently self-signed. Visitors to a website on + env['out'].print_error("""The SSL certificate for this domain is currently self-signed. Visitors to a website on this domain will get a security warning. If you are not serving a website on this domain, then it is safe to leave the self-signed certificate in place.""") - print() - print_block("""You can purchase a signed certificate from many places. You will need to provide this Certificate Signing Request (CSR) + env['out'].print_line("") + env['out'].print_line("""You can purchase a signed certificate from many places. You will need to provide this Certificate Signing Request (CSR) to whoever you purchase the SSL certificate from:""") - print() - print(open(ssl_csr_path).read().strip()) - print() - print_block("""When you purchase an SSL certificate you will receive a certificate in PEM format and possibly a file containing intermediate certificates in PEM format. + env['out'].print_line("") + env['out'].print_line(open(ssl_csr_path).read().strip(), monospace=True) + env['out'].print_line("") + env['out'].print_line("""When you purchase an SSL certificate you will receive a certificate in PEM format and possibly a file containing intermediate certificates in PEM format. If you receive intermediate certificates, use a text editor and paste your certificate on top and then the intermediate certificates below it. Save the file and place it onto this machine at %s. Then run "service nginx restart".""" % ssl_certificate) elif cert_status == "OK": - print_ok("SSL certificate is signed & valid.") + env['out'].print_ok("SSL certificate is signed & valid.") else: - print_error("The SSL certificate has a problem:") - print("") - print(cert_status) - print("") + env['out'].print_error("The SSL certificate has a problem:") + env['out'].print_line("") + env['out'].print_line(cert_status) + env['out'].print_line("") def check_certificate(domain, ssl_certificate, ssl_private_key): # Use openssl verify to check the status of a certificate. @@ -398,32 +394,56 @@ def check_certificate(domain, ssl_certificate, ssl_private_key): else: return verifyoutput.strip() -def print_ok(message): - print_block(message, first_line="✓ ") - -def print_error(message): - print_block(message, first_line="✖ ") - try: terminal_columns = int(shell('check_output', ['stty', 'size']).split()[1]) except: terminal_columns = 76 -def print_block(message, first_line=" "): - print(first_line, end='') - message = re.sub("\n\s*", " ", message) - words = re.split("(\s+)", message) - linelen = 0 - for w in words: - if linelen + len(w) > terminal_columns-1-len(first_line): - print() - print(" ", end="") - linelen = 0 - if linelen == 0 and w.strip() == "": continue - print(w, end="") - linelen += len(w) - if linelen > 0: +class ConsoleOutput: + def add_heading(self, heading): + print() + print(heading) + print("=" * len(heading)) + + def print_ok(self, message): + self.print_block(message, first_line="✓ ") + + def print_error(self, message): + self.print_block(message, first_line="✖ ") + + def print_block(self, message, first_line=" "): + print(first_line, end='') + message = re.sub("\n\s*", " ", message) + words = re.split("(\s+)", message) + linelen = 0 + for w in words: + if linelen + len(w) > terminal_columns-1-len(first_line): + print() + print(" ", end="") + linelen = 0 + if linelen == 0 and w.strip() == "": continue + print(w, end="") + linelen += len(w) print() + def print_line(self, message, monospace=False): + for line in message.split("\n"): + self.print_block(line) + if __name__ == "__main__": + import sys from utils import load_environment - run_checks(load_environment()) + env = load_environment() + if len(sys.argv) == 1: + run_checks(env, ConsoleOutput()) + elif sys.argv[1] == "--check-primary-hostname": + # See if the primary hostname appears resolvable and has a signed certificate. + domain = env['PRIMARY_HOSTNAME'] + if query_dns(domain, "A") != env['PUBLIC_IP']: + sys.exit(1) + ssl_key, ssl_certificate, ssl_csr_path = get_domain_ssl_files(domain, env) + if not os.path.exists(ssl_certificate): + sys.exit(1) + cert_status = check_certificate(domain, ssl_certificate, ssl_key) + if cert_status != "OK": + sys.exit(1) + sys.exit(0) diff --git a/setup/ssl.sh b/setup/ssl.sh index ec1cdb50..d440219f 100755 --- a/setup/ssl.sh +++ b/setup/ssl.sh @@ -40,8 +40,3 @@ if [ ! -f $STORAGE_ROOT/ssl/ssl_certificate.pem ]; then -in $STORAGE_ROOT/ssl/ssl_cert_sign_req.csr -signkey $STORAGE_ROOT/ssl/ssl_private_key.pem -out $STORAGE_ROOT/ssl/ssl_certificate.pem fi -echo -echo "Your SSL certificate's fingerpint is:" -openssl x509 -in $STORAGE_ROOT/ssl/ssl_certificate.pem -noout -fingerprint \ - | sed "s/SHA1 Fingerprint=//" -echo diff --git a/setup/start.sh b/setup/start.sh index 469f6162..c210e222 100755 --- a/setup/start.sh +++ b/setup/start.sh @@ -322,3 +322,28 @@ if [ -z "`tools/mail.py user`" ]; then tools/mail.py alias add administrator@$PRIMARY_HOSTNAME $EMAIL_ADDR fi +echo +echo "-----------------------------------------------" +echo +echo Your Mail-in-a-Box is running. +echo +echo Please log in to the control panel for further instructions at: +echo +if management/whats_next.py --check-primary-hostname; then + # Show the nice URL if it appears to be resolving and has a valid certificate. + echo https://$PRIMARY_HOSTNAME/admin + echo + echo If there are problems with this URL, instead use: + echo +fi +echo https://$PUBLIC_IP/admin +echo +echo You will be alerted that the website has an invalid certificate. Check that +echo the certificate fingerprint matches: +echo +openssl x509 -in $STORAGE_ROOT/ssl/ssl_certificate.pem -noout -fingerprint \ + | sed "s/SHA1 Fingerprint=//" +echo +echo Then you can confirm the security exception and continue. +echo + diff --git a/tools/mail.py b/tools/mail.py index ce2d3e46..439594bd 100755 --- a/tools/mail.py +++ b/tools/mail.py @@ -12,6 +12,10 @@ def mgmt(cmd, data=None, is_json=False): response = urllib.request.urlopen(req) except urllib.error.HTTPError as e: if e.code == 401: + try: + print(e.read().decode("utf8")) + except: + pass print("The management daemon refused access. The API key file may be out of sync. Try 'service mailinabox restart'.", file=sys.stderr) elif hasattr(e, 'read'): print(e.read().decode('utf8'), file=sys.stderr)