diff options
Diffstat (limited to 'src')
-rwxr-xr-x | src/conf_mode/http-api.py | 98 | ||||
-rwxr-xr-x | src/conf_mode/https.py | 186 | ||||
-rwxr-xr-x | src/conf_mode/vyos_cert.py | 143 | ||||
-rwxr-xr-x | src/services/vyos-http-api-server | 247 | ||||
-rw-r--r-- | src/systemd/vyos-http-api.service | 24 |
5 files changed, 698 insertions, 0 deletions
diff --git a/src/conf_mode/http-api.py b/src/conf_mode/http-api.py new file mode 100755 index 000000000..1f91ac582 --- /dev/null +++ b/src/conf_mode/http-api.py @@ -0,0 +1,98 @@ +#!/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 sys +import os +import subprocess +import json + +import vyos.defaults +from vyos.config import Config +from vyos import ConfigError + +config_file = '/etc/vyos/http-api.conf' + +vyos_conf_scripts_dir=vyos.defaults.directories['conf_mode'] + +# XXX: this model will need to be extended for tag nodes +dependencies = [ + 'https.py', +] + +def get_config(): + http_api = vyos.defaults.api_data + + conf = Config() + if not conf.exists('service https api'): + return None + else: + conf.set_level('service https api') + + if conf.exists('strict'): + http_api['strict'] = 'true' + + if conf.exists('debug'): + http_api['debug'] = 'true' + + if conf.exists('port'): + port = conf.return_value('port') + http_api['port'] = port + + if conf.exists('keys'): + for name in conf.list_nodes('keys id'): + if conf.exists('keys id {0} key'.format(name)): + key = conf.return_value('keys id {0} key'.format(name)) + new_key = { 'id': name, 'key': key } + http_api['api_keys'].append(new_key) + + return http_api + +def verify(http_api): + return None + +def generate(http_api): + if http_api is None: + return None + + with open(config_file, 'w') as f: + json.dump(http_api, f, indent=2) + + return None + +def apply(http_api): + if http_api is not None: + os.system('sudo systemctl restart vyos-http-api.service') + else: + os.system('sudo systemctl stop vyos-http-api.service') + + for dep in dependencies: + cmd = '{0}/{1}'.format(vyos_conf_scripts_dir, dep) + try: + subprocess.check_call(cmd, shell=True) + except subprocess.CalledProcessError as err: + raise ConfigError("{}.".format(err)) + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + sys.exit(1) diff --git a/src/conf_mode/https.py b/src/conf_mode/https.py new file mode 100755 index 000000000..d5aa1f5b3 --- /dev/null +++ b/src/conf_mode/https.py @@ -0,0 +1,186 @@ +#!/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 sys +import os + +import jinja2 + +import vyos.defaults +from vyos.config import Config +from vyos import ConfigError + +config_file = '/etc/nginx/sites-available/default' + +# Please be careful if you edit the template. +config_tmpl = """ + +### Autogenerated by http-api.py ### +# Default server configuration +# +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + return 302 https://$server_name$request_uri; +} + +{% for addr, names in listen_addresses.items() %} +server { + + # SSL configuration + # +{% if addr == '*' %} + listen 443 ssl default_server; + listen [::]:443 ssl default_server; +{% else %} + listen {{ addr }}:443 ssl; +{% endif %} + +{% for name in names %} + server_name {{ name }}; +{% endfor %} + +{% if vyos_cert %} + include {{ vyos_cert.conf }}; +{% else %} + # + # Self signed certs generated by the ssl-cert package + # Don't use them in a production server! + # + include snippets/snakeoil.conf; +{% endif %} + + # proxy settings for HTTP API, if enabled; 503, if not + location ~ /(retrieve|configure) { +{% if api %} + proxy_pass http://localhost:{{ api.port }}; + proxy_buffering off; +{% else %} + return 503; +{% endif %} + } + + error_page 501 502 503 =200 @50*_json; + + location @50*_json { + default_type application/json; + return 200 '{"error": "Start service in configuration mode: set service https api"}'; + } + +} +{% else %} +server { + # SSL configuration + # + listen 443 ssl default_server; + listen [::]:443 ssl default_server; + + server_name _; + +{% if vyos_cert %} + include {{ vyos_cert.conf }}; +{% else %} + # + # Self signed certs generated by the ssl-cert package + # Don't use them in a production server! + # + include snippets/snakeoil.conf; +{% endif %} + + # proxy settings for HTTP API, if enabled; 503, if not + location ~ /(retrieve|configure) { +{% if api %} + proxy_pass http://localhost:{{ api.port }}; + proxy_buffering off; +{% else %} + return 503; +{% endif %} + } + + error_page 501 502 503 =200 @50*_json; + + location @50*_json { + default_type application/json; + return 200 '{"error": "Start service in configuration mode: set service https api"}'; + } + +} + +{% endfor %} +""" + +def get_config(): + https = vyos.defaults.https_data + conf = Config() + if not conf.exists('service https'): + return None + else: + conf.set_level('service https') + + if conf.exists('listen-addresses'): + addrs = {} + for addr in conf.list_nodes('listen-addresses'): + addrs[addr] = ['_'] + if conf.exists('listen-addresses {0} server-names'.format(addr)): + names = conf.return_values('listen-addresses {0} server-names'.format(addr)) + addrs[addr] = names[:] + https['listen_addresses'] = addrs + + if conf.exists('certificates'): + if conf.exists('certificates system-generated-certificate'): + https['vyos_cert'] = vyos.defaults.vyos_cert_data + + if conf.exists('api'): + https['api'] = vyos.defaults.api_data + + if conf.exists('api port'): + port = conf.return_value('api port') + https['api']['port'] = port + + return https + +def verify(https): + return None + +def generate(https): + if https is None: + return None + + tmpl = jinja2.Template(config_tmpl, trim_blocks=True) + config_text = tmpl.render(https) + with open(config_file, 'w') as f: + f.write(config_text) + + return None + +def apply(https): + if https is not None: + os.system('sudo systemctl restart nginx.service') + else: + os.system('sudo systemctl stop nginx.service') + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + sys.exit(1) diff --git a/src/conf_mode/vyos_cert.py b/src/conf_mode/vyos_cert.py new file mode 100755 index 000000000..4a44573ca --- /dev/null +++ b/src/conf_mode/vyos_cert.py @@ -0,0 +1,143 @@ +#!/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 sys +import os +import subprocess +import tempfile +import pathlib +import ssl + +import vyos.defaults +from vyos.config import Config +from vyos import ConfigError + +vyos_conf_scripts_dir = vyos.defaults.directories['conf_mode'] + +# XXX: this model will need to be extended for tag nodes +dependencies = [ + 'https.py', +] + +def status_self_signed(cert_data): +# check existence and expiration date + path = pathlib.Path(cert_data['conf']) + if not path.is_file(): + return False + path = pathlib.Path(cert_data['crt']) + if not path.is_file(): + return False + path = pathlib.Path(cert_data['key']) + if not path.is_file(): + return False + + # check if certificate is 1/2 past lifetime, with openssl -checkend + end_days = int(cert_data['lifetime']) + end_seconds = int(0.5*60*60*24*end_days) + checkend_cmd = ('openssl x509 -checkend {end} -noout -in {crt}' + ''.format(end=end_seconds, **cert_data)) + try: + subprocess.check_call(checkend_cmd, shell=True) + return True + except subprocess.CalledProcessError as err: + if err.returncode == 1: + return False + else: + print("Called process error: {}.".format(err)) + +def generate_self_signed(cert_data): + san_config = None + + if ssl.OPENSSL_VERSION_INFO < (1, 1, 1, 0, 0): + san_config = tempfile.NamedTemporaryFile() + with open(san_config.name, 'w') as fd: + fd.write('[req]\n') + fd.write('distinguished_name=req\n') + fd.write('[san]\n') + fd.write('subjectAltName=DNS:vyos\n') + + openssl_req_cmd = ('openssl req -x509 -nodes -days {lifetime} ' + '-newkey rsa:4096 -keyout {key} -out {crt} ' + '-subj "/O=Sentrium/OU=VyOS/CN=vyos" ' + '-extensions san -config {san_conf}' + ''.format(san_conf=san_config.name, + **cert_data)) + + else: + openssl_req_cmd = ('openssl req -x509 -nodes -days {lifetime} ' + '-newkey rsa:4096 -keyout {key} -out {crt} ' + '-subj "/O=Sentrium/OU=VyOS/CN=vyos" ' + '-addext "subjectAltName=DNS:vyos"' + ''.format(**cert_data)) + + try: + subprocess.check_call(openssl_req_cmd, shell=True) + except subprocess.CalledProcessError as err: + print("Called process error: {}.".format(err)) + + os.chmod('{key}'.format(**cert_data), 0o400) + + with open('{conf}'.format(**cert_data), 'w') as f: + f.write('ssl_certificate {crt};\n'.format(**cert_data)) + f.write('ssl_certificate_key {key};\n'.format(**cert_data)) + + if san_config: + san_config.close() + +def get_config(): + vyos_cert = vyos.defaults.vyos_cert_data + + conf = Config() + if not conf.exists('service https certificates system-generated-certificate'): + return None + else: + conf.set_level('service https certificates system-generated-certificate') + + if conf.exists('lifetime'): + lifetime = conf.return_value('lifetime') + vyos_cert['lifetime'] = lifetime + + return vyos_cert + +def verify(vyos_cert): + return None + +def generate(vyos_cert): + if vyos_cert is None: + return None + + if not status_self_signed(vyos_cert): + generate_self_signed(vyos_cert) + +def apply(vyos_cert): + for dep in dependencies: + cmd = '{0}/{1}'.format(vyos_conf_scripts_dir, dep) + try: + subprocess.check_call(cmd, shell=True) + except subprocess.CalledProcessError as err: + raise ConfigError("{}.".format(err)) + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + sys.exit(1) diff --git a/src/services/vyos-http-api-server b/src/services/vyos-http-api-server new file mode 100755 index 000000000..afab9be70 --- /dev/null +++ b/src/services/vyos-http-api-server @@ -0,0 +1,247 @@ +#!/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 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) + +@app.route('/configure', method='POST') +def configure(): + session = app.config['vyos_session'] + config = app.config['vyos_config'] + api_keys = app.config['vyos_keys'] + + key = bottle.request.forms.get("key") + id = check_auth(api_keys, key) + if not id: + return error(401, "Valid API key is required") + + 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') +def get_value(): + config = app.config['vyos_config'] + session = app.config['vyos_session'] + + api_keys = app.config['vyos_keys'] + + key = bottle.request.forms.get("key") + id = check_auth(api_keys, key) + if not id: + return error(401, "Valid API key is required") + + 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 = 'raw' + if 'configFormat' in command: + config_format = command['configFormat'] + + res = session.show_config(command['path'], 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) + +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()) + env = session.get_session_env() + config = vyos.config.Config(session_env=env) + + app.config['vyos_session'] = session + app.config['vyos_config'] = config + 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) diff --git a/src/systemd/vyos-http-api.service b/src/systemd/vyos-http-api.service new file mode 100644 index 000000000..4fa68b4ff --- /dev/null +++ b/src/systemd/vyos-http-api.service @@ -0,0 +1,24 @@ +[Unit] +Description=VyOS HTTP API service +After=auditd.service systemd-user-sessions.service time-sync.target vyos-router.service +Requires=vyos-router.service + +[Service] +ExecStartPre=/usr/libexec/vyos/init/vyos-config +ExecStart=/usr/bin/python3 -u /usr/libexec/vyos/services/vyos-http-api-server +Type=idle +KillMode=process + +SyslogIdentifier=vyos-http-api +SyslogFacility=daemon + +Restart=on-failure + +# Does't work but leave it here +User=root +Group=vyattacfg + +[Install] +# Installing in a earlier target leaves ExecStartPre waiting +WantedBy=getty.target + |