daemon.py 20.6 KB
Newer Older
1
import os, os.path, re, json, time
2
import subprocess
3

4 5
from functools import wraps

6
from flask import Flask, request, render_template, abort, Response, send_from_directory, make_response
7

8
import auth, utils, multiprocessing.pool
9
from mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, remove_mail_user
10
from mailconfig import get_mail_user_privileges, add_remove_mail_user_privilege
11
from mailconfig import get_mail_aliases, get_mail_aliases_ex, get_mail_domains, add_mail_alias, remove_mail_alias
12

13 14
env = utils.load_environment()

15
auth_service = auth.KeyAuthService()
16

17 18 19 20 21 22 23
# We may deploy via a symbolic link, which confuses flask's template finding.
me = __file__
try:
	me = os.readlink(__file__)
except OSError:
	pass

24 25 26 27 28 29 30 31
# 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))

32 33
app = Flask(__name__, template_folder=os.path.abspath(os.path.join(os.path.dirname(me), "templates")))

34
# Decorator to protect views that require a user with 'admin' privileges.
35 36 37
def authorized_personnel_only(viewfunc):
	@wraps(viewfunc)
	def newview(*args, **kwargs):
38 39 40
		# Authenticate the passed credentials, which is either the API key or a username:password pair.
		error = None
		try:
41
			email, privs = auth_service.authenticate(request, env)
42 43 44
		except ValueError as e:
			# Authentication failed.
			privs = []
45
			error = "Incorrect username or password"
46

47 48 49
			# Write a line in the log recording the failed login
			log_failed_login(request)

50 51
		# Authorized to access an API view?
		if "admin" in privs:
52
			# Call view func.
53
			return viewfunc(*args, **kwargs)
54 55
		elif not error:
			error = "You are not an administrator."
56 57 58

		# Not authorized. Return a 401 (send auth) and a prompt to authorize by default.
		status = 401
59 60 61 62
		headers = {
			'WWW-Authenticate': 'Basic realm="{0}"'.format(auth_service.auth_realm),
			'X-Reason': error,
		}
63 64 65 66 67 68 69 70 71

		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.
72
			return Response(error+"\n", status=status, mimetype='text/plain', headers=headers)
73 74 75 76
		else:
			# Return JSON output.
			return Response(json.dumps({
				"status": "error",
77 78
				"reason": error,
				})+"\n", status=status, mimetype='application/json', headers=headers)
79 80

	return newview
81 82 83 84 85

@app.errorhandler(401)
def unauthorized(error):
	return auth_service.make_unauthorized_response()

86
def json_response(data):
87
	return Response(json.dumps(data, indent=2, sort_keys=True)+'\n', status=200, mimetype='application/json')
88 89 90 91 92

###################################

# Control Panel (unauthenticated views)

93 94
@app.route('/')
def index():
95 96
	# Render the control panel. This route does not require user authentication
	# so it must be safe!
97

98
	no_users_exist = (len(get_mail_users(env)) == 0)
99
	no_admins_exist = (len(get_admins(env)) == 0)
100

101
	utils.fix_boto() # must call prior to importing boto
102 103 104
	import boto.s3
	backup_s3_hosts = [(r.name, r.endpoint) for r in boto.s3.regions()]

105 106
	return render_template('index.html',
		hostname=env['PRIMARY_HOSTNAME'],
107
		storage_root=env['STORAGE_ROOT'],
108

109
		no_users_exist=no_users_exist,
110
		no_admins_exist=no_admins_exist,
111

112
		backup_s3_hosts=backup_s3_hosts,
113
		csr_country_codes=csr_country_codes,
114 115 116 117 118
	)

@app.route('/me')
def me():
	# Is the caller authorized?
119
	try:
120
		email, privs = auth_service.authenticate(request, env)
121
	except ValueError as e:
122 123 124
		# Log the failed login
		log_failed_login(request)

125
		return json_response({
126
			"status": "invalid",
127
			"reason": "Incorrect username or password",
128
			})
129 130 131

	resp = {
		"status": "ok",
132
		"email": email,
133 134 135
		"privileges": privs,
	}

136
	# Is authorized as admin? Return an API key for future use.
137
	if "admin" in privs:
138
		resp["api_key"] = auth_service.create_user_key(email, env)
139 140 141

	# Return.
	return json_response(resp)
142 143 144 145

# MAIL

@app.route('/mail/users')
146
@authorized_personnel_only
147
def mail_users():
148
	if request.args.get("format", "") == "json":
149
		return json_response(get_mail_users_ex(env, with_archived=True, with_slow_info=True))
150 151
	else:
		return "".join(x+"\n" for x in get_mail_users(env))
