diff options
Diffstat (limited to 'src')
25 files changed, 563 insertions, 213 deletions
diff --git a/src/conf_mode/http-api.py b/src/conf_mode/http-api.py index c196e272b..be80613c6 100755 --- a/src/conf_mode/http-api.py +++ b/src/conf_mode/http-api.py @@ -86,7 +86,7 @@ def get_config(config=None): if 'api_keys' in api_dict: keys_added = True - if 'gql' in api_dict: + if 'graphql' in api_dict: api_dict = dict_merge(defaults(base), api_dict) http_api.update(api_dict) diff --git a/src/conf_mode/nat.py b/src/conf_mode/nat.py index 8b1a5a720..978c043e9 100755 --- a/src/conf_mode/nat.py +++ b/src/conf_mode/nat.py @@ -146,6 +146,10 @@ def verify(nat): if config['outbound_interface'] not in 'any' and config['outbound_interface'] not in interfaces(): Warning(f'rule "{rule}" interface "{config["outbound_interface"]}" does not exist on this system') + if not dict_search('translation.address', config) and not dict_search('translation.port', config): + if 'exclude' not in config: + raise ConfigError(f'{err_msg} translation requires address and/or port') + addr = dict_search('translation.address', config) if addr != None and addr != 'masquerade' and not is_ip_network(addr): for ip in addr.split('-'): @@ -166,6 +170,10 @@ def verify(nat): elif config['inbound_interface'] not in 'any' and config['inbound_interface'] not in interfaces(): Warning(f'rule "{rule}" interface "{config["inbound_interface"]}" does not exist on this system') + if not dict_search('translation.address', config) and not dict_search('translation.port', config): + if 'exclude' not in config: + raise ConfigError(f'{err_msg} translation requires address and/or port') + # common rule verification verify_rule(config, err_msg) @@ -204,6 +212,10 @@ def apply(nat): cmd(f'nft -f {nftables_nat_config}') cmd(f'nft -f {nftables_static_nat_conf}') + if not nat or 'deleted' in nat: + os.unlink(nftables_nat_config) + os.unlink(nftables_static_nat_conf) + return None if __name__ == '__main__': diff --git a/src/migration-scripts/https/3-to-4 b/src/migration-scripts/https/3-to-4 new file mode 100755 index 000000000..5ee528b31 --- /dev/null +++ b/src/migration-scripts/https/3-to-4 @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 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/>. + +# T4768 rename node 'gql' to 'graphql'. + +import sys + +from vyos.configtree import ConfigTree + +if (len(sys.argv) < 2): + print("Must specify file name!") + sys.exit(1) + +file_name = sys.argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +config = ConfigTree(config_file) + +old_base = ['service', 'https', 'api', 'gql'] +if not config.exists(old_base): + # Nothing to do + sys.exit(0) + +new_base = ['service', 'https', 'api', 'graphql'] +config.set(new_base) + +nodes = config.list_nodes(old_base) +for node in nodes: + config.copy(old_base + [node], new_base + [node]) + +config.delete(old_base) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + sys.exit(1) diff --git a/src/op_mode/route.py b/src/op_mode/route.py index e1eee5bbf..d11b00ba0 100755 --- a/src/op_mode/route.py +++ b/src/op_mode/route.py @@ -83,7 +83,12 @@ def show(raw: bool, if raw: from json import loads - return loads(output) + d = loads(output) + collect = [] + for k,_ in d.items(): + for l in d[k]: + collect.append(l) + return collect else: return output diff --git a/src/services/api/graphql/__init__.py b/src/services/api/graphql/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/services/api/graphql/__init__.py diff --git a/src/services/api/graphql/bindings.py b/src/services/api/graphql/bindings.py index 0b1260912..aa1ba0eb0 100644 --- a/src/services/api/graphql/bindings.py +++ b/src/services/api/graphql/bindings.py @@ -18,16 +18,26 @@ from . graphql.queries import query from . graphql.mutations import mutation from . graphql.directives import directives_dict from . graphql.errors import op_mode_error -from . utils.schema_from_op_mode import generate_op_mode_definitions +from . graphql.auth_token_mutation import auth_token_mutation +from . generate.schema_from_op_mode import generate_op_mode_definitions +from . generate.schema_from_config_session import generate_config_session_definitions +from . generate.schema_from_composite import generate_composite_definitions +from . libs.token_auth import init_secret +from . import state 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'] generate_op_mode_definitions() + generate_config_session_definitions() + generate_composite_definitions() + + if state.settings['app'].state.vyos_auth_type == 'token': + init_secret() type_defs = load_schema_from_path(api_schema_dir) - schema = make_executable_schema(type_defs, query, op_mode_error, mutation, snake_case_fallback_resolvers, directives=directives_dict) + schema = make_executable_schema(type_defs, query, op_mode_error, mutation, auth_token_mutation, snake_case_fallback_resolvers, directives=directives_dict) return schema diff --git a/src/services/api/graphql/utils/composite_function.py b/src/services/api/graphql/generate/composite_function.py index bc9d80fbb..bc9d80fbb 100644 --- a/src/services/api/graphql/utils/composite_function.py +++ b/src/services/api/graphql/generate/composite_function.py diff --git a/src/services/api/graphql/utils/config_session_function.py b/src/services/api/graphql/generate/config_session_function.py index fc0dd7a87..fc0dd7a87 100644 --- a/src/services/api/graphql/utils/config_session_function.py +++ b/src/services/api/graphql/generate/config_session_function.py diff --git a/src/services/api/graphql/utils/schema_from_composite.py b/src/services/api/graphql/generate/schema_from_composite.py index f9983cd98..61a08cb2f 100755 --- a/src/services/api/graphql/utils/schema_from_composite.py +++ b/src/services/api/graphql/generate/schema_from_composite.py @@ -19,28 +19,60 @@ # composite functions comprising several requests. import os +import sys import json from inspect import signature, getmembers, isfunction, isclass, getmro from jinja2 import Template +from vyos.defaults import directories if __package__ is None or __package__ == '': - from util import snake_to_pascal_case, map_type_name + sys.path.append("/usr/libexec/vyos/services/api") + from graphql.libs.op_mode import snake_to_pascal_case, map_type_name + from composite_function import queries, mutations + from vyos.config import Config + from vyos.configdict import dict_merge + from vyos.xml import defaults else: - from . util import snake_to_pascal_case, map_type_name + from .. libs.op_mode import snake_to_pascal_case, map_type_name + from . composite_function import queries, mutations + from .. import state + +SCHEMA_PATH = directories['api_schema'] -# this will be run locally before the build -SCHEMA_PATH = '../graphql/schema' +if __package__ is None or __package__ == '': + # allow running stand-alone + conf = Config() + base = ['service', 'https', 'api'] + graphql_dict = conf.get_config_dict(base, key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + get_first_key=True) + if 'graphql' not in graphql_dict: + exit("graphql is not configured") + + graphql_dict = dict_merge(defaults(base), graphql_dict) + auth_type = graphql_dict['graphql']['authentication']['type'] +else: + auth_type = state.settings['app'].state.vyos_auth_type -schema_data: dict = {'schema_name': '', +schema_data: dict = {'auth_type': auth_type, + 'schema_name': '', 'schema_fields': []} query_template = """ +{%- if auth_type == 'key' %} input {{ schema_name }}Input { key: String! {%- for field_entry in schema_fields %} {{ field_entry }} {%- endfor %} } +{%- elif schema_fields %} +input {{ schema_name }}Input { + {%- for field_entry in schema_fields %} + {{ field_entry }} + {%- endfor %} +} +{%- endif %} type {{ schema_name }} { result: Generic @@ -53,17 +85,29 @@ type {{ schema_name }}Result { } extend type Query { +{%- if auth_type == 'key' or schema_fields %} {{ schema_name }}(data: {{ schema_name }}Input) : {{ schema_name }}Result @compositequery +{%- else %} + {{ schema_name }} : {{ schema_name }}Result @compositequery +{%- endif %} } """ mutation_template = """ +{%- if auth_type == 'key' %} input {{ schema_name }}Input { key: String! {%- for field_entry in schema_fields %} {{ field_entry }} {%- endfor %} } +{%- elif schema_fields %} +input {{ schema_name }}Input { + {%- for field_entry in schema_fields %} + {{ field_entry }} + {%- endfor %} +} +{%- endif %} type {{ schema_name }} { result: Generic @@ -76,7 +120,11 @@ type {{ schema_name }}Result { } extend type Mutation { +{%- if auth_type == 'key' or schema_fields %} {{ schema_name }}(data: {{ schema_name }}Input) : {{ schema_name }}Result @compositemutation +{%- else %} + {{ schema_name }} : {{ schema_name }}Result @compositemutation +{%- endif %} } """ @@ -100,8 +148,6 @@ def create_schema(func_name: str, func: callable, template: str) -> str: return res def generate_composite_definitions(): - from composite_function import queries, mutations - results = [] for name,func in queries.items(): res = create_schema(name, func, query_template) diff --git a/src/services/api/graphql/utils/schema_from_config_session.py b/src/services/api/graphql/generate/schema_from_config_session.py index ea78aaf88..49bf2440e 100755 --- a/src/services/api/graphql/utils/schema_from_config_session.py +++ b/src/services/api/graphql/generate/schema_from_config_session.py @@ -19,28 +19,60 @@ # (wrappers of) native configsession functions. import os +import sys import json from inspect import signature, getmembers, isfunction, isclass, getmro from jinja2 import Template +from vyos.defaults import directories if __package__ is None or __package__ == '': - from util import snake_to_pascal_case, map_type_name + sys.path.append("/usr/libexec/vyos/services/api") + from graphql.libs.op_mode import snake_to_pascal_case, map_type_name + from config_session_function import queries, mutations + from vyos.config import Config + from vyos.configdict import dict_merge + from vyos.xml import defaults else: - from . util import snake_to_pascal_case, map_type_name + from .. libs.op_mode import snake_to_pascal_case, map_type_name + from . config_session_function import queries, mutations + from .. import state + +SCHEMA_PATH = directories['api_schema'] -# this will be run locally before the build -SCHEMA_PATH = '../graphql/schema' +if __package__ is None or __package__ == '': + # allow running stand-alone + conf = Config() + base = ['service', 'https', 'api'] + graphql_dict = conf.get_config_dict(base, key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + get_first_key=True) + if 'graphql' not in graphql_dict: + exit("graphql is not configured") + + graphql_dict = dict_merge(defaults(base), graphql_dict) + auth_type = graphql_dict['graphql']['authentication']['type'] +else: + auth_type = state.settings['app'].state.vyos_auth_type -schema_data: dict = {'schema_name': '', +schema_data: dict = {'auth_type': auth_type, + 'schema_name': '', 'schema_fields': []} query_template = """ +{%- if auth_type == 'key' %} input {{ schema_name }}Input { key: String! {%- for field_entry in schema_fields %} {{ field_entry }} {%- endfor %} } +{%- elif schema_fields %} +input {{ schema_name }}Input { + {%- for field_entry in schema_fields %} + {{ field_entry }} + {%- endfor %} +} +{%- endif %} type {{ schema_name }} { result: Generic @@ -53,17 +85,29 @@ type {{ schema_name }}Result { } extend type Query { +{%- if auth_type == 'key' or schema_fields %} {{ schema_name }}(data: {{ schema_name }}Input) : {{ schema_name }}Result @configsessionquery +{%- else %} + {{ schema_name }} : {{ schema_name }}Result @configsessionquery +{%- endif %} } """ mutation_template = """ +{%- if auth_type == 'key' %} input {{ schema_name }}Input { key: String! {%- for field_entry in schema_fields %} {{ field_entry }} {%- endfor %} } +{%- elif schema_fields %} +input {{ schema_name }}Input { + {%- for field_entry in schema_fields %} + {{ field_entry }} + {%- endfor %} +} +{%- endif %} type {{ schema_name }} { result: Generic @@ -76,7 +120,11 @@ type {{ schema_name }}Result { } extend type Mutation { +{%- if auth_type == 'key' or schema_fields %} {{ schema_name }}(data: {{ schema_name }}Input) : {{ schema_name }}Result @configsessionmutation +{%- else %} + {{ schema_name }} : {{ schema_name }}Result @configsessionmutation +{%- endif %} } """ @@ -100,8 +148,6 @@ def create_schema(func_name: str, func: callable, template: str) -> str: return res def generate_config_session_definitions(): - from config_session_function import queries, mutations - results = [] for name,func in queries.items(): res = create_schema(name, func, query_template) diff --git a/src/services/api/graphql/utils/schema_from_op_mode.py b/src/services/api/graphql/generate/schema_from_op_mode.py index 57d63628b..1fd198a37 100755 --- a/src/services/api/graphql/utils/schema_from_op_mode.py +++ b/src/services/api/graphql/generate/schema_from_op_mode.py @@ -19,17 +19,23 @@ # scripts. import os +import sys import json from inspect import signature, getmembers, isfunction, isclass, getmro from jinja2 import Template from vyos.defaults import directories if __package__ is None or __package__ == '': - from util import load_as_module, is_op_mode_function_name, is_show_function_name - from util import snake_to_pascal_case, map_type_name + sys.path.append("/usr/libexec/vyos/services/api") + from graphql.libs.op_mode import load_as_module, is_op_mode_function_name, is_show_function_name + from graphql.libs.op_mode import snake_to_pascal_case, map_type_name + from vyos.config import Config + from vyos.configdict import dict_merge + from vyos.xml import defaults else: - from . util import load_as_module, is_op_mode_function_name, is_show_function_name - from . util import snake_to_pascal_case, map_type_name + from .. libs.op_mode import load_as_module, is_op_mode_function_name, is_show_function_name + from .. libs.op_mode import snake_to_pascal_case, map_type_name + from .. import state OP_MODE_PATH = directories['op_mode'] SCHEMA_PATH = directories['api_schema'] @@ -38,16 +44,40 @@ DATA_DIR = directories['data'] op_mode_include_file = os.path.join(DATA_DIR, 'op-mode-standardized.json') op_mode_error_schema = 'op_mode_error.graphql' -schema_data: dict = {'schema_name': '', +if __package__ is None or __package__ == '': + # allow running stand-alone + conf = Config() + base = ['service', 'https', 'api'] + graphql_dict = conf.get_config_dict(base, key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + get_first_key=True) + if 'graphql' not in graphql_dict: + exit("graphql is not configured") + + graphql_dict = dict_merge(defaults(base), graphql_dict) + auth_type = graphql_dict['graphql']['authentication']['type'] +else: + auth_type = state.settings['app'].state.vyos_auth_type + +schema_data: dict = {'auth_type': auth_type, + 'schema_name': '', 'schema_fields': []} query_template = """ +{%- if auth_type == 'key' %} input {{ schema_name }}Input { key: String! {%- for field_entry in schema_fields %} {{ field_entry }} {%- endfor %} } +{%- elif schema_fields %} +input {{ schema_name }}Input { + {%- for field_entry in schema_fields %} + {{ field_entry }} + {%- endfor %} +} +{%- endif %} type {{ schema_name }} { result: Generic @@ -61,17 +91,29 @@ type {{ schema_name }}Result { } extend type Query { +{%- if auth_type == 'key' or schema_fields %} {{ schema_name }}(data: {{ schema_name }}Input) : {{ schema_name }}Result @genopquery +{%- else %} + {{ schema_name }} : {{ schema_name }}Result @genopquery +{%- endif %} } """ mutation_template = """ +{%- if auth_type == 'key' %} input {{ schema_name }}Input { key: String! {%- for field_entry in schema_fields %} {{ field_entry }} {%- endfor %} } +{%- elif schema_fields %} +input {{ schema_name }}Input { + {%- for field_entry in schema_fields %} + {{ field_entry }} + {%- endfor %} +} +{%- endif %} type {{ schema_name }} { result: Generic @@ -85,7 +127,11 @@ type {{ schema_name }}Result { } extend type Mutation { +{%- if auth_type == 'key' or schema_fields %} {{ schema_name }}(data: {{ schema_name }}Input) : {{ schema_name }}Result @genopmutation +{%- else %} + {{ schema_name }} : {{ schema_name }}Result @genopquery +{%- endif %} } """ diff --git a/src/services/api/graphql/graphql/auth_token_mutation.py b/src/services/api/graphql/graphql/auth_token_mutation.py new file mode 100644 index 000000000..21ac40094 --- /dev/null +++ b/src/services/api/graphql/graphql/auth_token_mutation.py @@ -0,0 +1,49 @@ +# Copyright 2022 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this library. If not, see <http://www.gnu.org/licenses/>. + +import jwt +import datetime +from typing import Any, Dict +from ariadne import ObjectType, UnionType +from graphql import GraphQLResolveInfo + +from .. libs.token_auth import generate_token +from .. import state + +auth_token_mutation = ObjectType("Mutation") + +@auth_token_mutation.field('AuthToken') +def auth_token_resolver(obj: Any, info: GraphQLResolveInfo, data: Dict): + # non-nullable fields + user = data['username'] + passwd = data['password'] + + secret = state.settings['secret'] + exp_interval = int(state.settings['app'].state.vyos_token_exp) + expiration = (datetime.datetime.now(tz=datetime.timezone.utc) + + datetime.timedelta(seconds=exp_interval)) + + res = generate_token(user, passwd, secret, expiration) + if res: + data['result'] = res + return { + "success": True, + "data": data + } + + return { + "success": False, + "errors": ['token generation failed'] + } diff --git a/src/services/api/graphql/graphql/mutations.py b/src/services/api/graphql/graphql/mutations.py index 32da0eeb7..2778feb69 100644 --- a/src/services/api/graphql/graphql/mutations.py +++ b/src/services/api/graphql/graphql/mutations.py @@ -20,7 +20,7 @@ from graphql import GraphQLResolveInfo from makefun import with_signature from .. import state -from .. import key_auth +from .. libs import key_auth from api.graphql.session.session import Session from api.graphql.session.errors.op_mode_errors import op_mode_err_msg, op_mode_err_code from vyos.opmode import Error as OpModeError @@ -42,32 +42,54 @@ def make_mutation_resolver(mutation_name, class_name, session_func): 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)' + 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'] - key = data['key'] - - auth = key_auth.auth_required(key) - if auth is None: - return { - "success": False, - "errors": ['invalid API key'] - } - - # We are finished with the 'key' entry, and may remove so as to - # pass the rest of data (if any) to function. - del data['key'] + auth_type = state.settings['app'].state.vyos_auth_type + + if auth_type == 'key': + data = kwargs['data'] + key = data['key'] + + auth = key_auth.auth_required(key) + if auth is None: + return { + "success": False, + "errors": ['invalid API key'] + } + + # We are finished with the 'key' entry, and may remove so as to + # pass the rest of data (if any) to function. + del data['key'] + + elif auth_type == 'token': + # there is a subtlety here: with the removal of the key entry, + # some requests will now have empty input, hence no data arg, so + # make it optional in the func_sig. However, it can not be None, + # as the makefun package provides accurate TypeError exceptions; + # hence set it to {}, but now it is a mutable default argument, + # so clear the key 'result', which is added at the end of + # this function. + data = kwargs['data'] + if 'result' in data: + del data['result'] + + info = kwargs['info'] + user = info.context.get('user') + if user is None: + return { + "success": False, + "errors": ['not authenticated'] + } + else: + # AtrributeError will have already been raised if no + # vyos_auth_type; validation and defaultValue ensure it is + # one of the previous cases, so this is never reached. + pass session = state.settings['app'].state.vyos_session diff --git a/src/services/api/graphql/graphql/queries.py b/src/services/api/graphql/graphql/queries.py index 791b0d3e0..9c8a4f064 100644 --- a/src/services/api/graphql/graphql/queries.py +++ b/src/services/api/graphql/graphql/queries.py @@ -20,7 +20,7 @@ from graphql import GraphQLResolveInfo from makefun import with_signature from .. import state -from .. import key_auth +from .. libs import key_auth from api.graphql.session.session import Session from api.graphql.session.errors.op_mode_errors import op_mode_err_msg, op_mode_err_code from vyos.opmode import Error as OpModeError @@ -42,32 +42,54 @@ def make_query_resolver(query_name, class_name, session_func): 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)' + func_sig = '(obj: Any, info: GraphQLResolveInfo, data: Dict = {})' @query.field(query_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'] - key = data['key'] - - auth = key_auth.auth_required(key) - if auth is None: - return { - "success": False, - "errors": ['invalid API key'] - } - - # We are finished with the 'key' entry, and may remove so as to - # pass the rest of data (if any) to function. - del data['key'] + auth_type = state.settings['app'].state.vyos_auth_type + + if auth_type == 'key': + data = kwargs['data'] + key = data['key'] + + auth = key_auth.auth_required(key) + if auth is None: + return { + "success": False, + "errors": ['invalid API key'] + } + + # We are finished with the 'key' entry, and may remove so as to + # pass the rest of data (if any) to function. + del data['key'] + + elif auth_type == 'token': + # there is a subtlety here: with the removal of the key entry, + # some requests will now have empty input, hence no data arg, so + # make it optional in the func_sig. However, it can not be None, + # as the makefun package provides accurate TypeError exceptions; + # hence set it to {}, but now it is a mutable default argument, + # so clear the key 'result', which is added at the end of + # this function. + data = kwargs['data'] + if 'result' in data: + del data['result'] + + info = kwargs['info'] + user = info.context.get('user') + if user is None: + return { + "success": False, + "errors": ['not authenticated'] + } + else: + # AtrributeError will have already been raised if no + # vyos_auth_type; validation and defaultValue ensure it is + # one of the previous cases, so this is never reached. + pass session = state.settings['app'].state.vyos_session diff --git a/src/services/api/graphql/graphql/schema/auth_token.graphql b/src/services/api/graphql/graphql/schema/auth_token.graphql new file mode 100644 index 000000000..af53a293a --- /dev/null +++ b/src/services/api/graphql/graphql/schema/auth_token.graphql @@ -0,0 +1,19 @@ + +input AuthTokenInput { + username: String! + password: String! +} + +type AuthToken { + result: Generic +} + +type AuthTokenResult { + data: AuthToken + success: Boolean! + errors: [String] +} + +extend type Mutation { + AuthToken(data: AuthTokenInput) : AuthTokenResult +} diff --git a/src/services/api/graphql/graphql/schema/composite.graphql b/src/services/api/graphql/graphql/schema/composite.graphql deleted file mode 100644 index 717fbd89d..000000000 --- a/src/services/api/graphql/graphql/schema/composite.graphql +++ /dev/null @@ -1,18 +0,0 @@ - -input SystemStatusInput { - key: String! -} - -type SystemStatus { - result: Generic -} - -type SystemStatusResult { - data: SystemStatus - success: Boolean! - errors: [String] -} - -extend type Query { - SystemStatus(data: SystemStatusInput) : SystemStatusResult @compositequery -}
\ No newline at end of file diff --git a/src/services/api/graphql/graphql/schema/configsession.graphql b/src/services/api/graphql/graphql/schema/configsession.graphql deleted file mode 100644 index b1deac4b3..000000000 --- a/src/services/api/graphql/graphql/schema/configsession.graphql +++ /dev/null @@ -1,115 +0,0 @@ - -input ShowConfigInput { - key: String! - path: [String!]! - configFormat: String = null -} - -type ShowConfig { - result: Generic -} - -type ShowConfigResult { - data: ShowConfig - success: Boolean! - errors: [String] -} - -extend type Query { - ShowConfig(data: ShowConfigInput) : ShowConfigResult @configsessionquery -} - -input ShowInput { - key: String! - path: [String!]! -} - -type Show { - result: Generic -} - -type ShowResult { - data: Show - success: Boolean! - errors: [String] -} - -extend type Query { - Show(data: ShowInput) : ShowResult @configsessionquery -} - -input SaveConfigFileInput { - key: String! - fileName: String = null -} - -type SaveConfigFile { - result: Generic -} - -type SaveConfigFileResult { - data: SaveConfigFile - success: Boolean! - errors: [String] -} - -extend type Mutation { - SaveConfigFile(data: SaveConfigFileInput) : SaveConfigFileResult @configsessionmutation -} - -input LoadConfigFileInput { - key: String! - fileName: String! -} - -type LoadConfigFile { - result: Generic -} - -type LoadConfigFileResult { - data: LoadConfigFile - success: Boolean! - errors: [String] -} - -extend type Mutation { - LoadConfigFile(data: LoadConfigFileInput) : LoadConfigFileResult @configsessionmutation -} - -input AddSystemImageInput { - key: String! - location: String! -} - -type AddSystemImage { - result: Generic -} - -type AddSystemImageResult { - data: AddSystemImage - success: Boolean! - errors: [String] -} - -extend type Mutation { - AddSystemImage(data: AddSystemImageInput) : AddSystemImageResult @configsessionmutation -} - -input DeleteSystemImageInput { - key: String! - name: String! -} - -type DeleteSystemImage { - result: Generic -} - -type DeleteSystemImageResult { - data: DeleteSystemImage - success: Boolean! - errors: [String] -} - -extend type Mutation { - DeleteSystemImage(data: DeleteSystemImageInput) : DeleteSystemImageResult @configsessionmutation -}
\ No newline at end of file diff --git a/src/services/api/graphql/key_auth.py b/src/services/api/graphql/libs/key_auth.py index f756ed6d8..2db0f7d48 100644 --- a/src/services/api/graphql/key_auth.py +++ b/src/services/api/graphql/libs/key_auth.py @@ -1,5 +1,5 @@ -from . import state +from .. import state def check_auth(key_list, key): if not key_list: diff --git a/src/services/api/graphql/utils/util.py b/src/services/api/graphql/libs/op_mode.py index da2bcdb5b..da2bcdb5b 100644 --- a/src/services/api/graphql/utils/util.py +++ b/src/services/api/graphql/libs/op_mode.py diff --git a/src/services/api/graphql/libs/token_auth.py b/src/services/api/graphql/libs/token_auth.py new file mode 100644 index 000000000..3ecd8b855 --- /dev/null +++ b/src/services/api/graphql/libs/token_auth.py @@ -0,0 +1,68 @@ +import jwt +import uuid +import pam +from secrets import token_hex + +from .. import state + +def _check_passwd_pam(username: str, passwd: str) -> bool: + if pam.authenticate(username, passwd): + return True + return False + +def init_secret(): + length = int(state.settings['app'].state.vyos_secret_len) + secret = token_hex(length) + state.settings['secret'] = secret + +def generate_token(user: str, passwd: str, secret: str, exp: int) -> dict: + if user is None or passwd is None: + return {} + if _check_passwd_pam(user, passwd): + app = state.settings['app'] + try: + users = app.state.vyos_token_users + except AttributeError: + app.state.vyos_token_users = {} + users = app.state.vyos_token_users + user_id = uuid.uuid1().hex + payload_data = {'iss': user, 'sub': user_id, 'exp': exp} + secret = state.settings.get('secret') + if secret is None: + return { + "success": False, + "errors": ['failed secret generation'] + } + token = jwt.encode(payload=payload_data, key=secret, algorithm="HS256") + + users |= {user_id: user} + return {'token': token} + +def get_user_context(request): + context = {} + context['request'] = request + context['user'] = None + if 'Authorization' in request.headers: + auth = request.headers['Authorization'] + scheme, token = auth.split() + if scheme.lower() != 'bearer': + return context + + try: + secret = state.settings.get('secret') + payload = jwt.decode(token, secret, algorithms=["HS256"]) + user_id: str = payload.get('sub') + if user_id is None: + return context + except jwt.PyJWTError: + return context + try: + users = state.settings['app'].state.vyos_token_users + except AttributeError: + return context + + user = users.get(user_id) + if user is not None: + context['user'] = user + + return context diff --git a/src/services/api/graphql/session/composite/system_status.py b/src/services/api/graphql/session/composite/system_status.py index 3c1a3d45b..d809f32e3 100755 --- a/src/services/api/graphql/session/composite/system_status.py +++ b/src/services/api/graphql/session/composite/system_status.py @@ -23,7 +23,7 @@ import importlib.util from vyos.defaults import directories -from api.graphql.utils.util import load_op_mode_as_module +from api.graphql.libs.op_mode import load_op_mode_as_module def get_system_version() -> dict: show_version = load_op_mode_as_module('version.py') diff --git a/src/services/api/graphql/session/session.py b/src/services/api/graphql/session/session.py index f990e63d0..c2c1db1df 100644 --- a/src/services/api/graphql/session/session.py +++ b/src/services/api/graphql/session/session.py @@ -24,7 +24,7 @@ from vyos.defaults import directories from vyos.template import render from vyos.opmode import Error as OpModeError -from api.graphql.utils.util import load_op_mode_as_module, split_compound_op_mode_name +from api.graphql.libs.op_mode import load_op_mode_as_module, split_compound_op_mode_name op_mode_include_file = os.path.join(directories['data'], 'op-mode-standardized.json') diff --git a/src/services/vyos-http-api-server b/src/services/vyos-http-api-server index 4ace981ca..3c390d9dc 100755 --- a/src/services/vyos-http-api-server +++ b/src/services/vyos-http-api-server @@ -647,20 +647,21 @@ def reset_op(data: ResetModel): ### def graphql_init(fast_api_app): - from api.graphql.bindings import generate_schema - + from api.graphql.libs.token_auth import get_user_context api.graphql.state.init() api.graphql.state.settings['app'] = app + # import after initializaion of state + from api.graphql.bindings import generate_schema schema = generate_schema() in_spec = app.state.vyos_introspection if app.state.vyos_origins: origins = app.state.vyos_origins - app.add_route('/graphql', CORSMiddleware(GraphQL(schema, debug=True, introspection=in_spec), allow_origins=origins, allow_methods=("GET", "POST", "OPTIONS"))) + app.add_route('/graphql', CORSMiddleware(GraphQL(schema, context_value=get_user_context, debug=True, introspection=in_spec), allow_origins=origins, allow_methods=("GET", "POST", "OPTIONS"))) else: - app.add_route('/graphql', GraphQL(schema, debug=True, introspection=in_spec)) + app.add_route('/graphql', GraphQL(schema, context_value=get_user_context, debug=True, introspection=in_spec)) ### @@ -688,16 +689,21 @@ if __name__ == '__main__': app.state.vyos_debug = server_config['debug'] app.state.vyos_strict = server_config['strict'] app.state.vyos_origins = server_config.get('cors', {}).get('allow_origin', []) - if 'gql' in server_config: - app.state.vyos_gql = True - if isinstance(server_config['gql'], dict) and 'introspection' in server_config['gql']: - app.state.vyos_introspection = True - else: - app.state.vyos_introspection = False + if 'graphql' in server_config: + app.state.vyos_graphql = True + if isinstance(server_config['graphql'], dict): + if 'introspection' in server_config['graphql']: + app.state.vyos_introspection = True + else: + app.state.vyos_introspection = False + # default value is merged in conf_mode http-api.py, if not set + app.state.vyos_auth_type = server_config['graphql']['authentication']['type'] + app.state.vyos_token_exp = server_config['graphql']['authentication']['expiration'] + app.state.vyos_secret_len = server_config['graphql']['authentication']['secret_length'] else: - app.state.vyos_gql = False + app.state.vyos_graphql = False - if app.state.vyos_gql: + if app.state.vyos_graphql: graphql_init(app) try: diff --git a/src/tests/test_op_mode.py b/src/tests/test_op_mode.py new file mode 100644 index 000000000..90963b3c5 --- /dev/null +++ b/src/tests/test_op_mode.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 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/>. + +from unittest import TestCase + +import vyos.opmode + +class TestVyOSOpMode(TestCase): + def test_field_name_normalization(self): + from vyos.opmode import _normalize_field_name + + self.assertEqual(_normalize_field_name(" foo bar "), "foo_bar") + self.assertEqual(_normalize_field_name("foo-bar"), "foo_bar") + self.assertEqual(_normalize_field_name("foo (bar) baz"), "foo_bar_baz") + self.assertEqual(_normalize_field_name("load%"), "load_percentage") + + def test_dict_fields_normalization_non_unique(self): + from vyos.opmode import _normalize_field_names + + # Space and dot are both replaced by an underscore, + # so dicts like this cannor be normalized uniquely + data = {"foo bar": True, "foo.bar": False} + + with self.assertRaises(vyos.opmode.InternalError): + _normalize_field_names(data) + + def test_dict_fields_normalization_simple_dict(self): + from vyos.opmode import _normalize_field_names + + data = {"foo bar": True, "Bar-Baz": False} + self.assertEqual(_normalize_field_names(data), {"foo_bar": True, "bar_baz": False}) + + def test_dict_fields_normalization_nested_dict(self): + from vyos.opmode import _normalize_field_names + + data = {"foo bar": True, "bar-baz": {"baz-quux": {"quux-xyzzy": False}}} + self.assertEqual(_normalize_field_names(data), + {"foo_bar": True, "bar_baz": {"baz_quux": {"quux_xyzzy": False}}}) + + def test_dict_fields_normalization_mixed(self): + from vyos.opmode import _normalize_field_names + + data = [{"foo bar": True, "bar-baz": [{"baz-quux": {"quux-xyzzy": [False]}}]}] + self.assertEqual(_normalize_field_names(data), + [{"foo_bar": True, "bar_baz": [{"baz_quux": {"quux_xyzzy": [False]}}]}]) + + def test_dict_fields_normalization_primitive(self): + from vyos.opmode import _normalize_field_names + + data = [1, False, "foo"] + self.assertEqual(_normalize_field_names(data), [1, False, "foo"]) + diff --git a/src/tests/test_util.py b/src/tests/test_util.py index 8ac9a500a..d8b2b7940 100644 --- a/src/tests/test_util.py +++ b/src/tests/test_util.py @@ -26,3 +26,17 @@ class TestVyOSUtil(TestCase): def test_sysctl_read(self): self.assertEqual(sysctl_read('net.ipv4.conf.lo.forwarding'), '1') + + def test_camel_to_snake_case(self): + self.assertEqual(camel_to_snake_case('ConnectionTimeout'), + 'connection_timeout') + self.assertEqual(camel_to_snake_case('connectionTimeout'), + 'connection_timeout') + self.assertEqual(camel_to_snake_case('TCPConnectionTimeout'), + 'tcp_connection_timeout') + self.assertEqual(camel_to_snake_case('TCPPort'), + 'tcp_port') + self.assertEqual(camel_to_snake_case('UseHTTPProxy'), + 'use_http_proxy') + self.assertEqual(camel_to_snake_case('CustomerID'), + 'customer_id') |