Mailpile: an installation script and a multiplexer daemon that proxies Mailpile behind an authorization step and multiplexes Mailpile instances per logged in user

This commit is contained in:
Joshua Tauberer 2014-07-02 11:42:20 +00:00
parent d11ce688ca
commit 4f57c62681
7 changed files with 406 additions and 12 deletions

272
mailpile-multiplexer/daemon.py Executable file
View File

@ -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 "<html><body></body></html>"
# 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 """
<html>
<body>
<script>
top.location.reload();
</script>
</body>
</html>
"""
@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/<path:path>', 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)

View File

@ -0,0 +1,9 @@
<html>
<head>
<title>Mailpile</title>
</head>
<FRAMESET ROWS="35, *" BORDER="0">
<FRAME SRC="status">
<FRAME SRC="mailpile">
</FRAMESET>
</HTML>

View File

@ -0,0 +1,57 @@
<!DOCTYPE html>
<html class="no-js">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport" content="width=device-width">
<title>{{hostname}}</title>
<link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.min.css" />
</head>
<body>
<div class="container">
<div class="row">
<div class="col-sm-offset-2 col-sm-8 col-md-offset-3 col-md-6 col-lg-offset-3 col-lg-6">
<center>
<h1 style="margin: 1em">{{hostname}}</h1>
<p style="margin: 2em">Log in here to your Mailpile webmail.</p>
{% if error %}
<p style="margin: 2em" class="text-danger">{{error}}</p>
{% endif %}
</center>
<form class="form-horizontal" role="form" method="post">
<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="inputEmail" placeholder="Email" value="{{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" placeholder="Password" value="{{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" {% if remember %}checked{% endif %}> 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>
</div>
<script src="//code.jquery.com/jquery-2.1.1.min.js"> </script>
<script src="//maxcdn.bootstrapcdn.com/bootstrap/3.1.1/js/bootstrap.min.js"></script>
</body>
</html>

View File

@ -0,0 +1,24 @@
<html>
<head>
<style>
body {
margin: 0;
padding-top: 8px;
padding-right: 8px;
font-size: 12px;
font-family: sans-serif;
background: #f6f6f6;
color: #444;
border-bottom: 1px solid #EEE;
text-align: right;
}
body a {
color: #449;
}
</style>
</style>
<body>
You are logged in as {{auth.email}}.
<a href="logout">Log out?</a>
</body>
</html>

View File

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

View File

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

24
setup/mailpile.sh Executable file
View File

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