summaryrefslogtreecommitdiff
path: root/ncserver/module/system.py
diff options
context:
space:
mode:
authorA. Wilcox <AWilcox@Wilcox-Tech.com>2020-09-16 15:34:05 -0500
committerA. Wilcox <AWilcox@Wilcox-Tech.com>2020-09-16 15:34:05 -0500
commit76a0843c6b89a21faed426fb373b2ff1afd83e0f (patch)
treea9351471f82832a7d27e2b6eaf902e11a2736196 /ncserver/module/system.py
parent7915d8de1c5652fe0af2e824cd15a424dd84c014 (diff)
downloadnetconfapk-76a0843c6b89a21faed426fb373b2ff1afd83e0f.tar.gz
netconfapk-76a0843c6b89a21faed426fb373b2ff1afd83e0f.tar.bz2
netconfapk-76a0843c6b89a21faed426fb373b2ff1afd83e0f.tar.xz
netconfapk-76a0843c6b89a21faed426fb373b2ff1afd83e0f.zip
ietf-system: Add ability to edit DNS and contact/location
Diffstat (limited to 'ncserver/module/system.py')
-rw-r--r--ncserver/module/system.py312
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)