465bd4dc57
* Not all relays provide their own DKIM signatures (which is ok) * Closes #64
1309 lines
37 KiB
Python
Executable file
1309 lines
37 KiB
Python
Executable file
#!/usr/local/lib/mailinabox/env/bin/python3
|
|
#
|
|
# The API can be accessed on the command line, e.g. use `curl` like so:
|
|
# curl --user $(</var/lib/mailinabox/api.key): http://localhost:10222/mail/users
|
|
#
|
|
# During development, you can start the Mail-in-a-Box control panel
|
|
# by running this script, e.g.:
|
|
#
|
|
# service mailinabox stop # stop the system process
|
|
# DEBUG=1 management/daemon.py
|
|
# service mailinabox start # when done debugging, start it up again
|
|
|
|
import os
|
|
import os.path
|
|
import re
|
|
import json
|
|
import time
|
|
import multiprocessing.pool
|
|
import subprocess
|
|
|
|
from functools import wraps
|
|
|
|
from flask import Flask, request, render_template, abort, Response, send_from_directory, make_response
|
|
|
|
import auth
|
|
import utils
|
|
from mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, remove_mail_user
|
|
from mailconfig import get_mail_user_privileges, add_remove_mail_user_privilege, open_database
|
|
from mailconfig import get_mail_aliases, get_mail_aliases_ex, get_mail_domains, add_mail_alias, remove_mail_alias
|
|
from mailconfig import get_mail_quota, set_mail_quota, get_default_quota, validate_quota
|
|
from mfa import get_public_mfa_state, provision_totp, validate_totp_secret, enable_mfa, disable_mfa
|
|
|
|
env = utils.load_environment()
|
|
|
|
auth_service = auth.AuthService()
|
|
|
|
# We may deploy via a symbolic link, which confuses flask's template finding.
|
|
me = __file__
|
|
try:
|
|
me = os.readlink(__file__)
|
|
except OSError:
|
|
pass
|
|
|
|
# for generating CSRs we need a list of country codes
|
|
csr_country_codes = []
|
|
with open(os.path.join(os.path.dirname(me), "csr_country_codes.tsv")) as f:
|
|
for line in f:
|
|
if line.strip() == "" or line.startswith("#"):
|
|
continue
|
|
code, name = line.strip().split("\t")[0:2]
|
|
csr_country_codes.append((code, name))
|
|
|
|
app = Flask(__name__,
|
|
template_folder=os.path.abspath(
|
|
os.path.join(os.path.dirname(me), "templates")))
|
|
|
|
# Decorator to protect views that require a user with 'admin' privileges.
|
|
|
|
def authorized_personnel_only(admin = True):
|
|
def gatekeeper(viewfunc):
|
|
|
|
@wraps(viewfunc)
|
|
def newview(*args, **kwargs):
|
|
# Authenticate the passed credentials, which is either the API key or a username:password pair
|
|
# and an optional X-Auth-Token token.
|
|
error = None
|
|
privs = []
|
|
|
|
try:
|
|
email, privs = auth_service.authenticate(request, env)
|
|
|
|
# Store the email address of the logged in user so it can be accessed
|
|
# from the API methods that affect the calling user.
|
|
request.user_email = email
|
|
request.user_privs = privs
|
|
|
|
if not admin or "admin" in privs:
|
|
return viewfunc(*args, **kwargs)
|
|
else:
|
|
error = "You are not an administrator."
|
|
except ValueError as e:
|
|
# Write a line in the log recording the failed login, unless no authorization header
|
|
# was given which can happen on an initial request before a 403 response.
|
|
if "Authorization" in request.headers:
|
|
log_failed_login(request)
|
|
|
|
# Authentication failed.
|
|
error = str(e)
|
|
|
|
# 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),
|
|
'X-Reason': error,
|
|
}
|
|
|
|
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(error + "\n",
|
|
status=status,
|
|
mimetype='text/plain',
|
|
headers=headers)
|
|
else:
|
|
# Return JSON output.
|
|
return Response(json.dumps({
|
|
"status": "error",
|
|
"reason": error,
|
|
}) + "\n",
|
|
status=status,
|
|
mimetype='application/json',
|
|
headers=headers)
|
|
|
|
return newview
|
|
|
|
return gatekeeper
|
|
|
|
|
|
@app.errorhandler(401)
|
|
def unauthorized(error):
|
|
return auth_service.make_unauthorized_response()
|
|
|
|
|
|
def json_response(data, status=200):
|
|
return Response(json.dumps(data, indent=2, sort_keys=True) + '\n',
|
|
status=status,
|
|
mimetype='application/json')
|
|
|
|
|
|
###################################
|
|
|
|
# Control Panel (unauthenticated views)
|
|
|
|
|
|
@app.route('/')
|
|
def index():
|
|
# Render the control panel. This route does not require user authentication
|
|
# so it must be safe!
|
|
|
|
no_users_exist = (len(get_mail_users(env)) == 0)
|
|
no_admins_exist = (len(get_admins(env)) == 0)
|
|
|
|
import boto3.s3
|
|
backup_s3_hosts = [(r, f"s3.{r}.amazonaws.com") for r in boto3.session.Session().get_available_regions('s3')]
|
|
|
|
|
|
return render_template(
|
|
'index.html',
|
|
hostname=env['PRIMARY_HOSTNAME'],
|
|
storage_root=env['STORAGE_ROOT'],
|
|
no_users_exist=no_users_exist,
|
|
no_admins_exist=no_admins_exist,
|
|
backup_s3_hosts=backup_s3_hosts,
|
|
csr_country_codes=csr_country_codes,
|
|
)
|
|
|
|
|
|
# Create a session key by checking the username/password in the Authorization header.
|
|
|
|
|
|
@app.route('/login', methods=["POST"])
|
|
def login():
|
|
# Is the caller authorized?
|
|
try:
|
|
email, privs = auth_service.authenticate(request, env, login_only=True)
|
|
except ValueError as e:
|
|
if "missing-totp-token" in str(e):
|
|
return json_response({
|
|
"status": "missing-totp-token",
|
|
"reason": str(e),
|
|
})
|
|
else:
|
|
# Log the failed login
|
|
log_failed_login(request)
|
|
return json_response({
|
|
"status": "invalid",
|
|
"reason": str(e),
|
|
})
|
|
|
|
# Return a new session for the user.
|
|
resp = {
|
|
"status": "ok",
|
|
"email": email,
|
|
"privileges": privs,
|
|
"api_key": auth_service.create_session_key(email, env, type='login'),
|
|
}
|
|
|
|
app.logger.info("New login session created for {}".format(email))
|
|
|
|
# Return.
|
|
return json_response(resp)
|
|
|
|
|
|
@app.route('/logout', methods=["POST"])
|
|
def logout():
|
|
try:
|
|
email, _ = auth_service.authenticate(request, env, logout=True)
|
|
app.logger.info("{} logged out".format(email))
|
|
except ValueError as e:
|
|
pass
|
|
finally:
|
|
return json_response({"status": "ok"})
|
|
|
|
|
|
# MAIL
|
|
|
|
|
|
@app.route('/mail/users')
|
|
@authorized_personnel_only()
|
|
def mail_users():
|
|
if request.args.get("format", "") == "json":
|
|
return json_response(get_mail_users_ex(env, with_archived=True))
|
|
else:
|
|
return "".join(x + "\n" for x in get_mail_users(env))
|
|
|
|
|
|
@app.route('/mail/users/add', methods=['POST'])
|
|
@authorized_personnel_only()
|
|
def mail_users_add():
|
|
quota = request.form.get('quota', get_default_quota(env))
|
|
try:
|
|
return add_mail_user(request.form.get('email', ''),
|
|
request.form.get('password', ''),
|
|
request.form.get('privileges', ''), quota, env)
|
|
except ValueError as e:
|
|
return (str(e), 400)
|
|
|
|
|
|
@app.route('/mail/users/quota', methods=['GET'])
|
|
@authorized_personnel_only()
|
|
def get_mail_users_quota():
|
|
email = request.values.get('email', '')
|
|
quota = get_mail_quota(email, env)
|
|
|
|
if request.values.get('text'):
|
|
return quota
|
|
|
|
return json_response({"email": email, "quota": quota})
|
|
|
|
|
|
@app.route('/mail/users/quota', methods=['POST'])
|
|
@authorized_personnel_only()
|
|
def mail_users_quota():
|
|
try:
|
|
return set_mail_quota(request.form.get('email', ''),
|
|
request.form.get('quota'), env)
|
|
except ValueError as e:
|
|
return (str(e), 400)
|
|
|
|
|
|
@app.route('/mail/users/password', methods=['POST'])
|
|
@authorized_personnel_only(admin = False)
|
|
def mail_users_password():
|
|
if "admin" not in request.user_privs:
|
|
# Non-admins can only change their own password.
|
|
if request.form.get('email', '') != request.user_email:
|
|
return ("You are not an administrator; you can only change your own password!", 403)
|
|
|
|
try:
|
|
return set_mail_password(request.form.get('email', ''),
|
|
request.form.get('password', ''), env)
|
|
except ValueError as e:
|
|
return (str(e), 400)
|
|
|
|
|
|
@app.route('/mail/users/remove', methods=['POST'])
|
|
@authorized_personnel_only()
|
|
def mail_users_remove():
|
|
return remove_mail_user(request.form.get('email', ''), env)
|
|
|
|
|
|
@app.route('/mail/users/privileges')
|
|
@authorized_personnel_only()
|
|
def mail_user_privs():
|
|
privs = get_mail_user_privileges(request.args.get('email', ''), env)
|
|
if isinstance(privs, tuple):
|
|
return privs # error
|
|
return "\n".join(privs)
|
|
|
|
|
|
@app.route('/mail/users/privileges/add', methods=['POST'])
|
|
@authorized_personnel_only()
|
|
def mail_user_privs_add():
|
|
return add_remove_mail_user_privilege(request.form.get('email', ''),
|
|
request.form.get('privilege', ''),
|
|
"add", env)
|
|
|
|
|
|
@app.route('/mail/users/privileges/remove', methods=['POST'])
|
|
@authorized_personnel_only()
|
|
def mail_user_privs_remove():
|
|
return add_remove_mail_user_privilege(request.form.get('email', ''),
|
|
request.form.get('privilege', ''),
|
|
"remove", env)
|
|
|
|
|
|
@app.route('/mail/aliases')
|
|
@authorized_personnel_only()
|
|
def mail_aliases():
|
|
if request.args.get("format", "") == "json":
|
|
return json_response(get_mail_aliases_ex(env))
|
|
else:
|
|
return "".join(
|
|
address + "\t" + receivers + "\t" + (senders or "") + "\n"
|
|
for address, receivers, senders, auto in get_mail_aliases(env))
|
|
|
|
|
|
@app.route('/mail/aliases/add', methods=['POST'])
|
|
@authorized_personnel_only()
|
|
def mail_aliases_add():
|
|
return add_mail_alias(request.form.get('address', ''),
|
|
request.form.get('forwards_to', ''),
|
|
request.form.get('permitted_senders', ''),
|
|
env,
|
|
update_if_exists=(request.form.get(
|
|
'update_if_exists', '') == '1'))
|
|
|
|
|
|
@app.route('/mail/aliases/remove', methods=['POST'])
|
|
@authorized_personnel_only()
|
|
def mail_aliases_remove():
|
|
return remove_mail_alias(request.form.get('address', ''), env)
|
|
|
|
|
|
@app.route('/mail/domains')
|
|
@authorized_personnel_only()
|
|
def mail_domains():
|
|
return "".join(x + "\n" for x in get_mail_domains(env))
|
|
|
|
|
|
# DNS
|
|
|
|
|
|
@app.route('/dns/zones')
|
|
@authorized_personnel_only()
|
|
def dns_zones():
|
|
from dns_update import get_dns_zones
|
|
return json_response([z[0] for z in get_dns_zones(env)])
|
|
|
|
|
|
@app.route('/dns/update', methods=['POST'])
|
|
@authorized_personnel_only()
|
|
def dns_update():
|
|
from dns_update import do_dns_update
|
|
try:
|
|
return do_dns_update(env, force=request.form.get('force', '') == '1')
|
|
except Exception as e:
|
|
return (str(e), 500)
|
|
|
|
|
|
@app.route('/dns/secondary-nameserver')
|
|
@authorized_personnel_only()
|
|
def dns_get_secondary_nameserver():
|
|
from dns_update import get_custom_dns_config, get_secondary_dns
|
|
return json_response({
|
|
"hostnames":
|
|
get_secondary_dns(get_custom_dns_config(env), mode=None)
|
|
})
|
|
|
|
|
|
@app.route('/dns/secondary-nameserver', methods=['POST'])
|
|
@authorized_personnel_only()
|
|
def dns_set_secondary_nameserver():
|
|
from dns_update import set_secondary_dns
|
|
try:
|
|
return set_secondary_dns([
|
|
ns.strip() for ns in re.split(r"[, ]+",
|
|
request.form.get('hostnames') or "")
|
|
if ns.strip() != ""
|
|
], env)
|
|
except ValueError as e:
|
|
return (str(e), 400)
|
|
|
|
|
|
@app.route('/dns/custom')
|
|
@authorized_personnel_only()
|
|
def dns_get_records(qname=None, rtype=None):
|
|
# Get the current set of custom DNS records.
|
|
from dns_update import get_custom_dns_config, get_dns_zones
|
|
records = get_custom_dns_config(env, only_real_records=True)
|
|
|
|
# Filter per the arguments for the more complex GET routes below.
|
|
records = [
|
|
r for r in records
|
|
if (not qname or r[0] == qname) and (not rtype or r[1] == rtype)
|
|
]
|
|
|
|
# Make a better data structure.
|
|
records = [{
|
|
"qname": r[0],
|
|
"rtype": r[1],
|
|
"value": r[2],
|
|
"ttl": r[3],
|
|
"sort-order": {},
|
|
} for r in records]
|
|
|
|
# To help with grouping by zone in qname sorting, label each record with which zone it is in.
|
|
# There's an inconsistency in how we handle zones in get_dns_zones and in sort_domains, so
|
|
# do this first before sorting the domains within the zones.
|
|
zones = utils.sort_domains([z[0] for z in get_dns_zones(env)], env)
|
|
for r in records:
|
|
for z in zones:
|
|
if r["qname"] == z or r["qname"].endswith("." + z):
|
|
r["zone"] = z
|
|
break
|
|
|
|
# Add sorting information. The 'created' order follows the order in the YAML file on disk,
|
|
# which tracs the order entries were added in the control panel since we append to the end.
|
|
# The 'qname' sort order sorts by our standard domain name sort (by zone then by qname),
|
|
# then by rtype, and last by the original order in the YAML file (since sorting by value
|
|
# may not make sense, unless we parse IP addresses, for example).
|
|
for i, r in enumerate(records):
|
|
r["sort-order"]["created"] = i
|
|
domain_sort_order = utils.sort_domains([r["qname"] for r in records], env)
|
|
for i, r in enumerate(
|
|
sorted(
|
|
records,
|
|
key=lambda r: (
|
|
# record is not within a zone managed by the box
|
|
zones.index(r["zone"]) if r.get("zone") else 0,
|
|
domain_sort_order.index(r["qname"]),
|
|
r["rtype"]))):
|
|
r["sort-order"]["qname"] = i
|
|
|
|
# Return.
|
|
return json_response(records)
|
|
|
|
|
|
@app.route('/dns/custom/<qname>', methods=['GET', 'POST', 'PUT', 'DELETE'])
|
|
@app.route('/dns/custom/<qname>/<rtype>',
|
|
methods=['GET', 'POST', 'PUT', 'DELETE'])
|
|
@authorized_personnel_only()
|
|
def dns_set_record(qname, rtype="A"):
|
|
from dns_update import do_dns_update, set_custom_dns_record
|
|
try:
|
|
# Normalize.
|
|
rtype = rtype.upper()
|
|
|
|
# Read the record value from the request BODY, which must be
|
|
# ASCII-only. Not used with GET.
|
|
rec = request.form
|
|
value = ""
|
|
ttl = None
|
|
|
|
if isinstance(rec, dict):
|
|
value = request.form.get("value", "")
|
|
ttl = request.form.get("ttl", None)
|
|
else:
|
|
value = request.stream.read().decode("ascii", "ignore").strip()
|
|
|
|
if ttl is not None:
|
|
try:
|
|
ttl = int(ttl)
|
|
except Exception:
|
|
ttl = None
|
|
|
|
if request.method == "GET":
|
|
# Get the existing records matching the qname and rtype.
|
|
return dns_get_records(qname, rtype)
|
|
|
|
elif request.method in ("POST", "PUT"):
|
|
# There is a default value for A/AAAA records.
|
|
if rtype in ("A", "AAAA") and value == "":
|
|
# normally REMOTE_ADDR but we're behind nginx as a reverse proxy
|
|
value = request.environ.get("HTTP_X_FORWARDED_FOR")
|
|
|
|
# Cannot add empty records.
|
|
if value == '':
|
|
return ("No value for the record provided.", 400)
|
|
|
|
if request.method == "POST":
|
|
# Add a new record (in addition to any existing records
|
|
# for this qname-rtype pair).
|
|
action = "add"
|
|
elif request.method == "PUT":
|
|
# In REST, PUT is supposed to be idempotent, so we'll
|
|
# make this action set (replace all records for this
|
|
# qname-rtype pair) rather than add (add a new record).
|
|
action = "set"
|
|
|
|
elif request.method == "DELETE":
|
|
if value == '':
|
|
# Delete all records for this qname-type pair.
|
|
value = None
|
|
else:
|
|
# Delete just the qname-rtype-value record exactly.
|
|
pass
|
|
action = "remove"
|
|
|
|
if set_custom_dns_record(qname, rtype, value, action, env, ttl=ttl):
|
|
return do_dns_update(env) or "Something isn't right."
|
|
return "OK"
|
|
|
|
except ValueError as e:
|
|
return (str(e), 400)
|
|
|
|
|
|
@app.route('/dns/dump')
|
|
@authorized_personnel_only()
|
|
def dns_get_dump():
|
|
from dns_update import build_recommended_dns
|
|
return json_response(build_recommended_dns(env))
|
|
|
|
|
|
@app.route('/dns/zonefile/<zone>')
|
|
@authorized_personnel_only()
|
|
def dns_get_zonefile(zone):
|
|
from dns_update import get_dns_zonefile
|
|
return Response(get_dns_zonefile(zone, env),
|
|
status=200,
|
|
mimetype='text/plain')
|
|
|
|
|
|
# SSL
|
|
|
|
|
|
@app.route('/ssl/status')
|
|
@authorized_personnel_only()
|
|
def ssl_get_status():
|
|
from ssl_certificates import get_certificates_to_provision
|
|
from web_update import get_web_domains_info, get_web_domains
|
|
|
|
# What domains can we provision certificates for? What unexpected problems do we have?
|
|
provision, cant_provision = get_certificates_to_provision(
|
|
env, show_valid_certs=False)
|
|
|
|
# What's the current status of TLS certificates on all of the domain?
|
|
domains_status = get_web_domains_info(env)
|
|
domains_status = [{
|
|
"domain":
|
|
d["domain"],
|
|
"status":
|
|
d["ssl_certificate"][0],
|
|
"text":
|
|
d["ssl_certificate"][1] + ((" " + cant_provision[d["domain"]]
|
|
if d["domain"] in cant_provision else ""))
|
|
} for d in domains_status]
|
|
|
|
# Warn the user about domain names not hosted here because of other settings.
|
|
for domain in set(get_web_domains(env, exclude_dns_elsewhere=False)) - set(
|
|
get_web_domains(env)):
|
|
domains_status.append({
|
|
"domain":
|
|
domain,
|
|
"status":
|
|
"not-applicable",
|
|
"text":
|
|
"The domain's website is hosted elsewhere.",
|
|
})
|
|
|
|
return json_response({
|
|
"can_provision": utils.sort_domains(provision, env),
|
|
"status": domains_status,
|
|
})
|
|
|
|
|
|
@app.route('/ssl/csr/<domain>', methods=['POST'])
|
|
@authorized_personnel_only()
|
|
def ssl_get_csr(domain):
|
|
from ssl_certificates import create_csr
|
|
ssl_private_key = os.path.join(
|
|
os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_private_key.pem'))
|
|
return create_csr(domain, ssl_private_key,
|
|
request.form.get('countrycode', ''), env)
|
|
|
|
|
|
@app.route('/ssl/install', methods=['POST'])
|
|
@authorized_personnel_only()
|
|
def ssl_install_cert():
|
|
from web_update import get_web_domains
|
|
from ssl_certificates import install_cert
|
|
domain = request.form.get('domain')
|
|
ssl_cert = request.form.get('cert')
|
|
ssl_chain = request.form.get('chain')
|
|
if domain not in get_web_domains(env):
|
|
return "Invalid domain name."
|
|
return install_cert(domain, ssl_cert, ssl_chain, env)
|
|
|
|
|
|
@app.route('/ssl/provision', methods=['POST'])
|
|
@authorized_personnel_only()
|
|
def ssl_provision_certs():
|
|
from ssl_certificates import provision_certificates
|
|
requests = provision_certificates(env, limit_domains=None)
|
|
return json_response({"requests": requests})
|
|
|
|
|
|
# multi-factor auth
|
|
|
|
|
|
@app.route('/mfa/status', methods=['POST'])
|
|
@authorized_personnel_only(admin = False)
|
|
def mfa_get_status():
|
|
# Anyone accessing this route is an admin, and we permit them to
|
|
# see the MFA status for any user if they submit a 'user' form
|
|
# field. But we don't include provisioning info since a user can
|
|
# only provision for themselves.
|
|
# user field if given, otherwise the user making the request
|
|
email = request.form.get('user', request.user_email)
|
|
if "admin" not in request.user_privs and email != request.user_email:
|
|
return ("You are not an administrator; you can only view your own MFA status!", 403)
|
|
|
|
try:
|
|
resp = {"enabled_mfa": get_public_mfa_state(email, env)}
|
|
if email == request.user_email:
|
|
resp.update({"new_mfa": {"totp": provision_totp(email, env)}})
|
|
except ValueError as e:
|
|
return (str(e), 400)
|
|
return json_response(resp)
|
|
|
|
|
|
@app.route('/mfa/totp/enable', methods=['POST'])
|
|
@authorized_personnel_only(admin = False)
|
|
def totp_post_enable():
|
|
secret = request.form.get('secret')
|
|
token = request.form.get('token')
|
|
label = request.form.get('label')
|
|
if type(token) != str:
|
|
return ("Bad Input", 400)
|
|
try:
|
|
validate_totp_secret(secret)
|
|
enable_mfa(request.user_email, "totp", secret, token, label, env)
|
|
except ValueError as e:
|
|
return (str(e), 400)
|
|
return "OK"
|
|
|
|
|
|
@app.route('/mfa/disable', methods=['POST'])
|
|
@authorized_personnel_only(admin = False)
|
|
def totp_post_disable():
|
|
# Anyone accessing this route is an admin, and we permit them to
|
|
# disable the MFA status for any user if they submit a 'user' form
|
|
# field.
|
|
# user field if given, otherwise the user making the request
|
|
email = request.form.get('user', request.user_email)
|
|
if "admin" not in request.user_privs and email != request.user_email:
|
|
return ("You are not an administrator; you can only view your own MFA status!", 403)
|
|
|
|
try:
|
|
result = disable_mfa(email,
|
|
request.form.get('mfa-id') or None,
|
|
env) # convert empty string to None
|
|
except ValueError as e:
|
|
return (str(e), 400)
|
|
if result: # success
|
|
return "OK"
|
|
else: # error
|
|
return ("Invalid user or MFA id.", 400)
|
|
|
|
|
|
# WEB
|
|
|
|
|
|
@app.route('/web/domains')
|
|
@authorized_personnel_only()
|
|
def web_get_domains():
|
|
from web_update import get_web_domains_info
|
|
return json_response(get_web_domains_info(env))
|
|
|
|
|
|
@app.route('/web/update', methods=['POST'])
|
|
@authorized_personnel_only()
|
|
def web_update():
|
|
from web_update import do_web_update
|
|
try:
|
|
return do_web_update(env)
|
|
except Exception as e:
|
|
return (str(e), 500)
|
|
|
|
|
|
# System
|
|
|
|
|
|
@app.route('/system/version', methods=["GET"])
|
|
@authorized_personnel_only()
|
|
def system_version():
|
|
from status_checks import what_version_is_this
|
|
try:
|
|
return what_version_is_this(env)
|
|
except Exception as e:
|
|
return (str(e), 500)
|
|
|
|
|
|
@app.route('/system/latest-upstream-version', methods=["POST"])
|
|
@authorized_personnel_only()
|
|
def system_latest_upstream_version():
|
|
from status_checks import get_latest_miab_version
|
|
try:
|
|
return get_latest_miab_version()
|
|
except Exception as e:
|
|
return (str(e), 500)
|
|
|
|
|
|
@app.route('/system/status', methods=["POST"])
|
|
@authorized_personnel_only()
|
|
def system_status():
|
|
from status_checks 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_warning(self, message):
|
|
self.items.append({
|
|
"type": "warning",
|
|
"text": message,
|
|
"extra": []
|
|
})
|
|
|
|
def print_na(self, message):
|
|
self.items.append({"type": "na", "text": message, "extra": []})
|
|
|
|
def print_line(self, message, monospace=False):
|
|
self.items[-1]["extra"].append({
|
|
"text": message,
|
|
"monospace": monospace
|
|
})
|
|
|
|
output = WebOutput()
|
|
# Create a temporary pool of processes for the status checks
|
|
with multiprocessing.pool.Pool(processes=5) as pool:
|
|
run_checks(False, env, output, pool)
|
|
pool.close()
|
|
pool.join()
|
|
return json_response(output.items)
|
|
|
|
|
|
@app.route('/system/updates')
|
|
@authorized_personnel_only()
|
|
def show_updates():
|
|
from status_checks import list_apt_updates
|
|
return "".join("%s (%s)\n" % (p["package"], p["version"])
|
|
for p in list_apt_updates())
|
|
|
|
|
|
@app.route('/system/update-packages', methods=["POST"])
|
|
@authorized_personnel_only()
|
|
def do_updates():
|
|
utils.shell("check_call", ["/usr/bin/apt-get", "-qq", "update"])
|
|
return utils.shell("check_output", ["/usr/bin/apt-get", "-y", "upgrade"],
|
|
env={"DEBIAN_FRONTEND": "noninteractive"})
|
|
|
|
|
|
@app.route('/system/reboot', methods=["GET"])
|
|
@authorized_personnel_only()
|
|
def needs_reboot():
|
|
from status_checks import is_reboot_needed_due_to_package_installation
|
|
if is_reboot_needed_due_to_package_installation():
|
|
return json_response(True)
|
|
else:
|
|
return json_response(False)
|
|
|
|
|
|
@app.route('/system/reboot', methods=["POST"])
|
|
@authorized_personnel_only()
|
|
def do_reboot():
|
|
# To keep the attack surface low, we don't allow a remote reboot if one isn't necessary.
|
|
from status_checks import is_reboot_needed_due_to_package_installation
|
|
if is_reboot_needed_due_to_package_installation():
|
|
return utils.shell("check_output", ["/sbin/shutdown", "-r", "now"],
|
|
capture_stderr=True)
|
|
else:
|
|
return "No reboot is required, so it is not allowed."
|
|
|
|
|
|
@app.route('/system/backup/status')
|
|
@authorized_personnel_only()
|
|
def backup_status():
|
|
from backup import backup_status
|
|
try:
|
|
return json_response(backup_status(env))
|
|
except Exception as e:
|
|
return json_response({"error": str(e)})
|
|
|
|
|
|
@app.route('/system/backup/config', methods=["GET"])
|
|
@authorized_personnel_only()
|
|
def backup_get_custom():
|
|
from backup import get_backup_config
|
|
return json_response(get_backup_config(env, for_ui=True))
|
|
|
|
|
|
@app.route('/system/backup/config', methods=["POST"])
|
|
@authorized_personnel_only()
|
|
def backup_set_custom():
|
|
from backup import backup_set_custom
|
|
return json_response(
|
|
backup_set_custom(env, request.form.get('target', ''),
|
|
request.form.get('target_user', ''),
|
|
request.form.get('target_pass', ''),
|
|
request.form.get('target_rsync_port', ''),
|
|
request.form.get('min_age', '')))
|
|
|
|
|
|
@app.route('/system/backup/new', methods=["POST"])
|
|
@authorized_personnel_only()
|
|
def backup_new():
|
|
from backup import perform_backup, get_backup_config
|
|
|
|
# If backups are disabled, don't perform the backup
|
|
config = get_backup_config(env)
|
|
if config["target"] == "off":
|
|
return "Backups are disabled in this machine. Nothing was done."
|
|
|
|
msg = perform_backup(request.form.get('full', False) == 'true', True)
|
|
return "OK" if msg is None else msg
|
|
|
|
|
|
@app.route('/system/privacy', methods=["GET"])
|
|
@authorized_personnel_only()
|
|
def privacy_status_get():
|
|
config = utils.load_settings(env)
|
|
return json_response(config.get("privacy", True))
|
|
|
|
|
|
@app.route('/system/privacy', methods=["POST"])
|
|
@authorized_personnel_only()
|
|
def privacy_status_set():
|
|
config = utils.load_settings(env)
|
|
config["privacy"] = (request.form.get('value') == "private")
|
|
utils.write_settings(config, env)
|
|
return "OK"
|
|
|
|
|
|
@app.route('/system/smtp/relay', methods=["GET"])
|
|
@authorized_personnel_only()
|
|
def smtp_relay_get():
|
|
config = utils.load_settings(env)
|
|
|
|
dkim_rrtxt = ""
|
|
rr = config.get("SMTP_RELAY_DKIM_RR", None)
|
|
if rr is not None:
|
|
if rr.get("p") is None:
|
|
raise ValueError("Key doesn't exist!")
|
|
for c, d in (("v", "DKIM1"), ("h", None), ("k", "rsa"), ("n", None),
|
|
("s", None), ("t", None)):
|
|
txt = rr.get(c, d)
|
|
if txt is None:
|
|
continue
|
|
else:
|
|
dkim_rrtxt += f"{c}={txt}; "
|
|
dkim_rrtxt += f"p={rr.get('p')}"
|
|
|
|
return {
|
|
"enabled": config.get("SMTP_RELAY_ENABLED", False),
|
|
"host": config.get("SMTP_RELAY_HOST", ""),
|
|
"port": config.get("SMTP_RELAY_PORT", None),
|
|
"user": config.get("SMTP_RELAY_USER", ""),
|
|
"authorized_servers": config.get("SMTP_RELAY_AUTHORIZED_SERVERS", []),
|
|
"spf_record": config.get("SMTP_RELAY_SPF_RECORD", None),
|
|
"dkim_selector": config.get("SMTP_RELAY_DKIM_SELECTOR", None),
|
|
"dkim_rr": dkim_rrtxt
|
|
}
|
|
|
|
|
|
@app.route('/system/smtp/relay', methods=["POST"])
|
|
@authorized_personnel_only()
|
|
def smtp_relay_set():
|
|
from editconf import edit_conf
|
|
from os import chmod
|
|
import re
|
|
import socket
|
|
import ssl
|
|
|
|
config = utils.load_settings(env)
|
|
newconf = request.form
|
|
|
|
# Is DKIM configured?
|
|
sel = newconf.get("dkim_selector", "")
|
|
rr = newconf.get("dkim_rr", "")
|
|
check_dkim = True
|
|
if sel is None or sel.strip() == "":
|
|
config["SMTP_RELAY_DKIM_SELECTOR"] = None
|
|
# Check that the key RR doesn't exist either, otherwise we cannot be
|
|
# sure that the user wants to remove it.
|
|
if rr.strip() != "":
|
|
return ("Cannot publish a DKIM key without a selector!\n\
|
|
If you want to set up a relay without a DKIM record, both the selector and the key need to be empty.", 400)
|
|
config["SMTP_RELAY_DKIM_RR"] = None
|
|
check_dkim = False
|
|
elif re.fullmatch(r"[a-z\d\._][a-z\d\._\-]*", sel.strip()) is None:
|
|
return ("The DKIM selector is invalid!", 400)
|
|
|
|
if check_dkim:
|
|
# DKIM selector looks good, try processing the RR
|
|
if rr.strip() == "":
|
|
return ("Cannot publish a selector with an empty key!\n\
|
|
If you want to set up a relay without a DKIM record, both the selector and the key need to be empty.", 400)
|
|
|
|
components = {}
|
|
for r in re.split(r"[;\s]+", rr):
|
|
sp = re.split(r"\=", r)
|
|
if len(sp) != 2:
|
|
return ("DKIM public key RR is malformed!", 400)
|
|
components[sp[0]] = sp[1]
|
|
|
|
if not components.get("p"):
|
|
return ("The DKIM public key doesn't exist!", 400)
|
|
|
|
config["SMTP_RELAY_DKIM_SELECTOR"] = sel
|
|
config["SMTP_RELAY_DKIM_RR"] = components
|
|
|
|
relay_on = False
|
|
implicit_tls = False
|
|
|
|
if newconf.get("enabled") == "true":
|
|
relay_on = True
|
|
|
|
# Try negotiating TLS directly. We need to know this because we need to configure Postfix
|
|
# to be aware of this detail.
|
|
try:
|
|
ctx = ssl.create_default_context()
|
|
with socket.create_connection(
|
|
(newconf.get("host"), int(newconf.get("port"))), 5) as sock:
|
|
with ctx.wrap_socket(sock,
|
|
server_hostname=newconf.get("host")):
|
|
implicit_tls = True
|
|
except ssl.SSLError as sle:
|
|
# Couldn't connect via TLS, configure Postfix to send via STARTTLS
|
|
pass
|
|
except (socket.herror, socket.gaierror) as he:
|
|
return (
|
|
f"Unable to resolve hostname (it probably is incorrect): {he.strerror}",
|
|
400)
|
|
except socket.timeout:
|
|
return (
|
|
"We couldn't connect to the server. Is it down or did you write the wrong port number?",
|
|
400)
|
|
|
|
pw_file = "/etc/postfix/sasl_passwd"
|
|
modify_password = True
|
|
# Check that if the provided password is empty, that there was a password saved before
|
|
if (newconf.get("key", "") == ""):
|
|
if os.path.isfile(pw_file):
|
|
modify_password = False
|
|
else:
|
|
return (
|
|
"Please provide a password/key (there is no existing password to retain).",
|
|
400)
|
|
|
|
try:
|
|
# Write on daemon settings
|
|
config["local_dkim_selector"] = "mailorigin" if relay_on and sel == "mail" else "mail"
|
|
config["SMTP_RELAY_ENABLED"] = relay_on
|
|
config["SMTP_RELAY_HOST"] = newconf.get("host")
|
|
config["SMTP_RELAY_PORT"] = int(newconf.get("port"))
|
|
config["SMTP_RELAY_USER"] = newconf.get("user")
|
|
config["SMTP_RELAY_AUTHORIZED_SERVERS"] = [s.strip() for s in re.split(r"[, ]+", newconf.get("authorized_servers", []) or "") if s.strip() != ""]
|
|
config["SMTP_RELAY_SPF_RECORD"] = newconf.get("spf_record")
|
|
utils.write_settings(config, env)
|
|
|
|
# Write on Postfix configs
|
|
edit_conf("/etc/postfix/main.cf", [
|
|
"relayhost=" +
|
|
(f"[{config['SMTP_RELAY_HOST']}]:{config['SMTP_RELAY_PORT']}"
|
|
if config["SMTP_RELAY_ENABLED"] else ""),
|
|
f"smtp_tls_wrappermode={'yes' if implicit_tls else 'no'}"
|
|
],
|
|
delimiter_re=r"\s*=\s*",
|
|
delimiter="=",
|
|
comment_char="#")
|
|
|
|
# Edit the sasl password (still will edit the file, but keep the pw)
|
|
|
|
with open(pw_file, "a+") as f:
|
|
f.seek(0)
|
|
pwm = re.match(r"\[.+\]\:[0-9]+\s.+\:(.*)", f.readline())
|
|
if (pwm is None or len(pwm.groups()) != 1) and not modify_password:
|
|
# Well if this isn't a bruh moment
|
|
return (
|
|
"Please provide a password/key (there is no existing password to retain).",
|
|
400)
|
|
|
|
f.truncate(0)
|
|
f.write(
|
|
f"[{config['SMTP_RELAY_HOST']}]:{config['SMTP_RELAY_PORT']} {config['SMTP_RELAY_USER']}:{newconf.get('key') if modify_password else pwm[1]}\n"
|
|
)
|
|
chmod(pw_file, 0o600)
|
|
utils.shell("check_output", ["/usr/sbin/postmap", pw_file],
|
|
capture_stderr=True)
|
|
|
|
# Regenerate DNS (to apply whatever changes need to be made)
|
|
from dns_update import do_dns_update
|
|
do_dns_update(env)
|
|
|
|
# Restart Postfix
|
|
return utils.shell("check_output", ["/usr/sbin/postfix", "reload"],
|
|
capture_stderr=True)
|
|
except Exception as e:
|
|
return (str(e), 400)
|
|
|
|
|
|
# PGP
|
|
|
|
|
|
@app.route('/system/pgp/', methods=["GET"])
|
|
@authorized_personnel_only()
|
|
def get_keys():
|
|
from pgp import get_daemon_key, get_imported_keys, key_representation
|
|
return {
|
|
"daemon": key_representation(get_daemon_key()),
|
|
"imported": list(map(key_representation, get_imported_keys()))
|
|
}
|
|
|
|
|
|
@app.route('/system/pgp/<fpr>', methods=["GET"])
|
|
@authorized_personnel_only()
|
|
def get_key(fpr):
|
|
from pgp import get_key, key_representation
|
|
k = get_key(fpr)
|
|
if k is None:
|
|
abort(404)
|
|
return key_representation(k)
|
|
|
|
|
|
@app.route('/system/pgp/<fpr>', methods=["DELETE"])
|
|
@authorized_personnel_only()
|
|
def delete_key(fpr):
|
|
from pgp import delete_key
|
|
from wkd import parse_wkd_list, build_wkd
|
|
try:
|
|
if delete_key(fpr) is None:
|
|
abort(404)
|
|
removed = parse_wkd_list()[0]
|
|
build_wkd()
|
|
return json_response([e[0] for e in removed])
|
|
except ValueError as e:
|
|
return (str(e), 400)
|
|
|
|
|
|
@app.route('/system/pgp/<fpr>/export', methods=["GET"])
|
|
@authorized_personnel_only()
|
|
def export_key(fpr):
|
|
from pgp import export_key
|
|
exp = export_key(fpr)
|
|
if exp is None:
|
|
abort(404)
|
|
return exp
|
|
|
|
|
|
@app.route('/system/pgp/import', methods=["POST"])
|
|
@authorized_personnel_only()
|
|
def import_key():
|
|
from pgp import import_key
|
|
from wkd import build_wkd
|
|
|
|
k = request.form.get('key')
|
|
try:
|
|
result = import_key(k)
|
|
build_wkd() # Rebuild the WKD
|
|
return {
|
|
"keys_read": result.considered,
|
|
"keys_added": result.imported,
|
|
"keys_unchanged": result.unchanged,
|
|
"uids_added": result.new_user_ids,
|
|
"sigs_added": result.new_signatures,
|
|
"revs_added": result.new_revocations
|
|
}
|
|
except ValueError as e:
|
|
return (str(e), 400)
|
|
|
|
|
|
# Web Key Directory
|
|
|
|
|
|
@app.route('/system/pgp/wkd', methods=["GET"])
|
|
@authorized_personnel_only()
|
|
def get_wkd_status():
|
|
from pgp import get_daemon_key, get_imported_keys, key_representation
|
|
from wkd import get_user_fpr_maps, get_wkd_config
|
|
from mailconfig import get_domain
|
|
|
|
options = get_user_fpr_maps()
|
|
chosen = get_wkd_config()
|
|
|
|
wkd_tmp = {
|
|
x: {
|
|
"options": list(options.get(x)),
|
|
"selected": chosen.get(x)
|
|
}
|
|
for x in options.keys()
|
|
}
|
|
wkd = {}
|
|
|
|
for e in wkd_tmp.keys():
|
|
if wkd.get(get_domain(e)) is None:
|
|
wkd[get_domain(e)] = {}
|
|
wkd[get_domain(e)][e] = wkd_tmp[e]
|
|
|
|
return {
|
|
"keys": {
|
|
x.get("master_fpr"): x
|
|
for x in map(key_representation, [get_daemon_key()] +
|
|
get_imported_keys())
|
|
},
|
|
"wkd": wkd
|
|
}
|
|
|
|
|
|
@app.route('/system/pgp/wkd', methods=["POST"])
|
|
@authorized_personnel_only()
|
|
def update_wkd():
|
|
from wkd import update_wkd_config, build_wkd
|
|
update_wkd_config(request.form)
|
|
build_wkd()
|
|
return "OK"
|
|
|
|
|
|
@app.route('/system/default-quota', methods=["GET"])
|
|
@authorized_personnel_only()
|
|
def default_quota_get():
|
|
if request.values.get('text'):
|
|
return get_default_quota(env)
|
|
else:
|
|
return json_response({
|
|
"default-quota": get_default_quota(env),
|
|
})
|
|
|
|
|
|
@app.route('/system/default-quota', methods=["POST"])
|
|
@authorized_personnel_only()
|
|
def default_quota_set():
|
|
config = utils.load_settings(env)
|
|
try:
|
|
config["default-quota"] = validate_quota(
|
|
request.values.get('default_quota'))
|
|
utils.write_settings(config, env)
|
|
|
|
except ValueError as e:
|
|
return ("ERROR: %s" % str(e), 400)
|
|
|
|
return "OK"
|
|
|
|
|
|
# MUNIN
|
|
|
|
|
|
@app.route('/munin/')
|
|
@authorized_personnel_only()
|
|
def munin_start():
|
|
# Munin pages, static images, and dynamically generated images are served
|
|
# outside of the AJAX API. We'll start with a 'start' API that sets a cookie
|
|
# that subsequent requests will read for authorization. (We don't use cookies
|
|
# for the API to avoid CSRF vulnerabilities.)
|
|
response = make_response("OK")
|
|
response.set_cookie("session",
|
|
auth_service.create_session_key(request.user_email,
|
|
env,
|
|
type='cookie'),
|
|
max_age=60 * 30,
|
|
secure=True,
|
|
httponly=True,
|
|
samesite="Strict") # 30 minute duration
|
|
return response
|
|
|
|
|
|
def check_request_cookie_for_admin_access():
|
|
session = auth_service.get_session(None,
|
|
request.cookies.get("session",
|
|
""), "cookie", env)
|
|
if not session:
|
|
return False
|
|
privs = get_mail_user_privileges(session["email"], env)
|
|
if not isinstance(privs, list):
|
|
return False
|
|
if "admin" not in privs:
|
|
return False
|
|
return True
|
|
|
|
|
|
def authorized_personnel_only_via_cookie(f):
|
|
|
|
@wraps(f)
|
|
def g(*args, **kwargs):
|
|
if not check_request_cookie_for_admin_access():
|
|
return Response("Unauthorized",
|
|
status=403,
|
|
mimetype='text/plain',
|
|
headers={})
|
|
return f(*args, **kwargs)
|
|
|
|
return g
|
|
|
|
|
|
@app.route('/munin/<path:filename>')
|
|
@authorized_personnel_only_via_cookie
|
|
def munin_static_file(filename=""):
|
|
# Proxy the request to static files.
|
|
if filename == "":
|
|
filename = "index.html"
|
|
return send_from_directory("/var/cache/munin/www", filename)
|
|
|
|
|
|
@app.route('/munin/cgi-graph/<path:filename>')
|
|
@authorized_personnel_only_via_cookie
|
|
def munin_cgi(filename):
|
|
""" Relay munin cgi dynazoom requests
|
|
/usr/lib/munin/cgi/munin-cgi-graph is a perl cgi script in the munin package
|
|
that is responsible for generating binary png images _and_ associated HTTP
|
|
headers based on parameters in the requesting URL. All output is written
|
|
to stdout which munin_cgi splits into response headers and binary response
|
|
data.
|
|
munin-cgi-graph reads environment variables to determine
|
|
what it should do. It expects a path to be in the env-var PATH_INFO, and a
|
|
querystring to be in the env-var QUERY_STRING.
|
|
munin-cgi-graph has several failure modes. Some write HTTP Status headers and
|
|
others return nonzero exit codes.
|
|
Situating munin_cgi between the user-agent and munin-cgi-graph enables keeping
|
|
the cgi script behind mailinabox's auth mechanisms and avoids additional
|
|
support infrastructure like spawn-fcgi.
|
|
"""
|
|
|
|
COMMAND = 'su - munin --preserve-environment --shell=/bin/bash -c /usr/lib/munin/cgi/munin-cgi-graph'
|
|
# su changes user, we use the munin user here
|
|
# --preserve-environment retains the environment, which is where Popen's `env` data is
|
|
# --shell=/bin/bash ensures the shell used is bash
|
|
# -c "/usr/lib/munin/cgi/munin-cgi-graph" passes the command to run as munin
|
|
# "%s" is a placeholder for where the request's querystring will be added
|
|
|
|
if filename == "":
|
|
return ("a path must be specified", 404)
|
|
|
|
query_str = request.query_string.decode("utf-8", 'ignore')
|
|
|
|
env = {
|
|
'PATH_INFO': '/%s/' % filename,
|
|
'REQUEST_METHOD': 'GET',
|
|
'QUERY_STRING': query_str
|
|
}
|
|
code, binout = utils.shell(
|
|
'check_output',
|
|
COMMAND.split(" ", 5),
|
|
# Using a maxsplit of 5 keeps the last arguments together
|
|
env=env,
|
|
return_bytes=True,
|
|
trap=True)
|
|
|
|
if code != 0:
|
|
# nonzero returncode indicates error
|
|
app.logger.error(
|
|
"munin_cgi: munin-cgi-graph returned nonzero exit code, %s", code)
|
|
return ("error processing graph image", 500)
|
|
|
|
# /usr/lib/munin/cgi/munin-cgi-graph returns both headers and binary png when successful.
|
|
# A double-Windows-style-newline always indicates the end of HTTP headers.
|
|
headers, image_bytes = binout.split(b'\r\n\r\n', 1)
|
|
response = make_response(image_bytes)
|
|
for line in headers.splitlines():
|
|
name, value = line.decode("utf8").split(':', 1)
|
|
response.headers[name] = value
|
|
if 'Status' in response.headers and '404' in response.headers['Status']:
|
|
app.logger.warning(
|
|
"munin_cgi: munin-cgi-graph returned 404 status code. PATH_INFO=%s",
|
|
env['PATH_INFO'])
|
|
return response
|
|
|
|
|
|
def log_failed_login(request):
|
|
# We need to figure out the ip to list in the message, all our calls are routed
|
|
# through nginx who will put the original ip in X-Forwarded-For.
|
|
# During setup we call the management interface directly to determine the user
|
|
# status. So we can't always use X-Forwarded-For because during setup that header
|
|
# will not be present.
|
|
if request.headers.getlist("X-Forwarded-For"):
|
|
ip = request.headers.getlist("X-Forwarded-For")[0]
|
|
else:
|
|
ip = request.remote_addr
|
|
|
|
# We need to add a timestamp to the log message, otherwise /dev/log will eat the "duplicate"
|
|
# message.
|
|
app.logger.warning(
|
|
"Mail-in-a-Box Management Daemon: Failed login attempt from ip %s - timestamp %s"
|
|
% (ip, time.time()))
|
|
|
|
|
|
# APP
|
|
|
|
if __name__ == '__main__':
|
|
if "DEBUG" in os.environ:
|
|
# Turn on Flask debugging.
|
|
app.debug = True
|
|
|
|
if not app.debug:
|
|
app.logger.addHandler(utils.create_syslog_handler())
|
|
|
|
#app.logger.info('API key: ' + auth_service.key)
|
|
|
|
# Start the application server. Listens on 127.0.0.1 (IPv4 only).
|
|
app.run(port=10222)
|