summaryrefslogtreecommitdiff
path: root/src/services
diff options
context:
space:
mode:
Diffstat (limited to 'src/services')
-rw-r--r--src/services/api/graphql/README.graphql140
-rw-r--r--src/services/api/graphql/bindings.py14
-rw-r--r--src/services/api/graphql/graphql/__init__.py0
-rw-r--r--src/services/api/graphql/graphql/directives.py32
-rw-r--r--src/services/api/graphql/graphql/mutations.py109
-rw-r--r--src/services/api/graphql/graphql/schema/config_file.graphql27
-rw-r--r--src/services/api/graphql/graphql/schema/dhcp_server.graphql35
-rw-r--r--src/services/api/graphql/graphql/schema/interface_ethernet.graphql18
-rw-r--r--src/services/api/graphql/graphql/schema/schema.graphql18
-rw-r--r--src/services/api/graphql/recipes/__init__.py0
-rw-r--r--src/services/api/graphql/recipes/config_file.py16
-rw-r--r--src/services/api/graphql/recipes/dhcp_server.py13
-rw-r--r--src/services/api/graphql/recipes/interface_ethernet.py13
-rw-r--r--src/services/api/graphql/recipes/recipe.py68
-rw-r--r--src/services/api/graphql/recipes/templates/dhcp_server.tmpl9
-rw-r--r--src/services/api/graphql/recipes/templates/interface_ethernet.tmpl5
-rw-r--r--src/services/api/graphql/state.py4
-rwxr-xr-xsrc/services/vyos-http-api-server628
18 files changed, 962 insertions, 187 deletions
diff --git a/src/services/api/graphql/README.graphql b/src/services/api/graphql/README.graphql
new file mode 100644
index 000000000..c91b70782
--- /dev/null
+++ b/src/services/api/graphql/README.graphql
@@ -0,0 +1,140 @@
+
+Example using GraphQL mutations to configure a DHCP server:
+
+This assumes that the http-api is running:
+
+'set service https api'
+
+One can configure an address on an interface, and configure the DHCP server
+to run with that address as default router by requesting these 'mutations'
+in the GraphQL playground:
+
+mutation {
+ createInterfaceEthernet (data: {interface: "eth1",
+ address: "192.168.0.1/24",
+ description: "BOB"}) {
+ success
+ errors
+ data {
+ address
+ }
+ }
+}
+
+mutation {
+ createDhcpServer(data: {sharedNetworkName: "BOB",
+ subnet: "192.168.0.0/24",
+ defaultRouter: "192.168.0.1",
+ nameServer: "192.168.0.1",
+ domainName: "vyos.net",
+ lease: 86400,
+ range: 0,
+ start: "192.168.0.9",
+ stop: "192.168.0.254",
+ dnsForwardingAllowFrom: "192.168.0.0/24",
+ dnsForwardingCacheSize: 0,
+ dnsForwardingListenAddress: "192.168.0.1"}) {
+ success
+ errors
+ data {
+ defaultRouter
+ }
+ }
+}
+
+mutation {
+ saveConfigFile(data: {fileName: "/config/config.boot"}) {
+ success
+ errors
+ data {
+ fileName
+ }
+ }
+}
+
+N.B. fileName can be empty (fileName: "") or data can be empty (data: {}) to save to
+/config/config.boot; to save to an alternative path, specify fileName.
+
+mutation {
+ loadConfigFile(data: {fileName: "/home/vyos/config.boot"}) {
+ success
+ errors
+ data {
+ fileName
+ }
+ }
+}
+
+
+The GraphQL playground will be found at:
+
+https://{{ host_address }}/graphql
+
+An equivalent curl command to the first example above would be:
+
+curl -k 'https://192.168.100.168/graphql' -H 'Content-Type: application/json' --data-binary '{"query": "mutation {createInterfaceEthernet (data: {interface: \"eth1\", address: \"192.168.0.1/24\", description: \"BOB\"}) {success errors data {address}}}"}'
+
+Note that the 'mutation' term is prefaced by 'query' in the curl command.
+
+What's here:
+
+services
+├── api
+│   └── graphql
+│   ├── graphql
+│   │   ├── directives.py
+│   │   ├── __init__.py
+│   │   ├── mutations.py
+│   │   └── schema
+│   │   ├── dhcp_server.graphql
+│   │   ├── interface_ethernet.graphql
+│   │   └── schema.graphql
+│   ├── recipes
+│   │   ├── dhcp_server.py
+│   │   ├── __init__.py
+│   │   ├── interface_ethernet.py
+│   │   ├── recipe.py
+│   │   └── templates
+│   │   ├── dhcp_server.tmpl
+│   │   └── interface_ethernet.tmpl
+│   └── state.py
+├── vyos-configd
+├── vyos-hostsd
+└── vyos-http-api-server
+
+The GraphQL library that we are using, Ariadne, advertises itself as a
+'schema-first' implementation: define the schema; define resolvers
+(handlers) for declared Query and Mutation types (Subscription types are not
+currently used).
+
+In the current approach to a high-level API, we consider the
+Jinja2-templated collection of configuration mode 'set'/'delete' commands as
+the Ur-data; the GraphQL schema is produced from those files, located in
+'api/graphql/recipes/templates'.
+
+Resolvers for the schema Mutation fields are dynamically generated using a
+'directive' added to the respective schema field. The directive,
+'@generate', is handled by the class 'DataDirective' in
+'api/graphql/graphql/directives.py', which calls the 'make_resolver' function in
+'api/graphql/graphql/mutations.py'; the produced resolver calls the appropriate
+wrapper in 'api/graphql/recipes', with base class doing the (overridable)
+configuration steps of calling all defined 'set'/'delete' commands.
+
+Integrating the above with vyos-http-api-server is ~10 lines of code.
+
+What needs to be done:
+
+• automate generation of schema and wrappers from templated configuration
+commands
+
+• investigate whether the subclassing provided by the named wrappers in
+'api/graphql/recipes' is sufficient for use cases which need to modify data
+
+• encapsulate the manipulation of 'canonical names' which transforms the
+prefixed camel-case schema names to various snake-case file/function names
+
+• consider mechanism for migration of templates: offline vs. on-the-fly
+
+• define the naming convention for those schema fields that refer to
+configuration mode parameters: e.g. how much of the path is needed as prefix
+to uniquely define the term
diff --git a/src/services/api/graphql/bindings.py b/src/services/api/graphql/bindings.py
new file mode 100644
index 000000000..c123f68d8
--- /dev/null
+++ b/src/services/api/graphql/bindings.py
@@ -0,0 +1,14 @@
+import vyos.defaults
+from . graphql.mutations import mutation
+from . graphql.directives import DataDirective, ConfigFileDirective
+
+from ariadne import make_executable_schema, load_schema_from_path, snake_case_fallback_resolvers
+
+def generate_schema():
+ api_schema_dir = vyos.defaults.directories['api_schema']
+
+ type_defs = load_schema_from_path(api_schema_dir)
+
+ schema = make_executable_schema(type_defs, mutation, snake_case_fallback_resolvers, directives={"generate": DataDirective, "configfile": ConfigFileDirective})
+
+ return schema
diff --git a/src/services/api/graphql/graphql/__init__.py b/src/services/api/graphql/graphql/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/services/api/graphql/graphql/__init__.py
diff --git a/src/services/api/graphql/graphql/directives.py b/src/services/api/graphql/graphql/directives.py
new file mode 100644
index 000000000..85d514de4
--- /dev/null
+++ b/src/services/api/graphql/graphql/directives.py
@@ -0,0 +1,32 @@
+from ariadne import SchemaDirectiveVisitor, ObjectType
+from . mutations import make_resolver, make_config_file_resolver
+
+class DataDirective(SchemaDirectiveVisitor):
+ """
+ Class providing implementation of 'generate' directive in schema.
+
+ """
+ def visit_field_definition(self, field, object_type):
+ name = f'{field.type}'
+ # field.type contains the return value of the mutation; trim value
+ # to produce canonical name
+ name = name.replace('Result', '', 1)
+
+ func = make_resolver(name)
+ field.resolve = func
+ return field
+
+class ConfigFileDirective(SchemaDirectiveVisitor):
+ """
+ Class providing implementation of 'configfile' directive in schema.
+
+ """
+ def visit_field_definition(self, field, object_type):
+ name = f'{field.type}'
+ # field.type contains the return value of the mutation; trim value
+ # to produce canonical name
+ name = name.replace('Result', '', 1)
+
+ func = make_config_file_resolver(name)
+ field.resolve = func
+ return field
diff --git a/src/services/api/graphql/graphql/mutations.py b/src/services/api/graphql/graphql/mutations.py
new file mode 100644
index 000000000..2eb0a0b4a
--- /dev/null
+++ b/src/services/api/graphql/graphql/mutations.py
@@ -0,0 +1,109 @@
+
+from importlib import import_module
+from typing import Any, Dict
+from ariadne import ObjectType, convert_kwargs_to_snake_case, convert_camel_case_to_snake
+from graphql import GraphQLResolveInfo
+from makefun import with_signature
+
+from .. import state
+
+mutation = ObjectType("Mutation")
+
+def make_resolver(mutation_name):
+ """Dynamically generate a resolver for the mutation named in the
+ schema by 'mutation_name'.
+
+ Dynamic generation is provided using the package 'makefun' (via the
+ decorator 'with_signature'), which provides signature-preserving
+ function wrappers; it provides several improvements over, say,
+ functools.wraps.
+
+ :raise Exception:
+ encapsulating ConfigErrors, or internal errors
+ """
+ class_name = mutation_name.replace('create', '', 1).replace('delete', '', 1)
+ func_base_name = convert_camel_case_to_snake(class_name)
+ resolver_name = f'resolve_create_{func_base_name}'
+ func_sig = '(obj: Any, info: GraphQLResolveInfo, data: Dict)'
+
+ @mutation.field(mutation_name)
+ @convert_kwargs_to_snake_case
+ @with_signature(func_sig, func_name=resolver_name)
+ async def func_impl(*args, **kwargs):
+ try:
+ if 'data' not in kwargs:
+ return {
+ "success": False,
+ "errors": ['missing data']
+ }
+
+ data = kwargs['data']
+ session = state.settings['app'].state.vyos_session
+
+ mod = import_module(f'api.graphql.recipes.{func_base_name}')
+ klass = getattr(mod, class_name)
+ k = klass(session, data)
+ k.configure()
+
+ return {
+ "success": True,
+ "data": data
+ }
+ except Exception as error:
+ return {
+ "success": False,
+ "errors": [str(error)]
+ }
+
+ return func_impl
+
+def make_config_file_resolver(mutation_name):
+ op = ''
+ if 'save' in mutation_name:
+ op = 'save'
+ elif 'load' in mutation_name:
+ op = 'load'
+
+ class_name = mutation_name.replace('save', '', 1).replace('load', '', 1)
+ func_base_name = convert_camel_case_to_snake(class_name)
+ resolver_name = f'resolve_{func_base_name}'
+ func_sig = '(obj: Any, info: GraphQLResolveInfo, data: Dict)'
+
+ @mutation.field(mutation_name)
+ @convert_kwargs_to_snake_case
+ @with_signature(func_sig, func_name=resolver_name)
+ async def func_impl(*args, **kwargs):
+ try:
+ if 'data' not in kwargs:
+ return {
+ "success": False,
+ "errors": ['missing data']
+ }
+
+ data = kwargs['data']
+ session = state.settings['app'].state.vyos_session
+
+ mod = import_module(f'api.graphql.recipes.{func_base_name}')
+ klass = getattr(mod, class_name)
+ k = klass(session, data)
+ if op == 'save':
+ k.save()
+ elif op == 'load':
+ k.load()
+ else:
+ return {
+ "success": False,
+ "errors": ["Input must be saveConfigFile | loadConfigFile"]
+ }
+
+ return {
+ "success": True,
+ "data": data
+ }
+ except Exception as error:
+ return {
+ "success": False,
+ "errors": [str(error)]
+ }
+
+ return func_impl
diff --git a/src/services/api/graphql/graphql/schema/config_file.graphql b/src/services/api/graphql/graphql/schema/config_file.graphql
new file mode 100644
index 000000000..3096cf743
--- /dev/null
+++ b/src/services/api/graphql/graphql/schema/config_file.graphql
@@ -0,0 +1,27 @@
+input saveConfigFileInput {
+ fileName: String
+}
+
+type saveConfigFile {
+ fileName: String
+}
+
+type saveConfigFileResult {
+ data: saveConfigFile
+ success: Boolean!
+ errors: [String]
+}
+
+input loadConfigFileInput {
+ fileName: String!
+}
+
+type loadConfigFile {
+ fileName: String!
+}
+
+type loadConfigFileResult {
+ data: loadConfigFile
+ success: Boolean!
+ errors: [String]
+}
diff --git a/src/services/api/graphql/graphql/schema/dhcp_server.graphql b/src/services/api/graphql/graphql/schema/dhcp_server.graphql
new file mode 100644
index 000000000..9f741a0a5
--- /dev/null
+++ b/src/services/api/graphql/graphql/schema/dhcp_server.graphql
@@ -0,0 +1,35 @@
+input dhcpServerConfigInput {
+ sharedNetworkName: String
+ subnet: String
+ defaultRouter: String
+ nameServer: String
+ domainName: String
+ lease: Int
+ range: Int
+ start: String
+ stop: String
+ dnsForwardingAllowFrom: String
+ dnsForwardingCacheSize: Int
+ dnsForwardingListenAddress: String
+}
+
+type dhcpServerConfig {
+ sharedNetworkName: String
+ subnet: String
+ defaultRouter: String
+ nameServer: String
+ domainName: String
+ lease: Int
+ range: Int
+ start: String
+ stop: String
+ dnsForwardingAllowFrom: String
+ dnsForwardingCacheSize: Int
+ dnsForwardingListenAddress: String
+}
+
+type createDhcpServerResult {
+ data: dhcpServerConfig
+ success: Boolean!
+ errors: [String]
+}
diff --git a/src/services/api/graphql/graphql/schema/interface_ethernet.graphql b/src/services/api/graphql/graphql/schema/interface_ethernet.graphql
new file mode 100644
index 000000000..fdcf97bad
--- /dev/null
+++ b/src/services/api/graphql/graphql/schema/interface_ethernet.graphql
@@ -0,0 +1,18 @@
+input interfaceEthernetConfigInput {
+ interface: String
+ address: String
+ replace: Boolean = true
+ description: String
+}
+
+type interfaceEthernetConfig {
+ interface: String
+ address: String
+ description: String
+}
+
+type createInterfaceEthernetResult {
+ data: interfaceEthernetConfig
+ success: Boolean!
+ errors: [String]
+}
diff --git a/src/services/api/graphql/graphql/schema/schema.graphql b/src/services/api/graphql/graphql/schema/schema.graphql
new file mode 100644
index 000000000..70fe0d726
--- /dev/null
+++ b/src/services/api/graphql/graphql/schema/schema.graphql
@@ -0,0 +1,18 @@
+schema {
+ query: Query
+ mutation: Mutation
+}
+
+type Query {
+ _dummy: String
+}
+
+directive @generate on FIELD_DEFINITION
+directive @configfile on FIELD_DEFINITION
+
+type Mutation {
+ createDhcpServer(data: dhcpServerConfigInput) : createDhcpServerResult @generate
+ createInterfaceEthernet(data: interfaceEthernetConfigInput) : createInterfaceEthernetResult @generate
+ saveConfigFile(data: saveConfigFileInput) : saveConfigFileResult @configfile
+ loadConfigFile(data: loadConfigFileInput) : loadConfigFileResult @configfile
+}
diff --git a/src/services/api/graphql/recipes/__init__.py b/src/services/api/graphql/recipes/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/services/api/graphql/recipes/__init__.py
diff --git a/src/services/api/graphql/recipes/config_file.py b/src/services/api/graphql/recipes/config_file.py
new file mode 100644
index 000000000..850e5326e
--- /dev/null
+++ b/src/services/api/graphql/recipes/config_file.py
@@ -0,0 +1,16 @@
+
+from . recipe import Recipe
+
+class ConfigFile(Recipe):
+ def __init__(self, session, command_file):
+ super().__init__(session, command_file)
+
+ # Define any custom processing of parameters here by overriding
+ # save/load:
+ #
+ # def save(self):
+ # self.data = transform_data(self.data)
+ # super().save()
+ # def load(self):
+ # self.data = transform_data(self.data)
+ # super().load()
diff --git a/src/services/api/graphql/recipes/dhcp_server.py b/src/services/api/graphql/recipes/dhcp_server.py
new file mode 100644
index 000000000..3edb3028e
--- /dev/null
+++ b/src/services/api/graphql/recipes/dhcp_server.py
@@ -0,0 +1,13 @@
+
+from . recipe import Recipe
+
+class DhcpServer(Recipe):
+ def __init__(self, session, command_file):
+ super().__init__(session, command_file)
+
+ # Define any custom processing of parameters here by overriding
+ # configure:
+ #
+ # def configure(self):
+ # self.data = transform_data(self.data)
+ # super().configure()
diff --git a/src/services/api/graphql/recipes/interface_ethernet.py b/src/services/api/graphql/recipes/interface_ethernet.py
new file mode 100644
index 000000000..f88f5924f
--- /dev/null
+++ b/src/services/api/graphql/recipes/interface_ethernet.py
@@ -0,0 +1,13 @@
+
+from . recipe import Recipe
+
+class InterfaceEthernet(Recipe):
+ def __init__(self, session, command_file):
+ super().__init__(session, command_file)
+
+ # Define any custom processing of parameters here by overriding
+ # configure:
+ #
+ # def configure(self):
+ # self.data = transform_data(self.data)
+ # super().configure()
diff --git a/src/services/api/graphql/recipes/recipe.py b/src/services/api/graphql/recipes/recipe.py
new file mode 100644
index 000000000..91d8bd67a
--- /dev/null
+++ b/src/services/api/graphql/recipes/recipe.py
@@ -0,0 +1,68 @@
+from ariadne import convert_camel_case_to_snake
+import vyos.defaults
+from vyos.template import render
+
+class Recipe(object):
+ def __init__(self, session, data):
+ self._session = session
+ self.data = data
+ self._name = convert_camel_case_to_snake(type(self).__name__)
+
+ @property
+ def data(self):
+ return self.__data
+
+ @data.setter
+ def data(self, data):
+ if isinstance(data, dict):
+ self.__data = data
+ else:
+ raise ValueError("data must be of type dict")
+
+ def configure(self):
+ session = self._session
+ data = self.data
+ func_base_name = self._name
+
+ tmpl_file = f'{func_base_name}.tmpl'
+ cmd_file = f'/tmp/{func_base_name}.cmds'
+ tmpl_dir = vyos.defaults.directories['api_templates']
+
+ try:
+ render(cmd_file, tmpl_file, data, location=tmpl_dir)
+ commands = []
+ with open(cmd_file) as f:
+ lines = f.readlines()
+ for line in lines:
+ commands.append(line.split())
+ for cmd in commands:
+ if cmd[0] == 'set':
+ session.set(cmd[1:])
+ elif cmd[0] == 'delete':
+ session.delete(cmd[1:])
+ else:
+ raise ValueError('Operation must be "set" or "delete"')
+ session.commit()
+ except Exception as error:
+ raise error
+
+ def save(self):
+ session = self._session
+ data = self.data
+ if 'file_name' not in data or not data['file_name']:
+ data['file_name'] = '/config/config.boot'
+
+ try:
+ session.save_config(data['file_name'])
+ except Exception as error:
+ raise error
+
+ def load(self):
+ session = self._session
+ data = self.data
+
+ try:
+ session.load_config(data['file_name'])
+ session.commit()
+ except Exception as error:
+ raise error
diff --git a/src/services/api/graphql/recipes/templates/dhcp_server.tmpl b/src/services/api/graphql/recipes/templates/dhcp_server.tmpl
new file mode 100644
index 000000000..70de43183
--- /dev/null
+++ b/src/services/api/graphql/recipes/templates/dhcp_server.tmpl
@@ -0,0 +1,9 @@
+set service dhcp-server shared-network-name {{ shared_network_name }} subnet {{ subnet }} default-router {{ default_router }}
+set service dhcp-server shared-network-name {{ shared_network_name }} subnet {{ subnet }} name-server {{ name_server }}
+set service dhcp-server shared-network-name {{ shared_network_name }} subnet {{ subnet }} domain-name {{ domain_name }}
+set service dhcp-server shared-network-name {{ shared_network_name }} subnet {{ subnet }} lease {{ lease }}
+set service dhcp-server shared-network-name {{ shared_network_name }} subnet {{ subnet }} range {{ range }} start {{ start }}
+set service dhcp-server shared-network-name {{ shared_network_name }} subnet {{ subnet }} range {{ range }} stop {{ stop }}
+set service dns forwarding allow-from {{ dns_forwarding_allow_from }}
+set service dns forwarding cache-size {{ dns_forwarding_cache_size }}
+set service dns forwarding listen-address {{ dns_forwarding_listen_address }}
diff --git a/src/services/api/graphql/recipes/templates/interface_ethernet.tmpl b/src/services/api/graphql/recipes/templates/interface_ethernet.tmpl
new file mode 100644
index 000000000..d9d7ed691
--- /dev/null
+++ b/src/services/api/graphql/recipes/templates/interface_ethernet.tmpl
@@ -0,0 +1,5 @@
+{% if replace %}
+delete interfaces ethernet {{ interface }} address
+{% endif %}
+set interfaces ethernet {{ interface }} address {{ address }}
+set interfaces ethernet {{ interface }} description {{ description }}
diff --git a/src/services/api/graphql/state.py b/src/services/api/graphql/state.py
new file mode 100644
index 000000000..63db9f4ef
--- /dev/null
+++ b/src/services/api/graphql/state.py
@@ -0,0 +1,4 @@
+
+def init():
+ global settings
+ settings = {}
diff --git a/src/services/vyos-http-api-server b/src/services/vyos-http-api-server
index 703628558..aa7ac6708 100755
--- a/src/services/vyos-http-api-server
+++ b/src/services/vyos-http-api-server
@@ -1,6 +1,6 @@
-#!/usr/bin/env python3
+#!/usr/share/vyos-http-api-tools/bin/python3
#
-# Copyright (C) 2019 VyOS maintainers and contributors
+# Copyright (C) 2019-2021 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
@@ -19,25 +19,43 @@
import os
import sys
import grp
+import copy
import json
+import logging
import traceback
import threading
-import signal
+from typing import List, Union, Callable, Dict
-import vyos.config
-
-from flask import Flask, request
-from waitress import serve
+import uvicorn
+from fastapi import FastAPI, Depends, Request, Response, HTTPException
+from fastapi.responses import HTMLResponse
+from fastapi.exceptions import RequestValidationError
+from fastapi.routing import APIRoute
+from pydantic import BaseModel, StrictStr, validator
+from starlette.datastructures import FormData
+from starlette.formparsers import FormParser, MultiPartParser
+from multipart.multipart import parse_options_header
-from functools import wraps
+from ariadne.asgi import GraphQL
+import vyos.config
from vyos.configsession import ConfigSession, ConfigSessionError
+import api.graphql.state
DEFAULT_CONFIG_FILE = '/etc/vyos/http-api.conf'
CFG_GROUP = 'vyattacfg'
-app = Flask(__name__)
+debug = True
+
+logger = logging.getLogger(__name__)
+logs_handler = logging.StreamHandler()
+logger.addHandler(logs_handler)
+
+if debug:
+ logger.setLevel(logging.DEBUG)
+else:
+ logger.setLevel(logging.INFO)
# Giant lock!
lock = threading.Lock()
@@ -48,63 +66,347 @@ def load_server_config():
return config
def check_auth(key_list, key):
- id = None
+ key_id = None
for k in key_list:
if k['key'] == key:
- id = k['id']
- return id
+ key_id = k['id']
+ return key_id
def error(code, msg):
resp = {"success": False, "error": msg, "data": None}
- return json.dumps(resp), code
+ resp = json.dumps(resp)
+ return HTMLResponse(resp, status_code=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']
+ resp = json.dumps(resp)
+ return HTMLResponse(resp)
+
+# Pydantic models for validation
+# Pydantic will cast when possible, so use StrictStr
+# validators added as needed for additional constraints
+# schema_extra adds anotations to OpenAPI, to add examples
+
+class ApiModel(BaseModel):
+ key: StrictStr
+
+class BaseConfigureModel(BaseModel):
+ op: StrictStr
+ path: List[StrictStr]
+ value: StrictStr = None
+
+ @validator("path", pre=True, always=True)
+ def check_non_empty(cls, path):
+ assert len(path) > 0
+ return path
+
+class ConfigureModel(ApiModel):
+ op: StrictStr
+ path: List[StrictStr]
+ value: StrictStr = None
+
+ @validator("path", pre=True, always=True)
+ def check_non_empty(cls, path):
+ assert len(path) > 0
+ return path
+
+ class Config:
+ schema_extra = {
+ "example": {
+ "key": "id_key",
+ "op": "set | delete | comment",
+ "path": ['config', 'mode', 'path'],
+ }
+ }
+
+class ConfigureListModel(ApiModel):
+ commands: List[BaseConfigureModel]
+
+ class Config:
+ schema_extra = {
+ "example": {
+ "key": "id_key",
+ "commands": "list of commands",
+ }
+ }
+
+class RetrieveModel(ApiModel):
+ op: StrictStr
+ path: List[StrictStr]
+ configFormat: StrictStr = None
+
+ class Config:
+ schema_extra = {
+ "example": {
+ "key": "id_key",
+ "op": "returnValue | returnValues | exists | showConfig",
+ "path": ['config', 'mode', 'path'],
+ "configFormat": "json (default) | json_ast | raw",
+
+ }
+ }
+
+class ConfigFileModel(ApiModel):
+ op: StrictStr
+ file: StrictStr = None
+
+ class Config:
+ schema_extra = {
+ "example": {
+ "key": "id_key",
+ "op": "save | load",
+ "file": "filename",
+ }
+ }
+
+class ImageModel(ApiModel):
+ op: StrictStr
+ url: StrictStr = None
+ name: StrictStr = None
+
+ class Config:
+ schema_extra = {
+ "example": {
+ "key": "id_key",
+ "op": "add | delete",
+ "url": "imagelocation",
+ "name": "imagename",
+ }
+ }
+
+class GenerateModel(ApiModel):
+ op: StrictStr
+ path: List[StrictStr]
+
+ class Config:
+ schema_extra = {
+ "example": {
+ "key": "id_key",
+ "op": "generate",
+ "path": ["op", "mode", "path"],
+ }
+ }
+
+class ShowModel(ApiModel):
+ op: StrictStr
+ path: List[StrictStr]
+
+ class Config:
+ schema_extra = {
+ "example": {
+ "key": "id_key",
+ "op": "show",
+ "path": ["op", "mode", "path"],
+ }
+ }
+
+class Success(BaseModel):
+ success: bool
+ data: Union[str, bool, Dict]
+ error: str
+
+class Error(BaseModel):
+ success: bool = False
+ data: Union[str, bool, Dict]
+ error: str
+
+responses = {
+ 200: {'model': Success},
+ 400: {'model': Error},
+ 422: {'model': Error, 'description': 'Validation Error'},
+ 500: {'model': Error}
+}
+
+def auth_required(data: ApiModel):
+ key = data.key
+ api_keys = app.state.vyos_keys
+ key_id = check_auth(api_keys, key)
+ if not key_id:
+ raise HTTPException(status_code=401, detail="Valid API key is required")
+ app.state.vyos_id = key_id
+
+# override Request and APIRoute classes in order to convert form request to json;
+# do all explicit validation here, for backwards compatability of error messages;
+# the explicit validation may be dropped, if desired, in favor of native
+# validation by FastAPI/Pydantic, as is used for application/json requests
+class MultipartRequest(Request):
+ ERR_MISSING_KEY = False
+ ERR_MISSING_DATA = False
+ ERR_NOT_JSON = False
+ ERR_NOT_DICT = False
+ ERR_NO_OP = False
+ ERR_NO_PATH = False
+ ERR_EMPTY_PATH = False
+ ERR_PATH_NOT_LIST = False
+ ERR_VALUE_NOT_STRING = False
+ ERR_PATH_NOT_LIST_OF_STR = False
+ offending_command = {}
+ exception = None
+
+ @property
+ def orig_headers(self):
+ self._orig_headers = super().headers
+ return self._orig_headers
+
+ @property
+ def headers(self):
+ self._headers = super().headers.mutablecopy()
+ self._headers['content-type'] = 'application/json'
+ return self._headers
+
+ async def form(self) -> FormData:
+ if not hasattr(self, "_form"):
+ assert (
+ parse_options_header is not None
+ ), "The `python-multipart` library must be installed to use form parsing."
+ content_type_header = self.orig_headers.get("Content-Type")
+ content_type, options = parse_options_header(content_type_header)
+ if content_type == b"multipart/form-data":
+ multipart_parser = MultiPartParser(self.orig_headers, self.stream())
+ self._form = await multipart_parser.parse()
+ elif content_type == b"application/x-www-form-urlencoded":
+ form_parser = FormParser(self.orig_headers, self.stream())
+ self._form = await form_parser.parse()
+ else:
+ self._form = FormData()
+ return self._form
+
+ async def body(self) -> bytes:
+ if not hasattr(self, "_body"):
+ forms = {}
+ merge = {}
+ body = await super().body()
+ self._body = body
+
+ form_data = await self.form()
+ if form_data:
+ logger.debug("processing form data")
+ for k, v in form_data.multi_items():
+ forms[k] = v
+
+ if 'data' not in forms:
+ self.ERR_MISSING_DATA = True
+ else:
+ try:
+ tmp = json.loads(forms['data'])
+ except json.JSONDecodeError as e:
+ self.ERR_NOT_JSON = True
+ self.exception = e
+ tmp = {}
+ if isinstance(tmp, list):
+ merge['commands'] = tmp
+ else:
+ merge = tmp
+
+ if 'commands' in merge:
+ cmds = merge['commands']
+ else:
+ cmds = copy.deepcopy(merge)
+ cmds = [cmds]
+
+ for c in cmds:
+ if not isinstance(c, dict):
+ self.ERR_NOT_DICT = True
+ self.offending_command = c
+ elif 'op' not in c:
+ self.ERR_NO_OP = True
+ self.offending_command = c
+ elif 'path' not in c:
+ self.ERR_NO_PATH = True
+ self.offending_command = c
+ elif not c['path']:
+ self.ERR_EMPTY_PATH = True
+ self.offending_command = c
+ elif not isinstance(c['path'], list):
+ self.ERR_PATH_NOT_LIST = True
+ self.offending_command = c
+ elif not all(isinstance(el, str) for el in c['path']):
+ self.ERR_PATH_NOT_LIST_OF_STR = True
+ self.offending_command = c
+ elif 'value' in c and not isinstance(c['value'], str):
+ self.ERR_VALUE_NOT_STRING = True
+ self.offending_command = c
+
+ if 'key' not in forms and 'key' not in merge:
+ self.ERR_MISSING_KEY = True
+ if 'key' in forms and 'key' not in merge:
+ merge['key'] = forms['key']
+
+ new_body = json.dumps(merge)
+ new_body = new_body.encode()
+ self._body = new_body
+
+ return self._body
+
+class MultipartRoute(APIRoute):
+ def get_route_handler(self) -> Callable:
+ original_route_handler = super().get_route_handler()
+
+ async def custom_route_handler(request: Request) -> Response:
+ request = MultipartRequest(request.scope, request.receive)
+ endpoint = request.url.path
+ try:
+ response: Response = await original_route_handler(request)
+ except HTTPException as e:
+ return error(e.status_code, e.detail)
+ except Exception as e:
+ if request.ERR_MISSING_KEY:
+ return error(422, "Valid API key is required")
+ if request.ERR_MISSING_DATA:
+ return error(422, "Non-empty data field is required")
+ if request.ERR_NOT_JSON:
+ return error(400, "Failed to parse JSON: {0}".format(request.exception))
+ if endpoint == '/configure':
+ if request.ERR_NOT_DICT:
+ return error(400, "Malformed command \"{0}\": any command must be a dict".format(json.dumps(request.offending_command)))
+ if request.ERR_NO_OP:
+ return error(400, "Malformed command \"{0}\": missing \"op\" field".format(json.dumps(request.offending_command)))
+ if request.ERR_NO_PATH:
+ return error(400, "Malformed command \"{0}\": missing \"path\" field".format(json.dumps(request.offending_command)))
+ if request.ERR_EMPTY_PATH:
+ return error(400, "Malformed command \"{0}\": empty path".format(json.dumps(request.offending_command)))
+ if request.ERR_PATH_NOT_LIST:
+ return error(400, "Malformed command \"{0}\": \"path\" field must be a list".format(json.dumps(request.offending_command)))
+ if request.ERR_VALUE_NOT_STRING:
+ return error(400, "Malformed command \"{0}\": \"value\" field must be a string".format(json.dumps(request.offending_command)))
+ if request.ERR_PATH_NOT_LIST_OF_STR:
+ return error(400, "Malformed command \"{0}\": \"path\" field must be a list of strings".format(json.dumps(request.offending_command)))
+ if endpoint in ('/retrieve','/generate','/show'):
+ if request.ERR_NO_OP or request.ERR_NO_PATH:
+ return error(400, "Missing required field. \"op\" and \"path\" fields are required")
+ if endpoint in ('/config-file', '/image'):
+ if request.ERR_NO_OP:
+ return error(400, "Missing required field \"op\"")
+
+ raise e
+
+ return response
+
+ return custom_route_handler
+
+app = FastAPI(debug=True,
+ title="VyOS API",
+ version="0.1.0",
+ responses={**responses},
+ dependencies=[Depends(auth_required)])
+
+app.router.route_class = MultipartRoute
+
+@app.exception_handler(RequestValidationError)
+async def validation_exception_handler(request, exc):
+ return error(400, str(exc.errors()[0]))
+
+@app.post('/configure')
+def configure_op(data: Union[ConfigureModel, ConfigureListModel]):
+ session = app.state.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]
+ if not isinstance(data, ConfigureListModel):
+ data = [data]
+ else:
+ data = data.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,
@@ -114,53 +416,25 @@ def configure_op(commands):
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)))
+ for c in data:
+ op = c.op
+ path = c.path
- # Account for the case when value field is present and set to null
- if not value:
+ if c.value:
+ value = c.value
+ else:
value = ""
- # For vyos.configsessios calls that have no separate value arguments,
+ # For vyos.configsession 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)))
+ cfg_path = " ".join(path + [value]).strip()
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):
+ if app.state.vyos_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':
@@ -169,16 +443,16 @@ def configure_op(commands):
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))
+ logger.info(f"Configuration modified via HTTP API using key '{app.state.vyos_id}'")
except ConfigSessionError as e:
session.discard()
status = 400
- if app.config['vyos_debug']:
- print(traceback.format_exc(), file=sys.stderr)
+ if app.state.vyos_debug:
+ logger.critical(f"ConfigSessionError:\n {traceback.format_exc()}")
error_msg = str(e)
except Exception as e:
session.discard()
- print(traceback.format_exc(), file=sys.stderr)
+ logger.critical(traceback.format_exc())
status = 500
# Don't give the details away to the outer world
@@ -188,22 +462,17 @@ def configure_op(commands):
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']
+ return success(None)
+
+@app.post("/retrieve")
+def retrieve_op(data: RetrieveModel):
+ session = app.state.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")
+ op = data.op
+ path = " ".join(data.path)
try:
if op == 'returnValue':
@@ -214,10 +483,10 @@ def retrieve_op(command):
res = config.exists(path)
elif op == 'showConfig':
config_format = 'json'
- if 'configFormat' in command:
- config_format = command['configFormat']
+ if data.configFormat:
+ config_format = data.configFormat
- res = session.show_config(path=command['path'])
+ res = session.show_config(path=data.path)
if config_format == 'json':
config_tree = vyos.configtree.ConfigTree(res)
res = json.loads(config_tree.to_json())
@@ -233,33 +502,28 @@ def retrieve_op(command):
except ConfigSessionError as e:
return error(400, str(e))
except Exception as e:
- print(traceback.format_exc(), file=sys.stderr)
+ logger.critical(traceback.format_exc())
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']
+@app.post('/config-file')
+def config_file_op(data: ConfigFileModel):
+ session = app.state.vyos_session
- try:
- op = command['op']
- except KeyError:
- return error(400, "Missing required field \"op\"")
+ op = data.op
try:
if op == 'save':
- try:
- path = command['file']
- except KeyError:
+ if data.file:
+ path = data.file
+ else:
path = '/config/config.boot'
res = session.save_config(path)
elif op == 'load':
- try:
- path = command['file']
- except KeyError:
+ if data.file:
+ path = data.file
+ else:
return error(400, "Missing required field \"file\"")
res = session.migrate_and_load_config(path)
res = session.commit()
@@ -268,33 +532,28 @@ def config_file_op(command):
except ConfigSessionError as e:
return error(400, str(e))
except Exception as e:
- print(traceback.format_exc(), file=sys.stderr)
+ logger.critical(traceback.format_exc())
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']
+@app.post('/image')
+def image_op(data: ImageModel):
+ session = app.state.vyos_session
- try:
- op = command['op']
- except KeyError:
- return error(400, "Missing required field \"op\"")
+ op = data.op
try:
if op == 'add':
- try:
- url = command['url']
- except KeyError:
+ if data.url:
+ url = data.url
+ else:
return error(400, "Missing required field \"url\"")
res = session.install_image(url)
elif op == 'delete':
- try:
- name = command['name']
- except KeyError:
+ if data.name:
+ name = data.name
+ else:
return error(400, "Missing required field \"name\"")
res = session.remove_image(name)
else:
@@ -302,26 +561,17 @@ def image_op(command):
except ConfigSessionError as e:
return error(400, str(e))
except Exception as e:
- print(traceback.format_exc(), file=sys.stderr)
+ logger.critical(traceback.format_exc())
return error(500, "An internal error occured. Check the logs for details.")
return success(res)
+@app.post('/generate')
+def generate_op(data: GenerateModel):
+ session = app.state.vyos_session
-@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")
+ op = data.op
+ path = data.path
try:
if op == 'generate':
@@ -331,25 +581,17 @@ def generate_op(command):
except ConfigSessionError as e:
return error(400, str(e))
except Exception as e:
- print(traceback.format_exc(), file=sys.stderr)
+ logger.critical(traceback.format_exc())
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']
+@app.post('/show')
+def show_op(data: ShowModel):
+ session = app.state.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")
+ op = data.op
+ path = data.path
try:
if op == 'show':
@@ -359,13 +601,24 @@ def show_op(command):
except ConfigSessionError as e:
return error(400, str(e))
except Exception as e:
- print(traceback.format_exc(), file=sys.stderr)
+ logger.critical(traceback.format_exc())
return error(500, "An internal error occured. Check the logs for details.")
return success(res)
-def shutdown():
- raise KeyboardInterrupt
+###
+# GraphQL integration
+###
+
+from api.graphql.bindings import generate_schema
+
+api.graphql.state.init()
+
+schema = generate_schema()
+
+app.add_route('/graphql', GraphQL(schema, debug=True))
+
+###
if __name__ == '__main__':
# systemd's user and group options don't work, do it by hand here,
@@ -379,22 +632,23 @@ if __name__ == '__main__':
try:
server_config = load_server_config()
- except Exception as e:
- print("Failed to load the HTTP API server config: {0}".format(e))
+ except Exception as err:
+ logger.critical(f"Failed to load the HTTP API server config: {err}")
- session = ConfigSession(os.getpid())
+ config_session = ConfigSession(os.getpid())
- app.config['vyos_session'] = session
- app.config['vyos_keys'] = server_config['api_keys']
- app.config['vyos_debug'] = server_config['debug']
+ app.state.vyos_session = config_session
+ app.state.vyos_keys = server_config['api_keys']
- def sig_handler(signum, frame):
- shutdown()
+ app.state.vyos_debug = bool(server_config['debug'] == 'true')
+ app.state.vyos_strict = bool(server_config['strict'] == 'true')
- signal.signal(signal.SIGTERM, sig_handler)
+ api.graphql.state.settings['app'] = app
try:
- serve(app, host=server_config["listen_address"],
- port=server_config["port"])
- except OSError as e:
- print(f"OSError {e}")
+ uvicorn.run(app, host=server_config["listen_address"],
+ port=int(server_config["port"]),
+ proxy_headers=True)
+ except OSError as err:
+ logger.critical(f"OSError {err}")
+ sys.exit(1)