#!/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 . # # import os import sys import grp import json import traceback import threading import signal import vyos.config from flask import Flask, request from waitress import serve import systemd.daemon 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 = Flask(__name__) # 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): resp = {"success": False, "error": msg, "data": None} return json.dumps(resp), code def success(data): resp = {"success": True, "data": data, "error": None} return json.dumps(resp) def get_command(f): @wraps(f) def decorated_function(*args, **kwargs): cmd = request.form.get("data") if not cmd: return error(400, "Non-empty data field is required") try: cmd = json.loads(cmd) except Exception as e: return error(400, "Failed to parse JSON: {0}".format(e)) return f(cmd, *args, **kwargs) return decorated_function def auth_required(f): @wraps(f) def decorated_function(*args, **kwargs): key = request.form.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', methods=['POST']) @get_command @auth_required def configure_op(commands): session = app.config['vyos_session'] env = session.get_session_env() config = vyos.config.Config(session_env=env) strict_field = request.form.get("strict") if strict_field == "true": strict = True else: strict = False # 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', methods=['POST']) @get_command @auth_required def retrieve_op(command): session = app.config['vyos_session'] env = session.get_session_env() config = vyos.config.Config(session_env=env) 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 ConfigSessionError 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', methods=['POST']) @get_command @auth_required def config_file_op(command): session = app.config['vyos_session'] 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 ConfigSessionError 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', methods=['POST']) @get_command @auth_required def image_op(command): session = app.config['vyos_session'] 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 ConfigSessionError 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', methods=['POST']) @get_command @auth_required def generate_op(command): session = app.config['vyos_session'] try: op = command['op'] path = command['path'] except KeyError: return error(400, "Missing required field. \"op\" and \"path\" fields are required") if not isinstance(path, list): return error(400, "Malformed command: \"path\" field must be a list of strings") try: if op == 'generate': res = session.generate(path) else: return error(400, "\"{0}\" is not a valid operation".format(op)) except ConfigSessionError 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', methods=['POST']) @get_command @auth_required def show_op(command): session = app.config['vyos_session'] try: op = command['op'] path = command['path'] except KeyError: return error(400, "Missing required field. \"op\" and \"path\" fields are required") if not isinstance(path, list): return error(400, "Malformed command: \"path\" field must be a list of strings") try: if op == 'show': res = session.show(path) else: return error(400, "\"{0}\" is not a valid operation".format(op)) except ConfigSessionError 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) def shutdown(): raise KeyboardInterrupt 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'] def sig_handler(signum, frame): shutdown() signal.signal(signal.SIGTERM, sig_handler) systemd.daemon.notify('READY=1') try: serve(app, host=server_config["listen_address"], port=server_config["port"]) except OSError as e: sys.exit(f"OSError {e}")