#!/usr/bin/env python3 # # Copyright (C) 2019 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 sys import grp import json import traceback import threading import vyos.config import bottle from functools import wraps from vyos.configsession import ConfigSession, ConfigSessionError from vyos.config import VyOSError DEFAULT_CONFIG_FILE = '/etc/vyos/http-api.conf' CFG_GROUP = 'vyattacfg' app = bottle.default_app() # Giant lock! lock = threading.Lock() def load_server_config(): with open(DEFAULT_CONFIG_FILE) as f: config = json.load(f) return config def check_auth(key_list, key): id = None for k in key_list: if k['key'] == key: id = k['id'] return id def error(code, msg): bottle.response.status = code resp = {"success": False, "error": msg, "data": None} return json.dumps(resp) def success(data): resp = {"success": True, "data": data, "error": None} return json.dumps(resp) def auth_required(f): @wraps(f) def decorated_function(*args, **kwargs): key = bottle.request.forms.get("key") api_keys = app.config['vyos_keys'] id = check_auth(api_keys, key) if not id: return error(401, "Valid API key is required") return f(*args, **kwargs) return decorated_function @app.route('/configure', method='POST') @auth_required def configure(): session = app.config['vyos_session'] env = session.get_session_env() config = vyos.config.Config(session_env=env) strict_field = bottle.request.forms.get("strict") if strict_field == "true": strict = True else: strict = False commands = bottle.request.forms.get("data") if not commands: return error(400, "Non-empty data field is required") else: try: commands = json.loads(commands) except Exception as e: return error(400, "Failed to parse JSON: {0}".format(e)) # Allow users to pass just one command if not isinstance(commands, list): commands = [commands] # We don't want multiple people/apps to be able to commit at once, # or modify the shared session while someone else is doing the same, # so the lock is really global lock.acquire() status = 200 error_msg = None try: for c in commands: # What we've got may not even be a dict if not isinstance(c, dict): raise ConfigSessionError("Malformed command \"{0}\": any command must be a dict".format(json.dumps(c))) # Missing op or path is a show stopper if not ('op' in c): raise ConfigSessionError("Malformed command \"{0}\": missing \"op\" field".format(json.dumps(c))) if not ('path' in c): raise ConfigSessionError("Malformed command \"{0}\": missing \"path\" field".format(json.dumps(c))) # Missing value is fine, substitute for empty string if 'value' in c: value = c['value'] else: value = "" op = c['op'] path = c['path'] if not path: raise ConfigSessionError("Malformed command \"{0}\": empty path".format(json.dumps(c))) # Type checking if not isinstance(path, list): raise ConfigSessionError("Malformed command \"{0}\": \"path\" field must be a list".format(json.dumps(c))) if not isinstance(value, str): raise ConfigSessionError("Malformed command \"{0}\": \"value\" field must be a string".format(json.dumps(c))) # Account for the case when value field is present and set to null if not value: value = "" # For vyos.configsessios calls that have no separate value arguments, # and for type checking too try: cfg_path = " ".join(path + [value]).strip() except TypeError: raise ConfigSessionError("Malformed command \"{0}\": \"path\" field must be a list of strings".format(json.dumps(c))) if op == 'set': # XXX: it would be nice to do a strict check for "path already exists", # but there's probably no way to do that session.set(path, value=value) elif op == 'delete': if strict and not config.exists(cfg_path): raise ConfigSessionError("Cannot delete [{0}]: path/value does not exist".format(cfg_path)) session.delete(path, value=value) elif op == 'comment': session.comment(path, value=value) else: raise ConfigSessionError("\"{0}\" is not a valid operation".format(op)) # end for session.commit() print("Configuration modified via HTTP API using key \"{0}\"".format(id)) except ConfigSessionError as e: session.discard() status = 400 if app.config['vyos_debug']: print(traceback.format_exc(), file=sys.stderr) error_msg = str(e) except Exception as e: session.discard() print(traceback.format_exc(), file=sys.stderr) status = 500 # Don't give the details away to the outer world error_msg = "An internal error occured. Check the logs for details." finally: lock.release() if status != 200: return error(status, error_msg) else: return success(None) @app.route('/retrieve', method='POST') @auth_required def get_value(): session = app.config['vyos_session'] env = session.get_session_env() config = vyos.config.Config(session_env=env) command = bottle.request.forms.get("data") command = json.loads(command) try: op = command['op'] path = " ".join(command['path']) except KeyError: return error(400, "Missing required field. \"op\" and \"path\" fields are required") try: if op == 'returnValue': res = config.return_value(path) elif op == 'returnValues': res = config.return_values(path) elif op == 'exists': res = config.exists(path) elif op == 'showConfig': config_format = 'json' if 'configFormat' in command: config_format = command['configFormat'] res = session.show_config(path=command['path']) if config_format == 'json': config_tree = vyos.configtree.ConfigTree(res) res = json.loads(config_tree.to_json()) elif config_format == 'json_ast': config_tree = vyos.configtree.ConfigTree(res) res = json.loads(config_tree.to_json_ast()) elif config_format == 'raw': pass else: return error(400, "\"{0}\" is not a valid config format".format(config_format)) else: return error(400, "\"{0}\" is not a valid operation".format(op)) except VyOSError as e: return error(400, str(e)) except Exception as e: print(traceback.format_exc(), file=sys.stderr) return error(500, "An internal error occured. Check the logs for details.") return success(res) @app.route('/config-file', method='POST') @auth_required def config_file_op(): session = app.config['vyos_session'] command = bottle.request.forms.get("data") command = json.loads(command) try: op = command['op'] except KeyError: return error(400, "Missing required field \"op\"") try: if op == 'save': try: path = command['file'] except KeyError: path = '/config/config.boot' res = session.save_config(path) elif op == 'load': try: path = command['file'] except KeyError: return error(400, "Missing required field \"file\"") res = session.load_config(path) res = session.commit() else: return error(400, "\"{0}\" is not a valid operation".format(op)) except VyOSError as e: return error(400, str(e)) except Exception as e: print(traceback.format_exc(), file=sys.stderr) return error(500, "An internal error occured. Check the logs for details.") return success(res) @app.route('/image', method='POST') @auth_required def config_file_op(): session = app.config['vyos_session'] command = bottle.request.forms.get("data") command = json.loads(command) try: op = command['op'] except KeyError: return error(400, "Missing required field \"op\"") try: if op == 'add': try: url = command['url'] except KeyError: return error(400, "Missing required field \"url\"") res = session.install_image(url) elif op == 'delete': try: name = command['name'] except KeyError: return error(400, "Missing required field \"name\"") res = session.remove_image(name) else: return error(400, "\"{0}\" is not a valid operation".format(op)) except VyOSError as e: return error(400, str(e)) except Exception as e: print(traceback.format_exc(), file=sys.stderr) return error(500, "An internal error occured. Check the logs for details.") return success(res) @app.route('/generate', method='POST') @auth_required def generate_op(): session = app.config['vyos_session'] command = bottle.request.forms.get("data") command = json.loads(command) try: cmd = command['cmd'] res = session.generate(cmd) except KeyError: return error(400, "Missing required field \"cmd\"") except VyOSError as e: return error(400, str(e)) except Exception as e: print(traceback.format_exc(), file=sys.stderr) return error(500, "An internal error occured. Check the logs for details.") return success(res) @app.route('/show', method='POST') @auth_required def show_op(): session = app.config['vyos_session'] command = bottle.request.forms.get("data") command = json.loads(command) try: cmd = command['cmd'] res = session.show(cmd) except KeyError: return error(400, "Missing required field \"cmd\"") except VyOSError as e: return error(400, str(e)) except Exception as e: print(traceback.format_exc(), file=sys.stderr) return error(500, "An internal error occured. Check the logs for details.") return success(res) if __name__ == '__main__': # systemd's user and group options don't work, do it by hand here, # else no one else will be able to commit cfg_group = grp.getgrnam(CFG_GROUP) os.setgid(cfg_group.gr_gid) # Need to set file permissions to 775 too so that every vyattacfg group member # has write access to the running config os.umask(0o002) try: server_config = load_server_config() except Exception as e: print("Failed to load the HTTP API server config: {0}".format(e)) session = ConfigSession(os.getpid()) app.config['vyos_session'] = session app.config['vyos_keys'] = server_config['api_keys'] app.config['vyos_debug'] = server_config['debug'] bottle.run(app, host=server_config["listen_address"], port=server_config["port"], debug=True)