152 153

@app.route('/mail/users/add', methods=['POST'])
154
@authorized_personnel_only
155
def mail_users_add():
156 157 158 159
	try:
		return add_mail_user(request.form.get('email', ''), request.form.get('password', ''), request.form.get('privileges', ''), env)
	except ValueError as e:
		return (str(e), 400)
160 161

@app.route('/mail/users/password', methods=['POST'])
162
@authorized_personnel_only
163
def mail_users_password():
164 165 166 167
	try:
		return set_mail_password(request.form.get('email', ''), request.form.get('password', ''), env)
	except ValueError as e:
		return (str(e), 400)
168 169

@app.route('/mail/users/remove', methods=['POST'])
170
@authorized_personnel_only
171 172 173
def mail_users_remove():
	return remove_mail_user(request.form.get('email', ''), env)

174 175

@app.route('/mail/users/privileges')
176
@authorized_personnel_only
177 178 179 180 181 182
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'])
183
@authorized_personnel_only
184 185 186 187
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'])
188
@authorized_personnel_only
189 190 191 192
def mail_user_privs_remove():
	return add_remove_mail_user_privilege(request.form.get('email', ''), request.form.get('privilege', ''), "remove", env)


193
@app.route('/mail/aliases')
194
@authorized_personnel_only
195
def mail_aliases():
196
	if request.args.get("format", "") == "json":
197
		return json_response(get_mail_aliases_ex(env))
198
	else:
199
		return "".join(address+"\t"+receivers+"\t"+(senders or "")+"\n" for address, receivers, senders in get_mail_aliases(env))
200 201

@app.route('/mail/aliases/add', methods=['POST'])
202
@authorized_personnel_only
203
def mail_aliases_add():
204
	return add_mail_alias(
205
		request.form.get('address', ''),
206 207
		request.form.get('forwards_to', ''),
		request.form.get('permitted_senders', ''),
208 209 210
		env,
		update_if_exists=(request.form.get('update_if_exists', '') == '1')
		)
211 212

@app.route('/mail/aliases/remove', methods=['POST'])
213
@authorized_personnel_only
214
def mail_aliases_remove():
215
	return remove_mail_alias(request.form.get('address', ''), env)
216 217

@app.route('/mail/domains')
218
@authorized_personnel_only
219 220 221 222 223
def mail_domains():
    return "".join(x+"\n" for x in get_mail_domains(env))

# DNS

224 225 226 227 228 229
@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)])

230
@app.route('/dns/update', methods=['POST'])
231
@authorized_personnel_only
232 233
def dns_update():
	from dns_update import do_dns_update
Joshua Tauberer's avatar
Joshua Tauberer committed
234
	try:
235
		return do_dns_update(env, force=request.form.get('force', '') == '1')
Joshua Tauberer's avatar
Joshua Tauberer committed
236 237 238
	except Exception as e:
		return (str(e), 500)

239 240 241
@app.route('/dns/secondary-nameserver')
@authorized_personnel_only
def dns_get_secondary_nameserver():
242
	from dns_update import get_custom_dns_config, get_secondary_dns
243
	return json_response({ "hostnames": get_secondary_dns(get_custom_dns_config(env), mode=None) })
244 245 246 247 248 249

@app.route('/dns/secondary-nameserver', methods=['POST'])
@authorized_personnel_only
def dns_set_secondary_nameserver():
	from dns_update import set_secondary_dns
	try:
250
		return set_secondary_dns([ns.strip() for ns in re.split(r"[, ]+", request.form.get('hostnames') or "") if ns.strip() != ""], env)
251 252 253
	except ValueError as e:
		return (str(e), 400)

254
@app.route('/dns/custom')
255
@authorized_personnel_only
256
def dns_get_records(qname=None, rtype=None):
257
	from dns_update import get_custom_dns_config
258 259
	return json_response([
	{
260 261 262
		"qname": r[0],
		"rtype": r[1],
		"value": r[2],
263 264 265 266 267
	}
	for r in get_custom_dns_config(env)
	if r[0] != "_secondary_nameserver"
		and (not qname or r[0] == qname)
		and (not rtype or r[1] == rtype) ])
268

269 270
@app.route('/dns/custom/<qname>', methods=['GET', 'POST', 'PUT', 'DELETE'])
@app.route('/dns/custom/<qname>/<rtype>', methods=['GET', 'POST', 'PUT', 'DELETE'])
271
@authorized_personnel_only
272
def dns_set_record(qname, rtype="A"):
273 274
	from dns_update import do_dns_update, set_custom_dns_record
	try:
