1
0
mirror of https://github.com/mail-in-a-box/mailinabox.git synced 2024-11-24 02:37:05 +00:00

web-based administrative UI

closes #19
This commit is contained in:
Joshua Tauberer 2014-08-17 22:43:57 +00:00
parent ba8e015795
commit b30d7ad80a
19 changed files with 1527 additions and 210 deletions

View File

@ -27,11 +27,7 @@ In short, it's like this:
cd mailinabox cd mailinabox
sudo setup/start.sh sudo setup/start.sh
Then run the post-install checklist command to see what you need to do next: Congratulations! You should now have a working setup. You'll be given the address of the administrative interface for further instructions.
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
**Status**: This is a work in progress. It works for what it is, but it is missing such things as quotas, backup/restore, etc. **Status**: This is a work in progress. It works for what it is, but it is missing such things as quotas, backup/restore, etc.

View File

@ -24,6 +24,12 @@ server {
root $ROOT; root $ROOT;
index index.html index.htm; index index.html index.htm;
# Control Panel
rewrite ^/admin$ /admin/;
location /admin/ {
proxy_pass http://localhost:10222/;
}
# Roundcube Webmail configuration. # Roundcube Webmail configuration.
rewrite ^/mail$ /mail/ redirect; rewrite ^/mail$ /mail/ redirect;
rewrite ^/mail/$ /mail/index.php; rewrite ^/mail/$ /mail/index.php;

View File

@ -2,6 +2,9 @@ import base64, os, os.path
from flask import make_response from flask import make_response
import utils
from mailconfig import get_mail_user_privileges
DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key' DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key'
DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server' 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: with create_file_with_mode(self.key_path, 0o640) as key_file:
key_file.write(self.key + '\n') key_file.write(self.key + '\n')
def is_authenticated(self, request): def is_authenticated(self, request, env):
"""Test if the client key passed in HTTP header matches the service key""" """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): def decode(s):
return base64.b64decode(s.encode('utf-8')).decode('ascii') return base64.b64decode(s.encode('ascii')).decode('ascii')
def parse_api_key(header):
if header is None:
return
def parse_basic_auth(header):
if " " not in header: if " " not in header:
return return None, None
scheme, credentials = header.split(maxsplit=1) scheme, credentials = header.split(maxsplit=1)
if scheme != 'Basic': if scheme != 'Basic':
return return None, None
credentials = decode(credentials) credentials = decode(credentials)
if ":" not in credentials: if ":" not in credentials:
return return None, None
username, password = credentials.split(':', maxsplit=1) 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): if username in (None, ""):
return make_response( return "Authorization header invalid."
'You must pass the API key from "{0}" as the username\n'.format(self.key_path), elif username == self.key:
401, return "OK"
{ 'WWW-Authenticate': 'Basic realm="{0}"'.format(self.auth_realm) }) 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): def _generate_key(self):
raw_key = os.urandom(32) raw_key = os.urandom(32)

View File

@ -2,11 +2,12 @@
import os, os.path, re, json 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
app = Flask(__name__)
import auth, utils 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_user_privileges, add_remove_mail_user_privilege
from mailconfig import get_mail_aliases, get_mail_domains, add_mail_alias, remove_mail_alias 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() auth_service = auth.KeyAuthService()
@app.before_request # We may deploy via a symbolic link, which confuses flask's template finding.
def require_auth_key(): me = __file__
if not auth_service.is_authenticated(request): try:
abort(401) 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) @app.errorhandler(401)
def unauthorized(error): def unauthorized(error):
return auth_service.make_unauthorized_response() 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('/') @app.route('/')
def index(): 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 # MAIL
@app.route('/mail/users') @app.route('/mail/users')
@authorized_personnel_only
def mail_users(): def mail_users():
if request.args.get("format", "") == "json": if request.args.get("format", "") == "json":
users = get_mail_users(env, as_json=True) return json_response(get_mail_users(env, as_json=True) + get_archived_mail_users(env))
return Response(json.dumps(users), status=200, mimetype='application/json')
else: else:
return "".join(x+"\n" for x in get_mail_users(env)) return "".join(x+"\n" for x in get_mail_users(env))
@app.route('/mail/users/add', methods=['POST']) @app.route('/mail/users/add', methods=['POST'])
@authorized_personnel_only
def mail_users_add(): 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']) @app.route('/mail/users/password', methods=['POST'])
@authorized_personnel_only
def mail_users_password(): def mail_users_password():
return set_mail_password(request.form.get('email', ''), request.form.get('password', ''), env) return set_mail_password(request.form.get('email', ''), request.form.get('password', ''), env)
@app.route('/mail/users/remove', methods=['POST']) @app.route('/mail/users/remove', methods=['POST'])
@authorized_personnel_only
def mail_users_remove(): def mail_users_remove():
return remove_mail_user(request.form.get('email', ''), env) return remove_mail_user(request.form.get('email', ''), env)
@app.route('/mail/users/privileges') @app.route('/mail/users/privileges')
@authorized_personnel_only
def mail_user_privs(): def mail_user_privs():
privs = get_mail_user_privileges(request.args.get('email', ''), env) privs = get_mail_user_privileges(request.args.get('email', ''), env)
if isinstance(privs, tuple): return privs # error if isinstance(privs, tuple): return privs # error
return "\n".join(privs) return "\n".join(privs)
@app.route('/mail/users/privileges/add', methods=['POST']) @app.route('/mail/users/privileges/add', methods=['POST'])
@authorized_personnel_only
def mail_user_privs_add(): def mail_user_privs_add():
return add_remove_mail_user_privilege(request.form.get('email', ''), request.form.get('privilege', ''), "add", env) return add_remove_mail_user_privilege(request.form.get('email', ''), request.form.get('privilege', ''), "add", env)
@app.route('/mail/users/privileges/remove', methods=['POST']) @app.route('/mail/users/privileges/remove', methods=['POST'])
@authorized_personnel_only
def mail_user_privs_remove(): def mail_user_privs_remove():
return add_remove_mail_user_privilege(request.form.get('email', ''), request.form.get('privilege', ''), "remove", env) return add_remove_mail_user_privilege(request.form.get('email', ''), request.form.get('privilege', ''), "remove", env)
@app.route('/mail/aliases') @app.route('/mail/aliases')
@authorized_personnel_only
def mail_aliases(): def mail_aliases():
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)) return "".join(x+"\t"+y+"\n" for x, y in get_mail_aliases(env))
@app.route('/mail/aliases/add', methods=['POST']) @app.route('/mail/aliases/add', methods=['POST'])
@authorized_personnel_only
def mail_aliases_add(): 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']) @app.route('/mail/aliases/remove', methods=['POST'])
@authorized_personnel_only
def mail_aliases_remove(): def mail_aliases_remove():
return remove_mail_alias(request.form.get('source', ''), env) return remove_mail_alias(request.form.get('source', ''), env)
@app.route('/mail/domains') @app.route('/mail/domains')
@authorized_personnel_only
def mail_domains(): def mail_domains():
return "".join(x+"\n" for x in get_mail_domains(env)) return "".join(x+"\n" for x in get_mail_domains(env))
# DNS # DNS
@app.route('/dns/update', methods=['POST']) @app.route('/dns/update', methods=['POST'])
@authorized_personnel_only
def dns_update(): def dns_update():
from dns_update import do_dns_update from dns_update import do_dns_update
try: try:
@ -91,24 +172,43 @@ def dns_update():
except Exception as e: except Exception as e:
return (str(e), 500) return (str(e), 500)
@app.route('/dns/ds') @app.route('/dns/dump')
def dns_get_ds_records(): @authorized_personnel_only
from dns_update import get_ds_records def dns_get_dump():
try: from dns_update import build_recommended_dns
return get_ds_records(env).replace("\t", " ") # tabs confuse godaddy return json_response(build_recommended_dns(env))
except Exception as e:
return (str(e), 500)
# WEB # WEB
@app.route('/web/update', methods=['POST']) @app.route('/web/update', methods=['POST'])
@authorized_personnel_only
def web_update(): def web_update():
from web_update import do_web_update from web_update import do_web_update
return do_web_update(env) return do_web_update(env)
# System # 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') @app.route('/system/updates')
@authorized_personnel_only
def show_updates(): def show_updates():
utils.shell("check_call", ["/usr/bin/apt-get", "-qq", "update"]) utils.shell("check_call", ["/usr/bin/apt-get", "-qq", "update"])
simulated_install = utils.shell("check_output", ["/usr/bin/apt-get", "-qq", "-s", "upgrade"]) simulated_install = utils.shell("check_output", ["/usr/bin/apt-get", "-qq", "-s", "upgrade"])
@ -120,6 +220,7 @@ def show_updates():
return "\n".join(pkgs) return "\n".join(pkgs)
@app.route('/system/update-packages', methods=["POST"]) @app.route('/system/update-packages', methods=["POST"])
@authorized_personnel_only
def do_updates(): def do_updates():
utils.shell("check_call", ["/usr/bin/apt-get", "-qq", "update"]) utils.shell("check_call", ["/usr/bin/apt-get", "-qq", "update"])
return utils.shell("check_output", ["/usr/bin/apt-get", "-y", "upgrade"], env={ return utils.shell("check_output", ["/usr/bin/apt-get", "-y", "upgrade"], env={
@ -130,6 +231,7 @@ def do_updates():
if __name__ == '__main__': if __name__ == '__main__':
if "DEBUG" in os.environ: app.debug = True if "DEBUG" in os.environ: app.debug = True
if "APIKEY" in os.environ: auth_service.key = os.environ["APIKEY"]
if not app.debug: if not app.debug:
app.logger.addHandler(utils.create_syslog_handler()) app.logger.addHandler(utils.create_syslog_handler())

View File

@ -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.")) 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! # 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 # SPF record: Permit the box ('mx', see above) to send mail on behalf of
# the domain, and no one else. # 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 # Add DNS records for any subdomains of this domain. We should not have a zone for
# both a domain and one of its subdomains. # both a domain and one of its subdomains.
@ -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. # Add defaults if not overridden by the user's custom settings.
defaults = [ 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), ("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), ("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: 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. # Append the DKIM TXT record to the zone as generated by OpenDKIM, after string formatting above.
with open(opendkim_record_file) as orf: with open(opendkim_record_file) as orf:
m = re.match(r"(\S+)\s+IN\s+TXT\s+(\(.*\))\s*;", orf.read(), re.S) 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. # 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)) 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))
@ -499,19 +499,6 @@ def sign_zone(domain, zonefile, env):
######################################################################## ########################################################################
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
########################################################################
def write_opendkim_tables(zonefiles, env): def write_opendkim_tables(zonefiles, env):
# Append a record to OpenDKIM's KeyTable and SigningTable for each domain. # Append a record to OpenDKIM's KeyTable and SigningTable for each domain.
@ -605,9 +592,8 @@ def justtestingdotemail(domain, records):
######################################################################## ########################################################################
if __name__ == "__main__": def build_recommended_dns(env):
from utils import load_environment ret = []
env = load_environment()
domains = get_dns_domains(env) domains = get_dns_domains(env)
zonefiles = get_dns_zones(env) zonefiles = get_dns_zones(env)
for domain, zonefile in zonefiles: for domain, zonefile in zonefiles:
@ -616,15 +602,32 @@ if __name__ == "__main__":
# remove records that we don't dislay # remove records that we don't dislay
records = [r for r in records if r[3] is not False] 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)) records.sort(key = lambda r : 0 if r[3].startswith("Required.") else (1 if r[3].startswith("Recommended.") else 2))
# print # expand qnames
for qname, rtype, value, explanation in records: for i in range(len(records)):
print("; " + explanation) if records[i][0] == None:
if qname == None:
qname = domain qname = domain
else: else:
qname = qname + "." + domain qname = records[i][0] + "." + domain
print(qname, rtype, value)
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() print()

View File

@ -49,18 +49,80 @@ def open_database(env, with_connection=False):
def get_mail_users(env, as_json=False): def get_mail_users(env, as_json=False):
c = open_database(env) c = open_database(env)
c.execute('SELECT email, privileges FROM users') 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: if not as_json:
return [row[0] for row in c.fetchall()] return [email for email, privileges in users]
else: else:
aliases = get_mail_alias_map(env)
return [ 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 = open_database(env)
c.execute('SELECT source, destination FROM aliases') 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_mail_domains(env, filter_aliases=lambda alias : True):
def get_domain(emailaddr): 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)) ] + [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'): if not validate_email(email, mode='user'):
return ("Invalid email address.", 400) 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 # get the database
conn, c = open_database(env, with_connection=True) 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 # add the user to the database
try: 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: except sqlite3.IntegrityError:
return ("User already exists.", 400) 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 ("That's not a user (%s)." % email, 400)
return parse_privs(rows[0][0]) 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() == "": if "\n" in priv or priv.strip() == "":
return ("That's not a valid privilege (%s)." % priv, 400) 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) privs = get_mail_user_privileges(email, env)
if isinstance(privs, tuple): return privs # error if isinstance(privs, tuple): return privs # error
# update privs set
if action == "add": if action == "add":
if priv not in privs: if priv not in privs:
privs.append(priv) privs.append(priv)
@ -157,6 +248,7 @@ def add_remove_mail_user_privilege(email, priv, action, env):
else: else:
return ("Invalid action.", 400) return ("Invalid action.", 400)
# commit to database
conn, c = open_database(env, with_connection=True) conn, c = open_database(env, with_connection=True)
c.execute("UPDATE users SET privileges=? WHERE email=?", ("\n".join(privs), email)) c.execute("UPDATE users SET privileges=? WHERE email=?", ("\n".join(privs), email))
if c.rowcount != 1: if c.rowcount != 1:
@ -165,20 +257,42 @@ def add_remove_mail_user_privilege(email, priv, action, env):
return "OK" 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'): 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) conn, c = open_database(env, with_connection=True)
try: try:
c.execute("INSERT INTO aliases (source, destination) VALUES (?, ?)", (source, destination)) c.execute("INSERT INTO aliases (source, destination) VALUES (?, ?)", (source, destination))
return_status = "alias added"
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
if not update_if_exists:
return ("Alias already exists (%s)." % source, 400) return ("Alias already exists (%s)." % source, 400)
else:
c.execute("UPDATE aliases SET destination = ? WHERE source = ?", (destination, source))
return_status = "alias updated"
conn.commit() conn.commit()
if do_kick: if do_kick:
# Update things in case any new domains are added. # 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): def remove_mail_alias(source, env, do_kick=True):
conn, c = open_database(env, with_connection=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. # Update things in case any domains are removed.
return kick(env, "alias 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): def kick(env, mail_result=None):
results = [] results = []
@ -199,50 +342,32 @@ def kick(env, mail_result=None):
if mail_result is not None: if mail_result is not None:
results.append(mail_result + "\n") results.append(mail_result + "\n")
# Create hostmaster@ for the primary domain if it does not already exist. # Ensure every required alias exists.
# Default the target to administrator@ which the user is responsible for
# setting and keeping up to date.
existing_aliases = get_mail_aliases(env) existing_aliases = get_mail_aliases(env)
required_aliases = get_required_aliases(env)
administrator = "administrator@" + env['PRIMARY_HOSTNAME']
def ensure_admin_alias_exists(source): def ensure_admin_alias_exists(source):
# Does this alias exists? # Does this alias exists?
for s, t in existing_aliases: for s, t in existing_aliases:
if s == source: if s == source:
return return
# Doesn't exist. # Doesn't exist.
administrator = get_system_administrator(env)
add_mail_alias(source, administrator, env, do_kick=False) add_mail_alias(source, administrator, env, do_kick=False)
results.append("added alias %s (=> %s)\n" % (source, administrator)) 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 for alias in required_aliases:
# email on that domain is a postmaster/admin alias to the administrator. ensure_admin_alias_exists(alias)
real_mail_domains = get_mail_domains(env, # Remove auto-generated postmaster/admin on domains we no
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
# longer have any other email addresses for. # longer have any other email addresses for.
for source, target in existing_aliases: for source, target in existing_aliases:
user, domain = source.split("@") user, domain = source.split("@")
if user in ("postmaster", "admin") and domain not in real_mail_domains \ if user in ("postmaster", "admin") \
and target == administrator: and source not in required_aliases \
and target == get_system_administrator(env):
remove_mail_alias(source, env, do_kick=False) 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)) results.append("removed alias %s (was to %s; domain no longer used for email)\n" % (source, target))

View File

@ -0,0 +1,159 @@
<style>
#alias_table .actions > * { padding-right: 3px; }
#alias_table .alias-required .remove { display: none }
</style>
<h2>Aliases</h2>
<h3>Add a mail alias</h3>
<p>Aliases are email forwarders. An alias can forward email to a <a href="javascript:show_panel('users')">mail user</a> or to any email address.</p>
<form class="form-horizontal" role="form" onsubmit="do_add_alias(); return false;">
<div class="form-group">
<label for="addaliasEmail" class="col-sm-2 control-label">Email Address</label>
<div class="col-sm-10">
<input type="email" class="form-control" id="addaliasEmail" placeholder="Incoming Email Address">
</div>
</div>
<div class="form-group">
<label for="addaliasTargets" class="col-sm-2 control-label">Forward To</label>
<div class="col-sm-10">
<textarea class="form-control" rows="3" id="addaliasTargets" placeholder="Forward to these email addresses (one per line or separated by commas)"></textarea>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button id="add-alias-button" type="submit" class="btn btn-primary">Add</button>
<button id="alias-cancel" class="btn btn-default hidden" onclick="aliases_reset_form(); return false;">Cancel</button>
</div>
</div>
</form>
<h3>Existing mail aliases</h3>
<table id="alias_table" class="table" style="width: auto">
<thead>
<tr>
<th></th>
<th>Email Address<br></th>
<th>Forwards To</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<p style="margin-top: 1.5em"><small>Hostmaster@, postmaster@, and admin@ email addresses are required on some domains.</small></p>
<div style="display: none">
<table>
<tr id="alias-template">
<td class='actions'>
<a href="#" onclick="aliases_edit(this); return false;" class='edit' title="Edit Alias">
<span class="glyphicon glyphicon-pencil"></span>
</a>
<a href="#" onclick="aliases_remove(this); return false;" class='remove' title="Remove Alias">
<span class="glyphicon glyphicon-trash"></span>
</a>
</td>
<td class='email'> </td>
<td class='target'> </td>
</tr>
</table>
</div>
<script>
function show_aliases() {
$('#alias_table tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
api(
"/mail/aliases",
"GET",
{ format: 'json' },
function(r) {
$('#alias_table tbody').html("");
for (var i = 0; i < r.length; i++) {
var n = $("#alias-template").clone();
n.attr('id', '');
if (r[i].required) n.addClass('alias-required');
n.attr('data-email', r[i].source);
n.find('td.email').text(r[i].source)
for (var j = 0; j < r[i].destination.length; j++)
n.find('td.target').append($("<div></div>").text(r[i].destination[j]))
$('#alias_table tbody').append(n);
}
})
}
var is_alias_add_update = false;
function do_add_alias() {
var title = (!is_alias_add_update) ? "Add Alias" : "Update Alias";
var email = $("#addaliasEmail").val();
var targets = $("#addaliasTargets").val();
api(
"/mail/aliases/add",
"POST",
{
update_if_exists: is_alias_add_update ? '1' : '0',
source: email,
destination: targets
},
function(r) {
// Responses are multiple lines of pre-formatted text.
show_modal_error(title, $("<pre/>").text(r));
show_aliases()
aliases_reset_form();
},
function(r) {
show_modal_error(title, r);
});
return false;
}
function aliases_reset_form() {
$("#addaliasEmail").prop('disabled', false);
$("#addaliasEmail").val('')
$("#addaliasTargets").val('')
$('#alias-cancel').addClass('hidden');
$('#add-alias-button').text('Add');
is_alias_add_update = false;
}
function aliases_edit(elem) {
var email = $(elem).parents('tr').attr('data-email');
var targetdivs = $(elem).parents('tr').find('.target div');
var targets = "";
for (var i = 0; i < targetdivs.length; i++)
targets += $(targetdivs[i]).text() + "\n";
is_alias_add_update = true;
$('#alias-cancel').removeClass('hidden');
$("#addaliasEmail").prop('disabled', true);
$("#addaliasEmail").val(email);
$("#addaliasTargets").val(targets);
$('#add-alias-button').text('Update');
$('body').animate({ scrollTop: 0 })
}
function aliases_remove(elem) {
var email = $(elem).parents('tr').attr('data-email');
show_modal_confirm(
"Remove Alias",
"Remove " + email + "?",
"Remove",
function() {
api(
"/mail/aliases/remove",
"POST",
{
source: email
},
function(r) {
// Responses are multiple lines of pre-formatted text.
show_modal_error("Remove User", $("<pre/>").text(r));
show_aliases();
});
});
}
</script>

View File

@ -1,11 +1,341 @@
<!doctype html> <!DOCTYPE html>
<html> <!--[if lt IE 7]> <html class="no-js lt-ie9 lt-ie8 lt-ie7"> <![endif]-->
<!--[if IE 7]> <html class="no-js lt-ie9 lt-ie8"> <![endif]-->
<!--[if IE 8]> <html class="no-js lt-ie9"> <![endif]-->
<!--[if gt IE 8]><!--> <html class="no-js"> <!--<![endif]-->
<head> <head>
<title>Mail-in-a-Box Management Server</title> <meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport" content="width=device-width">
<title>{{hostname}} - Mail-in-a-Box Control Panel</title>
<meta name="robots" content="noindex, nofollow">
<link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css">
<style>
@import url(https://fonts.googleapis.com/css?family=Raleway:400,700);
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300);
html {
overflow-y: scroll;
}
body {
padding-top: 50px;
padding-bottom: 20px;
}
p {
margin-bottom: 1.25em;
}
h1, h2, h3 {
font-family: Raleway, sans-serif;
font-weight: bold;
}
h2 {
margin: 1em 0;
}
h3 {
font-size: 130%;
border-bottom: 1px solid black;
padding-bottom: 3px;
margin-bottom: 13px;
margin-top: 26px;
}
.panel {
display: none;
}
table.table {
margin: 1.5em 0;
}
</style>
<link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap-theme.min.css">
<style>
</style>
</head> </head>
<body> <body>
<h1>Mail-in-a-Box Management Server</h1> <!--[if lt IE 7]>
<p class="chromeframe">You are using an <strong>outdated</strong> browser. Please <a href="http://browsehappy.com/">upgrade your browser</a> or <a href="http://www.google.com/chromeframe/?redirect=true">activate Google Chrome Frame</a> to improve your experience.</p>
<![endif]-->
<div class="navbar navbar-inverse navbar-fixed-top">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="#">{{hostname}}</a>
</div>
<div class="navbar-collapse collapse">
<ul class="nav navbar-nav">
<li class="dropdown active">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">System <b class="caret"></b></a>
<ul class="dropdown-menu">
<li><a href="#system_status" onclick="return show_panel(this);">Status Checks</a></li>
<li><a href="#system_external_dns" onclick="return show_panel(this);">External DNS (Advanced)</a></li>
</ul>
</li>
<li class="dropdown active">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">Mail <b class="caret"></b></a>
<ul class="dropdown-menu">
<li><a href="#mail-guide" onclick="return show_panel(this);">Instructions</a></li>
<li><a href="#users" onclick="return show_panel(this);">Users</a></li>
<li><a href="#aliases" onclick="return show_panel(this);">Aliases</a></li>
<!--<li><a href="#">Another action</a></li>
<li><a href="#">Something else here</a></li>
<li class="divider"></li>
<li class="dropdown-header">Nav header</li>
<li><a href="#">Separated link</a></li>
<li><a href="#">One more separated link</a></li>-->
</ul>
</li>
</ul>
<ul class="nav navbar-nav navbar-right">
<li><a href="#" onclick="do_logout(); return false;" style="color: white">Log out?</a></li>
</ul>
</div><!--/.navbar-collapse -->
</div>
</div>
<p>Use this server to issue commands to the Mail-in-a-Box management daemon.</p> <div class="container-fluid">
<div id="panel_system_status" class="container panel">
{% include "system-status.html" %}
</div>
<div id="panel_system_external_dns" class="container panel">
{% include "system-external-dns.html" %}
</div>
<div id="panel_login" class="panel">
{% include "login.html" %}
</div>
<div id="panel_mail-guide" class="container panel">
{% include "mail-guide.html" %}
</div>
<div id="panel_users" class="container panel">
{% include "users.html" %}
</div>
<div id="panel_aliases" class="container panel">
{% include "aliases.html" %}
</div>
<hr>
<footer>
<p>This is a <a href="https://mailinabox.email">Mail-in-a-Box</a>.</p>
</footer>
</div> <!-- /container -->
<div id="ajax_loading_indicator" style="display: none; position: absolute; left: 0; top: 0; width: 100%; height: 100%; text-align: center; background-color: rgba(255,255,255,.75)">
<div style="margin: 20% auto">
<div><span class="glyphicon glyphicon-time"></span></div>
<div>Loading...</div>
</div>
</div>
<div id="global_modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="errorModalTitle" aria-hidden="true">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title" id="errorModalTitle"> </h4>
</div>
<div class="modal-body">
<p> </p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">OK</button>
<button type="button" class="btn btn-danger" data-dismiss="modal">Yes</button>
</div>
</div>
</div>
</div>
<script src="//ajax.googleapis.com/ajax/libs/jquery/1.10.1/jquery.min.js"></script>
<script src="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/js/bootstrap.min.js"></script>
<script>
var global_modal_state = null;
var global_modal_funcs = null;
$(function() {
$('#global_modal .btn-danger').click(function() {
// Don't take action now. Wait for the modal to be totally hidden
// so that we don't attempt to show another modal while this one
// is closing.
global_modal_state = 0; // OK
})
$('#global_modal .btn-default').click(function() {
global_modal_state = 1; // Cancel
})
$('#global_modal').on('hidden.bs.modal', function (e) {
// do the cancel function
if (global_modal_state == null) global_modal_state = 1; // cancel if the user hit ESC or clicked outside of the modal
if (global_modal_funcs && global_modal_funcs[global_modal_state])
global_modal_funcs[global_modal_state]();
})
})
function show_modal_error(title, message, callback) {
$('#global_modal h4').text(title);
$('#global_modal .modal-body').html("<p/>");
if (typeof question == String) {
$('#global_modal p').text(message);
$('#global_modal .modal-dialog').addClass("modal-sm");
} else {
$('#global_modal p').html("").append(message);
$('#global_modal .modal-dialog').removeClass("modal-sm");
}
$('#global_modal .btn-default').show().text("OK");
$('#global_modal .btn-danger').hide();
global_modal_funcs = [callback, callback];
global_modal_state = null;
$('#global_modal').modal({});
}
function show_modal_confirm(title, question, verb, yes_callback, cancel_callback) {
$('#global_modal h4').text(title);
if (typeof question == String) {
$('#global_modal .modal-dialog').addClass("modal-sm");
$('#global_modal .modal-body').html("<p/>");
$('#global_modal p').text(question);
} else {
$('#global_modal .modal-dialog').removeClass("modal-sm");
$('#global_modal .modal-body').html("").append(question);
}
$('#global_modal .btn-default').show().text("Cancel");
$('#global_modal .btn-danger').show().text(verb);
global_modal_funcs = [yes_callback, cancel_callback];
global_modal_state = null;
$('#global_modal').modal({});
}
var is_ajax_loading = false;
function ajax(options) {
setTimeout("if (is_ajax_loading) $('#ajax_loading_indicator').fadeIn()", 100);
function hide_loading_indicator() {
is_ajax_loading = false;
$('#ajax_loading_indicator').hide();
}
var old_success = options.success;
var old_error = options.error;
options.success = function(data) {
hide_loading_indicator();
if (data.status == "error")
show_modal_error("Error", data.message);
else if (old_success)
old_success(data);
};
options.error = function(jqxhr) {
hide_loading_indicator();
if (!old_error)
show_modal_error("Error", "Something went wrong, sorry.")
else
old_error(jqxhr.responseText);
};
is_ajax_loading = true;
$.ajax(options);
}
var api_credentials = ["", ""];
function api(url, method, data, callback, callback_error) {
// from http://www.webtoolkit.info/javascript-base64.html
function base64encode(input) {
_keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
var output = "";
var chr1, chr2, chr3, enc1, enc2, enc3, enc4;
var i = 0;
while (i < input.length) {
chr1 = input.charCodeAt(i++);
chr2 = input.charCodeAt(i++);
chr3 = input.charCodeAt(i++);
enc1 = chr1 >> 2;
enc2 = ((chr1 & 3) << 4) | (chr2 >> 4);
enc3 = ((chr2 & 15) << 2) | (chr3 >> 6);
enc4 = chr3 & 63;
if (isNaN(chr2)) {
enc3 = enc4 = 64;
} else if (isNaN(chr3)) {
enc4 = 64;
}
output = output +
_keyStr.charAt(enc1) + _keyStr.charAt(enc2) +
_keyStr.charAt(enc3) + _keyStr.charAt(enc4);
}
return output;
}
ajax({
url: "/admin" + url,
method: method,
data: data,
beforeSend: function(xhr) {
// We don't store user credentials in a cookie to avoid the hassle of CSRF
// attacks. The Authorization header only gets set in our AJAX calls triggered
// by user actions.
xhr.setRequestHeader(
'Authorization',
'Basic ' + base64encode(api_credentials[0] + ':' + api_credentials[1]));
},
success: callback,
error: callback_error,
statusCode: {
403: function(xhr) {
// Credentials are no longer valid. Try to login again.
var p = current_panel;
show_panel('login');
switch_back_to_panel = p;
}
}
})
}
var current_panel = null;
var switch_back_to_panel = null;
function show_panel(panelid) {
if (panelid.getAttribute)
// we might be passed an HTMLElement <a>.
panelid = panelid.getAttribute('href').substring(1);
$('.panel').hide();
$('#panel_' + panelid).show();
if (typeof localStorage != 'undefined')
localStorage.setItem("miab-cp-lastpanel", panelid);
if (window["show_" + panelid])
window["show_" + panelid]();
current_panel = panelid;
switch_back_to_panel = null;
return false; // when called from onclick, cancel navigation
}
$(function() {
// Recall saved user credentials.
if (typeof sessionStorage != 'undefined' && sessionStorage.getItem("miab-cp-credentials"))
api_credentials = sessionStorage.getItem("miab-cp-credentials").split(":");
else if (typeof localStorage != 'undefined' && localStorage.getItem("miab-cp-credentials"))
api_credentials = localStorage.getItem("miab-cp-credentials").split(":");
// Recall what the user was last looking at.
if (typeof localStorage != 'undefined' && localStorage.getItem("miab-cp-lastpanel")) {
show_panel(localStorage.getItem("miab-cp-lastpanel"));
} else {
show_panel('login');
}
})
</script>
</body> </body>
</html> </html>

View File

@ -0,0 +1,102 @@
<div class="row">
<div class="col-sm-offset-2 col-sm-8 col-md-offset-3 col-md-6 col-lg-offset-4 col-lg-4">
<center>
<h1 style="margin: 1em">{{hostname}}</h1>
<p style="margin: 2em">Log in here for your Mail-in-a-Box control panel.</p>
</center>
<form class="form-horizontal" role="form" onsubmit="do_login(); return false;">
<div class="form-group">
<label for="inputEmail3" class="col-sm-2 control-label">Email</label>
<div class="col-sm-10">
<input name="email" type="email" class="form-control" id="loginEmail" placeholder="Email">
</div>
</div>
<div class="form-group">
<label for="inputPassword3" class="col-sm-2 control-label">Password</label>
<div class="col-sm-10">
<input name="password" type="password" class="form-control" id="loginPassword" placeholder="Password">
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<div class="checkbox">
<label>
<input name='remember' type="checkbox" id="loginRemember"> Remember me
</label>
</div>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button type="submit" class="btn btn-default">Sign in</button>
</div>
</div>
</form>
</div>
</div>
<script>
function do_login() {
if ($('#loginEmail').val() == "") {
show_modal_error("Login Failed", "Enter your email address.")
return false;
}
if ($('#loginPassword').val() == "") {
show_modal_error("Login Failed", "Enter your email password.")
return false;
}
// Exchange the email address & password for an API key.
api_credentials = [$('#loginEmail').val(), $('#loginPassword').val()]
api(
"/me",
"GET",
{ },
function(response){
// This API call always succeeds. It returns a JSON object indicating
// whether the request was authenticated or not.
if (response.status != "authorized") {
// Show why the login failed.
show_modal_error("Login Failed", response.reason)
// Reset any saved credentials.
do_logout();
} else {
// Login succeeded.
// Save the new credentials.
api_credentials = [response.api_key, ""];
// Try to wipe the username/password information.
$('#loginEmail').val('');
$('#loginPassword').val('');
// Remember the credentials.
if (typeof localStorage != 'undefined' && typeof sessionStorage != 'undefined') {
if ($('#loginRemember').val()) {
localStorage.setItem("miab-cp-credentials", api_credentials.join(":"));
sessionStorage.removeItem("miab-cp-credentials");
} else {
localStorage.removeItem("miab-cp-credentials");
sessionStorage.setItem("miab-cp-credentials", api_credentials.join(":"));
}
}
// Open the next panel the user wants to go to.
show_panel(!switch_back_to_panel ? 'system_status' : switch_back_to_panel)
}
})
}
function do_logout() {
api_credentials = ["", ""];
if (typeof localStorage != 'undefined')
localStorage.removeItem("miab-cp-credentials");
if (typeof sessionStorage != 'undefined')
sessionStorage.removeItem("miab-cp-credentials");
show_panel('login');
}
</script>

View File

@ -0,0 +1,41 @@
<div class="container">
<h2>Checking and Sending Mail</h2>
<h4>App Configuration</h4>
<p>You can access your email using webmail, desktop mail clients, or mobile apps.</p>
<p>Here is what you need to know for webmail:</p>
<style>#panel_mail-guide table.table { width: auto; margin-left: 1.5em; }</style>
<table class="table">
<tr><th>Webmail Address:</th> <td><a href="https://{{hostname}}/mail"><b>https://{{hostname}}/mail</b></a></td></tr>
<tr><th>Username:</th> <td>Your whole email address.</td></tr>
<tr><th>Password:</th> <td>Your mail password.</td></tr>
</table>
<p>On mobile devices you might need to install a &ldquo;mail client&rdquo; app. We recommend <a href="https://play.google.com/store/apps/details?id=com.fsck.k9">K-9 Mail</a>. On a desktop you could try <a href="https://www.mozilla.org/en-US/thunderbird/">Mozilla Thunderbird</a>.</p>
<p>Configure your device or desktop mail client as follows:</p>
<table class="table" style="max-width: 30em">
<tr><th>Server Name:</th> <td>{{hostname}}</td></tr>
<tr><th>Username:</th> <td>Your whole email address.</td></tr>
<tr><th>Password:</th> <td>Your mail password.</td></tr>
</table>
<table class="table">
<thead><tr><th>Protocol</th> <th>Port</th> <th>Options</th></tr></thead>
<tr><th>IMAP</th> <td>993</td> <td>SSL</td></tr>
<tr><th>SMTP</th> <td>587</td> <td>STARTTLS</td></tr>
<tr><th>Exchange ActiveSync</th> <td>n/a</td> <td>Secure Connection</td></tr>
</table>
<p>Depending on your mail program, you will use either IMAP &amp; SMTP or Exchange ActiveSync. See this <a href="http://z-push.org/compatibility/">list of compatible devices</a> for Exchange ActiveSync.</p>
<h4>Notes</h4>
<p>Mail-in-a-Box uses <a href="http://en.wikipedia.org/wiki/Greylisting">greylisting</a> to cut down on spam. The first time you receive an email from a recipient, it may be delayed for ten minutes.</p>
</div>

View File

@ -0,0 +1,81 @@
<style>
#external_dns_settings .heading td {
font-weight: bold;
font-size: 120%;
padding-top: 1.5em;
}
#external_dns_settings .heading.first td {
border-top: none;
padding-top: 0;
}
#external_dns_settings .values td {
padding-top: .75em;
padding-bottom: 0;
max-width: 50vw;
word-wrap: break-word;
}
#external_dns_settings .explanation td {
border: 0;
padding-top: .5em;
padding-bottom: .75em;
font-style: italic;
color: #777;
}
</style>
<h2>External DNS</h2>
<p class="text-danger">This is for advanced configurations.</p>
<h3>Overview</h3>
<p>Although your box is configured to serve its own DNS, it is possible to host your DNS elsewhere. We do not recommend this.</p>
<p>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.</p>
<h3>DNS Settings</h3>
<p>Enter the following DNS entries at your DNS provider:</p>
<table id="external_dns_settings" class="table">
<thead>
<tr>
<th>QName</th>
<th>Type</th>
<th>Value</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<script>
function show_system_external_dns() {
$('#external_dns_settings tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
api(
"/dns/dump",
"GET",
{ },
function(zones) {
$('#external_dns_settings tbody').html("");
for (var j = 0; j < zones.length; j++) {
var h = $("<tr class='heading'><td colspan='3'></td></tr>");
h.find("td").text(zones[j][0]);
$('#external_dns_settings tbody').append(h);
var r = zones[j][1];
for (var i = 0; i < r.length; i++) {
var n = $("<tr class='values'><td class='qname'/><td class='rtype'/><td class='value'/></tr>");
n.find('.qname').text(r[i].qname);
n.find('.rtype').text(r[i].rtype);
n.find('.value').text(r[i].value);
$('#external_dns_settings tbody').append(n);
var n = $("<tr class='explanation'><td colspan='3'/></tr>");
n.find('td').text(r[i].explanation);
$('#external_dns_settings tbody').append(n);
}
}
})
}
</script>

View File

@ -0,0 +1,79 @@
<h2>System Status Checks</h2>
<style>
#system-checks .heading td {
font-weight: bold;
font-size: 120%;
padding-top: 1.5em;
}
#system-checks .heading.first td {
border-top: none;
padding-top: 0;
}
#system-checks .error td {
color: #733;
}
#system-checks .ok td {
color: #030;
}
#system-checks div.extra {
display: none;
margin-top: 1em;
max-width: 50em;
word-wrap: break-word;
}
#system-checks a.showhide {
display: none;
font-size: 85%;
}
#system-checks .pre {
margin: 1em;
font-family: monospace;
white-space: pre-wrap;
}
</style>
<table id="system-checks" class="table" style="max-width: 60em">
<thead>
</thead>
<tbody>
</tbody>
</table>
<script>
function show_system_status() {
$('#system-checks tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
api(
"/system/status",
"POST",
{ },
function(r) {
$('#system-checks tbody').html("");
for (var i = 0; i < r.length; i++) {
var n = $("<tr><td class='status'/><td class='message'><p style='margin: 0'/><div class='extra'/><a class='showhide' href='#'/></tr>");
if (i == 0) n.addClass('first')
n.addClass(r[i].type)
if (r[i].type == "ok") n.find('td.status').text("✓")
if (r[i].type == "error") n.find('td.status').text("✖")
n.find('td.message p').text(r[i].text)
$('#system-checks tbody').append(n);
if (r[i].extra.length > 0) {
n.find('a.showhide').show().text("show more").click(function() {
$(this).hide();
$(this).parent().find('.extra').fadeIn();
return false;
});
}
for (var j = 0; j < r[i].extra.length; j++) {
var m = $("<div/>").text(r[i].extra[j].text)
if (r[i].extra[j].monospace)
m.addClass("pre");
n.find('> td.message > div').append(m);
}
}
})
}
</script>

View File

@ -0,0 +1,196 @@
<h2>Users</h2>
<style>
#user_table tr.account_inactive td .address { color: #888; text-decoration: line-through; }
#user_table .aliases { margin: .25em 0 0 1em; font-size: 95%; }
#user_table .aliases div:before { content: "⇖ "; }
#user_table .aliases div { }
#user_table .actions { margin: .25em 0 0 1em; font-size: 95%; }
#user_table .actions > * { display: none; }
#user_table .account_active .actions a.archive { display: inline; }
#user_table .account_inactive .actions .restore { display: inline; }
</style>
<h3>Add a mail user</h3>
<p>Add an email address to this system. This will create a new login username/password. (Use <a href="javascript:show_panel('aliases')">aliases</a> to create email addresses that forward to existing accounts.)</p>
<form class="form-inline" role="form" onsubmit="return do_add_user(); return false;">
<div class="form-group">
<label class="sr-only" for="adduserEmail">Email address</label>
<input type="email" class="form-control" id="adduserEmail" placeholder="Email Address">
</div>
<div class="form-group">
<label class="sr-only" for="adduserPassword">Password</label>
<input type="password" class="form-control" id="adduserPassword" placeholder="Password">
</div>
<div class="form-group">
<select class="form-control" id="adduserPrivs">
<option value="">Normal User</option>
<option value="admin">Administrator</option>
</select>
</div>
<button type="submit" class="btn btn-primary">Add User</button>
</form>
<p style="margin-top: .5em"><small>
Passwords must be at least four characters and may not contain spaces.
Administrators get access to this control panel.
</small></p>
<h3>Existing mail users</h3>
<table id="user_table" class="table" style="width: auto">
<thead>
<tr>
<th></th>
<th>Email Address<br><small style="font-weight: normal">(Also the user&rsquo;s login username.)</small></th>
<th>Privileges</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<div style="display: none">
<table>
<tr id="user-template">
<td class='actions'>
<a href="#" onclick="users_remove(this); return false;" class='archive' title="Archive Account">
<span class="glyphicon glyphicon-trash"></span>
</a>
</td>
<td class='email'>
<div class='address'> </div>
<div class='aliases' style='display: none'> </div>
<div class='actions'>
<span class='restore'>To restore account, create a new account with this email address.</span>
</div>
</td>
<td class='privs'> </td>
</tr>
</table>
</div>
<script>
function show_users() {
$('#user_table tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
api(
"/mail/users",
"GET",
{ format: 'json' },
function(r) {
$('#user_table tbody').html("");
for (var i = 0; i < r.length; i++) {
var n = $("#user-template").clone();
n.attr('id', '');
n.addClass("account_" + r[i].status);
n.attr('data-email', r[i].email);
n.find('td.email .address').text(r[i].email)
var add_privs = ["admin"];
for (var j = 0; j < r[i].privileges.length; j++) {
var p = $("<div><span class='name'></span> <a href='#' onclick='mod_priv(this, \"remove\"); return false;'><span class=\"glyphicon glyphicon-trash\" style='font-size: 90%'></span></a></div>");
p.find('span.name').text(r[i].privileges[j]);
n.find('td.privs').append(p);
if (add_privs.indexOf(r[i].privileges[j]) >= 0)
add_privs.splice(add_privs.indexOf(r[i].privileges[j]), 1);
}
for (var j = 0; j < add_privs.length; j++) {
var p = $("<div><small><a href='#' onclick='mod_priv(this, \"add\"); return false;'><span class=\"glyphicon glyphicon-plus\" style='font-size: 90%'></span> <span class='name'></span>?</a></small></div>");
p.find('span.name').text(add_privs[j]);
n.find('td.privs').append(p);
}
if (r[i].aliases && r[i].aliases.length > 0) {
n.find('.aliases').show();
for (var j = 0; j < r[i].aliases.length; j++) {
n.find('td.email .aliases').append($("<div/>").text(
r[i].aliases[j][0]
+ (r[i].aliases[j][1].length > 0 ? " ⇐ " + r[i].aliases[j][1].join(", ") : "")
))
}
}
$('#user_table tbody').append(n);
}
})
}
function do_add_user() {
var email = $("#adduserEmail").val();
var pw = $("#adduserPassword").val();
var privs = $("#adduserPrivs").val();
api(
"/mail/users/add",
"POST",
{
email: email,
password: pw,
privileges: privs
},
function(r) {
// Responses are multiple lines of pre-formatted text.
show_modal_error("Add User", $("<pre/>").text(r));
show_users()
},
function(r) {
show_modal_error("Add User", r);
});
return false;
}
function users_remove(elem) {
var email = $(elem).parents('tr').attr('data-email');
show_modal_confirm(
"Archive User",
$("<p>Are you sure you want to archive " + email + "?</p> <p>The user's mailboxes will not be deleted (you can do that later), but the user will no longer be able to log into any services on this machine.</p>"),
"Archive",
function() {
api(
"/mail/users/remove",
"POST",
{
email: email
},
function(r) {
// Responses are multiple lines of pre-formatted text.
show_modal_error("Remove User", $("<pre/>").text(r));
show_users();
},
function(r) {
show_modal_error("Remove User", r);
});
});
}
function mod_priv(elem, add_remove) {
var email = $(elem).parents('tr').attr('data-email');
var priv = $(elem).parents('td').find('.name').text();
// can't remove your own admin access
if (priv == "admin" && add_remove == "remove" && api_credentials != null && email == api_credentials[0]) {
show_modal_error("Modify Privileges", "You cannot remove the admin privilege from yourself.");
return;
}
var add_remove1 = add_remove.charAt(0).toUpperCase() + add_remove.substring(1);
show_modal_confirm(
"Modify Privileges",
"Are you sure you want to " + add_remove + " the " + priv + " privilege for " + email + "?",
add_remove1,
function() {
api(
"/mail/users/privileges/" + add_remove,
"POST",
{
email: email,
privilege: priv
},
function(r) {
show_users();
});
});
}
</script>

View File

@ -23,6 +23,10 @@ def safe_domain_name(name):
import urllib.parse import urllib.parse
return urllib.parse.quote(name, safe='') 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): def sort_domains(domain_names, env):
# Put domain names in a nice sorted order. For web_update, PRIMARY_HOSTNAME # Put domain names in a nice sorted order. For web_update, PRIMARY_HOSTNAME
# must appear first so it becomes the nginx default server. # 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] 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): def exclusive_process(name):
# Ensure that a process named `name` does not execute multiple # Ensure that a process named `name` does not execute multiple
# times concurrently. # times concurrently.

View File

@ -59,8 +59,11 @@ def do_web_update(env):
with open(nginx_conf_fn, "w") as f: with open(nginx_conf_fn, "w") as f:
f.write(nginx_conf) f.write(nginx_conf)
# Kick nginx. # Kick nginx. Since this might be called from the web admin
shell('check_call', ["/usr/sbin/service", "nginx", "restart"]) # 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" return "web updated\n"

View File

@ -16,31 +16,29 @@ from mailconfig import get_mail_domains, get_mail_aliases
from utils import shell, sort_domains, load_env_vars_from_file from utils import shell, sort_domains, load_env_vars_from_file
def run_checks(env): def run_checks(env, output):
env["out"] = output
run_system_checks(env) run_system_checks(env)
run_domain_checks(env) run_domain_checks(env)
def run_system_checks(env): def run_system_checks(env):
print("System") env["out"].add_heading("System")
print("======")
# Check that SSH login with password is disabled. # Check that SSH login with password is disabled.
sshd = open("/etc/ssh/sshd_config").read() sshd = open("/etc/ssh/sshd_config").read()
if re.search("\nPasswordAuthentication\s+yes", sshd) \ if re.search("\nPasswordAuthentication\s+yes", sshd) \
or not re.search("\nPasswordAuthentication\s+no", 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 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 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'.""") /etc/ssh/sshd_config, and then restart the openssh via 'sudo service ssh restart'.""")
else: 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 # Check that the administrator alias exists since that's where all
# admin email is automatically directed. # admin email is automatically directed.
check_alias_exists("administrator@" + env['PRIMARY_HOSTNAME'], env) check_alias_exists("administrator@" + env['PRIMARY_HOSTNAME'], env)
print()
def run_domain_checks(env): def run_domain_checks(env):
# Get the list of domains we handle mail for. # Get the list of domains we handle mail for.
mail_domains = get_mail_domains(env) mail_domains = get_mail_domains(env)
@ -54,8 +52,7 @@ def run_domain_checks(env):
# Check the domains. # Check the domains.
for domain in sort_domains(mail_domains | dns_domains | web_domains, env): for domain in sort_domains(mail_domains | dns_domains | web_domains, env):
print(domain) env["out"].add_heading(domain)
print("=" * len(domain))
if domain == env["PRIMARY_HOSTNAME"]: if domain == env["PRIMARY_HOSTNAME"]:
check_primary_hostname_dns(domain, env) check_primary_hostname_dns(domain, env)
@ -69,16 +66,14 @@ def run_domain_checks(env):
if domain in web_domains: if domain in web_domains:
check_web_domain(domain, env) check_web_domain(domain, env)
print()
def check_primary_hostname_dns(domain, env): def check_primary_hostname_dns(domain, env):
# Check that the ns1/ns2 hostnames resolve to A records. This information probably # Check that the ns1/ns2 hostnames resolve to A records. This information probably
# comes from the TLD since the information is set at the registrar. # comes from the TLD since the information is set at the registrar.
ip = query_dns("ns1." + domain, "A") + '/' + query_dns("ns2." + domain, "A") ip = query_dns("ns1." + domain, "A") + '/' + query_dns("ns2." + domain, "A")
if ip == env['PUBLIC_IP'] + '/' + env['PUBLIC_IP']: 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: 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 registrar as having the IP address %s. They currently report addresses of %s. It may take several hours for
public DNS to update after a change.""" public DNS to update after a change."""
% (env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'], ip)) % (env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'], ip))
@ -86,9 +81,9 @@ def check_primary_hostname_dns(domain, env):
# Check that PRIMARY_HOSTNAME resolves to PUBLIC_IP in public DNS. # Check that PRIMARY_HOSTNAME resolves to PUBLIC_IP in public DNS.
ip = query_dns(domain, "A") ip = query_dns(domain, "A")
if ip == env['PUBLIC_IP']: 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: 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 to %s. It may take several hours for public DNS to update after a change. This problem may result from other
issues listed here.""" issues listed here."""
% (env['PUBLIC_IP'], ip)) % (env['PUBLIC_IP'], ip))
@ -98,9 +93,9 @@ def check_primary_hostname_dns(domain, env):
ipaddr_rev = dns.reversename.from_address(env['PUBLIC_IP']) ipaddr_rev = dns.reversename.from_address(env['PUBLIC_IP'])
existing_rdns = query_dns(ipaddr_rev, "PTR") existing_rdns = query_dns(ipaddr_rev, "PTR")
if existing_rdns == domain: 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: 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']) ) on setting up reverse DNS for your box at %s.""" % (existing_rdns, domain, env['PUBLIC_IP']) )
# Check the TLSA record. # Check the TLSA record.
@ -108,11 +103,11 @@ def check_primary_hostname_dns(domain, env):
tlsa25 = query_dns(tlsa_qname, "TLSA", nxdomain=None) tlsa25 = query_dns(tlsa_qname, "TLSA", nxdomain=None)
tlsa25_expected = build_tlsa_record(env) tlsa25_expected = build_tlsa_record(env)
if tlsa25 == tlsa25_expected: 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: 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: 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 regenerate the record. It may take several hours for
public DNS to update after a change.""" public DNS to update after a change."""
% (tlsa_qname, tlsa25, tlsa25_expected)) % (tlsa_qname, tlsa25, tlsa25_expected))
@ -123,9 +118,9 @@ def check_primary_hostname_dns(domain, env):
def check_alias_exists(alias, env): def check_alias_exists(alias, env):
mail_alises = dict(get_mail_aliases(env)) mail_alises = dict(get_mail_aliases(env))
if alias in mail_alises: 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: 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): def check_dns_zone(domain, env, dns_zonefiles):
# We provide a DNS zone for the domain. It should have NS records set up # 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") existing_ns = query_dns(domain, "NS")
correct_ns = "ns1.BOX; ns2.BOX".replace("BOX", env['PRIMARY_HOSTNAME']) correct_ns = "ns1.BOX; ns2.BOX".replace("BOX", env['PRIMARY_HOSTNAME'])
if existing_ns == correct_ns: 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: 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.""" control panel to set the nameservers to %s."""
% (existing_ns, correct_ns) ) % (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 ds_looks_valid = ds and len(ds.split(" ")) == 4
if ds_looks_valid: ds = ds.split(" ") 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]): 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: else:
if ds == None: 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:""") To set a DS record, you must follow the instructions provided by your domain name registrar and provide to them this information:""")
else: 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 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 make a change, you must resolve this immediately by following the instructions provided by your domain name registrar and
provide to them this information:""") provide to them this information:""")
print() env['out'].print_line("")
print("\tKey Tag: " + ds_keytag + ("" if not ds_looks_valid or ds[0] == ds_keytag else " (Got '%s')" % ds[0])) env['out'].print_line("Key Tag: " + ds_keytag + ("" if not ds_looks_valid or ds[0] == ds_keytag else " (Got '%s')" % ds[0]))
print("\tKey Flags: KSK") env['out'].print_line("Key Flags: KSK")
print("\tAlgorithm: 7 / RSASHA1-NSEC3-SHA1" + ("" if not ds_looks_valid or ds[1] == '7' else " (Got '%s')" % ds[1])) env['out'].print_line("Algorithm: 7 / RSASHA1-NSEC3-SHA1" + ("" if not ds_looks_valid or ds[1] == '7' else " (Got '%s')" % ds[1]))
print("\tDigest Type: 2 / SHA-256") env['out'].print_line("Digest Type: 2 / SHA-256")
print("\tDigest: " + digests['2']) env['out'].print_line("Digest: " + digests['2'])
if ds_looks_valid and ds[3] != digests.get(ds[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])) env['out'].print_line("(Got digest type %s and digest %s which do not match.)" % (ds[2], ds[3]))
print("\tPublic Key: " + dnsssec_pubkey) env['out'].print_line("Public Key: ")
print() env['out'].print_line(dnsssec_pubkey, monospace=True)
print("\tBulk/Record Format:") env['out'].print_line("")
print("\t" + ds_correct[0]) env['out'].print_line("Bulk/Record Format:")
print("") env['out'].print_line("" + ds_correct[0])
env['out'].print_line("")
def check_mail_domain(domain, env): def check_mail_domain(domain, env):
# Check the MX record. # Check the MX record.
@ -189,14 +185,14 @@ def check_mail_domain(domain, env):
expected_mx = "10 " + env['PRIMARY_HOSTNAME'] expected_mx = "10 " + env['PRIMARY_HOSTNAME']
if mx == expected_mx: 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: elif mx == None:
# A missing MX record is okay on the primary hostname because # A missing MX record is okay on the primary hostname because
# the primary hostname's A record (the MX fallback) is... itself, # the primary hostname's A record (the MX fallback) is... itself,
# which is what we want the MX to be. # which is what we want the MX to be.
if domain == env['PRIMARY_HOSTNAME']: 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 # 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 # 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) domain_a = query_dns(domain, "A", nxdomain=None)
primary_a = query_dns(env['PRIMARY_HOSTNAME'], "A", nxdomain=None) primary_a = query_dns(env['PRIMARY_HOSTNAME'], "A", nxdomain=None)
if domain_a != None and domain_a == primary_a: 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: 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 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,)) change. This problem may result from other issues listed here.""" % (expected_mx,))
else: 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 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)) other issues listed here.""" % (mx, expected_mx))
@ -226,9 +222,9 @@ def check_web_domain(domain, env):
if domain != env['PRIMARY_HOSTNAME']: if domain != env['PRIMARY_HOSTNAME']:
ip = query_dns(domain, "A") ip = query_dns(domain, "A")
if ip == env['PUBLIC_IP']: 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: 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 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)) 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. # Check that SSL certificate is signed.
# Skip the check if the A record is not pointed here. # Skip the check if the A record is not pointed here.
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? # Where is the SSL stored?
ssl_key, ssl_certificate, ssl_csr_path = get_domain_ssl_files(domain, env) ssl_key, ssl_certificate, ssl_csr_path = get_domain_ssl_files(domain, env)
if not os.path.exists(ssl_certificate): 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 return
# Check that the certificate is good. # Check that the certificate is good.
@ -280,34 +276,34 @@ def check_ssl_cert(domain, env):
fingerprint = re.sub(".*Fingerprint=", "", fingerprint).strip() fingerprint = re.sub(".*Fingerprint=", "", fingerprint).strip()
if domain == env['PRIMARY_HOSTNAME']: 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 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 static site hosting). You may choose to confirm the security exception, but check that the certificate
fingerprint matches the following:""") fingerprint matches the following:""")
print() env['out'].print_line("")
print(" " + fingerprint) env['out'].print_line(" " + fingerprint, monospace=True)
else: 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 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.""") safe to leave the self-signed certificate in place.""")
print() env['out'].print_line("")
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("""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:""") to whoever you purchase the SSL certificate from:""")
print() env['out'].print_line("")
print(open(ssl_csr_path).read().strip()) env['out'].print_line(open(ssl_csr_path).read().strip(), monospace=True)
print() env['out'].print_line("")
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("""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 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) below it. Save the file and place it onto this machine at %s. Then run "service nginx restart".""" % ssl_certificate)
elif cert_status == "OK": elif cert_status == "OK":
print_ok("SSL certificate is signed & valid.") env['out'].print_ok("SSL certificate is signed & valid.")
else: else:
print_error("The SSL certificate has a problem:") env['out'].print_error("The SSL certificate has a problem:")
print("") env['out'].print_line("")
print(cert_status) env['out'].print_line(cert_status)
print("") env['out'].print_line("")
def check_certificate(domain, ssl_certificate, ssl_private_key): def check_certificate(domain, ssl_certificate, ssl_private_key):
# Use openssl verify to check the status of a certificate. # Use openssl verify to check the status of a certificate.
@ -398,17 +394,23 @@ def check_certificate(domain, ssl_certificate, ssl_private_key):
else: else:
return verifyoutput.strip() return verifyoutput.strip()
def print_ok(message):
print_block(message, first_line="")
def print_error(message):
print_block(message, first_line="")
try: try:
terminal_columns = int(shell('check_output', ['stty', 'size']).split()[1]) terminal_columns = int(shell('check_output', ['stty', 'size']).split()[1])
except: except:
terminal_columns = 76 terminal_columns = 76
def print_block(message, first_line=" "): 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='') print(first_line, end='')
message = re.sub("\n\s*", " ", message) message = re.sub("\n\s*", " ", message)
words = re.split("(\s+)", message) words = re.split("(\s+)", message)
@ -421,9 +423,27 @@ def print_block(message, first_line=" "):
if linelen == 0 and w.strip() == "": continue if linelen == 0 and w.strip() == "": continue
print(w, end="") print(w, end="")
linelen += len(w) linelen += len(w)
if linelen > 0:
print() print()
def print_line(self, message, monospace=False):
for line in message.split("\n"):
self.print_block(line)
if __name__ == "__main__": if __name__ == "__main__":
import sys
from utils import load_environment 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)

View File

@ -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 -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 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

View File

@ -322,3 +322,28 @@ if [ -z "`tools/mail.py user`" ]; then
tools/mail.py alias add administrator@$PRIMARY_HOSTNAME $EMAIL_ADDR tools/mail.py alias add administrator@$PRIMARY_HOSTNAME $EMAIL_ADDR
fi 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

View File

@ -12,6 +12,10 @@ def mgmt(cmd, data=None, is_json=False):
response = urllib.request.urlopen(req) response = urllib.request.urlopen(req)
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
if e.code == 401: 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) 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'): elif hasattr(e, 'read'):
print(e.read().decode('utf8'), file=sys.stderr) print(e.read().decode('utf8'), file=sys.stderr)