openvpn: New module VPN into FreedomBox

- Authentication using client certificates.  Extra password based
  authentication for later.

- Auto setup of CA, server and client certificates.

- Provides a .ovpn profile for each user for easy setup.

- Use 4096 bit Diffie-Hellman parameters for better security.  If this
  takes to much time, reduce it to 2048 or 1024, at least during
  debugging.
This commit is contained in:
Sunil Mohan Adapa 2015-10-23 00:39:46 +05:30 committed by James Valleroy
parent 52bfb475e8
commit 646763ff3c
8 changed files with 600 additions and 0 deletions

211
actions/openvpn Executable file
View File

@ -0,0 +1,211 @@
#!/usr/bin/python3
# -*- mode: python -*-
#
# This file is part of Plinth.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
"""
Configuration helper for OpenVPN server.
"""
import argparse
import os
import subprocess
from plinth import action_utils
KEYS_DIRECTORY = '/etc/openvpn/freedombox-keys'
DH_KEY = '/etc/openvpn/freedombox-keys/dh4096.pem'
SERVER_CONFIGURATION_PATH = '/etc/openvpn/freedombox.conf'
CA_CERTIFICATE_PATH = KEYS_DIRECTORY + '/ca.crt'
USER_CERTIFICATE_PATH = KEYS_DIRECTORY + '/{username}.crt'
USER_KEY_PATH = KEYS_DIRECTORY + '/{username}.key'
SERVER_CONFIGURATION = '''
port 1194
proto udp
dev tun
ca /etc/openvpn/freedombox-keys/ca.crt
cert /etc/openvpn/freedombox-keys/server.crt
key /etc/openvpn/freedombox-keys/server.key
dh /etc/openvpn/freedombox-keys/dh4096.pem
server 10.91.0.0 255.255.255.0
keepalive 10 120
cipher AES-256-CBC
comp-lzo
verb 3
'''
CLIENT_CONFIGURATION = '''
client
remote {remote} 1194
proto udp
dev tun
nobind
remote-cert-tls server
cipher AES-256-CBC
comp-lzo
redirect-gateway
verb 3
<ca>
{ca}</ca>
<cert>
{cert}</cert>
<key>
{key}</key>'''
CERTIFICATE_CONFIGURATION = {
'KEY_CONFIG': '/usr/share/easy-rsa/openssl-1.0.0.cnf',
'KEY_DIR': KEYS_DIRECTORY,
'OPENSSL': 'openssl',
'KEY_SIZE': '4096',
'CA_EXPIRE': '3650',
'KEY_EXPIRE': '3650',
'KEY_COUNTRY': 'US',
'KEY_PROVINCE': 'NY',
'KEY_CITY': 'New York',
'KEY_ORG': 'FreedomBox',
'KEY_EMAIL': 'me@freedombox',
'KEY_OU': 'Home',
'KEY_NAME': 'FreedomBox'
}
COMMON_ARGS = {'env': CERTIFICATE_CONFIGURATION,
'cwd': KEYS_DIRECTORY}
def parse_arguments():
"""Return parsed command line arguments as dictionary."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest='subcommand', help='Sub command')
subparsers.add_parser('is-setup', help='Return whether setup is completed')
subparsers.add_parser('setup', help='Setup OpenVPN server configuration')
subparsers.add_parser('enable', help='Enable OpenVPN server')
subparsers.add_parser('disable', help='Disable OpenVPN server')
get_profile = subparsers.add_parser(
'get-profile', help='Return the OpenVPN profile of a user')
get_profile.add_argument('username', help='User to get profile for')
get_profile.add_argument('remote_server',
help='The server name for the user to connect')
return parser.parse_args()
def subcommand_is_setup(_):
"""Return whether setup is complete."""
print('true' if os.path.isfile(DH_KEY) else 'false')
def subcommand_setup(_):
"""Setup configuration, CA and certificates."""
_create_server_config()
_create_certificates()
_setup_firewall()
action_utils.service_enable('openvpn@freedombox')
action_utils.service_restart('openvpn@freedombox')
def _create_server_config():
"""Write server configuration."""
if os.path.exists(SERVER_CONFIGURATION_PATH):
return
with open(SERVER_CONFIGURATION_PATH, 'w') as file_handle:
file_handle.write(SERVER_CONFIGURATION)
def _setup_firewall():
"""Add TUN device to internal zone in firewalld."""
subprocess.call(['firewall-cmd', '--zone', 'internal',
'--add-interface', 'tun+'])
subprocess.call(['firewall-cmd', '--permanent', '--zone', 'internal',
'--add-interface', 'tun+'])
def _create_certificates():
"""Generate CA and server certificates."""
try:
os.mkdir(KEYS_DIRECTORY, 0o700)
except FileExistsError:
pass
subprocess.check_call(['/usr/share/easy-rsa/clean-all'], **COMMON_ARGS)
subprocess.check_call(['/usr/share/easy-rsa/pkitool', '--initca'],
**COMMON_ARGS)
subprocess.check_call(['/usr/share/easy-rsa/pkitool', '--server', 'server'],
**COMMON_ARGS)
subprocess.check_call(['/usr/share/easy-rsa/build-dh'], **COMMON_ARGS)
def subcommand_enable(_):
"""Start OpenVPN service."""
action_utils.service_enable('openvpn@freedombox')
def subcommand_disable(_):
"""Stop OpenVPN service."""
action_utils.service_disable('openvpn@freedombox')
def subcommand_get_profile(arguments):
"""Return the profile for a user."""
username = arguments.username
remote_server = arguments.remote_server
if username == 'ca' or username == 'server':
raise Exception('Invalid username')
user_certificate = USER_CERTIFICATE_PATH.format(username=username)
user_key = USER_KEY_PATH.format(username=username)
if not os.path.isfile(user_certificate) or not os.path.isfile(user_key):
subprocess.check_call(['/usr/share/easy-rsa/pkitool', username],
**COMMON_ARGS)
user_certificate_string = _read_file(user_certificate)
user_key_string = _read_file(user_key)
ca_string = _read_file(CA_CERTIFICATE_PATH)
profile = CLIENT_CONFIGURATION.format(
ca=ca_string, cert=user_certificate_string, key=user_key_string,
remote=remote_server)
print(profile)
def _read_file(filename):
"""Return the entire contens of a file as string."""
with open(filename, 'r') as file_handle:
return ''.join(file_handle.readlines())
def main():
"""Parse arguments and perform all duties."""
arguments = parse_arguments()
subcommand = arguments.subcommand.replace('-', '_')
subcommand_method = globals()['subcommand_' + subcommand]
subcommand_method(arguments)
if __name__ == '__main__':
main()

View File

@ -0,0 +1 @@
plinth.modules.openvpn

View File

@ -0,0 +1,68 @@
#
# This file is part of Plinth.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
"""
Plinth module to configure OpenVPN server.
"""
from gettext import gettext as _
from plinth import actions
from plinth import action_utils
from plinth import cfg
from plinth import service as service_module
depends = ['plinth.modules.apps']
service = None
def init():
"""Intialize the OpenVPN module."""
menu = cfg.main_menu.get('apps:index')
menu.add_urlname(_('Virtual Private Network (OpenVPN)'), 'glyphicon-lock',
'openvpn:index', 850)
global service
service = service_module.Service(
'openvpn', _('OpenVPN'), ['openvpn'],
is_external=True, enabled=is_enabled())
def is_enabled():
"""Return whether the module is enabled."""
return action_utils.service_is_enabled('openvpn@freedombox')
def is_running():
"""Return whether the service is running."""
return action_utils.service_is_running('openvpn@freedombox')
def is_setup():
"""Return whether the service is running."""
return actions.superuser_run('openvpn', ['is-setup']).strip() == 'true'
def diagnose():
"""Run diagnostics and return the results."""
results = []
results.append(action_utils.diagnose_port_listening(1194, 'udp4'))
return results

View File

@ -0,0 +1,30 @@
#
# This file is part of Plinth.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
"""
Plinth module for configuring OpenVPN.
"""
from django import forms
from gettext import gettext as _
class OpenVpnForm(forms.Form): # pylint: disable=W0232
"""OpenVPN configuration form."""
enabled = forms.BooleanField(
label=_('Enable OpenVPN server'),
required=False)

View File

@ -0,0 +1,116 @@
{% extends "base.html" %}
{% comment %}
#
# This file is part of Plinth.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
{% endcomment %}
{% load bootstrap %}
{% block page_head %}
{% if status.setup_running %}
<meta http-equiv="refresh" content="3" />
{% endif %}
{% endblock %}
{% block content %}
<h2>Virtual Private Network (OpenVPN)</h2>
<p>Virtual Private Network (VPN) is a technique for securely
connecting two machines in order to access resources of a private
network. While you are away from home, you can connect to your
{{ cfg.box_name }} in order to join your home network and access
private/internal services provided by {{ cfg.box_name }}. You can
also access the rest of the Internet via {{ cfg.box_name }} for
added security and anonymity.</p>
{% if status.is_setup %}
<h3>Profile</h3>
<p>To connect to {{ cfg.box_name }}'s VPN, you need to download a
profile and feed it to an OpenVPN client on your mobile or
desktop machine. OpenVPN Clients are available for most
platforms. See
<a href="https://wiki.debian.org/FreedomBox/Manual/OpenVPN"
title="FreedomBox Manual - OpenVPN">documentation</a> on
recommended clients and instructions on how to configure them.</p>
<p>Profile is specific to each user of {{ cfg.box_name }}. Keep
it a secret.</p>
<form class="form" method="post" action="{% url 'openvpn:profile' %}">
{% csrf_token %}
<input type="submit" class="btn btn-primary" value="Download my profile"/>
</form>
{% endif %}
<h3>Status</h3>
{% if not status.is_setup and not status.setup_running %}
<p>OpenVPN has not yet been setup. Performing a secure setups
takes a very long time. Depending on how fast your
{{ cfg.box_name }} is, it may even take hours. If the setup is
interrupted, you may start it again.</p>
<form class="form" method="post" action="{% url 'openvpn:setup' %}">
{% csrf_token %}
<input type="submit" class="btn btn-primary" value="Start setup"/>
</form>
{% endif %}
{% if not status.is_setup and status.setup_running %}
<p class="running-status-parent">
<span class='running-status active'></span> OpenVPN setup is running
</p>
<p>To perform a secure setup, this process takes a very long time.
Depending on how fast your {{ cfg.box_name }} is, it may even
take hours. If the setup is interrupted, you may start it
again.</p>
{% endif %}
{% if status.is_setup %}
<p class="running-status-parent">
{% if status.is_running %}
<span class='running-status active'></span> OpenVPN server is running
{% else %}
<span class='running-status inactive'></span> OpenVPN server is not running
{% endif %}
</p>
{% include "diagnostics_button.html" with module="openvpn" %}
<h3>Configuration</h3>
<form class="form" method="post">
{% csrf_token %}
{{ form|bootstrap }}
<input type="submit" class="btn btn-primary" value="Update setup"/>
</form>
{% endif %}
{% endblock %}

View File

View File

@ -0,0 +1,30 @@
#
# This file is part of Plinth.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
"""
URLs for the OpenVPN module.
"""
from django.conf.urls import patterns, url
urlpatterns = patterns( # pylint: disable-msg=C0103
'plinth.modules.openvpn.views',
url(r'^apps/openvpn/$', 'index', name='index'),
url(r'^apps/openvpn/setup/$', 'setup', name='setup'),
url(r'^apps/openvpn/profile/$', 'profile', name='profile'),
)

View File

@ -0,0 +1,144 @@
#
# This file is part of Plinth.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
"""
Plinth module for configuring OpenVPN server.
"""
from django.contrib import messages
from django.http import HttpResponse
from django.shortcuts import redirect
from django.template.response import TemplateResponse
from django.views.decorators.http import require_POST
from gettext import gettext as _
import logging
from .forms import OpenVpnForm
from plinth import actions
from plinth import package
from plinth.modules import openvpn
from plinth.modules.config import config
logger = logging.getLogger(__name__)
setup_process = None
@package.required(['openvpn', 'easy-rsa'])
def index(request):
"""Serve configuration page."""
status = get_status()
if status['setup_running']:
_collect_setup_result(request)
form = None
if request.method == 'POST':
form = OpenVpnForm(request.POST, prefix='openvpn')
# pylint: disable=E1101
if form.is_valid():
_apply_changes(request, status, form.cleaned_data)
status = get_status()
form = OpenVpnForm(initial=status, prefix='openvpn')
else:
form = OpenVpnForm(initial=status, prefix='openvpn')
return TemplateResponse(request, 'openvpn.html',
{'title': _('Virtual Private Network (OpenVPN)'),
'status': status,
'form': form})
@require_POST
def setup(request):
"""Start the setup process."""
openvpn.service.notify_enabled(None, True)
global setup_process
if not openvpn.is_setup() and not setup_process:
setup_process = actions.superuser_run('openvpn', ['setup'], async=True)
return redirect('openvpn:index')
@require_POST
def profile(request):
"""Provide the user's profile for download."""
username = request.user.username
domainname = config.get_domainname()
if not config.get_domainname():
domainname = config.get_hostname()
profile_string = actions.superuser_run(
'openvpn', ['get-profile', username, domainname])
response = HttpResponse(profile_string,
content_type='application/x-openvpn-profile')
response['Content-Disposition'] = \
'attachment; filename={username}.ovpn'.format(username=username)
return response
def get_status():
"""Get the current settings from Transmission server."""
status = {'is_setup': openvpn.is_setup(),
'setup_running': False,
'enabled': openvpn.is_enabled(),
'is_running': openvpn.is_running()}
status['setup_running'] = bool(setup_process)
return status
def _collect_setup_result(request):
"""Handle setup process is completion."""
global setup_process
if not setup_process:
return
return_code = setup_process.poll()
# Setup process is not complete yet
if return_code == None:
return
if not return_code:
messages.success(request, _('Setup completed.'))
else:
messages.info(request, _('Setup failed.'))
setup_process = None
def _apply_changes(request, old_status, new_status):
"""Apply the changes."""
modified = False
if old_status['enabled'] != new_status['enabled']:
sub_command = 'enable' if new_status['enabled'] else 'disable'
actions.superuser_run('openvpn', [sub_command])
openvpn.service.notify_enabled(None, new_status['enabled'])
modified = True
if modified:
messages.success(request, _('Configuration updated'))
else:
messages.info(request, _('Setting unchanged'))