diff --git a/actions/openvpn b/actions/openvpn new file mode 100755 index 000000000..9d622494c --- /dev/null +++ b/actions/openvpn @@ -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 . +# + +""" +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} + +{cert} + +{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() diff --git a/data/etc/plinth/modules-enabled/openvpn b/data/etc/plinth/modules-enabled/openvpn new file mode 100644 index 000000000..5357c47b4 --- /dev/null +++ b/data/etc/plinth/modules-enabled/openvpn @@ -0,0 +1 @@ +plinth.modules.openvpn diff --git a/plinth/modules/openvpn/__init__.py b/plinth/modules/openvpn/__init__.py new file mode 100644 index 000000000..35cfde45c --- /dev/null +++ b/plinth/modules/openvpn/__init__.py @@ -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 . +# + +""" +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 diff --git a/plinth/modules/openvpn/forms.py b/plinth/modules/openvpn/forms.py new file mode 100644 index 000000000..dd6ce4c46 --- /dev/null +++ b/plinth/modules/openvpn/forms.py @@ -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 . +# + +""" +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) diff --git a/plinth/modules/openvpn/templates/openvpn.html b/plinth/modules/openvpn/templates/openvpn.html new file mode 100644 index 000000000..99fce8a98 --- /dev/null +++ b/plinth/modules/openvpn/templates/openvpn.html @@ -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 . +# +{% endcomment %} + +{% load bootstrap %} + + +{% block page_head %} + + {% if status.setup_running %} + + {% endif %} + +{% endblock %} + + +{% block content %} + +

Virtual Private Network (OpenVPN)

+ +

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.

+ + {% if status.is_setup %} + +

Profile

+ +

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 + documentation on + recommended clients and instructions on how to configure them.

+ +

Profile is specific to each user of {{ cfg.box_name }}. Keep + it a secret.

+ +
+ {% csrf_token %} + + +
+ + {% endif %} + +

Status

+ + {% if not status.is_setup and not status.setup_running %} +

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.

+ +
+ {% csrf_token %} + + +
+ {% endif %} + + {% if not status.is_setup and status.setup_running %} +

+ OpenVPN setup is running +

+ +

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.

+ {% endif %} + + {% if status.is_setup %} +

+ {% if status.is_running %} + OpenVPN server is running + {% else %} + OpenVPN server is not running + {% endif %} +

+ + {% include "diagnostics_button.html" with module="openvpn" %} + +

Configuration

+ +
+ {% csrf_token %} + + {{ form|bootstrap }} + + +
+ {% endif %} + +{% endblock %} diff --git a/plinth/modules/openvpn/tests/__init__.py b/plinth/modules/openvpn/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plinth/modules/openvpn/urls.py b/plinth/modules/openvpn/urls.py new file mode 100644 index 000000000..935604ce0 --- /dev/null +++ b/plinth/modules/openvpn/urls.py @@ -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 . +# + +""" +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'), + ) diff --git a/plinth/modules/openvpn/views.py b/plinth/modules/openvpn/views.py new file mode 100644 index 000000000..8261fe654 --- /dev/null +++ b/plinth/modules/openvpn/views.py @@ -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 . +# + +""" +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'))