275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303
		# Normalize.
		rtype = rtype.upper()

		# Read the record value from the request BODY, which must be
		# ASCII-only. Not used with GET.
		value = request.stream.read().decode("ascii", "ignore").strip()

		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 == "":
				value = request.environ.get("HTTP_X_FORWARDED_FOR") # normally REMOTE_ADDR but we're behind nginx as a reverse proxy

			# 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"
304

305 306 307 308 309 310 311 312 313 314 315
		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):
			return do_dns_update(env) or "Something isn't right."
316
		return "OK"
317

318 319 320
	except ValueError as e:
		return (str(e), 400)

321 322 323 324 325
@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))
326

327 328
# SSL

329 330 331 332
@app.route('/ssl/status')
@authorized_personnel_only
def ssl_get_status():
	from ssl_certificates import get_certificates_to_provision
333 334 335 336 337 338
	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_extended_problems=False)
	
	# What's the current status of TLS certificates on all of the domain?
339
	domains_status = get_web_domains_info(env)
340 341 342 343 344 345 346 347 348 349
	domains_status = [{ "domain": d["domain"], "status": d["ssl_certificate"][0], "text": d["ssl_certificate"][1] } 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.",
		})

350
	return json_response({
351
		"can_provision": utils.sort_domains(provision, env),
352
		"cant_provision": [{ "domain": domain, "problem": cant_provision[domain] } for domain in utils.sort_domains(cant_provision, env) ],
353
		"status": domains_status,
354 355
	})

356 357 358
@app.route('/ssl/csr/<domain>', methods=['POST'])
@authorized_personnel_only
def ssl_get_csr(domain):
359
	from ssl_certificates import create_csr
360
	ssl_private_key = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_private_key.pem'))
361
	return create_csr(domain, ssl_private_key, request.form.get('countrycode', ''), env)
362 363 364 365

@app.route('/ssl/install', methods=['POST'])
@authorized_personnel_only
def ssl_install_cert():
366
	from web_update import get_web_domains
367
	from ssl_certificates import install_cert
368 369 370
	domain = request.form.get('domain')
	ssl_cert = request.form.get('cert')
	ssl_chain = request.form.get('chain')
371
	if domain not in get_web_domains(env):
372
		return "Invalid domain name."
373 374
	return install_cert(domain, ssl_cert, ssl_chain, env)

375 376 377 378 379 380 381 382 383 384 385
@app.route('/ssl/provision', methods=['POST'])
@authorized_personnel_only
def ssl_provision_certs():
	from ssl_certificates import provision_certificates
	agree_to_tos_url = request.form.get('agree_to_tos_url')
	status = provision_certificates(env,
		agree_to_tos_url=agree_to_tos_url,
		jsonable=True)
	return json_response(status)


386 387
# WEB

388 389 390 391 392 393
@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))

394
@app.route('/web/update', methods=['POST'])
395
@authorized_personnel_only
396 397 398 399
def web_update():
	from web_update import do_web_update
	return do_web_update(env)

400 401
# System

402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419
@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)

420 421 422
@app.route('/system/status', methods=["POST"])
@authorized_personnel_only
def system_status():
423
	from status_checks import run_checks
424 425 426 427 428 429 430 431 432
	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": [] })
433 434
		def print_warning(self, message):
			self.items.append({ "type": "warning", "text": message, "extra": [] })
435 436 437
		def print_line(self, message, monospace=False):
			self.items[-1]["extra"].append({ "text": message, "monospace": monospace })
	output = WebOutput()
438 439
	# Create a temporary pool of processes for the status checks
	pool = multiprocessing.pool.Pool(processes=5)
440
	run_checks(False, env, output, pool)
441
	pool.terminate()
442 443
	return json_response(output.items)

444
@app.route('/system/updates')
445
@authorized_personnel_only
446
def show_updates():
447 448 449 450 451
	from status_checks import list_apt_updates
	return "".join(
		"%s (%s)\n"
		% (p["package"], p["version"])
		for p in list_apt_updates())
452 453

@app.route('/system/update-packages', methods=["POST"])
454
@authorized_personnel_only
455
def do_updates():
456 457 458 459
	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"
	})
460

461

462 463 464
@app.route('/system/reboot', methods=["GET"])
@authorized_personnel_only
def needs_reboot():
465 466
	from status_checks import is_reboot_needed_due_to_package_installation
	if is_reboot_needed_due_to_package_installation():
467 468 469 470 471 472 473
		return json_response(True)
	else:
		return json_response(False)

@app.route('/system/reboot', methods=["POST"])
@authorized_personnel_only
def do_reboot():
474 475 476
	# 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():
