From 4f57c626819da85882d704e163debb29ad38a137 Mon Sep 17 00:00:00 2001 From: Joshua Tauberer Date: Wed, 2 Jul 2014 11:42:20 +0000 Subject: [PATCH] Mailpile: an installation script and a multiplexer daemon that proxies Mailpile behind an authorization step and multiplexes Mailpile instances per logged in user --- mailpile-multiplexer/daemon.py | 272 ++++++++++++++++++ mailpile-multiplexer/templates/frameset.html | 9 + mailpile-multiplexer/templates/login.html | 57 ++++ .../templates/login_status.html | 24 ++ management/auth.py | 9 +- management/utils.py | 23 +- setup/mailpile.sh | 24 ++ 7 files changed, 406 insertions(+), 12 deletions(-) create mode 100755 mailpile-multiplexer/daemon.py create mode 100644 mailpile-multiplexer/templates/frameset.html create mode 100644 mailpile-multiplexer/templates/login.html create mode 100644 mailpile-multiplexer/templates/login_status.html create mode 100755 setup/mailpile.sh diff --git a/mailpile-multiplexer/daemon.py b/mailpile-multiplexer/daemon.py new file mode 100755 index 00000000..b3a5d5cf --- /dev/null +++ b/mailpile-multiplexer/daemon.py @@ -0,0 +1,272 @@ +#!/usr/bin/python3 + +# A Mailpile multiplexer +# ---------------------- +# +# Mailpile has no built-in notion of user authentication. It's +# a single-user thing. This file provides a proxy server around +# Mailpile that interfaces with Mail-in-a-Box user authentication. +# When a user logs in, a new Mailpile instance is forked and we +# proxy to that Mailpile for that user's session. + +import sys, os, os.path, re, urllib.request, urllib.parse, urllib.error, time + +from flask import Flask, request, session, render_template, redirect, abort +app = Flask(__name__) + +sys.path.insert(0, 'management') +import utils +env = utils.load_environment() + +running_mailpiles = { } + +@app.route('/') +def index(): + return render_template('frameset.html') + +@app.route('/status') +def status(): + # If the user is not logged in, show a blank page because we'll + # display the login form in the mailpile frame. + if "auth" not in session: + return "" + + # Show the user's current logged in status. + return render_template('login_status.html', auth=session.get("auth")) + +@app.route('/refresh-frameset') +def refresh_frameset(): + # Force a reload of the frameset from within a frame. + return """ + + + + + +""" + +@app.route('/logout') +def logout(): + session.pop("auth", None) + return redirect('/refresh-frameset') + +@app.route('/mailpile', methods=['GET', 'POST']) +def mailpile(): + # If the user is not logged in, show a login form. + if "auth" not in session: + return login_form() + else: + return proxy_request_to_mailpile('/') + +@app.route('/mailpile/', methods=['GET', 'POST']) +def mailpile2(path): + # On inside pages, if the user is not logged in, then we can't + # really safely redirect to a login page because we don't know + # what sort of resource is being requested. + if "auth" not in session: + abort(403) + else: + return proxy_request_to_mailpile('/' + path) + +def login_form(): + if request.method == 'GET': + # Show the login form. + return render_template('login.html', hostname=env["PRIMARY_HOSTNAME"]) + else: + # Process the login form. + if request.form.get('email', '').strip() == '' or request.form.get('password', '').strip() == '': + error = "Enter your email address and password." + else: + # Get form fields. + email = request.form['email'].strip() + pw = request.form['password'].strip() + remember = request.form.get('remember') + + # See if credentials are good. + try: + # Use doveadm to check credentials. + utils.shell('check_call', [ + "/usr/bin/doveadm", + "auth", "test", + email, pw + ]) + + # If no exception was thrown, credentials are good! + if remember: session.permanent = True + session['auth'] = { + "email": email, + } + + # Use Javascript to reload the whole frameset so that we + # can trigger a reload of the top frame that shows login + # status. + return redirect('/refresh-frameset') + + except: + # Login failed. + error = "Email address & password did not match." + + return render_template('login.html', + hostname=env["PRIMARY_HOSTNAME"], + error=error, + email=request.form.get("email"), password=request.form.get("password"), remember=request.form.get("remember")) + +def proxy_request_to_mailpile(path): + # Proxy the request. + port = get_mailpile_port(session['auth']['email']) + + # Munge the headers. (http://www.snip2code.com/Snippet/45977/Simple-Flask-Proxy/) + headers = dict([(key.upper(), value) for key, value in request.headers.items() if key.upper() != "HOST"]) + + # Is this a POST? Re-create the request body. + data = None + if request.method == "POST": + data = urllib.parse.urlencode(list(request.form.items(multi=True)), encoding='utf8').encode('utf8') + headers['CONTENT-LENGTH'] = str(len(data)) + + # Configure request. + req = urllib.request.Request( + "http://localhost:%d%s" % (port, path), + data, + headers=headers, + ) + + # Execute request. + try: + response = urllib.request.urlopen(req) + body = response.read() + headers = dict(response.getheaders()) + content_type = response.getheader("content-type", default="") + response_status = response.status + except urllib.error.HTTPError as e: + # Exceptions (400s, 500s, etc) are response objects too? + body = e.read() + headers = dict(e.headers) + content_type = "?/?" # don't do any munging + response_status = e.code + + # Munge the response. + + def rewrite_url(href): + # Make the URL absolute. + import urllib.parse + href2 = urllib.parse.urljoin(path, href.decode("utf8")) + if urllib.parse.urlparse(href2).scheme == "": + # This was a relative URL that we are proxying. + return b'/mailpile' + href2.encode("utf8") + return href + + if content_type.startswith("text/html"): + # Rewrite URLs in HTML responses. + body = re.sub(rb" (href|src|HREF|SRC)=('[^']*'|\"[^\"]*\")", + lambda m : b' ' + m.group(1) + b'=' + m.group(2)[0:1] + rewrite_url(m.group(2)[1:-1]) + m.group(2)[0:1], + body) + + if content_type.startswith("text/css"): + # Rewrite URLs in CSS responses. + body = re.sub(rb"url\('([^)]*)'\)", lambda m : b"url('" + rewrite_url(m.group(1)) + b"')", body) + + if content_type.startswith("text/javascript"): + # Rewrite URLs in Javascript responses. + body = re.sub(rb"/(?:api|async)[/\w]*", lambda m : rewrite_url(m.group(0)), body) + body = re.sub(rb"((?:message_draft|message_sent|tags)\s+:\s+\")([^\"]+)", lambda m : m.group(1) + rewrite_url(m.group(2)), body) + + #if content_type == "application/json": + # body = json.loads(body.decode('utf8')) + # def procobj(obj, is_urls=False): + # if not isinstance(obj, dict): return + # for k, v in obj.items(): + # if is_urls: obj[k] = rewrite_url(v.encode('utf8')).decode('utf8') + # procobj(v, is_urls=(k == "urls")) + # procobj(body) + # body = json.dumps(body).encode('utf8') + + # Pass back response to the client. + return (body, response_status, headers) + +def get_mailpile_port(emailaddr): + if emailaddr not in running_mailpiles: + running_mailpiles[emailaddr] = spawn_mailpile(emailaddr) + return running_mailpiles[emailaddr] + +def spawn_mailpile(emailaddr): + # Spawn a new instance of Mailpile that will automatically die + # when this process exits (because then we've lost track of the + # Mailpile instances we started). + # + # To do that, use an inspired idea from http://stackoverflow.com/questions/284325/how-to-make-child-process-die-after-parent-exits + # which uses an intermediate process that catches a SIGPIPE from the parent. + # We don't need an intermediate process because Mailpile is waiting + # for commands on STDIN. By giving it a STDIN that is a file descriptor + # that we never write to but keep open, the process should die as soon + # as this process exits due to a SIGPIPE. + + # Prepare mailpile. + + user, domain = emailaddr.split("@") + mp_home = os.path.join(env['STORAGE_ROOT'], 'mail/mailpile', utils.safe_domain_name(domain), utils.safe_domain_name(user)) + maildir = os.path.join(env['STORAGE_ROOT'], 'mail/mailboxes', utils.safe_domain_name(domain), utils.safe_domain_name(user)) + port = 10300 + len(running_mailpiles) + + def mp(*args): + cmd = [os.path.join(os.path.dirname(__file__), '../externals/Mailpile/mp')] + list(args) + utils.shell("check_call", cmd, env={ "MAILPILE_HOME": mp_home }) + + os.makedirs(mp_home, exist_ok=True) + mp("--setup") + mp("--add", maildir, "--rescan", "all") + + # Create OS file descriptors for two ends of a pipe. + # The pipe's write end remains open until the process dies, which is right. + # But we close the read end immediately. + + pipe_r, pipe_w = os.pipe() + os.close(pipe_r) + + # Span mailpile in a way that lets us control its stdin. + mailpile_proc = \ + utils.shell("Popen", + [ + os.path.join(os.path.dirname(__file__), '../externals/Mailpile/mp'), + "--www", + "--set", "sys.http_port=%d" % port, + "--set", "profiles.0.email=%s" % emailaddr, + "--set", "profiles.0.name=%s" % emailaddr, + ], + stdin=pipe_w, + env={ "MAILPILE_HOME": mp_home } + ) + + # Give mailpile time to start before trying to send over a request. + time.sleep(3) + + return port + +# APP + +if __name__ == '__main__': + # Debugging, logging. + + if "DEBUG" in os.environ: app.debug = True + + if not app.debug: + app.logger.addHandler(utils.create_syslog_handler()) + + # Secret key. + key_file = "/tmp/mailpile-multiplexer-key" + try: + import base64 + with open(key_file, "rb") as f: + app.secret_key = base64.b64decode(f.read().strip()) + except IOError: + # Generate a fresh secret key, invalidating any sessions. + app.secret_key = os.urandom(24) + with utils.create_file_with_mode(key_file, 0o640) as f: + f.write(base64.b64encode(app.secret_key).decode('ascii') + "\n") + + # Start + + app.run(port=10223) + diff --git a/mailpile-multiplexer/templates/frameset.html b/mailpile-multiplexer/templates/frameset.html new file mode 100644 index 00000000..c2dcc279 --- /dev/null +++ b/mailpile-multiplexer/templates/frameset.html @@ -0,0 +1,9 @@ + + + Mailpile + + + + + + diff --git a/mailpile-multiplexer/templates/login.html b/mailpile-multiplexer/templates/login.html new file mode 100644 index 00000000..283b2ccd --- /dev/null +++ b/mailpile-multiplexer/templates/login.html @@ -0,0 +1,57 @@ + + + + + + + {{hostname}} + + + +
+
+
+
+

