Jakub Man | 81d1f0c | 2020-12-21 10:04:09 +0100 | [diff] [blame] | 1 | # coding=utf-8
|
| 2 | """
|
| 3 | Netconf session operations
|
| 4 | File: netconf.py
|
| 5 | Author: Jakub Man <xmanja00@stud.fit.vutbr.cz>
|
| 6 |
|
| 7 |
|
| 8 | Parts of this file was taken from the Netopeer2GUI project by Radek Krejčí
|
| 9 | Available here: https://github.com/CESNET/Netopeer2GUI
|
| 10 |
|
| 11 | Copyright 2017 Radek Krejčí
|
| 12 |
|
| 13 | Licensed under the Apache License, Version 2.0 (the "License");
|
| 14 | you may not use this file except in compliance with the License.
|
| 15 | You may obtain a copy of the License at
|
| 16 |
|
| 17 | http://www.apache.org/licenses/LICENSE-2.0
|
| 18 |
|
| 19 | Unless required by applicable law or agreed to in writing, software
|
| 20 | distributed under the License is distributed on an "AS IS" BASIS,
|
| 21 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 22 | See the License for the specific language governing permissions and
|
| 23 | limitations under the License.
|
| 24 | """
|
| 25 |
|
| 26 | from liberouterapi import db, auth, config, socketio
|
| 27 | from liberouterapi.dbConnector import dbConnector
|
| 28 | import netconf2 as nc
|
| 29 | import json
|
| 30 | from eventlet.timeout import Timeout
|
| 31 | from flask import request
|
| 32 | import logging
|
| 33 | from .sockets import *
|
| 34 | import os
|
| 35 | import yang
|
| 36 | from .schemas import get_schema
|
| 37 | from .devices import *
|
| 38 | from .data import *
|
| 39 | import pprint
|
| 40 |
|
| 41 | sessions = {}
|
| 42 | log = logging.getLogger(__name__)
|
| 43 | netconf_db = dbConnector('netconf', provider='mongodb', config={'database': config['netconf']['database']})
|
| 44 | netconf_coll = netconf_db.db[config['netconf']['collection']]
|
| 45 |
|
| 46 | """
|
| 47 | netconf session (ncs)
|
| 48 | static PyGetSetDef ncSessionGetSetters[] = {
|
| 49 | {"id", (getter)ncSessionGetId, NULL, "NETCONF Session id.", NULL},
|
| 50 | {"host", (getter)ncSessionGetHost, NULL, "Host where the NETCONF Session is connected.", NULL},
|
| 51 | {"port", (getter)ncSessionGetPort, NULL, "Port number where the NETCONF Session is connected.", NULL},
|
| 52 | {"user", (getter)ncSessionGetUser, NULL, "Username of the user connected with the NETCONF Session.", NULL},
|
| 53 | {"transport", (getter)ncSessionGetTransport, NULL, "Transport protocol used for the NETCONF Session.", NULL},
|
| 54 | {"version", (getter)ncSessionGetVersion, NULL, "NETCONF Protocol version used for the NETCONF Session.", NULL},
|
| 55 | {"capabilities", (getter)ncSessionGetCapabilities, NULL, "Capabilities of the NETCONF Session.", NULL},
|
| 56 | {"context", (getter)ncSessionGetContext, NULL, "libyang context of the NETCONF Session.", NULL},
|
| 57 | {NULL} /* Sentinel */
|
| 58 | };
|
| 59 | """
|
| 60 |
|
| 61 |
|
| 62 | def auth_common(session_id):
|
| 63 | global log
|
| 64 | result = None
|
| 65 | timeout = Timeout(60)
|
| 66 | try:
|
| 67 | # wait for response from the frontend
|
| 68 | data = sio_wait(session_id)
|
| 69 | result = data['password']
|
| 70 | except Timeout:
|
| 71 | # no response received within the timeout
|
| 72 | log.info("socketio: auth request timeout.")
|
| 73 | except KeyError:
|
| 74 | # no password
|
| 75 | log.info("socketio: invalid credential data received.")
|
| 76 | finally:
|
| 77 | # we have the response
|
| 78 | sio_clean(session_id)
|
| 79 | timeout.cancel()
|
| 80 |
|
| 81 | return result
|
| 82 |
|
| 83 |
|
| 84 | def auth_interactive(name, instruction, prompt, priv):
|
| 85 | try:
|
| 86 | pp = pprint.PrettyPrinter(indent=4)
|
| 87 | pp.pprint(priv)
|
| 88 | params = {'id': priv['id'], 'type': name, 'msg': instruction, 'prompt': prompt, 'device': priv['device']}
|
| 89 | sio_emit('device_auth', params)
|
| 90 | return auth_common(priv)
|
| 91 | except Exception as e:
|
| 92 | print(e)
|
| 93 |
|
| 94 |
|
| 95 | def auth_password(username, hostname, priv):
|
| 96 | sio_emit('device_auth', {'id': priv, 'type': 'Password Authentication', 'msg': username + '@' + hostname})
|
| 97 | return auth_common(priv)
|
| 98 |
|
| 99 |
|
| 100 | @auth.required()
|
| 101 | def connect_device():
|
| 102 | global sessions
|
| 103 | session = auth.lookup(request.headers.get('lgui-Authorization', None))
|
| 104 | username = str(session['user'].username)
|
| 105 | data = request.get_json()
|
| 106 |
|
| 107 | nc.setSchemaCallback(get_schema, session)
|
| 108 | site_root = os.path.realpath(os.path.dirname(__file__))
|
| 109 | path = os.path.join(site_root, 'userfiles', username)
|
| 110 | if not os.path.exists(path):
|
| 111 | os.makedirs(path)
|
| 112 | nc.setSearchpath(path)
|
Jakub Man | e5363e8 | 2021-02-04 12:35:32 +0100 | [diff] [blame] | 113 | if 'password' in data and data['password'] != '' and data['password'] is not None:
|
Jakub Man | 81d1f0c | 2020-12-21 10:04:09 +0100 | [diff] [blame] | 114 | ssh = nc.SSH(data['username'], password=data['password'])
|
| 115 | else:
|
| 116 | ssh = nc.SSH(data['username'])
|
| 117 | ssh.setAuthPasswordClb(auth_password, session['session_id'])
|
| 118 | ssh.setAuthInteractiveClb(func=auth_interactive, priv={'id': session['session_id'], 'device': data})
|
| 119 |
|
| 120 | ssh.setAuthHostkeyCheckClb(hostkey_check, {'session': session, 'device': data})
|
| 121 |
|
| 122 | try:
|
| 123 | ncs = nc.Session(data['hostname'], int(data['port']), ssh)
|
| 124 | except Exception as e:
|
| 125 | nc.setSchemaCallback(None)
|
| 126 | return json.dumps({'success': False, 'code': 500, 'message': str(e)})
|
| 127 | nc.setSchemaCallback(None)
|
| 128 |
|
| 129 | if username not in sessions:
|
| 130 | sessions[username] = {}
|
| 131 |
|
| 132 | # use key (as hostname:port:session-id) to store the created NETCONF session
|
| 133 | key = ncs.host + ":" + str(ncs.port) + ":" + ncs.id
|
| 134 | sessions[username][key] = {}
|
| 135 | sessions[username][key]['session'] = ncs
|
| 136 |
|
| 137 | # update inventory's list of schemas
|
| 138 | # schemas_update(session)
|
| 139 |
|
| 140 | return json.dumps({'success': True, 'session-key': key})
|
| 141 |
|
| 142 |
|
| 143 | def hostkey_check(hostname, state, keytype, hexa, priv):
|
| 144 | if 'fingerprint' in priv['device']:
|
| 145 | # check according to the stored fingerprint from previous connection
|
| 146 | if hexa == priv['device']['fingerprint']:
|
| 147 | return True
|
| 148 | elif state != 2:
|
| 149 | log.error("Incorrect host key state")
|
| 150 | state = 2
|
| 151 |
|
| 152 | # ask frontend/user for hostkey check
|
| 153 | params = {'id': priv['session']['session_id'], 'hostname': hostname, 'state': state, 'keytype': keytype,
|
| 154 | 'hexa': hexa, 'device': priv['device']}
|
| 155 | sio_emit('hostcheck', params)
|
| 156 |
|
| 157 | result = False
|
| 158 | timeout = Timeout(30)
|
| 159 | try:
|
| 160 | # wait for response from the frontend
|
| 161 | data = sio_wait(priv['session']['session_id'])
|
| 162 | result = data['result']
|
| 163 | except Timeout:
|
| 164 | # no response received within the timeout
|
| 165 | log.info("socketio: hostcheck timeout.")
|
| 166 | except KeyError:
|
| 167 | # invalid response
|
| 168 | log.error("socketio: invalid hostcheck_result received.")
|
| 169 | finally:
|
| 170 | # we have the response
|
| 171 | sio_clean(priv['session']['session_id'])
|
| 172 | timeout.cancel()
|
| 173 |
|
| 174 | if result:
|
| 175 | # store confirmed fingerprint for future connections
|
| 176 | priv['device']['fingerprint'] = hexa
|
| 177 | if 'id' in priv['device'].keys():
|
| 178 | update_hexa(priv['device']['id'], hexa, netconf_coll)
|
| 179 | else:
|
| 180 | update_hexa_by_device(priv['device'], hexa, netconf_coll)
|
| 181 | return result
|
| 182 |
|
| 183 |
|
| 184 | """ SESSION HANDLING """
|
| 185 |
|
| 186 |
|
| 187 | @auth.required()
|
| 188 | def sessions_get_open():
|
| 189 | """
|
| 190 | Get all open sessions belonging to the user
|
| 191 | :return: Array of session keys and devices belonging to sessions. Device names will not be loaded.
|
| 192 | """
|
| 193 | global sessions
|
| 194 | session = auth.lookup(request.headers.get('lgui-Authorization', None))
|
| 195 | username = str(session['user'].username)
|
| 196 |
|
| 197 | if username in sessions:
|
| 198 | result = []
|
| 199 | for key in sessions[username].keys():
|
| 200 | ncs = sessions[username][key]['session']
|
| 201 | device = get_device_from_session_data(ncs.host, ncs.port, username, ncs.user, netconf_coll)
|
| 202 | if device is not None:
|
| 203 | result.append({'key': key, 'device': device})
|
| 204 | return json.dumps(result)
|
| 205 | else:
|
| 206 | return json.dumps([])
|
| 207 |
|
| 208 |
|
| 209 | @auth.required()
|
| 210 | def session_alive(key):
|
| 211 | global sessions
|
| 212 | session = auth.lookup(request.headers.get('lgui-Authorization', None))
|
| 213 | username = str(session['user'].username)
|
| 214 |
|
| 215 | if not username in sessions:
|
| 216 | sessions[username] = {}
|
| 217 |
|
| 218 | if key in sessions[username]:
|
| 219 | return json.dumps({'success': True, 'code': 200})
|
| 220 | else:
|
| 221 | return json.dumps({'success': False, 'code': 404, 'message': 'Session not found'})
|
| 222 |
|
| 223 |
|
| 224 | @auth.required()
|
| 225 | def session_destroy(key):
|
| 226 | global sessions
|
| 227 | session = auth.lookup(request.headers.get('lgui-Authorization', None))
|
| 228 | username = str(session['user'].username)
|
| 229 | if not username in sessions:
|
| 230 | sessions[username] = {}
|
| 231 |
|
| 232 | if key in sessions[username]:
|
| 233 | del sessions[username][key]
|
| 234 | return json.dumps({'success': True, 'code': 200})
|
| 235 | else:
|
| 236 | return json.dumps({'success': False, 'code': 404, 'message': 'Session not found'})
|
| 237 |
|
| 238 |
|
| 239 | @auth.required()
|
| 240 | def session_destroy_all():
|
| 241 | global sessions
|
| 242 | session = auth.lookup(request.headers.get('lgui-Authorization', None))
|
| 243 | username = str(session['user'].username)
|
| 244 | if username in sessions:
|
| 245 | del sessions[username]
|
| 246 | return json.dumps({'success': True, 'code': 200})
|
| 247 |
|
| 248 |
|
| 249 | @auth.required()
|
| 250 | def session_rpc_get():
|
| 251 | """
|
| 252 | code 500: wrong argument
|
| 253 | code 404: session invalid -> try reconnecting
|
| 254 | code 410: connection gone -> remove session, try reconnecting
|
| 255 | code 418: Error in processing netconf request (nothing to do with a teapot)
|
| 256 | """
|
| 257 | global sessions
|
| 258 | session = auth.lookup(request.headers.get('lgui-Authorization', None))
|
| 259 | username = str(session['user'].username)
|
| 260 | req = request.args.to_dict()
|
| 261 | if 'key' not in req:
|
| 262 | return json.dumps({'success': False, 'code': 500, 'message': 'Missing session key.'})
|
| 263 | if 'recursive' not in req:
|
| 264 | return json.dumps({'success': False, 'code': 500, 'message': 'Missing recursive flag.'})
|
| 265 |
|
| 266 | if username not in sessions:
|
| 267 | sessions[username] = {}
|
| 268 |
|
| 269 | key = req['key']
|
| 270 | if key not in sessions[username]:
|
| 271 | return json.dumps({'success': False, 'code': 404, 'message': 'Invalid session key.'})
|
| 272 |
|
| 273 | try:
|
| 274 | sessions[username][key]['data'] = sessions[username][key]['session'].rpcGet()
|
| 275 | except ConnectionError as e:
|
| 276 | del sessions[username][key]
|
| 277 | return json.dumps({'success': False, 'code': 410, 'message': str(e)})
|
| 278 | except nc.ReplyError as e:
|
| 279 | err_list = []
|
| 280 | for err in e.args[0]:
|
| 281 | err_list.append(str(err))
|
| 282 | return json.dumps({'success': False, 'code': 418, 'message': str(e)})
|
| 283 |
|
| 284 | if 'path' not in req:
|
| 285 | return data_info_roots(sessions[username][key]['data'], True if req['recursive'] == 'true' else False)
|
| 286 | else:
|
| 287 | return data_info_subtree(sessions[username][key]['data'], req['path'],
|
| 288 | True if req['recursive'] == 'true' else False)
|
| 289 |
|
| 290 |
|
| 291 | @auth.required()
|
| 292 | def session_commit():
|
| 293 | session = auth.lookup(request.headers.get('lgui-Authorization', None))
|
| 294 | user = session['user']
|
| 295 |
|
| 296 | req = request.get_json(keep_order=True)
|
| 297 | if 'key' not in req:
|
| 298 | return json.dumps({'success': False, 'code': 500, 'message': 'Missing session key.'})
|
| 299 | if 'modifications' not in req:
|
| 300 | return json.dumps({'success': False, 'code': 500, 'message': 'Missing modifications.'})
|
| 301 |
|
| 302 | mods = req['modifications']
|
| 303 | ctx = sessions[user.username][req['key']]['session'].context
|
| 304 | root = None
|
| 305 | reorders = []
|
| 306 | for key in mods:
|
| 307 | recursion = False
|
| 308 | # get correct path and value if needed
|
| 309 | path = mods[key]['data']['path']
|
| 310 | value = None
|
| 311 | if mods[key]['type'] == 'change':
|
| 312 | value = mods[key]['value']
|
| 313 | elif mods[key]['type'] == 'create' or mods[key]['type'] == 'replace':
|
| 314 | if mods[key]['data']['info']['type'] == 1:
|
| 315 | # creating/replacing container
|
| 316 | recursion = True
|
| 317 | elif mods[key]['data']['info']['type'] == 4:
|
| 318 | # creating/replacing leaf
|
| 319 | value = mods[key]['data']['value']
|
| 320 | elif mods[key]['data']['info']['type'] == 8:
|
| 321 | # creating/replacing leaf-list
|
| 322 | value = mods[key]['data']['value'][0]
|
| 323 | path = mods[key]['data']['path']
|
| 324 | elif mods[key]['data']['info']['type'] == 16:
|
| 325 | recursion = True
|
| 326 | path = mods[key]['data']['path']
|
| 327 | elif mods[key]['type'] == 'reorder':
|
| 328 | # postpone reorders
|
| 329 | reorders.extend(mods[key]['transactions'])
|
| 330 | continue
|
| 331 |
|
| 332 | # create node
|
| 333 | # print("creating " + path)
|
| 334 | # print("value " + str(value))
|
| 335 | if root:
|
| 336 | root.new_path(ctx, path, value, 0, 0)
|
| 337 | else:
|
| 338 | try:
|
| 339 | root = yang.Data_Node(ctx, path, value, 0, 0)
|
| 340 | except Exception as e:
|
| 341 | print(e)
|
| 342 | return json.dumps({'success': False, 'code': 404, 'message': str(e)})
|
| 343 | node = root.find_path(path).data()[0]
|
| 344 |
|
| 345 | # set operation attribute and add additional data if any
|
| 346 | if mods[key]['type'] == 'change':
|
| 347 | node.insert_attr(None, 'ietf-netconf:operation', 'merge')
|
| 348 | elif mods[key]['type'] == 'delete':
|
| 349 | node.insert_attr(None, 'ietf-netconf:operation', 'delete')
|
| 350 | elif mods[key]['type'] == 'create':
|
| 351 | node.insert_attr(None, 'ietf-netconf:operation', 'create')
|
| 352 | elif mods[key]['type'] == 'replace':
|
| 353 | node.insert_attr(None, 'ietf-netconf:operation', 'replace')
|
| 354 | else:
|
| 355 | return json.dumps({'success': False, 'error-msg': 'Invalid modification ' + key})
|
| 356 |
|
| 357 | if recursion and 'children' in mods[key]['data']:
|
| 358 | for child in mods[key]['data']['children']:
|
| 359 | if 'key' in child['info'] and child['info']['key']:
|
| 360 | continue
|
| 361 | _create_child(ctx, node, child)
|
| 362 |
|
| 363 | # finally process reorders which must be last since they may refer newly created nodes
|
| 364 | # and they do not reflect removed nodes
|
| 365 | for move in reorders:
|
| 366 | try:
|
| 367 | node = root.find_path(move['node']).data()[0];
|
| 368 | parent = node.parent()
|
| 369 | node.unlink()
|
| 370 | if parent:
|
| 371 | parent.insert(node)
|
| 372 | else:
|
| 373 | root.insert_sibling(node)
|
| 374 | except Exception as e:
|
| 375 | if root:
|
| 376 | root.new_path(ctx, move['node'], None, 0, 0)
|
| 377 | else:
|
| 378 | root = yang.Data_Node(ctx, move['node'], None, 0, 0)
|
| 379 | node = root.find_path(move['node']).data()[0];
|
| 380 | node.insert_attr(None, 'yang:insert', move['insert'])
|
| 381 | if move['insert'] == 'after' or move['insert'] == 'before':
|
| 382 | if 'key' in move:
|
| 383 | node.insert_attr(None, 'yang:key', move['key'])
|
| 384 | elif 'value' in move:
|
| 385 | node.insert_attr(None, 'yang:value', move['value'])
|
| 386 |
|
| 387 | # print(root.print_mem(yang.LYD_XML, yang.LYP_FORMAT))
|
| 388 | try:
|
| 389 | sessions[user.username][req['key']]['session'].rpcEditConfig(nc.DATASTORE_RUNNING, root)
|
| 390 | except nc.ReplyError as e:
|
| 391 | reply = {'success': False, 'code': 500, 'message': '[]'}
|
| 392 | for err in e.args[0]:
|
| 393 | reply['message'] += str(err) + '; '
|
| 394 | return json.dumps(reply)
|
| 395 |
|
| 396 | return json.dumps({'success': True})
|
| 397 |
|
| 398 |
|
| 399 | def _create_child(ctx, parent, child_def):
|
| 400 | at = child_def['info']['module'].find('@')
|
| 401 | if at == -1:
|
| 402 | module = ctx.get_module(child_def['info']['module'])
|
| 403 | else:
|
| 404 | module = ctx.get_module(child_def['info']['module'][:at], child_def['info']['module'][at + 1:])
|
| 405 | if child_def['info']['type'] == 4:
|
| 406 | yang.Data_Node(parent, module, child_def['info']['name'], child_def['value'])
|
| 407 | elif child_def['info']['type'] == 8:
|
| 408 | yang.Data_Node(parent, module, child_def['info']['name'], child_def['value'][0])
|
| 409 | else:
|
| 410 | child = yang.Data_Node(parent, module, child_def['info']['name'])
|
| 411 | if 'children' in child_def:
|
| 412 | for grandchild in child_def['children']:
|
| 413 | _create_child(ctx, child, grandchild)
|