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.
+
+
+
+ {% 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.
+
+
+ {% 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
+
+
+ {% 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'))