477 478
		return utils.shell("check_output", ["/sbin/shutdown", "-r", "now"], capture_stderr=True)
	else:
479
		return "No reboot is required, so it is not allowed."
480 481


482 483 484 485
@app.route('/system/backup/status')
@authorized_personnel_only
def backup_status():
	from backup import backup_status
486 487 488 489
	try:
		return json_response(backup_status(env))
	except Exception as e:
		return json_response({ "error": str(e) })
490

491
@app.route('/system/backup/config', methods=["GET"])
492 493 494
@authorized_personnel_only
def backup_get_custom():
	from backup import get_backup_config
495
	return json_response(get_backup_config(env, for_ui=True))
496

497
@app.route('/system/backup/config', methods=["POST"])
498 499 500
@authorized_personnel_only
def backup_set_custom():
	from backup import backup_set_custom
501
	return json_response(backup_set_custom(env,
502 503 504
		request.form.get('target', ''),
		request.form.get('target_user', ''),
		request.form.get('target_pass', ''),
Leo Koppelkamm's avatar
Leo Koppelkamm committed
505
		request.form.get('min_age', '')
506 507
	))

508
@app.route('/system/privacy', methods=["GET"])
509
@authorized_personnel_only
510 511 512
def privacy_status_get():
	config = utils.load_settings(env)
	return json_response(config.get("privacy", True))
513

514
@app.route('/system/privacy', methods=["POST"])
515
@authorized_personnel_only
516 517 518 519 520
def privacy_status_set():
	config = utils.load_settings(env)
	config["privacy"] = (request.form.get('value') == "private")
	utils.write_settings(config, env)
	return "OK"
521

Joshua Tauberer's avatar
Joshua Tauberer committed
522 523 524 525 526 527 528 529 530 531 532
# MUNIN

@app.route('/munin/')
@app.route('/munin/<path:filename>')
@authorized_personnel_only
def munin(filename=""):
	# Checks administrative access (@authorized_personnel_only) and then just proxies
	# the request to static files.
	if filename == "": filename = "index.html"
	return send_from_directory("/var/cache/munin/www", filename)

533 534
@app.route('/munin/cgi-graph/<path:filename>')
@authorized_personnel_only
535
def munin_cgi(filename):
536 537 538 539 540 541
	""" 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.
542
	munin-cgi-graph reads environment variables to determine
543
	what it should do. It expects a path to be in the env-var PATH_INFO, and a
544
	querystring to be in the env-var QUERY_STRING.
545 546
	munin-cgi-graph has several failure modes. Some write HTTP Status headers and
	others return nonzero exit codes.
547 548 549 550 551
	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.
	"""

552
	COMMAND = 'su - munin --preserve-environment --shell=/bin/bash -c /usr/lib/munin/cgi/munin-cgi-graph'
553 554 555 556
	# 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
557
	# "%s" is a placeholder for where the request's querystring will be added
558 559 560 561 562 563

	if filename == "":
		return ("a path must be specified", 404)

	query_str = request.query_string.decode("utf-8", 'ignore')

564
	env = {'PATH_INFO': '/%s/' % filename, 'REQUEST_METHOD': 'GET', 'QUERY_STRING': query_str}
565
	code, binout = utils.shell('check_output',
566 567
							   COMMAND.split(" ", 5),
							   # Using a maxsplit of 5 keeps the last arguments together
568 569 570 571 572
							   env=env,
							   return_bytes=True,
							   trap=True)

	if code != 0:
573 574 575 576 577
		# nonzero returncode indicates error
		app.logger.error("munin_cgi: munin-cgi-graph returned nonzero exit code, %s", process.returncode)
		return ("error processing graph image", 500)

	# /usr/lib/munin/cgi/munin-cgi-graph returns both headers and binary png when successful.
578 579 580 581 582 583
	# 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
584 585 586 587
	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

588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603
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()))


604 605 606 607
# APP

if __name__ == '__main__':
	if "DEBUG" in os.environ: app.debug = True
608
	if "APIKEY" in os.environ: auth_service.key = os.environ["APIKEY"]
609

610 611 612
	if not app.debug:
		app.logger.addHandler(utils.create_syslog_handler())

613 614 615 616 617 618 619 620
	# For testing on the command line, you can use `curl` like so:
	#    curl --user $(</var/lib/mailinabox/api.key): http://localhost:10222/mail/users
	auth_service.write_key()

	# For testing in the browser, you can copy the API key that's output to the
	# debug console and enter that as the username
	app.logger.info('API key: ' + auth_service.key)

621
	# Start the application server. Listens on 127.0.0.1 (IPv4 only).
622
	app.run(port=10222)