#!/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 . import os import netifaces from sys import exit from copy import deepcopy from netifaces import interfaces from vyos.config import Config from vyos.configdict import is_member 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 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(config=None): ifname = os.environ.get('VYOS_TAGNODE_VALUE','') if not ifname: raise ConfigError('Interface not specified') if config: config = config else: 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', '') 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)