diff options
Diffstat (limited to 'src/conf_mode')
72 files changed, 14605 insertions, 0 deletions
diff --git a/src/conf_mode/arp.py b/src/conf_mode/arp.py new file mode 100755 index 000000000..aac07bd80 --- /dev/null +++ b/src/conf_mode/arp.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018 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 re +import syslog as sl + +from vyos.config import Config +from vyos.util import call +from vyos import ConfigError + +from vyos import airbag +airbag.enable() + +arp_cmd = '/usr/sbin/arp' + +def get_config(): + c = Config() + if not c.exists('protocols static arp'): + return None + + c.set_level('protocols static') + config_data = {} + + for ip_addr in c.list_nodes('arp'): + config_data.update( + { + ip_addr : c.return_value('arp ' + ip_addr + ' hwaddr') + } + ) + + return config_data + +def generate(c): + c_eff = Config() + c_eff.set_level('protocols static') + c_eff_cnf = {} + for ip_addr in c_eff.list_effective_nodes('arp'): + c_eff_cnf.update( + { + ip_addr : c_eff.return_effective_value('arp ' + ip_addr + ' hwaddr') + } + ) + + config_data = { + 'remove' : [], + 'update' : {} + } + ### removal + if c == None: + for ip_addr in c_eff_cnf: + config_data['remove'].append(ip_addr) + else: + for ip_addr in c_eff_cnf: + if not ip_addr in c or c[ip_addr] == None: + config_data['remove'].append(ip_addr) + + ### add/update + if c != None: + for ip_addr in c: + if not ip_addr in c_eff_cnf: + config_data['update'][ip_addr] = c[ip_addr] + if ip_addr in c_eff_cnf: + if c[ip_addr] != c_eff_cnf[ip_addr] and c[ip_addr] != None: + config_data['update'][ip_addr] = c[ip_addr] + + return config_data + +def apply(c): + for ip_addr in c['remove']: + sl.syslog(sl.LOG_NOTICE, "arp -d " + ip_addr) + call(f'{arp_cmd} -d {ip_addr} >/dev/null 2>&1') + + for ip_addr in c['update']: + sl.syslog(sl.LOG_NOTICE, "arp -s " + ip_addr + " " + c['update'][ip_addr]) + updated = c['update'][ip_addr] + call(f'{arp_cmd} -s {ip_addr} {updated}') + + +if __name__ == '__main__': + try: + c = get_config() + ## syntax verification is done via cli + config = generate(c) + apply(config) + except ConfigError as e: + print(e) + sys.exit(1) diff --git a/src/conf_mode/bcast_relay.py b/src/conf_mode/bcast_relay.py new file mode 100755 index 000000000..a3e141a00 --- /dev/null +++ b/src/conf_mode/bcast_relay.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2017-2020 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 + +from glob import glob +from netifaces import interfaces +from sys import exit + +from vyos.config import Config +from vyos.util import call +from vyos.template import render +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +config_file_base = r'/etc/default/udp-broadcast-relay' + +def get_config(): + conf = Config() + base = ['service', 'broadcast-relay'] + + relay = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + return relay + +def verify(relay): + if not relay or 'disabled' in relay: + return None + + for instance, config in relay.get('id', {}).items(): + # we don't have to check this instance when it's disabled + if 'disabled' in config: + continue + + # we certainly require a UDP port to listen to + if 'port' not in config: + raise ConfigError(f'Port number mandatory for udp broadcast relay "{instance}"') + + # if only oone interface is given it's a string -> move to list + if isinstance(config.get('interface', []), str): + config['interface'] = [ config['interface'] ] + # Relaying data without two interface is kinda senseless ... + if len(config.get('interface', [])) < 2: + raise ConfigError('At least two interfaces are required for udp broadcast relay "{instance}"') + + for interface in config.get('interface', []): + if interface not in interfaces(): + raise ConfigError('Interface "{interface}" does not exist!') + + return None + +def generate(relay): + if not relay or 'disabled' in relay: + return None + + for config in glob(config_file_base + '*'): + os.remove(config) + + for instance, config in relay.get('id').items(): + # we don't have to check this instance when it's disabled + if 'disabled' in config: + continue + + config['instance'] = instance + render(config_file_base + instance, 'bcast-relay/udp-broadcast-relay.tmpl', config) + + return None + +def apply(relay): + # first stop all running services + call('systemctl stop udp-broadcast-relay@*.service') + + if not relay or 'disable' in relay: + return None + + # start only required service instances + for instance, config in relay.get('id').items(): + # we don't have to check this instance when it's disabled + if 'disabled' in config: + continue + + call(f'systemctl start udp-broadcast-relay@{instance}.service') + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/dhcp_relay.py b/src/conf_mode/dhcp_relay.py new file mode 100755 index 000000000..f093a005e --- /dev/null +++ b/src/conf_mode/dhcp_relay.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2020 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 + +from sys import exit + +from vyos.config import Config +from vyos.template import render +from vyos.util import call +from vyos import ConfigError + +from vyos import airbag +airbag.enable() + +config_file = r'/run/dhcp-relay/dhcp.conf' + +default_config_data = { + 'interface': [], + 'server': [], + 'options': [], + 'hop_count': '10', + 'relay_agent_packets': 'forward' +} + +def get_config(): + relay = default_config_data + conf = Config() + if not conf.exists(['service', 'dhcp-relay']): + return None + else: + conf.set_level(['service', 'dhcp-relay']) + + # Network interfaces to listen on + if conf.exists(['interface']): + relay['interface'] = conf.return_values(['interface']) + + # Servers equal to the address of the DHCP server(s) + if conf.exists(['server']): + relay['server'] = conf.return_values(['server']) + + conf.set_level(['service', 'dhcp-relay', 'relay-options']) + + if conf.exists(['hop-count']): + count = '-c ' + conf.return_value(['hop-count']) + relay['options'].append(count) + + # Specify the maximum packet size to send to a DHCPv4/BOOTP server. + # This might be done to allow sufficient space for addition of relay agent + # options while still fitting into the Ethernet MTU size. + # + # Available in DHCPv4 mode only: + if conf.exists(['max-size']): + size = '-A ' + conf.return_value(['max-size']) + relay['options'].append(size) + + # Control the handling of incoming DHCPv4 packets which already contain + # relay agent options. If such a packet does not have giaddr set in its + # header, the DHCP standard requires that the packet be discarded. However, + # if giaddr is set, the relay agent may handle the situation in four ways: + # It may append its own set of relay options to the packet, leaving the + # supplied option field intact; it may replace the existing agent option + # field; it may forward the packet unchanged; or, it may discard it. + # + # Available in DHCPv4 mode only: + if conf.exists(['relay-agents-packets']): + pkt = '-a -m ' + conf.return_value(['relay-agents-packets']) + relay['options'].append(pkt) + + return relay + +def verify(relay): + # bail out early - looks like removal from running config + if relay is None: + return None + + if 'lo' in relay['interface']: + raise ConfigError('DHCP relay does not support the loopback interface.') + + if len(relay['server']) == 0: + raise ConfigError('No DHCP relay server(s) configured.\n' \ + 'At least one DHCP relay server required.') + + return None + +def generate(relay): + # bail out early - looks like removal from running config + if not relay: + return None + + render(config_file, 'dhcp-relay/config.tmpl', relay) + return None + +def apply(relay): + if relay: + call('systemctl restart isc-dhcp-relay.service') + else: + # DHCP relay support is removed in the commit + call('systemctl stop isc-dhcp-relay.service') + if os.path.exists(config_file): + os.unlink(config_file) + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/dhcp_server.py b/src/conf_mode/dhcp_server.py new file mode 100755 index 000000000..0eaa14c5b --- /dev/null +++ b/src/conf_mode/dhcp_server.py @@ -0,0 +1,625 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2020 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 + +from ipaddress import ip_address, ip_network +from socket import inet_ntoa +from struct import pack +from sys import exit + +from vyos.config import Config +from vyos.validate import is_subnet_connected +from vyos import ConfigError +from vyos.template import render +from vyos.util import call, chown + +from vyos import airbag +airbag.enable() + +config_file = r'/run/dhcp-server/dhcpd.conf' + +default_config_data = { + 'disabled': False, + 'ddns_enable': False, + 'global_parameters': [], + 'hostfile_update': False, + 'host_decl_name': False, + 'static_route': False, + 'wpad': False, + 'shared_network': [], +} + +def dhcp_slice_range(exclude_list, range_list): + """ + This function is intended to slice a DHCP range. What does it mean? + + Lets assume we have a DHCP range from '192.0.2.1' to '192.0.2.100' + but want to exclude address '192.0.2.74' and '192.0.2.75'. We will + pass an input 'range_list' in the format: + [{'start' : '192.0.2.1', 'stop' : '192.0.2.100' }] + and we will receive an output list of: + [{'start' : '192.0.2.1' , 'stop' : '192.0.2.73' }, + {'start' : '192.0.2.76', 'stop' : '192.0.2.100' }] + The resulting list can then be used in turn to build the proper dhcpd + configuration file. + """ + output = [] + # exclude list must be sorted for this to work + exclude_list = sorted(exclude_list) + for ra in range_list: + range_start = ra['start'] + range_stop = ra['stop'] + range_last_exclude = '' + + for e in exclude_list: + if (ip_address(e) >= ip_address(range_start)) and \ + (ip_address(e) <= ip_address(range_stop)): + range_last_exclude = e + + for e in exclude_list: + if (ip_address(e) >= ip_address(range_start)) and \ + (ip_address(e) <= ip_address(range_stop)): + + # Build new IP address range ending one IP address before exclude address + r = { + 'start' : range_start, + 'stop' : str(ip_address(e) -1) + } + # On the next run our IP address range will start one address after the exclude address + range_start = str(ip_address(e) + 1) + + # on subsequent exclude addresses we can not + # append them to our output + if not (ip_address(r['start']) > ip_address(r['stop'])): + # Everything is fine, add range to result + output.append(r) + + # Take care of last IP address range spanning from the last exclude + # address (+1) to the end of the initial configured range + if ip_address(e) == ip_address(range_last_exclude): + r = { + 'start': str(ip_address(e) + 1), + 'stop': str(range_stop) + } + if not (ip_address(r['start']) > ip_address(r['stop'])): + output.append(r) + else: + # if we have no exclude in the whole range - we just take the range + # as it is + if not range_last_exclude: + if ra not in output: + output.append(ra) + + return output + +def dhcp_static_route(static_subnet, static_router): + # https://ercpe.de/blog/pushing-static-routes-with-isc-dhcp-server + # Option format is: + # <netmask>, <network-byte1>, <network-byte2>, <network-byte3>, <router-byte1>, <router-byte2>, <router-byte3> + # where bytes with the value 0 are omitted. + net = ip_network(static_subnet) + # add netmask + string = str(net.prefixlen) + ',' + # add network bytes + if net.prefixlen: + width = net.prefixlen // 8 + if net.prefixlen % 8: + width += 1 + string += ','.join(map(str,tuple(net.network_address.packed)[:width])) + ',' + + # add router bytes + string += ','.join(static_router.split('.')) + + return string + +def get_config(): + dhcp = default_config_data + conf = Config() + if not conf.exists('service dhcp-server'): + return None + else: + conf.set_level('service dhcp-server') + + # check for global disable of DHCP service + if conf.exists('disable'): + dhcp['disabled'] = True + + # check for global dynamic DNS upste + if conf.exists('dynamic-dns-update'): + dhcp['ddns_enable'] = True + + # HACKS AND TRICKS + # + # check for global 'raw' ISC DHCP parameters configured by users + # actually this is a bad idea in general to pass raw parameters from any user + if conf.exists('global-parameters'): + dhcp['global_parameters'] = conf.return_values('global-parameters') + + # check for global DHCP server updating /etc/host per lease + if conf.exists('hostfile-update'): + dhcp['hostfile_update'] = True + + # If enabled every host declaration within that scope, the name provided + # for the host declaration will be supplied to the client as its hostname. + if conf.exists('host-decl-name'): + dhcp['host_decl_name'] = True + + # check for multiple, shared networks served with DHCP addresses + if conf.exists('shared-network-name'): + for network in conf.list_nodes('shared-network-name'): + conf.set_level('service dhcp-server shared-network-name {0}'.format(network)) + config = { + 'name': network, + 'authoritative': False, + 'description': '', + 'disabled': False, + 'network_parameters': [], + 'subnet': [] + } + # check if DHCP server should be authoritative on this network + if conf.exists('authoritative'): + config['authoritative'] = True + + # A description for this given network + if conf.exists('description'): + config['description'] = conf.return_value('description') + + # If disabled, the shared-network configuration becomes inactive in + # the running DHCP server instance + if conf.exists('disable'): + config['disabled'] = True + + # HACKS AND TRICKS + # + # check for 'raw' ISC DHCP parameters configured by users + # actually this is a bad idea in general to pass raw parameters + # from any user + # + # deprecate this and issue a warning like we do for DNS forwarding? + if conf.exists('shared-network-parameters'): + config['network_parameters'] = conf.return_values('shared-network-parameters') + + # check for multiple subnet configurations in a shared network + # config segment + if conf.exists('subnet'): + for net in conf.list_nodes('subnet'): + conf.set_level('service dhcp-server shared-network-name {0} subnet {1}'.format(network, net)) + subnet = { + 'network': net, + 'address': str(ip_network(net).network_address), + 'netmask': str(ip_network(net).netmask), + 'bootfile_name': '', + 'bootfile_server': '', + 'client_prefix_length': '', + 'default_router': '', + 'rfc3442_default_router': '', + 'dns_server': [], + 'domain_name': '', + 'domain_search': [], + 'exclude': [], + 'failover_local_addr': '', + 'failover_name': '', + 'failover_peer_addr': '', + 'failover_status': '', + 'ip_forwarding': False, + 'lease': '86400', + 'ntp_server': [], + 'pop_server': [], + 'server_identifier': '', + 'smtp_server': [], + 'range': [], + 'static_mapping': [], + 'static_subnet': '', + 'static_router': '', + 'static_route': '', + 'subnet_parameters': [], + 'tftp_server': '', + 'time_offset': '', + 'time_server': [], + 'wins_server': [], + 'wpad_url': '' + } + + # Used to identify a bootstrap file + if conf.exists('bootfile-name'): + subnet['bootfile_name'] = conf.return_value('bootfile-name') + + # Specify host address of the server from which the initial boot file + # (specified above) is to be loaded. Should be a numeric IP address or + # domain name. + if conf.exists('bootfile-server'): + subnet['bootfile_server'] = conf.return_value('bootfile-server') + + # The subnet mask option specifies the client's subnet mask as per RFC 950. If no subnet + # mask option is provided anywhere in scope, as a last resort dhcpd will use the subnet + # mask from the subnet declaration for the network on which an address is being assigned. + if conf.exists('client-prefix-length'): + # snippet borrowed from https://stackoverflow.com/questions/33750233/convert-cidr-to-subnet-mask-in-python + host_bits = 32 - int(conf.return_value('client-prefix-length')) + subnet['client_prefix_length'] = inet_ntoa(pack('!I', (1 << 32) - (1 << host_bits))) + + # Default router IP address on the client's subnet + if conf.exists('default-router'): + subnet['default_router'] = conf.return_value('default-router') + subnet['rfc3442_default_router'] = dhcp_static_route("0.0.0.0/0", subnet['default_router']) + + # Specifies a list of Domain Name System (STD 13, RFC 1035) name servers available to + # the client. Servers should be listed in order of preference. + if conf.exists('dns-server'): + subnet['dns_server'] = conf.return_values('dns-server') + + # Option specifies the domain name that client should use when resolving hostnames + # via the Domain Name System. + if conf.exists('domain-name'): + subnet['domain_name'] = conf.return_value('domain-name') + + # The domain-search option specifies a 'search list' of Domain Names to be used + # by the client to locate not-fully-qualified domain names. + if conf.exists('domain-search'): + for domain in conf.return_values('domain-search'): + subnet['domain_search'].append('"' + domain + '"') + + # IP address (local) for failover peer to connect + if conf.exists('failover local-address'): + subnet['failover_local_addr'] = conf.return_value('failover local-address') + + # DHCP failover peer name + if conf.exists('failover name'): + subnet['failover_name'] = conf.return_value('failover name') + + # IP address (remote) of failover peer + if conf.exists('failover peer-address'): + subnet['failover_peer_addr'] = conf.return_value('failover peer-address') + + # DHCP failover peer status (primary|secondary) + if conf.exists('failover status'): + subnet['failover_status'] = conf.return_value('failover status') + + # Option specifies whether the client should configure its IP layer for packet + # forwarding + if conf.exists('ip-forwarding'): + subnet['ip_forwarding'] = True + + # Time should be the length in seconds that will be assigned to a lease if the + # client requesting the lease does not ask for a specific expiration time + if conf.exists('lease'): + subnet['lease'] = conf.return_value('lease') + + # Specifies a list of IP addresses indicating NTP (RFC 5905) servers available + # to the client. + if conf.exists('ntp-server'): + subnet['ntp_server'] = conf.return_values('ntp-server') + + # POP3 server option specifies a list of POP3 servers available to the client. + # Servers should be listed in order of preference. + if conf.exists('pop-server'): + subnet['pop_server'] = conf.return_values('pop-server') + + # DHCP servers include this option in the DHCPOFFER in order to allow the client + # to distinguish between lease offers. DHCP clients use the contents of the + # 'server identifier' field as the destination address for any DHCP messages + # unicast to the DHCP server + if conf.exists('server-identifier'): + subnet['server_identifier'] = conf.return_value('server-identifier') + + # SMTP server option specifies a list of SMTP servers available to the client. + # Servers should be listed in order of preference. + if conf.exists('smtp-server'): + subnet['smtp_server'] = conf.return_values('smtp-server') + + # For any subnet on which addresses will be assigned dynamically, there must be at + # least one range statement. The range statement gives the lowest and highest IP + # addresses in a range. All IP addresses in the range should be in the subnet in + # which the range statement is declared. + if conf.exists('range'): + for range in conf.list_nodes('range'): + range = { + 'start': conf.return_value('range {0} start'.format(range)), + 'stop': conf.return_value('range {0} stop'.format(range)) + } + subnet['range'].append(range) + + # IP address that needs to be excluded from DHCP lease range + if conf.exists('exclude'): + subnet['exclude'] = conf.return_values('exclude') + subnet['range'] = dhcp_slice_range(subnet['exclude'], subnet['range']) + + # Static DHCP leases + if conf.exists('static-mapping'): + addresses_for_exclude = [] + for mapping in conf.list_nodes('static-mapping'): + conf.set_level('service dhcp-server shared-network-name {0} subnet {1} static-mapping {2}'.format(network, net, mapping)) + mapping = { + 'name': mapping, + 'disabled': False, + 'ip_address': '', + 'mac_address': '', + 'static_parameters': [] + } + + # This static lease is disabled + if conf.exists('disable'): + mapping['disabled'] = True + + # IP address used for this DHCP client + if conf.exists('ip-address'): + mapping['ip_address'] = conf.return_value('ip-address') + addresses_for_exclude.append(mapping['ip_address']) + + # MAC address of requesting DHCP client + if conf.exists('mac-address'): + mapping['mac_address'] = conf.return_value('mac-address') + + # HACKS AND TRICKS + # + # check for 'raw' ISC DHCP parameters configured by users + # actually this is a bad idea in general to pass raw parameters + # from any user + # + # deprecate this and issue a warning like we do for DNS forwarding? + if conf.exists('static-mapping-parameters'): + mapping['static_parameters'] = conf.return_values('static-mapping-parameters') + + # append static-mapping configuration to subnet list + subnet['static_mapping'].append(mapping) + + # Now we have all static DHCP leases - we also need to slice them + # out of our DHCP ranges to avoid ISC DHCPd warnings as: + # dhcpd: Dynamic and static leases present for 192.0.2.51. + # dhcpd: Remove host declaration DMZ_PC1 or remove 192.0.2.51 + # dhcpd: from the dynamic address pool for DMZ + subnet['range'] = dhcp_slice_range(addresses_for_exclude, subnet['range']) + + # Reset config level to matching hirachy + conf.set_level('service dhcp-server shared-network-name {0} subnet {1}'.format(network, net)) + + # This option specifies a list of static routes that the client should install in its routing + # cache. If multiple routes to the same destination are specified, they are listed in descending + # order of priority. + if conf.exists('static-route destination-subnet'): + subnet['static_subnet'] = conf.return_value('static-route destination-subnet') + # Required for global config section + dhcp['static_route'] = True + + if conf.exists('static-route router'): + subnet['static_router'] = conf.return_value('static-route router') + + if subnet['static_router'] and subnet['static_subnet']: + subnet['static_route'] = dhcp_static_route(subnet['static_subnet'], subnet['static_router']) + + # HACKS AND TRICKS + # + # check for 'raw' ISC DHCP parameters configured by users + # actually this is a bad idea in general to pass raw parameters + # from any user + # + # deprecate this and issue a warning like we do for DNS forwarding? + if conf.exists('subnet-parameters'): + subnet['subnet_parameters'] = conf.return_values('subnet-parameters') + + # This option is used to identify a TFTP server and, if supported by the client, should have + # the same effect as the server-name declaration. BOOTP clients are unlikely to support this + # option. Some DHCP clients will support it, and others actually require it. + if conf.exists('tftp-server-name'): + subnet['tftp_server'] = conf.return_value('tftp-server-name') + + # The time-offset option specifies the offset of the client’s subnet in seconds from + # Coordinated Universal Time (UTC). + if conf.exists('time-offset'): + subnet['time_offset'] = conf.return_value('time-offset') + + # The time-server option specifies a list of RFC 868 time servers available to the client. + # Servers should be listed in order of preference. + if conf.exists('time-server'): + subnet['time_server'] = conf.return_values('time-server') + + # The NetBIOS name server (NBNS) option specifies a list of RFC 1001/1002 NBNS name servers + # listed in order of preference. NetBIOS Name Service is currently more commonly referred to + # as WINS. WINS servers can be specified using the netbios-name-servers option. + if conf.exists('wins-server'): + subnet['wins_server'] = conf.return_values('wins-server') + + # URL for Web Proxy Autodiscovery Protocol + if conf.exists('wpad-url'): + subnet['wpad_url'] = conf.return_value('wpad-url') + # Required for global config section + dhcp['wpad'] = True + + # append subnet configuration to shared network subnet list + config['subnet'].append(subnet) + + # append shared network configuration to config dictionary + dhcp['shared_network'].append(config) + + return dhcp + +def verify(dhcp): + if not dhcp or dhcp['disabled']: + return None + + # If DHCP is enabled we need one share-network + if len(dhcp['shared_network']) == 0: + raise ConfigError('No DHCP shared networks configured.\n' \ + 'At least one DHCP shared network must be configured.') + + # Inspect shared-network/subnet + failover_names = [] + listen_ok = False + subnets = [] + + # A shared-network requires a subnet definition + for network in dhcp['shared_network']: + if len(network['subnet']) == 0: + raise ConfigError('No DHCP lease subnets configured for {0}. At least one\n' \ + 'lease subnet must be configured for each shared network.'.format(network['name'])) + + for subnet in network['subnet']: + # Subnet static route declaration requires destination and router + if subnet['static_subnet'] or subnet['static_router']: + if not (subnet['static_subnet'] and subnet['static_router']): + raise ConfigError('Please specify missing DHCP static-route parameter(s):\n' \ + 'destination-subnet | router') + + # Failover requires all 4 parameters set + if subnet['failover_local_addr'] or subnet['failover_peer_addr'] or subnet['failover_name'] or subnet['failover_status']: + if not (subnet['failover_local_addr'] and subnet['failover_peer_addr'] and subnet['failover_name'] and subnet['failover_status']): + raise ConfigError('Please specify missing DHCP failover parameter(s):\n' \ + 'local-address | peer-address | name | status') + + # Failover names must be uniquie + if subnet['failover_name'] in failover_names: + raise ConfigError('Failover names must be unique:\n' \ + '{0} has already been configured!'.format(subnet['failover_name'])) + else: + failover_names.append(subnet['failover_name']) + + # Failover requires start/stop ranges for pool + if (len(subnet['range']) == 0): + raise ConfigError('At least one start-stop range must be configured for {0}\n' \ + 'to set up DHCP failover!'.format(subnet['network'])) + + # Check if DHCP address range is inside configured subnet declaration + range_start = [] + range_stop = [] + for range in subnet['range']: + start = range['start'] + stop = range['stop'] + # DHCP stop IP required after start IP + if start and not stop: + raise ConfigError('DHCP range stop address for start {0} is not defined!'.format(start)) + + # Start address must be inside network + if not ip_address(start) in ip_network(subnet['network']): + raise ConfigError('DHCP range start address {0} is not in subnet {1}\n' \ + 'specified for shared network {2}!'.format(start, subnet['network'], network['name'])) + + # Stop address must be inside network + if not ip_address(stop) in ip_network(subnet['network']): + raise ConfigError('DHCP range stop address {0} is not in subnet {1}\n' \ + 'specified for shared network {2}!'.format(stop, subnet['network'], network['name'])) + + # Stop address must be greater or equal to start address + if not ip_address(stop) >= ip_address(start): + raise ConfigError('DHCP range stop address {0} must be greater or equal\n' \ + 'to the range start address {1}!'.format(stop, start)) + + # Range start address must be unique + if start in range_start: + raise ConfigError('Conflicting DHCP lease range:\n' \ + 'Pool start address {0} defined multipe times!'.format(start)) + else: + range_start.append(start) + + # Range stop address must be unique + if stop in range_stop: + raise ConfigError('Conflicting DHCP lease range:\n' \ + 'Pool stop address {0} defined multipe times!'.format(stop)) + else: + range_stop.append(stop) + + # Exclude addresses must be in bound + for exclude in subnet['exclude']: + if not ip_address(exclude) in ip_network(subnet['network']): + raise ConfigError('Exclude IP address {0} is outside of the DHCP lease network {1}\n' \ + 'under shared network {2}!'.format(exclude, subnet['network'], network['name'])) + + # At least one DHCP address range or static-mapping required + active_mapping = False + if (len(subnet['range']) == 0): + for mapping in subnet['static_mapping']: + # we need at least one active mapping + if (not active_mapping) and (not mapping['disabled']): + active_mapping = True + else: + active_mapping = True + + if not active_mapping: + raise ConfigError('No DHCP address range or active static-mapping set\n' \ + 'for subnet {0}!'.format(subnet['network'])) + + # Static mappings require just a MAC address (will use an IP from the dynamic pool if IP is not set) + for mapping in subnet['static_mapping']: + + if mapping['ip_address']: + # Static IP address must be in bound + if not ip_address(mapping['ip_address']) in ip_network(subnet['network']): + raise ConfigError('DHCP static lease IP address {0} for static mapping {1}\n' \ + 'in shared network {2} is outside DHCP lease subnet {3}!' \ + .format(mapping['ip_address'], mapping['name'], network['name'], subnet['network'])) + + # Static mapping requires MAC address + if not mapping['mac_address']: + raise ConfigError('DHCP static lease MAC address not specified for static mapping\n' \ + '{0} under shared network name {1}!'.format(mapping['name'], network['name'])) + + # There must be one subnet connected to a listen interface. + # This only counts if the network itself is not disabled! + if not network['disabled']: + if is_subnet_connected(subnet['network'], primary=True): + listen_ok = True + + # Subnets must be non overlapping + if subnet['network'] in subnets: + raise ConfigError('DHCP subnets must be unique! Subnet {0} defined multiple times!'.format(subnet['network'])) + else: + subnets.append(subnet['network']) + + # Check for overlapping subnets + net = ip_network(subnet['network']) + for n in subnets: + net2 = ip_network(n) + if (net != net2): + if net.overlaps(net2): + raise ConfigError('DHCP conflicting subnet ranges: {0} overlaps {1}'.format(net, net2)) + + if not listen_ok: + raise ConfigError('DHCP server configuration error!\n' \ + 'None of configured DHCP subnets does not have appropriate\n' \ + 'primary IP address on any broadcast interface.') + + return None + +def generate(dhcp): + if not dhcp or dhcp['disabled']: + return None + + # Please see: https://phabricator.vyos.net/T1129 for quoting of the raw parameters + # we can pass to ISC DHCPd + render(config_file, 'dhcp-server/dhcpd.conf.tmpl', dhcp, + formater=lambda _: _.replace(""", '"')) + return None + +def apply(dhcp): + if not dhcp or dhcp['disabled']: + # DHCP server is removed in the commit + call('systemctl stop isc-dhcp-server.service') + if os.path.exists(config_file): + os.unlink(config_file) + return None + + call('systemctl restart isc-dhcp-server.service') + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/dhcpv6_relay.py b/src/conf_mode/dhcpv6_relay.py new file mode 100755 index 000000000..6ef290bf0 --- /dev/null +++ b/src/conf_mode/dhcpv6_relay.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2020 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 + +from sys import exit +from copy import deepcopy + +from vyos.config import Config +from vyos import ConfigError +from vyos.util import call +from vyos.template import render + +from vyos import airbag +airbag.enable() + +config_file = r'/run/dhcp-relay/dhcpv6.conf' + +default_config_data = { + 'listen_addr': [], + 'upstream_addr': [], + 'options': [], +} + +def get_config(): + relay = deepcopy(default_config_data) + conf = Config() + if not conf.exists('service dhcpv6-relay'): + return None + else: + conf.set_level('service dhcpv6-relay') + + # Network interfaces/address to listen on for DHCPv6 query(s) + if conf.exists('listen-interface'): + interfaces = conf.list_nodes('listen-interface') + for intf in interfaces: + if conf.exists('listen-interface {0} address'.format(intf)): + addr = conf.return_value('listen-interface {0} address'.format(intf)) + listen = addr + '%' + intf + relay['listen_addr'].append(listen) + + # Upstream interface/address for remote DHCPv6 server + if conf.exists('upstream-interface'): + interfaces = conf.list_nodes('upstream-interface') + for intf in interfaces: + addresses = conf.return_values('upstream-interface {0} address'.format(intf)) + for addr in addresses: + server = addr + '%' + intf + relay['upstream_addr'].append(server) + + # Maximum hop count. When forwarding packets, dhcrelay discards packets + # which have reached a hop count of COUNT. Default is 10. Maximum is 255. + if conf.exists('max-hop-count'): + count = '-c ' + conf.return_value('max-hop-count') + relay['options'].append(count) + + if conf.exists('use-interface-id-option'): + relay['options'].append('-I') + + return relay + +def verify(relay): + # bail out early - looks like removal from running config + if relay is None: + return None + + if len(relay['listen_addr']) == 0 or len(relay['upstream_addr']) == 0: + raise ConfigError('Must set at least one listen and upstream interface addresses.') + + return None + +def generate(relay): + # bail out early - looks like removal from running config + if relay is None: + return None + + render(config_file, 'dhcpv6-relay/config.tmpl', relay) + return None + +def apply(relay): + if relay is not None: + call('systemctl restart isc-dhcp-relay6.service') + else: + # DHCPv6 relay support is removed in the commit + call('systemctl stop isc-dhcp-relay6.service') + if os.path.exists(config_file): + os.unlink(config_file) + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/dhcpv6_server.py b/src/conf_mode/dhcpv6_server.py new file mode 100755 index 000000000..53c8358a5 --- /dev/null +++ b/src/conf_mode/dhcpv6_server.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2020 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 ipaddress + +from sys import exit +from copy import deepcopy + +from vyos.config import Config +from vyos.template import render +from vyos.util import call +from vyos.validate import is_subnet_connected, is_ipv6 +from vyos import ConfigError + +from vyos import airbag +airbag.enable() + +config_file = r'/run/dhcp-server/dhcpdv6.conf' + +default_config_data = { + 'preference': '', + 'disabled': False, + 'shared_network': [] +} + +def get_config(): + dhcpv6 = deepcopy(default_config_data) + conf = Config() + base = ['service', 'dhcpv6-server'] + if not conf.exists(base): + return None + else: + conf.set_level(base) + + # Check for global disable of DHCPv6 service + if conf.exists(['disable']): + dhcpv6['disabled'] = True + return dhcpv6 + + # Preference of this DHCPv6 server compared with others + if conf.exists(['preference']): + dhcpv6['preference'] = conf.return_value(['preference']) + + # check for multiple, shared networks served with DHCPv6 addresses + if conf.exists(['shared-network-name']): + for network in conf.list_nodes(['shared-network-name']): + conf.set_level(base + ['shared-network-name', network]) + config = { + 'name': network, + 'disabled': False, + 'subnet': [] + } + + # If disabled, the shared-network configuration becomes inactive + if conf.exists(['disable']): + config['disabled'] = True + + # check for multiple subnet configurations in a shared network + if conf.exists(['subnet']): + for net in conf.list_nodes(['subnet']): + conf.set_level(base + ['shared-network-name', network, 'subnet', net]) + subnet = { + 'network': net, + 'range6_prefix': [], + 'range6': [], + 'default_router': '', + 'dns_server': [], + 'domain_name': '', + 'domain_search': [], + 'lease_def': '', + 'lease_min': '', + 'lease_max': '', + 'nis_domain': '', + 'nis_server': [], + 'nisp_domain': '', + 'nisp_server': [], + 'prefix_delegation': [], + 'sip_address': [], + 'sip_hostname': [], + 'sntp_server': [], + 'static_mapping': [] + } + + # For any subnet on which addresses will be assigned dynamically, there must be at + # least one address range statement. The range statement gives the lowest and highest + # IP addresses in a range. All IP addresses in the range should be in the subnet in + # which the range statement is declared. + if conf.exists(['address-range', 'prefix']): + for prefix in conf.list_nodes(['address-range', 'prefix']): + range = { + 'prefix': prefix, + 'temporary': False + } + + # Address range will be used for temporary addresses + if conf.exists(['address-range' 'prefix', prefix, 'temporary']): + range['temporary'] = True + + # Append to subnet temporary range6 list + subnet['range6_prefix'].append(range) + + if conf.exists(['address-range', 'start']): + for range in conf.list_nodes(['address-range', 'start']): + range = { + 'start': range, + 'stop': conf.return_value(['address-range', 'start', range, 'stop']) + } + + # Append to subnet range6 list + subnet['range6'].append(range) + + # The domain-search option specifies a 'search list' of Domain Names to be used + # by the client to locate not-fully-qualified domain names. + if conf.exists(['domain-search']): + subnet['domain_search'] = conf.return_values(['domain-search']) + + # IPv6 address valid lifetime + # (at the end the address is no longer usable by the client) + # (set to 30 days, the usual IPv6 default) + if conf.exists(['lease-time', 'default']): + subnet['lease_def'] = conf.return_value(['lease-time', 'default']) + + # Time should be the maximum length in seconds that will be assigned to a lease. + # The only exception to this is that Dynamic BOOTP lease lengths, which are not + # specified by the client, are not limited by this maximum. + if conf.exists(['lease-time', 'maximum']): + subnet['lease_max'] = conf.return_value(['lease-time', 'maximum']) + + # Time should be the minimum length in seconds that will be assigned to a lease + if conf.exists(['lease-time', 'minimum']): + subnet['lease_min'] = conf.return_value(['lease-time', 'minimum']) + + # Specifies a list of Domain Name System name servers available to the client. + # Servers should be listed in order of preference. + if conf.exists(['name-server']): + subnet['dns_server'] = conf.return_values(['name-server']) + + # Ancient NIS (Network Information Service) domain name + if conf.exists(['nis-domain']): + subnet['nis_domain'] = conf.return_value(['nis-domain']) + + # Ancient NIS (Network Information Service) servers + if conf.exists(['nis-server']): + subnet['nis_server'] = conf.return_values(['nis-server']) + + # Ancient NIS+ (Network Information Service) domain name + if conf.exists(['nisplus-domain']): + subnet['nisp_domain'] = conf.return_value(['nisplus-domain']) + + # Ancient NIS+ (Network Information Service) servers + if conf.exists(['nisplus-server']): + subnet['nisp_server'] = conf.return_values(['nisplus-server']) + + # Local SIP server that is to be used for all outbound SIP requests - IPv6 address + if conf.exists(['sip-server']): + for value in conf.return_values(['sip-server']): + if is_ipv6(value): + subnet['sip_address'].append(value) + else: + subnet['sip_hostname'].append(value) + + # List of local SNTP servers available for the client to synchronize their clocks + if conf.exists(['sntp-server']): + subnet['sntp_server'] = conf.return_values(['sntp-server']) + + # Prefix Delegation (RFC 3633) + if conf.exists(['prefix-delegation', 'start']): + for address in conf.list_nodes(['prefix-delegation', 'start']): + conf.set_level(base + ['shared-network-name', network, 'subnet', net, 'prefix-delegation', 'start', address]) + prefix = { + 'start' : address, + 'stop' : '', + 'length' : '' + } + + if conf.exists(['prefix-length']): + prefix['length'] = conf.return_value(['prefix-length']) + + if conf.exists(['stop']): + prefix['stop'] = conf.return_value(['stop']) + + subnet['prefix_delegation'].append(prefix) + + # + # Static DHCP v6 leases + # + conf.set_level(base + ['shared-network-name', network, 'subnet', net]) + if conf.exists(['static-mapping']): + for mapping in conf.list_nodes(['static-mapping']): + conf.set_level(base + ['shared-network-name', network, 'subnet', net, 'static-mapping', mapping]) + mapping = { + 'name': mapping, + 'disabled': False, + 'ipv6_address': '', + 'client_identifier': '', + } + + # This static lease is disabled + if conf.exists(['disable']): + mapping['disabled'] = True + + # IPv6 address used for this DHCP client + if conf.exists(['ipv6-address']): + mapping['ipv6_address'] = conf.return_value(['ipv6-address']) + + # This option specifies the client’s DUID identifier. DUIDs are similar but different from DHCPv4 client identifiers + if conf.exists(['identifier']): + mapping['client_identifier'] = conf.return_value(['identifier']) + + # append static mapping configuration tu subnet list + subnet['static_mapping'].append(mapping) + + # append subnet configuration to shared network subnet list + config['subnet'].append(subnet) + + # append shared network configuration to config dictionary + dhcpv6['shared_network'].append(config) + + # If all shared-networks are disabled, there's nothing to do. + if all(net['disabled'] for net in dhcpv6['shared_network']): + return None + + return dhcpv6 + +def verify(dhcpv6): + if not dhcpv6 or dhcpv6['disabled']: + return None + + # If DHCP is enabled we need one share-network + if len(dhcpv6['shared_network']) == 0: + raise ConfigError('No DHCPv6 shared networks configured.\n' \ + 'At least one DHCPv6 shared network must be configured.') + + # Inspect shared-network/subnet + subnets = [] + listen_ok = False + + for network in dhcpv6['shared_network']: + # A shared-network requires a subnet definition + if len(network['subnet']) == 0: + raise ConfigError('No DHCPv6 lease subnets configured for {0}. At least one\n' \ + 'lease subnet must be configured for each shared network.'.format(network['name'])) + + range6_start = [] + range6_stop = [] + for subnet in network['subnet']: + # Ususal range declaration with a start and stop address + for range6 in subnet['range6']: + # shorten names + start = range6['start'] + stop = range6['stop'] + + # DHCPv6 stop address is required + if start and not stop: + raise ConfigError('DHCPv6 range stop address for start {0} is not defined!'.format(start)) + + # Start address must be inside network + if not ipaddress.ip_address(start) in ipaddress.ip_network(subnet['network']): + raise ConfigError('DHCPv6 range start address {0} is not in subnet {1}\n' \ + 'specified for shared network {2}!'.format(start, subnet['network'], network['name'])) + + # Stop address must be inside network + if not ipaddress.ip_address(stop) in ipaddress.ip_network(subnet['network']): + raise ConfigError('DHCPv6 range stop address {0} is not in subnet {1}\n' \ + 'specified for shared network {2}!'.format(stop, subnet['network'], network['name'])) + + # Stop address must be greater or equal to start address + if not ipaddress.ip_address(stop) >= ipaddress.ip_address(start): + raise ConfigError('DHCPv6 range stop address {0} must be greater or equal\n' \ + 'to the range start address {1}!'.format(stop, start)) + + # DHCPv6 range start address must be unique - two ranges can't + # start with the same address - makes no sense + if start in range6_start: + raise ConfigError('Conflicting DHCPv6 lease range:\n' \ + 'Pool start address {0} defined multipe times!'.format(start)) + else: + range6_start.append(start) + + # DHCPv6 range stop address must be unique - two ranges can't + # end with the same address - makes no sense + if stop in range6_stop: + raise ConfigError('Conflicting DHCPv6 lease range:\n' \ + 'Pool stop address {0} defined multipe times!'.format(stop)) + else: + range6_stop.append(stop) + + # Prefix delegation sanity checks + for prefix in subnet['prefix_delegation']: + if not prefix['stop']: + raise ConfigError('Stop address of delegated IPv6 prefix range must be configured') + + if not prefix['length']: + raise ConfigError('Length of delegated IPv6 prefix must be configured') + + # We also have prefixes that require checking + for prefix in subnet['range6_prefix']: + # If configured prefix does not match our subnet, we have to check that it's inside + if ipaddress.ip_network(prefix['prefix']) != ipaddress.ip_network(subnet['network']): + # Configured prefixes must be inside our network + if not ipaddress.ip_network(prefix['prefix']) in ipaddress.ip_network(subnet['network']): + raise ConfigError('DHCPv6 prefix {0} is not in subnet {1}\n' \ + 'specified for shared network {2}!'.format(prefix['prefix'], subnet['network'], network['name'])) + + # Static mappings don't require anything (but check if IP is in subnet if it's set) + for mapping in subnet['static_mapping']: + if mapping['ipv6_address']: + # Static address must be in subnet + if not ipaddress.ip_address(mapping['ipv6_address']) in ipaddress.ip_network(subnet['network']): + raise ConfigError('DHCPv6 static mapping IPv6 address {0} for static mapping {1}\n' \ + 'in shared network {2} is outside subnet {3}!' \ + .format(mapping['ipv6_address'], mapping['name'], network['name'], subnet['network'])) + + # Subnets must be unique + if subnet['network'] in subnets: + raise ConfigError('DHCPv6 subnets must be unique! Subnet {0} defined multiple times!'.format(subnet['network'])) + else: + subnets.append(subnet['network']) + + # DHCPv6 requires at least one configured address range or one static mapping + # (FIXME: is not actually checked right now?) + + # There must be one subnet connected to a listen interface if network is not disabled. + if not network['disabled']: + if is_subnet_connected(subnet['network']): + listen_ok = True + + # DHCPv6 subnet must not overlap. ISC DHCP also complains about overlapping + # subnets: "Warning: subnet 2001:db8::/32 overlaps subnet 2001:db8:1::/32" + net = ipaddress.ip_network(subnet['network']) + for n in subnets: + net2 = ipaddress.ip_network(n) + if (net != net2): + if net.overlaps(net2): + raise ConfigError('DHCPv6 conflicting subnet ranges: {0} overlaps {1}'.format(net, net2)) + + if not listen_ok: + raise ConfigError('None of the DHCPv6 subnets are connected to a subnet6 on\n' \ + 'this machine. At least one subnet6 must be connected such that\n' \ + 'DHCPv6 listens on an interface!') + + + return None + +def generate(dhcpv6): + if not dhcpv6 or dhcpv6['disabled']: + return None + + render(config_file, 'dhcpv6-server/dhcpdv6.conf.tmpl', dhcpv6) + return None + +def apply(dhcpv6): + if not dhcpv6 or dhcpv6['disabled']: + # DHCP server is removed in the commit + call('systemctl stop isc-dhcp-server6.service') + if os.path.exists(config_file): + os.unlink(config_file) + + else: + call('systemctl restart isc-dhcp-server6.service') + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/dns_forwarding.py b/src/conf_mode/dns_forwarding.py new file mode 100755 index 000000000..51631dc16 --- /dev/null +++ b/src/conf_mode/dns_forwarding.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2020 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 + +from sys import exit +from copy import deepcopy + +from vyos.config import Config +from vyos.hostsd_client import Client as hostsd_client +from vyos import ConfigError +from vyos.util import call, chown +from vyos.template import render + +from vyos import airbag +airbag.enable() + +pdns_rec_user = pdns_rec_group = 'pdns' +pdns_rec_run_dir = '/run/powerdns' +pdns_rec_lua_conf_file = f'{pdns_rec_run_dir}/recursor.conf.lua' +pdns_rec_hostsd_lua_conf_file = f'{pdns_rec_run_dir}/recursor.vyos-hostsd.conf.lua' +pdns_rec_hostsd_zones_file = f'{pdns_rec_run_dir}/recursor.forward-zones.conf' +pdns_rec_config_file = f'{pdns_rec_run_dir}/recursor.conf' + +default_config_data = { + 'allow_from': [], + 'cache_size': 10000, + 'export_hosts_file': 'yes', + 'listen_address': [], + 'name_servers': [], + 'negative_ttl': 3600, + 'system': False, + 'domains': {}, + 'dnssec': 'process-no-validate', + 'dhcp_interfaces': [] +} + +hostsd_tag = 'static' + +def get_config(conf): + dns = deepcopy(default_config_data) + base = ['service', 'dns', 'forwarding'] + + if not conf.exists(base): + return None + + conf.set_level(base) + + if conf.exists(['allow-from']): + dns['allow_from'] = conf.return_values(['allow-from']) + + if conf.exists(['cache-size']): + cache_size = conf.return_value(['cache-size']) + dns['cache_size'] = cache_size + + if conf.exists('negative-ttl'): + negative_ttl = conf.return_value(['negative-ttl']) + dns['negative_ttl'] = negative_ttl + + if conf.exists(['domain']): + for domain in conf.list_nodes(['domain']): + conf.set_level(base + ['domain', domain]) + entry = { + 'nslist': bracketize_ipv6_addrs(conf.return_values(['server'])), + 'addNTA': conf.exists(['addnta']), + 'recursion-desired': conf.exists(['recursion-desired']) + } + dns['domains'][domain] = entry + + conf.set_level(base) + + if conf.exists(['ignore-hosts-file']): + dns['export_hosts_file'] = "no" + + if conf.exists(['name-server']): + dns['name_servers'] = bracketize_ipv6_addrs( + conf.return_values(['name-server'])) + + if conf.exists(['system']): + dns['system'] = True + + if conf.exists(['listen-address']): + dns['listen_address'] = conf.return_values(['listen-address']) + + if conf.exists(['dnssec']): + dns['dnssec'] = conf.return_value(['dnssec']) + + if conf.exists(['dhcp']): + dns['dhcp_interfaces'] = conf.return_values(['dhcp']) + + return dns + +def bracketize_ipv6_addrs(addrs): + """Wraps each IPv6 addr in addrs in [], leaving IPv4 addrs untouched.""" + return ['[{0}]'.format(a) if a.count(':') > 1 else a for a in addrs] + +def verify(conf, dns): + # bail out early - looks like removal from running config + if dns is None: + return None + + if not dns['listen_address']: + raise ConfigError( + "Error: DNS forwarding requires a listen-address") + + if not dns['allow_from']: + raise ConfigError( + "Error: DNS forwarding requires an allow-from network") + + if dns['domains']: + for domain in dns['domains']: + if not dns['domains'][domain]['nslist']: + raise ConfigError(( + f'Error: No server configured for domain {domain}')) + + no_system_nameservers = False + if dns['system'] and not ( + conf.exists(['system', 'name-server']) or + conf.exists(['system', 'name-servers-dhcp']) ): + no_system_nameservers = True + print(("DNS forwarding warning: No 'system name-server' or " + "'system name-servers-dhcp' set\n")) + + if (no_system_nameservers or not dns['system']) and not ( + dns['name_servers'] or dns['dhcp_interfaces']): + print(("DNS forwarding warning: No 'dhcp', 'name-server' or 'system' " + "nameservers set. Forwarding will operate as a recursor.\n")) + + return None + +def generate(dns): + # bail out early - looks like removal from running config + if dns is None: + return None + + render(pdns_rec_config_file, 'dns-forwarding/recursor.conf.tmpl', + dns, user=pdns_rec_user, group=pdns_rec_group) + + render(pdns_rec_lua_conf_file, 'dns-forwarding/recursor.conf.lua.tmpl', + dns, user=pdns_rec_user, group=pdns_rec_group) + + # if vyos-hostsd didn't create its files yet, create them (empty) + for f in [pdns_rec_hostsd_lua_conf_file, pdns_rec_hostsd_zones_file]: + with open(f, 'a'): + pass + chown(f, user=pdns_rec_user, group=pdns_rec_group) + + return None + +def apply(dns): + if dns is None: + # DNS forwarding is removed in the commit + call("systemctl stop pdns-recursor.service") + if os.path.isfile(pdns_rec_config_file): + os.unlink(pdns_rec_config_file) + else: + ### first apply vyos-hostsd config + hc = hostsd_client() + + # add static nameservers to hostsd so they can be joined with other + # sources + hc.delete_name_servers([hostsd_tag]) + if dns['name_servers']: + hc.add_name_servers({hostsd_tag: dns['name_servers']}) + + # delete all nameserver tags + hc.delete_name_server_tags_recursor(hc.get_name_server_tags_recursor()) + + ## add nameserver tags - the order determines the nameserver order! + # our own tag (static) + hc.add_name_server_tags_recursor([hostsd_tag]) + + if dns['system']: + hc.add_name_server_tags_recursor(['system']) + else: + hc.delete_name_server_tags_recursor(['system']) + + # add dhcp nameserver tags for configured interfaces + for intf in dns['dhcp_interfaces']: + hc.add_name_server_tags_recursor(['dhcp-' + intf, 'dhcpv6-' + intf ]) + + # hostsd will generate the forward-zones file + # the list and keys() are required as get returns a dict, not list + hc.delete_forward_zones(list(hc.get_forward_zones().keys())) + if dns['domains']: + hc.add_forward_zones(dns['domains']) + + # call hostsd to generate forward-zones and its lua-config-file + hc.apply() + + ### finally (re)start pdns-recursor + call("systemctl restart pdns-recursor.service") + +if __name__ == '__main__': + try: + conf = Config() + c = get_config(conf) + verify(conf, c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/dynamic_dns.py b/src/conf_mode/dynamic_dns.py new file mode 100755 index 000000000..5b1883c03 --- /dev/null +++ b/src/conf_mode/dynamic_dns.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2020 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 + +from sys import exit +from copy import deepcopy +from stat import S_IRUSR, S_IWUSR + +from vyos.config import Config +from vyos import ConfigError +from vyos.util import call +from vyos.template import render + +from vyos import airbag +airbag.enable() + +config_file = r'/run/ddclient/ddclient.conf' + +# Mapping of service name to service protocol +default_service_protocol = { + 'afraid': 'freedns', + 'changeip': 'changeip', + 'cloudflare': 'cloudflare', + 'dnspark': 'dnspark', + 'dslreports': 'dslreports1', + 'dyndns': 'dyndns2', + 'easydns': 'easydns', + 'namecheap': 'namecheap', + 'noip': 'noip', + 'sitelutions': 'sitelutions', + 'zoneedit': 'zoneedit1' +} + +default_config_data = { + 'interfaces': [], + 'deleted': False +} + +def get_config(): + dyndns = deepcopy(default_config_data) + conf = Config() + base_level = ['service', 'dns', 'dynamic'] + + if not conf.exists(base_level): + dyndns['deleted'] = True + return dyndns + + for interface in conf.list_nodes(base_level + ['interface']): + node = { + 'interface': interface, + 'rfc2136': [], + 'service': [], + 'web_skip': '', + 'web_url': '' + } + + # set config level to e.g. "service dns dynamic interface eth0" + conf.set_level(base_level + ['interface', interface]) + # Handle RFC2136 - Dynamic Updates in the Domain Name System + for rfc2136 in conf.list_nodes(['rfc2136']): + rfc = { + 'name': rfc2136, + 'keyfile': '', + 'record': [], + 'server': '', + 'ttl': '600', + 'zone': '' + } + + # set config level + conf.set_level(base_level + ['interface', interface, 'rfc2136', rfc2136]) + + if conf.exists(['key']): + rfc['keyfile'] = conf.return_value(['key']) + + if conf.exists(['record']): + rfc['record'] = conf.return_values(['record']) + + if conf.exists(['server']): + rfc['server'] = conf.return_value(['server']) + + if conf.exists(['ttl']): + rfc['ttl'] = conf.return_value(['ttl']) + + if conf.exists(['zone']): + rfc['zone'] = conf.return_value(['zone']) + + node['rfc2136'].append(rfc) + + # set config level to e.g. "service dns dynamic interface eth0" + conf.set_level(base_level + ['interface', interface]) + # Handle DynDNS service providers + for service in conf.list_nodes(['service']): + srv = { + 'provider': service, + 'host': [], + 'login': '', + 'password': '', + 'protocol': '', + 'server': '', + 'custom' : False, + 'zone' : '' + } + + # set config level + conf.set_level(base_level + ['interface', interface, 'service', service]) + + # preload protocol from default service mapping + if service in default_service_protocol.keys(): + srv['protocol'] = default_service_protocol[service] + else: + srv['custom'] = True + + if conf.exists(['login']): + srv['login'] = conf.return_value(['login']) + + if conf.exists(['host-name']): + srv['host'] = conf.return_values(['host-name']) + + if conf.exists(['protocol']): + srv['protocol'] = conf.return_value(['protocol']) + + if conf.exists(['password']): + srv['password'] = conf.return_value(['password']) + + if conf.exists(['server']): + srv['server'] = conf.return_value(['server']) + + if conf.exists(['zone']): + srv['zone'] = conf.return_value(['zone']) + elif srv['provider'] == 'cloudflare': + # default populate zone entry with bar.tld if + # host-name is foo.bar.tld + srv['zone'] = srv['host'][0].split('.',1)[1] + + node['service'].append(srv) + + # Set config back to appropriate level for these options + conf.set_level(base_level + ['interface', interface]) + + # Additional settings in CLI + if conf.exists(['use-web', 'skip']): + node['web_skip'] = conf.return_value(['use-web', 'skip']) + + if conf.exists(['use-web', 'url']): + node['web_url'] = conf.return_value(['use-web', 'url']) + + # set config level back to top level + conf.set_level(base_level) + + dyndns['interfaces'].append(node) + + return dyndns + +def verify(dyndns): + # bail out early - looks like removal from running config + if dyndns['deleted']: + return None + + # A 'node' corresponds to an interface + for node in dyndns['interfaces']: + + # RFC2136 - configuration validation + for rfc2136 in node['rfc2136']: + if not rfc2136['record']: + raise ConfigError('Set key for service "{0}" to send DDNS updates for interface "{1}"'.format(rfc2136['name'], node['interface'])) + + if not rfc2136['zone']: + raise ConfigError('Set zone for service "{0}" to send DDNS updates for interface "{1}"'.format(rfc2136['name'], node['interface'])) + + if not rfc2136['keyfile']: + raise ConfigError('Set keyfile for service "{0}" to send DDNS updates for interface "{1}"'.format(rfc2136['name'], node['interface'])) + else: + if not os.path.isfile(rfc2136['keyfile']): + raise ConfigError('Keyfile for service "{0}" to send DDNS updates for interface "{1}" does not exist'.format(rfc2136['name'], node['interface'])) + + if not rfc2136['server']: + raise ConfigError('Set server for service "{0}" to send DDNS updates for interface "{1}"'.format(rfc2136['name'], node['interface'])) + + # DynDNS service provider - configuration validation + for service in node['service']: + if not service['host']: + raise ConfigError('Set host-name for service "{0}" to send DDNS updates for interface "{1}"'.format(service['provider'], node['interface'])) + + if not service['login']: + raise ConfigError('Set login for service "{0}" to send DDNS updates for interface "{1}"'.format(service['provider'], node['interface'])) + + if not service['password']: + raise ConfigError('Set password for service "{0}" to send DDNS updates for interface "{1}"'.format(service['provider'], node['interface'])) + + if service['custom'] is True: + if not service['protocol']: + raise ConfigError('Set protocol for service "{0}" to send DDNS updates for interface "{1}"'.format(service['provider'], node['interface'])) + + if not service['server']: + raise ConfigError('Set server for service "{0}" to send DDNS updates for interface "{1}"'.format(service['provider'], node['interface'])) + + if service['zone']: + if service['provider'] != 'cloudflare': + raise ConfigError('Zone option not allowed for "{0}", it can only be used for CloudFlare'.format(service['provider'])) + + return None + +def generate(dyndns): + # bail out early - looks like removal from running config + if dyndns['deleted']: + return None + + render(config_file, 'dynamic-dns/ddclient.conf.tmpl', dyndns) + + # Config file must be accessible only by its owner + os.chmod(config_file, S_IRUSR | S_IWUSR) + + return None + +def apply(dyndns): + if dyndns['deleted']: + call('systemctl stop ddclient.service') + if os.path.exists(config_file): + os.unlink(config_file) + + else: + call('systemctl restart ddclient.service') + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/firewall_options.py b/src/conf_mode/firewall_options.py new file mode 100755 index 000000000..71b2a98b3 --- /dev/null +++ b/src/conf_mode/firewall_options.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018 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 copy + +from vyos.config import Config +from vyos import ConfigError +from vyos.util import call + +from vyos import airbag +airbag.enable() + +default_config_data = { + 'intf_opts': [], + 'new_chain4': False, + 'new_chain6': False +} + +def get_config(): + opts = copy.deepcopy(default_config_data) + conf = Config() + if not conf.exists('firewall options'): + # bail out early + return opts + else: + conf.set_level('firewall options') + + # Parse configuration of each individual instance + if conf.exists('interface'): + for intf in conf.list_nodes('interface'): + conf.set_level('firewall options interface {0}'.format(intf)) + config = { + 'intf': intf, + 'disabled': False, + 'mss4': '', + 'mss6': '' + } + + # Check if individual option is disabled + if conf.exists('disable'): + config['disabled'] = True + + # + # Get MSS value IPv4 + # + if conf.exists('adjust-mss'): + config['mss4'] = conf.return_value('adjust-mss') + + # We need a marker that a new iptables chain needs to be generated + if not opts['new_chain4']: + opts['new_chain4'] = True + + # + # Get MSS value IPv6 + # + if conf.exists('adjust-mss6'): + config['mss6'] = conf.return_value('adjust-mss6') + + # We need a marker that a new ip6tables chain needs to be generated + if not opts['new_chain6']: + opts['new_chain6'] = True + + # Append interface options to global list + opts['intf_opts'].append(config) + + return opts + +def verify(tcp): + # syntax verification is done via cli + return None + +def apply(tcp): + target = 'VYOS_FW_OPTIONS' + + # always cleanup iptables + call('iptables --table mangle --delete FORWARD --jump {} >&/dev/null'.format(target)) + call('iptables --table mangle --flush {} >&/dev/null'.format(target)) + call('iptables --table mangle --delete-chain {} >&/dev/null'.format(target)) + + # always cleanup ip6tables + call('ip6tables --table mangle --delete FORWARD --jump {} >&/dev/null'.format(target)) + call('ip6tables --table mangle --flush {} >&/dev/null'.format(target)) + call('ip6tables --table mangle --delete-chain {} >&/dev/null'.format(target)) + + # Setup new iptables rules + if tcp['new_chain4']: + call('iptables --table mangle --new-chain {} >&/dev/null'.format(target)) + call('iptables --table mangle --append FORWARD --jump {} >&/dev/null'.format(target)) + + for opts in tcp['intf_opts']: + intf = opts['intf'] + mss = opts['mss4'] + + # Check if this rule iis disabled + if opts['disabled']: + continue + + # adjust TCP MSS per interface + if mss: + call('iptables --table mangle --append {} --out-interface {} --protocol tcp ' + '--tcp-flags SYN,RST SYN --jump TCPMSS --set-mss {} >&/dev/null'.format(target, intf, mss)) + + # Setup new ip6tables rules + if tcp['new_chain6']: + call('ip6tables --table mangle --new-chain {} >&/dev/null'.format(target)) + call('ip6tables --table mangle --append FORWARD --jump {} >&/dev/null'.format(target)) + + for opts in tcp['intf_opts']: + intf = opts['intf'] + mss = opts['mss6'] + + # Check if this rule iis disabled + if opts['disabled']: + continue + + # adjust TCP MSS per interface + if mss: + call('ip6tables --table mangle --append {} --out-interface {} --protocol tcp ' + '--tcp-flags SYN,RST SYN --jump TCPMSS --set-mss {} >&/dev/null'.format(target, intf, mss)) + + return None + +if __name__ == '__main__': + + try: + c = get_config() + verify(c) + apply(c) + except ConfigError as e: + print(e) + sys.exit(1) diff --git a/src/conf_mode/flow_accounting_conf.py b/src/conf_mode/flow_accounting_conf.py new file mode 100755 index 000000000..b7e73eaeb --- /dev/null +++ b/src/conf_mode/flow_accounting_conf.py @@ -0,0 +1,371 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2020 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 +from sys import exit +import ipaddress + +from ipaddress import ip_address +from jinja2 import FileSystemLoader, Environment + +from vyos.ifconfig import Section +from vyos.ifconfig import Interface +from vyos.config import Config +from vyos import ConfigError +from vyos.util import cmd +from vyos.template import render + +from vyos import airbag +airbag.enable() + +# default values +default_sflow_server_port = 6343 +default_netflow_server_port = 2055 +default_plugin_pipe_size = 10 +default_captured_packet_size = 128 +default_netflow_version = '9' +default_sflow_agentip = 'auto' +uacctd_conf_path = '/etc/pmacct/uacctd.conf' +iptables_nflog_table = 'raw' +iptables_nflog_chain = 'VYATTA_CT_PREROUTING_HOOK' + +# helper functions +# check if node exists and return True if this is true +def _node_exists(path): + vyos_config = Config() + if vyos_config.exists(path): + return True + +# get sFlow agent-ip if agent-address is "auto" (default behaviour) +def _sflow_default_agentip(config): + # check if any of BGP, OSPF, OSPFv3 protocols are configured and use router-id from there + if config.exists('protocols bgp'): + bgp_router_id = config.return_value("protocols bgp {} parameters router-id".format(config.list_nodes('protocols bgp')[0])) + if bgp_router_id: + return bgp_router_id + if config.return_value('protocols ospf parameters router-id'): + return config.return_value('protocols ospf parameters router-id') + if config.return_value('protocols ospfv3 parameters router-id'): + return config.return_value('protocols ospfv3 parameters router-id') + + # if router-id was not found, use first available ip of any interface + for iface in Section.interfaces(): + for address in Interface(iface).get_addr(): + # return an IP, if this is not loopback + regex_filter = re.compile('^(?!(127)|(::1)|(fe80))(?P<ipaddr>[a-f\d\.:]+)/\d+$') + if regex_filter.search(address): + return regex_filter.search(address).group('ipaddr') + + # return nothing by default + return None + +# get iptables rule dict for chain in table +def _iptables_get_nflog(): + # define list with rules + rules = [] + + # prepare regex for parsing rules + rule_pattern = "^-A (?P<rule_definition>{0} -i (?P<interface>[\w\.\*\-]+).*--comment FLOW_ACCOUNTING_RULE.* -j NFLOG.*$)".format(iptables_nflog_chain) + rule_re = re.compile(rule_pattern) + + for iptables_variant in ['iptables', 'ip6tables']: + # run iptables, save output and split it by lines + iptables_command = f'{iptables_variant} -t {iptables_nflog_table} -S {iptables_nflog_chain}' + tmp = cmd(iptables_command, message='Failed to get flows list') + + # parse each line and add information to list + for current_rule in tmp.splitlines(): + current_rule_parsed = rule_re.search(current_rule) + if current_rule_parsed: + rules.append({ 'interface': current_rule_parsed.groupdict()["interface"], 'iptables_variant': iptables_variant, 'table': iptables_nflog_table, 'rule_definition': current_rule_parsed.groupdict()["rule_definition"] }) + + # return list with rules + return rules + +# modify iptables rules +def _iptables_config(configured_ifaces): + # define list of iptables commands to modify settings + iptable_commands = [] + + # prepare extended list with configured interfaces + configured_ifaces_extended = [] + for iface in configured_ifaces: + configured_ifaces_extended.append({ 'iface': iface, 'iptables_variant': 'iptables' }) + configured_ifaces_extended.append({ 'iface': iface, 'iptables_variant': 'ip6tables' }) + + # get currently configured interfaces with iptables rules + active_nflog_rules = _iptables_get_nflog() + + # compare current active list with configured one and delete excessive interfaces, add missed + active_nflog_ifaces = [] + for rule in active_nflog_rules: + iptables = rule['iptables_variant'] + interface = rule['interface'] + if interface not in configured_ifaces: + table = rule['table'] + rule = rule['rule_definition'] + iptable_commands.append(f'{iptables} -t {table} -D {rule}') + else: + active_nflog_ifaces.append({ + 'iface': interface, + 'iptables_variant': iptables, + }) + + # do not create new rules for already configured interfaces + for iface in active_nflog_ifaces: + if iface in active_nflog_ifaces: + configured_ifaces_extended.remove(iface) + + # create missed rules + for iface_extended in configured_ifaces_extended: + iface = iface_extended['iface'] + iptables = iface_extended['iptables_variant'] + rule_definition = f'{iptables_nflog_chain} -i {iface} -m comment --comment FLOW_ACCOUNTING_RULE -j NFLOG --nflog-group 2 --nflog-size {default_captured_packet_size} --nflog-threshold 100' + iptable_commands.append(f'{iptables} -t {iptables_nflog_table} -I {rule_definition}') + + # change iptables + for command in iptable_commands: + cmd(command, raising=ConfigError) + + +def get_config(): + vc = Config() + vc.set_level('') + # Convert the VyOS config to an abstract internal representation + flow_config = { + 'flow-accounting-configured': vc.exists('system flow-accounting'), + 'buffer-size': vc.return_value('system flow-accounting buffer-size'), + 'disable-imt': _node_exists('system flow-accounting disable-imt'), + 'syslog-facility': vc.return_value('system flow-accounting syslog-facility'), + 'interfaces': None, + 'sflow': { + 'configured': vc.exists('system flow-accounting sflow'), + 'agent-address': vc.return_value('system flow-accounting sflow agent-address'), + 'sampling-rate': vc.return_value('system flow-accounting sflow sampling-rate'), + 'servers': None + }, + 'netflow': { + 'configured': vc.exists('system flow-accounting netflow'), + 'engine-id': vc.return_value('system flow-accounting netflow engine-id'), + 'max-flows': vc.return_value('system flow-accounting netflow max-flows'), + 'sampling-rate': vc.return_value('system flow-accounting netflow sampling-rate'), + 'source-ip': vc.return_value('system flow-accounting netflow source-ip'), + 'version': vc.return_value('system flow-accounting netflow version'), + 'timeout': { + 'expint': vc.return_value('system flow-accounting netflow timeout expiry-interval'), + 'general': vc.return_value('system flow-accounting netflow timeout flow-generic'), + 'icmp': vc.return_value('system flow-accounting netflow timeout icmp'), + 'maxlife': vc.return_value('system flow-accounting netflow timeout max-active-life'), + 'tcp.fin': vc.return_value('system flow-accounting netflow timeout tcp-fin'), + 'tcp': vc.return_value('system flow-accounting netflow timeout tcp-generic'), + 'tcp.rst': vc.return_value('system flow-accounting netflow timeout tcp-rst'), + 'udp': vc.return_value('system flow-accounting netflow timeout udp') + }, + 'servers': None + } + } + + # get interfaces list + if vc.exists('system flow-accounting interface'): + flow_config['interfaces'] = vc.return_values('system flow-accounting interface') + + # get sFlow collectors list + if vc.exists('system flow-accounting sflow server'): + flow_config['sflow']['servers'] = [] + sflow_collectors = vc.list_nodes('system flow-accounting sflow server') + for collector in sflow_collectors: + port = default_sflow_server_port + if vc.return_value("system flow-accounting sflow server {} port".format(collector)): + port = vc.return_value("system flow-accounting sflow server {} port".format(collector)) + flow_config['sflow']['servers'].append({ 'address': collector, 'port': port }) + + # get NetFlow collectors list + if vc.exists('system flow-accounting netflow server'): + flow_config['netflow']['servers'] = [] + netflow_collectors = vc.list_nodes('system flow-accounting netflow server') + for collector in netflow_collectors: + port = default_netflow_server_port + if vc.return_value("system flow-accounting netflow server {} port".format(collector)): + port = vc.return_value("system flow-accounting netflow server {} port".format(collector)) + flow_config['netflow']['servers'].append({ 'address': collector, 'port': port }) + + # get sflow agent-id + if flow_config['sflow']['agent-address'] == None or flow_config['sflow']['agent-address'] == 'auto': + flow_config['sflow']['agent-address'] = _sflow_default_agentip(vc) + + # get NetFlow version + if not flow_config['netflow']['version']: + flow_config['netflow']['version'] = default_netflow_version + + # convert NetFlow engine-id format, if this is necessary + if flow_config['netflow']['engine-id'] and flow_config['netflow']['version'] == '5': + regex_filter = re.compile('^\d+$') + if regex_filter.search(flow_config['netflow']['engine-id']): + flow_config['netflow']['engine-id'] = "{}:0".format(flow_config['netflow']['engine-id']) + + # return dict with flow-accounting configuration + return flow_config + +def verify(config): + # Verify that configuration is valid + # skip all checks if flow-accounting was removed + if not config['flow-accounting-configured']: + return True + + # check if at least one collector is enabled + if not (config['sflow']['configured'] or config['netflow']['configured'] or not config['disable-imt']): + raise ConfigError("You need to configure at least one sFlow or NetFlow protocol, or not set \"disable-imt\" for flow-accounting") + + # Check if at least one interface is configured + if not config['interfaces']: + raise ConfigError("You need to configure at least one interface for flow-accounting") + + # check that all configured interfaces exists in the system + for iface in config['interfaces']: + if not iface in Section.interfaces(): + # chnged from error to warning to allow adding dynamic interfaces and interface templates + # raise ConfigError("The {} interface is not presented in the system".format(iface)) + print("Warning: the {} interface is not presented in the system".format(iface)) + + # check sFlow configuration + if config['sflow']['configured']: + # check if at least one sFlow collector is configured if sFlow configuration is presented + if not config['sflow']['servers']: + raise ConfigError("You need to configure at least one sFlow server") + + # check that all sFlow collectors use the same IP protocol version + sflow_collector_ipver = None + for sflow_collector in config['sflow']['servers']: + if sflow_collector_ipver: + if sflow_collector_ipver != ip_address(sflow_collector['address']).version: + raise ConfigError("All sFlow servers must use the same IP protocol") + else: + sflow_collector_ipver = ip_address(sflow_collector['address']).version + + + # check agent-id for sFlow: we should avoid mixing IPv4 agent-id with IPv6 collectors and vice-versa + for sflow_collector in config['sflow']['servers']: + if ip_address(sflow_collector['address']).version != ip_address(config['sflow']['agent-address']).version: + raise ConfigError("Different IP address versions cannot be mixed in \"sflow agent-address\" and \"sflow server\". You need to set manually the same IP version for \"agent-address\" as for all sFlow servers") + + # check if configured sFlow agent-id exist in the system + agent_id_presented = None + for iface in Section.interfaces(): + for address in Interface(iface).get_addr(): + # check an IP, if this is not loopback + regex_filter = re.compile('^(?!(127)|(::1)|(fe80))(?P<ipaddr>[a-f\d\.:]+)/\d+$') + if regex_filter.search(address): + if regex_filter.search(address).group('ipaddr') == config['sflow']['agent-address']: + agent_id_presented = True + break + if not agent_id_presented: + raise ConfigError("Your \"sflow agent-address\" does not exist in the system") + + # check NetFlow configuration + if config['netflow']['configured']: + # check if at least one NetFlow collector is configured if NetFlow configuration is presented + if not config['netflow']['servers']: + raise ConfigError("You need to configure at least one NetFlow server") + + # check if configured netflow source-ip exist in the system + if config['netflow']['source-ip']: + source_ip_presented = None + for iface in Section.interfaces(): + for address in Interface(iface).get_addr(): + # check an IP + regex_filter = re.compile('^(?!(127)|(::1)|(fe80))(?P<ipaddr>[a-f\d\.:]+)/\d+$') + if regex_filter.search(address): + if regex_filter.search(address).group('ipaddr') == config['netflow']['source-ip']: + source_ip_presented = True + break + if not source_ip_presented: + raise ConfigError("Your \"netflow source-ip\" does not exist in the system") + + # check if engine-id compatible with selected protocol version + if config['netflow']['engine-id']: + v5_filter = '^(\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5]):(\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5])$' + v9v10_filter = '^(\d|[1-9]\d{1,8}|[1-3]\d{9}|4[01]\d{8}|42[0-8]\d{7}|429[0-3]\d{6}|4294[0-8]\d{5}|42949[0-5]\d{4}|429496[0-6]\d{3}|4294967[01]\d{2}|42949672[0-8]\d|429496729[0-5])$' + if config['netflow']['version'] == '5': + regex_filter = re.compile(v5_filter) + if not regex_filter.search(config['netflow']['engine-id']): + raise ConfigError("You cannot use NetFlow engine-id {} together with NetFlow protocol version {}".format(config['netflow']['engine-id'], config['netflow']['version'])) + else: + regex_filter = re.compile(v9v10_filter) + if not regex_filter.search(config['netflow']['engine-id']): + raise ConfigError("You cannot use NetFlow engine-id {} together with NetFlow protocol version {}".format(config['netflow']['engine-id'], config['netflow']['version'])) + + # return True if all checks were passed + return True + +def generate(config): + # skip all checks if flow-accounting was removed + if not config['flow-accounting-configured']: + return True + + # Calculate all necessary values + if config['buffer-size']: + # circular queue size + config['plugin_pipe_size'] = int(config['buffer-size']) * 1024**2 + else: + config['plugin_pipe_size'] = default_plugin_pipe_size * 1024**2 + # transfer buffer size + # recommended value from pmacct developers 1/1000 of pipe size + config['plugin_buffer_size'] = int(config['plugin_pipe_size'] / 1000) + + # Prepare a timeouts string + timeout_string = '' + for timeout_type, timeout_value in config['netflow']['timeout'].items(): + if timeout_value: + if timeout_string == '': + timeout_string = "{}{}={}".format(timeout_string, timeout_type, timeout_value) + else: + timeout_string = "{}:{}={}".format(timeout_string, timeout_type, timeout_value) + config['netflow']['timeout_string'] = timeout_string + + render(uacctd_conf_path, 'netflow/uacctd.conf.tmpl', { + 'templatecfg': config, + 'snaplen': default_captured_packet_size, + }) + + +def apply(config): + # define variables + command = None + # Check if flow-accounting was removed and define command + if not config['flow-accounting-configured']: + command = 'systemctl stop uacctd.service' + else: + command = 'systemctl restart uacctd.service' + + # run command to start or stop flow-accounting + cmd(command, raising=ConfigError, message='Failed to start/stop flow-accounting') + + # configure iptables rules for defined interfaces + if config['interfaces']: + _iptables_config(config['interfaces']) + else: + _iptables_config([]) + +if __name__ == '__main__': + try: + config = get_config() + verify(config) + generate(config) + apply(config) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/host_name.py b/src/conf_mode/host_name.py new file mode 100755 index 000000000..9d66bd434 --- /dev/null +++ b/src/conf_mode/host_name.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2020 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/>. + +""" +conf-mode script for 'system host-name' and 'system domain-name'. +""" + +import re +import sys +import copy + +import vyos.util +import vyos.hostsd_client + +from vyos.config import Config +from vyos import ConfigError +from vyos.util import cmd, call, process_named_running + +from vyos import airbag +airbag.enable() + +default_config_data = { + 'hostname': 'vyos', + 'domain_name': '', + 'domain_search': [], + 'nameserver': [], + 'nameservers_dhcp_interfaces': [], + 'static_host_mapping': {} +} + +hostsd_tag = 'system' + +def get_config(): + conf = Config() + + hosts = copy.deepcopy(default_config_data) + + hosts['hostname'] = conf.return_value("system host-name") + + # This may happen if the config is not loaded yet, + # e.g. if run by cloud-init + if not hosts['hostname']: + hosts['hostname'] = default_config_data['hostname'] + + if conf.exists("system domain-name"): + hosts['domain_name'] = conf.return_value("system domain-name") + hosts['domain_search'].append(hosts['domain_name']) + + for search in conf.return_values("system domain-search domain"): + hosts['domain_search'].append(search) + + hosts['nameserver'] = conf.return_values("system name-server") + + hosts['nameservers_dhcp_interfaces'] = conf.return_values("system name-servers-dhcp") + + # system static-host-mapping + for hn in conf.list_nodes('system static-host-mapping host-name'): + hosts['static_host_mapping'][hn] = {} + hosts['static_host_mapping'][hn]['address'] = conf.return_value(f'system static-host-mapping host-name {hn} inet') + hosts['static_host_mapping'][hn]['aliases'] = conf.return_values(f'system static-host-mapping host-name {hn} alias') + + return hosts + + +def verify(hosts): + if hosts is None: + return None + + # pattern $VAR(@) "^[[:alnum:]][-.[:alnum:]]*[[:alnum:]]$" ; "invalid host name $VAR(@)" + hostname_regex = re.compile("^[A-Za-z0-9][-.A-Za-z0-9]*[A-Za-z0-9]$") + if not hostname_regex.match(hosts['hostname']): + raise ConfigError('Invalid host name ' + hosts["hostname"]) + + # pattern $VAR(@) "^.{1,63}$" ; "invalid host-name length" + length = len(hosts['hostname']) + if length < 1 or length > 63: + raise ConfigError( + 'Invalid host-name length, must be less than 63 characters') + + all_static_host_mapping_addresses = [] + # static mappings alias hostname + for host, hostprops in hosts['static_host_mapping'].items(): + if not hostprops['address']: + raise ConfigError(f'IP address required for static-host-mapping "{host}"') + all_static_host_mapping_addresses.append(hostprops['address']) + for a in hostprops['aliases']: + if not hostname_regex.match(a) and len(a) != 0: + raise ConfigError(f'Invalid alias "{a}" in static-host-mapping "{host}"') + + # TODO: add warnings for nameservers_dhcp_interfaces if interface doesn't + # exist or doesn't have address dhcp(v6) + + return None + + +def generate(config): + pass + +def apply(config): + if config is None: + return None + + ## Send the updated data to vyos-hostsd + try: + hc = vyos.hostsd_client.Client() + + hc.set_host_name(config['hostname'], config['domain_name']) + + hc.delete_search_domains([hostsd_tag]) + if config['domain_search']: + hc.add_search_domains({hostsd_tag: config['domain_search']}) + + hc.delete_name_servers([hostsd_tag]) + if config['nameserver']: + hc.add_name_servers({hostsd_tag: config['nameserver']}) + + # add our own tag's (system) nameservers and search to resolv.conf + hc.delete_name_server_tags_system(hc.get_name_server_tags_system()) + hc.add_name_server_tags_system([hostsd_tag]) + + # this will add the dhcp client nameservers to resolv.conf + for intf in config['nameservers_dhcp_interfaces']: + hc.add_name_server_tags_system([f'dhcp-{intf}', f'dhcpv6-{intf}']) + + hc.delete_hosts([hostsd_tag]) + if config['static_host_mapping']: + hc.add_hosts({hostsd_tag: config['static_host_mapping']}) + + hc.apply() + except vyos.hostsd_client.VyOSHostsdError as e: + raise ConfigError(str(e)) + + ## Actually update the hostname -- vyos-hostsd doesn't do that + + # No domain name -- the Debian way. + hostname_new = config['hostname'] + + # rsyslog runs into a race condition at boot time with systemd + # restart rsyslog only if the hostname changed. + hostname_old = cmd('hostnamectl --static') + call(f'hostnamectl set-hostname --static {hostname_new}') + + # Restart services that use the hostname + if hostname_new != hostname_old: + call("systemctl restart rsyslog.service") + + # If SNMP is running, restart it too + if process_named_running('snmpd'): + call('systemctl restart snmpd.service') + + 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/http-api.py b/src/conf_mode/http-api.py new file mode 100755 index 000000000..b8a084a40 --- /dev/null +++ b/src/conf_mode/http-api.py @@ -0,0 +1,113 @@ +#!/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 json +from copy import deepcopy + +import vyos.defaults +from vyos.config import Config +from vyos import ConfigError +from vyos.util import cmd +from vyos.util import call + +from vyos import airbag +airbag.enable() + +config_file = '/etc/vyos/http-api.conf' + +vyos_conf_scripts_dir=vyos.defaults.directories['conf_mode'] + +# XXX: this model will need to be extended for tag nodes +dependencies = [ + 'https.py', +] + +def get_config(): + http_api = deepcopy(vyos.defaults.api_data) + x = http_api.get('api_keys') + if x is None: + default_key = None + else: + default_key = x[0] + keys_added = False + + conf = Config() + if not conf.exists('service https api'): + return None + else: + conf.set_level('service https api') + + if conf.exists('strict'): + http_api['strict'] = 'true' + + if conf.exists('debug'): + http_api['debug'] = 'true' + + if conf.exists('port'): + port = conf.return_value('port') + http_api['port'] = port + + if conf.exists('keys'): + for name in conf.list_nodes('keys id'): + if conf.exists('keys id {0} key'.format(name)): + key = conf.return_value('keys id {0} key'.format(name)) + new_key = { 'id': name, 'key': key } + http_api['api_keys'].append(new_key) + keys_added = True + + if keys_added and default_key: + if default_key in http_api['api_keys']: + http_api['api_keys'].remove(default_key) + + return http_api + +def verify(http_api): + return None + +def generate(http_api): + if http_api is None: + return None + + if not os.path.exists('/etc/vyos'): + os.mkdir('/etc/vyos') + + with open(config_file, 'w') as f: + json.dump(http_api, f, indent=2) + + return None + +def apply(http_api): + if http_api is not None: + call('systemctl restart vyos-http-api.service') + else: + call('systemctl stop vyos-http-api.service') + + for dep in dependencies: + cmd(f'{vyos_conf_scripts_dir}/{dep}', raising=ConfigError) + +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/https.py b/src/conf_mode/https.py new file mode 100755 index 000000000..a13f131ab --- /dev/null +++ b/src/conf_mode/https.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2020 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 + +from copy import deepcopy + +import vyos.defaults +import vyos.certbot_util + +from vyos.config import Config +from vyos import ConfigError +from vyos.util import call +from vyos.template import render + +from vyos import airbag +airbag.enable() + +config_file = '/etc/nginx/sites-available/default' +certbot_dir = vyos.defaults.directories['certbot'] + +# https config needs to coordinate several subsystems: api, certbot, +# self-signed certificate, as well as the virtual hosts defined within the +# https config definition itself. Consequently, one needs a general dict, +# encompassing the https and other configs, and a list of such virtual hosts +# (server blocks in nginx terminology) to pass to the jinja2 template. +default_server_block = { + 'id' : '', + 'address' : '*', + 'port' : '443', + 'name' : ['_'], + 'api' : {}, + 'vyos_cert' : {}, + 'certbot' : False +} + +def get_config(): + conf = Config() + if not conf.exists('service https'): + return None + + server_block_list = [] + https_dict = conf.get_config_dict('service https', get_first_key=True) + + # organize by vhosts + + vhost_dict = https_dict.get('virtual-host', {}) + + if not vhost_dict: + # no specified virtual hosts (server blocks); use default + server_block_list.append(default_server_block) + else: + for vhost in list(vhost_dict): + server_block = deepcopy(default_server_block) + server_block['id'] = vhost + data = vhost_dict.get(vhost, {}) + server_block['address'] = data.get('listen-address', '*') + server_block['port'] = data.get('listen-port', '443') + name = data.get('server-name', ['_']) + # XXX: T2636 workaround: convert string to a list with one element + if not isinstance(name, list): + name = [name] + server_block['name'] = name + server_block_list.append(server_block) + + # get certificate data + + cert_dict = https_dict.get('certificates', {}) + + # self-signed certificate + + vyos_cert_data = {} + if 'system-generated-certificate' in list(cert_dict): + vyos_cert_data = vyos.defaults.vyos_cert_data + if vyos_cert_data: + for block in server_block_list: + block['vyos_cert'] = vyos_cert_data + + # letsencrypt certificate using certbot + + certbot = False + cert_domains = cert_dict.get('certbot', {}).get('domain-name', []) + if cert_domains: + # XXX: T2636 workaround: convert string to a list with one element + if not isinstance(cert_domains, list): + cert_domains = [cert_domains] + certbot = True + for domain in cert_domains: + sub_list = vyos.certbot_util.choose_server_block(server_block_list, + domain) + if sub_list: + for sb in sub_list: + sb['certbot'] = True + sb['certbot_dir'] = certbot_dir + # certbot organizes certificates by first domain + sb['certbot_domain_dir'] = cert_domains[0] + + # get api data + + api_set = False + api_data = {} + if 'api' in list(https_dict): + api_set = True + api_data = vyos.defaults.api_data + api_settings = https_dict.get('api', {}) + if api_settings: + port = api_settings.get('port', '') + if port: + api_data['port'] = port + vhosts = https_dict.get('api-restrict', {}).get('virtual-host', []) + # XXX: T2636 workaround: convert string to a list with one element + if not isinstance(vhosts, list): + vhosts = [vhosts] + if vhosts: + api_data['vhost'] = vhosts[:] + + if api_data: + vhost_list = api_data.get('vhost', []) + if not vhost_list: + for block in server_block_list: + block['api'] = api_data + else: + for block in server_block_list: + if block['id'] in vhost_list: + block['api'] = api_data + + # return dict for use in template + + https = {'server_block_list' : server_block_list, + 'api_set': api_set, + 'certbot': certbot} + + return https + +def verify(https): + if https is None: + return None + + if https['certbot']: + for sb in https['server_block_list']: + if sb['certbot']: + return None + raise ConfigError("At least one 'virtual-host <id> server-name' " + "matching the 'certbot domain-name' is required.") + return None + +def generate(https): + if https is None: + return None + + if 'server_block_list' not in https or not https['server_block_list']: + https['server_block_list'] = [default_server_block] + + render(config_file, 'https/nginx.default.tmpl', https, trim_blocks=True) + + return None + +def apply(https): + if https is not None: + call('systemctl restart nginx.service') + else: + call('systemctl stop nginx.service') + +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/igmp_proxy.py b/src/conf_mode/igmp_proxy.py new file mode 100755 index 000000000..49aea9b7f --- /dev/null +++ b/src/conf_mode/igmp_proxy.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2020 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 + +from sys import exit +from copy import deepcopy + +from netifaces import interfaces +from vyos.config import Config +from vyos import ConfigError +from vyos.util import call +from vyos.template import render + +from vyos import airbag +airbag.enable() + +config_file = r'/etc/igmpproxy.conf' + +default_config_data = { + 'disable': False, + 'disable_quickleave': False, + 'interfaces': [], +} + +def get_config(): + igmp_proxy = deepcopy(default_config_data) + conf = Config() + base = ['protocols', 'igmp-proxy'] + if not conf.exists(base): + return None + else: + conf.set_level(base) + + # Network interfaces to listen on + if conf.exists(['disable']): + igmp_proxy['disable'] = True + + # Option to disable "quickleave" + if conf.exists(['disable-quickleave']): + igmp_proxy['disable_quickleave'] = True + + for intf in conf.list_nodes(['interface']): + conf.set_level(base + ['interface', intf]) + interface = { + 'name': intf, + 'alt_subnet': [], + 'role': 'downstream', + 'threshold': '1', + 'whitelist': [] + } + + if conf.exists(['alt-subnet']): + interface['alt_subnet'] = conf.return_values(['alt-subnet']) + + if conf.exists(['role']): + interface['role'] = conf.return_value(['role']) + + if conf.exists(['threshold']): + interface['threshold'] = conf.return_value(['threshold']) + + if conf.exists(['whitelist']): + interface['whitelist'] = conf.return_values(['whitelist']) + + # Append interface configuration to global configuration list + igmp_proxy['interfaces'].append(interface) + + return igmp_proxy + +def verify(igmp_proxy): + # bail out early - looks like removal from running config + if igmp_proxy is None: + return None + + # bail out early - service is disabled + if igmp_proxy['disable']: + return None + + # at least two interfaces are required, one upstream and one downstream + if len(igmp_proxy['interfaces']) < 2: + raise ConfigError('Must define an upstream and at least 1 downstream interface!') + + upstream = 0 + for interface in igmp_proxy['interfaces']: + if interface['name'] not in interfaces(): + raise ConfigError('Interface "{}" does not exist'.format(interface['name'])) + if "upstream" == interface['role']: + upstream += 1 + + if upstream == 0: + raise ConfigError('At least 1 upstream interface is required!') + elif upstream > 1: + raise ConfigError('Only 1 upstream interface allowed!') + + return None + +def generate(igmp_proxy): + # bail out early - looks like removal from running config + if igmp_proxy is None: + return None + + # bail out early - service is disabled, but inform user + if igmp_proxy['disable']: + print('Warning: IGMP Proxy will be deactivated because it is disabled') + return None + + render(config_file, 'igmp-proxy/igmpproxy.conf.tmpl', igmp_proxy) + return None + +def apply(igmp_proxy): + if igmp_proxy is None or igmp_proxy['disable']: + # IGMP Proxy support is removed in the commit + call('systemctl stop igmpproxy.service') + if os.path.exists(config_file): + os.unlink(config_file) + else: + call('systemctl restart igmpproxy.service') + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/intel_qat.py b/src/conf_mode/intel_qat.py new file mode 100755 index 000000000..742f09a54 --- /dev/null +++ b/src/conf_mode/intel_qat.py @@ -0,0 +1,103 @@ +#!/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 re + +from vyos.config import Config +from vyos import ConfigError +from vyos.util import popen, run + +from vyos import airbag +airbag.enable() + +# Define for recovering +gl_ipsec_conf = None + +def get_config(): + c = Config() + config_data = { + 'qat_conf' : None, + 'ipsec_conf' : None, + 'openvpn_conf' : None, + } + + if c.exists('system acceleration qat'): + config_data['qat_conf'] = True + + if c.exists('vpn ipsec '): + gl_ipsec_conf = True + config_data['ipsec_conf'] = True + + if c.exists('interfaces openvpn'): + config_data['openvpn_conf'] = True + + return config_data + +# Control configured VPN service which can use QAT +def vpn_control(action): + # XXX: Should these commands report failure + if action == 'restore' and gl_ipsec_conf: + return run('ipsec start') + return run(f'ipsec {action}') + +def verify(c): + # Check if QAT service installed + if not os.path.exists('/etc/init.d/qat_service'): + raise ConfigError("Warning: QAT init file not found") + + if c['qat_conf'] == None: + return + + # Check if QAT device exist + output, err = popen('lspci -nn', decode='utf-8') + if not err: + data = re.findall('(8086:19e2)|(8086:37c8)|(8086:0435)|(8086:6f54)', output) + #If QAT devices found + if not data: + print("\t No QAT acceleration device found") + sys.exit(1) + +def apply(c): + if c['ipsec_conf']: + # Shutdown VPN service which can use QAT + vpn_control('stop') + + # Disable QAT service + if c['qat_conf'] == None: + run('/etc/init.d/qat_service stop') + if c['ipsec_conf']: + vpn_control('start') + return + + # Run qat init.d script + run('/etc/init.d/qat_service start') + if c['ipsec_conf']: + # Recovery VPN service + vpn_control('start') + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + apply(c) + except ConfigError as e: + print(e) + vpn_control('restore') + sys.exit(1) diff --git a/src/conf_mode/interfaces-bonding.py b/src/conf_mode/interfaces-bonding.py new file mode 100755 index 000000000..3b238f1ea --- /dev/null +++ b/src/conf_mode/interfaces-bonding.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2020 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 + +from sys import exit +from netifaces import interfaces + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configdict import leaf_node_changed +from vyos.configverify import verify_address +from vyos.configverify import verify_bridge_delete +from vyos.configverify import verify_dhcpv6 +from vyos.configverify import verify_source_interface +from vyos.configverify import verify_vlan_config +from vyos.configverify import verify_vrf +from vyos.ifconfig import BondIf +from vyos.validate import is_member +from vyos.validate import has_address_configured +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +def get_bond_mode(mode): + if mode == 'round-robin': + return 'balance-rr' + elif mode == 'active-backup': + return 'active-backup' + elif mode == 'xor-hash': + return 'balance-xor' + elif mode == 'broadcast': + return 'broadcast' + elif mode == '802.3ad': + return '802.3ad' + elif mode == 'transmit-load-balance': + return 'balance-tlb' + elif mode == 'adaptive-load-balance': + return 'balance-alb' + else: + raise ConfigError(f'invalid bond mode "{mode}"') + +def get_config(): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + interface name will be added or a deleted flag + """ + conf = Config() + base = ['interfaces', 'bonding'] + bond = get_interface_dict(conf, base) + + # To make our own life easier transfor the list of member interfaces + # into a dictionary - we will use this to add additional information + # later on for wach member + if 'member' in bond and 'interface' in bond['member']: + # first convert it to a list if only one member is given + if isinstance(bond['member']['interface'], str): + bond['member']['interface'] = [bond['member']['interface']] + + tmp={} + for interface in bond['member']['interface']: + tmp.update({interface: {}}) + + bond['member']['interface'] = tmp + + if 'mode' in bond: + bond['mode'] = get_bond_mode(bond['mode']) + + tmp = leaf_node_changed(conf, ['mode']) + if tmp: + bond.update({'shutdown_required': ''}) + + # determine which members have been removed + tmp = leaf_node_changed(conf, ['member', 'interface']) + if tmp: + bond.update({'shutdown_required': ''}) + if 'member' in bond: + bond['member'].update({'interface_remove': tmp }) + else: + bond.update({'member': {'interface_remove': tmp }}) + + if 'member' in bond and 'interface' in bond['member']: + for interface, interface_config in bond['member']['interface'].items(): + # Check if we are a member of another bond device + tmp = is_member(conf, interface, 'bridge') + if tmp: + interface_config.update({'is_bridge_member' : tmp}) + + # Check if we are a member of a bond device + tmp = is_member(conf, interface, 'bonding') + if tmp and tmp != bond['ifname']: + interface_config.update({'is_bond_member' : tmp}) + + # bond members must not have an assigned address + tmp = has_address_configured(conf, interface) + if tmp: + interface_config.update({'has_address' : ''}) + + return bond + + +def verify(bond): + if 'deleted' in bond: + verify_bridge_delete(bond) + return None + + if 'arp_monitor' in bond: + if 'target' in bond['arp_monitor'] and len(int(bond['arp_monitor']['target'])) > 16: + raise ConfigError('The maximum number of arp-monitor targets is 16') + + if 'interval' in bond['arp_monitor'] and len(int(bond['arp_monitor']['interval'])) > 0: + if bond['mode'] in ['802.3ad', 'balance-tlb', 'balance-alb']: + raise ConfigError('ARP link monitoring does not work for mode 802.3ad, ' \ + 'transmit-load-balance or adaptive-load-balance') + + if 'primary' in bond: + if bond['mode'] not in ['active-backup', 'balance-tlb', 'balance-alb']: + raise ConfigError('Option primary - mode dependency failed, not' + 'supported in mode {mode}!'.format(**bond)) + + verify_address(bond) + verify_dhcpv6(bond) + verify_vrf(bond) + + # use common function to verify VLAN configuration + verify_vlan_config(bond) + + bond_name = bond['ifname'] + if 'member' in bond: + member = bond.get('member') + for interface, interface_config in member.get('interface', {}).items(): + error_msg = f'Can not add interface "{interface}" to bond "{bond_name}", ' + + if interface == 'lo': + raise ConfigError('Loopback interface "lo" can not be added to a bond') + + if interface not in interfaces(): + raise ConfigError(error_msg + 'it does not exist!') + + if 'is_bridge_member' in interface_config: + tmp = interface_config['is_bridge_member'] + raise ConfigError(error_msg + f'it is already a member of bridge "{tmp}"!') + + if 'is_bond_member' in interface_config: + tmp = interface_config['is_bond_member'] + raise ConfigError(error_msg + f'it is already a member of bond "{tmp}"!') + + if 'has_address' in interface_config: + raise ConfigError(error_msg + 'it has an address assigned!') + + + if 'primary' in bond: + if bond['primary'] not in bond['member']['interface']: + raise ConfigError(f'Primary interface of bond "{bond_name}" must be a member interface') + + if bond['mode'] not in ['active-backup', 'balance-tlb', 'balance-alb']: + raise ConfigError('primary interface only works for mode active-backup, ' \ + 'transmit-load-balance or adaptive-load-balance') + + return None + +def generate(bond): + return None + +def apply(bond): + b = BondIf(bond['ifname']) + + if 'deleted' in bond: + # delete interface + b.remove() + else: + b.update(bond) + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/interfaces-bridge.py b/src/conf_mode/interfaces-bridge.py new file mode 100755 index 000000000..ee8e85e73 --- /dev/null +++ b/src/conf_mode/interfaces-bridge.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2020 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 + +from sys import exit +from netifaces import interfaces + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configdict import node_changed +from vyos.configverify import verify_dhcpv6 +from vyos.configverify import verify_vrf +from vyos.ifconfig import BridgeIf +from vyos.validate import is_member, has_address_configured +from vyos.xml import defaults + +from vyos.util import cmd +from vyos import ConfigError + +from vyos import airbag +airbag.enable() + +def get_config(): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + interface name will be added or a deleted flag + """ + conf = Config() + base = ['interfaces', 'bridge'] + bridge = get_interface_dict(conf, base) + + # determine which members have been removed + tmp = node_changed(conf, ['member', 'interface']) + if tmp: + if 'member' in bridge: + bridge['member'].update({'interface_remove': tmp }) + else: + bridge.update({'member': {'interface_remove': tmp }}) + + if 'member' in bridge and 'interface' in bridge['member']: + # XXX TT2665 we need a copy of the dict keys for iteration, else we will get: + # RuntimeError: dictionary changed size during iteration + for interface in list(bridge['member']['interface']): + for key in ['cost', 'priority']: + if interface == key: + del bridge['member']['interface'][key] + continue + + # the default dictionary is not properly paged into the dict (see T2665) + # thus we will ammend it ourself + default_member_values = defaults(base + ['member', 'interface']) + for interface, interface_config in bridge['member']['interface'].items(): + interface_config.update(default_member_values) + + # Check if we are a member of another bridge device + tmp = is_member(conf, interface, 'bridge') + if tmp and tmp != bridge['ifname']: + interface_config.update({'is_bridge_member' : tmp}) + + # Check if we are a member of a bond device + tmp = is_member(conf, interface, 'bonding') + if tmp: + interface_config.update({'is_bond_member' : tmp}) + + # Bridge members must not have an assigned address + tmp = has_address_configured(conf, interface) + if tmp: + interface_config.update({'has_address' : ''}) + + return bridge + +def verify(bridge): + if 'deleted' in bridge: + return None + + verify_dhcpv6(bridge) + verify_vrf(bridge) + + if 'member' in bridge: + member = bridge.get('member') + bridge_name = bridge['ifname'] + for interface, interface_config in member.get('interface', {}).items(): + error_msg = f'Can not add interface "{interface}" to bridge "{bridge_name}", ' + + if interface == 'lo': + raise ConfigError('Loopback interface "lo" can not be added to a bridge') + + if interface not in interfaces(): + raise ConfigError(error_msg + 'it does not exist!') + + if 'is_bridge_member' in interface_config: + tmp = interface_config['is_bridge_member'] + raise ConfigError(error_msg + f'it is already a member of bridge "{tmp}"!') + + if 'is_bond_member' in interface_config: + tmp = interface_config['is_bond_member'] + raise ConfigError(error_msg + f'it is already a member of bond "{tmp}"!') + + if 'has_address' in interface_config: + raise ConfigError(error_msg + 'it has an address assigned!') + + return None + +def generate(bridge): + return None + +def apply(bridge): + br = BridgeIf(bridge['ifname']) + if 'deleted' in bridge: + # delete interface + br.remove() + else: + br.update(bridge) + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/interfaces-dummy.py b/src/conf_mode/interfaces-dummy.py new file mode 100755 index 000000000..8df86c8ea --- /dev/null +++ b/src/conf_mode/interfaces-dummy.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2020 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 + +from sys import exit + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configverify import verify_vrf +from vyos.configverify import verify_address +from vyos.configverify import verify_bridge_delete +from vyos.ifconfig import DummyIf +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +def get_config(): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + interface name will be added or a deleted flag + """ + conf = Config() + base = ['interfaces', 'dummy'] + dummy = get_interface_dict(conf, base) + return dummy + +def verify(dummy): + if 'deleted' in dummy.keys(): + verify_bridge_delete(dummy) + return None + + verify_vrf(dummy) + verify_address(dummy) + + return None + +def generate(dummy): + return None + +def apply(dummy): + d = DummyIf(dummy['ifname']) + + # Remove dummy interface + if 'deleted' in dummy.keys(): + d.remove() + else: + d.update(dummy) + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/interfaces-ethernet.py b/src/conf_mode/interfaces-ethernet.py new file mode 100755 index 000000000..10758e35a --- /dev/null +++ b/src/conf_mode/interfaces-ethernet.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2020 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 + +from sys import exit + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configverify import verify_interface_exists +from vyos.configverify import verify_dhcpv6 +from vyos.configverify import verify_address +from vyos.configverify import verify_vrf +from vyos.configverify import verify_vlan_config +from vyos.ifconfig import EthernetIf +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +def get_config(): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + interface name will be added or a deleted flag + """ + conf = Config() + base = ['interfaces', 'ethernet'] + ethernet = get_interface_dict(conf, base) + return ethernet + +def verify(ethernet): + if 'deleted' in ethernet: + return None + + verify_interface_exists(ethernet) + + if ethernet.get('speed', None) == 'auto': + if ethernet.get('duplex', None) != 'auto': + raise ConfigError('If speed is hardcoded, duplex must be hardcoded, too') + + if ethernet.get('duplex', None) == 'auto': + if ethernet.get('speed', None) != 'auto': + raise ConfigError('If duplex is hardcoded, speed must be hardcoded, too') + + verify_dhcpv6(ethernet) + verify_address(ethernet) + verify_vrf(ethernet) + + if {'is_bond_member', 'mac'} <= set(ethernet): + print(f'WARNING: changing mac address "{mac}" will be ignored as "{ifname}" ' + f'is a member of bond "{is_bond_member}"'.format(**ethernet)) + + # use common function to verify VLAN configuration + verify_vlan_config(ethernet) + return None + +def generate(ethernet): + return None + +def apply(ethernet): + e = EthernetIf(ethernet['ifname']) + if 'deleted' in ethernet: + # delete interface + e.remove() + else: + e.update(ethernet) + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/interfaces-geneve.py b/src/conf_mode/interfaces-geneve.py new file mode 100755 index 000000000..1104bd3c0 --- /dev/null +++ b/src/conf_mode/interfaces-geneve.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2020 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 + +from sys import exit +from copy import deepcopy +from netifaces import interfaces + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configverify import verify_address +from vyos.configverify import verify_bridge_delete +from vyos.ifconfig import GeneveIf +from vyos import ConfigError + +from vyos import airbag +airbag.enable() + +def get_config(): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + interface name will be added or a deleted flag + """ + conf = Config() + base = ['interfaces', 'geneve'] + geneve = get_interface_dict(conf, base) + return geneve + +def verify(geneve): + if 'deleted' in geneve: + verify_bridge_delete(geneve) + return None + + verify_address(geneve) + + if 'remote' not in geneve: + raise ConfigError('Remote side must be configured') + + if 'vni' not in geneve: + raise ConfigError('VNI must be configured') + + return None + + +def generate(geneve): + return None + + +def apply(geneve): + # Check if GENEVE interface already exists + if geneve['ifname'] in interfaces(): + g = GeneveIf(geneve['ifname']) + # GENEVE is super picky and the tunnel always needs to be recreated, + # thus we can simply always delete it first. + g.remove() + + if 'deleted' not in geneve: + # GENEVE interface needs to be created on-block + # instead of passing a ton of arguments, I just use a dict + # that is managed by vyos.ifconfig + conf = deepcopy(GeneveIf.get_config()) + + # Assign GENEVE instance configuration parameters to config dict + conf['vni'] = geneve['vni'] + conf['remote'] = geneve['remote'] + + # Finally create the new interface + g = GeneveIf(geneve['ifname'], **conf) + g.update(geneve) + + return None + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/interfaces-l2tpv3.py b/src/conf_mode/interfaces-l2tpv3.py new file mode 100755 index 000000000..0978df5b6 --- /dev/null +++ b/src/conf_mode/interfaces-l2tpv3.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2020 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 + +from sys import exit +from copy import deepcopy +from netifaces import interfaces + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configdict import leaf_node_changed +from vyos.configverify import verify_address +from vyos.configverify import verify_bridge_delete +from vyos.ifconfig import L2TPv3If +from vyos.util import check_kmod +from vyos.validate import is_addr_assigned +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +k_mod = ['l2tp_eth', 'l2tp_netlink', 'l2tp_ip', 'l2tp_ip6'] + + +def get_config(): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + interface name will be added or a deleted flag + """ + conf = Config() + base = ['interfaces', 'l2tpv3'] + l2tpv3 = get_interface_dict(conf, base) + + # L2TPv3 is "special" the default MTU is 1488 - update accordingly + # as the config_level is already st in get_interface_dict() - we can use [] + tmp = conf.get_config_dict([], key_mangling=('-', '_'), get_first_key=True) + if 'mtu' not in tmp: + l2tpv3['mtu'] = '1488' + + # To delete an l2tpv3 interface we need the current tunnel and session-id + if 'deleted' in l2tpv3: + tmp = leaf_node_changed(conf, ['tunnel-id']) + l2tpv3.update({'tunnel_id': tmp}) + + tmp = leaf_node_changed(conf, ['session-id']) + l2tpv3.update({'session_id': tmp}) + + return l2tpv3 + +def verify(l2tpv3): + if 'deleted' in l2tpv3: + verify_bridge_delete(l2tpv3) + return None + + interface = l2tpv3['ifname'] + + for key in ['local_ip', 'remote_ip', 'tunnel_id', 'peer_tunnel_id', + 'session_id', 'peer_session_id']: + if key not in l2tpv3: + tmp = key.replace('_', '-') + raise ConfigError(f'L2TPv3 {tmp} must be configured!') + + if not is_addr_assigned(l2tpv3['local_ip']): + raise ConfigError('L2TPv3 local-ip address ' + '"{local_ip}" is not configured!'.format(**l2tpv3)) + + verify_address(l2tpv3) + return None + +def generate(l2tpv3): + return None + +def apply(l2tpv3): + # L2TPv3 interface needs to be created/deleted on-block, instead of + # passing a ton of arguments, I just use a dict that is managed by + # vyos.ifconfig + conf = deepcopy(L2TPv3If.get_config()) + + # Check if L2TPv3 interface already exists + if l2tpv3['ifname'] in interfaces(): + # L2TPv3 is picky when changing tunnels/sessions, thus we can simply + # always delete it first. + conf['session_id'] = l2tpv3['session_id'] + conf['tunnel_id'] = l2tpv3['tunnel_id'] + l = L2TPv3If(l2tpv3['ifname'], **conf) + l.remove() + + if 'deleted' not in l2tpv3: + conf['peer_tunnel_id'] = l2tpv3['peer_tunnel_id'] + conf['local_port'] = l2tpv3['source_port'] + conf['remote_port'] = l2tpv3['destination_port'] + conf['encapsulation'] = l2tpv3['encapsulation'] + conf['local_address'] = l2tpv3['local_ip'] + conf['remote_address'] = l2tpv3['remote_ip'] + conf['session_id'] = l2tpv3['session_id'] + conf['tunnel_id'] = l2tpv3['tunnel_id'] + conf['peer_session_id'] = l2tpv3['peer_session_id'] + + # Finally create the new interface + l = L2TPv3If(l2tpv3['ifname'], **conf) + l.update(l2tpv3) + + return None + +if __name__ == '__main__': + try: + check_kmod(k_mod) + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/interfaces-loopback.py b/src/conf_mode/interfaces-loopback.py new file mode 100755 index 000000000..0398cd591 --- /dev/null +++ b/src/conf_mode/interfaces-loopback.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2020 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 + +from sys import exit + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.ifconfig import LoopbackIf +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +def get_config(): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + interface name will be added or a deleted flag + """ + conf = Config() + base = ['interfaces', 'loopback'] + loopback = get_interface_dict(conf, base) + return loopback + +def verify(loopback): + return None + +def generate(loopback): + return None + +def apply(loopback): + l = LoopbackIf(loopback['ifname']) + if 'deleted' in loopback.keys(): + l.remove() + else: + l.update(loopback) + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/interfaces-macsec.py b/src/conf_mode/interfaces-macsec.py new file mode 100755 index 000000000..ca15212d4 --- /dev/null +++ b/src/conf_mode/interfaces-macsec.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2020 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 + +from copy import deepcopy +from sys import exit + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.ifconfig import MACsecIf +from vyos.template import render +from vyos.util import call +from vyos.configverify import verify_vrf +from vyos.configverify import verify_address +from vyos.configverify import verify_bridge_delete +from vyos.configverify import verify_source_interface +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +# XXX: wpa_supplicant works on the source interface +wpa_suppl_conf = '/run/wpa_supplicant/{source_interface}.conf' + +def get_config(): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + interface name will be added or a deleted flag + """ + conf = Config() + base = ['interfaces', 'macsec'] + macsec = get_interface_dict(conf, base) + + # Check if interface has been removed + if 'deleted' in macsec: + source_interface = conf.return_effective_value( + base + ['source-interface']) + macsec.update({'source_interface': source_interface}) + + return macsec + + +def verify(macsec): + if 'deleted' in macsec: + verify_bridge_delete(macsec) + return None + + verify_source_interface(macsec) + verify_vrf(macsec) + verify_address(macsec) + + if not (('security' in macsec) and + ('cipher' in macsec['security'])): + raise ConfigError( + 'Cipher suite must be set for MACsec "{ifname}"'.format(**macsec)) + + if (('security' in macsec) and + ('encrypt' in macsec['security'])): + tmp = macsec.get('security') + + if not (('mka' in tmp) and + ('cak' in tmp['mka']) and + ('ckn' in tmp['mka'])): + raise ConfigError('Missing mandatory MACsec security ' + 'keys as encryption is enabled!') + + return None + + +def generate(macsec): + render(wpa_suppl_conf.format(**macsec), + 'macsec/wpa_supplicant.conf.tmpl', macsec) + return None + + +def apply(macsec): + # Remove macsec interface + if 'deleted' in macsec.keys(): + call('systemctl stop wpa_supplicant-macsec@{source_interface}' + .format(**macsec)) + + MACsecIf(macsec['ifname']).remove() + + # delete configuration on interface removal + if os.path.isfile(wpa_suppl_conf.format(**macsec)): + os.unlink(wpa_suppl_conf.format(**macsec)) + + else: + # MACsec interfaces require a configuration when they are added using + # iproute2. This static method will provide the configuration + # dictionary used by this class. + + # XXX: subject of removal after completing T2653 + conf = deepcopy(MACsecIf.get_config()) + conf['source_interface'] = macsec['source_interface'] + conf['security_cipher'] = macsec['security']['cipher'] + + # It is safe to "re-create" the interface always, there is a sanity + # check that the interface will only be create if its non existent + i = MACsecIf(macsec['ifname'], **conf) + i.update(macsec) + + call('systemctl restart wpa_supplicant-macsec@{source_interface}' + .format(**macsec)) + + return None + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/interfaces-openvpn.py b/src/conf_mode/interfaces-openvpn.py new file mode 100755 index 000000000..1420b4116 --- /dev/null +++ b/src/conf_mode/interfaces-openvpn.py @@ -0,0 +1,1116 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2020 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 + +from copy import deepcopy +from sys import exit,stderr +from ipaddress import ip_address,ip_network,IPv4Address,IPv4Network,IPv6Address,IPv6Network,summarize_address_range +from netifaces import interfaces +from time import sleep +from shutil import rmtree + +from vyos.config import Config +from vyos.configdict import list_diff +from vyos.ifconfig import VTunIf +from vyos.template import render +from vyos.util import call, chown, chmod_600, chmod_755 +from vyos.validate import is_addr_assigned, is_member, is_ipv4 +from vyos import ConfigError + +from vyos import airbag +airbag.enable() + +user = 'openvpn' +group = 'openvpn' + +default_config_data = { + 'address': [], + 'auth_user': '', + 'auth_pass': '', + 'auth_user_pass_file': '', + 'auth': False, + 'compress_lzo': False, + 'deleted': False, + 'description': '', + 'disable': False, + 'disable_ncp': False, + 'encryption': '', + 'hash': '', + 'intf': '', + 'ipv6_accept_ra': 1, + 'ipv6_autoconf': 0, + 'ipv6_eui64_prefix': [], + 'ipv6_eui64_prefix_remove': [], + 'ipv6_forwarding': 1, + 'ipv6_dup_addr_detect': 1, + 'ipv6_local_address': [], + 'ipv6_remote_address': [], + 'is_bridge_member': False, + 'ping_restart': '60', + 'ping_interval': '10', + 'local_address': [], + 'local_address_subnet': '', + 'local_host': '', + 'local_port': '', + 'mode': '', + 'ncp_ciphers': '', + 'options': [], + 'persistent_tunnel': False, + 'protocol': 'udp', + 'protocol_real': '', + 'redirect_gateway': '', + 'remote_address': [], + 'remote_host': [], + 'remote_port': '', + 'client': [], + 'server_domain': '', + 'server_max_conn': '', + 'server_dns_nameserver': [], + 'server_pool': True, + 'server_pool_start': '', + 'server_pool_stop': '', + 'server_pool_netmask': '', + 'server_push_route': [], + 'server_reject_unconfigured': False, + 'server_subnet': [], + 'server_topology': '', + 'server_ipv6_dns_nameserver': [], + 'server_ipv6_local': '', + 'server_ipv6_prefixlen': '', + 'server_ipv6_remote': '', + 'server_ipv6_pool': True, + 'server_ipv6_pool_base': '', + 'server_ipv6_pool_prefixlen': '', + 'server_ipv6_push_route': [], + 'server_ipv6_subnet': [], + 'shared_secret_file': '', + 'tls': False, + 'tls_auth': '', + 'tls_ca_cert': '', + 'tls_cert': '', + 'tls_crl': '', + 'tls_dh': '', + 'tls_key': '', + 'tls_crypt': '', + 'tls_role': '', + 'tls_version_min': '', + 'type': 'tun', + 'uid': user, + 'gid': group, + 'vrf': '' +} + + +def get_config_name(intf): + cfg_file = f'/run/openvpn/{intf}.conf' + return cfg_file + + +def checkCertHeader(header, filename): + """ + Verify if filename contains specified header. + Returns True if match is found, False if no match or file is not found + """ + if not os.path.isfile(filename): + return False + + with open(filename, 'r') as f: + for line in f: + if re.match(header, line): + return True + + return False + +def getDefaultServer(network, topology, devtype): + """ + Gets the default server parameters for a IPv4 "server" directive. + Logic from openvpn's src/openvpn/helper.c. + Returns a dict with addresses or False if the input parameters were incorrect. + """ + if not (devtype == 'tun' or devtype == 'tap'): + return False + + if not network.version == 4: + return False + elif (devtype == 'tun' and network.prefixlen > 29) or (devtype == 'tap' and network.prefixlen > 30): + return False + + server = { + 'local': '', + 'remote_netmask': '', + 'client_remote_netmask': '', + 'pool_start': '', + 'pool_stop': '', + 'pool_netmask': '' + } + + if devtype == 'tun': + if topology == 'net30' or topology == 'point-to-point': + server['local'] = network[1] + server['remote_netmask'] = network[2] + server['client_remote_netmask'] = server['local'] + + # pool start is 4th host IP in subnet (.4 in a /24) + server['pool_start'] = network[4] + + if network.prefixlen == 29: + server['pool_stop'] = network.broadcast_address + else: + # pool end is -4 from the broadcast address (.251 in a /24) + server['pool_stop'] = network[-5] + + elif topology == 'subnet': + server['local'] = network[1] + server['remote_netmask'] = str(network.netmask) + server['client_remote_netmask'] = server['remote_netmask'] + server['pool_start'] = network[2] + server['pool_stop'] = network[-3] + server['pool_netmask'] = server['remote_netmask'] + + elif devtype == 'tap': + server['local'] = network[1] + server['remote_netmask'] = str(network.netmask) + server['client_remote_netmask'] = server['remote_netmask'] + server['pool_start'] = network[2] + server['pool_stop'] = network[-2] + server['pool_netmask'] = server['remote_netmask'] + + return server + +def get_config(): + openvpn = deepcopy(default_config_data) + conf = Config() + + # determine tagNode instance + if 'VYOS_TAGNODE_VALUE' not in os.environ: + raise ConfigError('Interface (VYOS_TAGNODE_VALUE) not specified') + + openvpn['intf'] = os.environ['VYOS_TAGNODE_VALUE'] + openvpn['auth_user_pass_file'] = f"/run/openvpn/{openvpn['intf']}.pw" + + # check if interface is member of a bridge + openvpn['is_bridge_member'] = is_member(conf, openvpn['intf'], 'bridge') + + # Check if interface instance has been removed + if not conf.exists('interfaces openvpn ' + openvpn['intf']): + openvpn['deleted'] = True + return openvpn + + # bridged server should not have a pool by default (but can be specified manually) + if openvpn['is_bridge_member']: + openvpn['server_pool'] = False + openvpn['server_ipv6_pool'] = False + + # 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 cipher + if conf.exists('encryption cipher'): + openvpn['encryption'] = conf.return_value('encryption cipher') + + # disable ncp-ciphers support + if conf.exists('encryption disable-ncp'): + openvpn['disable_ncp'] = True + + # data encryption algorithm ncp-list + if conf.exists('encryption ncp-ciphers'): + _ncp_ciphers = [] + for enc in conf.return_values('encryption ncp-ciphers'): + if enc == 'des': + _ncp_ciphers.append('des-cbc') + _ncp_ciphers.append('DES-CBC') + elif enc == '3des': + _ncp_ciphers.append('des-ede3-cbc') + _ncp_ciphers.append('DES-EDE3-CBC') + elif enc == 'aes128': + _ncp_ciphers.append('aes-128-cbc') + _ncp_ciphers.append('AES-128-CBC') + elif enc == 'aes128gcm': + _ncp_ciphers.append('aes-128-gcm') + _ncp_ciphers.append('AES-128-GCM') + elif enc == 'aes192': + _ncp_ciphers.append('aes-192-cbc') + _ncp_ciphers.append('AES-192-CBC') + elif enc == 'aes192gcm': + _ncp_ciphers.append('aes-192-gcm') + _ncp_ciphers.append('AES-192-GCM') + elif enc == 'aes256': + _ncp_ciphers.append('aes-256-cbc') + _ncp_ciphers.append('AES-256-CBC') + elif enc == 'aes256gcm': + _ncp_ciphers.append('aes-256-gcm') + _ncp_ciphers.append('AES-256-GCM') + openvpn['ncp_ciphers'] = ':'.join(_ncp_ciphers) + + # 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'): + for tmp in conf.list_nodes('local-address'): + tmp_ip = ip_address(tmp) + if tmp_ip.version == 4: + openvpn['local_address'].append(tmp) + if conf.exists('local-address {} subnet-mask'.format(tmp)): + openvpn['local_address_subnet'] = conf.return_value('local-address {} subnet-mask'.format(tmp)) + elif tmp_ip.version == 6: + # input IPv6 address could be expanded so get the compressed version + openvpn['ipv6_local_address'].append(str(tmp_ip)) + + # 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') + + # Enable acquisition of IPv6 address using stateless autoconfig (SLAAC) + if conf.exists('ipv6 address autoconf'): + openvpn['ipv6_autoconf'] = 1 + + # Get prefixes for IPv6 addressing based on MAC address (EUI-64) + if conf.exists('ipv6 address eui64'): + openvpn['ipv6_eui64_prefix'] = conf.return_values('ipv6 address eui64') + + # Determine currently effective EUI64 addresses - to determine which + # address is no longer valid and needs to be removed + eff_addr = conf.return_effective_values('ipv6 address eui64') + openvpn['ipv6_eui64_prefix_remove'] = list_diff(eff_addr, openvpn['ipv6_eui64_prefix']) + + # Remove the default link-local address if set. + if conf.exists('ipv6 address no-default-link-local'): + openvpn['ipv6_eui64_prefix_remove'].append('fe80::/64') + else: + # add the link-local by default to make IPv6 work + openvpn['ipv6_eui64_prefix'].append('fe80::/64') + + # Disable IPv6 forwarding on this interface + if conf.exists('ipv6 disable-forwarding'): + openvpn['ipv6_forwarding'] = 0 + + # IPv6 Duplicate Address Detection (DAD) tries + if conf.exists('ipv6 dup-addr-detect-transmits'): + openvpn['ipv6_dup_addr_detect'] = int(conf.return_value('ipv6 dup-addr-detect-transmits')) + + # to make IPv6 SLAAC and DHCPv6 work with forwarding=1, + # accept_ra must be 2 + if openvpn['ipv6_autoconf'] or 'dhcpv6' in openvpn['address']: + openvpn['ipv6_accept_ra'] = 2 + + # OpenVPN operation mode + if conf.exists('mode'): + openvpn['mode'] = conf.return_value('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'): + for tmp in conf.return_values('remote-address'): + tmp_ip = ip_address(tmp) + if tmp_ip.version == 4: + openvpn['remote_address'].append(tmp) + elif tmp_ip.version == 6: + openvpn['ipv6_remote_address'].append(str(tmp_ip)) + + # 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' + + # 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) + server_network_v4 = None + server_network_v6 = None + if conf.exists('server subnet'): + for tmp in conf.return_values('server subnet'): + tmp_ip = ip_network(tmp) + if tmp_ip.version == 4: + server_network_v4 = tmp_ip + # convert the network to format: "192.0.2.0 255.255.255.0" for later use in template + openvpn['server_subnet'].append(tmp_ip.with_netmask.replace(r'/', ' ')) + elif tmp_ip.version == 6: + server_network_v6 = tmp_ip + openvpn['server_ipv6_subnet'].append(str(tmp_ip)) + + # 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': [], + 'ipv6_ip': [], + 'ipv6_remote': '', + 'ipv6_push_route': [], + 'ipv6_subnet': [], + 'push_route': [], + 'subnet': [], + 'remote_netmask': '' + } + + # Option to disable client connection + if conf.exists('disable'): + data['disable'] = True + + # IP address of the client + for tmp in conf.return_values('ip'): + tmp_ip = ip_address(tmp) + if tmp_ip.version == 4: + data['ip'].append(tmp) + elif tmp_ip.version == 6: + data['ipv6_ip'].append(str(tmp_ip)) + + # Route to be pushed to the client + for tmp in conf.return_values('push-route'): + tmp_ip = ip_network(tmp) + if tmp_ip.version == 4: + data['push_route'].append(tmp_ip.with_netmask.replace(r'/', ' ')) + elif tmp_ip.version == 6: + data['ipv6_push_route'].append(str(tmp_ip)) + + # Subnet belonging to the client + for tmp in conf.return_values('subnet'): + tmp_ip = ip_network(tmp) + if tmp_ip.version == 4: + data['subnet'].append(tmp_ip.with_netmask.replace(r'/', ' ')) + elif tmp_ip.version == 6: + data['ipv6_subnet'].append(str(tmp_ip)) + + # Append to global client list + openvpn['client'].append(data) + + # re-set configuration level + conf.set_level('interfaces openvpn ' + openvpn['intf']) + + # Server client IP pool + if conf.exists('server client-ip-pool'): + conf.set_level('interfaces openvpn ' + openvpn['intf'] + ' server client-ip-pool') + + # enable or disable server_pool where necessary + # default is enabled, or disabled in bridge mode + openvpn['server_pool'] = not conf.exists('disable') + + if conf.exists('start'): + openvpn['server_pool_start'] = conf.return_value('start') + + if conf.exists('stop'): + openvpn['server_pool_stop'] = conf.return_value('stop') + + if conf.exists('netmask'): + openvpn['server_pool_netmask'] = conf.return_value('netmask') + + conf.set_level('interfaces openvpn ' + openvpn['intf']) + + # Server client IPv6 pool + if conf.exists('server client-ipv6-pool'): + conf.set_level('interfaces openvpn ' + openvpn['intf'] + ' server client-ipv6-pool') + openvpn['server_ipv6_pool'] = not conf.exists('disable') + if conf.exists('base'): + tmp = conf.return_value('base').split('/') + openvpn['server_ipv6_pool_base'] = str(IPv6Address(tmp[0])) + if 1 < len(tmp): + openvpn['server_ipv6_pool_prefixlen'] = tmp[1] + + 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'): + for tmp in conf.return_values('server name-server'): + tmp_ip = ip_address(tmp) + if tmp_ip.version == 4: + openvpn['server_dns_nameserver'].append(tmp) + elif tmp_ip.version == 6: + openvpn['server_ipv6_dns_nameserver'].append(str(tmp_ip)) + + # Route to be pushed to all clients + if conf.exists('server push-route'): + for tmp in conf.return_values('server push-route'): + tmp_ip = ip_network(tmp) + if tmp_ip.version == 4: + openvpn['server_push_route'].append(tmp_ip.with_netmask.replace(r'/', ' ')) + elif tmp_ip.version == 6: + openvpn['server_ipv6_push_route'].append(str(tmp_ip)) + + # Reject connections from clients that are not explicitly configured + if conf.exists('server reject-unconfigured-clients'): + openvpn['server_reject_unconfigured'] = True + + # File containing TLS auth static key + if conf.exists('tls auth-file'): + openvpn['tls_auth'] = conf.return_value('tls auth-file') + openvpn['tls'] = 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 + + # File containing key to encrypt control channel packets + if conf.exists('tls crypt-file'): + openvpn['tls_crypt'] = conf.return_value('tls crypt-file') + openvpn['tls'] = True + + # Role in TLS negotiation + if conf.exists('tls role'): + openvpn['tls_role'] = conf.return_value('tls role') + openvpn['tls'] = True + + # Minimum required TLS version + if conf.exists('tls tls-version-min'): + openvpn['tls_version_min'] = conf.return_value('tls tls-version-min') + 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 + + # Special case when using EC certificates: + # if key-file is EC and dh-file is unset, set tls_dh to 'none' + if not openvpn['tls_dh'] and openvpn['tls_key'] and checkCertHeader('-----BEGIN EC PRIVATE KEY-----', openvpn['tls_key']): + openvpn['tls_dh'] = 'none' + + # set default server topology to net30 + if openvpn['mode'] == 'server' and not openvpn['server_topology']: + openvpn['server_topology'] = 'net30' + + # Convert protocol to real protocol used by openvpn. + # To make openvpn listen on both IPv4 and IPv6 we must use *6 protocols + # (https://community.openvpn.net/openvpn/ticket/360), unless the local-host + # or each of the remote-host in client mode is IPv4 + # in which case it must use the standard protocols. + if openvpn['protocol'] == 'tcp-active': + openvpn['protocol_real'] = 'tcp6-client' + elif openvpn['protocol'] == 'tcp-passive': + openvpn['protocol_real'] = 'tcp6-server' + else: + openvpn['protocol_real'] = 'udp6' + + if ( is_ipv4(openvpn['local_host']) or + # in client mode test all the remotes instead + (openvpn['mode'] == 'client' and all([is_ipv4(h) for h in openvpn['remote_host']])) ): + # takes out the '6' + openvpn['protocol_real'] = openvpn['protocol_real'][:3] + openvpn['protocol_real'][4:] + + # Set defaults where necessary. + # If any of the input parameters are wrong, + # this will return False and no defaults will be set. + if server_network_v4 and openvpn['server_topology'] and openvpn['type']: + default_server = None + default_server = getDefaultServer(server_network_v4, openvpn['server_topology'], openvpn['type']) + if default_server: + # server-bridge doesn't require a pool so don't set defaults for it + if openvpn['server_pool'] and not openvpn['is_bridge_member']: + if not openvpn['server_pool_start']: + openvpn['server_pool_start'] = default_server['pool_start'] + + if not openvpn['server_pool_stop']: + openvpn['server_pool_stop'] = default_server['pool_stop'] + + if not openvpn['server_pool_netmask']: + openvpn['server_pool_netmask'] = default_server['pool_netmask'] + + for client in openvpn['client']: + client['remote_netmask'] = default_server['client_remote_netmask'] + + if server_network_v6: + if not openvpn['server_ipv6_local']: + openvpn['server_ipv6_local'] = server_network_v6[1] + if not openvpn['server_ipv6_prefixlen']: + openvpn['server_ipv6_prefixlen'] = server_network_v6.prefixlen + if not openvpn['server_ipv6_remote']: + openvpn['server_ipv6_remote'] = server_network_v6[2] + + if openvpn['server_ipv6_pool'] and server_network_v6.prefixlen < 112: + if not openvpn['server_ipv6_pool_base']: + openvpn['server_ipv6_pool_base'] = server_network_v6[0x1000] + if not openvpn['server_ipv6_pool_prefixlen']: + openvpn['server_ipv6_pool_prefixlen'] = openvpn['server_ipv6_prefixlen'] + + for client in openvpn['client']: + client['ipv6_remote'] = openvpn['server_ipv6_local'] + + if openvpn['redirect_gateway']: + openvpn['redirect_gateway'] += ' ipv6' + + # retrieve VRF instance + if conf.exists('vrf'): + openvpn['vrf'] = conf.return_value('vrf') + + return openvpn + +def verify(openvpn): + if openvpn['deleted']: + if openvpn['is_bridge_member']: + raise ConfigError(( + f'Cannot delete interface "{openvpn["intf"]}" as it is a ' + f'member of bridge "{openvpn["is_bridge_menber"]}"!')) + return None + + + if not openvpn['mode']: + raise ConfigError('Must specify OpenVPN operation mode') + + # Check if we have disabled ncp and at the same time specified ncp-ciphers + if openvpn['disable_ncp'] and openvpn['ncp_ciphers']: + raise ConfigError('Cannot specify both "encryption disable-ncp" and "encryption ncp-ciphers"') + # + # 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'] and openvpn['tls_dh'] != 'none': + raise ConfigError('Cannot specify "tls dh-file" in client mode') + + # + # OpenVPN site-to-site - VERIFY + # + if openvpn['mode'] == 'site-to-site': + if openvpn['ncp_ciphers']: + raise ConfigError('encryption ncp-ciphers cannot be specified in site-to-site mode, only server or client') + + if openvpn['mode'] == 'site-to-site' and not openvpn['is_bridge_member']: + if not (openvpn['local_address'] or openvpn['ipv6_local_address']): + raise ConfigError('Must specify "local-address" or add interface to bridge') + + if len(openvpn['local_address']) > 1 or len(openvpn['ipv6_local_address']) > 1: + raise ConfigError('Cannot specify more than 1 IPv4 and 1 IPv6 "local-address"') + + if len(openvpn['remote_address']) > 1 or len(openvpn['ipv6_remote_address']) > 1: + raise ConfigError('Cannot specify more than 1 IPv4 and 1 IPv6 "remote-address"') + + for host in openvpn['remote_host']: + if host in openvpn['remote_address'] or host in openvpn['ipv6_remote_address']: + raise ConfigError('"remote-address" cannot be the same as "remote-host"') + + if openvpn['local_address'] and not (openvpn['remote_address'] or openvpn['local_address_subnet']): + raise ConfigError('IPv4 "local-address" requires IPv4 "remote-address" or IPv4 "local-address subnet"') + + if openvpn['remote_address'] and not openvpn['local_address']: + raise ConfigError('IPv4 "remote-address" requires IPv4 "local-address"') + + if openvpn['ipv6_local_address'] and not openvpn['ipv6_remote_address']: + raise ConfigError('IPv6 "local-address" requires IPv6 "remote-address"') + + if openvpn['ipv6_remote_address'] and not openvpn['ipv6_local_address']: + raise ConfigError('IPv6 "remote-address" requires IPv6 "local-address"') + + if openvpn['type'] == 'tun': + if not (openvpn['remote_address'] or openvpn['ipv6_remote_address']): + raise ConfigError('Must specify "remote-address"') + + if ( (openvpn['local_address'] and openvpn['local_address'] == openvpn['remote_address']) or + (openvpn['ipv6_local_address'] and openvpn['ipv6_local_address'] == openvpn['ipv6_remote_address']) ): + raise ConfigError('"local-address" and "remote-address" cannot be the same') + + if openvpn['local_host'] in openvpn['local_address'] or openvpn['local_host'] in openvpn['ipv6_local_address']: + raise ConfigError('"local-address" cannot be the same as "local-host"') + + else: + # checks for client-server or site-to-site bridged + if openvpn['local_address'] or openvpn['ipv6_local_address'] or openvpn['remote_address'] or openvpn['ipv6_remote_address']: + raise ConfigError('Cannot specify "local-address" or "remote-address" in client-server or 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'] and not checkCertHeader('-----BEGIN EC PRIVATE KEY-----', openvpn['tls_key']): + raise ConfigError('Must specify "tls dh-file" when not using EC keys in server mode') + + if len(openvpn['server_subnet']) > 1 or len(openvpn['server_ipv6_subnet']) > 1: + raise ConfigError('Cannot specify more than 1 IPv4 and 1 IPv6 server subnet') + + for client in openvpn['client']: + 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 openvpn['server_subnet']: + subnet = IPv4Network(openvpn['server_subnet'][0].replace(' ', '/')) + + if openvpn['type'] == 'tun' and subnet.prefixlen > 29: + raise ConfigError('Server subnets smaller than /29 with device type "tun" are not supported') + elif openvpn['type'] == 'tap' and subnet.prefixlen > 30: + raise ConfigError('Server subnets smaller than /30 with device type "tap" are not supported') + + for client in openvpn['client']: + if client['ip'] and not IPv4Address(client['ip'][0]) in subnet: + raise ConfigError(f'Client "{client["name"]}" IP {client["ip"][0]} not in server subnet {subnet}') + + else: + if not openvpn['is_bridge_member']: + raise ConfigError('Must specify "server subnet" or add interface to bridge in server mode') + + if openvpn['server_pool']: + if not (openvpn['server_pool_start'] and openvpn['server_pool_stop']): + raise ConfigError('Server client-ip-pool requires both start and stop addresses in bridged mode') + else: + v4PoolStart = IPv4Address(openvpn['server_pool_start']) + v4PoolStop = IPv4Address(openvpn['server_pool_stop']) + if v4PoolStart > v4PoolStop: + raise ConfigError(f'Server client-ip-pool start address {v4PoolStart} is larger than stop address {v4PoolStop}') + + v4PoolSize = int(v4PoolStop) - int(v4PoolStart) + if v4PoolSize >= 65536: + raise ConfigError(f'Server client-ip-pool is too large [{v4PoolStart} -> {v4PoolStop} = {v4PoolSize}], maximum is 65536 addresses.') + + v4PoolNets = list(summarize_address_range(v4PoolStart, v4PoolStop)) + for client in openvpn['client']: + if client['ip']: + for v4PoolNet in v4PoolNets: + if IPv4Address(client['ip'][0]) in v4PoolNet: + print(f'Warning: Client "{client["name"]}" IP {client["ip"][0]} is in server IP pool, it is not reserved for this client.', + file=stderr) + + if openvpn['server_ipv6_subnet']: + if not openvpn['server_subnet']: + raise ConfigError('IPv6 server requires an IPv4 server subnet') + + if openvpn['server_ipv6_pool']: + if not openvpn['server_pool']: + raise ConfigError('IPv6 server pool requires an IPv4 server pool') + + if int(openvpn['server_ipv6_pool_prefixlen']) >= 112: + raise ConfigError('IPv6 server pool must be larger than /112') + + v6PoolStart = IPv6Address(openvpn['server_ipv6_pool_base']) + v6PoolStop = IPv6Network((v6PoolStart, openvpn['server_ipv6_pool_prefixlen']), strict=False)[-1] # don't remove the parentheses, it's a 2-tuple + v6PoolSize = int(v6PoolStop) - int(v6PoolStart) if int(openvpn['server_ipv6_pool_prefixlen']) > 96 else 65536 + if v6PoolSize < v4PoolSize: + raise ConfigError(f'IPv6 server pool must be at least as large as the IPv4 pool (current sizes: IPv6={v6PoolSize} IPv4={v4PoolSize})') + + v6PoolNets = list(summarize_address_range(v6PoolStart, v6PoolStop)) + for client in openvpn['client']: + if client['ipv6_ip']: + for v6PoolNet in v6PoolNets: + 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.', + file=stderr) + + else: + if openvpn['server_ipv6_push_route']: + raise ConfigError('IPv6 push-route requires an IPv6 server subnet') + + for client in openvpn ['client']: + if client['ipv6_ip']: + raise ConfigError(f'Server client "{client["name"]}" IPv6 IP requires an IPv6 server subnet') + if client['ipv6_push_route']: + raise ConfigError(f'Server client "{client["name"]} IPv6 push-route requires an IPv6 server subnet"') + if client['ipv6_subnet']: + raise ConfigError(f'Server client "{client["name"]} IPv6 subnet requires an IPv6 server subnet"') + + 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 openvpn['shared_secret_file']: + if openvpn['encryption'] in ['aes128gcm', 'aes192gcm', 'aes256gcm']: + raise ConfigError('GCM encryption with shared-secret-key-file is not supported') + + 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 openvpn['tls_auth'] and openvpn['tls_crypt']: + raise ConfigError('TLS auth and crypt are mutually exclusive') + + if not checkCertHeader('-----BEGIN CERTIFICATE-----', openvpn['tls_ca_cert']): + raise ConfigError('Specified ca-cert-file "{}" is invalid'.format(openvpn['tls_ca_cert'])) + + if openvpn['tls_auth']: + if not checkCertHeader('-----BEGIN OpenVPN Static key V1-----', openvpn['tls_auth']): + raise ConfigError('Specified auth-file "{}" is invalid'.format(openvpn['tls_auth'])) + + if openvpn['tls_cert']: + if not checkCertHeader('-----BEGIN CERTIFICATE-----', openvpn['tls_cert']): + raise ConfigError('Specified cert-file "{}" is invalid'.format(openvpn['tls_cert'])) + + if openvpn['tls_key']: + if not checkCertHeader('-----BEGIN (?:RSA |EC )?PRIVATE KEY-----', openvpn['tls_key']): + raise ConfigError('Specified key-file "{}" is not valid'.format(openvpn['tls_key'])) + + if openvpn['tls_crypt']: + if not checkCertHeader('-----BEGIN OpenVPN Static key V1-----', openvpn['tls_crypt']): + raise ConfigError('Specified TLS crypt-file "{}" is invalid'.format(openvpn['tls_crypt'])) + + if openvpn['tls_crl']: + if not checkCertHeader('-----BEGIN X509 CRL-----', openvpn['tls_crl']): + raise ConfigError('Specified crl-file "{} not valid'.format(openvpn['tls_crl'])) + + if openvpn['tls_dh'] and openvpn['tls_dh'] != 'none': + 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']: + if not openvpn['tls_auth']: + 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'] and openvpn['tls_dh'] != 'none': + 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"') + + if openvpn['tls_key'] and checkCertHeader('-----BEGIN EC PRIVATE KEY-----', openvpn['tls_key']): + if openvpn['tls_dh'] and openvpn['tls_dh'] != 'none': + print('Warning: using dh-file and EC keys simultaneously will lead to DH ciphers being used instead of ECDH') + else: + print('Diffie-Hellman prime file is unspecified, assuming ECDH') + + # + # 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') + + if openvpn['vrf']: + if openvpn['vrf'] not in interfaces(): + raise ConfigError(f'VRF "{openvpn["vrf"]}" does not exist') + + if openvpn['is_bridge_member']: + raise ConfigError(( + f'Interface "{openvpn["intf"]}" cannot be member of VRF ' + f'"{openvpn["vrf"]}" and bridge "{openvpn["is_bridge_member"]}" ' + f'at the same time!')) + + return None + +def generate(openvpn): + interface = openvpn['intf'] + directory = os.path.dirname(get_config_name(interface)) + + # we can't know in advance which clients have been removed, + # thus all client configs will be removed and re-added on demand + ccd_dir = os.path.join(directory, 'ccd', interface) + if os.path.isdir(ccd_dir): + rmtree(ccd_dir, ignore_errors=True) + + if openvpn['deleted'] or openvpn['disable']: + return None + + # create config directory on demand + directories = [] + directories.append(f'{directory}/status') + directories.append(f'{directory}/ccd/{interface}') + for onedir in directories: + if not os.path.exists(onedir): + os.makedirs(onedir, 0o755) + chown(onedir, user, group) + + # Fix file permissons for keys + fix_permissions = [] + fix_permissions.append(openvpn['shared_secret_file']) + fix_permissions.append(openvpn['tls_key']) + + # Generate User/Password authentication file + if openvpn['auth']: + with open(openvpn['auth_user_pass_file'], 'w') as f: + f.write('{}\n{}'.format(openvpn['auth_user'], openvpn['auth_pass'])) + # also change permission on auth file + fix_permissions.append(openvpn['auth_user_pass_file']) + + else: + # delete old auth file if present + if os.path.isfile(openvpn['auth_user_pass_file']): + os.remove(openvpn['auth_user_pass_file']) + + # Generate client specific configuration + for client in openvpn['client']: + client_file = os.path.join(ccd_dir, client['name']) + render(client_file, 'openvpn/client.conf.tmpl', client) + chown(client_file, user, group) + + # we need to support quoting of raw parameters from OpenVPN CLI + # see https://phabricator.vyos.net/T1632 + render(get_config_name(interface), 'openvpn/server.conf.tmpl', openvpn, + formater=lambda _: _.replace(""", '"')) + chown(get_config_name(interface), user, group) + + # Fixup file permissions + for file in fix_permissions: + chmod_600(file) + + return None + +def apply(openvpn): + interface = openvpn['intf'] + call(f'systemctl stop openvpn@{interface}.service') + + # Do some cleanup when OpenVPN is disabled/deleted + if openvpn['deleted'] or openvpn['disable']: + # cleanup old configuration files + cleanup = [] + cleanup.append(get_config_name(interface)) + cleanup.append(openvpn['auth_user_pass_file']) + + for file in cleanup: + if os.path.isfile(file): + os.unlink(file) + + return None + + # On configuration change we need to wait for the 'old' interface to + # vanish from the Kernel, if it is not gone, OpenVPN will report: + # ERROR: Cannot ioctl TUNSETIFF vtun10: Device or resource busy (errno=16) + while interface in interfaces(): + sleep(0.250) # 250ms + + # No matching OpenVPN process running - maybe it got killed or none + # existed - nevertheless, spawn new OpenVPN process + call(f'systemctl start openvpn@{interface}.service') + + # better late then sorry ... but we can only set interface alias after + # OpenVPN has been launched and created the interface + cnt = 0 + while interface not in interfaces(): + # If VPN tunnel can't be established because the peer/server isn't + # (temporarily) available, the vtun interface never becomes registered + # with the kernel, and the commit would hang if there is no bail out + # condition + cnt += 1 + if cnt == 50: + break + + # sleep 250ms + sleep(0.250) + + try: + # we need to catch the exception if the interface is not up due to + # reason stated above + o = VTunIf(interface) + # update interface description used e.g. within SNMP + o.set_alias(openvpn['description']) + # IPv6 accept RA + o.set_ipv6_accept_ra(openvpn['ipv6_accept_ra']) + # IPv6 address autoconfiguration + o.set_ipv6_autoconf(openvpn['ipv6_autoconf']) + # IPv6 forwarding + o.set_ipv6_forwarding(openvpn['ipv6_forwarding']) + # IPv6 Duplicate Address Detection (DAD) tries + o.set_ipv6_dad_messages(openvpn['ipv6_dup_addr_detect']) + + # IPv6 EUI-based addresses - only in TAP mode (TUN's have no MAC) + # If MAC has changed, old EUI64 addresses won't get deleted, + # but this isn't easy to solve, so leave them. + # This is even more difficult as openvpn uses a random MAC for the + # initial interface creation, unless set by 'lladdr'. + # NOTE: right now the interface is always deleted. For future + # compatibility when tap's are not deleted, leave the del_ in + if openvpn['mode'] == 'tap': + for addr in openvpn['ipv6_eui64_prefix_remove']: + o.del_ipv6_eui64_address(addr) + for addr in openvpn['ipv6_eui64_prefix']: + o.add_ipv6_eui64_address(addr) + + # assign/remove VRF (ONLY when not a member of a bridge, + # otherwise 'nomaster' removes it from it) + if not openvpn['is_bridge_member']: + o.set_vrf(openvpn['vrf']) + + except: + pass + + # TAP interface needs to be brought up explicitly + if openvpn['type'] == 'tap': + if not openvpn['disable']: + VTunIf(interface).set_admin_state('up') + + return None + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/interfaces-pppoe.py b/src/conf_mode/interfaces-pppoe.py new file mode 100755 index 000000000..901ea769c --- /dev/null +++ b/src/conf_mode/interfaces-pppoe.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2020 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 + +from sys import exit +from copy import deepcopy +from netifaces import interfaces + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configverify import verify_source_interface +from vyos.configverify import verify_vrf +from vyos.template import render +from vyos.util import call +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +def get_config(): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + interface name will be added or a deleted flag + """ + conf = Config() + base = ['interfaces', 'pppoe'] + pppoe = get_interface_dict(conf, base) + + # PPPoE is "special" the default MTU is 1492 - update accordingly + # as the config_level is already st in get_interface_dict() - we can use [] + tmp = conf.get_config_dict([], key_mangling=('-', '_'), get_first_key=True) + if 'mtu' not in tmp: + pppoe['mtu'] = '1492' + + return pppoe + +def verify(pppoe): + if 'deleted' in pppoe: + # bail out early + return None + + verify_source_interface(pppoe) + verify_vrf(pppoe) + + if {'connect_on_demand', 'vrf'} <= set(pppoe): + raise ConfigError('On-demand dialing and VRF can not be used at the same time') + + return None + +def generate(pppoe): + # set up configuration file path variables where our templates will be + # rendered into + ifname = pppoe['ifname'] + config_pppoe = f'/etc/ppp/peers/{ifname}' + script_pppoe_pre_up = f'/etc/ppp/ip-pre-up.d/1000-vyos-pppoe-{ifname}' + script_pppoe_ip_up = f'/etc/ppp/ip-up.d/1000-vyos-pppoe-{ifname}' + script_pppoe_ip_down = f'/etc/ppp/ip-down.d/1000-vyos-pppoe-{ifname}' + script_pppoe_ipv6_up = f'/etc/ppp/ipv6-up.d/1000-vyos-pppoe-{ifname}' + config_wide_dhcp6c = f'/run/dhcp6c/dhcp6c.{ifname}.conf' + + config_files = [config_pppoe, script_pppoe_pre_up, script_pppoe_ip_up, + script_pppoe_ip_down, script_pppoe_ipv6_up, config_wide_dhcp6c] + + if 'deleted' in pppoe: + # stop DHCPv6-PD client + call(f'systemctl stop dhcp6c@{ifname}.service') + # Hang-up PPPoE connection + call(f'systemctl stop ppp@{ifname}.service') + + # Delete PPP configuration files + for file in config_files: + if os.path.exists(file): + os.unlink(file) + + return None + + # Create PPP configuration files + render(config_pppoe, 'pppoe/peer.tmpl', + pppoe, trim_blocks=True, permission=0o755) + # Create script for ip-pre-up.d + render(script_pppoe_pre_up, 'pppoe/ip-pre-up.script.tmpl', + pppoe, trim_blocks=True, permission=0o755) + # Create script for ip-up.d + render(script_pppoe_ip_up, 'pppoe/ip-up.script.tmpl', + pppoe, trim_blocks=True, permission=0o755) + # Create script for ip-down.d + render(script_pppoe_ip_down, 'pppoe/ip-down.script.tmpl', + pppoe, trim_blocks=True, permission=0o755) + # Create script for ipv6-up.d + render(script_pppoe_ipv6_up, 'pppoe/ipv6-up.script.tmpl', + pppoe, trim_blocks=True, permission=0o755) + + if 'dhcpv6_options' in pppoe and 'pd' in pppoe['dhcpv6_options']: + # ipv6.tmpl relies on ifname - this should be made consitent in the + # future better then double key-ing the same value + render(config_wide_dhcp6c, 'dhcp-client/ipv6.tmpl', pppoe, trim_blocks=True) + + return None + +def apply(pppoe): + if 'deleted' in pppoe: + # bail out early + return None + + if 'disable' not in pppoe: + # Dial PPPoE connection + call('systemctl restart ppp@{ifname}.service'.format(**pppoe)) + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/interfaces-pseudo-ethernet.py b/src/conf_mode/interfaces-pseudo-ethernet.py new file mode 100755 index 000000000..fe2d7b1be --- /dev/null +++ b/src/conf_mode/interfaces-pseudo-ethernet.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2020 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 + +from copy import deepcopy +from sys import exit + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configdict import leaf_node_changed +from vyos.configverify import verify_vrf +from vyos.configverify import verify_address +from vyos.configverify import verify_bridge_delete +from vyos.configverify import verify_source_interface +from vyos.configverify import verify_vlan_config +from vyos.ifconfig import MACVLANIf +from vyos.validate import is_member +from vyos import ConfigError + +from vyos import airbag +airbag.enable() + +def get_config(): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at + least the interface name will be added or a deleted flag + """ + conf = Config() + base = ['interfaces', 'pseudo-ethernet'] + peth = get_interface_dict(conf, base) + + mode = leaf_node_changed(conf, ['mode']) + if mode: + peth.update({'mode_old' : mode}) + + # Check if source-interface is member of a bridge device + if 'source_interface' in peth: + bridge = is_member(conf, peth['source_interface'], 'bridge') + if bridge: + peth.update({'source_interface_is_bridge_member' : bridge}) + + # Check if we are a member of a bond device + bond = is_member(conf, peth['source_interface'], 'bonding') + if bond: + peth.update({'source_interface_is_bond_member' : bond}) + + return peth + +def verify(peth): + if 'deleted' in peth: + verify_bridge_delete(peth) + return None + + verify_source_interface(peth) + verify_vrf(peth) + verify_address(peth) + + if 'source_interface_is_bridge_member' in peth: + raise ConfigError( + 'Source interface "{source_interface}" can not be used as it is already a ' + 'member of bridge "{source_interface_is_bridge_member}"!'.format(**peth)) + + if 'source_interface_is_bond_member' in peth: + raise ConfigError( + 'Source interface "{source_interface}" can not be used as it is already a ' + 'member of bond "{source_interface_is_bond_member}"!'.format(**peth)) + + # use common function to verify VLAN configuration + verify_vlan_config(peth) + return None + +def generate(peth): + return None + +def apply(peth): + if 'deleted' in peth: + # delete interface + MACVLANIf(peth['ifname']).remove() + return None + + # Check if MACVLAN interface already exists. Parameters like the underlaying + # source-interface device or mode can not be changed on the fly and the + # interface needs to be recreated from the bottom. + if 'mode_old' in peth: + MACVLANIf(peth['ifname']).remove() + + # MACVLAN interface needs to be created on-block instead of passing a ton + # of arguments, I just use a dict that is managed by vyos.ifconfig + conf = deepcopy(MACVLANIf.get_config()) + + # Assign MACVLAN instance configuration parameters to config dict + conf['source_interface'] = peth['source_interface'] + conf['mode'] = peth['mode'] + + # It is safe to "re-create" the interface always, there is a sanity check + # that the interface will only be create if its non existent + p = MACVLANIf(peth['ifname'], **conf) + p.update(peth) + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/interfaces-tunnel.py b/src/conf_mode/interfaces-tunnel.py new file mode 100755 index 000000000..ea15a7fb7 --- /dev/null +++ b/src/conf_mode/interfaces-tunnel.py @@ -0,0 +1,718 @@ +#!/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 netifaces + +from sys import exit +from copy import deepcopy +from netifaces import interfaces + +from vyos.config import Config +from vyos.ifconfig import Interface, GREIf, GRETapIf, IPIPIf, IP6GREIf, IPIP6If, IP6IP6If, SitIf, Sit6RDIf +from vyos.ifconfig.afi import IP4, IP6 +from vyos.configdict import list_diff +from vyos.validate import is_ipv4, is_ipv6, is_member +from vyos import ConfigError +from vyos.dicts import FixedDict + +from vyos import airbag +airbag.enable() + + +class ConfigurationState(object): + """ + The current API require a dict to be generated by get_config() + which is then consumed by verify(), generate() and apply() + + ConfiguartionState is an helper class wrapping Config and providing + an common API to this dictionary structure + + Its to_api() function return a dictionary containing three fields, + each a dict, called options, changes, actions. + + options: + + contains the configuration options for the dict and its value + {'options': {'commment': 'test'}} will be set if + 'set interface dummy dum1 description test' was used and + the key 'commment' is used to index the description info. + + changes: + + per key, let us know how the data was modified using one of the action + a special key called 'section' is used to indicate what happened to the + section. for example: + + 'set interface dummy dum1 description test' when no interface was setup + will result in the following changes + {'changes': {'section': 'create', 'comment': 'create'}} + + on an existing interface, depending if there was a description + 'set interface dummy dum1 description test' will result in one of + {'changes': {'comment': 'create'}} (not present before) + {'changes': {'comment': 'static'}} (unchanged) + {'changes': {'comment': 'modify'}} (changed from half) + + and 'delete interface dummy dummy1 description' will result in: + {'changes': {'comment': 'delete'}} + + actions: + + for each action list the configuration key which were changes + in our example if we added the 'description' and added an IP we would have + {'actions': { 'create': ['comment'], 'modify': ['addresses-add']}} + + the actions are: + 'create': it did not exist previously and was created + 'modify': it did exist previously but its content changed + 'static': it did exist and did not change + 'delete': it was present but was removed from the configuration + 'absent': it was not and is not present + which for each field represent how it was modified since the last commit + """ + + def __init__(self, configuration, section, default): + """ + initialise the class for a given configuration path: + + >>> conf = ConfigurationState(conf, 'interfaces ethernet eth1') + all further references to get_value(s) and get_effective(s) + will be for this part of the configuration (eth1) + """ + self._conf = configuration + + self.default = deepcopy(default) + self.options = FixedDict(**default) + self.actions = { + 'create': [], # the key did not exist and was added + 'static': [], # the key exists and its value was not modfied + 'modify': [], # the key exists and its value was modified + 'absent': [], # the key is not present + 'delete': [], # the key was present and was deleted + } + self.changes = {} + if not self._conf.exists(section): + self.changes['section'] = 'delete' + elif self._conf.exists_effective(section): + self.changes['section'] = 'modify' + else: + self.changes['section'] = 'create' + + self.set_level(section) + + def set_level(self, lpath): + self.section = lpath + self._conf.set_level(lpath) + + def _act(self, section): + """ + Returns for a given configuration field determine what happened to it + + 'create': it did not exist previously and was created + 'modify': it did exist previously but its content changed + 'static': it did exist and did not change + 'delete': it was present but was removed from the configuration + 'absent': it was not and is not present + """ + if self._conf.exists(section): + if self._conf.exists_effective(section): + if self._conf.return_value(section) != self._conf.return_effective_value(section): + return 'modify' + return 'static' + return 'create' + else: + if self._conf.exists_effective(section): + return 'delete' + return 'absent' + + def _action(self, name, key): + action = self._act(key) + self.changes[name] = action + self.actions[action].append(name) + return action + + def _get(self, name, key, default, getter): + value = getter(key) + if not value: + if default: + self.options[name] = default + return + self.options[name] = self.default[name] + return + self.options[name] = value + + def get_value(self, name, key, default=None): + """ + >>> conf.get_value('comment', 'description') + will place the string of 'interface dummy description test' + into the dictionnary entry 'comment' using Config.return_value + (the data in the configuration to apply) + """ + if self._action(name, key) in ('delete', 'absent'): + return + return self._get(name, key, default, self._conf.return_value) + + def get_values(self, name, key, default=None): + """ + >>> conf.get_values('addresses', 'address') + will place a list of the new IP present in 'interface dummy dum1 address' + into the dictionnary entry "-add" (here 'addresses-add') using + Config.return_values and will add the the one which were removed in into + the entry "-del" (here addresses-del') + """ + add_name = f'{name}-add' + + if self._action(add_name, key) in ('delete', 'absent'): + return + + self._get(add_name, key, default, self._conf.return_values) + + # get the effective values to determine which data is no longer valid + self.options['addresses-del'] = list_diff( + self._conf.return_effective_values('address'), + self.options['addresses-add'] + ) + + def get_effective(self, name, key, default=None): + """ + >>> conf.get_value('comment', 'description') + will place the string of 'interface dummy description test' + into the dictionnary entry 'comment' using Config.return_effective_value + (the data in the configuration to apply) + """ + self._action(name, key) + return self._get(name, key, default, self._conf.return_effective_value) + + def get_effectives(self, name, key, default=None): + """ + >>> conf.get_effectives('addresses-add', 'address') + will place a list made of the IP present in 'interface ethernet eth1 address' + into the dictionnary entry 'addresses-add' using Config.return_effectives_value + (the data in the un-modified configuration) + """ + self._action(name, key) + return self._get(name, key, default, self._conf.return_effectives_value) + + def load(self, mapping): + """ + load will take a dictionary defining how we wish the configuration + to be parsed and apply this definition to set the data. + + >>> mapping = { + 'addresses-add' : ('address', True, None), + 'comment' : ('description', False, 'auto'), + } + >>> conf.load(mapping) + + mapping is a dictionary where each key represents the name we wish + to have (such as 'addresses-add'), with a list a content representing + how the data should be parsed: + - the configuration section name + such as 'address' under 'interface ethernet eth1' + - boolean indicating if this data can have multiple values + for 'address', True, as multiple IPs can be set + for 'description', False, as it is a single string + - default represent the default value if absent from the configuration + 'None' indicate that no default should be set if the configuration + does not have the configuration section + + """ + for local_name, (config_name, multiple, default) in mapping.items(): + if multiple: + self.get_values(local_name, config_name, default) + else: + self.get_value(local_name, config_name, default) + + def remove_default(self,*options): + """ + remove all the values which were not changed from the default + """ + for option in options: + if not self._conf.exists(option): + del self.options[option] + continue + + if self._conf.return_value(option) == self.default[option]: + del self.options[option] + continue + + if self._conf.return_values(option) == self.default[option]: + del self.options[option] + continue + + def as_dict(self, lpath): + l = self._conf.get_level() + self._conf.set_level([]) + d = self._conf.get_config_dict(lpath) + # XXX: that not what I would have expected from get_config_dict + if lpath: + d = d[lpath[-1]] + # XXX: it should have provided me the content and not the key + self._conf.set_level(l) + return d + + def to_api(self): + """ + provide a dictionary with the generated data for the configuration + options: the configuration value for the key + changes: per key how they changed from the previous configuration + actions: per changes all the options which were changed + """ + # as we have to use a dict() for the API for verify and apply the options + return { + 'options': self.options, + 'changes': self.changes, + 'actions': self.actions, + } + + +default_config_data = { + # interface definition + 'vrf': '', + 'addresses-add': [], + 'addresses-del': [], + 'state': 'up', + 'dhcp-interface': '', + 'link_detect': 1, + 'ip': False, + 'ipv6': False, + 'nhrp': [], + 'arp_filter': 1, + 'arp_accept': 0, + 'arp_announce': 0, + 'arp_ignore': 0, + 'ipv6_accept_ra': 1, + 'ipv6_autoconf': 0, + 'ipv6_forwarding': 1, + 'ipv6_dad_transmits': 1, + # internal + 'interfaces': [], + 'tunnel': {}, + 'bridge': '', + # the following names are exactly matching the name + # for the ip command and must not be changed + 'ifname': '', + 'type': '', + 'alias': '', + 'mtu': '1476', + 'local': '', + 'remote': '', + 'dev': '', + 'multicast': 'disable', + 'allmulticast': 'disable', + 'ttl': '255', + 'tos': 'inherit', + 'key': '', + 'encaplimit': '4', + 'flowlabel': 'inherit', + 'hoplimit': '64', + 'tclass': 'inherit', + '6rd-prefix': '', + '6rd-relay-prefix': '', +} + + +# dict name -> config name, multiple values, default +mapping = { + 'type': ('encapsulation', False, None), + 'alias': ('description', False, None), + 'mtu': ('mtu', False, None), + 'local': ('local-ip', False, None), + 'remote': ('remote-ip', False, None), + 'multicast': ('multicast', False, None), + 'dev': ('source-interface', False, None), + 'ttl': ('parameters ip ttl', False, None), + 'tos': ('parameters ip tos', False, None), + 'key': ('parameters ip key', False, None), + 'encaplimit': ('parameters ipv6 encaplimit', False, None), + 'flowlabel': ('parameters ipv6 flowlabel', False, None), + 'hoplimit': ('parameters ipv6 hoplimit', False, None), + 'tclass': ('parameters ipv6 tclass', False, None), + '6rd-prefix': ('6rd-prefix', False, None), + '6rd-relay-prefix': ('6rd-relay-prefix', False, None), + 'dhcp-interface': ('dhcp-interface', False, None), + 'state': ('disable', False, 'down'), + 'link_detect': ('disable-link-detect', False, 2), + 'vrf': ('vrf', False, None), + 'addresses': ('address', True, None), + 'arp_filter': ('ip disable-arp-filter', False, 0), + 'arp_accept': ('ip enable-arp-accept', False, 1), + 'arp_announce': ('ip enable-arp-announce', False, 1), + 'arp_ignore': ('ip enable-arp-ignore', False, 1), + 'ipv6_autoconf': ('ipv6 address autoconf', False, 1), + 'ipv6_forwarding': ('ipv6 disable-forwarding', False, 0), + 'ipv6_dad_transmits:': ('ipv6 dup-addr-detect-transmits', False, None) +} + + +def get_class (options): + dispatch = { + 'gre': GREIf, + 'gre-bridge': GRETapIf, + 'ipip': IPIPIf, + 'ipip6': IPIP6If, + 'ip6ip6': IP6IP6If, + 'ip6gre': IP6GREIf, + 'sit': SitIf, + } + + kls = dispatch[options['type']] + if options['type'] == 'gre' and not options['remote'] \ + and not options['key'] and not options['multicast']: + # will use GreTapIf on GreIf deletion but it does not matter + return GRETapIf + elif options['type'] == 'sit' and options['6rd-prefix']: + # will use SitIf on Sit6RDIf deletion but it does not matter + return Sit6RDIf + return kls + +def get_interface_ip (ifname): + if not ifname: + return '' + try: + addrs = Interface(ifname).get_addr() + if addrs: + return addrs[0].split('/')[0] + except Exception: + return '' + +def get_afi (ip): + return IP6 if is_ipv6(ip) else IP4 + +def ip_proto (afi): + return 6 if afi == IP6 else 4 + + +def get_config(): + ifname = os.environ.get('VYOS_TAGNODE_VALUE','') + if not ifname: + raise ConfigError('Interface not specified') + + config = Config() + conf = ConfigurationState(config, ['interfaces', 'tunnel ', ifname], default_config_data) + options = conf.options + changes = conf.changes + options['ifname'] = ifname + + if changes['section'] == 'delete': + conf.get_effective('type', mapping['type'][0]) + config.set_level(['protocols', 'nhrp', 'tunnel']) + options['nhrp'] = config.list_nodes('') + return conf.to_api() + + # load all the configuration option according to the mapping + conf.load(mapping) + + # remove default value if not set and not required + afi_local = get_afi(options['local']) + if afi_local == IP6: + conf.remove_default('ttl', 'tos', 'key') + if afi_local == IP4: + conf.remove_default('encaplimit', 'flowlabel', 'hoplimit', 'tclass') + + # if the local-ip is not set, pick one from the interface ! + # hopefully there is only one, otherwise it will not be very deterministic + # at time of writing the code currently returns ipv4 before ipv6 in the list + + # XXX: There is no way to trigger an update of the interface source IP if + # XXX: the underlying interface IP address does change, I believe this + # XXX: limit/issue is present in vyatta too + + if not options['local'] and options['dhcp-interface']: + # XXX: This behaviour changes from vyatta which would return 127.0.0.1 if + # XXX: the interface was not DHCP. As there is no easy way to find if an + # XXX: interface is using DHCP, and using this feature to get 127.0.0.1 + # XXX: makes little sense, I feel the change in behaviour is acceptable + picked = get_interface_ip(options['dhcp-interface']) + if picked == '': + picked = '127.0.0.1' + print('Could not get an IP address from {dhcp-interface} using 127.0.0.1 instead') + options['local'] = picked + options['dhcp-interface'] = '' + + # to make IPv6 SLAAC and DHCPv6 work with forwarding=1, + # accept_ra must be 2 + if options['ipv6_autoconf'] or 'dhcpv6' in options['addresses-add']: + options['ipv6_accept_ra'] = 2 + + # allmulticast fate is linked to multicast + options['allmulticast'] = options['multicast'] + + # check that per encapsulation all local-remote pairs are unique + ct = conf.as_dict(['interfaces', 'tunnel']) + options['tunnel'] = {} + + # check for bridges + options['bridge'] = is_member(config, ifname, 'bridge') + options['interfaces'] = interfaces() + + for name in ct: + tunnel = ct[name] + encap = tunnel.get('encapsulation', '') + local = tunnel.get('local-ip', '') + if not local: + local = get_interface_ip(tunnel.get('dhcp-interface', '')) + remote = tunnel.get('remote-ip', '<unset>') + pair = f'{local}-{remote}' + options['tunnel'][encap][pair] = options['tunnel'].setdefault(encap, {}).get(pair, 0) + 1 + + return conf.to_api() + + +def verify(conf): + options = conf['options'] + changes = conf['changes'] + actions = conf['actions'] + + ifname = options['ifname'] + iftype = options['type'] + + if changes['section'] == 'delete': + if ifname in options['nhrp']: + raise ConfigError(( + f'Cannot delete interface tunnel {iftype} {ifname}, ' + 'it is used by NHRP')) + + if options['bridge']: + raise ConfigError(( + f'Cannot delete interface "{options["ifname"]}" as it is a ' + f'member of bridge "{options["bridge"]}"!')) + + # done, bail out early + return None + + # tunnel encapsulation checks + + if not iftype: + raise ConfigError(f'Must provide an "encapsulation" for tunnel {iftype} {ifname}') + + if changes['type'] in ('modify', 'delete'): + # TODO: we could now deal with encapsulation modification by deleting / recreating + raise ConfigError(f'Encapsulation can only be set at tunnel creation for tunnel {iftype} {ifname}') + + if iftype != 'sit' and options['6rd-prefix']: + # XXX: should be able to remove this and let the definition catch it + print(f'6RD can only be configured for sit interfaces not tunnel {iftype} {ifname}') + + # what are the tunnel options we can set / modified / deleted + + kls = get_class(options) + valid = kls.updates + ['alias', 'addresses-add', 'addresses-del', 'vrf', 'state'] + valid += ['arp_filter', 'arp_accept', 'arp_announce', 'arp_ignore'] + valid += ['ipv6_accept_ra', 'ipv6_autoconf', 'ipv6_forwarding', 'ipv6_dad_transmits'] + + if changes['section'] == 'create': + valid.extend(['type',]) + valid.extend([o for o in kls.options if o not in kls.updates]) + + for create in actions['create']: + if create not in valid: + raise ConfigError(f'Can not set "{create}" for tunnel {iftype} {ifname} at tunnel creation') + + for modify in actions['modify']: + if modify not in valid: + raise ConfigError(f'Can not modify "{modify}" for tunnel {iftype} {ifname}. it must be set at tunnel creation') + + for delete in actions['delete']: + if delete in kls.required: + raise ConfigError(f'Can not remove "{delete}", it is an mandatory option for tunnel {iftype} {ifname}') + + # tunnel information + + tun_local = options['local'] + afi_local = get_afi(tun_local) + tun_remote = options['remote'] or tun_local + afi_remote = get_afi(tun_remote) + tun_ismgre = iftype == 'gre' and not options['remote'] + tun_is6rd = iftype == 'sit' and options['6rd-prefix'] + tun_dev = options['dev'] + + # incompatible options + + if not tun_local and not options['dhcp-interface'] and not tun_is6rd: + raise ConfigError(f'Must configure either local-ip or dhcp-interface for tunnel {iftype} {ifname}') + + if tun_local and options['dhcp-interface']: + raise ConfigError(f'Must configure only one of local-ip or dhcp-interface for tunnel {iftype} {ifname}') + + if tun_dev and iftype in ('gre-bridge', 'sit'): + raise ConfigError(f'source interface can not be used with {iftype} {ifname}') + + # tunnel endpoint + + if afi_local != afi_remote: + raise ConfigError(f'IPv4/IPv6 mismatch between local-ip and remote-ip for tunnel {iftype} {ifname}') + + if afi_local != kls.tunnel: + version = 4 if tun_local == IP4 else 6 + raise ConfigError(f'Invalid IPv{version} local-ip for tunnel {iftype} {ifname}') + + ipv4_count = len([ip for ip in options['addresses-add'] if is_ipv4(ip)]) + ipv6_count = len([ip for ip in options['addresses-add'] if is_ipv6(ip)]) + + if tun_ismgre and afi_local == IP6: + raise ConfigError(f'Using an IPv6 address is forbidden for mGRE tunnels such as tunnel {iftype} {ifname}') + + # check address family use + # checks are not enforced (but ip command failing) for backward compatibility + + if ipv4_count and not IP4 in kls.ip: + print(f'Should not use IPv4 addresses on tunnel {iftype} {ifname}') + + if ipv6_count and not IP6 in kls.ip: + print(f'Should not use IPv6 addresses on tunnel {iftype} {ifname}') + + # vrf check + if options['vrf']: + if options['vrf'] not in options['interfaces']: + raise ConfigError(f'VRF "{options["vrf"]}" does not exist') + + if options['bridge']: + raise ConfigError(( + f'Interface "{options["ifname"]}" cannot be member of VRF ' + f'"{options["vrf"]}" and bridge {options["bridge"]} ' + f'at the same time!')) + + # bridge and address check + if ( options['bridge'] + and ( options['addresses-add'] + or options['ipv6_autoconf'] ) ): + raise ConfigError(( + f'Cannot assign address to interface "{options["name"]}" ' + f'as it is a member of bridge "{options["bridge"]}"!')) + + # source-interface check + + if tun_dev and tun_dev not in options['interfaces']: + raise ConfigError(f'device "{tun_dev}" does not exist') + + # tunnel encapsulation check + + convert = { + (6, 4, 'gre'): 'ip6gre', + (6, 6, 'gre'): 'ip6gre', + (4, 6, 'ipip'): 'ipip6', + (6, 6, 'ipip'): 'ip6ip6', + } + + iprotos = [] + if ipv4_count: + iprotos.append(4) + if ipv6_count: + iprotos.append(6) + + for iproto in iprotos: + replace = convert.get((kls.tunnel, iproto, iftype), '') + if replace: + raise ConfigError( + f'Using IPv6 address in local-ip or remote-ip is not possible with "encapsulation {iftype}". ' + + f'Use "encapsulation {replace}" for tunnel {iftype} {ifname} instead.' + ) + + # tunnel options + + incompatible = [] + if afi_local == IP6: + incompatible.extend(['ttl', 'tos', 'key',]) + if afi_local == IP4: + incompatible.extend(['encaplimit', 'flowlabel', 'hoplimit', 'tclass']) + + for option in incompatible: + if option in options: + # TODO: raise converted to print as not enforced by vyatta + # raise ConfigError(f'{option} is not valid for tunnel {iftype} {ifname}') + print(f'Using "{option}" is invalid for tunnel {iftype} {ifname}') + + # duplicate tunnel pairs + + pair = '{}-{}'.format(options['local'], options['remote']) + if options['tunnel'].get(iftype, {}).get(pair, 0) > 1: + raise ConfigError(f'More than one tunnel configured for with the same encapulation and IPs for tunnel {iftype} {ifname}') + + return None + + +def generate(gre): + return None + +def apply(conf): + options = conf['options'] + changes = conf['changes'] + actions = conf['actions'] + kls = get_class(options) + + # extract ifname as otherwise it is duplicated on the interface creation + ifname = options.pop('ifname') + + # only the valid keys for creation of a Interface + config = dict((k, options[k]) for k in kls.options if options[k]) + + # setup or create the tunnel interface if it does not exist + tunnel = kls(ifname, **config) + + if changes['section'] == 'delete': + tunnel.remove() + # The perl code was calling/opt/vyatta/sbin/vyatta-tunnel-cleanup + # which identified tunnels type which were not used anymore to remove them + # (ie: gre0, gretap0, etc.) The perl code did however nothing + # This feature is also not implemented yet + return + + # A GRE interface without remote will be mGRE + # if the interface does not suppor the option, it skips the change + for option in tunnel.updates: + if changes['section'] in 'create' and option in tunnel.options: + # it was setup at creation + continue + if not options[option]: + # remote can be set to '' and it would generate an invalide command + continue + tunnel.set_interface(option, options[option]) + + # set other interface properties + for option in ('alias', 'mtu', 'link_detect', 'multicast', 'allmulticast', + 'arp_accept', 'arp_filter', 'arp_announce', 'arp_ignore', + 'ipv6_accept_ra', 'ipv6_autoconf', 'ipv6_forwarding', 'ipv6_dad_transmits'): + if not options[option]: + # should never happen but better safe + continue + tunnel.set_interface(option, options[option]) + + # assign/remove VRF (ONLY when not a member of a bridge, + # otherwise 'nomaster' removes it from it) + if not options['bridge']: + tunnel.set_vrf(options['vrf']) + + # Configure interface address(es) + for addr in options['addresses-del']: + tunnel.del_addr(addr) + for addr in options['addresses-add']: + tunnel.add_addr(addr) + + # now bring it up (or not) + tunnel.set_admin_state(options['state']) + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/interfaces-vxlan.py b/src/conf_mode/interfaces-vxlan.py new file mode 100755 index 000000000..47c0bdcb8 --- /dev/null +++ b/src/conf_mode/interfaces-vxlan.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2020 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 + +from sys import exit +from copy import deepcopy +from netifaces import interfaces + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configverify import verify_address +from vyos.configverify import verify_bridge_delete +from vyos.configverify import verify_source_interface +from vyos.ifconfig import VXLANIf, Interface +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +def get_config(): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + interface name will be added or a deleted flag + """ + conf = Config() + base = ['interfaces', 'vxlan'] + vxlan = get_interface_dict(conf, base) + + # VXLAN is "special" the default MTU is 1492 - update accordingly + # as the config_level is already st in get_interface_dict() - we can use [] + tmp = conf.get_config_dict([], key_mangling=('-', '_'), get_first_key=True) + if 'mtu' not in tmp: + vxlan['mtu'] = '1450' + + return vxlan + +def verify(vxlan): + if 'deleted' in vxlan: + verify_bridge_delete(vxlan) + return None + + if int(vxlan['mtu']) < 1500: + print('WARNING: RFC7348 recommends VXLAN tunnels preserve a 1500 byte MTU') + + if 'group' in vxlan: + if 'source_interface' not in vxlan: + raise ConfigError('Multicast VXLAN requires an underlaying interface ') + + verify_source_interface(vxlan) + + if not any(tmp in ['group', 'remote', 'source_address'] for tmp in vxlan): + raise ConfigError('Group, remote or source-address must be configured') + + if 'vni' not in vxlan: + raise ConfigError('Must configure VNI for VXLAN') + + if 'source_interface' in vxlan: + # VXLAN adds a 50 byte overhead - we need to check the underlaying MTU + # if our configured MTU is at least 50 bytes less + underlay_mtu = int(Interface(vxlan['source_interface']).get_mtu()) + if underlay_mtu < (int(vxlan['mtu']) + 50): + raise ConfigError('VXLAN has a 50 byte overhead, underlaying device ' \ + f'MTU is to small ({underlay_mtu} bytes)') + + verify_address(vxlan) + return None + + +def generate(vxlan): + return None + + +def apply(vxlan): + # Check if the VXLAN interface already exists + if vxlan['ifname'] in interfaces(): + v = VXLANIf(vxlan['ifname']) + # VXLAN is super picky and the tunnel always needs to be recreated, + # thus we can simply always delete it first. + v.remove() + + if 'deleted' not in vxlan: + # VXLAN interface needs to be created on-block + # instead of passing a ton of arguments, I just use a dict + # that is managed by vyos.ifconfig + conf = deepcopy(VXLANIf.get_config()) + + # Assign VXLAN instance configuration parameters to config dict + for tmp in ['vni', 'group', 'source_address', 'source_interface', 'remote', 'port']: + if tmp in vxlan: + conf[tmp] = vxlan[tmp] + + # Finally create the new interface + v = VXLANIf(vxlan['ifname'], **conf) + v.update(vxlan) + + return None + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/interfaces-wireguard.py b/src/conf_mode/interfaces-wireguard.py new file mode 100755 index 000000000..8b64cde4d --- /dev/null +++ b/src/conf_mode/interfaces-wireguard.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2020 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 + +from sys import exit +from copy import deepcopy + +from vyos.config import Config +from vyos.configdict import dict_merge +from vyos.configdict import get_interface_dict +from vyos.configdict import node_changed +from vyos.configdict import leaf_node_changed +from vyos.configverify import verify_vrf +from vyos.configverify import verify_address +from vyos.configverify import verify_bridge_delete +from vyos.ifconfig import WireGuardIf +from vyos.util import check_kmod +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +def get_config(): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + interface name will be added or a deleted flag + """ + conf = Config() + base = ['interfaces', 'wireguard'] + wireguard = get_interface_dict(conf, base) + + # Wireguard is "special" the default MTU is 1420 - update accordingly + # as the config_level is already st in get_interface_dict() - we can use [] + tmp = conf.get_config_dict([], key_mangling=('-', '_'), get_first_key=True) + if 'mtu' not in tmp: + wireguard['mtu'] = '1420' + + # Mangle private key - it has a default so its always valid + wireguard['private_key'] = '/config/auth/wireguard/{private_key}/private.key'.format(**wireguard) + + # Determine which Wireguard peer has been removed. + # Peers can only be removed with their public key! + tmp = node_changed(conf, ['peer']) + if tmp: + dict = {} + for peer in tmp: + peer_config = leaf_node_changed(conf, ['peer', peer, 'pubkey']) + dict = dict_merge({'peer_remove' : {peer : {'pubkey' : peer_config}}}, dict) + wireguard.update(dict) + + return wireguard + +def verify(wireguard): + if 'deleted' in wireguard: + verify_bridge_delete(wireguard) + return None + + verify_address(wireguard) + verify_vrf(wireguard) + + if not os.path.exists(wireguard['private_key']): + raise ConfigError('Wireguard private-key not found! Execute: ' \ + '"run generate wireguard [default-keypair|named-keypairs]"') + + if 'address' not in wireguard: + raise ConfigError('IP address required!') + + if 'peer' not in wireguard: + raise ConfigError('At least one Wireguard peer is required!') + + # run checks on individual configured WireGuard peer + for tmp in wireguard['peer']: + peer = wireguard['peer'][tmp] + + if 'allowed_ips' not in peer: + raise ConfigError(f'Wireguard allowed-ips required for peer "{tmp}"!') + + if 'pubkey' not in peer: + raise ConfigError(f'Wireguard public-key required for peer "{tmp}"!') + + if ('address' in peer and 'port' not in peer) or ('port' in peer and 'address' not in peer): + raise ConfigError('Both Wireguard port and address must be defined ' + f'for peer "{tmp}" if either one of them is set!') + +def apply(wireguard): + if 'deleted' in wireguard: + WireGuardIf(wireguard['ifname']).remove() + return None + + w = WireGuardIf(wireguard['ifname']) + w.update(wireguard) + return None + +if __name__ == '__main__': + try: + check_kmod('wireguard') + c = get_config() + verify(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/interfaces-wireless.py b/src/conf_mode/interfaces-wireless.py new file mode 100755 index 000000000..b6f247952 --- /dev/null +++ b/src/conf_mode/interfaces-wireless.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2020 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 + +from sys import exit +from re import findall +from copy import deepcopy +from netaddr import EUI, mac_unix_expanded + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configdict import dict_merge +from vyos.configverify import verify_address +from vyos.configverify import verify_bridge_delete +from vyos.configverify import verify_dhcpv6 +from vyos.configverify import verify_source_interface +from vyos.configverify import verify_vlan_config +from vyos.configverify import verify_vrf +from vyos.ifconfig import WiFiIf +from vyos.template import render +from vyos.util import call +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +# XXX: wpa_supplicant works on the source interface +wpa_suppl_conf = '/run/wpa_supplicant/{ifname}.conf' +hostapd_conf = '/run/hostapd/{ifname}.conf' + +def find_other_stations(conf, base, ifname): + """ + Only one wireless interface per phy can be in station mode - + find all interfaces attached to a phy which run in station mode + """ + old_level = conf.get_level() + conf.set_level(base) + dict = {} + for phy in os.listdir('/sys/class/ieee80211'): + list = [] + for interface in conf.list_nodes([]): + if interface == ifname: + continue + # the following node is mandatory + if conf.exists([interface, 'physical-device', phy]): + tmp = conf.return_value([interface, 'type']) + if tmp == 'station': + list.append(interface) + if list: + dict.update({phy: list}) + conf.set_level(old_level) + return dict + +def get_config(): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + interface name will be added or a deleted flag + """ + conf = Config() + base = ['interfaces', 'wireless'] + wifi = get_interface_dict(conf, base) + + if 'security' in wifi and 'wpa' in wifi['security']: + wpa_cipher = wifi['security']['wpa'].get('cipher') + wpa_mode = wifi['security']['wpa'].get('mode') + if not wpa_cipher: + tmp = None + if wpa_mode == 'wpa': + tmp = {'security': {'wpa': {'cipher' : ['TKIP', 'CCMP']}}} + elif wpa_mode == 'wpa2': + tmp = {'security': {'wpa': {'cipher' : ['CCMP']}}} + elif wpa_mode == 'both': + tmp = {'security': {'wpa': {'cipher' : ['CCMP', 'TKIP']}}} + + if tmp: wifi = dict_merge(tmp, wifi) + + # retrieve configured regulatory domain + conf.set_level(['system']) + if conf.exists(['wifi-regulatory-domain']): + wifi['country_code'] = conf.return_value(['wifi-regulatory-domain']) + + # Only one wireless interface per phy can be in station mode + tmp = find_other_stations(conf, base, wifi['ifname']) + if tmp: wifi['station_interfaces'] = tmp + + return wifi + +def verify(wifi): + if 'deleted' in wifi: + verify_bridge_delete(wifi) + return None + + if 'physical_device' not in wifi: + raise ConfigError('You must specify a physical-device "phy"') + + if 'type' not in wifi: + raise ConfigError('You must specify a WiFi mode') + + if 'ssid' not in wifi and wifi['type'] != 'monitor': + raise ConfigError('SSID must be configured') + + if wifi['type'] == 'access-point': + if 'country_code' not in wifi: + raise ConfigError('Wireless regulatory domain is mandatory,\n' \ + 'use "set system wifi-regulatory-domain" for configuration.') + + if 'channel' not in wifi: + raise ConfigError('Wireless channel must be configured!') + + if 'security' in wifi: + if {'wep', 'wpa'} <= set(wifi.get('security', {})): + raise ConfigError('Must either use WEP or WPA security!') + + if 'wep' in wifi['security']: + if 'key' in wifi['security']['wep'] and len(wifi['security']['wep']) > 4: + raise ConfigError('No more then 4 WEP keys configurable') + elif 'key' not in wifi['security']['wep']: + raise ConfigError('Security WEP configured - missing WEP keys!') + + elif 'wpa' in wifi['security']: + wpa = wifi['security']['wpa'] + if not any(i in ['passphrase', 'radius'] for i in wpa): + raise ConfigError('Misssing WPA key or RADIUS server') + + if 'radius' in wpa: + if 'server' in wpa['radius']: + for server in wpa['radius']['server']: + if 'key' not in wpa['radius']['server'][server]: + raise ConfigError(f'Misssing RADIUS shared secret key for server: {server}') + + if 'capabilities' in wifi: + capabilities = wifi['capabilities'] + if 'vht' in capabilities: + if 'ht' not in capabilities: + raise ConfigError('Specify HT flags if you want to use VHT!') + + if {'beamform', 'antenna_count'} <= set(capabilities.get('vht', {})): + if capabilities['vht']['antenna_count'] == '1': + raise ConfigError('Cannot use beam forming with just one antenna!') + + if capabilities['vht']['beamform'] == 'single-user-beamformer': + if int(capabilities['vht']['antenna_count']) < 3: + # Nasty Gotcha: see https://w1.fi/cgit/hostap/plain/hostapd/hostapd.conf lines 692-705 + raise ConfigError('Single-user beam former requires at least 3 antennas!') + + if 'station_interfaces' in wifi and wifi['type'] == 'station': + phy = wifi['physical_device'] + if phy in wifi['station_interfaces']: + if len(wifi['station_interfaces'][phy]) > 0: + raise ConfigError('Only one station per wireless physical interface possible!') + + verify_address(wifi) + verify_vrf(wifi) + + # use common function to verify VLAN configuration + verify_vlan_config(wifi) + + return None + +def generate(wifi): + interface = wifi['ifname'] + + # always stop hostapd service first before reconfiguring it + call(f'systemctl stop hostapd@{interface}.service') + # always stop wpa_supplicant service first before reconfiguring it + call(f'systemctl stop wpa_supplicant@{interface}.service') + + # Delete config files if interface is removed + if 'deleted' in wifi: + if os.path.isfile(hostapd_conf.format(**wifi)): + os.unlink(hostapd_conf.format(**wifi)) + + if os.path.isfile(wpa_suppl_conf.format(**wifi)): + os.unlink(wpa_suppl_conf.format(**wifi)) + + return None + + if 'mac' not in wifi: + # http://wiki.stocksy.co.uk/wiki/Multiple_SSIDs_with_hostapd + # generate locally administered MAC address from used phy interface + with open('/sys/class/ieee80211/{physical_device}/addresses'.format(**wifi), 'r') as f: + # some PHYs tend to have multiple interfaces and thus supply multiple MAC + # addresses - we only need the first one for our calculation + tmp = f.readline().rstrip() + tmp = EUI(tmp).value + # mask last nibble from the MAC address + tmp &= 0xfffffffffff0 + # set locally administered bit in MAC address + tmp |= 0x020000000000 + # we now need to add an offset to our MAC address indicating this + # subinterfaces index + tmp += int(findall(r'\d+', interface)[0]) + + # convert integer to "real" MAC address representation + mac = EUI(hex(tmp).split('x')[-1]) + # change dialect to use : as delimiter instead of - + mac.dialect = mac_unix_expanded + wifi['mac'] = str(mac) + + # render appropriate new config files depending on access-point or station mode + if wifi['type'] == 'access-point': + render(hostapd_conf.format(**wifi), 'wifi/hostapd.conf.tmpl', wifi, trim_blocks=True) + + elif wifi['type'] == 'station': + render(wpa_suppl_conf.format(**wifi), 'wifi/wpa_supplicant.conf.tmpl', wifi, trim_blocks=True) + + return None + +def apply(wifi): + interface = wifi['ifname'] + if 'deleted' in wifi: + WiFiIf(interface).remove() + else: + # WiFi interface needs to be created on-block (e.g. mode or physical + # interface) instead of passing a ton of arguments, I just use a dict + # that is managed by vyos.ifconfig + conf = deepcopy(WiFiIf.get_config()) + + # Assign WiFi instance configuration parameters to config dict + conf['phy'] = wifi['physical_device'] + + # Finally create the new interface + w = WiFiIf(interface, **conf) + w.update(wifi) + + # Enable/Disable interface - interface is always placed in + # administrative down state in WiFiIf class + if 'disable' not in wifi: + # Physical interface is now configured. Proceed by starting hostapd or + # wpa_supplicant daemon. When type is monitor we can just skip this. + if wifi['type'] == 'access-point': + call(f'systemctl start hostapd@{interface}.service') + + elif wifi['type'] == 'station': + call(f'systemctl start wpa_supplicant@{interface}.service') + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/interfaces-wirelessmodem.py b/src/conf_mode/interfaces-wirelessmodem.py new file mode 100755 index 000000000..6d168d918 --- /dev/null +++ b/src/conf_mode/interfaces-wirelessmodem.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2020 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 + +from sys import exit + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configverify import verify_vrf +from vyos.template import render +from vyos.util import call +from vyos.util import check_kmod +from vyos.util import find_device_file +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +k_mod = ['option', 'usb_wwan', 'usbserial'] + +def get_config(): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + interface name will be added or a deleted flag + """ + conf = Config() + base = ['interfaces', 'wirelessmodem'] + wwan = get_interface_dict(conf, base) + return wwan + +def verify(wwan): + if 'deleted' in wwan: + return None + + if not 'apn' in wwan: + raise ConfigError('No APN configured for "{ifname}"'.format(**wwan)) + + if not 'device' in wwan: + raise ConfigError('Physical "device" must be configured') + + # we can not use isfile() here as Linux device files are no regular files + # thus the check will return False + if not os.path.exists(find_device_file(wwan['device'])): + raise ConfigError('Device "{device}" does not exist'.format(**wwan)) + + verify_vrf(wwan) + + return None + +def generate(wwan): + # set up configuration file path variables where our templates will be + # rendered into + ifname = wwan['ifname'] + config_wwan = f'/etc/ppp/peers/{ifname}' + config_wwan_chat = f'/etc/ppp/peers/chat.{ifname}' + script_wwan_pre_up = f'/etc/ppp/ip-pre-up.d/1010-vyos-wwan-{ifname}' + script_wwan_ip_up = f'/etc/ppp/ip-up.d/1010-vyos-wwan-{ifname}' + script_wwan_ip_down = f'/etc/ppp/ip-down.d/1010-vyos-wwan-{ifname}' + + config_files = [config_wwan, config_wwan_chat, script_wwan_pre_up, + script_wwan_ip_up, script_wwan_ip_down] + + # Always hang-up WWAN connection prior generating new configuration file + call(f'systemctl stop ppp@{ifname}.service') + + if 'deleted' in wwan: + # Delete PPP configuration files + for file in config_files: + if os.path.exists(file): + os.unlink(file) + + else: + wwan['device'] = find_device_file(wwan['device']) + + # Create PPP configuration files + render(config_wwan, 'wwan/peer.tmpl', wwan) + # Create PPP chat script + render(config_wwan_chat, 'wwan/chat.tmpl', wwan) + + # generated script file must be executable + + # Create script for ip-pre-up.d + render(script_wwan_pre_up, 'wwan/ip-pre-up.script.tmpl', + wwan, permission=0o755) + # Create script for ip-up.d + render(script_wwan_ip_up, 'wwan/ip-up.script.tmpl', + wwan, permission=0o755) + # Create script for ip-down.d + render(script_wwan_ip_down, 'wwan/ip-down.script.tmpl', + wwan, permission=0o755) + + return None + +def apply(wwan): + if 'deleted' in wwan: + # bail out early + return None + + if not 'disable' in wwan: + # "dial" WWAN connection + call('systemctl start ppp@{ifname}.service'.format(**wwan)) + + return None + +if __name__ == '__main__': + try: + check_kmod(k_mod) + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/ipsec-settings.py b/src/conf_mode/ipsec-settings.py new file mode 100755 index 000000000..015d1a480 --- /dev/null +++ b/src/conf_mode/ipsec-settings.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2020 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 re +import os + +from time import sleep +from sys import exit + +from vyos.config import Config +from vyos import ConfigError +from vyos.util import call +from vyos.template import render + +from vyos import airbag +airbag.enable() + +ra_conn_name = "remote-access" +charon_conf_file = "/etc/strongswan.d/charon.conf" +ipsec_secrets_file = "/etc/ipsec.secrets" +ipsec_ra_conn_dir = "/etc/ipsec.d/tunnels/" +ipsec_ra_conn_file = ipsec_ra_conn_dir + ra_conn_name +ipsec_conf_file = "/etc/ipsec.conf" +ca_cert_path = "/etc/ipsec.d/cacerts" +server_cert_path = "/etc/ipsec.d/certs" +server_key_path = "/etc/ipsec.d/private" +delim_ipsec_l2tp_begin = "### VyOS L2TP VPN Begin ###" +delim_ipsec_l2tp_end = "### VyOS L2TP VPN End ###" +charon_pidfile = "/var/run/charon.pid" + +def get_config(): + config = Config() + data = {"install_routes": "yes"} + + if config.exists("vpn ipsec options disable-route-autoinstall"): + data["install_routes"] = "no" + + if config.exists("vpn ipsec ipsec-interfaces interface"): + data["ipsec_interfaces"] = config.return_values("vpn ipsec ipsec-interfaces interface") + + # Init config variables + data["delim_ipsec_l2tp_begin"] = delim_ipsec_l2tp_begin + data["delim_ipsec_l2tp_end"] = delim_ipsec_l2tp_end + data["ipsec_ra_conn_file"] = ipsec_ra_conn_file + data["ra_conn_name"] = ra_conn_name + # Get l2tp ipsec settings + data["ipsec_l2tp"] = False + conf_ipsec_command = "vpn l2tp remote-access ipsec-settings " #last space is useful + if config.exists(conf_ipsec_command): + data["ipsec_l2tp"] = True + + # Authentication params + if config.exists(conf_ipsec_command + "authentication mode"): + data["ipsec_l2tp_auth_mode"] = config.return_value(conf_ipsec_command + "authentication mode") + if config.exists(conf_ipsec_command + "authentication pre-shared-secret"): + data["ipsec_l2tp_secret"] = config.return_value(conf_ipsec_command + "authentication pre-shared-secret") + + # mode x509 + if config.exists(conf_ipsec_command + "authentication x509 ca-cert-file"): + data["ipsec_l2tp_x509_ca_cert_file"] = config.return_value(conf_ipsec_command + "authentication x509 ca-cert-file") + if config.exists(conf_ipsec_command + "authentication x509 crl-file"): + data["ipsec_l2tp_x509_crl_file"] = config.return_value(conf_ipsec_command + "authentication x509 crl-file") + if config.exists(conf_ipsec_command + "authentication x509 server-cert-file"): + data["ipsec_l2tp_x509_server_cert_file"] = config.return_value(conf_ipsec_command + "authentication x509 server-cert-file") + data["server_cert_file_copied"] = server_cert_path+"/"+re.search('\w+(?:\.\w+)*$', config.return_value(conf_ipsec_command + "authentication x509 server-cert-file")).group(0) + if config.exists(conf_ipsec_command + "authentication x509 server-key-file"): + data["ipsec_l2tp_x509_server_key_file"] = config.return_value(conf_ipsec_command + "authentication x509 server-key-file") + data["server_key_file_copied"] = server_key_path+"/"+re.search('\w+(?:\.\w+)*$', config.return_value(conf_ipsec_command + "authentication x509 server-key-file")).group(0) + if config.exists(conf_ipsec_command + "authentication x509 server-key-password"): + data["ipsec_l2tp_x509_server_key_password"] = config.return_value(conf_ipsec_command + "authentication x509 server-key-password") + + # Common l2tp ipsec params + if config.exists(conf_ipsec_command + "ike-lifetime"): + data["ipsec_l2tp_ike_lifetime"] = config.return_value(conf_ipsec_command + "ike-lifetime") + else: + data["ipsec_l2tp_ike_lifetime"] = "3600" + + if config.exists(conf_ipsec_command + "lifetime"): + data["ipsec_l2tp_lifetime"] = config.return_value(conf_ipsec_command + "lifetime") + else: + data["ipsec_l2tp_lifetime"] = "3600" + + if config.exists("vpn l2tp remote-access outside-address"): + data['outside_addr'] = config.return_value('vpn l2tp remote-access outside-address') + + return data + +def write_ipsec_secrets(c): + if c.get("ipsec_l2tp_auth_mode") == "pre-shared-secret": + secret_txt = "{0}\n{1} %any : PSK \"{2}\"\n{3}\n".format(delim_ipsec_l2tp_begin, c['outside_addr'], c['ipsec_l2tp_secret'], delim_ipsec_l2tp_end) + elif c.get("ipsec_l2tp_auth_mode") == "x509": + secret_txt = "{0}\n: RSA {1}\n{2}\n".format(delim_ipsec_l2tp_begin, c['server_key_file_copied'], delim_ipsec_l2tp_end) + + old_umask = os.umask(0o077) + with open(ipsec_secrets_file, 'a+') as f: + f.write(secret_txt) + os.umask(old_umask) + +def write_ipsec_conf(c): + ipsec_confg_txt = "{0}\ninclude {1}\n{2}\n".format(delim_ipsec_l2tp_begin, ipsec_ra_conn_file, delim_ipsec_l2tp_end) + + old_umask = os.umask(0o077) + with open(ipsec_conf_file, 'a+') as f: + f.write(ipsec_confg_txt) + os.umask(old_umask) + +### Remove config from file by delimiter +def remove_confs(delim_begin, delim_end, conf_file): + call("sed -i '/"+delim_begin+"/,/"+delim_end+"/d' "+conf_file) + + +### Checking certificate storage and notice if certificate not in /config directory +def check_cert_file_store(cert_name, file_path, dts_path): + if not re.search('^\/config\/.+', file_path): + print("Warning: \"" + file_path + "\" lies outside of /config/auth directory. It will not get preserved during image upgrade.") + #Checking file existence + if not os.path.isfile(file_path): + raise ConfigError("L2TP VPN configuration error: Invalid "+cert_name+" \""+file_path+"\"") + else: + ### Cpy file to /etc/ipsec.d/certs/ /etc/ipsec.d/cacerts/ + # todo make check + ret = call('cp -f '+file_path+' '+dts_path) + if ret: + raise ConfigError("L2TP VPN configuration error: Cannot copy "+file_path) + +def verify(data): + # l2tp ipsec check + if data["ipsec_l2tp"]: + # Checking dependecies for "authentication mode pre-shared-secret" + if data.get("ipsec_l2tp_auth_mode") == "pre-shared-secret": + if not data.get("ipsec_l2tp_secret"): + raise ConfigError("pre-shared-secret required") + if not data.get("outside_addr"): + raise ConfigError("outside-address not defined") + + # Checking dependecies for "authentication mode x509" + if data.get("ipsec_l2tp_auth_mode") == "x509": + if not data.get("ipsec_l2tp_x509_server_key_file"): + raise ConfigError("L2TP VPN configuration error: \"server-key-file\" not defined.") + else: + check_cert_file_store("server-key-file", data['ipsec_l2tp_x509_server_key_file'], server_key_path) + + if not data.get("ipsec_l2tp_x509_server_cert_file"): + raise ConfigError("L2TP VPN configuration error: \"server-cert-file\" not defined.") + else: + check_cert_file_store("server-cert-file", data['ipsec_l2tp_x509_server_cert_file'], server_cert_path) + + if not data.get("ipsec_l2tp_x509_ca_cert_file"): + raise ConfigError("L2TP VPN configuration error: \"ca-cert-file\" must be defined for X.509") + else: + check_cert_file_store("ca-cert-file", data['ipsec_l2tp_x509_ca_cert_file'], ca_cert_path) + + if not data.get('ipsec_interfaces'): + raise ConfigError("L2TP VPN configuration error: \"vpn ipsec ipsec-interfaces\" must be specified.") + +def generate(data): + render(charon_conf_file, 'ipsec/charon.tmpl', data, trim_blocks=True) + + if data["ipsec_l2tp"]: + remove_confs(delim_ipsec_l2tp_begin, delim_ipsec_l2tp_end, ipsec_secrets_file) + # old_umask = os.umask(0o077) + # render(ipsec_secrets_file, 'ipsec/ipsec.secrets.tmpl', data, trim_blocks=True) + # os.umask(old_umask) + ## Use this method while IPSec CLI handler won't be overwritten to python + write_ipsec_secrets(data) + + old_umask = os.umask(0o077) + + # Create tunnels directory if does not exist + if not os.path.exists(ipsec_ra_conn_dir): + os.makedirs(ipsec_ra_conn_dir) + + render(ipsec_ra_conn_file, 'ipsec/remote-access.tmpl', data, trim_blocks=True) + os.umask(old_umask) + + remove_confs(delim_ipsec_l2tp_begin, delim_ipsec_l2tp_end, ipsec_conf_file) + # old_umask = os.umask(0o077) + # render(ipsec_conf_file, 'ipsec/ipsec.conf.tmpl', data, trim_blocks=True) + # os.umask(old_umask) + ## Use this method while IPSec CLI handler won't be overwritten to python + write_ipsec_conf(data) + + else: + if os.path.exists(ipsec_ra_conn_file): + remove_confs(delim_ipsec_l2tp_begin, delim_ipsec_l2tp_end, ipsec_ra_conn_file) + remove_confs(delim_ipsec_l2tp_begin, delim_ipsec_l2tp_end, ipsec_secrets_file) + remove_confs(delim_ipsec_l2tp_begin, delim_ipsec_l2tp_end, ipsec_conf_file) + +def restart_ipsec(): + call('ipsec restart >&/dev/null') + # counter for apply swanctl config + counter = 10 + while counter <= 10: + if os.path.exists(charon_pidfile): + call('swanctl -q >&/dev/null') + break + counter -=1 + sleep(1) + if counter == 0: + raise ConfigError('VPN configuration error: IPSec is not running.') + +def apply(data): + # Restart IPSec daemon + restart_ipsec() + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/le_cert.py b/src/conf_mode/le_cert.py new file mode 100755 index 000000000..755c89966 --- /dev/null +++ b/src/conf_mode/le_cert.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2020 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 vyos.defaults +from vyos.config import Config +from vyos import ConfigError +from vyos.util import cmd +from vyos.util import call + +from vyos import airbag +airbag.enable() + +vyos_conf_scripts_dir = vyos.defaults.directories['conf_mode'] +vyos_certbot_dir = vyos.defaults.directories['certbot'] + +dependencies = [ + 'https.py', +] + +def request_certbot(cert): + email = cert.get('email') + if email is not None: + email_flag = '-m {0}'.format(email) + else: + email_flag = '' + + domains = cert.get('domains') + if domains is not None: + domain_flag = '-d ' + ' -d '.join(domains) + else: + domain_flag = '' + + certbot_cmd = f'certbot certonly --config-dir {vyos_certbot_dir} -n --nginx --agree-tos --no-eff-email --expand {email_flag} {domain_flag}' + + cmd(certbot_cmd, + raising=ConfigError, + message="The certbot request failed for the specified domains.") + +def get_config(): + conf = Config() + if not conf.exists('service https certificates certbot'): + return None + else: + conf.set_level('service https certificates certbot') + + cert = {} + + if conf.exists('domain-name'): + cert['domains'] = conf.return_values('domain-name') + + if conf.exists('email'): + cert['email'] = conf.return_value('email') + + return cert + +def verify(cert): + if cert is None: + return None + + if 'domains' not in cert: + raise ConfigError("At least one domain name is required to" + " request a letsencrypt certificate.") + + if 'email' not in cert: + raise ConfigError("An email address is required to request" + " a letsencrypt certificate.") + +def generate(cert): + if cert is None: + return None + + # certbot will attempt to reload nginx, even with 'certonly'; + # start nginx if not active + ret = call('systemctl is-active --quiet nginx.service') + if ret: + call('systemctl start nginx.service') + + request_certbot(cert) + +def apply(cert): + if cert is not None: + call('systemctl restart certbot.timer') + else: + call('systemctl stop certbot.timer') + return None + + for dep in dependencies: + cmd(f'{vyos_conf_scripts_dir}/{dep}', raising=ConfigError) + +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/lldp.py b/src/conf_mode/lldp.py new file mode 100755 index 000000000..1b539887a --- /dev/null +++ b/src/conf_mode/lldp.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2017-2020 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 + +from copy import deepcopy +from sys import exit + +from vyos.config import Config +from vyos.validate import is_addr_assigned,is_loopback_addr +from vyos.version import get_version_data +from vyos import ConfigError +from vyos.util import call +from vyos.template import render + +from vyos import airbag +airbag.enable() + +config_file = "/etc/default/lldpd" +vyos_config_file = "/etc/lldpd.d/01-vyos.conf" +base = ['service', 'lldp'] + +default_config_data = { + "options": '', + "interface_list": '', + "location": '' +} + +def get_options(config): + options = {} + config.set_level(base) + + options['listen_vlan'] = config.exists('listen-vlan') + options['mgmt_addr'] = [] + for addr in config.return_values('management-address'): + if is_addr_assigned(addr) and not is_loopback_addr(addr): + options['mgmt_addr'].append(addr) + else: + message = 'WARNING: LLDP management address {0} invalid - '.format(addr) + if is_loopback_addr(addr): + message += '(loopback address).' + else: + message += 'address not found.' + print(message) + + snmp = config.exists('snmp enable') + options["snmp"] = snmp + if snmp: + config.set_level('') + options["sys_snmp"] = config.exists('service snmp') + config.set_level(base) + + config.set_level(base + ['legacy-protocols']) + options['cdp'] = config.exists('cdp') + options['edp'] = config.exists('edp') + options['fdp'] = config.exists('fdp') + options['sonmp'] = config.exists('sonmp') + + # start with an unknown version information + version_data = get_version_data() + options['description'] = version_data['version'] + options['listen_on'] = [] + + return options + +def get_interface_list(config): + config.set_level(base) + intfs_names = config.list_nodes(['interface']) + if len(intfs_names) < 0: + return 0 + + interface_list = [] + for name in intfs_names: + config.set_level(base + ['interface', name]) + disable = config.exists(['disable']) + intf = { + 'name': name, + 'disable': disable + } + interface_list.append(intf) + return interface_list + + +def get_location_intf(config, name): + path = base + ['interface', name] + config.set_level(path) + + config.set_level(path + ['location']) + elin = '' + coordinate_based = {} + + if config.exists('elin'): + elin = config.return_value('elin') + + if config.exists('coordinate-based'): + config.set_level(path + ['location', 'coordinate-based']) + + coordinate_based['latitude'] = config.return_value(['latitude']) + coordinate_based['longitude'] = config.return_value(['longitude']) + + coordinate_based['altitude'] = '0' + if config.exists(['altitude']): + coordinate_based['altitude'] = config.return_value(['altitude']) + + coordinate_based['datum'] = 'WGS84' + if config.exists(['datum']): + coordinate_based['datum'] = config.return_value(['datum']) + + intf = { + 'name': name, + 'elin': elin, + 'coordinate_based': coordinate_based + + } + return intf + + +def get_location(config): + config.set_level(base) + intfs_names = config.list_nodes(['interface']) + if len(intfs_names) < 0: + return 0 + + if config.exists('disable'): + return 0 + + intfs_location = [] + for name in intfs_names: + intf = get_location_intf(config, name) + intfs_location.append(intf) + + return intfs_location + + +def get_config(): + lldp = deepcopy(default_config_data) + conf = Config() + if not conf.exists(base): + return None + else: + lldp['options'] = get_options(conf) + lldp['interface_list'] = get_interface_list(conf) + lldp['location'] = get_location(conf) + + return lldp + + +def verify(lldp): + # bail out early - looks like removal from running config + if lldp is None: + return + + # check location + for location in lldp['location']: + # check coordinate-based + if len(location['coordinate_based']) > 0: + # check longitude and latitude + if not location['coordinate_based']['longitude']: + raise ConfigError('Must define longitude for interface {0}'.format(location['name'])) + + if not location['coordinate_based']['latitude']: + raise ConfigError('Must define latitude for interface {0}'.format(location['name'])) + + if not re.match(r'^(\d+)(\.\d+)?[nNsS]$', location['coordinate_based']['latitude']): + raise ConfigError('Invalid location for interface {0}:\n' \ + 'latitude should be a number followed by S or N'.format(location['name'])) + + if not re.match(r'^(\d+)(\.\d+)?[eEwW]$', location['coordinate_based']['longitude']): + raise ConfigError('Invalid location for interface {0}:\n' \ + 'longitude should be a number followed by E or W'.format(location['name'])) + + # check altitude and datum if exist + if location['coordinate_based']['altitude']: + if not re.match(r'^[-+0-9\.]+$', location['coordinate_based']['altitude']): + raise ConfigError('Invalid location for interface {0}:\n' \ + 'altitude should be a positive or negative number'.format(location['name'])) + + if location['coordinate_based']['datum']: + if not re.match(r'^(WGS84|NAD83|MLLW)$', location['coordinate_based']['datum']): + raise ConfigError("Invalid location for interface {0}:\n' \ + 'datum should be WGS84, NAD83, or MLLW".format(location['name'])) + + # check elin + elif location['elin']: + if not re.match(r'^[0-9]{10,25}$', location['elin']): + raise ConfigError('Invalid location for interface {0}:\n' \ + 'ELIN number must be between 10-25 numbers'.format(location['name'])) + + # check options + if lldp['options']['snmp']: + if not lldp['options']['sys_snmp']: + raise ConfigError('SNMP must be configured to enable LLDP SNMP') + + +def generate(lldp): + # bail out early - looks like removal from running config + if lldp is None: + return + + # generate listen on interfaces + for intf in lldp['interface_list']: + tmp = '' + # add exclamation mark if interface is disabled + if intf['disable']: + tmp = '!' + + tmp += intf['name'] + lldp['options']['listen_on'].append(tmp) + + # generate /etc/default/lldpd + render(config_file, 'lldp/lldpd.tmpl', lldp) + # generate /etc/lldpd.d/01-vyos.conf + render(vyos_config_file, 'lldp/vyos.conf.tmpl', lldp) + + +def apply(lldp): + if lldp: + # start/restart lldp service + call('systemctl restart lldpd.service') + else: + # LLDP service has been terminated + call('systemctl stop lldpd.service') + os.unlink(config_file) + os.unlink(vyos_config_file) + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) + diff --git a/src/conf_mode/nat.py b/src/conf_mode/nat.py new file mode 100755 index 000000000..dd34dfd66 --- /dev/null +++ b/src/conf_mode/nat.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2020 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 jmespath +import json +import os + +from copy import deepcopy +from sys import exit +from netifaces import interfaces + +from vyos.config import Config +from vyos.template import render +from vyos.util import call +from vyos.util import cmd +from vyos.util import check_kmod +from vyos.validate import is_addr_assigned +from vyos import ConfigError + +from vyos import airbag +airbag.enable() + +k_mod = ['nft_nat', 'nft_chain_nat_ipv4'] + +default_config_data = { + 'deleted': False, + 'destination': [], + 'helper_functions': None, + 'pre_ct_helper': '', + 'pre_ct_conntrack': '', + 'out_ct_helper': '', + 'out_ct_conntrack': '', + 'source': [] +} + +iptables_nat_config = '/tmp/vyos-nat-rules.nft' + +def get_handler(json, chain, target): + """ Get nftable rule handler number of given chain/target combination. + Handler is required when adding NAT/Conntrack helper targets """ + for x in json: + if x['chain'] != chain: + continue + if x['target'] != target: + continue + return x['handle'] + + return None + + +def verify_rule(rule, err_msg): + """ Common verify steps used for both source and destination NAT """ + if rule['translation_port'] or rule['dest_port'] or rule['source_port']: + if rule['protocol'] not in ['tcp', 'udp', 'tcp_udp']: + proto = rule['protocol'] + raise ConfigError(f'{err_msg} ports can only be specified when protocol is "tcp", "udp" or "tcp_udp" (currently "{proto}")') + + if '/' in rule['translation_address']: + raise ConfigError(f'{err_msg}\n' \ + 'Cannot use ports with an IPv4net type translation address as it\n' \ + 'statically maps a whole network of addresses onto another\n' \ + 'network of addresses') + + +def parse_configuration(conf, source_dest): + """ Common wrapper to read in both NAT source and destination CLI """ + ruleset = [] + base_level = ['nat', source_dest] + conf.set_level(base_level) + for number in conf.list_nodes(['rule']): + rule = { + 'description': '', + 'dest_address': '', + 'dest_port': '', + 'disabled': False, + 'exclude': False, + 'interface_in': '', + 'interface_out': '', + 'log': False, + 'protocol': 'all', + 'number': number, + 'source_address': '', + 'source_prefix': '', + 'source_port': '', + 'translation_address': '', + 'translation_prefix': '', + 'translation_port': '' + } + conf.set_level(base_level + ['rule', number]) + + if conf.exists(['description']): + rule['description'] = conf.return_value(['description']) + + if conf.exists(['destination', 'address']): + tmp = conf.return_value(['destination', 'address']) + if tmp.startswith('!'): + tmp = tmp.replace('!', '!=') + rule['dest_address'] = tmp + + if conf.exists(['destination', 'port']): + tmp = conf.return_value(['destination', 'port']) + if tmp.startswith('!'): + tmp = tmp.replace('!', '!=') + rule['dest_port'] = tmp + + if conf.exists(['disable']): + rule['disabled'] = True + + if conf.exists(['exclude']): + rule['exclude'] = True + + if conf.exists(['inbound-interface']): + rule['interface_in'] = conf.return_value(['inbound-interface']) + + if conf.exists(['outbound-interface']): + rule['interface_out'] = conf.return_value(['outbound-interface']) + + if conf.exists(['log']): + rule['log'] = True + + if conf.exists(['protocol']): + rule['protocol'] = conf.return_value(['protocol']) + + if conf.exists(['source', 'address']): + tmp = conf.return_value(['source', 'address']) + if tmp.startswith('!'): + tmp = tmp.replace('!', '!=') + rule['source_address'] = tmp + + if conf.exists(['source', 'prefix']): + rule['source_prefix'] = conf.return_value(['source', 'prefix']) + + if conf.exists(['source', 'port']): + tmp = conf.return_value(['source', 'port']) + if tmp.startswith('!'): + tmp = tmp.replace('!', '!=') + rule['source_port'] = tmp + + if conf.exists(['translation', 'address']): + rule['translation_address'] = conf.return_value(['translation', 'address']) + + if conf.exists(['translation', 'prefix']): + rule['translation_prefix'] = conf.return_value(['translation', 'prefix']) + + if conf.exists(['translation', 'port']): + rule['translation_port'] = conf.return_value(['translation', 'port']) + + ruleset.append(rule) + + return ruleset + +def get_config(): + nat = deepcopy(default_config_data) + conf = Config() + + # read in current nftable (once) for further processing + tmp = cmd('nft -j list table raw') + nftable_json = json.loads(tmp) + + # condense the full JSON table into a list with only relevand informations + pattern = 'nftables[?rule].rule[?expr[].jump].{chain: chain, handle: handle, target: expr[].jump.target | [0]}' + condensed_json = jmespath.search(pattern, nftable_json) + + if not conf.exists(['nat']): + nat['helper_functions'] = 'remove' + + # Retrieve current table handler positions + nat['pre_ct_ignore'] = get_handler(condensed_json, 'PREROUTING', 'VYATTA_CT_HELPER') + nat['pre_ct_conntrack'] = get_handler(condensed_json, 'PREROUTING', 'NAT_CONNTRACK') + nat['out_ct_ignore'] = get_handler(condensed_json, 'OUTPUT', 'VYATTA_CT_HELPER') + nat['out_ct_conntrack'] = get_handler(condensed_json, 'OUTPUT', 'NAT_CONNTRACK') + + nat['deleted'] = True + + return nat + + # check if NAT connection tracking helpers need to be set up - this has to + # be done only once + if not get_handler(condensed_json, 'PREROUTING', 'NAT_CONNTRACK'): + nat['helper_functions'] = 'add' + + # Retrieve current table handler positions + nat['pre_ct_ignore'] = get_handler(condensed_json, 'PREROUTING', 'VYATTA_CT_IGNORE') + nat['pre_ct_conntrack'] = get_handler(condensed_json, 'PREROUTING', 'VYATTA_CT_PREROUTING_HOOK') + nat['out_ct_ignore'] = get_handler(condensed_json, 'OUTPUT', 'VYATTA_CT_IGNORE') + nat['out_ct_conntrack'] = get_handler(condensed_json, 'OUTPUT', 'VYATTA_CT_OUTPUT_HOOK') + + # set config level for parsing in NAT configuration + conf.set_level(['nat']) + + # use a common wrapper function to read in the source / destination + # tree from the config - thus we do not need to replicate almost the + # same code :-) + for tgt in ['source', 'destination', 'nptv6']: + nat[tgt] = parse_configuration(conf, tgt) + + return nat + +def verify(nat): + if nat['deleted']: + # no need to verify the CLI as NAT is going to be deactivated + return None + + if nat['helper_functions']: + if not (nat['pre_ct_ignore'] or nat['pre_ct_conntrack'] or nat['out_ct_ignore'] or nat['out_ct_conntrack']): + raise Exception('could not determine nftable ruleset handlers') + + for rule in nat['source']: + interface = rule['interface_out'] + err_msg = f'Source NAT configuration error in rule "{rule["number"]}":' + + if interface and interface not in 'any' and interface not in interfaces(): + print(f'Warning: rule "{rule["number"]}" interface "{interface}" does not exist on this system') + + if not rule['interface_out']: + raise ConfigError(f'{err_msg} outbound-interface not specified') + + if rule['translation_address']: + addr = rule['translation_address'] + if addr != 'masquerade' and not is_addr_assigned(addr): + print(f'Warning: IP address {addr} does not exist on the system!') + + # common rule verification + verify_rule(rule, err_msg) + + for rule in nat['destination']: + interface = rule['interface_in'] + err_msg = f'Destination NAT configuration error in rule "{rule["number"]}":' + + if interface and interface not in 'any' and interface not in interfaces(): + print(f'Warning: rule "{rule["number"]}" interface "{interface}" does not exist on this system') + + if not rule['interface_in']: + raise ConfigError(f'{err_msg} inbound-interface not specified') + + # common rule verification + verify_rule(rule, err_msg) + + return None + +def generate(nat): + render(iptables_nat_config, 'firewall/nftables-nat.tmpl', nat, trim_blocks=True, permission=0o755) + return None + +def apply(nat): + cmd(f'{iptables_nat_config}') + if os.path.isfile(iptables_nat_config): + os.unlink(iptables_nat_config) + + return None + +if __name__ == '__main__': + try: + check_kmod(k_mod) + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/ntp.py b/src/conf_mode/ntp.py new file mode 100755 index 000000000..bba8f87a4 --- /dev/null +++ b/src/conf_mode/ntp.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2020 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 + +from vyos.config import Config +from vyos.configverify import verify_vrf +from vyos import ConfigError +from vyos.util import call +from vyos.template import render +from vyos import airbag +airbag.enable() + +config_file = r'/etc/ntp.conf' +systemd_override = r'/etc/systemd/system/ntp.service.d/override.conf' + +def get_config(): + conf = Config() + base = ['system', 'ntp'] + + ntp = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + return ntp + +def verify(ntp): + # bail out early - looks like removal from running config + if not ntp: + return None + + if len(ntp.get('allow_clients', {})) and not (len(ntp.get('server', {})) > 0): + raise ConfigError('NTP server not configured') + + verify_vrf(ntp) + return None + +def generate(ntp): + # bail out early - looks like removal from running config + if not ntp: + return None + + render(config_file, 'ntp/ntp.conf.tmpl', ntp, trim_blocks=True) + render(systemd_override, 'ntp/override.conf.tmpl', ntp, trim_blocks=True) + + return None + +def apply(ntp): + if not ntp: + # NTP support is removed in the commit + call('systemctl stop ntp.service') + if os.path.exists(config_file): + os.unlink(config_file) + if os.path.isfile(systemd_override): + os.unlink(systemd_override) + + # Reload systemd manager configuration + call('systemctl daemon-reload') + if ntp: + call('systemctl restart ntp.service') + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/protocols_bfd.py b/src/conf_mode/protocols_bfd.py new file mode 100755 index 000000000..c8e791c78 --- /dev/null +++ b/src/conf_mode/protocols_bfd.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2020 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 + +from sys import exit +from copy import deepcopy + +from vyos.config import Config +from vyos.validate import is_ipv6_link_local, is_ipv6 +from vyos import ConfigError +from vyos.util import call +from vyos.template import render + +from vyos import airbag +airbag.enable() + +config_file = r'/tmp/bfd.frr' + +default_config_data = { + 'new_peers': [], + 'old_peers' : [] +} + +# get configuration for BFD peer from proposed or effective configuration +def get_bfd_peer_config(peer, conf_mode="proposed"): + conf = Config() + conf.set_level('protocols bfd peer {0}'.format(peer)) + + bfd_peer = { + 'remote': peer, + 'shutdown': False, + 'src_if': '', + 'src_addr': '', + 'multiplier': '3', + 'rx_interval': '300', + 'tx_interval': '300', + 'multihop': False, + 'echo_interval': '', + 'echo_mode': False, + } + + # Check if individual peer is disabled + if conf_mode == "effective" and conf.exists_effective('shutdown'): + bfd_peer['shutdown'] = True + if conf_mode == "proposed" and conf.exists('shutdown'): + bfd_peer['shutdown'] = True + + # Check if peer has a local source interface configured + if conf_mode == "effective" and conf.exists_effective('source interface'): + bfd_peer['src_if'] = conf.return_effective_value('source interface') + if conf_mode == "proposed" and conf.exists('source interface'): + bfd_peer['src_if'] = conf.return_value('source interface') + + # Check if peer has a local source address configured - this is mandatory for IPv6 + if conf_mode == "effective" and conf.exists_effective('source address'): + bfd_peer['src_addr'] = conf.return_effective_value('source address') + if conf_mode == "proposed" and conf.exists('source address'): + bfd_peer['src_addr'] = conf.return_value('source address') + + # Tell BFD daemon that we should expect packets with TTL less than 254 + # (because it will take more than one hop) and to listen on the multihop + # port (4784) + if conf_mode == "effective" and conf.exists_effective('multihop'): + bfd_peer['multihop'] = True + if conf_mode == "proposed" and conf.exists('multihop'): + bfd_peer['multihop'] = True + + # Configures the minimum interval that this system is capable of receiving + # control packets. The default value is 300 milliseconds. + if conf_mode == "effective" and conf.exists_effective('interval receive'): + bfd_peer['rx_interval'] = conf.return_effective_value('interval receive') + if conf_mode == "proposed" and conf.exists('interval receive'): + bfd_peer['rx_interval'] = conf.return_value('interval receive') + + # The minimum transmission interval (less jitter) that this system wants + # to use to send BFD control packets. + if conf_mode == "effective" and conf.exists_effective('interval transmit'): + bfd_peer['tx_interval'] = conf.return_effective_value('interval transmit') + if conf_mode == "proposed" and conf.exists('interval transmit'): + bfd_peer['tx_interval'] = conf.return_value('interval transmit') + + # Configures the detection multiplier to determine packet loss. The remote + # transmission interval will be multiplied by this value to determine the + # connection loss detection timer. The default value is 3. + if conf_mode == "effective" and conf.exists_effective('interval multiplier'): + bfd_peer['multiplier'] = conf.return_effective_value('interval multiplier') + if conf_mode == "proposed" and conf.exists('interval multiplier'): + bfd_peer['multiplier'] = conf.return_value('interval multiplier') + + # Configures the minimal echo receive transmission interval that this system is capable of handling + if conf_mode == "effective" and conf.exists_effective('interval echo-interval'): + bfd_peer['echo_interval'] = conf.return_effective_value('interval echo-interval') + if conf_mode == "proposed" and conf.exists('interval echo-interval'): + bfd_peer['echo_interval'] = conf.return_value('interval echo-interval') + + # Enables or disables the echo transmission mode + if conf_mode == "effective" and conf.exists_effective('echo-mode'): + bfd_peer['echo_mode'] = True + if conf_mode == "proposed" and conf.exists('echo-mode'): + bfd_peer['echo_mode'] = True + + return bfd_peer + +def get_config(): + bfd = deepcopy(default_config_data) + conf = Config() + if not (conf.exists('protocols bfd') or conf.exists_effective('protocols bfd')): + return None + else: + conf.set_level('protocols bfd') + + # as we have to use vtysh to talk to FRR we also need to know + # which peers are gone due to a config removal - thus we read in + # all peers (active or to delete) + for peer in conf.list_effective_nodes('peer'): + bfd['old_peers'].append(get_bfd_peer_config(peer, "effective")) + + for peer in conf.list_nodes('peer'): + bfd['new_peers'].append(get_bfd_peer_config(peer)) + + # find deleted peers + set_new_peers = set(conf.list_nodes('peer')) + set_old_peers = set(conf.list_effective_nodes('peer')) + bfd['deleted_peers'] = set_old_peers - set_new_peers + + return bfd + +def verify(bfd): + if bfd is None: + return None + + # some variables to use later + conf = Config() + + for peer in bfd['new_peers']: + # IPv6 link local peers require an explicit local address/interface + if is_ipv6_link_local(peer['remote']): + if not (peer['src_if'] and peer['src_addr']): + raise ConfigError('BFD IPv6 link-local peers require explicit local address and interface setting') + + # IPv6 peers require an explicit local address + if is_ipv6(peer['remote']): + if not peer['src_addr']: + raise ConfigError('BFD IPv6 peers require explicit local address setting') + + # multihop require source address + if peer['multihop'] and not peer['src_addr']: + raise ConfigError('Multihop require source address') + + # multihop and echo-mode cannot be used together + 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') + + # check if we deleted peers are not used in configuration + if conf.exists('protocols bgp'): + bgp_as = conf.list_nodes('protocols bgp')[0] + + # check BGP neighbors + for peer in bfd['deleted_peers']: + if conf.exists('protocols bgp {0} neighbor {1} bfd'.format(bgp_as, peer)): + raise ConfigError('Cannot delete BFD peer {0}: it is used in BGP configuration'.format(peer)) + if conf.exists('protocols bgp {0} neighbor {1} peer-group'.format(bgp_as, peer)): + peer_group = conf.return_value('protocols bgp {0} neighbor {1} peer-group'.format(bgp_as, peer)) + if conf.exists('protocols bgp {0} peer-group {1} bfd'.format(bgp_as, peer_group)): + raise ConfigError('Cannot delete BFD peer {0}: it belongs to BGP peer-group {1} with enabled BFD'.format(peer, peer_group)) + + return None + +def generate(bfd): + if bfd is None: + return None + + render(config_file, 'frr/bfd.frr.tmpl', bfd) + return None + +def apply(bfd): + if bfd is None: + return None + + call("vtysh -d bfdd -f " + config_file) + if os.path.exists(config_file): + os.remove(config_file) + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/protocols_bgp.py b/src/conf_mode/protocols_bgp.py new file mode 100755 index 000000000..3aa76d866 --- /dev/null +++ b/src/conf_mode/protocols_bgp.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2020 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 jmespath + +from copy import deepcopy +from sys import exit + +from vyos.config import Config +from vyos.template import render +from vyos import ConfigError, airbag +airbag.enable() + +config_file = r'/tmp/bgp.frr' + +default_config_data = { + 'as_number': '' +} + +def get_config(): + bgp = deepcopy(default_config_data) + conf = Config() + + # this lives in the "nbgp" tree until we switch over + base = ['protocols', 'nbgp'] + if not conf.exists(base): + return None + + bgp = deepcopy(default_config_data) + # Get full BGP configuration as dictionary - output the configuration for development + # + # vyos@vyos# commit + # [ protocols nbgp 65000 ] + # {'nbgp': {'65000': {'address-family': {'ipv4-unicast': {'aggregate-address': {'1.1.0.0/16': {}, + # '2.2.2.0/24': {}}}, + # 'ipv6-unicast': {'aggregate-address': {'2001:db8::/32': {}}}}, + # 'neighbor': {'192.0.2.1': {'password': 'foo', + # 'remote-as': '100'}}}}} + # + tmp = conf.get_config_dict(base) + + # extract base key from dict as this is our AS number + bgp['as_number'] = jmespath.search('nbgp | keys(@) [0]', tmp) + + # adjust level of dictionary returned by get_config_dict() + # by using jmesgpath and update dictionary + bgp.update(jmespath.search('nbgp.* | [0]', tmp)) + + from pprint import pprint + pprint(bgp) + # resulting in e.g. + # vyos@vyos# commit + # [ protocols nbgp 65000 ] + # {'address-family': {'ipv4-unicast': {'aggregate-address': {'1.1.0.0/16': {}, + # '2.2.2.0/24': {}}}, + # 'ipv6-unicast': {'aggregate-address': {'2001:db8::/32': {}}}}, + # 'as_number': '65000', + # 'neighbor': {'192.0.2.1': {'password': 'foo', 'remote-as': '100'}}, + # 'timers': {'holdtime': '5'}} + + return bgp + +def verify(bgp): + # bail out early - looks like removal from running config + if not bgp: + return None + + return None + +def generate(bgp): + # bail out early - looks like removal from running config + if not bgp: + return None + + render(config_file, 'frr/bgp.frr.tmpl', bgp) + return None + +def apply(bgp): + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/protocols_igmp.py b/src/conf_mode/protocols_igmp.py new file mode 100755 index 000000000..ca148fd6a --- /dev/null +++ b/src/conf_mode/protocols_igmp.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2020 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 + +from ipaddress import IPv4Address +from sys import exit + +from vyos import ConfigError +from vyos.config import Config +from vyos.util import call +from vyos.template import render + +from vyos import airbag +airbag.enable() + +config_file = r'/tmp/igmp.frr' + +def get_config(): + conf = Config() + igmp_conf = { + 'igmp_conf' : False, + 'old_ifaces' : {}, + 'ifaces' : {} + } + if not (conf.exists('protocols igmp') or conf.exists_effective('protocols igmp')): + return None + + if conf.exists('protocols igmp'): + igmp_conf['igmp_conf'] = True + + conf.set_level('protocols igmp') + + # # Get interfaces + for iface in conf.list_effective_nodes('interface'): + igmp_conf['old_ifaces'].update({ + iface : { + 'version' : conf.return_effective_value('interface {0} version'.format(iface)), + 'query_interval' : conf.return_effective_value('interface {0} query-interval'.format(iface)), + 'query_max_resp_time' : conf.return_effective_value('interface {0} query-max-response-time'.format(iface)), + 'gr_join' : {} + } + }) + for gr_join in conf.list_effective_nodes('interface {0} join'.format(iface)): + igmp_conf['old_ifaces'][iface]['gr_join'][gr_join] = conf.return_effective_values('interface {0} join {1} source'.format(iface, gr_join)) + + for iface in conf.list_nodes('interface'): + igmp_conf['ifaces'].update({ + iface : { + 'version' : conf.return_value('interface {0} version'.format(iface)), + 'query_interval' : conf.return_value('interface {0} query-interval'.format(iface)), + 'query_max_resp_time' : conf.return_value('interface {0} query-max-response-time'.format(iface)), + 'gr_join' : {} + } + }) + for gr_join in conf.list_nodes('interface {0} join'.format(iface)): + igmp_conf['ifaces'][iface]['gr_join'][gr_join] = conf.return_values('interface {0} join {1} source'.format(iface, gr_join)) + + return igmp_conf + +def verify(igmp): + if igmp is None: + return None + + if igmp['igmp_conf']: + # Check interfaces + if not igmp['ifaces']: + raise ConfigError(f"IGMP require defined interfaces!") + # Check, is this multicast group + for intfc in igmp['ifaces']: + for gr_addr in igmp['ifaces'][intfc]['gr_join']: + if IPv4Address(gr_addr) < IPv4Address('224.0.0.0'): + raise ConfigError(gr_addr + " not a multicast group") + +def generate(igmp): + if igmp is None: + return None + + render(config_file, 'frr/igmp.frr.tmpl', igmp) + return None + +def apply(igmp): + if igmp is None: + return None + + if os.path.exists(config_file): + call(f'vtysh -d pimd -f {config_file}') + os.remove(config_file) + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/protocols_mpls.py b/src/conf_mode/protocols_mpls.py new file mode 100755 index 000000000..bcb16fa04 --- /dev/null +++ b/src/conf_mode/protocols_mpls.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2020 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 + +from vyos.config import Config +from vyos import ConfigError +from vyos.util import call +from vyos.template import render + +from vyos import airbag +airbag.enable() + +config_file = r'/tmp/ldpd.frr' + +def sysctl(name, value): + call('sysctl -wq {}={}'.format(name, value)) + +def get_config(): + conf = Config() + mpls_conf = { + 'router_id' : None, + 'mpls_ldp' : False, + 'old_ldp' : { + 'interfaces' : [], + 'neighbors' : {}, + 'd_transp_ipv4' : None, + 'd_transp_ipv6' : None, + 'hello_holdtime' : None, + 'hello_interval' : None + }, + 'ldp' : { + 'interfaces' : [], + 'neighbors' : {}, + 'd_transp_ipv4' : None, + 'd_transp_ipv6' : None, + 'hello_holdtime' : None, + 'hello_interval' : None + } + } + if not (conf.exists('protocols mpls') or conf.exists_effective('protocols mpls')): + return None + + if conf.exists('protocols mpls ldp'): + mpls_conf['mpls_ldp'] = True + + conf.set_level('protocols mpls ldp') + + # Get router-id + if conf.exists_effective('router-id'): + mpls_conf['old_router_id'] = conf.return_effective_value('router-id') + if conf.exists('router-id'): + mpls_conf['router_id'] = conf.return_value('router-id') + + # Get hello holdtime + if conf.exists_effective('discovery hello-holdtime'): + mpls_conf['old_ldp']['hello_holdtime'] = conf.return_effective_value('discovery hello-holdtime') + + if conf.exists('discovery hello-holdtime'): + mpls_conf['ldp']['hello_holdtime'] = conf.return_value('discovery hello-holdtime') + + # Get hello interval + if conf.exists_effective('discovery hello-interval'): + mpls_conf['old_ldp']['hello_interval'] = conf.return_effective_value('discovery hello-interval') + + if conf.exists('discovery hello-interval'): + mpls_conf['ldp']['hello_interval'] = conf.return_value('discovery hello-interval') + + # Get discovery transport-ipv4-address + if conf.exists_effective('discovery transport-ipv4-address'): + mpls_conf['old_ldp']['d_transp_ipv4'] = conf.return_effective_value('discovery transport-ipv4-address') + + if conf.exists('discovery transport-ipv4-address'): + mpls_conf['ldp']['d_transp_ipv4'] = conf.return_value('discovery transport-ipv4-address') + + # Get discovery transport-ipv6-address + if conf.exists_effective('discovery transport-ipv6-address'): + mpls_conf['old_ldp']['d_transp_ipv6'] = conf.return_effective_value('discovery transport-ipv6-address') + + if conf.exists('discovery transport-ipv6-address'): + mpls_conf['ldp']['d_transp_ipv6'] = conf.return_value('discovery transport-ipv6-address') + + # Get interfaces + if conf.exists_effective('interface'): + mpls_conf['old_ldp']['interfaces'] = conf.return_effective_values('interface') + + if conf.exists('interface'): + mpls_conf['ldp']['interfaces'] = conf.return_values('interface') + + # Get neighbors + for neighbor in conf.list_effective_nodes('neighbor'): + mpls_conf['old_ldp']['neighbors'].update({ + neighbor : { + 'password' : conf.return_effective_value('neighbor {0} password'.format(neighbor)) + } + }) + + for neighbor in conf.list_nodes('neighbor'): + mpls_conf['ldp']['neighbors'].update({ + neighbor : { + 'password' : conf.return_value('neighbor {0} password'.format(neighbor)) + } + }) + + return mpls_conf + +def operate_mpls_on_intfc(interfaces, action): + rp_filter = 0 + if action == 1: + rp_filter = 2 + for iface in interfaces: + sysctl('net.mpls.conf.{0}.input'.format(iface), action) + # Operate rp filter + sysctl('net.ipv4.conf.{0}.rp_filter'.format(iface), rp_filter) + +def verify(mpls): + if mpls is None: + return None + + if mpls['mpls_ldp']: + # Requre router-id + if not mpls['router_id']: + raise ConfigError(f"MPLS ldp router-id is mandatory!") + + # Requre discovery transport-address + if not mpls['ldp']['d_transp_ipv4'] and not mpls['ldp']['d_transp_ipv6']: + raise ConfigError(f"MPLS ldp discovery transport address is mandatory!") + + # Requre interface + if not mpls['ldp']['interfaces']: + raise ConfigError(f"MPLS ldp interface is mandatory!") + +def generate(mpls): + if mpls is None: + return None + + render(config_file, 'frr/ldpd.frr.tmpl', mpls) + return None + +def apply(mpls): + if mpls is None: + return None + + # Set number of entries in the platform label table + if mpls['mpls_ldp']: + sysctl('net.mpls.platform_labels', '1048575') + else: + sysctl('net.mpls.platform_labels', '0') + + # Do not copy IP TTL to MPLS header + sysctl('net.mpls.ip_ttl_propagate', '0') + + # Allow mpls on interfaces + operate_mpls_on_intfc(mpls['ldp']['interfaces'], 1) + + # Disable mpls on deleted interfaces + diactive_ifaces = set(mpls['old_ldp']['interfaces']).difference(mpls['ldp']['interfaces']) + operate_mpls_on_intfc(diactive_ifaces, 0) + + if os.path.exists(config_file): + call(f'vtysh -d ldpd -f {config_file}') + os.remove(config_file) + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/protocols_pim.py b/src/conf_mode/protocols_pim.py new file mode 100755 index 000000000..8aa324bac --- /dev/null +++ b/src/conf_mode/protocols_pim.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2020 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 + +from ipaddress import IPv4Address +from sys import exit + +from vyos.config import Config +from vyos import ConfigError +from vyos.util import call +from vyos.template import render + +from vyos import airbag +airbag.enable() + +config_file = r'/tmp/pimd.frr' + +def get_config(): + conf = Config() + pim_conf = { + 'pim_conf' : False, + 'old_pim' : { + 'ifaces' : {}, + 'rp' : {} + }, + 'pim' : { + 'ifaces' : {}, + 'rp' : {} + } + } + if not (conf.exists('protocols pim') or conf.exists_effective('protocols pim')): + return None + + if conf.exists('protocols pim'): + pim_conf['pim_conf'] = True + + conf.set_level('protocols pim') + + # Get interfaces + for iface in conf.list_effective_nodes('interface'): + pim_conf['old_pim']['ifaces'].update({ + iface : { + 'hello' : conf.return_effective_value('interface {0} hello'.format(iface)), + 'dr_prio' : conf.return_effective_value('interface {0} dr-priority'.format(iface)) + } + }) + + for iface in conf.list_nodes('interface'): + pim_conf['pim']['ifaces'].update({ + iface : { + 'hello' : conf.return_value('interface {0} hello'.format(iface)), + 'dr_prio' : conf.return_value('interface {0} dr-priority'.format(iface)), + } + }) + + conf.set_level('protocols pim rp') + + # Get RPs addresses + for rp_addr in conf.list_effective_nodes('address'): + pim_conf['old_pim']['rp'][rp_addr] = conf.return_effective_values('address {0} group'.format(rp_addr)) + + for rp_addr in conf.list_nodes('address'): + pim_conf['pim']['rp'][rp_addr] = conf.return_values('address {0} group'.format(rp_addr)) + + # Get RP keep-alive-timer + if conf.exists_effective('rp keep-alive-timer'): + pim_conf['old_pim']['rp_keep_alive'] = conf.return_effective_value('rp keep-alive-timer') + if conf.exists('rp keep-alive-timer'): + pim_conf['pim']['rp_keep_alive'] = conf.return_value('rp keep-alive-timer') + + return pim_conf + +def verify(pim): + if pim is None: + return None + + if pim['pim_conf']: + # Check interfaces + if not pim['pim']['ifaces']: + raise ConfigError(f"PIM require defined interfaces!") + + if not pim['pim']['rp']: + raise ConfigError(f"RP address required") + + # Check unique multicast groups + uniq_groups = [] + for rp_addr in pim['pim']['rp']: + if not pim['pim']['rp'][rp_addr]: + raise ConfigError(f"Group should be specified for RP " + rp_addr) + for group in pim['pim']['rp'][rp_addr]: + if (group in uniq_groups): + raise ConfigError(f"Group range " + group + " specified cannot exact match another") + + # Check, is this multicast group + gr_addr = group.split('/') + if IPv4Address(gr_addr[0]) < IPv4Address('224.0.0.0'): + raise ConfigError(group + " not a multicast group") + + uniq_groups.extend(pim['pim']['rp'][rp_addr]) + +def generate(pim): + if pim is None: + return None + + render(config_file, 'frr/pimd.frr.tmpl', pim) + return None + +def apply(pim): + if pim is None: + return None + + if os.path.exists(config_file): + call("vtysh -d pimd -f " + config_file) + os.remove(config_file) + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/protocols_rip.py b/src/conf_mode/protocols_rip.py new file mode 100755 index 000000000..4f8816d61 --- /dev/null +++ b/src/conf_mode/protocols_rip.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2020 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 + +from sys import exit + +from vyos import ConfigError +from vyos.config import Config +from vyos.util import call +from vyos.template import render + +from vyos import airbag +airbag.enable() + +config_file = r'/tmp/ripd.frr' + +def get_config(): + conf = Config() + base = ['protocols', 'rip'] + rip_conf = { + 'rip_conf' : False, + 'default_distance' : [], + 'default_originate' : False, + 'old_rip' : { + 'default_metric' : [], + 'distribute' : {}, + 'neighbors' : {}, + 'networks' : {}, + 'net_distance' : {}, + 'passive_iface' : {}, + 'redist' : {}, + 'route' : {}, + 'ifaces' : {}, + 'timer_garbage' : 120, + 'timer_timeout' : 180, + 'timer_update' : 30 + }, + 'rip' : { + 'default_metric' : None, + 'distribute' : {}, + 'neighbors' : {}, + 'networks' : {}, + 'net_distance' : {}, + 'passive_iface' : {}, + 'redist' : {}, + 'route' : {}, + 'ifaces' : {}, + 'timer_garbage' : 120, + 'timer_timeout' : 180, + 'timer_update' : 30 + } + } + + if not (conf.exists(base) or conf.exists_effective(base)): + return None + + if conf.exists(base): + rip_conf['rip_conf'] = True + + conf.set_level(base) + + # Get default distance + if conf.exists_effective('default-distance'): + rip_conf['old_default_distance'] = conf.return_effective_value('default-distance') + + if conf.exists('default-distance'): + rip_conf['default_distance'] = conf.return_value('default-distance') + + # Get default information originate (originate default route) + if conf.exists_effective('default-information originate'): + rip_conf['old_default_originate'] = True + + if conf.exists('default-information originate'): + rip_conf['default_originate'] = True + + # Get default-metric + if conf.exists_effective('default-metric'): + rip_conf['old_rip']['default_metric'] = conf.return_effective_value('default-metric') + + if conf.exists('default-metric'): + rip_conf['rip']['default_metric'] = conf.return_value('default-metric') + + # Get distribute list interface old_rip + for dist_iface in conf.list_effective_nodes('distribute-list interface'): + # Set level 'distribute-list interface ethX' + conf.set_level((str(base)) + ' distribute-list interface ' + dist_iface) + rip_conf['rip']['distribute'].update({ + dist_iface : { + 'iface_access_list_in': conf.return_effective_value('access-list in'.format(dist_iface)), + 'iface_access_list_out': conf.return_effective_value('access-list out'.format(dist_iface)), + 'iface_prefix_list_in': conf.return_effective_value('prefix-list in'.format(dist_iface)), + 'iface_prefix_list_out': conf.return_effective_value('prefix-list out'.format(dist_iface)) + } + }) + + # Access-list in old_rip + if conf.exists_effective('access-list in'.format(dist_iface)): + rip_conf['old_rip']['iface_access_list_in'] = conf.return_effective_value('access-list in'.format(dist_iface)) + # Access-list out old_rip + if conf.exists_effective('access-list out'.format(dist_iface)): + rip_conf['old_rip']['iface_access_list_out'] = conf.return_effective_value('access-list out'.format(dist_iface)) + # Prefix-list in old_rip + if conf.exists_effective('prefix-list in'.format(dist_iface)): + rip_conf['old_rip']['iface_prefix_list_in'] = conf.return_effective_value('prefix-list in'.format(dist_iface)) + # Prefix-list out old_rip + if conf.exists_effective('prefix-list out'.format(dist_iface)): + rip_conf['old_rip']['iface_prefix_list_out'] = conf.return_effective_value('prefix-list out'.format(dist_iface)) + + conf.set_level(base) + + # Get distribute list interface + for dist_iface in conf.list_nodes('distribute-list interface'): + # Set level 'distribute-list interface ethX' + conf.set_level((str(base)) + ' distribute-list interface ' + dist_iface) + rip_conf['rip']['distribute'].update({ + dist_iface : { + 'iface_access_list_in': conf.return_value('access-list in'.format(dist_iface)), + 'iface_access_list_out': conf.return_value('access-list out'.format(dist_iface)), + 'iface_prefix_list_in': conf.return_value('prefix-list in'.format(dist_iface)), + 'iface_prefix_list_out': conf.return_value('prefix-list out'.format(dist_iface)) + } + }) + + # Access-list in + if conf.exists('access-list in'.format(dist_iface)): + rip_conf['rip']['iface_access_list_in'] = conf.return_value('access-list in'.format(dist_iface)) + # Access-list out + if conf.exists('access-list out'.format(dist_iface)): + rip_conf['rip']['iface_access_list_out'] = conf.return_value('access-list out'.format(dist_iface)) + # Prefix-list in + if conf.exists('prefix-list in'.format(dist_iface)): + rip_conf['rip']['iface_prefix_list_in'] = conf.return_value('prefix-list in'.format(dist_iface)) + # Prefix-list out + if conf.exists('prefix-list out'.format(dist_iface)): + rip_conf['rip']['iface_prefix_list_out'] = conf.return_value('prefix-list out'.format(dist_iface)) + + conf.set_level((str(base)) + ' distribute-list') + + # Get distribute list, access-list in + if conf.exists_effective('access-list in'): + rip_conf['old_rip']['dist_acl_in'] = conf.return_effective_value('access-list in') + + if conf.exists('access-list in'): + rip_conf['rip']['dist_acl_in'] = conf.return_value('access-list in') + + # Get distribute list, access-list out + if conf.exists_effective('access-list out'): + rip_conf['old_rip']['dist_acl_out'] = conf.return_effective_value('access-list out') + + if conf.exists('access-list out'): + rip_conf['rip']['dist_acl_out'] = conf.return_value('access-list out') + + # Get ditstribute list, prefix-list in + if conf.exists_effective('prefix-list in'): + rip_conf['old_rip']['dist_prfx_in'] = conf.return_effective_value('prefix-list in') + + if conf.exists('prefix-list in'): + rip_conf['rip']['dist_prfx_in'] = conf.return_value('prefix-list in') + + # Get distribute list, prefix-list out + if conf.exists_effective('prefix-list out'): + rip_conf['old_rip']['dist_prfx_out'] = conf.return_effective_value('prefix-list out') + + if conf.exists('prefix-list out'): + rip_conf['rip']['dist_prfx_out'] = conf.return_value('prefix-list out') + + conf.set_level(base) + + # Get network Interfaces + if conf.exists_effective('interface'): + rip_conf['old_rip']['ifaces'] = conf.return_effective_values('interface') + + if conf.exists('interface'): + rip_conf['rip']['ifaces'] = conf.return_values('interface') + + # Get neighbors + if conf.exists_effective('neighbor'): + rip_conf['old_rip']['neighbors'] = conf.return_effective_values('neighbor') + + if conf.exists('neighbor'): + rip_conf['rip']['neighbors'] = conf.return_values('neighbor') + + # Get networks + if conf.exists_effective('network'): + rip_conf['old_rip']['networks'] = conf.return_effective_values('network') + + if conf.exists('network'): + rip_conf['rip']['networks'] = conf.return_values('network') + + # Get network-distance old_rip + for net_dist in conf.list_effective_nodes('network-distance'): + rip_conf['old_rip']['net_distance'].update({ + net_dist : { + 'access_list' : conf.return_effective_value('network-distance {0} access-list'.format(net_dist)), + 'distance' : conf.return_effective_value('network-distance {0} distance'.format(net_dist)), + } + }) + + # Get network-distance + for net_dist in conf.list_nodes('network-distance'): + rip_conf['rip']['net_distance'].update({ + net_dist : { + 'access_list' : conf.return_value('network-distance {0} access-list'.format(net_dist)), + 'distance' : conf.return_value('network-distance {0} distance'.format(net_dist)), + } + }) + + # Get passive-interface + if conf.exists_effective('passive-interface'): + rip_conf['old_rip']['passive_iface'] = conf.return_effective_values('passive-interface') + + if conf.exists('passive-interface'): + rip_conf['rip']['passive_iface'] = conf.return_values('passive-interface') + + # Get redistribute for old_rip + for protocol in conf.list_effective_nodes('redistribute'): + rip_conf['old_rip']['redist'].update({ + protocol : { + 'metric' : conf.return_effective_value('redistribute {0} metric'.format(protocol)), + 'route_map' : conf.return_effective_value('redistribute {0} route-map'.format(protocol)), + } + }) + + # Get redistribute + for protocol in conf.list_nodes('redistribute'): + rip_conf['rip']['redist'].update({ + protocol : { + 'metric' : conf.return_value('redistribute {0} metric'.format(protocol)), + 'route_map' : conf.return_value('redistribute {0} route-map'.format(protocol)), + } + }) + + conf.set_level(base) + + # Get route + if conf.exists_effective('route'): + rip_conf['old_rip']['route'] = conf.return_effective_values('route') + + if conf.exists('route'): + rip_conf['rip']['route'] = conf.return_values('route') + + # Get timers garbage + if conf.exists_effective('timers garbage-collection'): + rip_conf['old_rip']['timer_garbage'] = conf.return_effective_value('timers garbage-collection') + + if conf.exists('timers garbage-collection'): + rip_conf['rip']['timer_garbage'] = conf.return_value('timers garbage-collection') + + # Get timers timeout + if conf.exists_effective('timers timeout'): + rip_conf['old_rip']['timer_timeout'] = conf.return_effective_value('timers timeout') + + if conf.exists('timers timeout'): + rip_conf['rip']['timer_timeout'] = conf.return_value('timers timeout') + + # Get timers update + if conf.exists_effective('timers update'): + rip_conf['old_rip']['timer_update'] = conf.return_effective_value('timers update') + + if conf.exists('timers update'): + rip_conf['rip']['timer_update'] = conf.return_value('timers update') + + return rip_conf + +def verify(rip): + if rip is None: + return None + + # Check for network. If network-distance acl is set and distance not set + for net in rip['rip']['net_distance']: + if not rip['rip']['net_distance'][net]['distance']: + raise ConfigError(f"Must specify distance for network {net}") + +def generate(rip): + if rip is None: + return None + + render(config_file, 'frr/rip.frr.tmpl', rip) + return None + +def apply(rip): + if rip is None: + return None + + if os.path.exists(config_file): + call(f'vtysh -d ripd -f {config_file}') + os.remove(config_file) + else: + print("File {0} not found".format(config_file)) + + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) + diff --git a/src/conf_mode/protocols_static_multicast.py b/src/conf_mode/protocols_static_multicast.py new file mode 100755 index 000000000..232d1e181 --- /dev/null +++ b/src/conf_mode/protocols_static_multicast.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2020 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 + +from ipaddress import IPv4Address +from sys import exit + +from vyos import ConfigError +from vyos.config import Config +from vyos.util import call +from vyos.template import render + +from vyos import airbag +airbag.enable() + +config_file = r'/tmp/static_mcast.frr' + +# Get configuration for static multicast route +def get_config(): + conf = Config() + mroute = { + 'old_mroute' : {}, + 'mroute' : {} + } + + base_path = "protocols static multicast" + + if not (conf.exists(base_path) or conf.exists_effective(base_path)): + return None + + conf.set_level(base_path) + + # Get multicast effective routes + for route in conf.list_effective_nodes('route'): + mroute['old_mroute'][route] = {} + for next_hop in conf.list_effective_nodes('route {0} next-hop'.format(route)): + mroute['old_mroute'][route].update({ + next_hop : conf.return_value('route {0} next-hop {1} distance'.format(route, next_hop)) + }) + + # Get multicast effective interface-routes + for route in conf.list_effective_nodes('interface-route'): + if not route in mroute['old_mroute']: + mroute['old_mroute'][route] = {} + for next_hop in conf.list_effective_nodes('interface-route {0} next-hop-interface'.format(route)): + mroute['old_mroute'][route].update({ + next_hop : conf.return_value('interface-route {0} next-hop-interface {1} distance'.format(route, next_hop)) + }) + + # Get multicast routes + for route in conf.list_nodes('route'): + mroute['mroute'][route] = {} + for next_hop in conf.list_nodes('route {0} next-hop'.format(route)): + mroute['mroute'][route].update({ + next_hop : conf.return_value('route {0} next-hop {1} distance'.format(route, next_hop)) + }) + + # Get multicast interface-routes + for route in conf.list_nodes('interface-route'): + if not route in mroute['mroute']: + mroute['mroute'][route] = {} + for next_hop in conf.list_nodes('interface-route {0} next-hop-interface'.format(route)): + mroute['mroute'][route].update({ + next_hop : conf.return_value('interface-route {0} next-hop-interface {1} distance'.format(route, next_hop)) + }) + + return mroute + +def verify(mroute): + if mroute is None: + return None + + for route in mroute['mroute']: + route = route.split('/') + if IPv4Address(route[0]) < IPv4Address('224.0.0.0'): + raise ConfigError(route + " not a multicast network") + +def generate(mroute): + if mroute is None: + return None + + render(config_file, 'frr/static_mcast.frr.tmpl', mroute) + return None + +def apply(mroute): + if mroute is None: + return None + + if os.path.exists(config_file): + call(f'vtysh -d staticd -f {config_file}') + os.remove(config_file) + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/salt-minion.py b/src/conf_mode/salt-minion.py new file mode 100755 index 000000000..3343d1247 --- /dev/null +++ b/src/conf_mode/salt-minion.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2020 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 + +from copy import deepcopy +from socket import gethostname +from sys import exit +from urllib3 import PoolManager + +from vyos.config import Config +from vyos.template import render +from vyos.util import call, chown +from vyos import ConfigError + +from vyos import airbag +airbag.enable() + +config_file = r'/etc/salt/minion' +master_keyfile = r'/opt/vyatta/etc/config/salt/pki/minion/master_sign.pub' + +default_config_data = { + 'hash': 'sha256', + 'log_level': 'warning', + 'master' : 'salt', + 'user': 'minion', + 'group': 'vyattacfg', + 'salt_id': gethostname(), + 'mine_interval': '60', + 'verify_master_pubkey_sign': 'false', + 'master_key': '' +} + +def get_config(): + salt = deepcopy(default_config_data) + conf = Config() + base = ['service', 'salt-minion'] + + if not conf.exists(base): + return None + else: + conf.set_level(base) + + if conf.exists(['hash']): + salt['hash'] = conf.return_value(['hash']) + + if conf.exists(['master']): + salt['master'] = conf.return_values(['master']) + + if conf.exists(['id']): + salt['salt_id'] = conf.return_value(['id']) + + if conf.exists(['user']): + salt['user'] = conf.return_value(['user']) + + if conf.exists(['interval']): + salt['interval'] = conf.return_value(['interval']) + + if conf.exists(['master-key']): + salt['master_key'] = conf.return_value(['master-key']) + salt['verify_master_pubkey_sign'] = 'true' + + return salt + +def verify(salt): + return None + +def generate(salt): + if not salt: + return None + + render(config_file, 'salt-minion/minion.tmpl', salt, + user=salt['user'], group=salt['group']) + + if not os.path.exists(master_keyfile): + if salt['master_key']: + req = PoolManager().request('GET', salt['master_key'], preload_content=False) + + with open(master_keyfile, 'wb') as f: + while True: + data = req.read(1024) + if not data: + break + f.write(data) + + req.release_conn() + chown(master_keyfile, salt['user'], salt['group']) + + return None + +def apply(salt): + if not salt: + # Salt removed from running config + call('systemctl stop salt-minion.service') + if os.path.exists(config_file): + os.unlink(config_file) + else: + call('systemctl restart salt-minion.service') + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/service_console-server.py b/src/conf_mode/service_console-server.py new file mode 100755 index 000000000..613ec6879 --- /dev/null +++ b/src/conf_mode/service_console-server.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2020 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 + +from sys import exit + +from vyos.config import Config +from vyos.configdict import dict_merge +from vyos.template import render +from vyos.util import call +from vyos.xml import defaults +from vyos import ConfigError + +config_file = r'/run/conserver/conserver.cf' + +def get_config(): + conf = Config() + base = ['service', 'console-server'] + + # Retrieve CLI representation as dictionary + proxy = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True) + # The retrieved dictionary will look something like this: + # + # {'device': {'usb0b2.4p1.0': {'speed': '9600'}, + # 'usb0b2.4p1.1': {'data_bits': '8', + # 'parity': 'none', + # 'speed': '115200', + # 'stop_bits': '2'}}} + + # We have gathered the dict representation of the CLI, but there are default + # options which we need to update into the dictionary retrived. + default_values = defaults(base + ['device']) + if 'device' in proxy: + for device in proxy['device']: + tmp = dict_merge(default_values, proxy['device'][device]) + proxy['device'][device] = tmp + + return proxy + +def verify(proxy): + if not proxy: + return None + + if 'device' in proxy: + for device in proxy['device']: + if 'speed' not in proxy['device'][device]: + raise ConfigError(f'Serial port speed must be defined for "{device}"!') + + if 'ssh' in proxy['device'][device]: + if 'port' not in proxy['device'][device]['ssh']: + raise ConfigError(f'SSH port must be defined for "{device}"!') + + return None + +def generate(proxy): + if not proxy: + return None + + render(config_file, 'conserver/conserver.conf.tmpl', proxy) + return None + +def apply(proxy): + call('systemctl stop dropbear@*.service conserver-server.service') + + if not proxy: + if os.path.isfile(config_file): + os.unlink(config_file) + return None + + call('systemctl restart conserver-server.service') + + if 'device' in proxy: + for device in proxy['device']: + if 'ssh' in proxy['device'][device]: + port = proxy['device'][device]['ssh']['port'] + call(f'systemctl restart dropbear@{device}.service') + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/service_ids_fastnetmon.py b/src/conf_mode/service_ids_fastnetmon.py new file mode 100755 index 000000000..d46f9578e --- /dev/null +++ b/src/conf_mode/service_ids_fastnetmon.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2020 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 + +from sys import exit + +from vyos.config import Config +from vyos import ConfigError +from vyos.util import call +from vyos.template import render +from vyos import airbag +airbag.enable() + +config_file = r'/etc/fastnetmon.conf' +networks_list = r'/etc/networks_list' + +def get_config(): + conf = Config() + base = ['service', 'ids', 'ddos-protection'] + fastnetmon = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + return fastnetmon + +def verify(fastnetmon): + if not fastnetmon: + return None + + if not "mode" in fastnetmon: + raise ConfigError('ddos-protection mode is mandatory!') + + if not "network" in fastnetmon: + raise ConfigError('Required define network!') + + if not "listen_interface" in fastnetmon: + raise ConfigError('Define listen-interface is mandatory!') + + if "alert_script" in fastnetmon: + if os.path.isfile(fastnetmon["alert_script"]): + # Check script permissions + if not os.access(fastnetmon["alert_script"], os.X_OK): + raise ConfigError('Script {0} does not have permissions for execution'.format(fastnetmon["alert_script"])) + else: + raise ConfigError('File {0} does not exists!'.format(fastnetmon["alert_script"])) + +def generate(fastnetmon): + if not fastnetmon: + if os.path.isfile(config_file): + os.unlink(config_file) + if os.path.isfile(networks_list): + os.unlink(networks_list) + + return + + render(config_file, 'ids/fastnetmon.tmpl', fastnetmon, trim_blocks=True) + render(networks_list, 'ids/fastnetmon_networks_list.tmpl', fastnetmon, trim_blocks=True) + + return None + +def apply(fastnetmon): + if not fastnetmon: + # Stop fastnetmon service if removed + call('systemctl stop fastnetmon.service') + else: + call('systemctl restart fastnetmon.service') + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/service_ipoe-server.py b/src/conf_mode/service_ipoe-server.py new file mode 100755 index 000000000..553cc2e97 --- /dev/null +++ b/src/conf_mode/service_ipoe-server.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2020 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 + +from copy import deepcopy +from stat import S_IRUSR, S_IWUSR, S_IRGRP +from sys import exit + +from vyos.config import Config +from vyos.template import render +from vyos.util import call, get_half_cpus +from vyos.validate import is_ipv4 +from vyos import ConfigError + +from vyos import airbag +airbag.enable() + +ipoe_conf = '/run/accel-pppd/ipoe.conf' +ipoe_chap_secrets = '/run/accel-pppd/ipoe.chap-secrets' + +default_config_data = { + 'auth_mode': 'local', + 'auth_interfaces': [], + 'chap_secrets_file': ipoe_chap_secrets, # used in Jinja2 template + 'interfaces': [], + 'dnsv4': [], + 'dnsv6': [], + 'client_ipv6_pool': [], + 'client_ipv6_delegate_prefix': [], + 'radius_server': [], + 'radius_acct_tmo': '3', + 'radius_max_try': '3', + 'radius_timeout': '3', + 'radius_nas_id': '', + 'radius_nas_ip': '', + 'radius_source_address': '', + 'radius_shaper_attr': '', + 'radius_shaper_vendor': '', + 'radius_dynamic_author': '', + 'thread_cnt': get_half_cpus() +} + +def get_config(): + conf = Config() + base_path = ['service', 'ipoe-server'] + if not conf.exists(base_path): + return None + + conf.set_level(base_path) + ipoe = deepcopy(default_config_data) + + for interface in conf.list_nodes(['interface']): + tmp = { + 'mode': 'L2', + 'name': interface, + 'shared': '1', + # may need a config option, can be dhcpv4 or up for unclassified pkts + 'sess_start': 'dhcpv4', + 'range': None, + 'ifcfg': '1', + 'vlan_mon': [] + } + + conf.set_level(base_path + ['interface', interface]) + + if conf.exists(['network-mode']): + tmp['mode'] = conf.return_value(['network-mode']) + + if conf.exists(['network']): + mode = conf.return_value(['network']) + if mode == 'vlan': + tmp['shared'] = '0' + + if conf.exists(['vlan-id']): + tmp['vlan_mon'] += conf.return_values(['vlan-id']) + + if conf.exists(['vlan-range']): + tmp['vlan_mon'] += conf.return_values(['vlan-range']) + + if conf.exists(['client-subnet']): + tmp['range'] = conf.return_value(['client-subnet']) + + ipoe['interfaces'].append(tmp) + + conf.set_level(base_path) + + if conf.exists(['name-server']): + for name_server in conf.return_values(['name-server']): + if is_ipv4(name_server): + ipoe['dnsv4'].append(name_server) + else: + ipoe['dnsv6'].append(name_server) + + if conf.exists(['authentication', 'mode']): + ipoe['auth_mode'] = conf.return_value(['authentication', 'mode']) + + if conf.exists(['authentication', 'interface']): + for interface in conf.list_nodes(['authentication', 'interface']): + tmp = { + 'name': interface, + 'mac': [] + } + for mac in conf.list_nodes(['authentication', 'interface', interface, 'mac-address']): + client = { + 'address': mac, + 'rate_download': '', + 'rate_upload': '', + 'vlan_id': '' + } + conf.set_level(base_path + ['authentication', 'interface', interface, 'mac-address', mac]) + + if conf.exists(['rate-limit', 'download']): + client['rate_download'] = conf.return_value(['rate-limit', 'download']) + + if conf.exists(['rate-limit', 'upload']): + client['rate_upload'] = conf.return_value(['rate-limit', 'upload']) + + if conf.exists(['vlan-id']): + client['vlan'] = conf.return_value(['vlan-id']) + + tmp['mac'].append(client) + + ipoe['auth_interfaces'].append(tmp) + + conf.set_level(base_path) + + # + # authentication mode radius servers and settings + if conf.exists(['authentication', 'mode', 'radius']): + for server in conf.list_nodes(['authentication', 'radius', 'server']): + radius = { + 'server' : server, + 'key' : '', + 'fail_time' : 0, + 'port' : '1812', + 'acct_port' : '1813' + } + + conf.set_level(base_path + ['authentication', 'radius', 'server', server]) + + if conf.exists(['fail-time']): + radius['fail_time'] = conf.return_value(['fail-time']) + + if conf.exists(['port']): + radius['port'] = conf.return_value(['port']) + + if conf.exists(['acct-port']): + radius['acct_port'] = conf.return_value(['acct-port']) + + if conf.exists(['key']): + radius['key'] = conf.return_value(['key']) + + if not conf.exists(['disable']): + ipoe['radius_server'].append(radius) + + # + # advanced radius-setting + conf.set_level(base_path + ['authentication', 'radius']) + if conf.exists(['acct-timeout']): + ipoe['radius_acct_tmo'] = conf.return_value(['acct-timeout']) + + if conf.exists(['max-try']): + ipoe['radius_max_try'] = conf.return_value(['max-try']) + + if conf.exists(['timeout']): + ipoe['radius_timeout'] = conf.return_value(['timeout']) + + if conf.exists(['nas-identifier']): + ipoe['radius_nas_id'] = conf.return_value(['nas-identifier']) + + if conf.exists(['nas-ip-address']): + ipoe['radius_nas_ip'] = conf.return_value(['nas-ip-address']) + + if conf.exists(['source-address']): + ipoe['radius_source_address'] = conf.return_value(['source-address']) + + # Dynamic Authorization Extensions (DOA)/Change Of Authentication (COA) + if conf.exists(['dynamic-author']): + dae = { + 'port' : '', + 'server' : '', + 'key' : '' + } + + if conf.exists(['dynamic-author', 'server']): + dae['server'] = conf.return_value(['dynamic-author', 'server']) + + if conf.exists(['dynamic-author', 'port']): + dae['port'] = conf.return_value(['dynamic-author', 'port']) + + if conf.exists(['dynamic-author', 'key']): + dae['key'] = conf.return_value(['dynamic-author', 'key']) + + ipoe['radius_dynamic_author'] = dae + + + conf.set_level(base_path) + if conf.exists(['client-ipv6-pool', 'prefix']): + for prefix in conf.list_nodes(['client-ipv6-pool', 'prefix']): + tmp = { + 'prefix': prefix, + 'mask': '64' + } + + if conf.exists(['client-ipv6-pool', 'prefix', prefix, 'mask']): + tmp['mask'] = conf.return_value(['client-ipv6-pool', 'prefix', prefix, 'mask']) + + ipoe['client_ipv6_pool'].append(tmp) + + + if conf.exists(['client-ipv6-pool', 'delegate']): + for prefix in conf.list_nodes(['client-ipv6-pool', 'delegate']): + tmp = { + 'prefix': prefix, + 'mask': '' + } + + if conf.exists(['client-ipv6-pool', 'delegate', prefix, 'delegation-prefix']): + tmp['mask'] = conf.return_value(['client-ipv6-pool', 'delegate', prefix, 'delegation-prefix']) + + ipoe['client_ipv6_delegate_prefix'].append(tmp) + + return ipoe + + +def verify(ipoe): + if not ipoe: + return None + + if not ipoe['interfaces']: + raise ConfigError('No IPoE interface configured') + + for interface in ipoe['interfaces']: + if not interface['range']: + raise ConfigError(f'No IPoE client subnet defined on interface "{ interface }"') + + if len(ipoe['dnsv4']) > 2: + raise ConfigError('Not more then two IPv4 DNS name-servers can be configured') + + if len(ipoe['dnsv6']) > 3: + raise ConfigError('Not more then three IPv6 DNS name-servers can be configured') + + if ipoe['auth_mode'] == 'radius': + if len(ipoe['radius_server']) == 0: + raise ConfigError('RADIUS authentication requires at least one server') + + for radius in ipoe['radius_server']: + if not radius['key']: + server = radius['server'] + raise ConfigError(f'Missing RADIUS secret key for server "{ server }"') + + if ipoe['client_ipv6_delegate_prefix'] and not ipoe['client_ipv6_pool']: + raise ConfigError('IPoE IPv6 deletate-prefix requires IPv6 prefix to be configured!') + + return None + + +def generate(ipoe): + if not ipoe: + return None + + render(ipoe_conf, 'accel-ppp/ipoe.config.tmpl', ipoe, trim_blocks=True) + + if ipoe['auth_mode'] == 'local': + render(ipoe_chap_secrets, 'accel-ppp/chap-secrets.ipoe.tmpl', ipoe) + os.chmod(ipoe_chap_secrets, S_IRUSR | S_IWUSR | S_IRGRP) + + else: + if os.path.exists(ipoe_chap_secrets): + os.unlink(ipoe_chap_secrets) + + return None + + +def apply(ipoe): + if ipoe == None: + call('systemctl stop accel-ppp@ipoe.service') + for file in [ipoe_conf, ipoe_chap_secrets]: + if os.path.exists(file): + os.unlink(file) + + return None + + call('systemctl restart accel-ppp@ipoe.service') + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/service_mdns-repeater.py b/src/conf_mode/service_mdns-repeater.py new file mode 100755 index 000000000..1a6b2c328 --- /dev/null +++ b/src/conf_mode/service_mdns-repeater.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2017-2020 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 + +from sys import exit +from netifaces import ifaddresses, interfaces, AF_INET + +from vyos.config import Config +from vyos.template import render +from vyos.util import call +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +config_file = r'/etc/default/mdns-repeater' + +def get_config(): + conf = Config() + base = ['service', 'mdns', 'repeater'] + mdns = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + return mdns + +def verify(mdns): + if not mdns: + return None + + if 'disable' in mdns: + return None + + # We need at least two interfaces to repeat mDNS advertisments + if 'interface' not in mdns or len(mdns['interface']) < 2: + raise ConfigError('mDNS repeater requires at least 2 configured interfaces!') + + # For mdns-repeater to work it is essential that the interfaces has + # an IPv4 address assigned + for interface in mdns['interface']: + if interface not in interfaces(): + raise ConfigError(f'Interface "{interface}" does not exist!') + + if AF_INET not in ifaddresses(interface): + raise ConfigError('mDNS repeater requires an IPv4 address to be ' + f'configured on interface "{interface}"') + + return None + +def generate(mdns): + if not mdns: + return None + + if 'disable' in mdns: + print('Warning: mDNS repeater will be deactivated because it is disabled') + return None + + render(config_file, 'mdns-repeater/mdns-repeater.tmpl', mdns) + return None + +def apply(mdns): + if not mdns or 'disable' in mdns: + call('systemctl stop mdns-repeater.service') + if os.path.exists(config_file): + os.unlink(config_file) + else: + call('systemctl restart mdns-repeater.service') + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/service_pppoe-server.py b/src/conf_mode/service_pppoe-server.py new file mode 100755 index 000000000..39d34a7e2 --- /dev/null +++ b/src/conf_mode/service_pppoe-server.py @@ -0,0 +1,473 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2020 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 + +from copy import deepcopy +from stat import S_IRUSR, S_IWUSR, S_IRGRP +from sys import exit + +from vyos.config import Config +from vyos.template import render +from vyos.util import call, get_half_cpus +from vyos.validate import is_ipv4 +from vyos import ConfigError + +from vyos import airbag +airbag.enable() + +pppoe_conf = r'/run/accel-pppd/pppoe.conf' +pppoe_chap_secrets = r'/run/accel-pppd/pppoe.chap-secrets' + +default_config_data = { + 'auth_mode': 'local', + 'auth_proto': ['auth_mschap_v2', 'auth_mschap_v1', 'auth_chap_md5', 'auth_pap'], + 'chap_secrets_file': pppoe_chap_secrets, # used in Jinja2 template + 'client_ip_pool': '', + 'client_ip_subnets': [], + 'client_ipv6_pool': [], + 'client_ipv6_delegate_prefix': [], + 'concentrator': 'vyos-ac', + 'interfaces': [], + 'local_users' : [], + + 'svc_name': [], + 'dnsv4': [], + 'dnsv6': [], + 'wins': [], + 'mtu': '1492', + + 'limits_burst': '', + 'limits_connections': '', + 'limits_timeout': '', + + 'pado_delay': '', + 'ppp_ccp': False, + 'ppp_gw': '', + 'ppp_ipv4': '', + 'ppp_ipv6': '', + 'ppp_ipv6_accept_peer_intf_id': False, + 'ppp_ipv6_intf_id': '', + 'ppp_ipv6_peer_intf_id': '', + 'ppp_echo_failure': '3', + 'ppp_echo_interval': '30', + 'ppp_echo_timeout': '0', + 'ppp_min_mtu': '', + 'ppp_mppe': 'prefer', + 'ppp_mru': '', + + 'radius_server': [], + 'radius_acct_tmo': '3', + 'radius_max_try': '3', + 'radius_timeout': '3', + 'radius_nas_id': '', + 'radius_nas_ip': '', + 'radius_source_address': '', + 'radius_shaper_attr': '', + 'radius_shaper_vendor': '', + 'radius_dynamic_author': '', + 'sesscrtl': 'replace', + 'snmp': False, + 'thread_cnt': get_half_cpus() +} + +def get_config(): + conf = Config() + base_path = ['service', 'pppoe-server'] + if not conf.exists(base_path): + return None + + conf.set_level(base_path) + pppoe = deepcopy(default_config_data) + + # general options + if conf.exists(['access-concentrator']): + pppoe['concentrator'] = conf.return_value(['access-concentrator']) + + if conf.exists(['service-name']): + pppoe['svc_name'] = conf.return_values(['service-name']) + + if conf.exists(['interface']): + for interface in conf.list_nodes(['interface']): + conf.set_level(base_path + ['interface', interface]) + tmp = { + 'name': interface, + 'vlans': [] + } + + if conf.exists(['vlan-id']): + tmp['vlans'] += conf.return_values(['vlan-id']) + + if conf.exists(['vlan-range']): + tmp['vlans'] += conf.return_values(['vlan-range']) + + pppoe['interfaces'].append(tmp) + + conf.set_level(base_path) + + if conf.exists(['local-ip']): + pppoe['ppp_gw'] = conf.return_value(['local-ip']) + + if conf.exists(['name-server']): + for name_server in conf.return_values(['name-server']): + if is_ipv4(name_server): + pppoe['dnsv4'].append(name_server) + else: + pppoe['dnsv6'].append(name_server) + + if conf.exists(['wins-server']): + pppoe['wins'] = conf.return_values(['wins-server']) + + + if conf.exists(['client-ip-pool']): + if conf.exists(['client-ip-pool', 'start']) and conf.exists(['client-ip-pool', 'stop']): + start = conf.return_value(['client-ip-pool', 'start']) + stop = conf.return_value(['client-ip-pool', 'stop']) + pppoe['client_ip_pool'] = start + '-' + re.search('[0-9]+$', stop).group(0) + + if conf.exists(['client-ip-pool', 'subnet']): + pppoe['client_ip_subnets'] = conf.return_values(['client-ip-pool', 'subnet']) + + + if conf.exists(['client-ipv6-pool', 'prefix']): + for prefix in conf.list_nodes(['client-ipv6-pool', 'prefix']): + tmp = { + 'prefix': prefix, + 'mask': '64' + } + + if conf.exists(['client-ipv6-pool', 'prefix', prefix, 'mask']): + tmp['mask'] = conf.return_value(['client-ipv6-pool', 'prefix', prefix, 'mask']) + + pppoe['client_ipv6_pool'].append(tmp) + + + if conf.exists(['client-ipv6-pool', 'delegate']): + for prefix in conf.list_nodes(['client-ipv6-pool', 'delegate']): + tmp = { + 'prefix': prefix, + 'mask': '' + } + + if conf.exists(['client-ipv6-pool', 'delegate', prefix, 'delegation-prefix']): + tmp['mask'] = conf.return_value(['client-ipv6-pool', 'delegate', prefix, 'delegation-prefix']) + + pppoe['client_ipv6_delegate_prefix'].append(tmp) + + + if conf.exists(['limits']): + if conf.exists(['limits', 'burst']): + pppoe['limits_burst'] = conf.return_value(['limits', 'burst']) + + if conf.exists(['limits', 'connection-limit']): + pppoe['limits_connections'] = conf.return_value(['limits', 'connection-limit']) + + if conf.exists(['limits', 'timeout']): + pppoe['limits_timeout'] = conf.return_value(['limits', 'timeout']) + + + if conf.exists(['snmp']): + pppoe['snmp'] = True + + if conf.exists(['snmp', 'master-agent']): + pppoe['snmp'] = 'enable-ma' + + # authentication mode local + if conf.exists(['authentication', 'mode']): + pppoe['auth_mode'] = conf.return_value(['authentication', 'mode']) + + if conf.exists(['authentication', 'local-users']): + for username in conf.list_nodes(['authentication', 'local-users', 'username']): + user = { + 'name' : username, + 'password' : '', + 'state' : 'enabled', + 'ip' : '*', + 'upload' : None, + 'download' : None + } + conf.set_level(base_path + ['authentication', 'local-users', 'username', username]) + + if conf.exists(['password']): + user['password'] = conf.return_value(['password']) + + if conf.exists(['disable']): + user['state'] = 'disable' + + if conf.exists(['static-ip']): + user['ip'] = conf.return_value(['static-ip']) + + if conf.exists(['rate-limit', 'download']): + user['download'] = conf.return_value(['rate-limit', 'download']) + + if conf.exists(['rate-limit', 'upload']): + user['upload'] = conf.return_value(['rate-limit', 'upload']) + + pppoe['local_users'].append(user) + + conf.set_level(base_path) + + if conf.exists(['authentication', 'protocols']): + auth_mods = { + 'mschap-v2': 'auth_mschap_v2', + 'mschap': 'auth_mschap_v1', + 'chap': 'auth_chap_md5', + 'pap': 'auth_pap' + } + + pppoe['auth_proto'] = [] + for proto in conf.return_values(['authentication', 'protocols']): + pppoe['auth_proto'].append(auth_mods[proto]) + + # + # authentication mode radius servers and settings + if conf.exists(['authentication', 'mode', 'radius']): + + for server in conf.list_nodes(['authentication', 'radius', 'server']): + radius = { + 'server' : server, + 'key' : '', + 'fail_time' : 0, + 'port' : '1812', + 'acct_port' : '1813' + } + + conf.set_level(base_path + ['authentication', 'radius', 'server', server]) + + if conf.exists(['fail-time']): + radius['fail_time'] = conf.return_value(['fail-time']) + + if conf.exists(['port']): + radius['port'] = conf.return_value(['port']) + + if conf.exists(['acct-port']): + radius['acct_port'] = conf.return_value(['acct-port']) + + if conf.exists(['key']): + radius['key'] = conf.return_value(['key']) + + if not conf.exists(['disable']): + pppoe['radius_server'].append(radius) + + # + # advanced radius-setting + conf.set_level(base_path + ['authentication', 'radius']) + + if conf.exists(['acct-timeout']): + pppoe['radius_acct_tmo'] = conf.return_value(['acct-timeout']) + + if conf.exists(['max-try']): + pppoe['radius_max_try'] = conf.return_value(['max-try']) + + if conf.exists(['timeout']): + pppoe['radius_timeout'] = conf.return_value(['timeout']) + + if conf.exists(['nas-identifier']): + pppoe['radius_nas_id'] = conf.return_value(['nas-identifier']) + + if conf.exists(['nas-ip-address']): + pppoe['radius_nas_ip'] = conf.return_value(['nas-ip-address']) + + if conf.exists(['source-address']): + pppoe['radius_source_address'] = conf.return_value(['source-address']) + + # Dynamic Authorization Extensions (DOA)/Change Of Authentication (COA) + if conf.exists(['dynamic-author']): + dae = { + 'port' : '', + 'server' : '', + 'key' : '' + } + + if conf.exists(['dynamic-author', 'server']): + dae['server'] = conf.return_value(['dynamic-author', 'server']) + + if conf.exists(['dynamic-author', 'port']): + dae['port'] = conf.return_value(['dynamic-author', 'port']) + + if conf.exists(['dynamic-author', 'key']): + dae['key'] = conf.return_value(['dynamic-author', 'key']) + + pppoe['radius_dynamic_author'] = dae + + # RADIUS based rate-limiter + if conf.exists(['rate-limit', 'enable']): + pppoe['radius_shaper_attr'] = 'Filter-Id' + c_attr = ['rate-limit', 'enable', 'attribute'] + if conf.exists(c_attr): + pppoe['radius_shaper_attr'] = conf.return_value(c_attr) + + c_vendor = ['rate-limit', 'enable', 'vendor'] + if conf.exists(c_vendor): + pppoe['radius_shaper_vendor'] = conf.return_value(c_vendor) + + # re-set config level + conf.set_level(base_path) + + if conf.exists(['mtu']): + pppoe['mtu'] = conf.return_value(['mtu']) + + if conf.exists(['session-control']): + pppoe['sesscrtl'] = conf.return_value(['session-control']) + + # ppp_options + if conf.exists(['ppp-options']): + conf.set_level(base_path + ['ppp-options']) + + if conf.exists(['ccp']): + pppoe['ppp_ccp'] = True + + if conf.exists(['ipv4']): + pppoe['ppp_ipv4'] = conf.return_value(['ipv4']) + + if conf.exists(['ipv6']): + pppoe['ppp_ipv6'] = conf.return_value(['ipv6']) + + if conf.exists(['ipv6-accept-peer-intf-id']): + pppoe['ppp_ipv6_peer_intf_id'] = True + + if conf.exists(['ipv6-intf-id']): + pppoe['ppp_ipv6_intf_id'] = conf.return_value(['ipv6-intf-id']) + + if conf.exists(['ipv6-peer-intf-id']): + pppoe['ppp_ipv6_peer_intf_id'] = conf.return_value(['ipv6-peer-intf-id']) + + if conf.exists(['lcp-echo-failure']): + pppoe['ppp_echo_failure'] = conf.return_value(['lcp-echo-failure']) + + if conf.exists(['lcp-echo-failure']): + pppoe['ppp_echo_interval'] = conf.return_value(['lcp-echo-failure']) + + if conf.exists(['lcp-echo-timeout']): + pppoe['ppp_echo_timeout'] = conf.return_value(['lcp-echo-timeout']) + + if conf.exists(['min-mtu']): + pppoe['ppp_min_mtu'] = conf.return_value(['min-mtu']) + + if conf.exists(['mppe']): + pppoe['ppp_mppe'] = conf.return_value(['mppe']) + + if conf.exists(['mru']): + pppoe['ppp_mru'] = conf.return_value(['mru']) + + if conf.exists(['pado-delay']): + pppoe['pado_delay'] = '0' + a = {} + for id in conf.list_nodes(['pado-delay']): + if not conf.return_value(['pado-delay', id, 'sessions']): + a[id] = 0 + else: + a[id] = conf.return_value(['pado-delay', id, 'sessions']) + + for k in sorted(a.keys()): + if k != sorted(a.keys())[-1]: + pppoe['pado_delay'] += ",{0}:{1}".format(k, a[k]) + else: + pppoe['pado_delay'] += ",{0}:{1}".format('-1', a[k]) + + return pppoe + + +def verify(pppoe): + if not pppoe: + return None + + # vertify auth settings + if pppoe['auth_mode'] == 'local': + if not pppoe['local_users']: + raise ConfigError('PPPoE local auth mode requires local users to be configured!') + + for user in pppoe['local_users']: + username = user['name'] + if not user['password']: + raise ConfigError(f'Password required for local user "{username}"') + + # if up/download is set, check that both have a value + if user['upload'] and not user['download']: + raise ConfigError(f'Download speed value required for local user "{username}"') + + if user['download'] and not user['upload']: + raise ConfigError(f'Upload speed value required for local user "{username}"') + + elif pppoe['auth_mode'] == 'radius': + if len(pppoe['radius_server']) == 0: + raise ConfigError('RADIUS authentication requires at least one server') + + for radius in pppoe['radius_server']: + if not radius['key']: + server = radius['server'] + raise ConfigError(f'Missing RADIUS secret key for server "{ server }"') + + if len(pppoe['wins']) > 2: + raise ConfigError('Not more then two IPv4 WINS name-servers can be configured') + + if len(pppoe['dnsv4']) > 2: + raise ConfigError('Not more then two IPv4 DNS name-servers can be configured') + + if len(pppoe['dnsv6']) > 3: + raise ConfigError('Not more then three IPv6 DNS name-servers can be configured') + + if not pppoe['interfaces']: + raise ConfigError('At least one listen interface must be defined!') + + # local ippool and gateway settings config checks + if pppoe['client_ip_subnets'] or pppoe['client_ip_pool']: + if not pppoe['ppp_gw']: + raise ConfigError('PPPoE server requires local IP to be configured') + + if pppoe['ppp_gw'] and not pppoe['client_ip_subnets'] and not pppoe['client_ip_pool']: + print("Warning: No PPPoE client pool defined") + + return None + + +def generate(pppoe): + if not pppoe: + return None + + render(pppoe_conf, 'accel-ppp/pppoe.config.tmpl', pppoe, trim_blocks=True) + + if pppoe['local_users']: + render(pppoe_chap_secrets, 'accel-ppp/chap-secrets.tmpl', pppoe, trim_blocks=True) + os.chmod(pppoe_chap_secrets, S_IRUSR | S_IWUSR | S_IRGRP) + else: + if os.path.exists(pppoe_chap_secrets): + os.unlink(pppoe_chap_secrets) + + return None + + +def apply(pppoe): + if not pppoe: + call('systemctl stop accel-ppp@pppoe.service') + for file in [pppoe_conf, pppoe_chap_secrets]: + if os.path.exists(file): + os.unlink(file) + + return None + + call('systemctl restart accel-ppp@pppoe.service') + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/service_router-advert.py b/src/conf_mode/service_router-advert.py new file mode 100755 index 000000000..4e1c432ab --- /dev/null +++ b/src/conf_mode/service_router-advert.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-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 + +from sys import exit + +from vyos.config import Config +from vyos.configdict import dict_merge +from vyos.template import render +from vyos.util import call +from vyos.xml import defaults +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +config_file = r'/run/radvd/radvd.conf' + +def get_config(): + conf = Config() + base = ['service', 'router-advert'] + rtradv = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + + # We have gathered the dict representation of the CLI, but there are default + # options which we need to update into the dictionary retrived. + default_interface_values = defaults(base + ['interface']) + # we deal with prefix defaults later on + if 'prefix' in default_interface_values: + del default_interface_values['prefix'] + + default_prefix_values = defaults(base + ['interface', 'prefix']) + + if 'interface' in rtradv: + for interface in rtradv['interface']: + rtradv['interface'][interface] = dict_merge( + default_interface_values, rtradv['interface'][interface]) + + if 'prefix' in rtradv['interface'][interface]: + for prefix in rtradv['interface'][interface]['prefix']: + rtradv['interface'][interface]['prefix'][prefix] = dict_merge( + default_prefix_values, rtradv['interface'][interface]['prefix'][prefix]) + + if 'name_server' in rtradv['interface'][interface]: + # always use a list when dealing with nameservers - eases the template generation + if isinstance(rtradv['interface'][interface]['name_server'], str): + rtradv['interface'][interface]['name_server'] = [ + rtradv['interface'][interface]['name_server']] + + return rtradv + +def verify(rtradv): + if not rtradv: + return None + + if 'interface' not in rtradv: + return None + + for interface in rtradv['interface']: + interface = rtradv['interface'][interface] + if 'prefix' in interface: + for prefix in interface['prefix']: + prefix = interface['prefix'][prefix] + valid_lifetime = prefix['valid_lifetime'] + if valid_lifetime == 'infinity': + valid_lifetime = 4294967295 + + preferred_lifetime = prefix['preferred_lifetime'] + if preferred_lifetime == 'infinity': + preferred_lifetime = 4294967295 + + if not (int(valid_lifetime) > int(preferred_lifetime)): + raise ConfigError('Prefix valid-lifetime must be greater then preferred-lifetime') + + return None + +def generate(rtradv): + if not rtradv: + return None + + render(config_file, 'router-advert/radvd.conf.tmpl', rtradv, trim_blocks=True, permission=0o644) + return None + +def apply(rtradv): + if not rtradv: + # bail out early - looks like removal from running config + call('systemctl stop radvd.service') + if os.path.exists(config_file): + os.unlink(config_file) + + return None + + call('systemctl restart radvd.service') + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/snmp.py b/src/conf_mode/snmp.py new file mode 100755 index 000000000..e9806ef47 --- /dev/null +++ b/src/conf_mode/snmp.py @@ -0,0 +1,581 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2020 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 + +from sys import exit + +from vyos.config import Config +from vyos.configverify import verify_vrf +from vyos.snmpv3_hashgen import plaintext_to_md5, plaintext_to_sha1, random +from vyos.template import render +from vyos.util import call +from vyos.validate import is_ipv4, is_addr_assigned +from vyos.version import get_version_data +from vyos import ConfigError, airbag +airbag.enable() + +config_file_client = r'/etc/snmp/snmp.conf' +config_file_daemon = r'/etc/snmp/snmpd.conf' +config_file_access = r'/usr/share/snmp/snmpd.conf' +config_file_user = r'/var/lib/snmp/snmpd.conf' +default_script_dir = r'/config/user-data/' +systemd_override = r'/etc/systemd/system/snmpd.service.d/override.conf' + +# SNMP OIDs used to mark auth/priv type +OIDs = { + 'md5' : '.1.3.6.1.6.3.10.1.1.2', + 'sha' : '.1.3.6.1.6.3.10.1.1.3', + 'aes' : '.1.3.6.1.6.3.10.1.2.4', + 'des' : '.1.3.6.1.6.3.10.1.2.2', + 'none': '.1.3.6.1.6.3.10.1.2.1' +} + +default_config_data = { + 'listen_on': [], + 'listen_address': [], + 'ipv6_enabled': 'True', + 'communities': [], + 'smux_peers': [], + 'location' : '', + 'description' : '', + 'contact' : '', + 'trap_source': '', + 'trap_targets': [], + 'vyos_user': '', + 'vyos_user_pass': '', + 'version': '', + 'v3_enabled': 'False', + 'v3_engineid': '', + 'v3_groups': [], + 'v3_traps': [], + 'v3_users': [], + 'v3_views': [], + 'script_ext': [] +} + +def rmfile(file): + if os.path.isfile(file): + os.unlink(file) + +def get_config(): + snmp = default_config_data + conf = Config() + if not conf.exists('service snmp'): + return None + else: + if conf.exists('system ipv6 disable'): + snmp['ipv6_enabled'] = False + + conf.set_level('service snmp') + + version_data = get_version_data() + snmp['version'] = version_data['version'] + + # create an internal snmpv3 user of the form 'vyosxxxxxxxxxxxxxxxx' + snmp['vyos_user'] = 'vyos' + random(8) + snmp['vyos_user_pass'] = random(16) + + if conf.exists('community'): + for name in conf.list_nodes('community'): + community = { + 'name': name, + 'authorization': 'ro', + 'network_v4': [], + 'network_v6': [], + 'has_source' : False + } + + if conf.exists('community {0} authorization'.format(name)): + community['authorization'] = conf.return_value('community {0} authorization'.format(name)) + + # Subnet of SNMP client(s) allowed to contact system + if conf.exists('community {0} network'.format(name)): + for addr in conf.return_values('community {0} network'.format(name)): + if is_ipv4(addr): + community['network_v4'].append(addr) + else: + community['network_v6'].append(addr) + + # IP address of SNMP client allowed to contact system + if conf.exists('community {0} client'.format(name)): + for addr in conf.return_values('community {0} client'.format(name)): + if is_ipv4(addr): + community['network_v4'].append(addr) + else: + community['network_v6'].append(addr) + + if (len(community['network_v4']) > 0) or (len(community['network_v6']) > 0): + community['has_source'] = True + + snmp['communities'].append(community) + + if conf.exists('contact'): + snmp['contact'] = conf.return_value('contact') + + if conf.exists('description'): + snmp['description'] = conf.return_value('description') + + if conf.exists('listen-address'): + for addr in conf.list_nodes('listen-address'): + port = '161' + if conf.exists('listen-address {0} port'.format(addr)): + port = conf.return_value('listen-address {0} port'.format(addr)) + + snmp['listen_address'].append((addr, port)) + + # Always listen on localhost if an explicit address has been configured + # This is a safety measure to not end up with invalid listen addresses + # that are not configured on this system. See https://phabricator.vyos.net/T850 + if not '127.0.0.1' in conf.list_nodes('listen-address'): + snmp['listen_address'].append(('127.0.0.1', '161')) + + if not '::1' in conf.list_nodes('listen-address'): + snmp['listen_address'].append(('::1', '161')) + + if conf.exists('location'): + snmp['location'] = conf.return_value('location') + + if conf.exists('smux-peer'): + snmp['smux_peers'] = conf.return_values('smux-peer') + + if conf.exists('trap-source'): + snmp['trap_source'] = conf.return_value('trap-source') + + if conf.exists('trap-target'): + for target in conf.list_nodes('trap-target'): + trap_tgt = { + 'target': target, + 'community': '', + 'port': '' + } + + if conf.exists('trap-target {0} community'.format(target)): + trap_tgt['community'] = conf.return_value('trap-target {0} community'.format(target)) + + if conf.exists('trap-target {0} port'.format(target)): + trap_tgt['port'] = conf.return_value('trap-target {0} port'.format(target)) + + snmp['trap_targets'].append(trap_tgt) + + if conf.exists('script-extensions'): + for extname in conf.list_nodes('script-extensions extension-name'): + conf_script = conf.return_value('script-extensions extension-name {} script'.format(extname)) + # if script has not absolute path, use pre configured path + if "/" not in conf_script: + conf_script = default_script_dir + conf_script + + extension = { + 'name': extname, + 'script' : conf_script + } + + snmp['script_ext'].append(extension) + + if conf.exists('vrf'): + # Append key to dict but don't place it in the default dictionary. + # This is required to make the override.conf.tmpl work until we + # migrate to get_config_dict(). + snmp['vrf'] = conf.return_value('vrf') + + + ######################################################################### + # ____ _ _ __ __ ____ _____ # + # / ___|| \ | | \/ | _ \ __ _|___ / # + # \___ \| \| | |\/| | |_) | \ \ / / |_ \ # + # ___) | |\ | | | | __/ \ V / ___) | # + # |____/|_| \_|_| |_|_| \_/ |____/ # + # # + # now take care about the fancy SNMP v3 stuff, or bail out eraly # + ######################################################################### + if not conf.exists('v3'): + return snmp + else: + snmp['v3_enabled'] = True + + # 'set service snmp v3 engineid' + if conf.exists('v3 engineid'): + snmp['v3_engineid'] = conf.return_value('v3 engineid') + + # 'set service snmp v3 group' + if conf.exists('v3 group'): + for group in conf.list_nodes('v3 group'): + v3_group = { + 'name': group, + 'mode': 'ro', + 'seclevel': 'auth', + 'view': '' + } + + if conf.exists('v3 group {0} mode'.format(group)): + v3_group['mode'] = conf.return_value('v3 group {0} mode'.format(group)) + + if conf.exists('v3 group {0} seclevel'.format(group)): + v3_group['seclevel'] = conf.return_value('v3 group {0} seclevel'.format(group)) + + if conf.exists('v3 group {0} view'.format(group)): + v3_group['view'] = conf.return_value('v3 group {0} view'.format(group)) + + snmp['v3_groups'].append(v3_group) + + # 'set service snmp v3 trap-target' + if conf.exists('v3 trap-target'): + for trap in conf.list_nodes('v3 trap-target'): + trap_cfg = { + 'ipAddr': trap, + 'secName': '', + 'authProtocol': 'md5', + 'authPassword': '', + 'authMasterKey': '', + 'privProtocol': 'des', + 'privPassword': '', + 'privMasterKey': '', + 'ipProto': 'udp', + 'ipPort': '162', + 'type': '', + 'secLevel': 'noAuthNoPriv' + } + + if conf.exists('v3 trap-target {0} user'.format(trap)): + # Set the securityName used for authenticated SNMPv3 messages. + trap_cfg['secName'] = conf.return_value('v3 trap-target {0} user'.format(trap)) + + if conf.exists('v3 trap-target {0} auth type'.format(trap)): + # Set the authentication protocol (MD5 or SHA) used for authenticated SNMPv3 messages + # cmdline option '-a' + trap_cfg['authProtocol'] = conf.return_value('v3 trap-target {0} auth type'.format(trap)) + + if conf.exists('v3 trap-target {0} auth plaintext-password'.format(trap)): + # Set the authentication pass phrase used for authenticated SNMPv3 messages. + # cmdline option '-A' + trap_cfg['authPassword'] = conf.return_value('v3 trap-target {0} auth plaintext-password'.format(trap)) + + if conf.exists('v3 trap-target {0} auth encrypted-password'.format(trap)): + # Sets the keys to be used for SNMPv3 transactions. These options allow you to set the master authentication keys. + # cmdline option '-3m' + trap_cfg['authMasterKey'] = conf.return_value('v3 trap-target {0} auth encrypted-password'.format(trap)) + + if conf.exists('v3 trap-target {0} privacy type'.format(trap)): + # Set the privacy protocol (DES or AES) used for encrypted SNMPv3 messages. + # cmdline option '-x' + trap_cfg['privProtocol'] = conf.return_value('v3 trap-target {0} privacy type'.format(trap)) + + if conf.exists('v3 trap-target {0} privacy plaintext-password'.format(trap)): + # Set the privacy pass phrase used for encrypted SNMPv3 messages. + # cmdline option '-X' + trap_cfg['privPassword'] = conf.return_value('v3 trap-target {0} privacy plaintext-password'.format(trap)) + + if conf.exists('v3 trap-target {0} privacy encrypted-password'.format(trap)): + # Sets the keys to be used for SNMPv3 transactions. These options allow you to set the master encryption keys. + # cmdline option '-3M' + trap_cfg['privMasterKey'] = conf.return_value('v3 trap-target {0} privacy encrypted-password'.format(trap)) + + if conf.exists('v3 trap-target {0} protocol'.format(trap)): + trap_cfg['ipProto'] = conf.return_value('v3 trap-target {0} protocol'.format(trap)) + + if conf.exists('v3 trap-target {0} port'.format(trap)): + trap_cfg['ipPort'] = conf.return_value('v3 trap-target {0} port'.format(trap)) + + if conf.exists('v3 trap-target {0} type'.format(trap)): + trap_cfg['type'] = conf.return_value('v3 trap-target {0} type'.format(trap)) + + # Determine securityLevel used for SNMPv3 messages (noAuthNoPriv|authNoPriv|authPriv). + # Appropriate pass phrase(s) must provided when using any level higher than noAuthNoPriv. + if trap_cfg['authPassword'] or trap_cfg['authMasterKey']: + if trap_cfg['privProtocol'] or trap_cfg['privPassword']: + trap_cfg['secLevel'] = 'authPriv' + else: + trap_cfg['secLevel'] = 'authNoPriv' + + snmp['v3_traps'].append(trap_cfg) + + # 'set service snmp v3 user' + if conf.exists('v3 user'): + for user in conf.list_nodes('v3 user'): + user_cfg = { + 'name': user, + 'authMasterKey': '', + 'authPassword': '', + 'authProtocol': 'md5', + 'authOID': 'none', + 'group': '', + 'mode': 'ro', + 'privMasterKey': '', + 'privPassword': '', + 'privOID': '', + 'privProtocol': 'des' + } + + # v3 user {0} auth + if conf.exists('v3 user {0} auth encrypted-password'.format(user)): + user_cfg['authMasterKey'] = conf.return_value('v3 user {0} auth encrypted-password'.format(user)) + + if conf.exists('v3 user {0} auth plaintext-password'.format(user)): + user_cfg['authPassword'] = conf.return_value('v3 user {0} auth plaintext-password'.format(user)) + + # load default value + type = user_cfg['authProtocol'] + if conf.exists('v3 user {0} auth type'.format(user)): + type = conf.return_value('v3 user {0} auth type'.format(user)) + + # (re-)update with either default value or value from CLI + user_cfg['authProtocol'] = type + user_cfg['authOID'] = OIDs[type] + + # v3 user {0} group + if conf.exists('v3 user {0} group'.format(user)): + user_cfg['group'] = conf.return_value('v3 user {0} group'.format(user)) + + # v3 user {0} mode + if conf.exists('v3 user {0} mode'.format(user)): + user_cfg['mode'] = conf.return_value('v3 user {0} mode'.format(user)) + + # v3 user {0} privacy + if conf.exists('v3 user {0} privacy encrypted-password'.format(user)): + user_cfg['privMasterKey'] = conf.return_value('v3 user {0} privacy encrypted-password'.format(user)) + + if conf.exists('v3 user {0} privacy plaintext-password'.format(user)): + user_cfg['privPassword'] = conf.return_value('v3 user {0} privacy plaintext-password'.format(user)) + + # load default value + type = user_cfg['privProtocol'] + if conf.exists('v3 user {0} privacy type'.format(user)): + type = conf.return_value('v3 user {0} privacy type'.format(user)) + + # (re-)update with either default value or value from CLI + user_cfg['privProtocol'] = type + user_cfg['privOID'] = OIDs[type] + + snmp['v3_users'].append(user_cfg) + + # 'set service snmp v3 view' + if conf.exists('v3 view'): + for view in conf.list_nodes('v3 view'): + view_cfg = { + 'name': view, + 'oids': [] + } + + if conf.exists('v3 view {0} oid'.format(view)): + for oid in conf.list_nodes('v3 view {0} oid'.format(view)): + oid_cfg = { + 'oid': oid + } + view_cfg['oids'].append(oid_cfg) + snmp['v3_views'].append(view_cfg) + + return snmp + +def verify(snmp): + if snmp is None: + # we can not delete SNMP when LLDP is configured with SNMP + conf = Config() + if conf.exists('service lldp snmp enable'): + raise ConfigError('Can not delete SNMP service, as LLDP still uses SNMP!') + + return None + + ### check if the configured script actually exist + if snmp['script_ext']: + for ext in snmp['script_ext']: + if not os.path.isfile(ext['script']): + print ("WARNING: script: {} doesn't exist".format(ext['script'])) + else: + chmod_755(ext['script']) + + for listen in snmp['listen_address']: + addr = listen[0] + port = listen[1] + + if is_ipv4(addr): + # example: udp:127.0.0.1:161 + listen = 'udp:' + addr + ':' + port + elif snmp['ipv6_enabled']: + # example: udp6:[::1]:161 + listen = 'udp6:' + '[' + addr + ']' + ':' + port + + # We only wan't to configure addresses that exist on the system. + # Hint the user if they don't exist + if is_addr_assigned(addr): + snmp['listen_on'].append(listen) + else: + print('WARNING: SNMP listen address {0} not configured!'.format(addr)) + + verify_vrf(snmp) + + # bail out early if SNMP v3 is not configured + if not snmp['v3_enabled']: + return None + + if 'v3_groups' in snmp.keys(): + for group in snmp['v3_groups']: + # + # A view must exist prior to mapping it into a group + # + if 'view' in group.keys(): + error = True + if 'v3_views' in snmp.keys(): + for view in snmp['v3_views']: + if view['name'] == group['view']: + error = False + if error: + raise ConfigError('You must create view "{0}" first'.format(group['view'])) + else: + raise ConfigError('"view" must be specified') + + if not 'mode' in group.keys(): + raise ConfigError('"mode" must be specified') + + if not 'seclevel' in group.keys(): + raise ConfigError('"seclevel" must be specified') + + if 'v3_traps' in snmp.keys(): + for trap in snmp['v3_traps']: + if trap['authPassword'] and trap['authMasterKey']: + raise ConfigError('Must specify only one of encrypted-password/plaintext-key for trap auth') + + if trap['authPassword'] == '' and trap['authMasterKey'] == '': + raise ConfigError('Must specify encrypted-password or plaintext-key for trap auth') + + if trap['privPassword'] and trap['privMasterKey']: + raise ConfigError('Must specify only one of encrypted-password/plaintext-key for trap privacy') + + if trap['privPassword'] == '' and trap['privMasterKey'] == '': + raise ConfigError('Must specify encrypted-password or plaintext-key for trap privacy') + + if not 'type' in trap.keys(): + raise ConfigError('v3 trap: "type" must be specified') + + if not 'authPassword' and 'authMasterKey' in trap.keys(): + raise ConfigError('v3 trap: "auth" must be specified') + + if not 'authProtocol' in trap.keys(): + raise ConfigError('v3 trap: "protocol" must be specified') + + if not 'privPassword' and 'privMasterKey' in trap.keys(): + raise ConfigError('v3 trap: "user" must be specified') + + if 'v3_users' in snmp.keys(): + for user in snmp['v3_users']: + # + # Group must exist prior to mapping it into a group + # seclevel will be extracted from group + # + if user['group']: + error = True + if 'v3_groups' in snmp.keys(): + for group in snmp['v3_groups']: + if group['name'] == user['group']: + seclevel = group['seclevel'] + error = False + + if error: + raise ConfigError('You must create group "{0}" first'.format(user['group'])) + + # Depending on the configured security level the user has to provide additional info + if (not user['authPassword'] and not user['authMasterKey']): + raise ConfigError('Must specify encrypted-password or plaintext-key for user auth') + + if user['privPassword'] == '' and user['privMasterKey'] == '': + raise ConfigError('Must specify encrypted-password or plaintext-key for user privacy') + + if user['mode'] == '': + raise ConfigError('Must specify user mode ro/rw') + + if 'v3_views' in snmp.keys(): + for view in snmp['v3_views']: + if not view['oids']: + raise ConfigError('Must configure an oid') + + return None + +def generate(snmp): + # + # As we are manipulating the snmpd user database we have to stop it first! + # This is even save if service is going to be removed + call('systemctl stop snmpd.service') + config_files = [config_file_client, config_file_daemon, config_file_access, + config_file_user, systemd_override] + for file in config_files: + rmfile(file) + + if not snmp: + return None + + if 'v3_users' in snmp.keys(): + # net-snmp is now regenerating the configuration file in the background + # thus we need to re-open and re-read the file as the content changed. + # After that we can no read the encrypted password from the config and + # replace the CLI plaintext password with its encrypted version. + os.environ["vyos_libexec_dir"] = "/usr/libexec/vyos" + + for user in snmp['v3_users']: + if user['authProtocol'] == 'sha': + hash = plaintext_to_sha1 + else: + hash = plaintext_to_md5 + + if user['authPassword']: + user['authMasterKey'] = hash(user['authPassword'], snmp['v3_engineid']) + user['authPassword'] = '' + + call('/opt/vyatta/sbin/my_set service snmp v3 user "{name}" auth encrypted-password "{authMasterKey}" > /dev/null'.format(**user)) + call('/opt/vyatta/sbin/my_delete service snmp v3 user "{name}" auth plaintext-password > /dev/null'.format(**user)) + + if user['privPassword']: + user['privMasterKey'] = hash(user['privPassword'], snmp['v3_engineid']) + user['privPassword'] = '' + + call('/opt/vyatta/sbin/my_set service snmp v3 user "{name}" privacy encrypted-password "{privMasterKey}" > /dev/null'.format(**user)) + call('/opt/vyatta/sbin/my_delete service snmp v3 user "{name}" privacy plaintext-password > /dev/null'.format(**user)) + + # Write client config file + render(config_file_client, 'snmp/etc.snmp.conf.tmpl', snmp) + # Write server config file + render(config_file_daemon, 'snmp/etc.snmpd.conf.tmpl', snmp) + # Write access rights config file + render(config_file_access, 'snmp/usr.snmpd.conf.tmpl', snmp) + # Write access rights config file + render(config_file_user, 'snmp/var.snmpd.conf.tmpl', snmp) + # Write daemon configuration file + render(systemd_override, 'snmp/override.conf.tmpl', snmp) + + return None + +def apply(snmp): + # Always reload systemd manager configuration + call('systemctl daemon-reload') + + if not snmp: + return None + + # start SNMP daemon + call('systemctl restart snmpd.service') + + # Enable AgentX in FRR + call('vtysh -c "configure terminal" -c "agentx" >/dev/null') + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/ssh.py b/src/conf_mode/ssh.py new file mode 100755 index 000000000..7b262565a --- /dev/null +++ b/src/conf_mode/ssh.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2020 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 + +from netifaces import interfaces +from sys import exit + +from vyos.config import Config +from vyos.configdict import dict_merge +from vyos import ConfigError +from vyos.util import call +from vyos.template import render +from vyos.xml import defaults +from vyos import airbag +airbag.enable() + +config_file = r'/run/ssh/sshd_config' +systemd_override = r'/etc/systemd/system/ssh.service.d/override.conf' + +def get_config(): + conf = Config() + base = ['service', 'ssh'] + if not conf.exists(base): + return None + + ssh = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + # We have gathered the dict representation of the CLI, but there are default + # options which we need to update into the dictionary retrived. + default_values = defaults(base) + ssh = dict_merge(default_values, ssh) + # pass config file path - used in override template + ssh['config_file'] = config_file + + return ssh + +def verify(ssh): + if not ssh: + return None + + if 'vrf' in ssh.keys() and ssh['vrf'] not in interfaces(): + raise ConfigError('VRF "{vrf}" does not exist'.format(**ssh)) + + return None + +def generate(ssh): + if not ssh: + if os.path.isfile(config_file): + os.unlink(config_file) + if os.path.isfile(systemd_override): + os.unlink(systemd_override) + + return None + + render(config_file, 'ssh/sshd_config.tmpl', ssh, trim_blocks=True) + render(systemd_override, 'ssh/override.conf.tmpl', ssh, trim_blocks=True) + + return None + +def apply(ssh): + if not ssh: + # SSH access is removed in the commit + call('systemctl stop ssh.service') + + # Reload systemd manager configuration + call('systemctl daemon-reload') + + if ssh: + call('systemctl restart ssh.service') + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/system-ip.py b/src/conf_mode/system-ip.py new file mode 100755 index 000000000..85f1e3771 --- /dev/null +++ b/src/conf_mode/system-ip.py @@ -0,0 +1,85 @@ +#!/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 + +from sys import exit +from copy import deepcopy +from vyos.config import Config +from vyos import ConfigError +from vyos.util import call + +from vyos import airbag +airbag.enable() + +default_config_data = { + 'arp_table': 8192, + 'ipv4_forward': '1', + 'mp_unreach_nexthop': '0', + 'mp_layer4_hashing': '0' +} + +def sysctl(name, value): + call('sysctl -wq {}={}'.format(name, value)) + +def get_config(): + ip_opt = deepcopy(default_config_data) + conf = Config() + conf.set_level('system ip') + if conf.exists(''): + if conf.exists('arp table-size'): + ip_opt['arp_table'] = int(conf.return_value('arp table-size')) + + if conf.exists('disable-forwarding'): + ip_opt['ipv4_forward'] = '0' + + if conf.exists('multipath ignore-unreachable-nexthops'): + ip_opt['mp_unreach_nexthop'] = '1' + + if conf.exists('multipath layer4-hashing'): + ip_opt['mp_layer4_hashing'] = '1' + + return ip_opt + +def verify(ip_opt): + pass + +def generate(ip_opt): + pass + +def apply(ip_opt): + # apply ARP threshold values + sysctl('net.ipv4.neigh.default.gc_thresh3', ip_opt['arp_table']) + sysctl('net.ipv4.neigh.default.gc_thresh2', ip_opt['arp_table'] // 2) + sysctl('net.ipv4.neigh.default.gc_thresh1', ip_opt['arp_table'] // 8) + + # enable/disable IPv4 forwarding + with open('/proc/sys/net/ipv4/conf/all/forwarding', 'w') as f: + f.write(ip_opt['ipv4_forward']) + + # configure multipath + sysctl('net.ipv4.fib_multipath_use_neigh', ip_opt['mp_unreach_nexthop']) + sysctl('net.ipv4.fib_multipath_hash_policy', ip_opt['mp_layer4_hashing']) + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/system-ipv6.py b/src/conf_mode/system-ipv6.py new file mode 100755 index 000000000..3417c609d --- /dev/null +++ b/src/conf_mode/system-ipv6.py @@ -0,0 +1,113 @@ +#!/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 + +from sys import exit +from copy import deepcopy +from vyos.config import Config +from vyos import ConfigError +from vyos.util import call + +from vyos import airbag +airbag.enable() + +ipv6_disable_file = '/etc/modprobe.d/vyos_disable_ipv6.conf' + +default_config_data = { + 'reboot_message': False, + 'ipv6_forward': '1', + 'disable_addr_assignment': False, + 'mp_layer4_hashing': '0', + 'neighbor_cache': 8192, + 'strict_dad': '1' + +} + +def sysctl(name, value): + call('sysctl -wq {}={}'.format(name, value)) + +def get_config(): + ip_opt = deepcopy(default_config_data) + conf = Config() + conf.set_level('system ipv6') + if conf.exists(''): + ip_opt['disable_addr_assignment'] = conf.exists('disable') + if conf.exists_effective('disable') != conf.exists('disable'): + ip_opt['reboot_message'] = True + + if conf.exists('disable-forwarding'): + ip_opt['ipv6_forward'] = '0' + + if conf.exists('multipath layer4-hashing'): + ip_opt['mp_layer4_hashing'] = '1' + + if conf.exists('neighbor table-size'): + ip_opt['neighbor_cache'] = int(conf.return_value('neighbor table-size')) + + if conf.exists('strict-dad'): + ip_opt['strict_dad'] = 2 + + return ip_opt + +def verify(ip_opt): + pass + +def generate(ip_opt): + pass + +def apply(ip_opt): + # disable IPv6 address assignment + if ip_opt['disable_addr_assignment']: + with open(ipv6_disable_file, 'w') as f: + f.write('options ipv6 disable_ipv6=1') + else: + if os.path.exists(ipv6_disable_file): + os.unlink(ipv6_disable_file) + + if ip_opt['reboot_message']: + print('Changing IPv6 disable parameter will only take affect\n' \ + 'when the system is rebooted.') + + # configure multipath + sysctl('net.ipv6.fib_multipath_hash_policy', ip_opt['mp_layer4_hashing']) + + # apply neighbor table threshold values + sysctl('net.ipv6.neigh.default.gc_thresh3', ip_opt['neighbor_cache']) + sysctl('net.ipv6.neigh.default.gc_thresh2', ip_opt['neighbor_cache'] // 2) + sysctl('net.ipv6.neigh.default.gc_thresh1', ip_opt['neighbor_cache'] // 8) + + # enable/disable IPv6 forwarding + with open('/proc/sys/net/ipv6/conf/all/forwarding', 'w') as f: + f.write(ip_opt['ipv6_forward']) + + # configure IPv6 strict-dad + for root, dirs, files in os.walk('/proc/sys/net/ipv6/conf'): + for name in files: + if name == "accept_dad": + with open(os.path.join(root, name), 'w') as f: + f.write(str(ip_opt['strict_dad'])) + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/system-login-banner.py b/src/conf_mode/system-login-banner.py new file mode 100755 index 000000000..5c0adc921 --- /dev/null +++ b/src/conf_mode/system-login-banner.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2020 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/>. + +from sys import exit +from vyos.config import Config +from vyos import ConfigError + +from vyos import airbag +airbag.enable() + +motd=""" +The programs included with the Debian GNU/Linux system are free software; +the exact distribution terms for each program are described in the +individual files in /usr/share/doc/*/copyright. + +Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent +permitted by applicable law. + +""" + +PRELOGIN_FILE = r'/etc/issue' +PRELOGIN_NET_FILE = r'/etc/issue.net' +POSTLOGIN_FILE = r'/etc/motd' + +default_config_data = { + 'issue': 'Welcome to VyOS - \n \l\n', + 'issue_net': 'Welcome to VyOS\n', + 'motd': motd +} + +def get_config(): + banner = default_config_data + conf = Config() + base_level = ['system', 'login', 'banner'] + + if not conf.exists(base_level): + return banner + else: + conf.set_level(base_level) + + # Post-Login banner + if conf.exists(['post-login']): + tmp = conf.return_value(['post-login']) + # post-login banner can be empty as well + if tmp: + tmp = tmp.replace('\\n','\n') + tmp = tmp.replace('\\t','\t') + # always add newline character + tmp += '\n' + else: + tmp = '' + + banner['motd'] = tmp + + # Pre-Login banner + if conf.exists(['pre-login']): + tmp = conf.return_value(['pre-login']) + # pre-login banner can be empty as well + if tmp: + tmp = tmp.replace('\\n','\n') + tmp = tmp.replace('\\t','\t') + # always add newline character + tmp += '\n' + else: + tmp = '' + + banner['issue'] = banner['issue_net'] = tmp + + return banner + +def verify(banner): + pass + +def generate(banner): + pass + +def apply(banner): + with open(PRELOGIN_FILE, 'w') as f: + f.write(banner['issue']) + + with open(PRELOGIN_NET_FILE, 'w') as f: + f.write(banner['issue_net']) + + with open(POSTLOGIN_FILE, 'w') as f: + f.write(banner['motd']) + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/system-login.py b/src/conf_mode/system-login.py new file mode 100755 index 000000000..b1dd583b5 --- /dev/null +++ b/src/conf_mode/system-login.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2020 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 + +from crypt import crypt, METHOD_SHA512 +from netifaces import interfaces +from psutil import users +from pwd import getpwall, getpwnam +from spwd import getspnam +from sys import exit + +from vyos.config import Config +from vyos.template import render +from vyos.util import cmd, call, DEVNULL, chmod_600, chmod_755 +from vyos import ConfigError + +from vyos import airbag +airbag.enable() + +radius_config_file = "/etc/pam_radius_auth.conf" + +default_config_data = { + 'deleted': False, + 'add_users': [], + 'del_users': [], + 'radius_server': [], + 'radius_source_address': '', + 'radius_vrf': '' +} + + +def get_local_users(): + """Return list of dynamically allocated users (see Debian Policy Manual)""" + local_users = [] + for p in getpwall(): + username = p[0] + uid = getpwnam(username).pw_uid + if uid in range(1000, 29999): + if username not in ['radius_user', 'radius_priv_user']: + local_users.append(username) + + return local_users + + +def get_config(): + login = default_config_data + conf = Config() + base_level = ['system', 'login'] + + # We do not need to check if the nodes exist or not and bail out early + # ... this would interrupt the following logic on determine which users + # should be deleted and which users should stay. + # + # All fine so far! + + # Read in all local users and store to list + for username in conf.list_nodes(base_level + ['user']): + user = { + 'name': username, + 'password_plaintext': '', + 'password_encrypted': '!', + 'public_keys': [], + 'full_name': '', + 'home_dir': '/home/' + username, + } + conf.set_level(base_level + ['user', username]) + + # Plaintext password + if conf.exists(['authentication', 'plaintext-password']): + user['password_plaintext'] = conf.return_value( + ['authentication', 'plaintext-password']) + + # Encrypted password + if conf.exists(['authentication', 'encrypted-password']): + user['password_encrypted'] = conf.return_value( + ['authentication', 'encrypted-password']) + + # User real name + if conf.exists(['full-name']): + user['full_name'] = conf.return_value(['full-name']) + + # User home-directory + if conf.exists(['home-directory']): + user['home_dir'] = conf.return_value(['home-directory']) + + # Read in public keys + for id in conf.list_nodes(['authentication', 'public-keys']): + key = { + 'name': id, + 'key': '', + 'options': '', + 'type': '' + } + conf.set_level(base_level + ['user', username, 'authentication', + 'public-keys', id]) + + # Public Key portion + if conf.exists(['key']): + key['key'] = conf.return_value(['key']) + + # Options for individual public key + if conf.exists(['options']): + key['options'] = conf.return_value(['options']) + + # Type of public key + if conf.exists(['type']): + key['type'] = conf.return_value(['type']) + + # Append individual public key to list of user keys + user['public_keys'].append(key) + + login['add_users'].append(user) + + # + # RADIUS configuration + # + conf.set_level(base_level + ['radius']) + + if conf.exists(['source-address']): + login['radius_source_address'] = conf.return_value(['source-address']) + + # retrieve VRF instance + if conf.exists(['vrf']): + login['radius_vrf'] = conf.return_value(['vrf']) + + # Read in all RADIUS servers and store to list + for server in conf.list_nodes(['server']): + server_cfg = { + 'address': server, + 'disabled': False, + 'key': '', + 'port': '1812', + 'timeout': '2', + 'priority': 255 + } + conf.set_level(base_level + ['radius', 'server', server]) + + # Check if RADIUS server was temporary disabled + if conf.exists(['disable']): + server_cfg['disabled'] = True + + # RADIUS shared secret + if conf.exists(['key']): + server_cfg['key'] = conf.return_value(['key']) + + # RADIUS authentication port + if conf.exists(['port']): + server_cfg['port'] = conf.return_value(['port']) + + # RADIUS session timeout + if conf.exists(['timeout']): + server_cfg['timeout'] = conf.return_value(['timeout']) + + # Check if RADIUS server has priority + if conf.exists(['priority']): + server_cfg['priority'] = int(conf.return_value(['priority'])) + + # Append individual RADIUS server configuration to global server list + login['radius_server'].append(server_cfg) + + # users no longer existing in the running configuration need to be deleted + local_users = get_local_users() + cli_users = [tmp['name'] for tmp in login['add_users']] + # create a list of all users, cli and users + all_users = list(set(local_users+cli_users)) + + # Remove any normal users that dos not exist in the current configuration. + # This can happen if user is added but configuration was not saved and + # system is rebooted. + login['del_users'] = [tmp for tmp in all_users if tmp not in cli_users] + + return login + + +def verify(login): + cur_user = os.environ['SUDO_USER'] + if cur_user in login['del_users']: + raise ConfigError( + 'Attempting to delete current user: {}'.format(cur_user)) + + for user in login['add_users']: + for key in user['public_keys']: + if not key['type']: + raise ConfigError( + 'SSH public key type missing for "{name}"!'.format(**key)) + + if not key['key']: + raise ConfigError( + 'SSH public key for id "{name}" missing!'.format(**key)) + + # At lease one RADIUS server must not be disabled + if len(login['radius_server']) > 0: + fail = True + for server in login['radius_server']: + if not server['disabled']: + fail = False + if fail: + raise ConfigError('At least one RADIUS server must be active.') + + vrf_name = login['radius_vrf'] + if vrf_name and vrf_name not in interfaces(): + raise ConfigError(f'VRF "{vrf_name}" does not exist') + + return None + + +def generate(login): + # calculate users encrypted password + for user in login['add_users']: + if user['password_plaintext']: + user['password_encrypted'] = crypt( + user['password_plaintext'], METHOD_SHA512) + user['password_plaintext'] = '' + + # remove old plaintext password and set new encrypted password + env = os.environ.copy() + env['vyos_libexec_dir'] = '/usr/libexec/vyos' + + call("/opt/vyatta/sbin/my_set system login user '{name}' " + "authentication plaintext-password '{password_plaintext}'" + .format(**user), env=env) + + call("/opt/vyatta/sbin/my_set system login user '{name}' " + "authentication encrypted-password '{password_encrypted}'" + .format(**user), env=env) + + else: + try: + if getspnam(user['name']).sp_pwdp == user['password_encrypted']: + # If the current encrypted bassword matches the encrypted password + # from the config - do not update it. This will remove the encrypted + # value from the system logs. + # + # The encrypted password will be set only once during the first boot + # after an image upgrade. + user['password_encrypted'] = '' + except: + pass + + if len(login['radius_server']) > 0: + render(radius_config_file, 'system-login/pam_radius_auth.conf.tmpl', + login, trim_blocks=True) + + uid = getpwnam('root').pw_uid + gid = getpwnam('root').pw_gid + os.chown(radius_config_file, uid, gid) + chmod_600(radius_config_file) + else: + if os.path.isfile(radius_config_file): + os.unlink(radius_config_file) + + return None + + +def apply(login): + for user in login['add_users']: + # make new user using vyatta shell and make home directory (-m), + # default group of 100 (users) + command = "useradd -m -N" + # check if user already exists: + if user['name'] in get_local_users(): + # update existing account + command = "usermod" + + # all accounts use /bin/vbash + command += " -s /bin/vbash" + # we need to use '' quotes when passing formatted data to the shell + # else it will not work as some data parts are lost in translation + if user['password_encrypted']: + command += " -p '{}'".format(user['password_encrypted']) + + if user['full_name']: + command += " -c '{}'".format(user['full_name']) + + if user['home_dir']: + command += " -d '{}'".format(user['home_dir']) + + command += " -G frrvty,vyattacfg,sudo,adm,dip,disk" + command += " {}".format(user['name']) + + try: + cmd(command) + + uid = getpwnam(user['name']).pw_uid + gid = getpwnam(user['name']).pw_gid + + # we should not rely on the value stored in user['home_dir'], as a + # crazy user will choose username root or any other system user + # which will fail. Should we deny using root at all? + home_dir = getpwnam(user['name']).pw_dir + + # install ssh keys + ssh_key_dir = home_dir + '/.ssh' + if not os.path.isdir(ssh_key_dir): + os.mkdir(ssh_key_dir) + os.chown(ssh_key_dir, uid, gid) + chmod_755(ssh_key_dir) + + ssh_key_file = ssh_key_dir + '/authorized_keys' + with open(ssh_key_file, 'w') as f: + f.write("# Automatically generated by VyOS\n") + f.write("# Do not edit, all changes will be lost\n") + + for id in user['public_keys']: + line = '' + if id['options']: + line = '{} '.format(id['options']) + + line += '{} {} {}\n'.format(id['type'], + id['key'], id['name']) + f.write(line) + + os.chown(ssh_key_file, uid, gid) + chmod_600(ssh_key_file) + + except Exception as e: + print(e) + raise ConfigError('Adding user "{name}" raised exception' + .format(**user)) + + for user in login['del_users']: + try: + # Logout user if he is logged in + if user in list(set([tmp[0] for tmp in users()])): + print('{} is logged in, forcing logout'.format(user)) + call('pkill -HUP -u {}'.format(user)) + + # Remove user account but leave home directory to be safe + call(f'userdel -r {user}', stderr=DEVNULL) + + except Exception as e: + raise ConfigError(f'Deleting user "{user}" raised exception: {e}') + + # + # RADIUS configuration + # + if len(login['radius_server']) > 0: + try: + env = os.environ.copy() + env['DEBIAN_FRONTEND'] = 'noninteractive' + # Enable RADIUS in PAM + cmd("pam-auth-update --package --enable radius", env=env) + + # Make NSS system aware of RADIUS, too + command = "sed -i -e \'/\smapname/b\' \ + -e \'/^passwd:/s/\s\s*/&mapuid /\' \ + -e \'/^passwd:.*#/s/#.*/mapname &/\' \ + -e \'/^passwd:[^#]*$/s/$/ mapname &/\' \ + -e \'/^group:.*#/s/#.*/ mapname &/\' \ + -e \'/^group:[^#]*$/s/: */&mapname /\' \ + /etc/nsswitch.conf" + + cmd(command) + + except Exception as e: + raise ConfigError('RADIUS configuration failed: {}'.format(e)) + + else: + try: + env = os.environ.copy() + env['DEBIAN_FRONTEND'] = 'noninteractive' + + # Disable RADIUS in PAM + cmd("pam-auth-update --package --remove radius", env=env) + + command = "sed -i -e \'/^passwd:.*mapuid[ \t]/s/mapuid[ \t]//\' \ + -e \'/^passwd:.*[ \t]mapname/s/[ \t]mapname//\' \ + -e \'/^group:.*[ \t]mapname/s/[ \t]mapname//\' \ + -e \'s/[ \t]*$//\' \ + /etc/nsswitch.conf" + + cmd(command) + + except Exception as e: + raise ConfigError( + 'Removing RADIUS configuration failed.\n{}'.format(e)) + + return None + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/system-options.py b/src/conf_mode/system-options.py new file mode 100755 index 000000000..0aacd19d8 --- /dev/null +++ b/src/conf_mode/system-options.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2020 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 + +from netifaces import interfaces +from sys import exit + +from vyos.config import Config +from vyos.template import render +from vyos.util import call +from vyos.validate import is_addr_assigned +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +curlrc_config = r'/etc/curlrc' +ssh_config = r'/etc/ssh/ssh_config' +systemd_action_file = '/lib/systemd/system/ctrl-alt-del.target' + +def get_config(): + conf = Config() + base = ['system', 'options'] + options = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + return options + +def verify(options): + if 'http_client' in options: + config = options['http_client'] + if 'source_interface' in config: + if not config['source_interface'] in interfaces(): + raise ConfigError(f'Source interface {source_interface} does not ' + f'exist'.format(**config)) + + if {'source_address', 'source_interface'} <= set(config): + raise ConfigError('Can not define both HTTP source-interface and source-address') + + if 'source_address' in config: + if not is_addr_assigned(config['source_address']): + raise ConfigError('No interface with give address specified!') + + if 'ssh_client' in options: + config = options['ssh_client'] + if 'source_address' in config: + if not is_addr_assigned(config['source_address']): + raise ConfigError('No interface with give address specified!') + + return None + +def generate(options): + render(curlrc_config, 'system/curlrc.tmpl', options, trim_blocks=True) + render(ssh_config, 'system/ssh_config.tmpl', options, trim_blocks=True) + return None + +def apply(options): + # Beep action + if 'beep_if_fully_booted' in options.keys(): + call('systemctl enable vyos-beep.service') + else: + call('systemctl disable vyos-beep.service') + + # Ctrl-Alt-Delete action + if os.path.exists(systemd_action_file): + os.unlink(systemd_action_file) + + if 'ctrl_alt_del_action' in options: + if options['ctrl_alt_del_action'] == 'reboot': + os.symlink('/lib/systemd/system/reboot.target', systemd_action_file) + elif options['ctrl_alt_del_action'] == 'poweroff': + os.symlink('/lib/systemd/system/poweroff.target', systemd_action_file) + + if 'http_client' not in options: + if os.path.exists(curlrc_config): + os.unlink(curlrc_config) + + if 'ssh_client' not in options: + if os.path.exists(ssh_config): + os.unlink(ssh_config) + + # Reboot system on kernel panic + with open('/proc/sys/kernel/panic', 'w') as f: + if 'reboot_on_panic' in options.keys(): + f.write('60') + else: + f.write('0') + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) + diff --git a/src/conf_mode/system-proxy.py b/src/conf_mode/system-proxy.py new file mode 100755 index 000000000..02536c2ab --- /dev/null +++ b/src/conf_mode/system-proxy.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018 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 re + +from vyos import ConfigError +from vyos.config import Config + +from vyos import airbag +airbag.enable() + +proxy_def = r'/etc/profile.d/vyos-system-proxy.sh' + + +def get_config(): + c = Config() + if not c.exists('system proxy'): + return None + + c.set_level('system proxy') + + cnf = { + 'url': None, + 'port': None, + 'usr': None, + 'passwd': None + } + + if c.exists('url'): + cnf['url'] = c.return_value('url') + if c.exists('port'): + cnf['port'] = c.return_value('port') + if c.exists('username'): + cnf['usr'] = c.return_value('username') + if c.exists('password'): + cnf['passwd'] = c.return_value('password') + + return cnf + + +def verify(c): + if not c: + return None + if not c['url'] or not c['port']: + raise ConfigError("proxy url and port requires a value") + elif c['usr'] and not c['passwd']: + raise ConfigError("proxy password requires a value") + elif not c['usr'] and c['passwd']: + raise ConfigError("proxy username requires a value") + + +def generate(c): + if not c: + return None + if not c['usr']: + return str("export http_proxy={url}:{port}\nexport https_proxy=$http_proxy\nexport ftp_proxy=$http_proxy" + .format(url=c['url'], port=c['port'])) + else: + return str("export http_proxy=http://{usr}:{passwd}@{url}:{port}\nexport https_proxy=$http_proxy\nexport ftp_proxy=$http_proxy" + .format(url=re.sub('http://', '', c['url']), port=c['port'], usr=c['usr'], passwd=c['passwd'])) + + +def apply(ln): + if not ln and os.path.exists(proxy_def): + os.remove(proxy_def) + else: + open(proxy_def, 'w').write( + "# generated by system-proxy.py\n{}\n".format(ln)) + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + ln = generate(c) + apply(ln) + except ConfigError as e: + print(e) + sys.exit(1) diff --git a/src/conf_mode/system-syslog.py b/src/conf_mode/system-syslog.py new file mode 100755 index 000000000..cfc1ca55f --- /dev/null +++ b/src/conf_mode/system-syslog.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2020 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 + +from sys import exit + +from vyos.config import Config +from vyos import ConfigError +from vyos.util import run +from vyos.template import render + +from vyos import airbag +airbag.enable() + +def get_config(): + c = Config() + if not c.exists('system syslog'): + return None + c.set_level('system syslog') + + config_data = { + 'files': {}, + 'console': {}, + 'hosts': {}, + 'user': {} + } + + # + # /etc/rsyslog.d/vyos-rsyslog.conf + # 'set system syslog global' + # + config_data['files'].update( + { + 'global': { + 'log-file': '/var/log/messages', + 'max-size': 262144, + 'action-on-max-size': '/usr/sbin/logrotate /etc/logrotate.d/vyos-rsyslog', + 'selectors': '*.notice;local7.debug', + 'max-files': '5', + 'preserver_fqdn': False + } + } + ) + + if c.exists('global marker'): + config_data['files']['global']['marker'] = True + if c.exists('global marker interval'): + config_data['files']['global'][ + 'marker-interval'] = c.return_value('global marker interval') + if c.exists('global facility'): + config_data['files']['global'][ + 'selectors'] = generate_selectors(c, 'global facility') + if c.exists('global archive size'): + config_data['files']['global']['max-size'] = int( + c.return_value('global archive size')) * 1024 + if c.exists('global archive file'): + config_data['files']['global'][ + 'max-files'] = c.return_value('global archive file') + if c.exists('global preserve-fqdn'): + config_data['files']['global']['preserver_fqdn'] = True + + # + # set system syslog file + # + + if c.exists('file'): + filenames = c.list_nodes('file') + for filename in filenames: + config_data['files'].update( + { + filename: { + 'log-file': '/var/log/user/' + filename, + 'max-files': '5', + 'action-on-max-size': '/usr/sbin/logrotate /etc/logrotate.d/' + filename, + 'selectors': '*.err', + 'max-size': 262144 + } + } + ) + + if c.exists('file ' + filename + ' facility'): + config_data['files'][filename]['selectors'] = generate_selectors( + c, 'file ' + filename + ' facility') + if c.exists('file ' + filename + ' archive size'): + config_data['files'][filename]['max-size'] = int( + c.return_value('file ' + filename + ' archive size')) * 1024 + if c.exists('file ' + filename + ' archive files'): + config_data['files'][filename]['max-files'] = c.return_value( + 'file ' + filename + ' archive files') + + # set system syslog console + if c.exists('console'): + config_data['console'] = { + '/dev/console': { + 'selectors': '*.err' + } + } + + for f in c.list_nodes('console facility'): + if c.exists('console facility ' + f + ' level'): + config_data['console'] = { + '/dev/console': { + 'selectors': generate_selectors(c, 'console facility') + } + } + + # set system syslog host + if c.exists('host'): + rhosts = c.list_nodes('host') + proto = 'udp' + for rhost in rhosts: + for fac in c.list_nodes('host ' + rhost + ' facility'): + if c.exists('host ' + rhost + ' facility ' + fac + ' protocol'): + proto = c.return_value( + 'host ' + rhost + ' facility ' + fac + ' protocol') + else: + proto = 'udp' + + config_data['hosts'].update( + { + rhost: { + 'selectors': generate_selectors(c, 'host ' + rhost + ' facility'), + 'proto': proto + } + } + ) + if c.exists('host ' + rhost + ' port'): + config_data['hosts'][rhost][ + 'port'] = c.return_value(['host', rhost, 'port']) + + # set system syslog user + if c.exists('user'): + usrs = c.list_nodes('user') + for usr in usrs: + config_data['user'].update( + { + usr: { + 'selectors': generate_selectors(c, 'user ' + usr + ' facility') + } + } + ) + + return config_data + + +def generate_selectors(c, config_node): +# protocols and security are being mapped here +# for backward compatibility with old configs +# security and protocol mappings can be removed later + nodes = c.list_nodes(config_node) + selectors = "" + for node in nodes: + lvl = c.return_value(config_node + ' ' + node + ' level') + if lvl == None: + lvl = "err" + if lvl == 'all': + lvl = '*' + if node == 'all' and node != nodes[-1]: + selectors += "*." + lvl + ";" + elif node == 'all': + selectors += "*." + lvl + elif node != nodes[-1]: + if node == 'protocols': + node = 'local7' + if node == 'security': + node = 'auth' + selectors += node + "." + lvl + ";" + else: + if node == 'protocols': + node = 'local7' + if node == 'security': + node = 'auth' + selectors += node + "." + lvl + return selectors + + +def generate(c): + if c == None: + return None + + conf = '/etc/rsyslog.d/vyos-rsyslog.conf' + render(conf, 'syslog/rsyslog.conf.tmpl', c, trim_blocks=True) + + # eventually write for each file its own logrotate file, since size is + # defined it shouldn't matter + conf = '/etc/logrotate.d/vyos-rsyslog' + render(conf, 'syslog/logrotate.tmpl', c, trim_blocks=True) + + +def verify(c): + if c == None: + return None + + # may be obsolete + # /etc/rsyslog.conf is generated somewhere and copied over the original (exists in /opt/vyatta/etc/rsyslog.conf) + # it interferes with the global logging, to make sure we are using a single base, template is enforced here + # + if not os.path.islink('/etc/rsyslog.conf'): + os.remove('/etc/rsyslog.conf') + os.symlink( + '/usr/share/vyos/templates/rsyslog/rsyslog.conf', '/etc/rsyslog.conf') + + # /var/log/vyos-rsyslog were the old files, we may want to clean those up, but currently there + # is a chance that someone still needs it, so I don't automatically remove + # them + # + + if c == None: + return None + + fac = [ + '*', 'auth', 'authpriv', 'cron', 'daemon', 'kern', 'lpr', 'mail', 'mark', 'news', 'protocols', 'security', + 'syslog', 'user', 'uucp', 'local0', 'local1', 'local2', 'local3', 'local4', 'local5', 'local6', 'local7'] + lvl = ['emerg', 'alert', 'crit', 'err', + 'warning', 'notice', 'info', 'debug', '*'] + + for conf in c: + if c[conf]: + for item in c[conf]: + for s in c[conf][item]['selectors'].split(";"): + f = re.sub("\..*$", "", s) + if f not in fac: + raise ConfigError( + 'Invalid facility ' + s + ' set in ' + conf + ' ' + item) + l = re.sub("^.+\.", "", s) + if l not in lvl: + raise ConfigError( + 'Invalid logging level ' + s + ' set in ' + conf + ' ' + item) + + +def apply(c): + if not c: + return run('systemctl stop syslog.service') + return run('systemctl restart syslog.service') + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/system-timezone.py b/src/conf_mode/system-timezone.py new file mode 100755 index 000000000..0f4513122 --- /dev/null +++ b/src/conf_mode/system-timezone.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 sys +import os + +from copy import deepcopy +from vyos.config import Config +from vyos import ConfigError +from vyos.util import call + +from vyos import airbag +airbag.enable() + +default_config_data = { + 'name': 'UTC' +} + +def get_config(): + tz = deepcopy(default_config_data) + conf = Config() + if conf.exists('system time-zone'): + tz['name'] = conf.return_value('system time-zone') + + return tz + +def verify(tz): + pass + +def generate(tz): + pass + +def apply(tz): + call('/usr/bin/timedatectl set-timezone {}'.format(tz['name'])) + +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/system-wifi-regdom.py b/src/conf_mode/system-wifi-regdom.py new file mode 100755 index 000000000..30ea89098 --- /dev/null +++ b/src/conf_mode/system-wifi-regdom.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2020 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 + +from copy import deepcopy +from sys import exit + +from vyos.config import Config +from vyos import ConfigError +from vyos.template import render + +from vyos import airbag +airbag.enable() + +config_80211_file='/etc/modprobe.d/cfg80211.conf' +config_crda_file='/etc/default/crda' + +default_config_data = { + 'regdom' : '', + 'deleted' : False +} + +def get_config(): + regdom = deepcopy(default_config_data) + conf = Config() + base = ['system', 'wifi-regulatory-domain'] + + # Check if interface has been removed + if not conf.exists(base): + regdom['deleted'] = True + return regdom + else: + regdom['regdom'] = conf.return_value(base) + + return regdom + +def verify(regdom): + if regdom['deleted']: + return None + + if not regdom['regdom']: + raise ConfigError("Wireless regulatory domain is mandatory.") + + return None + +def generate(regdom): + print("Changing the wireless regulatory domain requires a system reboot.") + + if regdom['deleted']: + if os.path.isfile(config_80211_file): + os.unlink(config_80211_file) + + if os.path.isfile(config_crda_file): + os.unlink(config_crda_file) + + return None + + render(config_80211_file, 'wifi/cfg80211.conf.tmpl', regdom) + render(config_crda_file, 'wifi/crda.tmpl', regdom) + return None + +def apply(regdom): + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/system_console.py b/src/conf_mode/system_console.py new file mode 100755 index 000000000..6f83335f3 --- /dev/null +++ b/src/conf_mode/system_console.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2020 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 + +from fileinput import input as replace_in_file +from vyos.config import Config +from vyos.util import call +from vyos.template import render +from vyos import ConfigError, airbag +airbag.enable() + +by_bus_dir = '/dev/serial/by-bus' + +def get_config(): + conf = Config() + base = ['system', 'console'] + + # retrieve configuration at once + console = conf.get_config_dict(base, get_first_key=True) + + # bail out early if no serial console is configured + if 'device' not in console.keys(): + return console + + # convert CLI values to system values + for device in console['device'].keys(): + # no speed setting has been configured - use default value + if not 'speed' in console['device'][device].keys(): + tmp = { 'speed': '' } + if device.startswith('hvc'): + tmp['speed'] = 38400 + else: + tmp['speed'] = 115200 + + console['device'][device].update(tmp) + + if device.startswith('usb'): + # It is much easiert to work with the native ttyUSBn name when using + # getty, but that name may change across reboots - depending on the + # amount of connected devices. We will resolve the fixed device name + # to its dynamic device file - and create a new dict entry for it. + by_bus_device = f'{by_bus_dir}/{device}' + if os.path.isdir(by_bus_dir) and os.path.exists(by_bus_device): + tmp = os.path.basename(os.readlink(by_bus_device)) + # updating the dict must come as last step in the loop! + console['device'][tmp] = console['device'].pop(device) + + return console + +def verify(console): + return None + +def generate(console): + base_dir = '/etc/systemd/system' + # Remove all serial-getty configuration files in advance + for root, dirs, files in os.walk(base_dir): + for basename in files: + if 'serial-getty' in basename: + call(f'systemctl stop {basename}') + os.unlink(os.path.join(root, basename)) + + if not console: + return None + + for device in console['device'].keys(): + config_file = base_dir + f'/serial-getty@{device}.service' + getty_wants_symlink = base_dir + f'/getty.target.wants/serial-getty@{device}.service' + + render(config_file, 'getty/serial-getty.service.tmpl', console['device'][device]) + os.symlink(config_file, getty_wants_symlink) + + # GRUB + # For existing serial line change speed (if necessary) + # Only applys to ttyS0 + if 'ttyS0' not in console['device'].keys(): + return None + + speed = console['device']['ttyS0']['speed'] + grub_config = '/boot/grub/grub.cfg' + if not os.path.isfile(grub_config): + return None + + # stdin/stdout are redirected in replace_in_file(), thus print() is fine + p = re.compile(r'^(.* console=ttyS0),[0-9]+(.*)$') + for line in replace_in_file(grub_config, inplace=True): + if line.startswith('serial --unit'): + line = f'serial --unit=0 --speed={speed}\n' + elif p.match(line): + line = '{},{}{}\n'.format(p.search(line)[1], speed, p.search(line)[2]) + + print(line, end='') + + return None + +def apply(console): + # reset screen blanking + call('/usr/bin/setterm -blank 0 -powersave off -powerdown 0 -term linux </dev/tty1 >/dev/tty1 2>&1') + + # Reload systemd manager configuration + call('systemctl daemon-reload') + + if not console: + return None + + if 'powersave' in console.keys(): + # Configure screen blank powersaving on VGA console + call('/usr/bin/setterm -blank 15 -powersave powerdown -powerdown 60 -term linux </dev/tty1 >/dev/tty1 2>&1') + + # Start getty process on configured serial interfaces + for device in console['device'].keys(): + # Only start console if it exists on the running system. If a user + # detaches a USB serial console and reboots - it should not fail! + if os.path.exists(f'/dev/{device}'): + call(f'systemctl start serial-getty@{device}.service') + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/system_lcd.py b/src/conf_mode/system_lcd.py new file mode 100755 index 000000000..31a09252d --- /dev/null +++ b/src/conf_mode/system_lcd.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +# +# Copyright 2020 VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 + +from sys import exit + +from vyos.config import Config +from vyos.util import call +from vyos.util import find_device_file +from vyos.template import render +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +lcdd_conf = '/run/LCDd/LCDd.conf' +lcdproc_conf = '/run/lcdproc/lcdproc.conf' + +def get_config(): + conf = Config() + base = ['system', 'lcd'] + lcd = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True) + # Return (possibly empty) dictionary + return lcd + +def verify(lcd): + if not lcd: + return None + + if 'model' in lcd and lcd['model'] in ['sdec']: + # This is a fixed LCD display, no device needed - bail out early + return None + + if not {'device', 'model'} <= set(lcd): + raise ConfigError('Both device and driver must be set!') + + return None + +def generate(lcd): + if not lcd: + return None + + if 'device' in lcd: + lcd['device'] = find_device_file(lcd['device']) + + # Render config file for daemon LCDd + render(lcdd_conf, 'lcd/LCDd.conf.tmpl', lcd, trim_blocks=True) + # Render config file for client lcdproc + render(lcdproc_conf, 'lcd/lcdproc.conf.tmpl', lcd, trim_blocks=True) + + return None + +def apply(lcd): + if not lcd: + call('systemctl stop lcdproc.service LCDd.service') + + for file in [lcdd_conf, lcdproc_conf]: + if os.path.exists(file): + os.remove(file) + else: + # Restart server + call('systemctl restart LCDd.service lcdproc.service') + + return None + +if __name__ == '__main__': + try: + config_dict = get_config() + verify(config_dict) + generate(config_dict) + apply(config_dict) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/task_scheduler.py b/src/conf_mode/task_scheduler.py new file mode 100755 index 000000000..51d8684cb --- /dev/null +++ b/src/conf_mode/task_scheduler.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2017 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 sys + +from vyos.config import Config +from vyos import ConfigError + +from vyos import airbag +airbag.enable() + +crontab_file = "/etc/cron.d/vyos-crontab" + + +def format_task(minute="*", hour="*", day="*", dayofweek="*", month="*", user="root", rawspec=None, command=""): + fmt_full = "{minute} {hour} {day} {month} {dayofweek} {user} {command}\n" + fmt_raw = "{spec} {user} {command}\n" + + if rawspec is None: + s = fmt_full.format(minute=minute, hour=hour, day=day, + dayofweek=dayofweek, month=month, command=command, user=user) + else: + s = fmt_raw.format(spec=rawspec, user=user, command=command) + + return s + +def split_interval(s): + result = re.search(r"(\d+)([mdh]?)", s) + value = int(result.group(1)) + suffix = result.group(2) + return( (value, suffix) ) + +def make_command(executable, arguments): + if arguments: + return("sg vyattacfg \"{0} {1}\"".format(executable, arguments)) + else: + return("sg vyattacfg \"{0}\"".format(executable)) + +def get_config(): + conf = Config() + conf.set_level("system task-scheduler task") + task_names = conf.list_nodes("") + tasks = [] + + for name in task_names: + interval = conf.return_value("{0} interval".format(name)) + spec = conf.return_value("{0} crontab-spec".format(name)) + executable = conf.return_value("{0} executable path".format(name)) + args = conf.return_value("{0} executable arguments".format(name)) + task = { + "name": name, + "interval": interval, + "spec": spec, + "executable": executable, + "args": args + } + tasks.append(task) + + return tasks + +def verify(tasks): + for task in tasks: + if not task["interval"] and not task["spec"]: + raise ConfigError("Invalid task {0}: must define either interval or crontab-spec".format(task["name"])) + + if task["interval"]: + if task["spec"]: + raise ConfigError("Invalid task {0}: cannot use interval and crontab-spec at the same time".format(task["name"])) + + if not re.match(r"^\d+[mdh]?$", task["interval"]): + raise(ConfigError("Invalid interval {0} in task {1}: interval should be a number optionally followed by m, h, or d".format(task["name"], task["interval"]))) + else: + # Check if values are within allowed range + value, suffix = split_interval(task["interval"]) + + if not suffix or suffix == "m": + if value > 60: + raise ConfigError("Invalid task {0}: interval in minutes must not exceed 60".format(task["name"])) + elif suffix == "h": + if value > 24: + raise ConfigError("Invalid task {0}: interval in hours must not exceed 24".format(task["name"])) + elif suffix == "d": + if value > 31: + raise ConfigError("Invalid task {0}: interval in days must not exceed 31".format(task["name"])) + + if not task["executable"]: + raise ConfigError("Invalid task {0}: executable is not defined".format(task["name"])) + else: + # Check if executable exists and is executable + if not (os.path.isfile(task["executable"]) and os.access(task["executable"], os.X_OK)): + raise ConfigError("Invalid task {0}: file {1} does not exist or is not executable".format(task["name"], task["executable"])) + +def generate(tasks): + crontab_header = "### Generated by vyos-update-crontab.py ###\n" + if len(tasks) == 0: + if os.path.exists(crontab_file): + os.remove(crontab_file) + else: + pass + else: + crontab_lines = [] + for task in tasks: + command = make_command(task["executable"], task["args"]) + if task["spec"]: + line = format_task(command=command, rawspec=task["spec"]) + else: + value, suffix = split_interval(task["interval"]) + if not suffix or suffix == "m": + line = format_task(command=command, minute="*/{0}".format(value)) + elif suffix == "h": + line = format_task(command=command, minute="0", hour="*/{0}".format(value)) + elif suffix == "d": + line = format_task(command=command, minute="0", hour="0", day="*/{0}".format(value)) + crontab_lines.append(line) + + with open(crontab_file, 'w') as f: + f.write(crontab_header) + f.writelines(crontab_lines) + +def apply(config): + # No daemon restarts etc. needed for cron + pass + + +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/tftp_server.py b/src/conf_mode/tftp_server.py new file mode 100755 index 000000000..d31851bef --- /dev/null +++ b/src/conf_mode/tftp_server.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2020 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 stat +import pwd + +from copy import deepcopy +from glob import glob +from sys import exit + +from vyos.config import Config +from vyos.validate import is_ipv4, is_addr_assigned +from vyos import ConfigError +from vyos.util import call +from vyos.template import render + +from vyos import airbag +airbag.enable() + +config_file = r'/etc/default/tftpd' + +default_config_data = { + 'directory': '', + 'allow_upload': False, + 'port': '69', + 'listen': [] +} + +def get_config(): + tftpd = deepcopy(default_config_data) + conf = Config() + base = ['service', 'tftp-server'] + if not conf.exists(base): + return None + else: + conf.set_level(base) + + if conf.exists(['directory']): + tftpd['directory'] = conf.return_value(['directory']) + + if conf.exists(['allow-upload']): + tftpd['allow_upload'] = True + + if conf.exists(['port']): + tftpd['port'] = conf.return_value(['port']) + + if conf.exists(['listen-address']): + tftpd['listen'] = conf.return_values(['listen-address']) + + return tftpd + +def verify(tftpd): + # bail out early - looks like removal from running config + if tftpd is None: + return None + + # Configuring allowed clients without a server makes no sense + if not tftpd['directory']: + raise ConfigError('TFTP root directory must be configured!') + + if not tftpd['listen']: + raise ConfigError('TFTP server listen address must be configured!') + + for addr in tftpd['listen']: + if not is_addr_assigned(addr): + print('WARNING: TFTP server listen address {0} not assigned to any interface!'.format(addr)) + + return None + +def generate(tftpd): + # cleanup any available configuration file + # files will be recreated on demand + for i in glob(config_file + '*'): + os.unlink(i) + + # bail out early - looks like removal from running config + if tftpd is None: + return None + + idx = 0 + for listen in tftpd['listen']: + config = deepcopy(tftpd) + if is_ipv4(listen): + config['listen'] = [listen + ":" + tftpd['port'] + " -4"] + else: + config['listen'] = ["[" + listen + "]" + tftpd['port'] + " -6"] + + file = config_file + str(idx) + render(file, 'tftp-server/default.tmpl', config) + + idx = idx + 1 + + return None + +def apply(tftpd): + # stop all services first - then we will decide + call('systemctl stop tftpd@{0..20}.service') + + # bail out early - e.g. service deletion + if tftpd is None: + return None + + tftp_root = tftpd['directory'] + if not os.path.exists(tftp_root): + os.makedirs(tftp_root) + os.chmod(tftp_root, stat.S_IRUSR|stat.S_IWUSR|stat.S_IXUSR|stat.S_IRGRP|stat.S_IXGRP|stat.S_IROTH|stat.S_IXOTH) + + # get UNIX uid for user 'tftp' + tftp_uid = pwd.getpwnam('tftp').pw_uid + tftp_gid = pwd.getpwnam('tftp').pw_gid + + # get UNIX uid for tftproot directory + dir_uid = os.stat(tftp_root).st_uid + dir_gid = os.stat(tftp_root).st_gid + + # adjust uid/gid of tftproot directory if files don't belong to user tftp + if (tftp_uid != dir_uid) or (tftp_gid != dir_gid): + os.chown(tftp_root, tftp_uid, tftp_gid) + + idx = 0 + for listen in tftpd['listen']: + call('systemctl restart tftpd@{0}.service'.format(idx)) + idx = idx + 1 + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/vpn_anyconnect.py b/src/conf_mode/vpn_anyconnect.py new file mode 100755 index 000000000..158e1a117 --- /dev/null +++ b/src/conf_mode/vpn_anyconnect.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2020 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 +from sys import exit + +from vyos.config import Config +from vyos.configdict import dict_merge +from vyos.xml import defaults +from vyos.template import render +from vyos.util import call +from vyos import ConfigError +from crypt import crypt, mksalt, METHOD_SHA512 + +from vyos import airbag +airbag.enable() + +cfg_dir = '/run/ocserv' +ocserv_conf = cfg_dir + '/ocserv.conf' +ocserv_passwd = cfg_dir + '/ocpasswd' +radius_cfg = cfg_dir + '/radiusclient.conf' +radius_servers = cfg_dir + '/radius_servers' + + +# Generate hash from user cleartext password +def get_hash(password): + return crypt(password, mksalt(METHOD_SHA512)) + + +def get_config(): + conf = Config() + base = ['vpn', 'anyconnect'] + if not conf.exists(base): + return None + + ocserv = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + default_values = defaults(base) + ocserv = dict_merge(default_values, ocserv) + return ocserv + + +def verify(ocserv): + if ocserv is None: + return None + + # Check authentication + if "authentication" in ocserv: + if "mode" in ocserv["authentication"]: + if "local" in ocserv["authentication"]["mode"]: + if not ocserv["authentication"]["local_users"] or not ocserv["authentication"]["local_users"]["username"]: + raise ConfigError('Anyconect mode local required at leat one user') + else: + for user in ocserv["authentication"]["local_users"]["username"]: + if not "password" in ocserv["authentication"]["local_users"]["username"][user]: + raise ConfigError(f'password required for user {user}') + else: + raise ConfigError('anyconnect authentication mode required') + else: + raise ConfigError('anyconnect authentication credentials required') + + # Check ssl + if "ssl" in ocserv: + req_cert = ['ca_cert_file', 'cert_file', 'key_file'] + for cert in req_cert: + if not cert in ocserv["ssl"]: + raise ConfigError('anyconnect ssl {0} required'.format(cert.replace('_', '-'))) + else: + raise ConfigError('anyconnect ssl required') + + # Check network settings + if "network_settings" in ocserv: + if "push_route" in ocserv["network_settings"]: + # Replace default route + if "0.0.0.0/0" in ocserv["network_settings"]["push_route"]: + ocserv["network_settings"]["push_route"].remove("0.0.0.0/0") + ocserv["network_settings"]["push_route"].append("default") + else: + ocserv["network_settings"]["push_route"] = "default" + else: + raise ConfigError('anyconnect network settings required') + + +def generate(ocserv): + if not ocserv: + return None + + if "radius" in ocserv["authentication"]["mode"]: + # Render radius client configuration + render(radius_cfg, 'ocserv/radius_conf.tmpl', ocserv["authentication"]["radius"], trim_blocks=True) + # Render radius servers + render(radius_servers, 'ocserv/radius_servers.tmpl', ocserv["authentication"]["radius"], trim_blocks=True) + else: + if "local_users" in ocserv["authentication"]: + for user in ocserv["authentication"]["local_users"]["username"]: + ocserv["authentication"]["local_users"]["username"][user]["hash"] = get_hash(ocserv["authentication"]["local_users"]["username"][user]["password"]) + # Render local users + render(ocserv_passwd, 'ocserv/ocserv_passwd.tmpl', ocserv["authentication"]["local_users"], trim_blocks=True) + + # Render config + render(ocserv_conf, 'ocserv/ocserv_config.tmpl', ocserv, trim_blocks=True) + + + +def apply(ocserv): + if not ocserv: + call('systemctl stop ocserv.service') + for file in [ocserv_conf, ocserv_passwd]: + if os.path.exists(file): + os.unlink(file) + else: + call('systemctl restart ocserv.service') + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/vpn_l2tp.py b/src/conf_mode/vpn_l2tp.py new file mode 100755 index 000000000..26ad1af84 --- /dev/null +++ b/src/conf_mode/vpn_l2tp.py @@ -0,0 +1,381 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2020 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 + +from copy import deepcopy +from stat import S_IRUSR, S_IWUSR, S_IRGRP +from sys import exit +from time import sleep + +from ipaddress import ip_network + +from vyos.config import Config +from vyos.util import call, get_half_cpus +from vyos.validate import is_ipv4 +from vyos import ConfigError +from vyos.template import render + +from vyos import airbag +airbag.enable() + +l2tp_conf = '/run/accel-pppd/l2tp.conf' +l2tp_chap_secrets = '/run/accel-pppd/l2tp.chap-secrets' + +default_config_data = { + 'auth_mode': 'local', + 'auth_ppp_mppe': 'prefer', + 'auth_proto': ['auth_mschap_v2'], + 'chap_secrets_file': l2tp_chap_secrets, # used in Jinja2 template + 'client_ip_pool': None, + 'client_ip_subnets': [], + 'client_ipv6_pool': [], + 'client_ipv6_delegate_prefix': [], + 'dnsv4': [], + 'dnsv6': [], + 'gateway_address': '10.255.255.0', + 'local_users' : [], + 'mtu': '1436', + 'outside_addr': '', + 'ppp_mppe': 'prefer', + 'ppp_echo_failure' : '3', + 'ppp_echo_interval' : '30', + 'ppp_echo_timeout': '0', + 'radius_server': [], + 'radius_acct_tmo': '3', + 'radius_max_try': '3', + 'radius_timeout': '3', + 'radius_nas_id': '', + 'radius_nas_ip': '', + 'radius_source_address': '', + 'radius_shaper_attr': '', + 'radius_shaper_vendor': '', + 'radius_dynamic_author': '', + 'wins': [], + 'ip6_column': [], + 'thread_cnt': get_half_cpus() +} + +def get_config(): + conf = Config() + base_path = ['vpn', 'l2tp', 'remote-access'] + if not conf.exists(base_path): + return None + + conf.set_level(base_path) + l2tp = deepcopy(default_config_data) + + ### general options ### + if conf.exists(['name-server']): + for name_server in conf.return_values(['name-server']): + if is_ipv4(name_server): + l2tp['dnsv4'].append(name_server) + else: + l2tp['dnsv6'].append(name_server) + + if conf.exists(['wins-server']): + l2tp['wins'] = conf.return_values(['wins-server']) + + if conf.exists('outside-address'): + l2tp['outside_addr'] = conf.return_value('outside-address') + + if conf.exists(['authentication', 'mode']): + l2tp['auth_mode'] = conf.return_value(['authentication', 'mode']) + + if conf.exists(['authentication', 'protocols']): + auth_mods = { + 'pap': 'auth_pap', + 'chap': 'auth_chap_md5', + 'mschap': 'auth_mschap_v1', + 'mschap-v2': 'auth_mschap_v2' + } + + for proto in conf.return_values(['authentication', 'protocols']): + l2tp['auth_proto'].append(auth_mods[proto]) + + if conf.exists(['authentication', 'mppe']): + l2tp['auth_ppp_mppe'] = conf.return_value(['authentication', 'mppe']) + + # + # local auth + if conf.exists(['authentication', 'local-users']): + for username in conf.list_nodes(['authentication', 'local-users', 'username']): + user = { + 'name' : username, + 'password' : '', + 'state' : 'enabled', + 'ip' : '*', + 'upload' : None, + 'download' : None + } + + conf.set_level(base_path + ['authentication', 'local-users', 'username', username]) + + if conf.exists(['password']): + user['password'] = conf.return_value(['password']) + + if conf.exists(['disable']): + user['state'] = 'disable' + + if conf.exists(['static-ip']): + user['ip'] = conf.return_value(['static-ip']) + + if conf.exists(['rate-limit', 'download']): + user['download'] = conf.return_value(['rate-limit', 'download']) + + if conf.exists(['rate-limit', 'upload']): + user['upload'] = conf.return_value(['rate-limit', 'upload']) + + l2tp['local_users'].append(user) + + # + # RADIUS auth and settings + conf.set_level(base_path + ['authentication', 'radius']) + if conf.exists(['server']): + for server in conf.list_nodes(['server']): + radius = { + 'server' : server, + 'key' : '', + 'fail_time' : 0, + 'port' : '1812', + 'acct_port' : '1813' + } + + conf.set_level(base_path + ['authentication', 'radius', 'server', server]) + + if conf.exists(['fail-time']): + radius['fail_time'] = conf.return_value(['fail-time']) + + if conf.exists(['port']): + radius['port'] = conf.return_value(['port']) + + if conf.exists(['acct-port']): + radius['acct_port'] = conf.return_value(['acct-port']) + + if conf.exists(['key']): + radius['key'] = conf.return_value(['key']) + + if not conf.exists(['disable']): + l2tp['radius_server'].append(radius) + + # + # advanced radius-setting + conf.set_level(base_path + ['authentication', 'radius']) + + if conf.exists(['acct-timeout']): + l2tp['radius_acct_tmo'] = conf.return_value(['acct-timeout']) + + if conf.exists(['max-try']): + l2tp['radius_max_try'] = conf.return_value(['max-try']) + + if conf.exists(['timeout']): + l2tp['radius_timeout'] = conf.return_value(['timeout']) + + if conf.exists(['nas-identifier']): + l2tp['radius_nas_id'] = conf.return_value(['nas-identifier']) + + if conf.exists(['nas-ip-address']): + l2tp['radius_nas_ip'] = conf.return_value(['nas-ip-address']) + + if conf.exists(['source-address']): + l2tp['radius_source_address'] = conf.return_value(['source-address']) + + # Dynamic Authorization Extensions (DOA)/Change Of Authentication (COA) + if conf.exists(['dynamic-author']): + dae = { + 'port' : '', + 'server' : '', + 'key' : '' + } + + if conf.exists(['dynamic-author', 'server']): + dae['server'] = conf.return_value(['dynamic-author', 'server']) + + if conf.exists(['dynamic-author', 'port']): + dae['port'] = conf.return_value(['dynamic-author', 'port']) + + if conf.exists(['dynamic-author', 'key']): + dae['key'] = conf.return_value(['dynamic-author', 'key']) + + l2tp['radius_dynamic_author'] = dae + + if conf.exists(['rate-limit', 'enable']): + l2tp['radius_shaper_attr'] = 'Filter-Id' + c_attr = ['rate-limit', 'enable', 'attribute'] + if conf.exists(c_attr): + l2tp['radius_shaper_attr'] = conf.return_value(c_attr) + + c_vendor = ['rate-limit', 'enable', 'vendor'] + if conf.exists(c_vendor): + l2tp['radius_shaper_vendor'] = conf.return_value(c_vendor) + + conf.set_level(base_path) + if conf.exists(['client-ip-pool']): + if conf.exists(['client-ip-pool', 'start']) and conf.exists(['client-ip-pool', 'stop']): + start = conf.return_value(['client-ip-pool', 'start']) + stop = conf.return_value(['client-ip-pool', 'stop']) + l2tp['client_ip_pool'] = start + '-' + re.search('[0-9]+$', stop).group(0) + + if conf.exists(['client-ip-pool', 'subnet']): + l2tp['client_ip_subnets'] = conf.return_values(['client-ip-pool', 'subnet']) + + if conf.exists(['client-ipv6-pool', 'prefix']): + l2tp['ip6_column'].append('ip6') + for prefix in conf.list_nodes(['client-ipv6-pool', 'prefix']): + tmp = { + 'prefix': prefix, + 'mask': '64' + } + + if conf.exists(['client-ipv6-pool', 'prefix', prefix, 'mask']): + tmp['mask'] = conf.return_value(['client-ipv6-pool', 'prefix', prefix, 'mask']) + + l2tp['client_ipv6_pool'].append(tmp) + + if conf.exists(['client-ipv6-pool', 'delegate']): + l2tp['ip6_column'].append('ip6-db') + for prefix in conf.list_nodes(['client-ipv6-pool', 'delegate']): + tmp = { + 'prefix': prefix, + 'mask': '' + } + + if conf.exists(['client-ipv6-pool', 'delegate', prefix, 'delegation-prefix']): + tmp['mask'] = conf.return_value(['client-ipv6-pool', 'delegate', prefix, 'delegation-prefix']) + + l2tp['client_ipv6_delegate_prefix'].append(tmp) + + if conf.exists(['mtu']): + l2tp['mtu'] = conf.return_value(['mtu']) + + # gateway address + if conf.exists(['gateway-address']): + l2tp['gateway_address'] = conf.return_value(['gateway-address']) + else: + # calculate gw-ip-address + if conf.exists(['client-ip-pool', 'start']): + # use start ip as gw-ip-address + l2tp['gateway_address'] = conf.return_value(['client-ip-pool', 'start']) + + elif conf.exists(['client-ip-pool', 'subnet']): + # use first ip address from first defined pool + subnet = conf.return_values(['client-ip-pool', 'subnet'])[0] + subnet = ip_network(subnet) + l2tp['gateway_address'] = str(list(subnet.hosts())[0]) + + # LNS secret + if conf.exists(['lns', 'shared-secret']): + l2tp['lns_shared_secret'] = conf.return_value(['lns', 'shared-secret']) + + if conf.exists(['ccp-disable']): + l2tp['ccp_disable'] = True + + # PPP options + if conf.exists(['idle']): + l2tp['ppp_echo_timeout'] = conf.return_value(['idle']) + + if conf.exists(['ppp-options', 'lcp-echo-failure']): + l2tp['ppp_echo_failure'] = conf.return_value(['ppp-options', 'lcp-echo-failure']) + + if conf.exists(['ppp-options', 'lcp-echo-interval']): + l2tp['ppp_echo_interval'] = conf.return_value(['ppp-options', 'lcp-echo-interval']) + + return l2tp + + +def verify(l2tp): + if not l2tp: + return None + + if l2tp['auth_mode'] == 'local': + if not l2tp['local_users']: + raise ConfigError('L2TP local auth mode requires local users to be configured!') + + for user in l2tp['local_users']: + if not user['password']: + raise ConfigError(f"Password required for user {user['name']}") + + elif l2tp['auth_mode'] == 'radius': + if len(l2tp['radius_server']) == 0: + raise ConfigError("RADIUS authentication requires at least one server") + + for radius in l2tp['radius_server']: + if not radius['key']: + raise ConfigError(f"Missing RADIUS secret for server { radius['key'] }") + + # check for the existence of a client ip pool + if not (l2tp['client_ip_pool'] or l2tp['client_ip_subnets']): + raise ConfigError( + "set vpn l2tp remote-access client-ip-pool requires subnet or start/stop IP pool") + + # check ipv6 + if l2tp['client_ipv6_delegate_prefix'] and not l2tp['client_ipv6_pool']: + raise ConfigError('IPv6 prefix delegation requires client-ipv6-pool prefix') + + for prefix in l2tp['client_ipv6_delegate_prefix']: + if not prefix['mask']: + raise ConfigError('Delegation-prefix required for individual delegated networks') + + if len(l2tp['wins']) > 2: + raise ConfigError('Not more then two IPv4 WINS name-servers can be configured') + + if len(l2tp['dnsv4']) > 2: + raise ConfigError('Not more then two IPv4 DNS name-servers can be configured') + + if len(l2tp['dnsv6']) > 3: + raise ConfigError('Not more then three IPv6 DNS name-servers can be configured') + + return None + + +def generate(l2tp): + if not l2tp: + return None + + render(l2tp_conf, 'accel-ppp/l2tp.config.tmpl', l2tp, trim_blocks=True) + + if l2tp['auth_mode'] == 'local': + render(l2tp_chap_secrets, 'accel-ppp/chap-secrets.tmpl', l2tp) + os.chmod(l2tp_chap_secrets, S_IRUSR | S_IWUSR | S_IRGRP) + + else: + if os.path.exists(l2tp_chap_secrets): + os.unlink(l2tp_chap_secrets) + + return None + + +def apply(l2tp): + if not l2tp: + call('systemctl stop accel-ppp@l2tp.service') + for file in [l2tp_chap_secrets, l2tp_conf]: + if os.path.exists(file): + os.unlink(file) + + return None + + call('systemctl restart accel-ppp@l2tp.service') + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/vpn_pptp.py b/src/conf_mode/vpn_pptp.py new file mode 100755 index 000000000..32cbadd74 --- /dev/null +++ b/src/conf_mode/vpn_pptp.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2020 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 + +from copy import deepcopy +from stat import S_IRUSR, S_IWUSR, S_IRGRP +from sys import exit + +from vyos.config import Config +from vyos.template import render +from vyos.util import call, get_half_cpus +from vyos import ConfigError + +from vyos import airbag +airbag.enable() + +pptp_conf = '/run/accel-pppd/pptp.conf' +pptp_chap_secrets = '/run/accel-pppd/pptp.chap-secrets' + +default_pptp = { + 'auth_mode' : 'local', + 'local_users' : [], + 'radius_server' : [], + 'radius_acct_tmo' : '30', + 'radius_max_try' : '3', + 'radius_timeout' : '30', + 'radius_nas_id' : '', + 'radius_nas_ip' : '', + 'radius_source_address' : '', + 'radius_shaper_attr' : '', + 'radius_shaper_vendor': '', + 'radius_dynamic_author' : '', + 'chap_secrets_file': pptp_chap_secrets, # used in Jinja2 template + 'outside_addr': '', + 'dnsv4': [], + 'wins': [], + 'client_ip_pool': '', + 'mtu': '1436', + 'auth_proto' : ['auth_mschap_v2'], + 'ppp_mppe' : 'prefer', + 'thread_cnt': get_half_cpus() +} + +def get_config(): + conf = Config() + base_path = ['vpn', 'pptp', 'remote-access'] + if not conf.exists(base_path): + return None + + pptp = deepcopy(default_pptp) + conf.set_level(base_path) + + if conf.exists(['name-server']): + pptp['dnsv4'] = conf.return_values(['name-server']) + + if conf.exists(['wins-server']): + pptp['wins'] = conf.return_values(['wins-server']) + + if conf.exists(['outside-address']): + pptp['outside_addr'] = conf.return_value(['outside-address']) + + if conf.exists(['authentication', 'mode']): + pptp['auth_mode'] = conf.return_value(['authentication', 'mode']) + + # + # local auth + if conf.exists(['authentication', 'local-users']): + for username in conf.list_nodes(['authentication', 'local-users', 'username']): + user = { + 'name': username, + 'password' : '', + 'state' : 'enabled', + 'ip' : '*', + } + + conf.set_level(base_path + ['authentication', 'local-users', 'username', username]) + + if conf.exists(['password']): + user['password'] = conf.return_value(['password']) + + if conf.exists(['disable']): + user['state'] = 'disable' + + if conf.exists(['static-ip']): + user['ip'] = conf.return_value(['static-ip']) + + if not conf.exists(['disable']): + pptp['local_users'].append(user) + + # + # RADIUS auth and settings + conf.set_level(base_path + ['authentication', 'radius']) + if conf.exists(['server']): + for server in conf.list_nodes(['server']): + radius = { + 'server' : server, + 'key' : '', + 'fail_time' : 0, + 'port' : '1812', + 'acct_port' : '1813' + } + + conf.set_level(base_path + ['authentication', 'radius', 'server', server]) + + if conf.exists(['fail-time']): + radius['fail_time'] = conf.return_value(['fail-time']) + + if conf.exists(['port']): + radius['port'] = conf.return_value(['port']) + + if conf.exists(['acct-port']): + radius['acct_port'] = conf.return_value(['acct-port']) + + if conf.exists(['key']): + radius['key'] = conf.return_value(['key']) + + if not conf.exists(['disable']): + pptp['radius_server'].append(radius) + + # + # advanced radius-setting + conf.set_level(base_path + ['authentication', 'radius']) + + if conf.exists(['acct-timeout']): + pptp['radius_acct_tmo'] = conf.return_value(['acct-timeout']) + + if conf.exists(['max-try']): + pptp['radius_max_try'] = conf.return_value(['max-try']) + + if conf.exists(['timeout']): + pptp['radius_timeout'] = conf.return_value(['timeout']) + + if conf.exists(['nas-identifier']): + pptp['radius_nas_id'] = conf.return_value(['nas-identifier']) + + if conf.exists(['nas-ip-address']): + pptp['radius_nas_ip'] = conf.return_value(['nas-ip-address']) + + if conf.exists(['source-address']): + pptp['radius_source_address'] = conf.return_value(['source-address']) + + # Dynamic Authorization Extensions (DOA)/Change Of Authentication (COA) + if conf.exists(['dae-server']): + dae = { + 'port' : '', + 'server' : '', + 'key' : '' + } + + if conf.exists(['dynamic-author', 'ip-address']): + dae['server'] = conf.return_value(['dynamic-author', 'ip-address']) + + if conf.exists(['dynamic-author', 'port']): + dae['port'] = conf.return_value(['dynamic-author', 'port']) + + if conf.exists(['dynamic-author', 'key']): + dae['key'] = conf.return_value(['dynamic-author', 'key']) + + pptp['radius_dynamic_author'] = dae + + if conf.exists(['rate-limit', 'enable']): + pptp['radius_shaper_attr'] = 'Filter-Id' + c_attr = ['rate-limit', 'enable', 'attribute'] + if conf.exists(c_attr): + pptp['radius_shaper_attr'] = conf.return_value(c_attr) + + c_vendor = ['rate-limit', 'enable', 'vendor'] + if conf.exists(c_vendor): + pptp['radius_shaper_vendor'] = conf.return_value(c_vendor) + + conf.set_level(base_path) + if conf.exists(['client-ip-pool']): + if conf.exists(['client-ip-pool', 'start']) and conf.exists(['client-ip-pool', 'stop']): + start = conf.return_value(['client-ip-pool', 'start']) + stop = conf.return_value(['client-ip-pool', 'stop']) + pptp['client_ip_pool'] = start + '-' + re.search('[0-9]+$', stop).group(0) + + if conf.exists(['mtu']): + pptp['mtu'] = conf.return_value(['mtu']) + + # gateway address + if conf.exists(['gateway-address']): + pptp['gw_ip'] = conf.return_value(['gateway-address']) + else: + # calculate gw-ip-address + if conf.exists(['client-ip-pool', 'start']): + # use start ip as gw-ip-address + pptp['gateway_address'] = conf.return_value(['client-ip-pool', 'start']) + + if conf.exists(['authentication', 'require']): + # clear default list content, now populate with actual CLI values + pptp['auth_proto'] = [] + auth_mods = { + 'pap': 'auth_pap', + 'chap': 'auth_chap_md5', + 'mschap': 'auth_mschap_v1', + 'mschap-v2': 'auth_mschap_v2' + } + + for proto in conf.return_values(['authentication', 'require']): + pptp['auth_proto'].append(auth_mods[proto]) + + if conf.exists(['authentication', 'mppe']): + pptp['ppp_mppe'] = conf.return_value(['authentication', 'mppe']) + + return pptp + + +def verify(pptp): + if not pptp: + return None + + if pptp['auth_mode'] == 'local': + if not pptp['local_users']: + raise ConfigError('PPTP local auth mode requires local users to be configured!') + + for user in pptp['local_users']: + username = user['name'] + if not user['password']: + raise ConfigError(f'Password required for local user "{username}"') + + elif pptp['auth_mode'] == 'radius': + if len(pptp['radius_server']) == 0: + raise ConfigError('RADIUS authentication requires at least one server') + + for radius in pptp['radius_server']: + if not radius['key']: + server = radius['server'] + raise ConfigError(f'Missing RADIUS secret key for server "{ server }"') + + if len(pptp['dnsv4']) > 2: + raise ConfigError('Not more then two IPv4 DNS name-servers can be configured') + + if len(pptp['wins']) > 2: + raise ConfigError('Not more then two IPv4 WINS name-servers can be configured') + + +def generate(pptp): + if not pptp: + return None + + render(pptp_conf, 'accel-ppp/pptp.config.tmpl', pptp, trim_blocks=True) + + if pptp['local_users']: + render(pptp_chap_secrets, 'accel-ppp/chap-secrets.tmpl', pptp, trim_blocks=True) + os.chmod(pptp_chap_secrets, S_IRUSR | S_IWUSR | S_IRGRP) + else: + if os.path.exists(pptp_chap_secrets): + os.unlink(pptp_chap_secrets) + + +def apply(pptp): + if not pptp: + call('systemctl stop accel-ppp@pptp.service') + for file in [pptp_conf, pptp_chap_secrets]: + if os.path.exists(file): + os.unlink(file) + + return None + + call('systemctl restart accel-ppp@pptp.service') + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/vpn_sstp.py b/src/conf_mode/vpn_sstp.py new file mode 100755 index 000000000..ddb499bf4 --- /dev/null +++ b/src/conf_mode/vpn_sstp.py @@ -0,0 +1,387 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2020 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 + +from time import sleep +from sys import exit +from copy import deepcopy +from stat import S_IRUSR, S_IWUSR, S_IRGRP + +from vyos.config import Config +from vyos.template import render +from vyos.util import call, run, get_half_cpus +from vyos.validate import is_ipv4 +from vyos import ConfigError + +from vyos import airbag +airbag.enable() + +sstp_conf = '/run/accel-pppd/sstp.conf' +sstp_chap_secrets = '/run/accel-pppd/sstp.chap-secrets' + +default_config_data = { + 'local_users' : [], + 'auth_mode' : 'local', + 'auth_proto' : ['auth_mschap_v2'], + 'chap_secrets_file': sstp_chap_secrets, # used in Jinja2 template + 'client_ip_pool' : [], + 'client_ipv6_pool': [], + 'client_ipv6_delegate_prefix': [], + 'client_gateway': '', + 'dnsv4' : [], + 'dnsv6' : [], + 'radius_server' : [], + 'radius_acct_tmo' : '3', + 'radius_max_try' : '3', + 'radius_timeout' : '3', + 'radius_nas_id' : '', + 'radius_nas_ip' : '', + 'radius_source_address' : '', + 'radius_shaper_attr' : '', + 'radius_shaper_vendor': '', + 'radius_dynamic_author' : '', + 'ssl_ca' : '', + 'ssl_cert' : '', + 'ssl_key' : '', + 'mtu' : '', + 'ppp_mppe' : 'prefer', + 'ppp_echo_failure' : '', + 'ppp_echo_interval' : '', + 'ppp_echo_timeout' : '', + 'thread_cnt' : get_half_cpus() +} + +def get_config(): + sstp = deepcopy(default_config_data) + base_path = ['vpn', 'sstp'] + conf = Config() + if not conf.exists(base_path): + return None + + conf.set_level(base_path) + + if conf.exists(['authentication', 'mode']): + sstp['auth_mode'] = conf.return_value(['authentication', 'mode']) + + # + # local auth + if conf.exists(['authentication', 'local-users']): + for username in conf.list_nodes(['authentication', 'local-users', 'username']): + user = { + 'name' : username, + 'password' : '', + 'state' : 'enabled', + 'ip' : '*', + 'upload' : None, + 'download' : None + } + + conf.set_level(base_path + ['authentication', 'local-users', 'username', username]) + + if conf.exists(['password']): + user['password'] = conf.return_value(['password']) + + if conf.exists(['disable']): + user['state'] = 'disable' + + if conf.exists(['static-ip']): + user['ip'] = conf.return_value(['static-ip']) + + if conf.exists(['rate-limit', 'download']): + user['download'] = conf.return_value(['rate-limit', 'download']) + + if conf.exists(['rate-limit', 'upload']): + user['upload'] = conf.return_value(['rate-limit', 'upload']) + + sstp['local_users'].append(user) + + # + # RADIUS auth and settings + conf.set_level(base_path + ['authentication', 'radius']) + if conf.exists(['server']): + for server in conf.list_nodes(['server']): + radius = { + 'server' : server, + 'key' : '', + 'fail_time' : 0, + 'port' : '1812', + 'acct_port' : '1813' + } + + conf.set_level(base_path + ['authentication', 'radius', 'server', server]) + + if conf.exists(['fail-time']): + radius['fail_time'] = conf.return_value(['fail-time']) + + if conf.exists(['port']): + radius['port'] = conf.return_value(['port']) + + if conf.exists(['acct-port']): + radius['acct_port'] = conf.return_value(['acct-port']) + + if conf.exists(['key']): + radius['key'] = conf.return_value(['key']) + + if not conf.exists(['disable']): + sstp['radius_server'].append(radius) + + # + # advanced radius-setting + conf.set_level(base_path + ['authentication', 'radius']) + + if conf.exists(['acct-timeout']): + sstp['radius_acct_tmo'] = conf.return_value(['acct-timeout']) + + if conf.exists(['max-try']): + sstp['radius_max_try'] = conf.return_value(['max-try']) + + if conf.exists(['timeout']): + sstp['radius_timeout'] = conf.return_value(['timeout']) + + if conf.exists(['nas-identifier']): + sstp['radius_nas_id'] = conf.return_value(['nas-identifier']) + + if conf.exists(['nas-ip-address']): + sstp['radius_nas_ip'] = conf.return_value(['nas-ip-address']) + + if conf.exists(['source-address']): + sstp['radius_source_address'] = conf.return_value(['source-address']) + + # Dynamic Authorization Extensions (DOA)/Change Of Authentication (COA) + if conf.exists(['dynamic-author']): + dae = { + 'port' : '', + 'server' : '', + 'key' : '' + } + + if conf.exists(['dynamic-author', 'server']): + dae['server'] = conf.return_value(['dynamic-author', 'server']) + + if conf.exists(['dynamic-author', 'port']): + dae['port'] = conf.return_value(['dynamic-author', 'port']) + + if conf.exists(['dynamic-author', 'key']): + dae['key'] = conf.return_value(['dynamic-author', 'key']) + + sstp['radius_dynamic_author'] = dae + + if conf.exists(['rate-limit', 'enable']): + sstp['radius_shaper_attr'] = 'Filter-Id' + c_attr = ['rate-limit', 'enable', 'attribute'] + if conf.exists(c_attr): + sstp['radius_shaper_attr'] = conf.return_value(c_attr) + + c_vendor = ['rate-limit', 'enable', 'vendor'] + if conf.exists(c_vendor): + sstp['radius_shaper_vendor'] = conf.return_value(c_vendor) + + # + # authentication protocols + conf.set_level(base_path + ['authentication']) + if conf.exists(['protocols']): + # clear default list content, now populate with actual CLI values + sstp['auth_proto'] = [] + auth_mods = { + 'pap': 'auth_pap', + 'chap': 'auth_chap_md5', + 'mschap': 'auth_mschap_v1', + 'mschap-v2': 'auth_mschap_v2' + } + + for proto in conf.return_values(['protocols']): + sstp['auth_proto'].append(auth_mods[proto]) + + # + # read in SSL certs + conf.set_level(base_path + ['ssl']) + if conf.exists(['ca-cert-file']): + sstp['ssl_ca'] = conf.return_value(['ca-cert-file']) + + if conf.exists(['cert-file']): + sstp['ssl_cert'] = conf.return_value(['cert-file']) + + if conf.exists(['key-file']): + sstp['ssl_key'] = conf.return_value(['key-file']) + + + # + # read in client IPv4 pool + conf.set_level(base_path + ['network-settings', 'client-ip-settings']) + if conf.exists(['subnet']): + sstp['client_ip_pool'] = conf.return_values(['subnet']) + + if conf.exists(['gateway-address']): + sstp['client_gateway'] = conf.return_value(['gateway-address']) + + # + # read in client IPv6 pool + conf.set_level(base_path + ['network-settings', 'client-ipv6-pool']) + if conf.exists(['prefix']): + for prefix in conf.list_nodes(['prefix']): + tmp = { + 'prefix': prefix, + 'mask': '64' + } + + if conf.exists(['prefix', prefix, 'mask']): + tmp['mask'] = conf.return_value(['prefix', prefix, 'mask']) + + sstp['client_ipv6_pool'].append(tmp) + + if conf.exists(['delegate']): + for prefix in conf.list_nodes(['delegate']): + tmp = { + 'prefix': prefix, + 'mask': '' + } + + if conf.exists(['delegate', prefix, 'delegation-prefix']): + tmp['mask'] = conf.return_value(['delegate', prefix, 'delegation-prefix']) + + sstp['client_ipv6_delegate_prefix'].append(tmp) + + # + # read in network settings + conf.set_level(base_path + ['network-settings']) + if conf.exists(['name-server']): + for name_server in conf.return_values(['name-server']): + if is_ipv4(name_server): + sstp['dnsv4'].append(name_server) + else: + sstp['dnsv6'].append(name_server) + + if conf.exists(['mtu']): + sstp['mtu'] = conf.return_value(['mtu']) + + # + # read in PPP stuff + conf.set_level(base_path + ['ppp-settings']) + if conf.exists('mppe'): + sstp['ppp_mppe'] = conf.return_value(['ppp-settings', 'mppe']) + + if conf.exists(['lcp-echo-failure']): + sstp['ppp_echo_failure'] = conf.return_value(['lcp-echo-failure']) + + if conf.exists(['lcp-echo-interval']): + sstp['ppp_echo_interval'] = conf.return_value(['lcp-echo-interval']) + + if conf.exists(['lcp-echo-timeout']): + sstp['ppp_echo_timeout'] = conf.return_value(['lcp-echo-timeout']) + + return sstp + + +def verify(sstp): + if sstp is None: + return None + + # vertify auth settings + if sstp['auth_mode'] == 'local': + if not sstp['local_users']: + raise ConfigError('SSTP local auth mode requires local users to be configured!') + + for user in sstp['local_users']: + username = user['name'] + if not user['password']: + raise ConfigError(f'Password required for local user "{username}"') + + # if up/download is set, check that both have a value + if user['upload'] and not user['download']: + raise ConfigError(f'Download speed value required for local user "{username}"') + + if user['download'] and not user['upload']: + raise ConfigError(f'Upload speed value required for local user "{username}"') + + if not sstp['client_ip_pool']: + raise ConfigError('Client IP subnet required') + + if not sstp['client_gateway']: + raise ConfigError('Client gateway IP address required') + + if len(sstp['dnsv4']) > 2: + raise ConfigError('Not more then two IPv4 DNS name-servers can be configured') + + # check ipv6 + if sstp['client_ipv6_delegate_prefix'] and not sstp['client_ipv6_pool']: + raise ConfigError('IPv6 prefix delegation requires client-ipv6-pool prefix') + + for prefix in sstp['client_ipv6_delegate_prefix']: + if not prefix['mask']: + raise ConfigError('Delegation-prefix required for individual delegated networks') + + if not sstp['ssl_ca'] or not sstp['ssl_cert'] or not sstp['ssl_key']: + raise ConfigError('One or more SSL certificates missing') + + if not os.path.exists(sstp['ssl_ca']): + file = sstp['ssl_ca'] + raise ConfigError(f'SSL CA certificate file "{file}" does not exist') + + if not os.path.exists(sstp['ssl_cert']): + file = sstp['ssl_cert'] + raise ConfigError(f'SSL public key file "{file}" does not exist') + + if not os.path.exists(sstp['ssl_key']): + file = sstp['ssl_key'] + raise ConfigError(f'SSL private key file "{file}" does not exist') + + if sstp['auth_mode'] == 'radius': + if len(sstp['radius_server']) == 0: + raise ConfigError('RADIUS authentication requires at least one server') + + for radius in sstp['radius_server']: + if not radius['key']: + server = radius['server'] + raise ConfigError(f'Missing RADIUS secret key for server "{ server }"') + +def generate(sstp): + if not sstp: + return None + + # accel-cmd reload doesn't work so any change results in a restart of the daemon + render(sstp_conf, 'accel-ppp/sstp.config.tmpl', sstp, trim_blocks=True) + + if sstp['local_users']: + render(sstp_chap_secrets, 'accel-ppp/chap-secrets.tmpl', sstp, trim_blocks=True) + os.chmod(sstp_chap_secrets, S_IRUSR | S_IWUSR | S_IRGRP) + else: + if os.path.exists(sstp_chap_secrets): + os.unlink(sstp_chap_secrets) + + return sstp + +def apply(sstp): + if not sstp: + call('systemctl stop accel-ppp@sstp.service') + for file in [sstp_chap_secrets, sstp_conf]: + if os.path.exists(file): + os.unlink(file) + + return None + + call('systemctl restart accel-ppp@sstp.service') + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/vrf.py b/src/conf_mode/vrf.py new file mode 100755 index 000000000..56ca813ff --- /dev/null +++ b/src/conf_mode/vrf.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2020 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 + +from sys import exit +from copy import deepcopy +from json import loads + +from vyos.config import Config +from vyos.configdict import list_diff +from vyos.ifconfig import Interface +from vyos.util import read_file, cmd +from vyos import ConfigError +from vyos.template import render + +from vyos import airbag +airbag.enable() + +config_file = r'/etc/iproute2/rt_tables.d/vyos-vrf.conf' + +default_config_data = { + 'bind_to_all': '0', + 'deleted': False, + 'vrf_add': [], + 'vrf_existing': [], + 'vrf_remove': [] +} + +def _cmd(command): + cmd(command, raising=ConfigError, message='Error changing VRF') + +def list_rules(): + command = 'ip -j -4 rule show' + answer = loads(cmd(command)) + return [_ for _ in answer if _] + +def vrf_interfaces(c, match): + matched = [] + old_level = c.get_level() + c.set_level(['interfaces']) + section = c.get_config_dict([], get_first_key=True) + for type in section: + interfaces = section[type] + for name in interfaces: + interface = interfaces[name] + if 'vrf' in interface: + v = interface.get('vrf', '') + if v == match: + matched.append(name) + + c.set_level(old_level) + return matched + +def vrf_routing(c, match): + matched = [] + old_level = c.get_level() + c.set_level(['protocols', 'vrf']) + if match in c.list_nodes([]): + matched.append(match) + + c.set_level(old_level) + return matched + + +def get_config(): + conf = Config() + vrf_config = deepcopy(default_config_data) + + cfg_base = ['vrf'] + if not conf.exists(cfg_base): + # get all currently effetive VRFs and mark them for deletion + vrf_config['vrf_remove'] = conf.list_effective_nodes(cfg_base + ['name']) + else: + # set configuration level base + conf.set_level(cfg_base) + + # Should services be allowed to bind to all VRFs? + if conf.exists(['bind-to-all']): + vrf_config['bind_to_all'] = '1' + + # Determine vrf interfaces (currently effective) - to determine which + # vrf interface is no longer present and needs to be removed + eff_vrf = conf.list_effective_nodes(['name']) + act_vrf = conf.list_nodes(['name']) + vrf_config['vrf_remove'] = list_diff(eff_vrf, act_vrf) + + # read in individual VRF definition and build up + # configuration + for name in conf.list_nodes(['name']): + vrf_inst = { + 'description' : '', + 'members': [], + 'name' : name, + 'table' : '', + 'table_mod': False + } + conf.set_level(cfg_base + ['name', name]) + + if conf.exists(['table']): + # VRF table can't be changed on demand, thus we need to read in the + # current and the effective routing table number + act_table = conf.return_value(['table']) + eff_table = conf.return_effective_value(['table']) + vrf_inst['table'] = act_table + if eff_table and eff_table != act_table: + vrf_inst['table_mod'] = True + + if conf.exists(['description']): + vrf_inst['description'] = conf.return_value(['description']) + + # append individual VRF configuration to global configuration list + vrf_config['vrf_add'].append(vrf_inst) + + # set configuration level base + conf.set_level(cfg_base) + + # check VRFs which need to be removed as they are not allowed to have + # interfaces attached + tmp = [] + for name in vrf_config['vrf_remove']: + vrf_inst = { + 'interfaces': [], + 'name': name, + 'routes': [] + } + + # find member interfaces of this particulat VRF + vrf_inst['interfaces'] = vrf_interfaces(conf, name) + + # find routing protocols used by this VRF + vrf_inst['routes'] = vrf_routing(conf, name) + + # append individual VRF configuration to temporary configuration list + tmp.append(vrf_inst) + + # replace values in vrf_remove with list of dictionaries + # as we need it in verify() - we can't delete a VRF with members attached + vrf_config['vrf_remove'] = tmp + return vrf_config + +def verify(vrf_config): + # ensure VRF is not assigned to any interface + for vrf in vrf_config['vrf_remove']: + if len(vrf['interfaces']) > 0: + raise ConfigError(f"VRF {vrf['name']} can not be deleted. It has active member interfaces!") + + if len(vrf['routes']) > 0: + raise ConfigError(f"VRF {vrf['name']} can not be deleted. It has active routing protocols!") + + table_ids = [] + for vrf in vrf_config['vrf_add']: + # table id is mandatory + if not vrf['table']: + raise ConfigError(f"VRF {vrf['name']} table id is mandatory!") + + # routing table id can't be changed - OS restriction + if vrf['table_mod']: + raise ConfigError(f"VRF {vrf['name']} table id modification is not possible!") + + # VRf routing table ID must be unique on the system + if vrf['table'] in table_ids: + raise ConfigError(f"VRF {vrf['name']} table id {vrf['table']} is not unique!") + + table_ids.append(vrf['table']) + + return None + +def generate(vrf_config): + render(config_file, 'vrf/vrf.conf.tmpl', vrf_config) + return None + +def apply(vrf_config): + # Documentation + # + # - https://github.com/torvalds/linux/blob/master/Documentation/networking/vrf.txt + # - https://github.com/Mellanox/mlxsw/wiki/Virtual-Routing-and-Forwarding-(VRF) + # - https://github.com/Mellanox/mlxsw/wiki/L3-Tunneling + # - https://netdevconf.info/1.1/proceedings/slides/ahern-vrf-tutorial.pdf + # - https://netdevconf.info/1.2/slides/oct6/02_ahern_what_is_l3mdev_slides.pdf + + # set the default VRF global behaviour + bind_all = vrf_config['bind_to_all'] + if read_file('/proc/sys/net/ipv4/tcp_l3mdev_accept') != bind_all: + _cmd(f'sysctl -wq net.ipv4.tcp_l3mdev_accept={bind_all}') + _cmd(f'sysctl -wq net.ipv4.udp_l3mdev_accept={bind_all}') + + for vrf in vrf_config['vrf_remove']: + name = vrf['name'] + if os.path.isdir(f'/sys/class/net/{name}'): + _cmd(f'ip -4 route del vrf {name} unreachable default metric 4278198272') + _cmd(f'ip -6 route del vrf {name} unreachable default metric 4278198272') + _cmd(f'ip link delete dev {name}') + + for vrf in vrf_config['vrf_add']: + name = vrf['name'] + table = vrf['table'] + + if not os.path.isdir(f'/sys/class/net/{name}'): + # For each VRF apart from your default context create a VRF + # interface with a separate routing table + _cmd(f'ip link add {name} type vrf table {table}') + # Start VRf + _cmd(f'ip link set dev {name} up') + # The kernel Documentation/networking/vrf.txt also recommends + # adding unreachable routes to the VRF routing tables so that routes + # afterwards are taken. + _cmd(f'ip -4 route add vrf {name} unreachable default metric 4278198272') + _cmd(f'ip -6 route add vrf {name} unreachable default metric 4278198272') + + # set VRF description for e.g. SNMP monitoring + Interface(name).set_alias(vrf['description']) + + # Linux routing uses rules to find tables - routing targets are then + # looked up in those tables. If the lookup got a matching route, the + # process ends. + # + # TL;DR; first table with a matching entry wins! + # + # You can see your routing table lookup rules using "ip rule", sadly the + # local lookup is hit before any VRF lookup. Pinging an addresses from the + # VRF will usually find a hit in the local table, and never reach the VRF + # routing table - this is usually not what you want. Thus we will + # re-arrange the tables and move the local lookup furhter down once VRFs + # are enabled. + + # get current preference on local table + local_pref = [r.get('priority') for r in list_rules() if r.get('table') == 'local'][0] + + # change preference when VRFs are enabled and local lookup table is default + if not local_pref and vrf_config['vrf_add']: + for af in ['-4', '-6']: + _cmd(f'ip {af} rule add pref 32765 table local') + _cmd(f'ip {af} rule del pref 0') + + # return to default lookup preference when no VRF is configured + if not vrf_config['vrf_add']: + for af in ['-4', '-6']: + _cmd(f'ip {af} rule add pref 0 table local') + _cmd(f'ip {af} rule del pref 32765') + + # clean out l3mdev-table rule if present + if 1000 in [r.get('priority') for r in list_rules() if r.get('priority') == 1000]: + _cmd(f'ip {af} rule del pref 1000') + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/vrrp.py b/src/conf_mode/vrrp.py new file mode 100755 index 000000000..292eb0c78 --- /dev/null +++ b/src/conf_mode/vrrp.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2020 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 + +from sys import exit +from ipaddress import ip_address, ip_interface, IPv4Interface, IPv6Interface, IPv4Address, IPv6Address +from json import dumps +from pathlib import Path + +import vyos.config + +from vyos import ConfigError +from vyos.util import call +from vyos.template import render + +from vyos.ifconfig.vrrp import VRRP + +from vyos import airbag +airbag.enable() + +def get_config(): + vrrp_groups = [] + sync_groups = [] + + config = vyos.config.Config() + + # Get the VRRP groups + for group_name in config.list_nodes("high-availability vrrp group"): + config.set_level("high-availability vrrp group {0}".format(group_name)) + + # Retrieve the values + group = {"preempt": True, "use_vmac": False, "disable": False} + + if config.exists("disable"): + group["disable"] = True + + group["name"] = group_name + group["vrid"] = config.return_value("vrid") + group["interface"] = config.return_value("interface") + group["description"] = config.return_value("description") + group["advertise_interval"] = config.return_value("advertise-interval") + group["priority"] = config.return_value("priority") + group["hello_source"] = config.return_value("hello-source-address") + group["peer_address"] = config.return_value("peer-address") + group["sync_group"] = config.return_value("sync-group") + group["preempt_delay"] = config.return_value("preempt-delay") + group["virtual_addresses"] = config.return_values("virtual-address") + + group["auth_password"] = config.return_value("authentication password") + group["auth_type"] = config.return_value("authentication type") + + group["health_check_script"] = config.return_value("health-check script") + group["health_check_interval"] = config.return_value("health-check interval") + group["health_check_count"] = config.return_value("health-check failure-count") + + group["master_script"] = config.return_value("transition-script master") + group["backup_script"] = config.return_value("transition-script backup") + group["fault_script"] = config.return_value("transition-script fault") + group["stop_script"] = config.return_value("transition-script stop") + + if config.exists("no-preempt"): + group["preempt"] = False + if config.exists("rfc3768-compatibility"): + group["use_vmac"] = True + + # Substitute defaults where applicable + if not group["advertise_interval"]: + group["advertise_interval"] = 1 + if not group["priority"]: + group["priority"] = 100 + if not group["preempt_delay"]: + group["preempt_delay"] = 0 + if not group["health_check_interval"]: + group["health_check_interval"] = 60 + if not group["health_check_count"]: + group["health_check_count"] = 3 + + # FIXUP: translate our option for auth type to keepalived's syntax + # for simplicity + if group["auth_type"]: + if group["auth_type"] == "plaintext-password": + group["auth_type"] = "PASS" + else: + group["auth_type"] = "AH" + + vrrp_groups.append(group) + + config.set_level("") + + # Get the sync group used for conntrack-sync + conntrack_sync_group = None + if config.exists("service conntrack-sync failover-mechanism vrrp"): + conntrack_sync_group = config.return_value("service conntrack-sync failover-mechanism vrrp sync-group") + + # Get the sync groups + for sync_group_name in config.list_nodes("high-availability vrrp sync-group"): + config.set_level("high-availability vrrp sync-group {0}".format(sync_group_name)) + + sync_group = {"conntrack_sync": False} + sync_group["name"] = sync_group_name + sync_group["members"] = config.return_values("member") + if conntrack_sync_group: + if conntrack_sync_group == sync_group_name: + sync_group["conntrack_sync"] = True + + # add transition script configuration + sync_group["master_script"] = config.return_value("transition-script master") + sync_group["backup_script"] = config.return_value("transition-script backup") + sync_group["fault_script"] = config.return_value("transition-script fault") + sync_group["stop_script"] = config.return_value("transition-script stop") + + sync_groups.append(sync_group) + + # create a file with dict with proposed configuration + with open("{}.temp".format(VRRP.location['vyos']), 'w') as dict_file: + dict_file.write(dumps({'vrrp_groups': vrrp_groups, 'sync_groups': sync_groups})) + + return (vrrp_groups, sync_groups) + + +def verify(data): + vrrp_groups, sync_groups = data + + for group in vrrp_groups: + # Check required fields + if not group["vrid"]: + raise ConfigError("vrid is required but not set in VRRP group {0}".format(group["name"])) + if not group["interface"]: + raise ConfigError("interface is required but not set in VRRP group {0}".format(group["name"])) + if not group["virtual_addresses"]: + raise ConfigError("virtual-address is required but not set in VRRP group {0}".format(group["name"])) + + if group["auth_password"] and (not group["auth_type"]): + raise ConfigError("authentication type is required but not set in VRRP group {0}".format(group["name"])) + + # Keepalived doesn't allow mixing IPv4 and IPv6 in one group, so we mirror that restriction + + # XXX: filter on map object is destructive, so we force it to list. + # Additionally, filter objects always evaluate to True, empty or not, + # so we force them to lists as well. + vaddrs = list(map(lambda i: ip_interface(i), group["virtual_addresses"])) + vaddrs4 = list(filter(lambda x: isinstance(x, IPv4Interface), vaddrs)) + vaddrs6 = list(filter(lambda x: isinstance(x, IPv6Interface), vaddrs)) + + if vaddrs4 and vaddrs6: + raise ConfigError("VRRP group {0} mixes IPv4 and IPv6 virtual addresses, this is not allowed. Create separate groups for IPv4 and IPv6".format(group["name"])) + + if vaddrs4: + if group["hello_source"]: + hsa = ip_address(group["hello_source"]) + if isinstance(hsa, IPv6Address): + raise ConfigError("VRRP group {0} uses IPv4 but its hello-source-address is IPv6".format(group["name"])) + if group["peer_address"]: + pa = ip_address(group["peer_address"]) + if isinstance(pa, IPv6Address): + raise ConfigError("VRRP group {0} uses IPv4 but its peer-address is IPv6".format(group["name"])) + + if vaddrs6: + if group["hello_source"]: + hsa = ip_address(group["hello_source"]) + if isinstance(hsa, IPv4Address): + raise ConfigError("VRRP group {0} uses IPv6 but its hello-source-address is IPv4".format(group["name"])) + if group["peer_address"]: + pa = ip_address(group["peer_address"]) + if isinstance(pa, IPv4Address): + raise ConfigError("VRRP group {0} uses IPv6 but its peer-address is IPv4".format(group["name"])) + + # Disallow same VRID on multiple interfaces + _groups = sorted(vrrp_groups, key=(lambda x: x["interface"])) + count = len(_groups) - 1 + index = 0 + while (index < count): + if (_groups[index]["vrid"] == _groups[index + 1]["vrid"]) and (_groups[index]["interface"] == _groups[index + 1]["interface"]): + raise ConfigError("VRID {0} is used in groups {1} and {2} that both use interface {3}. Groups on the same interface must use different VRIDs".format( + _groups[index]["vrid"], _groups[index]["name"], _groups[index + 1]["name"], _groups[index]["interface"])) + else: + index += 1 + + # Check sync groups + vrrp_group_names = list(map(lambda x: x["name"], vrrp_groups)) + + for sync_group in sync_groups: + for m in sync_group["members"]: + if not (m in vrrp_group_names): + raise ConfigError("VRRP sync-group {0} refers to VRRP group {1}, but group {1} does not exist".format(sync_group["name"], m)) + + +def generate(data): + vrrp_groups, sync_groups = data + + # Remove disabled groups from the sync group member lists + for sync_group in sync_groups: + for member in sync_group["members"]: + g = list(filter(lambda x: x["name"] == member, vrrp_groups))[0] + if g["disable"]: + print("Warning: ignoring disabled VRRP group {0} in sync-group {1}".format(g["name"], sync_group["name"])) + # Filter out disabled groups + vrrp_groups = list(filter(lambda x: x["disable"] is not True, vrrp_groups)) + + render(VRRP.location['config'], 'vrrp/keepalived.conf.tmpl', + {"groups": vrrp_groups, "sync_groups": sync_groups}) + render(VRRP.location['daemon'], 'vrrp/daemon.tmpl', {}) + return None + + +def apply(data): + vrrp_groups, sync_groups = data + if vrrp_groups: + # safely rename a temporary file with configuration dict + try: + dict_file = Path("{}.temp".format(VRRP.location['vyos'])) + dict_file.rename(Path(VRRP.location['vyos'])) + except Exception as err: + print("Unable to rename the file with keepalived config for FIFO pipe: {}".format(err)) + + if not VRRP.is_running(): + print("Starting the VRRP process") + ret = call("systemctl restart keepalived.service") + else: + print("Reloading the VRRP process") + ret = call("systemctl reload keepalived.service") + + if ret != 0: + raise ConfigError("keepalived failed to start") + else: + # VRRP is removed in the commit + print("Stopping the VRRP process") + call("systemctl stop keepalived.service") + os.unlink(VRRP.location['daemon']) + + return None + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print("VRRP error: {0}".format(str(e))) + exit(1) diff --git a/src/conf_mode/vyos_cert.py b/src/conf_mode/vyos_cert.py new file mode 100755 index 000000000..fb4644d5a --- /dev/null +++ b/src/conf_mode/vyos_cert.py @@ -0,0 +1,144 @@ +#!/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 tempfile +import pathlib +import ssl + +import vyos.defaults +from vyos.config import Config +from vyos import ConfigError +from vyos.util import cmd + +from vyos import airbag +airbag.enable() + +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: + cmd(checkend_cmd, message='Called process error') + return True + except OSError as err: + if err.errno == 1: + return False + print(err) + # XXX: This seems wrong to continue on failure + # implicitely returning None + +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: + cmd(openssl_req_cmd, message='Called process error') + except OSError as err: + print(err) + # XXX: seems wrong to ignore the failure + + 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: + command = '{0}/{1}'.format(vyos_conf_scripts_dir, dep) + cmd(command, raising=ConfigError) + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + sys.exit(1) |