diff --git a/python/vyos/config.py b/python/vyos/config.py index 6fececd76..0ca41718f 100644 --- a/python/vyos/config.py +++ b/python/vyos/config.py @@ -1,568 +1,573 @@ # Copyright 2017, 2019-2023 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/>. """ A library for reading VyOS running config data. This library is used internally by all config scripts of VyOS, but its API should be considered stable and safe to use in user scripts. Note that this module will not work outside VyOS. Node taxonomy ############# There are multiple types of config tree nodes in VyOS, each requires its own set of operations. *Leaf nodes* (such as "address" in interfaces) can have values, but cannot have children. Leaf nodes can have one value, multiple values, or no values at all. For example, "system host-name" is a single-value leaf node, "system name-server" is a multi-value leaf node (commonly abbreviated "multi node"), and "system ip disable-forwarding" is a valueless leaf node. Non-leaf nodes cannot have values, but they can have child nodes. They are divided into two classes depending on whether the names of their children are fixed or not. For example, under "system", the names of all valid child nodes are predefined ("login", "name-server" etc.). To the contrary, children of the "system task-scheduler task" node can have arbitrary names. Such nodes are called *tag nodes*. This terminology is confusing but we keep using it for lack of a better word. No one remembers if the "tag" in "task Foo" is "task" or "Foo", but the distinction is irrelevant in practice. Configuration modes ################### VyOS has two distinct modes: operational mode and configuration mode. When a user logins, the CLI is in the operational mode. In this mode, only the running (effective) config is accessible for reading. When a user enters the "configure" command, a configuration session is setup. Every config session has its *proposed* (or *session*) config built on top of the current running config. When changes are commited, if commit succeeds, the proposed config is merged into the running config. In configuration mode, "base" functions like `exists`, `return_value` return values from the session config, while functions prefixed "effective" return values from the running config. In operational mode, all functions return values from the running config. """ import re import json from copy import deepcopy from typing import Union import vyos.configtree from vyos.xml_ref import multi_to_list from vyos.xml_ref import from_source from vyos.xml_ref import ext_dict_merge from vyos.xml_ref import relative_defaults from vyos.utils.dict import get_sub_dict from vyos.utils.dict import mangle_dict_keys from vyos.configsource import ConfigSource from vyos.configsource import ConfigSourceSession class ConfigDict(dict): _from_defaults = {} _dict_kwargs = {} def from_defaults(self, path: list[str]) -> bool: return from_source(self._from_defaults, path) @property def kwargs(self) -> dict: return self._dict_kwargs def config_dict_merge(src: dict, dest: Union[dict, ConfigDict]) -> ConfigDict: if not isinstance(dest, ConfigDict): dest = ConfigDict(dest) return ext_dict_merge(src, dest) class Config(object): """ The class of config access objects. Internally, in the current implementation, this object is *almost* stateless, the only state it keeps is relative *config path* for convenient access to config subtrees. """ def __init__(self, session_env=None, config_source=None): if config_source is None: self._config_source = ConfigSourceSession(session_env) else: if not isinstance(config_source, ConfigSource): raise TypeError("config_source not of type ConfigSource") self._config_source = config_source self._level = [] self._dict_cache = {} (self._running_config, self._session_config) = self._config_source.get_configtree_tuple() + def get_config_tree(self, effective=False): + if effective: + return self._running_config + return self._session_config + def _make_path(self, path): # Backwards-compatibility stuff: original implementation used string paths # libvyosconfig paths are lists, but since node names cannot contain whitespace, # splitting at whitespace is reasonably safe. # It may cause problems with exists() when it's used for checking values, # since values may contain whitespace. if isinstance(path, str): path = re.split(r'\s+', path) elif isinstance(path, list): pass else: raise TypeError("Path must be a whitespace-separated string or a list") return (self._level + path) def set_level(self, path): """ Set the *edit level*, that is, a relative config tree path. Once set, all operations will be relative to this path, for example, after ``set_level("system")``, calling ``exists("name-server")`` is equivalent to calling ``exists("system name-server"`` without ``set_level``. Args: path (str|list): relative config path """ # Make sure there's always a space between default path (level) # and path supplied as method argument # XXX: for small strings in-place concatenation is not a problem if isinstance(path, str): if path: self._level = re.split(r'\s+', path) else: self._level = [] elif isinstance(path, list): self._level = path.copy() else: raise TypeError("Level path must be either a whitespace-separated string or a list") def get_level(self): """ Gets the current edit level. Returns: str: current edit level """ return(self._level.copy()) def exists(self, path): """ Checks if a node or value with given path exists in the proposed config. Args: path (str): Configuration tree path Returns: True if node or value exists in the proposed config, False otherwise Note: This function should not be used outside of configuration sessions. In operational mode scripts, use ``exists_effective``. """ if self._session_config is None: return False # Assume the path is a node path first if self._session_config.exists(self._make_path(path)): return True else: # If that check fails, it may mean the path has a value at the end. # libvyosconfig exists() works only for _nodes_, not _values_ # libvyattacfg also worked for values, so we emulate that case here if isinstance(path, str): path = re.split(r'\s+', path) path_without_value = path[:-1] try: # return_values() is safe to use with single-value nodes, # it simply returns a single-item list in that case. values = self._session_config.return_values(self._make_path(path_without_value)) # If we got this far, the node does exist and has values, # so we need to check if it has the value in question among its values. return (path[-1] in values) except vyos.configtree.ConfigTreeError: # Even the parent node doesn't exist at all return False def session_changed(self): """ Returns: True if the config session has uncommited changes, False otherwise. """ return self._config_source.session_changed() def in_session(self): """ Returns: True if called from a configuration session, False otherwise. """ return self._config_source.in_session() def show_config(self, path=[], default=None, effective=False): """ Args: path (str list): Configuration tree path, or empty default (str): Default value to return Returns: str: working configuration """ return self._config_source.show_config(path, default, effective) def get_cached_root_dict(self, effective=False): cached = self._dict_cache.get(effective, {}) if cached: return cached if effective: config = self._running_config else: config = self._session_config if config: config_dict = json.loads(config.to_json()) else: config_dict = {} self._dict_cache[effective] = config_dict return config_dict def verify_mangling(self, key_mangling): if not (isinstance(key_mangling, tuple) and \ (len(key_mangling) == 2) and \ isinstance(key_mangling[0], str) and \ isinstance(key_mangling[1], str)): raise ValueError("key_mangling must be a tuple of two strings") def get_config_dict(self, path=[], effective=False, key_mangling=None, get_first_key=False, no_multi_convert=False, no_tag_node_value_mangle=False, with_defaults=False, with_recursive_defaults=False): """ Args: path (str list): Configuration tree path, can be empty effective=False: effective or session config key_mangling=None: mangle dict keys according to regex and replacement get_first_key=False: if k = path[:-1], return sub-dict d[k] instead of {k: d[k]} no_multi_convert=False: if convert, return single value of multi node as list Returns: a dict representation of the config under path """ kwargs = locals().copy() del kwargs['self'] del kwargs['no_multi_convert'] del kwargs['with_defaults'] del kwargs['with_recursive_defaults'] lpath = self._make_path(path) root_dict = self.get_cached_root_dict(effective) conf_dict = get_sub_dict(root_dict, lpath, get_first_key=get_first_key) rpath = lpath if get_first_key else lpath[:-1] if not no_multi_convert: conf_dict = multi_to_list(rpath, conf_dict) if key_mangling is not None: self.verify_mangling(key_mangling) conf_dict = mangle_dict_keys(conf_dict, key_mangling[0], key_mangling[1], abs_path=rpath, no_tag_node_value_mangle=no_tag_node_value_mangle) if with_defaults or with_recursive_defaults: defaults = self.get_config_defaults(**kwargs, recursive=with_recursive_defaults) conf_dict = config_dict_merge(defaults, conf_dict) else: conf_dict = ConfigDict(conf_dict) # save optional args for a call to get_config_defaults setattr(conf_dict, '_dict_kwargs', kwargs) return conf_dict def get_config_defaults(self, path=[], effective=False, key_mangling=None, no_tag_node_value_mangle=False, get_first_key=False, recursive=False) -> dict: lpath = self._make_path(path) root_dict = self.get_cached_root_dict(effective) conf_dict = get_sub_dict(root_dict, lpath, get_first_key) defaults = relative_defaults(lpath, conf_dict, get_first_key=get_first_key, recursive=recursive) rpath = lpath if get_first_key else lpath[:-1] if key_mangling is not None: self.verify_mangling(key_mangling) defaults = mangle_dict_keys(defaults, key_mangling[0], key_mangling[1], abs_path=rpath, no_tag_node_value_mangle=no_tag_node_value_mangle) return defaults def merge_defaults(self, config_dict: ConfigDict, recursive=False): if not isinstance(config_dict, ConfigDict): raise TypeError('argument is not of type ConfigDict') if not config_dict.kwargs: raise ValueError('argument missing metadata') args = config_dict.kwargs d = self.get_config_defaults(**args, recursive=recursive) config_dict = config_dict_merge(d, config_dict) return config_dict def is_multi(self, path): """ Args: path (str): Configuration tree path Returns: True if a node can have multiple values, False otherwise. Note: It also returns False if node doesn't exist. """ self._config_source.set_level(self.get_level) return self._config_source.is_multi(path) def is_tag(self, path): """ Args: path (str): Configuration tree path Returns: True if a node is a tag node, False otherwise. Note: It also returns False if node doesn't exist. """ self._config_source.set_level(self.get_level) return self._config_source.is_tag(path) def is_leaf(self, path): """ Args: path (str): Configuration tree path Returns: True if a node is a leaf node, False otherwise. Note: It also returns False if node doesn't exist. """ self._config_source.set_level(self.get_level) return self._config_source.is_leaf(path) def return_value(self, path, default=None): """ Retrieve a value of single-value leaf node in the running or proposed config Args: path (str): Configuration tree path default (str): Default value to return if node does not exist Returns: str: Node value, if it has any None: if node is valueless *or* if it doesn't exist Note: Due to the issue with treatment of valueless nodes by this function, valueless nodes should be checked with ``exists`` instead. This function cannot be used outside a configuration session. In operational mode scripts, use ``return_effective_value``. """ if self._session_config: try: value = self._session_config.return_value(self._make_path(path)) except vyos.configtree.ConfigTreeError: value = None else: value = None if not value: return(default) else: return(value) def return_values(self, path, default=[]): """ Retrieve all values of a multi-value leaf node in the running or proposed config Args: path (str): Configuration tree path Returns: str list: Node values, if it has any []: if node does not exist Note: This function cannot be used outside a configuration session. In operational mode scripts, use ``return_effective_values``. """ if self._session_config: try: values = self._session_config.return_values(self._make_path(path)) except vyos.configtree.ConfigTreeError: values = [] else: values = [] if not values: return(default.copy()) else: return(values) def list_nodes(self, path, default=[]): """ Retrieve names of all children of a tag node in the running or proposed config Args: path (str): Configuration tree path Returns: string list: child node names """ if self._session_config: try: nodes = self._session_config.list_nodes(self._make_path(path)) except vyos.configtree.ConfigTreeError: nodes = [] else: nodes = [] if not nodes: return(default.copy()) else: return(nodes) def exists_effective(self, path): """ Checks if a node or value exists in the running (effective) config. Args: path (str): Configuration tree path Returns: True if node exists in the running config, False otherwise Note: This function is safe to use in operational mode. In configuration mode, it ignores uncommited changes. """ if self._running_config is None: return False # Assume the path is a node path first if self._running_config.exists(self._make_path(path)): return True else: # If that check fails, it may mean the path has a value at the end. # libvyosconfig exists() works only for _nodes_, not _values_ # libvyattacfg also worked for values, so we emulate that case here if isinstance(path, str): path = re.split(r'\s+', path) path_without_value = path[:-1] try: # return_values() is safe to use with single-value nodes, # it simply returns a single-item list in that case. values = self._running_config.return_values(self._make_path(path_without_value)) # If we got this far, the node does exist and has values, # so we need to check if it has the value in question among its values. return (path[-1] in values) except vyos.configtree.ConfigTreeError: # Even the parent node doesn't exist at all return False def return_effective_value(self, path, default=None): """ Retrieve a values of a single-value leaf node in a running (effective) config Args: path (str): Configuration tree path default (str): Default value to return if node does not exist Returns: str: Node value """ if self._running_config: try: value = self._running_config.return_value(self._make_path(path)) except vyos.configtree.ConfigTreeError: value = None else: value = None if not value: return(default) else: return(value) def return_effective_values(self, path, default=[]): """ Retrieve all values of a multi-value node in a running (effective) config Args: path (str): Configuration tree path Returns: str list: A list of values """ if self._running_config: try: values = self._running_config.return_values(self._make_path(path)) except vyos.configtree.ConfigTreeError: values = [] else: values = [] if not values: return(default.copy()) else: return(values) def list_effective_nodes(self, path, default=[]): """ Retrieve names of all children of a tag node in the running config Args: path (str): Configuration tree path Returns: str list: child node names """ if self._running_config: try: nodes = self._running_config.list_nodes(self._make_path(path)) except vyos.configtree.ConfigTreeError: nodes = [] else: nodes = [] if not nodes: return(default.copy()) else: return(nodes) diff --git a/python/vyos/config_mgmt.py b/python/vyos/config_mgmt.py index 4ddabd6c2..0fc72e660 100644 --- a/python/vyos/config_mgmt.py +++ b/python/vyos/config_mgmt.py @@ -1,698 +1,698 @@ # Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 2.1 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with this library. If not, see <http://www.gnu.org/licenses/>. import os import re import sys import gzip import logging from typing import Optional, Tuple, Union from filecmp import cmp from datetime import datetime from textwrap import dedent from pathlib import Path from tabulate import tabulate from vyos.config import Config from vyos.configtree import ConfigTree, ConfigTreeError, show_diff from vyos.defaults import directories from vyos.version import get_full_version_data from vyos.utils.io import ask_yes_no from vyos.utils.process import is_systemd_service_active from vyos.utils.process import rc_cmd -SAVE_CONFIG = '/opt/vyatta/sbin/vyatta-save-config.pl' +SAVE_CONFIG = '/usr/libexec/vyos/vyos-save-config.py' # created by vyatta-cfg-postinst commit_post_hook_dir = '/etc/commit/post-hooks.d' commit_hooks = {'commit_revision': '01vyos-commit-revision', 'commit_archive': '02vyos-commit-archive'} DEFAULT_TIME_MINUTES = 10 timer_name = 'commit-confirm' config_file = os.path.join(directories['config'], 'config.boot') archive_dir = os.path.join(directories['config'], 'archive') archive_config_file = os.path.join(archive_dir, 'config.boot') commit_log_file = os.path.join(archive_dir, 'commits') logrotate_conf = os.path.join(archive_dir, 'lr.conf') logrotate_state = os.path.join(archive_dir, 'lr.state') rollback_config = os.path.join(archive_dir, 'config.boot-rollback') prerollback_config = os.path.join(archive_dir, 'config.boot-prerollback') tmp_log_entry = '/tmp/commit-rev-entry' logger = logging.getLogger('config_mgmt') logger.setLevel(logging.INFO) ch = logging.StreamHandler() formatter = logging.Formatter('%(funcName)s: %(levelname)s:%(message)s') ch.setFormatter(formatter) logger.addHandler(ch) def save_config(target): cmd = f'{SAVE_CONFIG} {target}' rc, out = rc_cmd(cmd) if rc != 0: logger.critical(f'save config failed: {out}') def unsaved_commits() -> bool: if get_full_version_data()['boot_via'] == 'livecd': return False tmp_save = '/tmp/config.running' save_config(tmp_save) ret = not cmp(tmp_save, config_file, shallow=False) os.unlink(tmp_save) return ret def get_file_revision(rev: int): revision = os.path.join(archive_dir, f'config.boot.{rev}.gz') try: with gzip.open(revision) as f: r = f.read().decode() except FileNotFoundError: logger.warning(f'commit revision {rev} not available') return '' return r def get_config_tree_revision(rev: int): c = get_file_revision(rev) return ConfigTree(c) def is_node_revised(path: list = [], rev1: int = 1, rev2: int = 0) -> bool: from vyos.configtree import DiffTree left = get_config_tree_revision(rev1) right = get_config_tree_revision(rev2) diff_tree = DiffTree(left, right) if diff_tree.add.exists(path) or diff_tree.sub.exists(path): return True return False class ConfigMgmtError(Exception): pass class ConfigMgmt: def __init__(self, session_env=None, config=None): if session_env: self._session_env = session_env else: self._session_env = None if config is None: config = Config() d = config.get_config_dict(['system', 'config-management'], key_mangling=('-', '_'), get_first_key=True) self.max_revisions = int(d.get('commit_revisions', 0)) self.locations = d.get('commit_archive', {}).get('location', []) self.source_address = d.get('commit_archive', {}).get('source_address', '') if config.exists(['system', 'host-name']): self.hostname = config.return_value(['system', 'host-name']) else: self.hostname = 'vyos' # upload only on existence of effective values, notably, on boot. # one still needs session self.locations (above) for setting # post-commit hook in conf_mode script path = ['system', 'config-management', 'commit-archive', 'location'] if config.exists_effective(path): self.effective_locations = config.return_effective_values(path) else: self.effective_locations = [] # a call to compare without args is edit_level aware edit_level = os.getenv('VYATTA_EDIT_LEVEL', '') self.edit_path = [l for l in edit_level.split('/') if l] self.active_config = config._running_config self.working_config = config._session_config # Console script functions # def commit_confirm(self, minutes: int=DEFAULT_TIME_MINUTES, no_prompt: bool=False) -> Tuple[str,int]: """Commit with reboot to saved config in 'minutes' minutes if 'confirm' call is not issued. """ if is_systemd_service_active(f'{timer_name}.timer'): msg = 'Another confirm is pending' return msg, 1 if unsaved_commits(): W = '\nYou should save previous commits before commit-confirm !\n' else: W = '' prompt_str = f''' commit-confirm will automatically reboot in {minutes} minutes unless changes are confirmed.\n Proceed ?''' prompt_str = W + prompt_str if not no_prompt and not ask_yes_no(prompt_str, default=True): msg = 'commit-confirm canceled' return msg, 1 action = 'sg vyattacfg "/usr/bin/config-mgmt revert"' cmd = f'sudo systemd-run --quiet --on-active={minutes}m --unit={timer_name} {action}' rc, out = rc_cmd(cmd) if rc != 0: raise ConfigMgmtError(out) # start notify cmd = f'sudo -b /usr/libexec/vyos/commit-confirm-notify.py {minutes}' os.system(cmd) msg = f'Initialized commit-confirm; {minutes} minutes to confirm before reboot' return msg, 0 def confirm(self) -> Tuple[str,int]: """Do not reboot to saved config following 'commit-confirm'. Update commit log and archive. """ if not is_systemd_service_active(f'{timer_name}.timer'): msg = 'No confirm pending' return msg, 0 cmd = f'sudo systemctl stop --quiet {timer_name}.timer' rc, out = rc_cmd(cmd) if rc != 0: raise ConfigMgmtError(out) # kill notify cmd = 'sudo pkill -f commit-confirm-notify.py' rc, out = rc_cmd(cmd) if rc != 0: raise ConfigMgmtError(out) entry = self._read_tmp_log_entry() self._add_log_entry(**entry) if self._archive_active_config(): self._update_archive() msg = 'Reboot timer stopped' return msg, 0 def revert(self) -> Tuple[str,int]: """Reboot to saved config, dropping commits from 'commit-confirm'. """ _ = self._read_tmp_log_entry() # archived config will be reverted on boot rc, out = rc_cmd('sudo systemctl reboot') if rc != 0: raise ConfigMgmtError(out) return '', 0 def rollback(self, rev: int, no_prompt: bool=False) -> Tuple[str,int]: """Reboot to config revision 'rev'. """ from shutil import copy msg = '' if not self._check_revision_number(rev): msg = f'Invalid revision number {rev}: must be 0 < rev < {maxrev}' return msg, 1 prompt_str = 'Proceed with reboot ?' if not no_prompt and not ask_yes_no(prompt_str, default=True): msg = 'Canceling rollback' return msg, 0 rc, out = rc_cmd(f'sudo cp {archive_config_file} {prerollback_config}') if rc != 0: raise ConfigMgmtError(out) path = os.path.join(archive_dir, f'config.boot.{rev}.gz') with gzip.open(path) as f: config = f.read() try: with open(rollback_config, 'wb') as f: f.write(config) copy(rollback_config, config_file) except OSError as e: raise ConfigMgmtError from e rc, out = rc_cmd('sudo systemctl reboot') if rc != 0: raise ConfigMgmtError(out) return msg, 0 def compare(self, saved: bool=False, commands: bool=False, rev1: Optional[int]=None, rev2: Optional[int]=None) -> Tuple[str,int]: """General compare function for config file revisions: revision n vs. revision m; working version vs. active version; or working version vs. saved version. """ ct1 = self.active_config ct2 = self.working_config msg = 'No changes between working and active configurations.\n' if saved: ct1 = self._get_saved_config_tree() ct2 = self.working_config msg = 'No changes between working and saved configurations.\n' if rev1 is not None: if not self._check_revision_number(rev1): return f'Invalid revision number {rev1}', 1 ct1 = self._get_config_tree_revision(rev1) ct2 = self.working_config msg = f'No changes between working and revision {rev1} configurations.\n' if rev2 is not None: if not self._check_revision_number(rev2): return f'Invalid revision number {rev2}', 1 # compare older to newer ct2 = ct1 ct1 = self._get_config_tree_revision(rev2) msg = f'No changes between revisions {rev2} and {rev1} configurations.\n' out = '' path = [] if commands else self.edit_path try: if commands: out = show_diff(ct1, ct2, path=path, commands=True) else: out = show_diff(ct1, ct2, path=path) except ConfigTreeError as e: return e, 1 if out: msg = out return msg, 0 def wrap_compare(self, options) -> Tuple[str,int]: """Interface to vyatta-cfg-run: args collected as 'options' to parse for compare. """ cmnds = False r1 = None r2 = None if 'commands' in options: cmnds=True options.remove('commands') for i in options: if not i.isnumeric(): options.remove(i) if len(options) > 0: r1 = int(options[0]) if len(options) > 1: r2 = int(options[1]) return self.compare(commands=cmnds, rev1=r1, rev2=r2) # Initialization and post-commit hooks for conf-mode # def initialize_revision(self): """Initialize config archive, logrotate conf, and commit log. """ mask = os.umask(0o002) os.makedirs(archive_dir, exist_ok=True) self._add_logrotate_conf() if (not os.path.exists(commit_log_file) or self._get_number_of_revisions() == 0): user = self._get_user() via = 'init' comment = '' self._add_log_entry(user, via, comment) # add empty init config before boot-config load for revision # and diff consistency if self._archive_active_config(): self._update_archive() os.umask(mask) def commit_revision(self): """Update commit log and rotate archived config.boot. commit_revision is called in post-commit-hooks, if ['commit-archive', 'commit-revisions'] is configured. """ if os.getenv('IN_COMMIT_CONFIRM', ''): self._new_log_entry(tmp_file=tmp_log_entry) return self._add_log_entry() if self._archive_active_config(): self._update_archive() def commit_archive(self): """Upload config to remote archive. """ from vyos.remote import upload hostname = self.hostname t = datetime.now() timestamp = t.strftime('%Y%m%d_%H%M%S') remote_file = f'config.boot-{hostname}.{timestamp}' source_address = self.source_address for location in self.effective_locations: upload(archive_config_file, f'{location}/{remote_file}', source_host=source_address) # op-mode functions # def get_raw_log_data(self) -> list: """Return list of dicts of log data: keys: [timestamp, user, commit_via, commit_comment] """ log = self._get_log_entries() res_l = [] for line in log: d = self._get_log_entry(line) res_l.append(d) return res_l @staticmethod def format_log_data(data: list) -> str: """Return formatted log data as str. """ res_l = [] for l_no, l in enumerate(data): time_d = datetime.fromtimestamp(int(l['timestamp'])) time_str = time_d.strftime("%Y-%m-%d %H:%M:%S") res_l.append([l_no, time_str, f"by {l['user']}", f"via {l['commit_via']}"]) if l['commit_comment'] != 'commit': # default comment res_l.append([None, l['commit_comment']]) ret = tabulate(res_l, tablefmt="plain") return ret @staticmethod def format_log_data_brief(data: list) -> str: """Return 'brief' form of log data as str. Slightly compacted format used in completion help for 'rollback'. """ res_l = [] for l_no, l in enumerate(data): time_d = datetime.fromtimestamp(int(l['timestamp'])) time_str = time_d.strftime("%Y-%m-%d %H:%M:%S") res_l.append(['\t', l_no, time_str, f"{l['user']}", f"by {l['commit_via']}"]) ret = tabulate(res_l, tablefmt="plain") return ret def show_commit_diff(self, rev: int, rev2: Optional[int]=None, commands: bool=False) -> str: """Show commit diff at revision number, compared to previous revision, or to another revision. """ if rev2 is None: out, _ = self.compare(commands=commands, rev1=rev, rev2=(rev+1)) return out out, _ = self.compare(commands=commands, rev1=rev, rev2=rev2) return out def show_commit_file(self, rev: int) -> str: return self._get_file_revision(rev) # utility functions # @staticmethod def _strip_version(s): return re.split(r'(^//)', s, maxsplit=1, flags=re.MULTILINE)[0] def _get_saved_config_tree(self): with open(config_file) as f: c = self._strip_version(f.read()) return ConfigTree(c) def _get_file_revision(self, rev: int): if rev not in range(0, self._get_number_of_revisions()): raise ConfigMgmtError('revision not available') revision = os.path.join(archive_dir, f'config.boot.{rev}.gz') with gzip.open(revision) as f: r = f.read().decode() return r def _get_config_tree_revision(self, rev: int): c = self._strip_version(self._get_file_revision(rev)) return ConfigTree(c) def _add_logrotate_conf(self): conf: str = dedent(f"""\ {archive_config_file} {{ su root vyattacfg rotate {self.max_revisions} start 0 compress copy }} """) conf_file = Path(logrotate_conf) conf_file.write_text(conf) conf_file.chmod(0o644) def _archive_active_config(self) -> bool: mask = os.umask(0o113) ext = os.getpid() tmp_save = f'/tmp/config.boot.{ext}' save_config(tmp_save) try: if cmp(tmp_save, archive_config_file, shallow=False): # this will be the case on boot, as well as certain # re-initialiation instances after delete/set os.unlink(tmp_save) return False except FileNotFoundError: pass rc, out = rc_cmd(f'sudo mv {tmp_save} {archive_config_file}') os.umask(mask) if rc != 0: logger.critical(f'mv file to archive failed: {out}') return False return True @staticmethod def _update_archive(): cmd = f"sudo logrotate -f -s {logrotate_state} {logrotate_conf}" rc, out = rc_cmd(cmd) if rc != 0: logger.critical(f'logrotate failure: {out}') @staticmethod def _get_log_entries() -> list: """Return lines of commit log as list of strings """ entries = [] if os.path.exists(commit_log_file): with open(commit_log_file) as f: entries = f.readlines() return entries def _get_number_of_revisions(self) -> int: l = self._get_log_entries() return len(l) def _check_revision_number(self, rev: int) -> bool: # exclude init revision: maxrev = self._get_number_of_revisions() if not 0 <= rev < maxrev - 1: return False return True @staticmethod def _get_user() -> str: import pwd try: user = os.getlogin() except OSError: try: user = pwd.getpwuid(os.geteuid())[0] except KeyError: user = 'unknown' return user def _new_log_entry(self, user: str='', commit_via: str='', commit_comment: str='', timestamp: Optional[int]=None, tmp_file: str=None) -> Optional[str]: # Format log entry and return str or write to file. # # Usage is within a post-commit hook, using env values. In case of # commit-confirm, it can be written to a temporary file for # inclusion on 'confirm'. from time import time if timestamp is None: timestamp = int(time()) if not user: user = self._get_user() if not commit_via: commit_via = os.getenv('COMMIT_VIA', 'other') if not commit_comment: commit_comment = os.getenv('COMMIT_COMMENT', 'commit') # the commit log reserves '|' as field demarcation, so replace in # comment if present; undo this in _get_log_entry, below if re.search(r'\|', commit_comment): commit_comment = commit_comment.replace('|', '%%') entry = f'|{timestamp}|{user}|{commit_via}|{commit_comment}|\n' mask = os.umask(0o113) if tmp_file is not None: try: with open(tmp_file, 'w') as f: f.write(entry) except OSError as e: logger.critical(f'write to {tmp_file} failed: {e}') os.umask(mask) return None os.umask(mask) return entry @staticmethod def _get_log_entry(line: str) -> dict: log_fmt = re.compile(r'\|.*\|\n?$') keys = ['user', 'commit_via', 'commit_comment', 'timestamp'] if not log_fmt.match(line): logger.critical(f'Invalid log format {line}') return {} timestamp, user, commit_via, commit_comment = ( tuple(line.strip().strip('|').split('|'))) commit_comment = commit_comment.replace('%%', '|') d = dict(zip(keys, [user, commit_via, commit_comment, timestamp])) return d def _read_tmp_log_entry(self) -> dict: try: with open(tmp_log_entry) as f: entry = f.read() os.unlink(tmp_log_entry) except OSError as e: logger.critical(f'error on file {tmp_log_entry}: {e}') return self._get_log_entry(entry) def _add_log_entry(self, user: str='', commit_via: str='', commit_comment: str='', timestamp: Optional[int]=None): mask = os.umask(0o113) entry = self._new_log_entry(user=user, commit_via=commit_via, commit_comment=commit_comment, timestamp=timestamp) log_entries = self._get_log_entries() log_entries.insert(0, entry) if len(log_entries) > self.max_revisions: log_entries = log_entries[:-1] try: with open(commit_log_file, 'w') as f: f.writelines(log_entries) except OSError as e: logger.critical(e) os.umask(mask) # entry_point for console script # def run(): from argparse import ArgumentParser, REMAINDER config_mgmt = ConfigMgmt() for s in list(commit_hooks): if sys.argv[0].replace('-', '_').endswith(s): func = getattr(config_mgmt, s) try: func() except Exception as e: print(f'{s}: {e}') sys.exit(0) parser = ArgumentParser() subparsers = parser.add_subparsers(dest='subcommand') commit_confirm = subparsers.add_parser('commit_confirm', help="Commit with opt-out reboot to saved config") commit_confirm.add_argument('-t', dest='minutes', type=int, default=DEFAULT_TIME_MINUTES, help="Minutes until reboot, unless 'confirm'") commit_confirm.add_argument('-y', dest='no_prompt', action='store_true', help="Execute without prompt") subparsers.add_parser('confirm', help="Confirm commit") subparsers.add_parser('revert', help="Revert commit-confirm") rollback = subparsers.add_parser('rollback', help="Rollback to earlier config") rollback.add_argument('--rev', type=int, help="Revision number for rollback") rollback.add_argument('-y', dest='no_prompt', action='store_true', help="Excute without prompt") compare = subparsers.add_parser('compare', help="Compare config files") compare.add_argument('--saved', action='store_true', help="Compare session config with saved config") compare.add_argument('--commands', action='store_true', help="Show difference between commands") compare.add_argument('--rev1', type=int, default=None, help="Compare revision with session config or other revision") compare.add_argument('--rev2', type=int, default=None, help="Compare revisions") wrap_compare = subparsers.add_parser('wrap_compare', help="Wrapper interface for vyatta-cfg-run") wrap_compare.add_argument('--options', nargs=REMAINDER) args = vars(parser.parse_args()) func = getattr(config_mgmt, args['subcommand']) del args['subcommand'] res = '' try: res, rc = func(**args) except ConfigMgmtError as e: print(e) sys.exit(1) if res: print(res) sys.exit(rc) diff --git a/python/vyos/configsession.py b/python/vyos/configsession.py index e8918d577..6d4b2af59 100644 --- a/python/vyos/configsession.py +++ b/python/vyos/configsession.py @@ -1,237 +1,237 @@ # configsession -- the write API for the VyOS running config # Copyright (C) 2019-2023 VyOS maintainers and contributors # # 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, write to the Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA import os import re import sys import subprocess from vyos.utils.process import is_systemd_service_running from vyos.utils.dict import dict_to_paths CLI_SHELL_API = '/bin/cli-shell-api' SET = '/opt/vyatta/sbin/my_set' DELETE = '/opt/vyatta/sbin/my_delete' COMMENT = '/opt/vyatta/sbin/my_comment' COMMIT = '/opt/vyatta/sbin/my_commit' DISCARD = '/opt/vyatta/sbin/my_discard' SHOW_CONFIG = ['/bin/cli-shell-api', 'showConfig'] LOAD_CONFIG = ['/bin/cli-shell-api', 'loadFile'] MIGRATE_LOAD_CONFIG = ['/usr/libexec/vyos/vyos-load-config.py'] -SAVE_CONFIG = ['/opt/vyatta/sbin/vyatta-save-config.pl'] +SAVE_CONFIG = ['/usr/libexec/vyos/vyos-save-config.py'] INSTALL_IMAGE = ['/opt/vyatta/sbin/install-image', '--url'] REMOVE_IMAGE = ['/opt/vyatta/bin/vyatta-boot-image.pl', '--del'] GENERATE = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'generate'] SHOW = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'show'] RESET = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'reset'] OP_CMD_ADD = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'add'] OP_CMD_DELETE = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'delete'] # Default "commit via" string APP = "vyos-http-api" # When started as a service rather than from a user shell, # the process lacks the VyOS-specific environment that comes # from bash configs, so we have to inject it # XXX: maybe it's better to do via a systemd environment file def inject_vyos_env(env): env['VYATTA_CFG_GROUP_NAME'] = 'vyattacfg' env['VYATTA_USER_LEVEL_DIR'] = '/opt/vyatta/etc/shell/level/admin' env['VYATTA_PROCESS_CLIENT'] = 'gui2_rest' env['VYOS_HEADLESS_CLIENT'] = 'vyos_http_api' env['vyatta_bindir']= '/opt/vyatta/bin' env['vyatta_cfg_templates'] = '/opt/vyatta/share/vyatta-cfg/templates' env['vyatta_configdir'] = '/opt/vyatta/config' env['vyatta_datadir'] = '/opt/vyatta/share' env['vyatta_datarootdir'] = '/opt/vyatta/share' env['vyatta_libdir'] = '/opt/vyatta/lib' env['vyatta_libexecdir'] = '/opt/vyatta/libexec' env['vyatta_op_templates'] = '/opt/vyatta/share/vyatta-op/templates' env['vyatta_prefix'] = '/opt/vyatta' env['vyatta_sbindir'] = '/opt/vyatta/sbin' env['vyatta_sysconfdir'] = '/opt/vyatta/etc' env['vyos_bin_dir'] = '/usr/bin' env['vyos_cfg_templates'] = '/opt/vyatta/share/vyatta-cfg/templates' env['vyos_completion_dir'] = '/usr/libexec/vyos/completion' env['vyos_configdir'] = '/opt/vyatta/config' env['vyos_conf_scripts_dir'] = '/usr/libexec/vyos/conf_mode' env['vyos_datadir'] = '/opt/vyatta/share' env['vyos_datarootdir']= '/opt/vyatta/share' env['vyos_libdir'] = '/opt/vyatta/lib' env['vyos_libexec_dir'] = '/usr/libexec/vyos' env['vyos_op_scripts_dir'] = '/usr/libexec/vyos/op_mode' env['vyos_op_templates'] = '/opt/vyatta/share/vyatta-op/templates' env['vyos_prefix'] = '/opt/vyatta' env['vyos_sbin_dir'] = '/usr/sbin' env['vyos_validators_dir'] = '/usr/libexec/vyos/validators' # if running the vyos-configd daemon, inject the vyshim env var if is_systemd_service_running('vyos-configd.service'): env['vyshim'] = '/usr/sbin/vyshim' return env class ConfigSessionError(Exception): pass class ConfigSession(object): """ The write API of VyOS. """ def __init__(self, session_id, app=APP): """ Creates a new config session. Args: session_id (str): Session identifier app (str): Application name, purely informational Note: The session identifier MUST be globally unique within the system. The best practice is to only have one ConfigSession object per process and used the PID for the session identifier. """ env_str = subprocess.check_output([CLI_SHELL_API, 'getSessionEnv', str(session_id)]) self.__session_id = session_id # Extract actual variables from the chunk of shell it outputs # XXX: it's better to extend cli-shell-api to provide easily readable output env_list = re.findall(r'([A-Z_]+)=([^;\s]+)', env_str.decode()) session_env = os.environ session_env = inject_vyos_env(session_env) for k, v in env_list: session_env[k] = v self.__session_env = session_env self.__session_env["COMMIT_VIA"] = app self.__run_command([CLI_SHELL_API, 'setupSession']) def __del__(self): try: output = subprocess.check_output([CLI_SHELL_API, 'teardownSession'], env=self.__session_env).decode().strip() if output: print("cli-shell-api teardownSession output for sesion {0}: {1}".format(self.__session_id, output), file=sys.stderr) except Exception as e: print("Could not tear down session {0}: {1}".format(self.__session_id, e), file=sys.stderr) def __run_command(self, cmd_list): p = subprocess.Popen(cmd_list, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=self.__session_env) (stdout_data, stderr_data) = p.communicate() output = stdout_data.decode() result = p.wait() if result != 0: raise ConfigSessionError(output) return output def get_session_env(self): return self.__session_env def set(self, path, value=None): if not value: value = [] else: value = [value] self.__run_command([SET] + path + value) def set_section(self, path: list, d: dict): try: for p in dict_to_paths(d): self.set(path + p) except (ValueError, ConfigSessionError) as e: raise ConfigSessionError(e) def delete(self, path, value=None): if not value: value = [] else: value = [value] self.__run_command([DELETE] + path + value) def load_section(self, path: list, d: dict): try: self.delete(path) if d: for p in dict_to_paths(d): self.set(path + p) except (ValueError, ConfigSessionError) as e: raise ConfigSessionError(e) def comment(self, path, value=None): if not value: value = [""] else: value = [value] self.__run_command([COMMENT] + path + value) def commit(self): out = self.__run_command([COMMIT]) return out def discard(self): self.__run_command([DISCARD]) def show_config(self, path, format='raw'): config_data = self.__run_command(SHOW_CONFIG + path) if format == 'raw': return config_data def load_config(self, file_path): out = self.__run_command(LOAD_CONFIG + [file_path]) return out def migrate_and_load_config(self, file_path): out = self.__run_command(MIGRATE_LOAD_CONFIG + [file_path]) return out def save_config(self, file_path): out = self.__run_command(SAVE_CONFIG + [file_path]) return out def install_image(self, url): out = self.__run_command(INSTALL_IMAGE + [url]) return out def remove_image(self, name): out = self.__run_command(REMOVE_IMAGE + [name]) return out def generate(self, path): out = self.__run_command(GENERATE + path) return out def show(self, path): out = self.__run_command(SHOW + path) return out def reset(self, path): out = self.__run_command(RESET + path) return out def add_container_image(self, name): out = self.__run_command(OP_CMD_ADD + ['container', 'image'] + [name]) return out def delete_container_image(self, name): out = self.__run_command(OP_CMD_DELETE + ['container', 'image'] + [name]) return out def show_container_image(self): out = self.__run_command(SHOW + ['container', 'image']) return out diff --git a/src/helpers/vyos-save-config.py b/src/helpers/vyos-save-config.py new file mode 100755 index 000000000..2812155e8 --- /dev/null +++ b/src/helpers/vyos-save-config.py @@ -0,0 +1,55 @@ +#!/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 re +import sys +from tempfile import NamedTemporaryFile + +from vyos.config import Config +from vyos.remote import urlc +from vyos.component_version import system_footer +from vyos.defaults import directories + +DEFAULT_CONFIG_PATH = os.path.join(directories['config'], 'config.boot') +remote_save = None + +if len(sys.argv) > 1: + save_file = sys.argv[1] +else: + save_file = DEFAULT_CONFIG_PATH + +if re.match(r'\w+:/', save_file): + try: + remote_save = urlc(save_file) + except ValueError as e: + sys.exit(e) + +config = Config() +ct = config.get_config_tree(effective=True) + +write_file = save_file if remote_save is None else NamedTemporaryFile(delete=False).name +with open(write_file, 'w') as f: + f.write(ct.to_string()) + f.write("\n") + f.write(system_footer()) + +if remote_save is not None: + try: + remote_save.upload(write_file) + finally: + os.remove(write_file)