# Copyright 2020 VyOS maintainers and contributors <maintainers@vyos.io> # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 2.1 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with this library. If not, see <http://www.gnu.org/licenses/>. r""" A Library for interracting with the FRR daemon suite. It supports simple configuration manipulation and loading using the official tools supplied with FRR (vtysh and frr-reload) All configuration management and manipulation is done using strings and regex. Example Usage ##### # Reading configuration from frr: ``` >>> original_config = get_configuration() >>> repr(original_config) '!\nfrr version 7.3.1\nfrr defaults traditional\nhostname debian\n...... ``` # Modify a configuration section: ``` >>> new_bgp_section = 'router bgp 65000\n neighbor 192.0.2.1 remote-as 65000\n' >>> modified_config = replace_section(original_config, new_bgp_section, replace_re=r'router bgp \d+') >>> repr(modified_config) '............router bgp 65000\n neighbor 192.0.2.1 remote-as 65000\n...........' ``` Remove a configuration section: ``` >>> modified_config = remove_section(original_config, r'router ospf') ``` Test the new configuration: ``` >>> try: >>> mark_configuration(modified configuration) >>> except ConfigurationNotValid as e: >>> print('resulting configuration is not valid') >>> sys.exit(1) ``` Apply the new configuration: ``` >>> try: >>> replace_configuration(modified_config) >>> except CommitError as e: >>> print('Exception while commiting the supplied configuration') >>> print(e) >>> exit(1) ``` """ import tempfile import re from vyos import util _frr_daemons = ['zebra', 'bgpd', 'fabricd', 'isisd', 'ospf6d', 'ospfd', 'pbrd', 'pimd', 'ripd', 'ripngd', 'sharpd', 'staticd', 'vrrpd', 'ldpd'] path_vtysh = '/usr/bin/vtysh' path_frr_reload = '/usr/lib/frr/frr-reload.py' class FrrError(Exception): pass class ConfigurationNotValid(FrrError): """ The configuratioin supplied to vtysh is not valid """ pass class CommitError(FrrError): """ Commiting the supplied configuration failed to commit by a unknown reason see commit error and/or run mark_configuration on the specified configuration to se error generated used by: reload_configuration() """ pass class ConfigSectionNotFound(FrrError): """ Removal of configuration failed because it is not existing in the supplied configuration """ pass def get_configuration(daemon=None, marked=False): """ Get current running FRR configuration daemon: Collect only configuration for the specified FRR daemon, supplying daemon=None retrieves the complete configuration marked: Mark the configuration with "end" tags return: string containing the running configuration from frr """ if daemon and daemon not in _frr_daemons: raise ValueError(f'The specified daemon type is not supported {repr(daemon)}') cmd = f"{path_vtysh} -c 'show run'" if daemon: cmd += f' -d {daemon}' output, code = util.popen(cmd, stderr=util.STDOUT) if code: raise OSError(code, output) config = output.replace('\r', '') # Remove first header lines from FRR config config = config.split("\n", 3)[-1] # Mark the configuration with end tags if marked: config = mark_configuration(config) return config def mark_configuration(config): """ Add end marks and Test the configuration for syntax faults If the configuration is valid a marked version of the configuration is returned, or else it failes with a ConfigurationNotValid Exception config: The configuration string to mark/test return: The marked configuration from FRR """ output, code = util.popen(f"{path_vtysh} -m -f -", stderr=util.STDOUT, input=config) if code == 2: raise ConfigurationNotValid(str(output)) elif code: raise OSError(code, output) config = output.replace('\r', '') return config def reload_configuration(config, daemon=None): """ Execute frr-reload with the new configuration This will try to reapply the supplied configuration inside FRR. The configuration needs to be a complete configuration from the integrated config or from a daemon. config: The configuration to apply daemon: Apply the conigutaion to the specified FRR daemon, supplying daemon=None applies to the integrated configuration return: None """ if daemon and daemon not in _frr_daemons: raise ValueError(f'The specified daemon type is not supported {repr(daemon)}') f = tempfile.NamedTemporaryFile('w') f.write(config) f.flush() cmd = f'{path_frr_reload} --reload' if daemon: cmd += f' --daemon {daemon}' cmd += f' {f.name}' output, code = util.popen(cmd, stderr=util.STDOUT) f.close() if code == 1: raise CommitError(f'Configuration FRR failed while commiting code: {repr(output)}') elif code: raise OSError(code, output) return output def execute(command): """ Run commands inside vtysh command: str containing commands to execute inside a vtysh session """ if not isinstance(command, str): raise ValueError(f'command needs to be a string: {repr(command)}') cmd = f"{path_vtysh} -c '{command}'" output, code = util.popen(cmd, stderr=util.STDOUT) if code: raise OSError(code, output) config = output.replace('\r', '') return config def configure(lines, daemon=False): """ run commands inside config mode vtysh lines: list or str conaining commands to execute inside a configure session only one command executed on each configure() Executing commands inside a subcontext uses the list to describe the context ex: ['router bgp 6500', 'neighbor 192.0.2.1 remote-as 65000'] return: None """ if isinstance(lines, str): lines = [lines] elif not isinstance(lines, list): raise ValueError('lines needs to be string or list of commands') if daemon and daemon not in _frr_daemons: raise ValueError(f'The specified daemon type is not supported {repr(daemon)}') cmd = f'{path_vtysh}' if daemon: cmd += f' -d {daemon}' cmd += " -c 'configure terminal'" for x in lines: cmd += f" -c '{x}'" output, code = util.popen(cmd, stderr=util.STDOUT) if code == 1: raise ConfigurationNotValid(f'Configuration FRR failed: {repr(output)}') elif code: raise OSError(code, output) config = output.replace('\r', '') return config def _replace_section(config, replacement, replace_re, before_re): r"""Replace a section of FRR config config: full original configuration replacement: replacement configuration section replace_re: The regex to replace example: ^router bgp \d+$.?*^!$ this will replace everything between ^router bgp X$ and ^!$ before_re: When replace_re is not existant, the config will be added before this tag example: ^line vty$ return: modified configuration as a text file """ # Check if block is configured, remove the existing instance else add a new one if re.findall(replace_re, config, flags=re.MULTILINE | re.DOTALL): # Section is in the configration, replace it return re.sub(replace_re, replacement, config, count=1, flags=re.MULTILINE | re.DOTALL) if before_re: if not re.findall(before_re, config, flags=re.MULTILINE | re.DOTALL): raise ConfigSectionNotFound(f"Config section {before_re} not found in config") # If no section is in the configuration, add it before the line vty line return re.sub(before_re, rf'{replacement}\n\g<1>', config, count=1, flags=re.MULTILINE | re.DOTALL) raise ConfigSectionNotFound(f"Config section {replacement} not found in config") def replace_section(config, replacement, from_re, to_re=r'!', before_re=r'line vty'): r"""Replace a section of FRR config config: full original configuration replacement: replacement configuration section from_re: Regex for the start of section matching example: 'router bgp \d+' to_re: Regex for stop of section matching default: '!' example: '!' or 'end' before_re: When from_re/to_re does not return a match, the config will be added before this tag default: ^line vty$ startline and endline tags will be automatically added to the resulting from_re/to_re and before_re regex'es """ return _replace_section(config, replacement, replace_re=rf'^{from_re}$.*?^{to_re}$', before_re=rf'^({before_re})$') def remove_section(config, from_re, to_re='!'): return _replace_section(config, '', replace_re=rf'^{from_re}$.*?^{to_re}$', before_re=None)