diff options
| -rw-r--r-- | interface-definitions/service-config-sync.xml.in | 104 | ||||
| -rwxr-xr-x | src/conf_mode/service_config_sync.py | 111 | ||||
| -rwxr-xr-x | src/helpers/vyos_config_sync.py | 190 | 
3 files changed, 405 insertions, 0 deletions
| diff --git a/interface-definitions/service-config-sync.xml.in b/interface-definitions/service-config-sync.xml.in new file mode 100644 index 000000000..e804e17f7 --- /dev/null +++ b/interface-definitions/service-config-sync.xml.in @@ -0,0 +1,104 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interfaceDefinition> +  <node name="service"> +    <children> +      <node name="config-sync" owner="${vyos_conf_scripts_dir}/service_config_sync.py"> +        <properties> +          <help>Configuration synchronization</help> +        </properties> +        <children> +          <node name="secondary"> +            <properties> +              <help>Secondary server parameters</help> +            </properties> +            <children> +              <leafNode name="address"> +                <properties> +                  <help>IP address</help> +                  <valueHelp> +                    <format>ipv4</format> +                    <description>IPv4 address to match</description> +                  </valueHelp> +                  <valueHelp> +                    <format>ipv6</format> +                    <description>IPv6 address to match</description> +                  </valueHelp> +                  <valueHelp> +                    <format>hostname</format> +                    <description>FQDN address to match</description> +                  </valueHelp> +                  <constraint> +                    <validator name="ipv4-address"/> +                    <validator name="ipv6-address"/> +                    <validator name="fqdn"/> +                  </constraint> +                </properties> +              </leafNode> +              <leafNode name="timeout"> +                <properties> +                  <help>Connection API timeout</help> +                  <valueHelp> +                    <format>u32:1-300</format> +                    <description>Connection API timeout</description> +                  </valueHelp> +                  <constraint> +                    <validator name="numeric" argument="--range 1-300"/> +                  </constraint> +                </properties> +                <defaultValue>60</defaultValue> +              </leafNode> +              <leafNode name="key"> +                <properties> +                  <help>HTTP API key</help> +                </properties> +              </leafNode> +            </children> +          </node> +          <leafNode name="mode"> +            <properties> +              <help>Synchronization mode</help> +              <completionHelp> +                <list>load set</list> +              </completionHelp> +              <valueHelp> +                <format>load</format> +                <description>Load and replace configuration section</description> +              </valueHelp> +              <valueHelp> +                <format>set</format> +                <description>Set configuration section</description> +              </valueHelp> +              <constraint> +                <regex>(load|set)</regex> +              </constraint> +            </properties> +          </leafNode> +          <leafNode name="section"> +            <properties> +              <help>Section for synchronization</help> +              <completionHelp> +                <list>nat nat66 firewall</list> +              </completionHelp> +              <valueHelp> +                <format>nat</format> +                <description>NAT</description> +              </valueHelp> +              <valueHelp> +                <format>nat66</format> +                <description>NAT66</description> +              </valueHelp> +              <valueHelp> +                <format>firewall</format> +                <description>firewall</description> +              </valueHelp> +              <constraint> +                <regex>(nat|nat66|firewall)</regex> +              </constraint> +              <multi/> +            </properties> +          </leafNode> +        </children> +      </node> +    </children> +  </node> +</interfaceDefinition> diff --git a/src/conf_mode/service_config_sync.py b/src/conf_mode/service_config_sync.py new file mode 100755 index 000000000..5cde735a1 --- /dev/null +++ b/src/conf_mode/service_config_sync.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 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 json +from pathlib import Path + +from vyos.config import Config +from vyos.configdict import dict_merge +from vyos.xml import defaults +from vyos import ConfigError +from vyos import airbag + +airbag.enable() + + +service_conf = Path(f'/run/config_sync_conf.conf') +post_commit_dir = '/run/scripts/commit/post-hooks.d' +post_commit_file_src = '/usr/libexec/vyos/vyos_config_sync.py' +post_commit_file = f'{post_commit_dir}/vyos_config_sync' + + +def get_config(config=None): +    if config: +        conf = config +    else: +        conf = Config() + +    base = ['service', 'config-sync'] +    if not conf.exists(base): +        return None +    config = conf.get_config_dict(base, +                                  get_first_key=True, +                                  no_tag_node_value_mangle=True) + +    default_values = defaults(base) +    config = dict_merge(default_values, config) + +    return config + + +def verify(config): +    # bail out early - looks like removal from running config +    if not config: +        return None + +    if 'mode' not in config: +        raise ConfigError(f'config-sync mode is mandatory!') + +    for option in ['secondary', 'section']: +        if option not in config: +            raise ConfigError(f"config-sync '{option}' is not configured!") + +    if 'address' not in config['secondary']: +        raise ConfigError(f'secondary address is mandatory!') +    if 'key' not in config['secondary']: +        raise ConfigError(f'secondary key is mandatory!') + + +def generate(config): +    if not config: + +        if os.path.exists(post_commit_file): +            os.unlink(post_commit_file) + +        if service_conf.exists(): +            service_conf.unlink() + +        return None + +    # Write configuration file +    conf_json = json.dumps(config, indent=4) +    service_conf.write_text(conf_json) + +    # Create post commit dir +    if not os.path.isdir(post_commit_dir): +        os.makedirs(post_commit_dir) + +    # Symlink from helpers to post-commit +    if not os.path.exists(post_commit_file): +        os.symlink(post_commit_file_src, post_commit_file) + +    return None + + +def apply(config): +    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/helpers/vyos_config_sync.py b/src/helpers/vyos_config_sync.py new file mode 100755 index 000000000..6e66a6be0 --- /dev/null +++ b/src/helpers/vyos_config_sync.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 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 json +import requests +import urllib3 +import logging +from typing import Optional, List, Union, Dict, Any + +from vyos.config import Config + + +CONFIG_FILE = '/run/config_sync_conf.conf' + +# Logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) +logger.name = os.path.basename(__file__) + +# API +API_HEADERS = {'Content-Type': 'application/json'} + + +def post_request(url: str, +                 data: str, +                 headers: Dict[str, str]) -> requests.Response: +    """Sends a POST request to the specified URL + +    Args: +        url (str): The URL to send the POST request to. +        data (Dict[str, Any]): The data to send with the POST request. +        headers (Dict[str, str]): The headers to include with the POST request. + +    Returns: +        requests.Response: The response object representing the server's response to the request +    """ + +    response = requests.post(url, +                             data=data, +                             headers=headers, +                             verify=False, +                             timeout=timeout) +    return response + + +def retrieve_config(section: str = None) -> Optional[Dict[str, Any]]: +    """Retrieves the configuration from the local server. + +    Args: +        section: str: The section of the configuration to retrieve. Default is None. + +    Returns: +        Optional[Dict[str, Any]]: The retrieved configuration as a dictionary, or None if an error occurred. +    """ +    if section is None: +        section = [] +    else: +        section = section.split() + +    conf = Config() +    config = conf.get_config_dict(section, get_first_key=True) +    if config: +        return config +    return None + + +def set_remote_config( +        address: str, +        key: str, +        op: str, +        path: str = None, +        section: Optional[str] = None) -> Optional[Dict[str, Any]]: +    """Loads the VyOS configuration in JSON format to a remote host. + +    Args: +        address (str): The address of the remote host. +        key (str): The key to use for loading the configuration. +        path (Optional[str]): The path of the configuration. Default is None. +        section (Optional[str]): The section of the configuration to load. Default is None. + +    Returns: +        Optional[Dict[str, Any]]: The response from the remote host as a dictionary, or None if an error occurred. +    """ + +    if path is None: +        path = [] +    else: +        path = path.split() +    headers = {'Content-Type': 'application/json'} + +    # Disable the InsecureRequestWarning +    urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +    url = f'https://{address}/configure-section' +    data = json.dumps({ +        'op': mode, +        'path': path, +        'section': section, +        'key': key +    }) + +    try: +        config = post_request(url, data, headers) +        return config.json() +    except requests.exceptions.RequestException as e: +        print(f"An error occurred: {e}") +        logger.error(f"An error occurred: {e}") +        return None + + +def is_section_revised(section: str) -> bool: +    from vyos.config_mgmt import is_node_revised +    return is_node_revised([section]) + + +def config_sync(secondary_address: str, +                secondary_key: str, +                sections: List[str], +                mode: str): +    """Retrieve a config section from primary router in JSON format and send it to +       secondary router +    """ +    # Config sync only if sections changed +    if not any(map(is_section_revised, sections)): +        return + +    logger.info( +        f"Config synchronization: Mode={mode}, Secondary={secondary_address}" +    ) + +    # Sync sections ("nat", "firewall", etc) +    for section in sections: +        config_json = retrieve_config(section=section) +        # Check if config path deesn't exist, for example "set nat" +        # we set empty value for config_json data +        # As we cannot send to the remote host section "nat None" config +        if not config_json: +            config_json = "" +        logger.debug( +            f"Retrieved config for section '{section}': {config_json}") +        set_config = set_remote_config(address=secondary_address, +                                       key=secondary_key, +                                       op=mode, +                                       path=section, +                                       section=config_json) +        logger.debug(f"Set config for section '{section}': {set_config}") + + +if __name__ == '__main__': +    # Read configuration from file +    if not os.path.exists(CONFIG_FILE): +        logger.error(f"Post-commit: No config file '{CONFIG_FILE}' exists") +        exit(0) + +    with open(CONFIG_FILE, 'r') as f: +        config_data = f.read() + +    config = json.loads(config_data) + +    mode = config.get('mode') +    secondary_address = config.get('secondary', {}).get('address') +    secondary_key = config.get('secondary', {}).get('key') +    sections = config.get('section') +    timeout = int(config.get('secondary', {}).get('timeout')) + +    if not all([ +            mode, secondary_address, secondary_key, sections +    ]): +        logger.error( +            "Missing required configuration data for config synchronization.") +        exit(0) + +    config_sync(secondary_address, secondary_key, +                sections, mode) | 
