diff options
author | A. Wilcox <AWilcox@Wilcox-Tech.com> | 2020-09-16 15:34:05 -0500 |
---|---|---|
committer | A. Wilcox <AWilcox@Wilcox-Tech.com> | 2020-09-16 15:34:05 -0500 |
commit | 76a0843c6b89a21faed426fb373b2ff1afd83e0f (patch) | |
tree | a9351471f82832a7d27e2b6eaf902e11a2736196 | |
parent | 7915d8de1c5652fe0af2e824cd15a424dd84c014 (diff) | |
download | netconfapk-76a0843c6b89a21faed426fb373b2ff1afd83e0f.tar.gz netconfapk-76a0843c6b89a21faed426fb373b2ff1afd83e0f.tar.bz2 netconfapk-76a0843c6b89a21faed426fb373b2ff1afd83e0f.tar.xz netconfapk-76a0843c6b89a21faed426fb373b2ff1afd83e0f.zip |
ietf-system: Add ability to edit DNS and contact/location
-rw-r--r-- | ncserver/module/system.py | 312 |
1 files changed, 304 insertions, 8 deletions
diff --git a/ncserver/module/system.py b/ncserver/module/system.py index 5962327..8b940c5 100644 --- a/ncserver/module/system.py +++ b/ncserver/module/system.py @@ -12,17 +12,23 @@ SPDX-License-Identifier: NCSA import logging import os.path +import pathlib import platform import subprocess import time from datetime import datetime, timezone from math import floor -from socket import gethostname +from socket import gethostname, sethostname -from netconf import util +from lxml import etree +from netconf import error, util -from ncserver.base.util import _ +from ncserver.base.util import _, ensure_leaf, handle_list_operation, \ + node_operation + + +QName = etree.QName # pylint: disable=I1101 LOGGER = logging.getLogger(__name__) @@ -70,6 +76,14 @@ M_FEATURES = ['ntp'] """The supported features declared in YANG for this module.""" +_CONTACT_PATH = '/etc/netconf/contact.txt' +"""The file path for the contents of /sys:system/contact.""" + + +_LOCATION_PATH = '/etc/netconf/location.txt' +"""The file path for the contents of /sys:system/location.""" + + def _parse_ntp_conf_to(sys_node): """Parse NTP configuration into /sys:system/ntp format.""" root = util.subelm(sys_node, 'sys:ntp') @@ -105,7 +119,9 @@ def _parse_resolv_conf() -> dict: search = list() # musl defaults; see musl:include/resolv.h attempts = 2 + attempts_default = True timeout = 5 + timeout_default = True for line in rconf: line = line.rstrip() @@ -121,12 +137,14 @@ def _parse_resolv_conf() -> dict: try: # musl caps attempts at 10 attempts = min(10, int(line[17:])) + attempts_default = False except ValueError: LOGGER.warning(_("invalid resolv.conf: non-integral attempts")) elif line.startswith('options timeout:'): try: # musl caps timeout at 60 timeout = min(60, int(line[16:])) + timeout_default = False except ValueError: LOGGER.warning(_("invalid resolv.conf: non-integral timeout")) # This logic is taken directly from musl source code. @@ -134,7 +152,8 @@ def _parse_resolv_conf() -> dict: search = line[7:].split(' ') return {'resolvers': resolvers, 'search': search, 'attempts': attempts, - 'timeout': timeout} + 'timeout': timeout, 'attempts_default': attempts_default, + 'timeout_default': timeout_default} def _parse_resolv_conf_to(sys_node): @@ -166,14 +185,14 @@ def running(node): """Retrieve the running configuration for this system.""" sys = util.subelm(node, 'sys:system') - if os.path.exists('/etc/netconf/contact…txt'): - with open('/etc/netconf/contact.txt', 'r') as contact_f: + if os.path.exists(_CONTACT_PATH): + with open(_CONTACT_PATH, 'r') as contact_f: sys.append(util.leaf_elm('sys:contact', contact_f.read())) else: sys.append(util.leaf_elm('sys:contact', '')) - if os.path.exists('/etc/netconf/location.txt'): - with open('/etc/netconf/location.txt', 'r') as loc_f: + if os.path.exists(_LOCATION_PATH): + with open(_LOCATION_PATH, 'r') as loc_f: sys.append(util.leaf_elm('sys:location', loc_f.read())) else: sys.append(util.leaf_elm('sys:location', '')) @@ -213,3 +232,280 @@ def operational(node): boot = floor(time.time() - float(raw)) fmted = datetime.fromtimestamp(boot, tz=zone).isoformat() clock.append(util.leaf_elm('sys:boot-datetime', fmted)) + + +# -- Editing functions -- + + +def _save_resolv_conf(conf: dict): + """Save the current configuration to /etc/resolv.conf.""" + rcfile = open('/etc/resolv.conf', 'w') + rcfile.write("# Generated by NETCONFAPK\n") + rcfile.write("# This file will be overwritten upon configuration. " + "Do not edit directly.\n\n") + + if 'attempts' in conf and not conf.get('attempts_default', False): + rcfile.write("options attempts:{}\n".format(conf['attempts'])) + + if 'timeout' in conf and not conf.get('timeout_default', False): + rcfile.write("options timeout:{}\n".format(conf['timeout'])) + + if len(conf.get('search', list())) > 0: + rcfile.write("search {}\n".format(" ".join(conf['search']))) + + for pair in conf.get('resolvers', dict()).items(): + rcfile.write("# NAME: {}\nnameserver {}\n".format(pair[0], pair[1])) + + rcfile.close() + + +def _edit_dns_option(rpc, node, def_op, curr): + """Edit DNS option configuration.""" + root_op = node_operation(node, def_op) + + if root_op in ['replace', 'delete', 'remove']: + # delete must raise if no options are set. + if root_op == 'delete': + if ('attempts' not in curr or curr.get('attempts_default', False))\ + or ('timeout' not in curr or curr.get('timeout_default', False)): + raise error.DataMissingAppError(rpc) + + curr.pop('attempts', None) + curr.pop('timeout', None) + elif root_op == 'create': + if 'attempts' in curr and not curr.get('attempts_default', False) \ + or 'timeout' in curr and not curr.get('timeout_default', False): + raise error.DataExistsAppError(rpc) + + for opt in node: + option = QName(opt.tag).localname + operation = node_operation(opt, root_op) + ensure_leaf(rpc, opt) + + if option == 'attempts': + limit = 10 + elif option == 'timeout': + limit = 60 + else: + raise error.UnknownElementAppError(rpc, opt) + + if operation in ['delete', 'remove']: + if option in curr: + del curr[option] + continue + + try: + value = int(opt.text) + except ValueError as value: + raise error.InvalidValueAppError(rpc) from value + + curr[option] = min(limit, value) + curr['{}_default'.format(option)] = False + + _save_resolv_conf(curr) + + +def _edit_dns_search(rpc, node, def_op, curr: dict): + """Edit DNS search-domain configuration.""" + operation = node_operation(node, def_op) + + handle_list_operation(rpc, curr['search'], node, operation) + _save_resolv_conf(curr) + + +def _edit_dns_server(rpc, node, def_op, curr: dict): + """Edit DNS nameserver configuration.""" + operation = node_operation(node, def_op) + xmlmap = {'': 'urn:ietf:params:xml:ns:yang:ietf-system'} + name = node.find('name', xmlmap) + + # This lets us handle cases where there are no resolvers yet. + # We set the dict key to the value in case we used the default. + resolvers = curr.get('resolvers', dict()) + curr['resolvers'] = resolvers + + if name is None: + raise error.MissingElementAppError( + rpc, '{urn:ietf:params:xml:ns:yang:ietf-system}name' + ) + name = name.text + + if operation in ['delete', 'remove']: + if operation == 'delete' and name not in resolvers.keys(): + raise error.DataMissingAppError(rpc) + + resolvers.pop(name, None) + _save_resolv_conf(curr) + return + + insert = node.attrib.get('{urn:ietf:params:xml:ns:yang:1}insert', None) + other_key = node.attrib.get('{urn:ietf:params:xml:ns:yang:1}key', None) + value = None + + for opt in node: + opt_name = QName(opt.tag).localname + if opt_name == 'name': + continue # already handled + elif opt_name == 'udp-and-tcp': + for subopt in opt: + if QName(subopt.tag).localname != 'address': + raise error.UnknownElementAppError(rpc, subopt) + value = subopt.text + else: + raise error.UnknownElementAppError(rpc, opt) + + if operation == 'create': + if name in resolvers: + raise error.DataExistsAppError(rpc) + + if len(resolvers) >= 3: + # there is no guidance in the RFC for what to do with this... + raise error.BadElementAppError(rpc, node) + + if insert is None: + insert = 'last' + elif operation in ['merge', 'replace']: + if value is None: + raise error.MissingElementAppError( + rpc, '{urn:ietf:params:xml:ns:yang:ietf-system}udp-and-tcp' + ) + + if name not in resolvers and len(resolvers) >= 3: + # ditto from above + raise error.BadElementAppError(rpc, node) + + # Okay, we have to juggle everything around. + if insert is None or insert == 'last': + resolvers[name] = value + elif insert == 'first': + # update always adds keys to the end of a dict. + curr['resolvers'] = {name: value} + curr['resolvers'].update(resolvers) + elif insert == 'before' or insert == 'after': + if other_key is None: + raise error.MissingAttributeAppError( + rpc, node, '{urn:ietf:params:xml:ns:yang:1}key' + ) + + if other_key not in resolvers: + raise error.DataMissingAppError(rpc) + + work = dict() + for r_name, r_ip in resolvers.items(): + if r_name == other_key and insert == 'before': + work[name] = value + work[r_name] = r_ip + if r_name == other_key and insert == 'after': + work[name] = value + + curr['resolvers'] = work + else: + raise error.BadAttributeAppError( + rpc, node, '{urn:ietf:params:xml:ns:yang:1}insert' + ) + + _save_resolv_conf(curr) + + +def _is_resolv_conf_empty(curr: dict) -> bool: + """Determine if a DNS configuration is empty/default.""" + return ( + len(curr.get('resolvers', dict())) == 0 and + len(curr.get('search', list())) == 0 and + ('attempts' not in curr or curr.get('attempts_default', True)) and + ('timeout' not in curr or curr.get('timeout_default', True)) + ) + + +def _edit_dns(rpc, node, def_op): + """Edit the DNS configuration for this system.""" + curr = _parse_resolv_conf() + + root_op = node_operation(node, def_op) + if root_op == 'replace': + curr = {} + elif root_op == 'remove': + curr = {} + _save_resolv_conf(curr) + elif root_op == 'delete': + if _is_resolv_conf_empty(curr): + raise error.DataMissingAppError(rpc) + curr = {} + _save_resolv_conf(curr) + elif root_op == 'create': + if not _is_resolv_conf_empty(curr): + raise error.DataExistsAppError(rpc) + + editors = {'options': _edit_dns_option, 'search': _edit_dns_search, + 'server': _edit_dns_server} + + for key_node in node: + key = QName(key_node.tag).localname + if key not in editors: + raise error.UnknownElementAppError(rpc, key) + + editors[key](rpc, key_node, root_op, curr) + + +def _edit_file(rpc, node, def_op): + """Edit a file-based configuration for this system.""" + operation = node_operation(node, def_op) + ensure_leaf(rpc, node) + + paths = {'contact': _CONTACT_PATH, 'location': _LOCATION_PATH} + selected = paths[QName(node.tag).localname] + already = os.path.exists(selected) + + if operation == 'create' and already: + raise error.DataExistsAppError(rpc) + + if operation == 'delete' and not already: + raise error.DataMissingAppError(rpc) + + if operation in ['delete', 'remove']: + if already: + pathlib.Path(selected).unlink() + return + + with open(selected, 'wt') as myfile: + myfile.write(node.text) + + +def _edit_hostname(rpc, node, def_op): + """Edit the hostname for this system.""" + operation = node_operation(node, def_op) + ensure_leaf(rpc, node) + + # you can't *unset* a hostname. + if operation in ['delete', 'remove']: + raise error.OperationNotSupportedAppError(rpc) + + if operation == 'create': + raise error.DataExistsAppError(rpc) + + if operation not in ['merge', 'replace']: + raise error.OperationNotSupportedAppError(rpc) + + newname = node.text + with open('/etc/hostname', 'wt') as hostfile: + hostfile.write(newname + '\n') + sethostname(newname) + + +def _edit_ntp(rpc, node, def_op): + """Edit NTP configuration for this system.""" + + +def edit(rpc, node, def_op): + """Edit the configuration for this system.""" + methods = {'dns-resolver': _edit_dns, + 'contact': _edit_file, 'location': _edit_file, + 'hostname': _edit_hostname, + 'ntp': _edit_ntp} + + for subsystem in node: + name = QName(subsystem.tag).localname + if name in methods: + methods[name](rpc, subsystem, def_op) + else: + raise error.UnknownElementAppError(rpc, subsystem) |