summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--data/templates/openvpn/server.conf.tmpl6
-rw-r--r--debian/control1
-rw-r--r--interface-definitions/interfaces-openvpn.xml.in86
-rw-r--r--op-mode-definitions/openvpn.xml.in35
-rwxr-xr-xsrc/completion/list_openvpn_users.py48
-rwxr-xr-xsrc/conf_mode/interfaces-openvpn.py56
-rwxr-xr-xsrc/op_mode/show_openvpn_mfa.py64
7 files changed, 292 insertions, 4 deletions
diff --git a/data/templates/openvpn/server.conf.tmpl b/data/templates/openvpn/server.conf.tmpl
index bdf88b85f..bc2790965 100644
--- a/data/templates/openvpn/server.conf.tmpl
+++ b/data/templates/openvpn/server.conf.tmpl
@@ -126,6 +126,12 @@ push "dhcp-option DNS6 {{ nameserver }}"
{% if server.domain_name is defined and server.domain_name is not none %}
push "dhcp-option DOMAIN {{ server.domain_name }}"
{% endif %}
+{% if server.mfa is defined and server.mfa is not none %}
+{% if server.mfa.totp is defined and server.mfa.totp is not none %}
+{% set totp_config = server.mfa.totp %}
+plugin "{{ plugin_dir}}/openvpn-otp.so" "otp_secrets=/config/auth/openvpn/{{ ifname }}-otp-secrets {{ 'otp_slop=' ~ totp_config.slop }} {{ 'totp_t0=' ~ totp_config.drift }} {{ 'totp_step=' ~ totp_config.step }} {{ 'totp_digits=' ~ totp_config.digits }} password_is_cr={{ '1' if totp_config.challenge == 'enable' else '0' }}"
+{% endif %}
+{% endif %}
{% endif %}
{% else %}
#
diff --git a/debian/control b/debian/control
index 0843b9025..08c04b439 100644
--- a/debian/control
+++ b/debian/control
@@ -106,6 +106,7 @@ Depends:
openvpn,
openvpn-auth-ldap,
openvpn-auth-radius,
+ openvpn-otp,
pciutils,
pdns-recursor,
pmacct (>= 1.6.0),
diff --git a/interface-definitions/interfaces-openvpn.xml.in b/interface-definitions/interfaces-openvpn.xml.in
index d67549d87..6b4440688 100644
--- a/interface-definitions/interfaces-openvpn.xml.in
+++ b/interface-definitions/interfaces-openvpn.xml.in
@@ -633,6 +633,92 @@
</properties>
<defaultValue>net30</defaultValue>
</leafNode>
+ <node name="mfa">
+ <properties>
+ <help>multi-factor authentication</help>
+ </properties>
+ <children>
+ <node name="totp">
+ <properties>
+ <help>Time-based one-time passwords</help>
+ </properties>
+ <children>
+ <leafNode name="slop">
+ <properties>
+ <help>Maximum allowed clock slop in seconds (default: 180)</help>
+ <valueHelp>
+ <format>1-65535</format>
+ <description>Seconds</description>
+ </valueHelp>
+ <constraint>
+ <validator name="numeric" argument="--range 1-65535"/>
+ </constraint>
+ </properties>
+ <defaultValue>180</defaultValue>
+ </leafNode>
+ <leafNode name="drift">
+ <properties>
+ <help>Time drift in seconds (default: 0)</help>
+ <valueHelp>
+ <format>1-65535</format>
+ <description>Seconds</description>
+ </valueHelp>
+ <constraint>
+ <validator name="numeric" argument="--range 1-65535"/>
+ </constraint>
+ </properties>
+ <defaultValue>0</defaultValue>
+ </leafNode>
+ <leafNode name="step">
+ <properties>
+ <help>Step value for totp in seconds (default: 30)</help>
+ <valueHelp>
+ <format>1-65535</format>
+ <description>Seconds</description>
+ </valueHelp>
+ <constraint>
+ <validator name="numeric" argument="--range 1-65535"/>
+ </constraint>
+ </properties>
+ <defaultValue>30</defaultValue>
+ </leafNode>
+ <leafNode name="digits">
+ <properties>
+ <help>Number of digits to use for totp hash (default: 6)</help>
+ <valueHelp>
+ <format>1-65535</format>
+ <description>Seconds</description>
+ </valueHelp>
+ <constraint>
+ <validator name="numeric" argument="--range 1-65535"/>
+ </constraint>
+ </properties>
+ <defaultValue>6</defaultValue>
+ </leafNode>
+ <leafNode name="challenge">
+ <properties>
+ <help>Expect password as result of a challenge response protocol (default: enabled)</help>
+ <completionHelp>
+ <list>disable enable</list>
+ </completionHelp>
+ <valueHelp>
+ <format>disable</format>
+ <description>Disable challenge-response</description>
+ </valueHelp>
+ <valueHelp>
+ <format>enable</format>
+ <description>Enable chalenge-response (default)</description>
+ </valueHelp>
+ <constraint>
+ <regex>^(disable|enable)$</regex>
+ </constraint>
+ </properties>
+ <defaultValue>enable</defaultValue>
+ </leafNode>
+ </children>
+ </node>
+ </children>
+ </node>
</children>
</node>
<leafNode name="shared-secret-key">
diff --git a/op-mode-definitions/openvpn.xml.in b/op-mode-definitions/openvpn.xml.in
index 73cbbe501..301688271 100644
--- a/op-mode-definitions/openvpn.xml.in
+++ b/op-mode-definitions/openvpn.xml.in
@@ -55,6 +55,41 @@
</properties>
<command>${vyos_op_scripts_dir}/show_interfaces.py --intf=$4</command>
<children>
+ <tagNode name="user">
+ <properties>
+ <help>Show OpenVPN interface users</help>
+ <completionHelp>
+ <script>sudo ${vyos_completion_dir}/list_openvpn_users.py --interface ${COMP_WORDS[3]}</script>
+ </completionHelp>
+ </properties>
+ <children>
+ <node name="mfa">
+ <properties>
+ <help>Show multi-factor authentication information</help>
+ </properties>
+ <children>
+ <leafNode name="secret">
+ <properties>
+ <help>Show multi-factor authentication secret</help>
+ </properties>
+ <command>${vyos_op_scripts_dir}/show_openvpn_mfa.py --user="$6" --intf="$4" --action=secret</command>
+ </leafNode>
+ <leafNode name="uri">
+ <properties>
+ <help>Show multi-factor authentication otpauth uri</help>
+ </properties>
+ <command>${vyos_op_scripts_dir}/show_openvpn_mfa.py --user="$6" --intf="$4" --action=uri</command>
+ </leafNode>
+ <leafNode name="qrcode">
+ <properties>
+ <help>Show multi-factor authentication QR code</help>
+ </properties>
+ <command>${vyos_op_scripts_dir}/show_openvpn_mfa.py --user="$6" --intf="$4" --action=qrcode</command>
+ </leafNode>
+ </children>
+ </node>
+ </children>
+ </tagNode>
<leafNode name="brief">
<properties>
<help>Show summary of specified OpenVPN interface information</help>
diff --git a/src/completion/list_openvpn_users.py b/src/completion/list_openvpn_users.py
new file mode 100755
index 000000000..c472dbeab
--- /dev/null
+++ b/src/completion/list_openvpn_users.py
@@ -0,0 +1,48 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-2021 VyOS maintainers and contributors
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 or later as
+# published by the Free Software Foundation.
+#
+# 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+import sys
+import argparse
+
+from vyos.config import Config
+from vyos.util import dict_search
+
+def get_user_from_interface(interface):
+ config = Config()
+ base = ['interfaces', 'openvpn', interface]
+ openvpn = config.get_config_dict(base, effective=True, key_mangling=('-', '_'))
+ users = []
+
+ try:
+ for user in (dict_search('server.client', openvpn[interface]) or []):
+ users.append(user.split(',')[0])
+ except:
+ pass
+
+ return users
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser()
+ parser.add_argument("-i", "--interface", type=str, help="List users per interface")
+ args = parser.parse_args()
+
+ users = []
+
+ users = get_user_from_interface(args.interface)
+
+ print(" ".join(users))
+
diff --git a/src/conf_mode/interfaces-openvpn.py b/src/conf_mode/interfaces-openvpn.py
index ce62a8b82..7f4aa367f 100755
--- a/src/conf_mode/interfaces-openvpn.py
+++ b/src/conf_mode/interfaces-openvpn.py
@@ -16,6 +16,7 @@
import os
import re
+import tempfile
from cryptography.hazmat.primitives.asymmetric import ec
from glob import glob
@@ -26,6 +27,7 @@ from ipaddress import IPv6Address
from ipaddress import IPv6Network
from ipaddress import summarize_address_range
from netifaces import interfaces
+from secrets import SystemRandom
from shutil import rmtree
from vyos.config import Config
@@ -48,6 +50,7 @@ from vyos.util import chown
from vyos.util import dict_search
from vyos.util import dict_search_args
from vyos.util import makedir
+from vyos.util import read_file
from vyos.util import write_file
from vyos.validate import is_addr_assigned
@@ -60,6 +63,9 @@ group = 'openvpn'
cfg_dir = '/run/openvpn'
cfg_file = '/run/openvpn/{ifname}.conf'
+otp_path = '/config/auth/openvpn'
+otp_file = '/config/auth/openvpn/{ifname}-otp-secrets'
+secret_chars = list('ABCDEFGHIJKLMNOPQRSTUVWXYZ234567')
def get_config(config=None):
"""
@@ -75,12 +81,26 @@ def get_config(config=None):
tmp_pki = conf.get_config_dict(['pki'], key_mangling=('-', '_'),
get_first_key=True, no_tag_node_value_mangle=True)
+ # We have to get the dict using 'get_config_dict' instead of 'get_interface_dict'
+ # as 'get_interface_dict' merges the defaults in, so we can not check for defaults in there.
+ tmp_openvpn = conf.get_config_dict(base + [os.environ['VYOS_TAGNODE_VALUE']], key_mangling=('-', '_'),
+ get_first_key=True, no_tag_node_value_mangle=True)
+
openvpn = get_interface_dict(conf, base)
if 'deleted' not in openvpn:
openvpn['pki'] = tmp_pki
openvpn['auth_user_pass_file'] = '/run/openvpn/{ifname}.pw'.format(**openvpn)
+
+ # We have to cleanup the config dict, as default values could enable features
+ # which are not explicitly enabled on the CLI. Example: server mfa totp
+ # originate comes with defaults, which will enable the
+ # totp plugin, even when not set via CLI so we
+ # need to check this first and drop those keys
+ if 'totp' not in tmp_openvpn['server']:
+ del openvpn['server']['mfa']['totp']
+
return openvpn
def is_ec_private_key(pki, cert_name):
@@ -169,6 +189,10 @@ def verify_pki(openvpn):
def verify(openvpn):
if 'deleted' in openvpn:
+ # remove totp secrets file if totp is not configured
+ if os.path.isfile(otp_file.format(**openvpn)):
+ os.remove(otp_file.format(**openvpn))
+
verify_bridge_delete(openvpn)
return None
@@ -309,10 +333,10 @@ def verify(openvpn):
if 'is_bridge_member' not in openvpn:
raise ConfigError('Must specify "server subnet" or add interface to bridge in server mode')
-
- for client in (dict_search('client', openvpn) or []):
- if len(client['ip']) > 1 or len(client['ipv6_ip']) > 1:
- raise ConfigError(f'Server client "{client["name"]}": cannot specify more than 1 IPv4 and 1 IPv6 IP')
+ if hasattr(dict_search('server.client', openvpn), '__iter__'):
+ for client_k, client_v in dict_search('server.client', openvpn).items():
+ if (client_v.get('ip') and len(client_v['ip']) > 1) or (client_v.get('ipv6_ip') and len(client_v['ipv6_ip']) > 1):
+ raise ConfigError(f'Server client "{client_k}": cannot specify more than 1 IPv4 and 1 IPv6 IP')
if dict_search('server.client_ip_pool', openvpn):
if not (dict_search('server.client_ip_pool.start', openvpn) and dict_search('server.client_ip_pool.stop', openvpn)):
@@ -360,6 +384,29 @@ def verify(openvpn):
if IPv6Address(client['ipv6_ip'][0]) in v6PoolNet:
print(f'Warning: Client "{client["name"]}" IP {client["ipv6_ip"][0]} is in server IP pool, it is not reserved for this client.')
+ # add mfa users to the file the mfa plugin uses
+ if dict_search('server.mfa.totp', openvpn):
+ user_data = ''
+ if not os.path.isfile(otp_file.format(**openvpn)):
+ write_file(otp_file.format(**openvpn), user_data,
+ user=user, group=group, mode=0o644)
+
+ ovpn_users = read_file(otp_file.format(**openvpn))
+ for client in (dict_search('server.client', openvpn) or []):
+ exists = None
+ for ovpn_user in ovpn_users.split('\n'):
+ if re.search('^' + client + ' ', ovpn_user):
+ user_data += f'{ovpn_user}\n'
+ exists = 'true'
+
+ if not exists:
+ random = SystemRandom()
+ totp_secret = ''.join(random.choice(secret_chars) for _ in range(16))
+ user_data += f'{client} otp totp:sha1:base32:{totp_secret}::xxx *\n'
+
+ write_file(otp_file.format(**openvpn), user_data,
+ user=user, group=group, mode=0o644)
+
else:
# checks for both client and site-to-site go here
if dict_search('server.reject_unconfigured_clients', openvpn):
@@ -525,6 +572,7 @@ def generate_pki_files(openvpn):
def generate(openvpn):
interface = openvpn['ifname']
directory = os.path.dirname(cfg_file.format(**openvpn))
+ plugin_dir = '/usr/lib/openvpn'
# create base config directory on demand
makedir(directory, user, group)
# enforce proper permissions on /run/openvpn
diff --git a/src/op_mode/show_openvpn_mfa.py b/src/op_mode/show_openvpn_mfa.py
new file mode 100755
index 000000000..1ab54600c
--- /dev/null
+++ b/src/op_mode/show_openvpn_mfa.py
@@ -0,0 +1,64 @@
+#!/usr/bin/env python3
+
+# Copyright 2017, 2021 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+import re
+import socket
+import urllib.parse
+import argparse
+
+from vyos.util import popen
+
+otp_file = '/config/auth/openvpn/{interface}-otp-secrets'
+
+def get_mfa_secret(interface, client):
+ try:
+ with open(otp_file.format(interface=interface), "r") as f:
+ users = f.readlines()
+ for user in users:
+ if re.search('^' + client + ' ', user):
+ return user.split(':')[3]
+ except:
+ pass
+
+def get_mfa_uri(client, secret):
+ hostname = socket.gethostname()
+ fqdn = socket.getfqdn()
+ uri = 'otpauth://totp/{hostname}:{client}@{fqdn}?secret={secret}'
+
+ return urllib.parse.quote(uri.format(hostname=hostname, client=client, fqdn=fqdn, secret=secret), safe='/:@?=')
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser(add_help=False, description='Show two-factor authentication information')
+ parser.add_argument('--intf', action="store", type=str, default='', help='only show the specified interface')
+ parser.add_argument('--user', action="store", type=str, default='', help='only show the specified users')
+ parser.add_argument('--action', action="store", type=str, default='show', help='action to perform')
+
+ args = parser.parse_args()
+ secret = get_mfa_secret(args.intf, args.user)
+
+ if args.action == "secret" and secret:
+ print(secret)
+
+ if args.action == "uri" and secret:
+ uri = get_mfa_uri(args.user, secret)
+ print(uri)
+
+ if args.action == "qrcode" and secret:
+ uri = get_mfa_uri(args.user, secret)
+ qrcode,err = popen('qrencode -t ansiutf8', input=uri)
+ print(qrcode)
+