Commit c5dbc384 authored by ale's avatar ale
Browse files

split from "autoca" repository

parents
AutoVPN
=======
A simple self-service OpenVPN infrastructure (with X.509 PKI).
Installation
------------
The 'autovpn' Python package is bundled with 'autoca'.
Once you've got the autoca/autovpn sources, just run::
$ sudo python setup.py install
from the top-level directory.
Configuration
-------------
How to run the AutoVPN web application will depend on the deployment
method that you choose (mod_wsgi, FastCGI, standalone HTTP server...).
Examples of a few different drivers can be found in the apps/
directory.
In all cases though, you'll need to create a configuration file and
point the application at it (for example using the VPN_APP_CONFIG
environment variable). This file should define the following variables
(using Python syntax):
VPN_ENDPOINT
DNS name for the VPN server (use more than one A record for
crude load balancing)
VPN_SITE_URL
The public URL for the AutoVPN web application
VPN_CERT_VALIDITY
How many days should the generated certificates be valid for
(default: 7)
VPN_DEFAULT_SUBJECT_ATTRS
A dictionary containing X.509 attributes to use as defaults for
the subject of the generated certificates. For example::
VPN_DEFAULT_SUBJECT_ATTRS = {'O': 'My VPN', 'C': 'IT'}
The app will only set the 'CN' attribute.
There are two different options to connect to the CA, you can either
connect to a remote autoca web application:
VPN_CA_URL
URL of the remote autoca web application
CA_SHARED_SECRET
(optional) the shared secret to use for authentication
Otherwise, you can instantiate a CA that is local to the AutoVPN
application itself (for simpler deployments):
VPN_CA_ROOT
Base directory for the CA storage -- keep it private!
VPN_CA_BITS
How many bits to use for the CA RSA key (default 4096)
VPN_CA_SUBJECT
Dictionary with the X.509 CA subject attributes
In this latter case, the autoca web application will be available
under the /ca/ URL prefix.
The VPN web application supports authentication (to control who has
access to the certificate generation). To enable it, define the
following variables:
AUTH_ENABLE
Set this to True to enable authentication
AUTH_FUNCTION
Set this to a function that should accept two arguments (username
and password) and return a True/Fals result.
import json
import optparse
import redis
import time
def time2day(unix_stamp):
return time.strftime('%Y%m%d', time.gmtime(unix_stamp))
class Accounting(object):
def __init__(self, local_db, aggr_db=None):
self._local_db = local_db
self._aggr_db = aggr_db or local_db
def add_connection(self, cn, conn_info):
data = json.dumps(conn_info)
local_pipe = self._local_db.pipeline()
local_pipe.sadd('in_cns', cn)
local_pipe.rpush('aggr_in:%s' % cn, data)
local_pipe.execute()
def get_connections(self, cn, n=0):
return (json.loads(x) for x in
self._aggr_db.lrange('connections:%s' % cn, 0, (n - 1)))
def aggregate(self, cn):
conns = []
local_pipe = self._local_db.pipeline()
while True:
try:
key = 'aggr_in:%s' % cn
local_pipe.watch(key)
for data in local_pipe.lrange(key, 0, -1):
conns.append(data)
local_pipe.delete(key)
break
except redis.WatchError:
del conns[:]
finally:
local_pipe.reset()
# Compute daily aggregates, and copy the connection data to the master.
aggr = {}
pipe = self._aggr_db.pipeline()
pipe.sadd('all_cns', cn)
for data in conns:
pipe.lpush('connections:%s' % cn, data)
conn_info = json.loads(data)
day = time2day(conn_info['end_time'])
aggr_day = aggr.setdefault(day, {'conn_time': 0,
'bytes_sent': 0,
'bytes_recv': 0})
for attr in ('conn_time', 'bytes_recv', 'bytes_sent'):
aggr_day[attr] += conn_info[attr]
pipe.execute()
# Short return if there's nothing to do.
if not aggr:
return
# Aggregate values on the master server.
days = aggr.keys()
aggr_key = 'aggr:%s' % cn
pipe = self._aggr_db.pipeline()
while True:
try:
pipe.watch(aggr_key)
old_aggr = {}
for day, data in zip(days, pipe.hmget(aggr_key, days)):
if data:
old_aggr[day] = json.loads(data)
pipe.multi()
for day, aggr_data in aggr.iteritems():
old_aggr_data = old_aggr.get(day, {})
for attr in aggr_data:
aggr_data[attr] += old_aggr_data.get(attr, 0)
pipe.hset(aggr_key, day, json.dumps(aggr_data))
pipe.execute()
break
except redis.WatchError:
continue
finally:
pipe.reset()
def aggregate_all(self):
local_pipe = self._local_db.pipeline()
while True:
try:
local_pipe.watch('in_cns')
input_cns = local_pipe.smembers('in_cns')
local_pipe.delete('in_cns')
break
except redis.WatchError:
continue
finally:
local_pipe.reset()
for cn in input_cns:
self.aggregate(cn)
def get_aggregate_counts(self, cn, when=None):
if not when:
when = time.time()
day = time2day(when)
data = self._aggr_db.hget('aggr:%s' % cn, day)
if data:
return json.loads(data)
else:
return {'bytes_sent': 0, 'bytes_recv': 0, 'conn_time': 0}
def str2kv(args):
return dict(x.split('=', 1) for x in args)
def main():
parser = optparse.OptionParser()
parser.add_option('--db', dest='local_db', default='localhost',
help='endpoint of the local Redis database')
parser.add_option('--aggr-db', dest='aggr_db',
help='endpoint of the aggregate Redis database (optional)')
parser.add_option('--password', dest='password',
help='Redis password (optional)')
opts, args = parser.parse_args()
if not args:
parser.error('No command specified')
cmd, args = args[0], args[1:]
if cmd == 'help':
parser.show_help()
local_db = redis.Redis(opts.local_db, password=opts.password)
if opts.aggr_db:
aggr_db = redis.Redis(opts.aggr_db, password=opts.password)
else:
aggr_db = None
acct = Accounting(local_db, aggr_db)
if cmd == 'connect':
if len(args) < 2:
parser.error('Syntax: connect <CN> <ATTR=VALUE>...')
cn = args[0]
conn_info = str2kv(args[1:])
for mandatory in ('bytes_sent', 'bytes_recv', 'remote_ip', 'conn_time'):
if mandatory not in conn_info:
parser.error('Missing mandatory attribute "%s"' % mandatory)
for int_attr in ('bytes_sent', 'bytes_recv', 'conn_time'):
conn_info[int_attr] = int(conn_info[int_attr])
conn_info['end_time'] = int(time.time())
conn_info['start_time'] = conn_info['end_time'] - conn_info['conn_time']
acct.add_connection(cn, conn_info)
elif cmd == 'aggregate':
if len(args) != 1:
parser.error('Syntax: aggregate <CN | "all">')
cn = args[0]
if cn == 'all':
acct.aggregate_all()
else:
acct.aggregate(cn)
elif cmd == 'get-aggr':
if len(args) != 1:
parser.error('Syntax: get-aggr <CN>')
cn = args[0]
result = acct.get_aggregate_counts(cn)
for key in sorted(result):
print '%s %d' % (key, result[key])
elif cmd == 'list':
if len(args) != 1:
parser.error('Syntax: list <CN>')
cn = args[0]
for conn in acct.get_connections(cn):
print '%s %s %-6d %-20s %-20s' % (
time.strftime('%Y/%m/%d %H:%M:%S', time.gmtime(conn['start_time'])),
time.strftime('%Y/%m/%d %H:%M:%S', time.gmtime(conn['end_time'])),
conn['conn_time'],
'%d/%d' % (conn['bytes_recv'], conn['bytes_sent']),
conn['remote_ip'])
else:
parser.error('Unknown command "%s"' % cmd)
if __name__ == '__main__':
main()
This diff is collapsed.
/*
* jQuery Backstretch
* Version 1.2.5
* http://srobbin.com/jquery-plugins/jquery-backstretch/
*
* Add a dynamically-resized background image to the page
*
* Copyright (c) 2011 Scott Robbin (srobbin.com)
* Dual licensed under the MIT and GPL licenses.
*/
(function(a){a.backstretch=function(l,b,j){function m(c){try{h={left:0,top:0},e=f.width(),d=e/k,d>=f.height()?(i=(d-f.height())/2,g.centeredY&&a.extend(h,{top:"-"+i+"px"})):(d=f.height(),e=d*k,i=(e-f.width())/2,g.centeredX&&a.extend(h,{left:"-"+i+"px"})),a("#backstretch, #backstretch img:not(.deleteable)").width(e).height(d).filter("img").css(h)}catch(b){}"function"==typeof c&&c()}var n={centeredX:!0,centeredY:!0,speed:0},c=a("#backstretch"),g=c.data("settings")||n;c.data("settings");var f="onorientationchange"in window?a(document):a(window),k,e,d,i,h;b&&"object"==typeof b&&a.extend(g,b);b&&"function"==typeof b&&(j=b);a(document).ready(function(){if(l){var b;0==c.length?c=a("<div />").attr("id","backstretch").css({left:0,top:0,position:"fixed",overflow:"hidden",zIndex:-999999,margin:0,padding:0,height:"100%",width:"100%"}):c.find("img").addClass("deleteable");b=a("<img />").css({position:"absolute",display:"none",margin:0,padding:0,border:"none",zIndex:-999999}).bind("load",function(b){var d=a(this),e;d.css({width:"auto",height:"auto"});e=this.width||a(b.target).width();b=this.height||a(b.target).height();k=e/b;m(function(){d.fadeIn(g.speed,function(){c.find(".deleteable").remove();"function"==typeof j&&j()})})}).appendTo(c);0==a("body #backstretch").length&&a("body").append(c);c.data("settings",g);b.attr("src",l);a(window).resize(m)}});return this}})(jQuery);
\ No newline at end of file
body {
background-color: white;
font-family: helvetica, arial, sans-serif;
margin-left: 0px;
margin-right: auto;
width: 100%;
}
#container {
width: 100%;
margin-right: 0;
margin-left: 0;
padding: 0;
}
h1 {
font-family: helvetica, arial, sans-serif;
font-size: 6em;
color: red;
padding: 10px;
width: 50%;
background: url(/static/ant.jpg) white no-repeat top left;
opacity: 0.9;
}
h1 span {
display: block;
padding-left: 150px;
}
#content {
background-color: rgba(128,128,128,0.9);
font-weight: bold;
color: #FFF;
padding: 10px;
width: 50%;
font-size: 1.2em;
}
a {
text-decoration: underline;
color: white;
}
a:hover {
text-decoration: underline;
}
.footer {
font-size: 0.8em;
color: #AAA;
text-align: right;
padding-right: 5px;
margin-top: 20px;
}
.footer a {
text-decoration: none;
}
input {
font-size: 1.2em;
}
.center {
text-align: center;
}
<!doctype html>
<html>
<head>
<title>A/I VPN</title>
<link href="/static/favicon.ico" type="image/x-icon" rel="shortcut icon">
<link rel="stylesheet" type="text/css" href="/static/style.css">
<script type="text/javascript"
src="/static/jquery-1.6.4.min.js"></script>
<script type="text/javascript"
src="/static/jquery.backstretch.min.js"></script>
<script>
$(document).ready(function() {
$.backstretch('/static/background.jpg');
});
</script>
{% block head %}{% endblock -%}
</head>
<body>
<div id="container">
<h1>
<span>VPN</span>
</h1>
<div id="content">
{% block content %}{% endblock %}
{% if config.get('FOOTER') %}{{ config['FOOTER'] | safe }}{% endif %}
</div>
</div>
</body>
</html>
{% extends "_base.html" %}
{% set dl_url = url_for('vpn_admin.new_cert_dl', _csrf=csrf_token(), t=cn_token) %}
{% block head %}
<meta http-equiv="refresh" content="2; url={{ dl_url }}">
{% endblock %}
{% block content %}
<p>
The download of the ZIP file should start in a few seconds.
If it doesn't work, try with
<a href="{{ dl_url }}">this link</a> (note: it will only work
once).
</p>
<p>
If you have any trouble downloading the ZIP file, try clearing
the cookies in your browser and
<a href="{{ url_for('vpn_admin.index') }}">starting over</a>.
</p>
<p>
<b>This ZIP file is VERY IMPORTANT, do not
share it with anyone, and save it only on trusted media.</b>
</p>
{% endblock %}
{% extends "_base.html" %}
{% block content %}
<p>
Sorry, you are trying to re-use an expired session. If you are
trying to download a certificate, it probably means that the
file is already on your computer, check in your Downloads
folder.
</p>
<p>
Otherwise, you should
<a href="{{ url_for('vpn_admin.index') }}">start the process
from scratch</a> (it's easy anyway!)
</p>
{% endblock %}
{% extends "_base.html" %}
{% block content %}
<p>
A reasonably anonymous (but low-traffic) VPN service, for when
you really need to get on the Internet from a nasty place!
</p>
<p>
You only need a SSL certificate to connect, click the link
below to download a ZIP file containing the certificate and
a private key.
</p>
<form action="{{ url_for('vpn_admin.new_cert') }}" method="post">
<div style="display:none;">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
</div>
<p class="center">
<input type="submit" value=" Generate new SSL certificate ">
</p>
</form>
{% if config.get('HELP_MESSAGE') %}
{{ config['HELP_MESSAGE'] | safe }}
{% endif %}
{% endblock %}
{% extends "_base.html" %}
{% block content %}
<p>
{% if config.get('AUTH_MESSAGE') %}
{{ config['AUTH_MESSAGE'] | safe }}
{% else %}
Enter your authentication credentials:
{% endif %}
</p>
{% if error %}
<p class="center" style="color:red;">{{ error }}</p>
{% endif %}
<form action="{{ url_for('vpn_admin.login') }}" method="post">
<div style="display:none;">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
</div>
<p class="center">
<input type="text" size="20" placeholder="Email"
name="username" value="{{ username }}"><br>
<input type="password" size="20" placeholder="Password"
name="password"><br>
<input type="submit" value="Login">
</p>
</form>
<p>
<b>NOTE:</b> the credentials are only used to limit access
to this application. They will not be saved, nor they will
ever be associated with the VPN connection.
</p>
{% endblock %}
import tempfile
import shutil
import unittest
from flask import session
from autovpn import vpn_app
class VpnAppTest(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
self.config = {
'DEBUG': 'true',
'SECRET_KEY': 'somesecret',
'SIGNER_SECRET': 'abracadabra',
'CACHE_DIR': self.tmpdir,
'VPN_CA_ROOT': self.tmpdir,
'VPN_CA_SUBJECT': {'CN': 'test CA', 'O': 'test'},
'VPN_CA_BITS': 1024,
'VPN_ENDPOINT': 'vpn.example.com',
'VPN_SITE_URL': 'http://localhost:4000/',
'FOOTER': '''
<p class="footer">
built by <a href="http://www.autistici.org/">autistici.org</a>
</p>
''',
'AUTH_ENABLE': True,
'AUTH_FUNCTION': lambda x, y: (x and y and x == y),
'TLS_AUTH_KEY': self.tmpdir + '/tlsauth.key',
}
self.app = vpn_app.make_app(self.config)
def tearDown(self):
shutil.rmtree(self.tmpdir)
def test_login_ok(self):
with self.app.test_client() as c:
rv = c.get('/login')
csrf = session['_csrf']
rv = c.post('/login', data={
'_csrf': csrf,
'username': 'admin',
'password': 'admin'},
follow_redirects=True)
self.assertTrue('download of the ZIP file' in rv.data)
def test_login_fail(self):
with self.app.test_client() as c:
rv = c.get('/login')
csrf = session['_csrf']
rv = c.post('/login', data={
'_csrf': csrf,
'username': 'user',
'password': 'wrong password'},
follow_redirects=True)
self.assertFalse(session.get('dl_ok'))
self.assertTrue('Authentication failed' in rv.data)
def test_cert_dl(self):
cn = 'testcn1234'
t = self.app.signer.encode(cn)
with self.app.test_client() as c:
with c.session_transaction() as sess:
sess['dl_ok'] = True
sess['_csrf'] = 'csrf'
sess['logged_in'] = True
rv = c.get('/newcertdl?_csrf=csrf&t=' + t)
self.assertEquals('200 OK', rv.status)
self.assertEquals('application/zip', rv.content_type)
import datetime
import functools
import itsdangerous
import logging
import os
import shutil
import subprocess
import tempfile
import threading
import time
import uuid
import zipfile
from cStringIO import StringIO
from OpenSSL import crypto
from flask import Blueprint, Flask, abort, redirect, request, make_response, \
render_template, session, g, current_app, url_for
from autoca import ca
from autoca import ca_app
from autoca import ca_stub
vpn_admin = Blueprint('vpn_admin', __name__)
log = logging.getLogger(__name__)
OPENVPN_CONFIG_TEMPLATE = '''
client
dev tun
resolv-retry infinite
nobind
persist-key
persist-tun
<connection>