diff options
Diffstat (limited to 'src')
-rwxr-xr-x | src/completion/list_openvpn_clients.py | 57 | ||||
-rwxr-xr-x | src/conf_mode/host_name.py | 2 | ||||
-rwxr-xr-x | src/conf_mode/http-api.py | 11 | ||||
-rwxr-xr-x | src/conf_mode/https.py | 22 | ||||
-rwxr-xr-x | src/conf_mode/interface-openvpn.py | 911 | ||||
-rwxr-xr-x | src/conf_mode/protocols_bfd.py | 4 | ||||
-rwxr-xr-x | src/conf_mode/vyos_cert.py | 143 | ||||
-rwxr-xr-x | src/helpers/vyos-sudo.py | 40 | ||||
-rwxr-xr-x | src/op_mode/show_ram.sh | 33 | ||||
-rwxr-xr-x | src/op_mode/show_users.py | 111 |
10 files changed, 1314 insertions, 20 deletions
diff --git a/src/completion/list_openvpn_clients.py b/src/completion/list_openvpn_clients.py new file mode 100755 index 000000000..828ce6b5e --- /dev/null +++ b/src/completion/list_openvpn_clients.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019 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.interfaces import list_interfaces_of_type + +def get_client_from_interface(interface): + clients = [] + with open('/opt/vyatta/etc/openvpn/status/' + interface + '.status', 'r') as f: + dump = False + for line in f: + if line.startswith("Common Name,"): + dump = True + continue + if line.startswith("ROUTING TABLE"): + dump = False + continue + if dump: + # client entry in this file looks like + # client1,172.18.202.10:47495,2957,2851,Sat Aug 17 00:07:11 2019 + # we are only interested in the client name 'client1' + clients.append(line.split(',')[0]) + + return clients + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("-i", "--interface", type=str, help="List connected clients per interface") + parser.add_argument("-a", "--all", action='store_true', help="List all connected OpenVPN clients") + args = parser.parse_args() + + clients = [] + + if args.interface: + clients = get_client_from_interface(args.interface) + elif args.all: + for interface in list_interfaces_of_type("openvpn"): + clients += get_client_from_interface(interface) + + print(" ".join(clients)) + diff --git a/src/conf_mode/host_name.py b/src/conf_mode/host_name.py index 16467c8df..2fad57db6 100755 --- a/src/conf_mode/host_name.py +++ b/src/conf_mode/host_name.py @@ -45,7 +45,7 @@ config_file_resolv = '/etc/resolv.conf' config_tmpl_hosts = """ ### Autogenerated by host_name.py ### 127.0.0.1 localhost -127.0.1.1 {{ hostname }}{% if domain_name %}.{{ domain_name }}{% endif %} +127.0.1.1 {{ hostname }}{% if domain_name %}.{{ domain_name }} {{ hostname }}{% endif %} # The following lines are desirable for IPv6 capable hosts ::1 localhost ip6-localhost ip6-loopback diff --git a/src/conf_mode/http-api.py b/src/conf_mode/http-api.py index c1d596ea3..1f91ac582 100755 --- a/src/conf_mode/http-api.py +++ b/src/conf_mode/http-api.py @@ -27,14 +27,6 @@ from vyos import ConfigError config_file = '/etc/vyos/http-api.conf' -default_config_data = { - 'listen_address' : '127.0.0.1', - 'port' : '8080', - 'strict' : 'false', - 'debug' : 'false', - 'api_keys' : [ {"id": "testapp", "key": "qwerty"} ] -} - vyos_conf_scripts_dir=vyos.defaults.directories['conf_mode'] # XXX: this model will need to be extended for tag nodes @@ -43,7 +35,8 @@ dependencies = [ ] def get_config(): - http_api = default_config_data + http_api = vyos.defaults.api_data + conf = Config() if not conf.exists('service https api'): return None diff --git a/src/conf_mode/https.py b/src/conf_mode/https.py index e1e81eef1..289eacf69 100755 --- a/src/conf_mode/https.py +++ b/src/conf_mode/https.py @@ -21,6 +21,7 @@ import os import jinja2 +import vyos.defaults from vyos.config import Config from vyos import ConfigError @@ -45,11 +46,16 @@ server { # listen 443 ssl default_server; listen [::]:443 ssl default_server; + +{% if vyos_cert %} + include {{ vyos_cert.conf }}; +{% else %} # # Self signed certs generated by the ssl-cert package # Don't use them in a production server! # include snippets/snakeoil.conf; +{% endif %} {% for l_addr in listen_address %} server_name {{ l_addr }}; @@ -75,16 +81,8 @@ server { } """ -default_config_data = { - 'listen_address' : [ '127.0.0.1' ] -} - -default_api_config_data = { - 'port' : '8080', -} - def get_config(): - https = default_config_data + https = vyos.defaults.https_data conf = Config() if not conf.exists('service https'): return None @@ -95,8 +93,12 @@ def get_config(): addrs = conf.return_values('listen-address') https['listen_address'] = addrs[:] + if conf.exists('certificates'): + if conf.exists('certificates system-generated-certificate'): + https['vyos_cert'] = vyos.defaults.vyos_cert_data + if conf.exists('api'): - https['api'] = default_api_config_data + https['api'] = vyos.defaults.api_data if conf.exists('api port'): port = conf.return_value('api port') diff --git a/src/conf_mode/interface-openvpn.py b/src/conf_mode/interface-openvpn.py new file mode 100755 index 000000000..e4bde7bb0 --- /dev/null +++ b/src/conf_mode/interface-openvpn.py @@ -0,0 +1,911 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019 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 re +import pwd +import grp +import sys +import stat +import copy +import jinja2 +import psutil +from ipaddress import ip_address,ip_network,IPv4Interface + +from signal import SIGUSR1 +from subprocess import Popen, PIPE + +from vyos.config import Config +from vyos import ConfigError +from vyos.validate import is_addr_assigned + +user = 'nobody' +group = 'nogroup' + +# Please be careful if you edit the template. +config_tmpl = """ +### Autogenerated by interfaces-openvpn.py ### +# +# See https://community.openvpn.net/openvpn/wiki/Openvpn24ManPage +# for individual keyword definition + +{% if description %} +# {{ description }} +{% endif %} + +verb 3 +status /opt/vyatta/etc/openvpn/status/{{ intf }}.status 30 +writepid /var/run/openvpn/{{ intf }}.pid +daemon openvpn-{{ intf }} + +dev-type {{ type }} +dev {{ intf }} +user {{ uid }} +group {{ gid }} +persist-key + +proto {% if 'tcp-active' in protocol -%}tcp-client{% elif 'tcp-passive' in protocol -%}tcp-server{% else %}udp{% endif %} + +{%- if local_host %} +local {{ local_host }} +{% endif %} + +{%- if local_port %} +lport {{ local_port }} +{% endif %} + +{%- if remote_port %} +rport {{ remote_port }} +{% endif %} + +{%- if remote_host %} +{% for remote in remote_host -%} +remote {{ remote }} +{% endfor -%} +{% endif %} + +{%- if shared_secret_file %} +secret {{ shared_secret_file }} +{% endif %} + +{%- if persistent_tunnel %} +persist-tun +{% endif %} + +{%- if mode %} +{%- if 'client' in mode %} +# +# OpenVPN Client mode +# +client +nobind +{%- elif 'server' in mode %} +# +# OpenVPN Server mode +# +mode server +tls-server +keepalive {{ ping_interval }} {{ ping_restart }} +management /tmp/openvpn-mgmt-intf unix + +{%- if server_topology %} +topology {% if 'point-to-point' in server_topology %}p2p{% else %}subnet{% endif %} +{% endif %} + +{% for ns in server_dns_nameserver -%} +push "dhcp-option DNS {{ ns }}" +{% endfor -%} + +{% for route in server_push_route -%} +push "route {{ route }}" +{% endfor -%} + +{%- if server_domain %} +push "dhcp-option DOMAIN {{ server_domain }}" +{% endif %} + +{%- if server_max_conn %} +max-clients {{ server_max_conn }} +{% endif %} + +{%- if bridge_member %} +server-bridge nogw +{%- else %} +server {{ server_subnet }} +{% endif %} + +{%- if server_reject_unconfigured %} +ccd-exclusive +{% endif %} + +{%- else %} +# +# OpenVPN site-2-site mode +# +ping {{ ping_interval }} +ping-restart {{ ping_restart }} + +{%- if local_address_subnet %} +ifconfig {{ local_address }} {{ local_address_subnet }} +{% elif remote_address %} +ifconfig {{ local_address }} {{ remote_address }} +{% endif %} + +{% endif %} +{% endif %} + +{%- if tls_ca_cert %} +ca {{ tls_ca_cert }} +{% endif %} + +{%- if tls_cert %} +cert {{ tls_cert }} +{% endif %} + +{%- if tls_key %} +key {{ tls_key }} +{% endif %} + +{%- if tls_crl %} +crl-verify {{ tls_crl }} +{% endif %} + +{%- if tls_dh %} +dh {{ tls_dh }} +{% endif %} + +{%- if 'active' in tls_role %} +tls-client +{%- elif 'passive' in tls_role %} +tls-server +{% endif %} + +{%- if redirect_gateway %} +push "redirect-gateway {{ redirect_gateway }}" +{% endif %} + +{%- if compress_lzo %} +compress lzo +{% endif %} + +{%- if hash %} +auth {{ hash }} +{% endif %} + +{%- if encryption %} +{%- if 'des' in encryption %} +cipher des-cbc +{%- elif '3des' in encryption %} +cipher des-ede3-cbc +{%- elif 'bf128' in encryption %} +cipher bf-cbc +keysize 128 +{%- elif 'bf256' in encryption %} +cipher bf-cbc +keysize 25 +{%- elif 'aes128' in encryption %} +cipher aes-128-cbc +{%- elif 'aes192' in encryption %} +cipher aes-192-cbc +{%- elif 'aes256' in encryption %} +cipher aes-256-cbc +{% endif %} +{% endif %} + +{%- if auth %} +auth-user-pass /tmp/openvpn-{{ intf }}-pw +auth-retry nointeract +{% endif %} + +{%- if client %} +client-config-dir /opt/vyatta/etc/openvpn/ccd/{{ intf }} +{% endif %} + +{% for option in options -%} +{{ option }} +{% endfor -%} + +{%- if server_2fa_authy_key %} +plugin /usr/lib/authy/authy-openvpn.so https://api.authy.com/protected/json {{ server_2fa_authy_key }} nopam +{% endif %} +""" + +client_tmpl = """ +### Autogenerated by interfaces-openvpn.py ### + +ifconfig-push {{ ip }} {{ remote_netmask }} +{% for route in push_route -%} +push "route {{ route }}" +{% endfor -%} + +{% for net in subnet -%} +iroute {{ net }} +{% endfor -%} + +{%- if disable %} +disable +{% endif %} +""" + +default_config_data = { + 'address': [], + 'auth_user': '', + 'auth_pass': '', + 'auth': False, + 'bridge_member': [], + 'compress_lzo': False, + 'deleted': False, + 'description': '', + 'disable': False, + 'encryption': '', + 'hash': '', + 'intf': '', + 'ping_restart': '60', + 'ping_interval': '10', + 'local_address': '', + 'local_address_subnet': '', + 'local_host': '', + 'local_port': '', + 'mode': '', + 'options': [], + 'persistent_tunnel': False, + 'protocol': '', + 'redirect_gateway': '', + 'remote_address': '', + 'remote_host': [], + 'remote_port': '', + 'server_2fa_authy_key': '', + 'server_2fa_authy': [], + 'client': [], + 'server_domain': '', + 'server_max_conn': '', + 'server_dns_nameserver': [], + 'server_push_route': [], + 'server_reject_unconfigured': False, + 'server_subnet': '', + 'server_topology': '', + 'shared_secret_file': '', + 'tls': False, + 'tls_ca_cert': '', + 'tls_cert': '', + 'tls_crl': '', + 'tls_dh': '', + 'tls_key': '', + 'tls_role': '', + 'type': 'tun', + 'uid': user, + 'gid': group, +} + +def subprocess_cmd(command): + p = Popen(command, stdout=PIPE, shell=True) + p.communicate() + +def get_config_name(intf): + cfg_file = r'/opt/vyatta/etc/openvpn/openvpn-{}.conf'.format(intf) + return cfg_file + +def openvpn_mkdir(directory): + # create directory on demand + if not os.path.exists(directory): + os.mkdir(directory) + + # fix permissions - corresponds to mode 755 + os.chmod(directory, stat.S_IRWXU|stat.S_IRGRP|stat.S_IXGRP|stat.S_IROTH|stat.S_IXOTH) + uid = pwd.getpwnam(user).pw_uid + gid = grp.getgrnam(group).gr_gid + os.chown(directory, uid, gid) + +def fixup_permission(filename, permission=stat.S_IRUSR): + """ + Check if the given file exists and change ownershit to root/vyattacfg + and appripriate file access permissions - default is user and group readable + """ + if os.path.isfile(filename): + os.chmod(filename, permission) + + # make file owned by root / vyattacfg + uid = pwd.getpwnam('root').pw_uid + gid = grp.getgrnam('vyattacfg').gr_gid + os.chown(filename, uid, gid) + +def checkCertHeader(header, filename): + """ + Verify if filename contains specified header. + Returns True on success or on file not found to not trigger the exceptions + """ + if not os.path.isfile(filename): + return True + + with open(filename, 'r') as f: + for line in f: + if re.match(header, line): + return True + + return False + +def get_config(): + openvpn = copy.deepcopy(default_config_data) + conf = Config() + + # determine tagNode instance + try: + openvpn['intf'] = os.environ['VYOS_TAGNODE_VALUE'] + except KeyError as E: + print("Interface not specified") + + # Check if interface instance has been removed + if not conf.exists('interfaces openvpn ' + openvpn['intf']): + openvpn['deleted'] = True + return openvpn + + # Check if we belong to any bridge interface + for bridge in conf.list_nodes('interfaces bridge'): + for intf in conf.list_nodes('interfaces bridge {} member interface'.format(bridge)): + if intf == openvpn['intf']: + openvpn['bridge_member'].append(intf) + + # set configuration level + conf.set_level('interfaces openvpn ' + openvpn['intf']) + + # retrieve authentication options - username + if conf.exists('authentication username'): + openvpn['auth_user'] = conf.return_value('authentication username') + openvpn['auth'] = True + + # retrieve authentication options - username + if conf.exists('authentication password'): + openvpn['auth_pass'] = conf.return_value('authentication password') + openvpn['auth'] = True + + # retrieve interface description + if conf.exists('description'): + openvpn['description'] = conf.return_value('description') + + # interface device-type + if conf.exists('device-type'): + openvpn['type'] = conf.return_value('device-type') + + # disable interface + if conf.exists('disable'): + openvpn['disable'] = True + + # data encryption algorithm + if conf.exists('encryption'): + openvpn['encryption'] = conf.return_value('encryption') + + # hash algorithm + if conf.exists('hash'): + openvpn['hash'] = conf.return_value('hash') + + # Maximum number of keepalive packet failures + if conf.exists('keep-alive failure-count') and conf.exists('keep-alive interval'): + fail_count = conf.return_value('keep-alive failure-count') + interval = conf.return_value('keep-alive interval') + openvpn['ping_interval' ] = interval + openvpn['ping_restart' ] = int(interval) * int(fail_count) + + # Local IP address of tunnel - even as it is a tag node - we can only work + # on the first address + if conf.exists('local-address'): + openvpn['local_address'] = conf.list_nodes('local-address')[0] + if conf.exists('local-address {} subnet-mask'.format(openvpn['local_address'])): + openvpn['local_address_subnet'] = conf.return_value('local-address {} subnet-mask'.format(openvpn['local_address'])) + + # Local IP address to accept connections + if conf.exists('local-host'): + openvpn['local_host'] = conf.return_value('local-host') + + # Local port number to accept connections + if conf.exists('local-port'): + openvpn['local_port'] = conf.return_value('local-port') + + # OpenVPN operation mode + if conf.exists('mode'): + mode = conf.return_value('mode') + openvpn['mode'] = mode + + # Additional OpenVPN options + if conf.exists('openvpn-option'): + openvpn['options'] = conf.return_values('openvpn-option') + + # Do not close and reopen interface + if conf.exists('persistent-tunnel'): + openvpn['persistent_tunnel'] = True + + # Communication protocol + if conf.exists('protocol'): + openvpn['protocol'] = conf.return_value('protocol') + + # IP address of remote end of tunnel + if conf.exists('remote-address'): + openvpn['remote_address'] = conf.return_value('remote-address') + + # Remote host to connect to (dynamic if not set) + if conf.exists('remote-host'): + openvpn['remote_host'] = conf.return_values('remote-host') + + # Remote port number to connect to + if conf.exists('remote-port'): + openvpn['remote_port'] = conf.return_value('remote-port') + + # OpenVPN tunnel to be used as the default route + # see https://openvpn.net/community-resources/reference-manual-for-openvpn-2-4/ + # redirect-gateway flags + if conf.exists('replace-default-route'): + openvpn['redirect_gateway'] = 'def1' + + if conf.exists('replace-default-route local'): + openvpn['redirect_gateway'] = 'local def1' + + # Two Factor Authentication providers + # currently limited to authy + if conf.exists('2-factor-authentication authy api-key'): + openvpn['server_2fa_authy_key'] = conf.return_value('2-factor-authentication authy api-key') + + # Authy users (must be email address) + for user in conf.list_nodes('server 2-factor-authentication authy user'): + # set configuration level + conf.set_level('interfaces openvpn ' + openvpn['intf'] + ' 2-factor-authentication authy user ' + user) + data = { + 'user': user, + 'country_code': '', + 'mobile_number': '' + } + + # Country calling codes + if conf.exists('country-calling-code'): + data['country_code'] = conf.return_value('country-calling-code') + + # Mobile phone number + if conf.exists('phone-number'): + data['mobile_number'] = conf.return_value('phone-number') + + openvpn['server_2fa_authy'].append(data) + + # Topology for clients + if conf.exists('server topology'): + openvpn['server_topology'] = conf.return_value('server topology') + + # Server-mode subnet (from which client IPs are allocated) + if conf.exists('server subnet'): + network = conf.return_value('server subnet') + tmp = IPv4Interface(network).with_netmask + # convert the network in format: "192.0.2.0 255.255.255.0" for later use in template + openvpn['server_subnet'] = tmp.replace(r'/', ' ') + + # Client-specific settings + for client in conf.list_nodes('server client'): + # set configuration level + conf.set_level('interfaces openvpn ' + openvpn['intf'] + ' server client ' + client) + data = { + 'name': client, + 'disable': False, + 'ip': '', + 'push_route': [], + 'subnet': [], + 'remote_netmask': '' + } + + # note: with "topology subnet", this is "<ip> <netmask>". + # with "topology p2p", this is "<ip> <our_ip>". + if openvpn['server_topology'] == 'subnet': + # we are only interested in the netmask portion of server_subnet + data['remote_netmask'] = openvpn['server_subnet'].split(' ')[1] + else: + # we need the server subnet in format 192.0.2.0/255.255.255.0 + subnet = openvpn['server_subnet'].replace(' ', r'/') + # get iterator over the usable hosts in the network + tmp = ip_network(subnet).hosts() + # OpenVPN always uses the subnets first available IP address + data['remote_netmask'] = list(tmp)[0] + + # Option to disable client connection + if conf.exists('disable'): + data['disable'] = True + + # IP address of the client + if conf.exists('ip'): + data['ip'] = conf.return_value('ip') + + # Route to be pushed to the client + for network in conf.return_values('push-route'): + tmp = IPv4Interface(network).with_netmask + data['push_route'].append(tmp.replace(r'/', ' ')) + + # Subnet belonging to the client + for network in conf.return_values('subnet'): + tmp = IPv4Interface(network).with_netmask + data['subnet'].append(tmp.replace(r'/', ' ')) + + # Append to global client list + openvpn['client'].append(data) + + # re-set configuration level + conf.set_level('interfaces openvpn ' + openvpn['intf']) + + # DNS suffix to be pushed to all clients + if conf.exists('server domain-name'): + openvpn['server_domain'] = conf.return_value('server domain-name') + + # Number of maximum client connections + if conf.exists('server max-connections'): + openvpn['server_max_conn'] = conf.return_value('server max-connections') + + # Domain Name Server (DNS) + if conf.exists('server name-server'): + openvpn['server_dns_nameserver'] = conf.return_values('server name-server') + + # Route to be pushed to all clients + if conf.exists('server push-route'): + network = conf.return_value('server push-route') + tmp = IPv4Interface(network).with_netmask + openvpn['server_push_route'] = tmp.replace(r'/', ' ') + + # Reject connections from clients that are not explicitly configured + if conf.exists('server reject-unconfigured-clients'): + openvpn['server_reject_unconfigured'] = True + + # File containing certificate for Certificate Authority (CA) + if conf.exists('tls ca-cert-file'): + openvpn['tls_ca_cert'] = conf.return_value('tls ca-cert-file') + openvpn['tls'] = True + + # File containing certificate for this host + if conf.exists('tls cert-file'): + openvpn['tls_cert'] = conf.return_value('tls cert-file') + openvpn['tls'] = True + + # File containing certificate revocation list (CRL) for this host + if conf.exists('tls crl-file'): + openvpn['tls_crl'] = conf.return_value('tls crl-file') + openvpn['tls'] = True + + # File containing Diffie Hellman parameters (server only) + if conf.exists('tls dh-file'): + openvpn['tls_dh'] = conf.return_value('tls dh-file') + openvpn['tls'] = True + + # File containing this host's private key + if conf.exists('tls key-file'): + openvpn['tls_key'] = conf.return_value('tls key-file') + openvpn['tls'] = True + + # Role in TLS negotiation + if conf.exists('tls role'): + openvpn['tls_role'] = conf.return_value('tls role') + openvpn['tls'] = True + + if conf.exists('shared-secret-key-file'): + openvpn['shared_secret_file'] = conf.return_value('shared-secret-key-file') + + if conf.exists('use-lzo-compression'): + openvpn['compress_lzo'] = True + + return openvpn + +def verify(openvpn): + if openvpn['deleted']: + return None + + if not openvpn['mode']: + raise ConfigError('Must specify OpenVPN operation mode') + + # Checks which need to be performed on interface rmeoval + if openvpn['deleted']: + # OpenVPN interface can not be deleted if it's still member of a bridge + if openvpn['bridge_member']: + raise ConfigError('Can not delete {} as it is a member interface of bridge {}!'.format(openvpn['intf'], bridge)) + + # + # OpenVPN client mode - VERIFY + # + if openvpn['mode'] == 'client': + if openvpn['local_port']: + raise ConfigError('Cannot specify "local-port" in client mode') + + if openvpn['local_host']: + raise ConfigError('Cannot specify "local-host" in client mode') + + if openvpn['protocol'] == 'tcp-passive': + raise ConfigError('Protocol "tcp-passive" is not valid in client mode') + + if not openvpn['remote_host']: + raise ConfigError('Must specify "remote-host" in client mode') + + if openvpn['tls_dh']: + raise ConfigError('Cannot specify "tls dh-file" in client mode') + + # + # OpenVPN site-to-site - VERIFY + # + if openvpn['mode'] == 'site-to-site': + if not (openvpn['local_address'] or openvpn['bridge_member']): + raise ConfigError('Must specify "local-address" or "bridge member interface"') + + for host in openvpn['remote_host']: + if host == openvpn['remote_address']: + raise ConfigError('"remote-address" cannot be the same as "remote-host"') + + if openvpn['type'] == 'tun': + if not openvpn['remote_address']: + raise ConfigError('Must specify "remote-address"') + + if openvpn['local_address'] == openvpn['remote_address']: + raise ConfigError('"local-address" and "remote-address" cannot be the same') + + if openvpn['local_address'] == openvpn['local_host']: + raise ConfigError('"local-address" cannot be the same as "local-host"') + + else: + if openvpn['local_address'] or openvpn['remote_address']: + raise ConfigError('Cannot specify "local-address" or "remote-address" in client-server mode') + + elif openvpn['bridge_member']: + raise ConfigError('Cannot specify "local-address" or "remote-address" in bridge mode') + + # + # OpenVPN server mode - VERIFY + # + if openvpn['mode'] == 'server': + if openvpn['protocol'] == 'tcp-active': + raise ConfigError('Protocol "tcp-active" is not valid in server mode') + + if openvpn['remote_port']: + raise ConfigError('Cannot specify "remote-port" in server mode') + + if openvpn['remote_host']: + raise ConfigError('Cannot specify "remote-host" in server mode') + + if openvpn['protocol'] == 'tcp-passive' and len(openvpn['remote_host']) > 1: + raise ConfigError('Cannot specify more than 1 "remote-host" with "tcp-passive"') + + if not openvpn['tls_dh']: + raise ConfigError('Must specify "tls dh-file" in server mode') + + if not openvpn['server_subnet']: + if not openvpn['bridge_member']: + raise ConfigError('Must specify "server subnet" option in server mode') + + else: + # checks for both client and site-to-site go here + if openvpn['server_reject_unconfigured']: + raise ConfigError('reject-unconfigured-clients is only supported in OpenVPN server mode') + + if openvpn['server_topology']: + raise ConfigError('The "topology" option is only valid in server mode') + + if (not openvpn['remote_host']) and openvpn['redirect_gateway']: + raise ConfigError('Cannot set "replace-default-route" without "remote-host"') + + # + # OpenVPN common verification section + # not depending on any operation mode + # + + # verify specified IP address is present on any interface on this system + if openvpn['local_host']: + if not is_addr_assigned(openvpn['local_host']): + raise ConfigError('No interface on system with specified local-host IP address: {}'.format(openvpn['local_host'])) + + # TCP active + if openvpn['protocol'] == 'tcp-active': + if openvpn['local_port']: + raise ConfigError('Cannot specify "local-port" with "tcp-active"') + + if not openvpn['remote_host']: + raise ConfigError('Must specify "remote-host" with "tcp-active"') + + # shared secret and TLS + if not (openvpn['shared_secret_file'] or openvpn['tls']): + raise ConfigError('Must specify one of "shared-secret-key-file" and "tls"') + + if openvpn['shared_secret_file'] and openvpn['tls']: + raise ConfigError('Can only specify one of "shared-secret-key-file" and "tls"') + + if openvpn['mode'] in ['client', 'server']: + if not openvpn['tls']: + raise ConfigError('Must specify "tls" in client-server mode') + + # + # TLS/encryption + # + if not checkCertHeader('-----BEGIN OpenVPN Static key V1-----', openvpn['shared_secret_file']): + raise ConfigError('Specified shared-secret-key-file "{}" is not valid'.format(openvpn['shared_secret_file'])) + + if openvpn['tls']: + if not openvpn['tls_ca_cert']: + raise ConfigError('Must specify "tls ca-cert-file"') + + if not (openvpn['mode'] == 'client' and openvpn['auth']): + if not openvpn['tls_cert']: + raise ConfigError('Must specify "tls cert-file"') + + if not openvpn['tls_key']: + raise ConfigError('Must specify "tls key-file"') + + if not checkCertHeader('-----BEGIN CERTIFICATE-----', openvpn['tls_ca_cert']): + raise ConfigError('Specified ca-cert-file "{}" is invalid'.format(openvpn['tls_ca_cert'])) + + if not checkCertHeader('-----BEGIN CERTIFICATE-----', openvpn['tls_cert']): + raise ConfigError('Specified cert-file "{}" is invalid'.format(openvpn['tls_cert'])) + + if not checkCertHeader('-----BEGIN (?:RSA )?PRIVATE KEY-----', openvpn['tls_key']): + raise ConfigError('Specified key-file "{}" is not valid'.format(openvpn['tls_key'])) + + if not checkCertHeader('-----BEGIN X509 CRL-----', openvpn['tls_crl']): + raise ConfigError('Specified crl-file "{} not valid'.format(openvpn['tls_crl'])) + + if not checkCertHeader('-----BEGIN DH PARAMETERS-----', openvpn['tls_dh']): + raise ConfigError('Specified dh-file "{}" is not valid'.format(openvpn['tls_dh'])) + + if openvpn['tls_role']: + if openvpn['mode'] in ['client', 'server']: + raise ConfigError('Cannot specify "tls role" in client-server mode') + + if openvpn['tls_role'] == 'active': + if openvpn['protocol'] == 'tcp-passive': + raise ConfigError('Cannot specify "tcp-passive" when "tls role" is "active"') + + if openvpn['tls_dh']: + raise ConfigError('Cannot specify "tls dh-file" when "tls role" is "active"') + + elif openvpn['tls_role'] == 'passive': + if openvpn['protocol'] == 'tcp-active': + raise ConfigError('Cannot specify "tcp-active" when "tls role" is "passive"') + + if not openvpn['tls_dh']: + raise ConfigError('Must specify "tls dh-file" when "tls role" is "passive"') + + # + # Auth user/pass + # + if openvpn['auth']: + if not openvpn['auth_user']: + raise ConfigError('Username for authentication is missing') + + if not openvpn['auth_pass']: + raise ConfigError('Password for authentication is missing') + + # + # Client + # + subnet = openvpn['server_subnet'].replace(' ', '/') + for client in openvpn['client']: + if not ip_address(client['ip']) in ip_network(subnet): + raise ConfigError('Client IP "{}" not in server subnet "{}'.format(client['ip'], subnet)) + + + + return None + +def generate(openvpn): + if openvpn['deleted'] or openvpn['disable']: + return None + + interface = openvpn['intf'] + directory = os.path.dirname(get_config_name(interface)) + + # create config directory on demand + openvpn_mkdir(directory) + # create status directory on demand + openvpn_mkdir(directory + '/status') + # create client config dir on demand + openvpn_mkdir(directory + '/ccd') + # crete client config dir per interface on demand + openvpn_mkdir(directory + '/ccd/' + interface) + + # Fix file permissons for keys + fixup_permission(openvpn['shared_secret_file']) + fixup_permission(openvpn['tls_key']) + + # Generate User/Password authentication file + if openvpn['auth']: + auth_file = '/tmp/openvpn-{}-pw'.format(interface) + with open(auth_file, 'w') as f: + f.write('{}\n{}'.format(openvpn['auth_user'], openvpn['auth_pass'])) + + fixup_permission(auth_file) + + # get numeric uid/gid + uid = pwd.getpwnam(user).pw_uid + gid = grp.getgrnam(group).gr_gid + + # Generate client specific configuration + for client in openvpn['client']: + client_file = directory + '/ccd/' + interface + '/' + client['name'] + tmpl = jinja2.Template(client_tmpl) + client_text = tmpl.render(client) + with open(client_file, 'w') as f: + f.write(client_text) + os.chown(client_file, uid, gid) + + tmpl = jinja2.Template(config_tmpl) + config_text = tmpl.render(openvpn) + with open(get_config_name(interface), 'w') as f: + f.write(config_text) + os.chown(get_config_name(interface), uid, gid) + + return None + +def apply(openvpn): + interface = openvpn['intf'] + + pid = 0 + pidfile = '/var/run/openvpn/{}.pid'.format(interface) + if os.path.isfile(pidfile): + pid = 0 + with open(pidfile, 'r') as f: + pid = int(f.read()) + + # If tunnel interface has been deleted - stop service + if openvpn['deleted'] or openvpn['disable']: + directory = os.path.dirname(get_config_name(interface)) + + # we only need to stop the demon if it's running + # daemon could have died or killed by someone + if psutil.pid_exists(pid): + cmd = 'start-stop-daemon --stop --quiet' + cmd += ' --pidfile ' + pidfile + subprocess_cmd(cmd) + + # cleanup old PID file + if os.path.isfile(pidfile): + os.remove(pidfile) + + # cleanup old configuration file + if os.path.isfile(get_config_name(interface)): + os.remove(get_config_name(interface)) + + # cleanup client config dir + if os.path.isdir(directory + '/ccd/' + interface): + try: + os.remove(directory + '/ccd/' + interface + '/*') + except: + pass + + return None + + # Send SIGUSR1 to the process instead of creating a new process + if psutil.pid_exists(pid): + os.kill(pid, SIGUSR1) + return None + + # No matching OpenVPN process running - maybe it got killed or none + # existed - nevertheless, spawn new OpenVPN process + cmd = 'start-stop-daemon --start --quiet' + cmd += ' --pidfile ' + pidfile + cmd += ' --exec /usr/sbin/openvpn' + # now pass arguments to openvpn binary + cmd += ' --' + cmd += ' --config ' + get_config_name(interface) + + # execute assembled command + subprocess_cmd(cmd) + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + sys.exit(1) diff --git a/src/conf_mode/protocols_bfd.py b/src/conf_mode/protocols_bfd.py index 98f38035a..9ca194edd 100755 --- a/src/conf_mode/protocols_bfd.py +++ b/src/conf_mode/protocols_bfd.py @@ -176,6 +176,10 @@ def verify(bfd): if peer['multihop'] and peer['echo_mode']: raise ConfigError('Multihop and echo-mode cannot be used together') + # multihop doesn't accept interface names + if peer['multihop'] and peer['src_if']: + raise ConfigError('Multihop and source interface cannot be used together') + # echo interval can be configured only with enabled echo-mode if peer['echo_interval'] != '' and not peer['echo_mode']: raise ConfigError('echo-interval can be configured only with enabled echo-mode') diff --git a/src/conf_mode/vyos_cert.py b/src/conf_mode/vyos_cert.py new file mode 100755 index 000000000..4a44573ca --- /dev/null +++ b/src/conf_mode/vyos_cert.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019 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 sys +import os +import subprocess +import tempfile +import pathlib +import ssl + +import vyos.defaults +from vyos.config import Config +from vyos import ConfigError + +vyos_conf_scripts_dir = vyos.defaults.directories['conf_mode'] + +# XXX: this model will need to be extended for tag nodes +dependencies = [ + 'https.py', +] + +def status_self_signed(cert_data): +# check existence and expiration date + path = pathlib.Path(cert_data['conf']) + if not path.is_file(): + return False + path = pathlib.Path(cert_data['crt']) + if not path.is_file(): + return False + path = pathlib.Path(cert_data['key']) + if not path.is_file(): + return False + + # check if certificate is 1/2 past lifetime, with openssl -checkend + end_days = int(cert_data['lifetime']) + end_seconds = int(0.5*60*60*24*end_days) + checkend_cmd = ('openssl x509 -checkend {end} -noout -in {crt}' + ''.format(end=end_seconds, **cert_data)) + try: + subprocess.check_call(checkend_cmd, shell=True) + return True + except subprocess.CalledProcessError as err: + if err.returncode == 1: + return False + else: + print("Called process error: {}.".format(err)) + +def generate_self_signed(cert_data): + san_config = None + + if ssl.OPENSSL_VERSION_INFO < (1, 1, 1, 0, 0): + san_config = tempfile.NamedTemporaryFile() + with open(san_config.name, 'w') as fd: + fd.write('[req]\n') + fd.write('distinguished_name=req\n') + fd.write('[san]\n') + fd.write('subjectAltName=DNS:vyos\n') + + openssl_req_cmd = ('openssl req -x509 -nodes -days {lifetime} ' + '-newkey rsa:4096 -keyout {key} -out {crt} ' + '-subj "/O=Sentrium/OU=VyOS/CN=vyos" ' + '-extensions san -config {san_conf}' + ''.format(san_conf=san_config.name, + **cert_data)) + + else: + openssl_req_cmd = ('openssl req -x509 -nodes -days {lifetime} ' + '-newkey rsa:4096 -keyout {key} -out {crt} ' + '-subj "/O=Sentrium/OU=VyOS/CN=vyos" ' + '-addext "subjectAltName=DNS:vyos"' + ''.format(**cert_data)) + + try: + subprocess.check_call(openssl_req_cmd, shell=True) + except subprocess.CalledProcessError as err: + print("Called process error: {}.".format(err)) + + os.chmod('{key}'.format(**cert_data), 0o400) + + with open('{conf}'.format(**cert_data), 'w') as f: + f.write('ssl_certificate {crt};\n'.format(**cert_data)) + f.write('ssl_certificate_key {key};\n'.format(**cert_data)) + + if san_config: + san_config.close() + +def get_config(): + vyos_cert = vyos.defaults.vyos_cert_data + + conf = Config() + if not conf.exists('service https certificates system-generated-certificate'): + return None + else: + conf.set_level('service https certificates system-generated-certificate') + + if conf.exists('lifetime'): + lifetime = conf.return_value('lifetime') + vyos_cert['lifetime'] = lifetime + + return vyos_cert + +def verify(vyos_cert): + return None + +def generate(vyos_cert): + if vyos_cert is None: + return None + + if not status_self_signed(vyos_cert): + generate_self_signed(vyos_cert) + +def apply(vyos_cert): + for dep in dependencies: + cmd = '{0}/{1}'.format(vyos_conf_scripts_dir, dep) + try: + subprocess.check_call(cmd, shell=True) + except subprocess.CalledProcessError as err: + raise ConfigError("{}.".format(err)) + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + sys.exit(1) diff --git a/src/helpers/vyos-sudo.py b/src/helpers/vyos-sudo.py new file mode 100755 index 000000000..0101a0c95 --- /dev/null +++ b/src/helpers/vyos-sudo.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 + +# Copyright 2019 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 getpass +import grp +import os +import sys + + +def is_admin() -> bool: + """Look if current user is in sudo group""" + current_user = getpass.getuser() + (_, _, _, admin_group_members) = grp.getgrnam('sudo') + return current_user in admin_group_members + + +if __name__ == '__main__': + if len(sys.argv) < 2: + print('Missing command argument') + sys.exit(1) + + if not is_admin(): + print('This account is not authorized to run this command') + sys.exit(1) + + os.execvp('sudo', ['sudo'] + sys.argv[1:]) diff --git a/src/op_mode/show_ram.sh b/src/op_mode/show_ram.sh new file mode 100755 index 000000000..b013e16f8 --- /dev/null +++ b/src/op_mode/show_ram.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# +# Module: vyos-show-ram.sh +# Displays memory usage information in minimalistic format +# +# Copyright (C) 2019 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 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/>. + +MB_DIVISOR=1024 + +TOTAL=$(cat /proc/meminfo | grep -E "^MemTotal:" | awk -F ' ' '{print $2}') +FREE=$(cat /proc/meminfo | grep -E "^MemFree:" | awk -F ' ' '{print $2}') +BUFFERS=$(cat /proc/meminfo | grep -E "^Buffers:" | awk -F ' ' '{print $2}') +CACHED=$(cat /proc/meminfo | grep -E "^Cached:" | awk -F ' ' '{print $2}') + +DISPLAY_FREE=$(( ($FREE + $BUFFERS + $CACHED) / $MB_DIVISOR )) +DISPLAY_TOTAL=$(( $TOTAL / $MB_DIVISOR )) +DISPLAY_USED=$(( $DISPLAY_TOTAL - $DISPLAY_FREE )) + +echo "Total: $DISPLAY_TOTAL" +echo "Free: $DISPLAY_FREE" +echo "Used: $DISPLAY_USED" diff --git a/src/op_mode/show_users.py b/src/op_mode/show_users.py new file mode 100755 index 000000000..8e4f12851 --- /dev/null +++ b/src/op_mode/show_users.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019 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 argparse +import pwd +import spwd +import struct +import sys +from time import ctime + +from tabulate import tabulate +from vyos.config import Config + + +class UserInfo: + def __init__(self, uid, name, user_type, is_locked, login_time, tty, host): + self.uid = uid + self.name = name + self.user_type = user_type + self.is_locked = is_locked + self.login_time = login_time + self.tty = tty + self.host = host + + +filters = { + 'default': lambda user: not user.is_locked, # Default is everything but locked accounts + 'vyos': lambda user: user.user_type == 'vyos', + 'other': lambda user: user.user_type != 'vyos', + 'locked': lambda user: user.is_locked, + 'all': lambda user: True +} + + +def is_locked(user_name: str) -> bool: + """Check if a given user has password in shadow db""" + + try: + encrypted_password = spwd.getspnam(user_name)[1] + return encrypted_password == '*' or encrypted_password.startswith('!') + except (KeyError, PermissionError): + print('Cannot access shadow database, ensure this script is run with sufficient permissions') + sys.exit(1) + + +def decode_lastlog(lastlog_file, uid: int): + """Decode last login info of a given user uid from the lastlog file""" + + struct_fmt = '=L32s256s' + recordsize = struct.calcsize(struct_fmt) + lastlog_file.seek(recordsize * uid) + buf = lastlog_file.read(recordsize) + if len(buf) < recordsize: + return None + (time, tty, host) = struct.unpack(struct_fmt, buf) + time = 'never logged in' if time == 0 else ctime(time) + tty = tty.strip(b'\x00') + host = host.strip(b'\x00') + return time, tty, host + + +def list_users(): + cfg = Config() + vyos_users = cfg.list_effective_nodes('system login user') + users = [] + with open('/var/log/lastlog', 'rb') as lastlog_file: + for (name, _, uid, _, _, _, _) in pwd.getpwall(): + lastlog_info = decode_lastlog(lastlog_file, uid) + if lastlog_info is None: + continue + user_info = UserInfo( + uid, name, + user_type='vyos' if name in vyos_users else 'other', + is_locked=is_locked(name), + login_time=lastlog_info[0], + tty=lastlog_info[1], + host=lastlog_info[2]) + users.append(user_info) + return users + + +def main(): + parser = argparse.ArgumentParser(prog=sys.argv[0], add_help=False) + parser.add_argument('type', nargs='?', choices=['all', 'vyos', 'other', 'locked']) + args = parser.parse_args() + + filter_type = args.type if args.type is not None else 'default' + filter_expr = filters[filter_type] + + headers = ['Username', 'Type', 'Locked', 'Tty', 'From', 'Last login'] + table_data = [] + for user in list_users(): + if filter_expr(user): + table_data.append([user.name, user.user_type, user.is_locked, user.tty, user.host, user.login_time]) + print(tabulate(table_data, headers, tablefmt='simple')) + + +if __name__ == '__main__': + main() |