{{hostname}}

+

Log in here to your Mailpile webmail.

+ {% if error %} +

{{error}}

+ {% endif %} +
+ +
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/mailpile-multiplexer/templates/login_status.html b/mailpile-multiplexer/templates/login_status.html new file mode 100644 index 00000000..a9b21c7e --- /dev/null +++ b/mailpile-multiplexer/templates/login_status.html @@ -0,0 +1,24 @@ + + + + + + You are logged in as {{auth.email}}. + Log out? + + \ No newline at end of file diff --git a/management/auth.py b/management/auth.py index dee8c7e5..e1dc4ffb 100644 --- a/management/auth.py +++ b/management/auth.py @@ -8,6 +8,8 @@ from mailconfig import get_mail_user_privileges DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key' DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server' +from utils import create_file_with_mode + class KeyAuthService: """Generate an API key for authenticating clients @@ -27,13 +29,6 @@ class KeyAuthService: authorized to access the API by granting group/ACL read permissions on the key file. """ - def create_file_with_mode(path, mode): - # Based on answer by A-B-B: http://stackoverflow.com/a/15015748 - old_umask = os.umask(0) - try: - return os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT, mode), 'w') - finally: - os.umask(old_umask) os.makedirs(os.path.dirname(self.key_path), exist_ok=True) diff --git a/management/utils.py b/management/utils.py index ffe49734..fa33011e 100644 --- a/management/utils.py +++ b/management/utils.py @@ -1,4 +1,4 @@ -import os.path +import os, os.path, subprocess CONF_DIR = os.path.join(os.path.dirname(__file__), "../conf") @@ -136,11 +136,12 @@ def is_pid_valid(pid): else: return True -def shell(method, cmd_args, env={}, capture_stderr=False, return_bytes=False, trap=False, input=None): - # A safe way to execute processes. - # Some processes like apt-get require being given a sane PATH. - import subprocess +def shell(method, cmd_args, env={}, capture_stderr=False, return_bytes=False, trap=False, + input=None, stdin=subprocess.DEVNULL): + # A safe way to execute processes. + + # Some processes like apt-get require being given a sane PATH. env.update({ "PATH": "/sbin:/bin:/usr/sbin:/usr/bin" }) kwargs = { 'env': env, @@ -148,6 +149,10 @@ def shell(method, cmd_args, env={}, capture_stderr=False, return_bytes=False, tr } if method == "check_output" and input is not None: kwargs['input'] = input + else: + kwargs['stdin'] = stdin + + #print(method, "".join(("%s=%s " % kv) for kv in env.items()) + " ".join(cmd_args)) if not trap: ret = getattr(subprocess, method)(cmd_args, **kwargs) @@ -169,3 +174,11 @@ def create_syslog_handler(): handler = logging.handlers.SysLogHandler(address='/dev/log') handler.setLevel(logging.WARNING) return handler + +def create_file_with_mode(path, mode): + # Based on answer by A-B-B: http://stackoverflow.com/a/15015748 + old_umask = os.umask(0) + try: + return os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT, mode), 'w') + finally: + os.umask(old_umask) diff --git a/setup/mailpile.sh b/setup/mailpile.sh new file mode 100755 index 00000000..7b207afd --- /dev/null +++ b/setup/mailpile.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# Install Mailpile (https://www.mailpile.is/), a new +# modern webmail client currently in alpha. + +source setup/functions.sh # load our functions +source /etc/mailinabox.conf # load global vars + +# Dependencies, via their Makefile's debian-dev target and things they +# should have also mentioned. + +apt_install python-imaging python-lxml python-jinja2 pep8 \ + ruby-dev yui-compressor python-nose spambayes \ + phantomjs python-pip python-mock python-pgpdump +pip install 'selenium>=2.40.0' +gem install therubyracer less + +# Install Mailpile + +# TODO: Install from a release. +if [ ! -d externals/Mailpile ]; then + mkdir -p externals + git clone https://github.com/pagekite/Mailpile.git externals/Mailpile +fi