summaryrefslogtreecommitdiff
path: root/src/services/api
diff options
context:
space:
mode:
Diffstat (limited to 'src/services/api')
-rw-r--r--src/services/api/__init__.py0
-rw-r--r--src/services/api/graphql/bindings.py21
-rw-r--r--src/services/api/graphql/graphql/auth_token_mutation.py8
-rw-r--r--src/services/api/graphql/graphql/mutations.py13
-rw-r--r--src/services/api/graphql/graphql/queries.py13
-rw-r--r--src/services/api/graphql/libs/__init__.py0
-rw-r--r--src/services/api/graphql/libs/key_auth.py22
-rw-r--r--src/services/api/graphql/libs/token_auth.py41
-rw-r--r--src/services/api/graphql/routers.py54
-rw-r--r--src/services/api/graphql/session/session.py6
-rw-r--r--src/services/api/graphql/state.py4
-rw-r--r--src/services/api/rest/__init__.py0
-rw-r--r--src/services/api/rest/models.py276
-rw-r--r--src/services/api/rest/routers.py685
-rw-r--r--src/services/api/session.py40
15 files changed, 1141 insertions, 42 deletions
diff --git a/src/services/api/__init__.py b/src/services/api/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/services/api/__init__.py
diff --git a/src/services/api/graphql/bindings.py b/src/services/api/graphql/bindings.py
index ef4966466..93dd0fbfb 100644
--- a/src/services/api/graphql/bindings.py
+++ b/src/services/api/graphql/bindings.py
@@ -1,4 +1,4 @@
-# Copyright 2021 VyOS maintainers and contributors <maintainers@vyos.io>
+# Copyright 2021-2024 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
@@ -13,24 +13,35 @@
# 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 vyos.defaults
+
+from ariadne import make_executable_schema
+from ariadne import load_schema_from_path
+from ariadne import snake_case_fallback_resolvers
+
from . graphql.queries import query
from . graphql.mutations import mutation
from . graphql.directives import directives_dict
from . graphql.errors import op_mode_error
from . graphql.auth_token_mutation import auth_token_mutation
from . libs.token_auth import init_secret
-from . import state
-from ariadne import make_executable_schema, load_schema_from_path, snake_case_fallback_resolvers
+
+from .. session import SessionState
+
def generate_schema():
+ state = SessionState()
api_schema_dir = vyos.defaults.directories['api_schema']
- if state.settings['app'].state.vyos_auth_type == 'token':
+ if state.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, auth_token_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/graphql/auth_token_mutation.py b/src/services/api/graphql/graphql/auth_token_mutation.py
index a53fa4d60..164960217 100644
--- a/src/services/api/graphql/graphql/auth_token_mutation.py
+++ b/src/services/api/graphql/graphql/auth_token_mutation.py
@@ -21,7 +21,7 @@ from graphql import GraphQLResolveInfo
from .. libs.token_auth import generate_token
from .. session.session import get_user_info
-from .. import state
+from ... session import SessionState
auth_token_mutation = ObjectType("Mutation")
@@ -31,8 +31,10 @@ def auth_token_resolver(obj: Any, info: GraphQLResolveInfo, data: Dict):
user = data['username']
passwd = data['password']
- secret = state.settings['secret']
- exp_interval = int(state.settings['app'].state.vyos_token_exp)
+ state = SessionState()
+
+ secret = getattr(state, 'secret', '')
+ exp_interval = int(state.token_exp)
expiration = (datetime.datetime.now(tz=datetime.timezone.utc) +
datetime.timedelta(seconds=exp_interval))
diff --git a/src/services/api/graphql/graphql/mutations.py b/src/services/api/graphql/graphql/mutations.py
index d115a8e94..62031ada3 100644
--- a/src/services/api/graphql/graphql/mutations.py
+++ b/src/services/api/graphql/graphql/mutations.py
@@ -21,10 +21,10 @@ from makefun import with_signature
from typing import Any, Dict, Optional # pylint: disable=W0611
from graphql import GraphQLResolveInfo # pylint: disable=W0611
-from .. import state
+from ... session import SessionState
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 .. session.session import Session
+from .. session.errors.op_mode_errors import op_mode_err_msg, op_mode_err_code
from vyos.opmode import Error as OpModeError
mutation = ObjectType("Mutation")
@@ -45,12 +45,13 @@ 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: Optional[Dict]=None)'
+ state = SessionState()
@mutation.field(mutation_name)
@with_signature(func_sig, func_name=resolver_name)
async def func_impl(*args, **kwargs):
try:
- auth_type = state.settings['app'].state.vyos_auth_type
+ auth_type = state.auth_type
if auth_type == 'key':
data = kwargs['data']
@@ -86,11 +87,11 @@ def make_mutation_resolver(mutation_name, class_name, session_func):
}
else:
# AtrributeError will have already been raised if no
- # vyos_auth_type; validation and defaultValue ensure it is
+ # 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
+ session = state.session
# one may override the session functions with a local subclass
try:
diff --git a/src/services/api/graphql/graphql/queries.py b/src/services/api/graphql/graphql/queries.py
index 717098259..1e9036574 100644
--- a/src/services/api/graphql/graphql/queries.py
+++ b/src/services/api/graphql/graphql/queries.py
@@ -21,10 +21,10 @@ from makefun import with_signature
from typing import Any, Dict, Optional # pylint: disable=W0611
from graphql import GraphQLResolveInfo # pylint: disable=W0611
-from .. import state
+from ... session import SessionState
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 .. session.session import Session
+from .. session.errors.op_mode_errors import op_mode_err_msg, op_mode_err_code
from vyos.opmode import Error as OpModeError
query = ObjectType("Query")
@@ -45,12 +45,13 @@ 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: Optional[Dict]=None)'
+ state = SessionState()
@query.field(query_name)
@with_signature(func_sig, func_name=resolver_name)
async def func_impl(*args, **kwargs):
try:
- auth_type = state.settings['app'].state.vyos_auth_type
+ auth_type = state.auth_type
if auth_type == 'key':
data = kwargs['data']
@@ -86,11 +87,11 @@ def make_query_resolver(query_name, class_name, session_func):
}
else:
# AtrributeError will have already been raised if no
- # vyos_auth_type; validation and defaultValue ensure it is
+ # 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
+ session = state.session
# one may override the session functions with a local subclass
try:
diff --git a/src/services/api/graphql/libs/__init__.py b/src/services/api/graphql/libs/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/services/api/graphql/libs/__init__.py
diff --git a/src/services/api/graphql/libs/key_auth.py b/src/services/api/graphql/libs/key_auth.py
index 2db0f7d48..9e49a1203 100644
--- a/src/services/api/graphql/libs/key_auth.py
+++ b/src/services/api/graphql/libs/key_auth.py
@@ -1,5 +1,20 @@
+# Copyright 2021-2024 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/>.
-from .. import state
+
+from ... session import SessionState
def check_auth(key_list, key):
if not key_list:
@@ -11,8 +26,9 @@ def check_auth(key_list, key):
return key_id
def auth_required(key):
+ state = SessionState()
api_keys = None
- api_keys = state.settings['app'].state.vyos_keys
+ api_keys = state.keys
key_id = check_auth(api_keys, key)
- state.settings['app'].state.vyos_id = key_id
+ state.id = key_id
return key_id
diff --git a/src/services/api/graphql/libs/token_auth.py b/src/services/api/graphql/libs/token_auth.py
index 8585485c9..2d772e035 100644
--- a/src/services/api/graphql/libs/token_auth.py
+++ b/src/services/api/graphql/libs/token_auth.py
@@ -1,33 +1,52 @@
+# Copyright 2021-2024 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 uuid
import pam
from secrets import token_hex
-from .. import state
+from ... session import SessionState
+
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)
+ state = SessionState()
+ length = int(state.secret_len)
secret = token_hex(length)
- state.settings['secret'] = secret
+ state.secret = secret
+
def generate_token(user: str, passwd: str, secret: str, exp: int) -> dict:
if user is None or passwd is None:
return {}
+ state = SessionState()
if _check_passwd_pam(user, passwd):
- app = state.settings['app']
try:
- users = app.state.vyos_token_users
+ users = state.token_users
except AttributeError:
- app.state.vyos_token_users = {}
- users = app.state.vyos_token_users
+ users = state.token_users = {}
user_id = uuid.uuid1().hex
payload_data = {'iss': user, 'sub': user_id, 'exp': exp}
- secret = state.settings.get('secret')
+ secret = getattr(state, 'secret', None)
if secret is None:
return {"errors": ['missing secret']}
token = jwt.encode(payload=payload_data, key=secret, algorithm="HS256")
@@ -37,10 +56,12 @@ def generate_token(user: str, passwd: str, secret: str, exp: int) -> dict:
else:
return {"errors": ['failed pam authentication']}
+
def get_user_context(request):
context = {}
context['request'] = request
context['user'] = None
+ state = SessionState()
if 'Authorization' in request.headers:
auth = request.headers['Authorization']
scheme, token = auth.split()
@@ -48,7 +69,7 @@ def get_user_context(request):
return context
try:
- secret = state.settings.get('secret')
+ secret = getattr(state, 'secret', None)
payload = jwt.decode(token, secret, algorithms=["HS256"])
user_id: str = payload.get('sub')
if user_id is None:
@@ -59,7 +80,7 @@ def get_user_context(request):
except jwt.PyJWTError:
return context
try:
- users = state.settings['app'].state.vyos_token_users
+ users = state.token_users
except AttributeError:
return context
diff --git a/src/services/api/graphql/routers.py b/src/services/api/graphql/routers.py
new file mode 100644
index 000000000..d04375a49
--- /dev/null
+++ b/src/services/api/graphql/routers.py
@@ -0,0 +1,54 @@
+# Copyright 2024 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/>.
+
+# pylint: disable=import-outside-toplevel
+
+
+import typing
+
+from ariadne.asgi import GraphQL
+from starlette.middleware.cors import CORSMiddleware
+
+
+if typing.TYPE_CHECKING:
+ from fastapi import FastAPI
+
+
+def graphql_init(app: "FastAPI"):
+ from .. session import SessionState
+ from .libs.token_auth import get_user_context
+
+ state = SessionState()
+
+ # import after initializaion of state
+ from .bindings import generate_schema
+ schema = generate_schema()
+
+ in_spec = state.introspection
+
+ if state.origins:
+ origins = state.origins
+ 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"),
+ allow_headers=("Authorization",)))
+ else:
+ app.add_route('/graphql', GraphQL(schema,
+ context_value=get_user_context,
+ debug=True,
+ introspection=in_spec))
diff --git a/src/services/api/graphql/session/session.py b/src/services/api/graphql/session/session.py
index 6ae44b9bf..6e2875f3c 100644
--- a/src/services/api/graphql/session/session.py
+++ b/src/services/api/graphql/session/session.py
@@ -138,7 +138,6 @@ class Session:
return res
def show_user_info(self):
- session = self._session
data = self._data
user_info = {}
@@ -151,10 +150,9 @@ class Session:
return user_info
def system_status(self):
- import api.graphql.session.composite.system_status as system_status
+ from api.graphql.session.composite import system_status
session = self._session
- data = self._data
status = {}
status['host_name'] = session.show(['host', 'name']).strip()
@@ -165,7 +163,6 @@ class Session:
return status
def gen_op_query(self):
- session = self._session
data = self._data
name = self._name
op_mode_list = self._op_mode_list
@@ -189,7 +186,6 @@ class Session:
return res
def gen_op_mutation(self):
- session = self._session
data = self._data
name = self._name
op_mode_list = self._op_mode_list
diff --git a/src/services/api/graphql/state.py b/src/services/api/graphql/state.py
deleted file mode 100644
index 63db9f4ef..000000000
--- a/src/services/api/graphql/state.py
+++ /dev/null
@@ -1,4 +0,0 @@
-
-def init():
- global settings
- settings = {}
diff --git a/src/services/api/rest/__init__.py b/src/services/api/rest/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/services/api/rest/__init__.py
diff --git a/src/services/api/rest/models.py b/src/services/api/rest/models.py
new file mode 100644
index 000000000..034e3fcdb
--- /dev/null
+++ b/src/services/api/rest/models.py
@@ -0,0 +1,276 @@
+# Copyright 2024 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/>.
+
+
+# pylint: disable=too-few-public-methods
+
+import json
+from enum import Enum
+from typing import List
+from typing import Union
+from typing import Dict
+from typing import Self
+
+from pydantic import BaseModel
+from pydantic import StrictStr
+from pydantic import field_validator
+from pydantic import model_validator
+from fastapi.responses import HTMLResponse
+
+
+def error(code, msg):
+ resp = {"success": False, "error": msg, "data": None}
+ resp = json.dumps(resp)
+ return HTMLResponse(resp, status_code=code)
+
+def success(data):
+ resp = {"success": True, "data": data, "error": None}
+ 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
+# json_schema_extra adds anotations to OpenAPI to add examples
+
+class ApiModel(BaseModel):
+ key: StrictStr
+
+class BasePathModel(BaseModel):
+ op: StrictStr
+ path: List[StrictStr]
+
+ @field_validator("path")
+ @classmethod
+ def check_non_empty(cls, path: str) -> str:
+ if not len(path) > 0:
+ raise ValueError('path must be non-empty')
+ return path
+
+class BaseConfigureModel(BasePathModel):
+ value: StrictStr = None
+
+class ConfigureModel(ApiModel, BaseConfigureModel):
+ class Config:
+ json_schema_extra = {
+ "example": {
+ "key": "id_key",
+ "op": "set | delete | comment",
+ "path": ["config", "mode", "path"],
+ }
+ }
+
+class ConfigureListModel(ApiModel):
+ commands: List[BaseConfigureModel]
+
+ class Config:
+ json_schema_extra = {
+ "example": {
+ "key": "id_key",
+ "commands": "list of commands",
+ }
+ }
+
+class BaseConfigSectionModel(BasePathModel):
+ section: Dict
+
+class ConfigSectionModel(ApiModel, BaseConfigSectionModel):
+ pass
+
+class ConfigSectionListModel(ApiModel):
+ commands: List[BaseConfigSectionModel]
+
+class BaseConfigSectionTreeModel(BaseModel):
+ op: StrictStr
+ mask: Dict
+ config: Dict
+
+class ConfigSectionTreeModel(ApiModel, BaseConfigSectionTreeModel):
+ pass
+
+class RetrieveModel(ApiModel):
+ op: StrictStr
+ path: List[StrictStr]
+ configFormat: StrictStr = None
+
+ class Config:
+ json_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:
+ json_schema_extra = {
+ "example": {
+ "key": "id_key",
+ "op": "save | load",
+ "file": "filename",
+ }
+ }
+
+
+class ImageOp(str, Enum):
+ add = "add"
+ delete = "delete"
+ show = "show"
+ set_default = "set_default"
+
+
+class ImageModel(ApiModel):
+ op: ImageOp
+ url: StrictStr = None
+ name: StrictStr = None
+
+ @model_validator(mode='after')
+ def check_data(self) -> Self:
+ if self.op == 'add':
+ if not self.url:
+ raise ValueError("Missing required field \"url\"")
+ elif self.op in ['delete', 'set_default']:
+ if not self.name:
+ raise ValueError("Missing required field \"name\"")
+
+ return self
+
+ class Config:
+ json_schema_extra = {
+ "example": {
+ "key": "id_key",
+ "op": "add | delete | show | set_default",
+ "url": "imagelocation",
+ "name": "imagename",
+ }
+ }
+
+class ImportPkiModel(ApiModel):
+ op: StrictStr
+ path: List[StrictStr]
+ passphrase: StrictStr = None
+
+ class Config:
+ json_schema_extra = {
+ "example": {
+ "key": "id_key",
+ "op": "import_pki",
+ "path": ["op", "mode", "path"],
+ "passphrase": "passphrase",
+ }
+ }
+
+
+class ContainerImageModel(ApiModel):
+ op: StrictStr
+ name: StrictStr = None
+
+ class Config:
+ json_schema_extra = {
+ "example": {
+ "key": "id_key",
+ "op": "add | delete | show",
+ "name": "imagename",
+ }
+ }
+
+class GenerateModel(ApiModel):
+ op: StrictStr
+ path: List[StrictStr]
+
+ class Config:
+ json_schema_extra = {
+ "example": {
+ "key": "id_key",
+ "op": "generate",
+ "path": ["op", "mode", "path"],
+ }
+ }
+
+class ShowModel(ApiModel):
+ op: StrictStr
+ path: List[StrictStr]
+
+ class Config:
+ json_schema_extra = {
+ "example": {
+ "key": "id_key",
+ "op": "show",
+ "path": ["op", "mode", "path"],
+ }
+ }
+
+class RebootModel(ApiModel):
+ op: StrictStr
+ path: List[StrictStr]
+
+ class Config:
+ json_schema_extra = {
+ "example": {
+ "key": "id_key",
+ "op": "reboot",
+ "path": ["op", "mode", "path"],
+ }
+ }
+
+class ResetModel(ApiModel):
+ op: StrictStr
+ path: List[StrictStr]
+
+ class Config:
+ json_schema_extra = {
+ "example": {
+ "key": "id_key",
+ "op": "reset",
+ "path": ["op", "mode", "path"],
+ }
+ }
+
+class PoweroffModel(ApiModel):
+ op: StrictStr
+ path: List[StrictStr]
+
+ class Config:
+ json_schema_extra = {
+ "example": {
+ "key": "id_key",
+ "op": "poweroff",
+ "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}
+}
diff --git a/src/services/api/rest/routers.py b/src/services/api/rest/routers.py
new file mode 100644
index 000000000..1568f7d0c
--- /dev/null
+++ b/src/services/api/rest/routers.py
@@ -0,0 +1,685 @@
+# Copyright 2024 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/>.
+
+
+# pylint: disable=line-too-long,raise-missing-from,invalid-name
+# pylint: disable=wildcard-import,unused-wildcard-import
+# pylint: disable=broad-exception-caught
+
+import copy
+import logging
+import traceback
+from threading import Lock
+from typing import Callable
+from typing import TYPE_CHECKING
+
+from fastapi import Depends
+from fastapi import Request
+from fastapi import Response
+from fastapi import HTTPException
+from fastapi import APIRouter
+from fastapi import BackgroundTasks
+from fastapi.routing import APIRoute
+from starlette.datastructures import FormData
+from starlette.formparsers import FormParser
+from starlette.formparsers import MultiPartParser
+from starlette.formparsers import MultiPartException
+from multipart.multipart import parse_options_header
+
+from vyos.config import Config
+from vyos.configtree import ConfigTree
+from vyos.configdiff import get_config_diff
+from vyos.configsession import ConfigSessionError
+
+from .. session import SessionState
+from . models import *
+
+if TYPE_CHECKING:
+ from fastapi import FastAPI
+
+
+LOG = logging.getLogger('http_api.routers')
+
+lock = Lock()
+
+
+def check_auth(key_list, key):
+ key_id = None
+ for k in key_list:
+ if k['key'] == key:
+ key_id = k['id']
+ return key_id
+
+
+def auth_required(data: ApiModel):
+ session = SessionState()
+ key = data.key
+ api_keys = session.keys
+ key_id = check_auth(api_keys, key)
+ if not key_id:
+ raise HTTPException(status_code=401, detail="Valid API key is required")
+ session.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):
+ """Override Request class to convert form request to json"""
+ # pylint: disable=attribute-defined-outside-init
+ # pylint: disable=too-many-branches,too-many-statements
+
+ _form_err = ()
+ @property
+ def form_err(self):
+ return self._form_err
+
+ @form_err.setter
+ def form_err(self, val):
+ if not self._form_err:
+ self._form_err = val
+
+ @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 _get_form(
+ self, *, max_files: int | float = 1000, max_fields: int | float = 1000
+ ) -> FormData:
+ if self._form is None:
+ 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: bytes
+ content_type, _ = parse_options_header(content_type_header)
+ if content_type == b"multipart/form-data":
+ try:
+ multipart_parser = MultiPartParser(
+ self.orig_headers,
+ self.stream(),
+ max_files=max_files,
+ max_fields=max_fields,
+ )
+ self._form = await multipart_parser.parse()
+ except MultiPartException as exc:
+ if "app" in self.scope:
+ raise HTTPException(status_code=400, detail=exc.message)
+ raise exc
+ 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:
+ endpoint = self.url.path
+ LOG.debug("processing form data")
+ for k, v in form_data.multi_items():
+ forms[k] = v
+
+ if 'data' not in forms:
+ self.form_err = (422, "Non-empty data field is required")
+ return self._body
+ try:
+ tmp = json.loads(forms['data'])
+ except json.JSONDecodeError as e:
+ self.form_err = (400, f'Failed to parse JSON: {e}')
+ return self._body
+ 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.form_err = (400,
+ f"Malformed command '{c}': any command must be JSON of dict")
+ return self._body
+ if 'op' not in c:
+ self.form_err = (400,
+ f"Malformed command '{c}': missing 'op' field")
+ if endpoint not in ('/config-file', '/container-image',
+ '/image', '/configure-section'):
+ if 'path' not in c:
+ self.form_err = (400,
+ f"Malformed command '{c}': missing 'path' field")
+ elif not isinstance(c['path'], list):
+ self.form_err = (400,
+ f"Malformed command '{c}': 'path' field must be a list")
+ elif not all(isinstance(el, str) for el in c['path']):
+ self.form_err = (400,
+ f"Malformed command '{0}': 'path' field must be a list of strings")
+ if endpoint in ('/configure'):
+ if not c['path']:
+ self.form_err = (400,
+ f"Malformed command '{c}': 'path' list must be non-empty")
+ if 'value' in c and not isinstance(c['value'], str):
+ self.form_err = (400,
+ f"Malformed command '{c}': 'value' field must be a string")
+ if endpoint in ('/configure-section'):
+ if 'section' not in c and 'config' not in c:
+ self.form_err = (400,
+ f"Malformed command '{c}': missing 'section' or 'config' field")
+
+ if 'key' not in forms and 'key' not in merge:
+ self.form_err = (401, "Valid API key is required")
+ 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):
+ """Override APIRoute class to convert form request to json"""
+
+ 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)
+ try:
+ response: Response = await original_route_handler(request)
+ except HTTPException as e:
+ return error(e.status_code, e.detail)
+ except Exception as e:
+ form_err = request.form_err
+ if form_err:
+ return error(*form_err)
+ raise e
+
+ return response
+
+ return custom_route_handler
+
+
+router = APIRouter(route_class=MultipartRoute,
+ responses={**responses},
+ dependencies=[Depends(auth_required)])
+
+
+self_ref_msg = "Requested HTTP API server configuration change; commit will be called in the background"
+
+def call_commit(s: SessionState):
+ try:
+ s.session.commit()
+ except ConfigSessionError as e:
+ s.session.discard()
+ if s.debug:
+ LOG.warning(f"ConfigSessionError:\n {traceback.format_exc()}")
+ else:
+ LOG.warning(f"ConfigSessionError: {e}")
+
+
+def _configure_op(data: Union[ConfigureModel, ConfigureListModel,
+ ConfigSectionModel, ConfigSectionListModel,
+ ConfigSectionTreeModel],
+ _request: Request, background_tasks: BackgroundTasks):
+ # pylint: disable=too-many-branches,too-many-locals,too-many-nested-blocks,too-many-statements
+ # pylint: disable=consider-using-with
+
+ state = SessionState()
+ session = state.session
+ env = session.get_session_env()
+
+ # Allow users to pass just one command
+ if not isinstance(data, (ConfigureListModel, ConfigSectionListModel)):
+ 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,
+ # so the lock is really global
+ lock.acquire()
+
+ config = Config(session_env=env)
+
+ status = 200
+ msg = None
+ error_msg = None
+ try:
+ for c in data:
+ op = c.op
+ if not isinstance(c, BaseConfigSectionTreeModel):
+ path = c.path
+
+ if isinstance(c, BaseConfigureModel):
+ if c.value:
+ value = c.value
+ else:
+ value = ""
+ # For vyos.configsession calls that have no separate value arguments,
+ # and for type checking too
+ cfg_path = " ".join(path + [value]).strip()
+
+ elif isinstance(c, BaseConfigSectionModel):
+ section = c.section
+
+ elif isinstance(c, BaseConfigSectionTreeModel):
+ mask = c.mask
+ config = c.config
+
+ if isinstance(c, BaseConfigureModel):
+ if op == 'set':
+ session.set(path, value=value)
+ elif op == 'delete':
+ if state.strict and not config.exists(cfg_path):
+ raise ConfigSessionError(f"Cannot delete [{cfg_path}]: path/value does not exist")
+ session.delete(path, value=value)
+ elif op == 'comment':
+ session.comment(path, value=value)
+ else:
+ raise ConfigSessionError(f"'{op}' is not a valid operation")
+
+ elif isinstance(c, BaseConfigSectionModel):
+ if op == 'set':
+ session.set_section(path, section)
+ elif op == 'load':
+ session.load_section(path, section)
+ else:
+ raise ConfigSessionError(f"'{op}' is not a valid operation")
+
+ elif isinstance(c, BaseConfigSectionTreeModel):
+ if op == 'set':
+ session.set_section_tree(config)
+ elif op == 'load':
+ session.load_section_tree(mask, config)
+ else:
+ raise ConfigSessionError(f"'{op}' is not a valid operation")
+ # end for
+ config = Config(session_env=env)
+ d = get_config_diff(config)
+
+ if d.is_node_changed(['service', 'https']):
+ background_tasks.add_task(call_commit, state)
+ msg = self_ref_msg
+ else:
+ # capture non-fatal warnings
+ out = session.commit()
+ msg = out if out else msg
+
+ LOG.info(f"Configuration modified via HTTP API using key '{state.id}'")
+ except ConfigSessionError as e:
+ session.discard()
+ status = 400
+ if state.debug:
+ LOG.critical(f"ConfigSessionError:\n {traceback.format_exc()}")
+ error_msg = str(e)
+ except Exception:
+ session.discard()
+ LOG.critical(traceback.format_exc())
+ 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)
+
+ return success(msg)
+
+
+def create_path_import_pki_no_prompt(path):
+ correct_paths = ['ca', 'certificate', 'key-pair']
+ if path[1] not in correct_paths:
+ return False
+ path[1] = '--' + path[1].replace('-', '')
+ path[3] = '--key-filename'
+ return path[1:]
+
+
+@router.post('/configure')
+def configure_op(data: Union[ConfigureModel,
+ ConfigureListModel],
+ request: Request, background_tasks: BackgroundTasks):
+ return _configure_op(data, request, background_tasks)
+
+
+@router.post('/configure-section')
+def configure_section_op(data: Union[ConfigSectionModel,
+ ConfigSectionListModel,
+ ConfigSectionTreeModel],
+ request: Request, background_tasks: BackgroundTasks):
+ return _configure_op(data, request, background_tasks)
+
+
+@router.post("/retrieve")
+async def retrieve_op(data: RetrieveModel):
+ state = SessionState()
+ session = state.session
+ env = session.get_session_env()
+ config = Config(session_env=env)
+
+ op = data.op
+ path = " ".join(data.path)
+
+ try:
+ if op == 'returnValue':
+ res = config.return_value(path)
+ elif op == 'returnValues':
+ res = config.return_values(path)
+ elif op == 'exists':
+ res = config.exists(path)
+ elif op == 'showConfig':
+ config_format = 'json'
+ if data.configFormat:
+ config_format = data.configFormat
+
+ res = session.show_config(path=data.path)
+ if config_format == 'json':
+ config_tree = ConfigTree(res)
+ res = json.loads(config_tree.to_json())
+ elif config_format == 'json_ast':
+ config_tree = ConfigTree(res)
+ res = json.loads(config_tree.to_json_ast())
+ elif config_format == 'raw':
+ pass
+ else:
+ return error(400, f"'{config_format}' is not a valid config format")
+ else:
+ return error(400, f"'{op}' is not a valid operation")
+ except ConfigSessionError as e:
+ return error(400, str(e))
+ except Exception:
+ LOG.critical(traceback.format_exc())
+ return error(500, "An internal error occured. Check the logs for details.")
+
+ return success(res)
+
+
+@router.post('/config-file')
+def config_file_op(data: ConfigFileModel, background_tasks: BackgroundTasks):
+ state = SessionState()
+ session = state.session
+ env = session.get_session_env()
+ op = data.op
+ msg = None
+
+ try:
+ if op == 'save':
+ if data.file:
+ path = data.file
+ else:
+ path = '/config/config.boot'
+ msg = session.save_config(path)
+ elif op == 'load':
+ if data.file:
+ path = data.file
+ else:
+ return error(400, "Missing required field \"file\"")
+
+ session.migrate_and_load_config(path)
+
+ config = Config(session_env=env)
+ d = get_config_diff(config)
+
+ if d.is_node_changed(['service', 'https']):
+ background_tasks.add_task(call_commit, state)
+ msg = self_ref_msg
+ else:
+ session.commit()
+ else:
+ return error(400, f"'{op}' is not a valid operation")
+ except ConfigSessionError as e:
+ return error(400, str(e))
+ except Exception:
+ LOG.critical(traceback.format_exc())
+ return error(500, "An internal error occured. Check the logs for details.")
+
+ return success(msg)
+
+
+@router.post('/image')
+def image_op(data: ImageModel):
+ state = SessionState()
+ session = state.session
+
+ op = data.op
+
+ try:
+ if op == 'add':
+ res = session.install_image(data.url)
+ elif op == 'delete':
+ res = session.remove_image(data.name)
+ elif op == 'show':
+ res = session.show(["system", "image"])
+ elif op == 'set_default':
+ res = session.set_default_image(data.name)
+ except ConfigSessionError as e:
+ return error(400, str(e))
+ except Exception:
+ LOG.critical(traceback.format_exc())
+ return error(500, "An internal error occured. Check the logs for details.")
+
+ return success(res)
+
+
+@router.post('/container-image')
+def container_image_op(data: ContainerImageModel):
+ state = SessionState()
+ session = state.session
+
+ op = data.op
+
+ try:
+ if op == 'add':
+ if data.name:
+ name = data.name
+ else:
+ return error(400, "Missing required field \"name\"")
+ res = session.add_container_image(name)
+ elif op == 'delete':
+ if data.name:
+ name = data.name
+ else:
+ return error(400, "Missing required field \"name\"")
+ res = session.delete_container_image(name)
+ elif op == 'show':
+ res = session.show_container_image()
+ else:
+ return error(400, f"'{op}' is not a valid operation")
+ except ConfigSessionError as e:
+ return error(400, str(e))
+ except Exception:
+ LOG.critical(traceback.format_exc())
+ return error(500, "An internal error occured. Check the logs for details.")
+
+ return success(res)
+
+
+@router.post('/generate')
+def generate_op(data: GenerateModel):
+ state = SessionState()
+ session = state.session
+
+ op = data.op
+ path = data.path
+
+ try:
+ if op == 'generate':
+ res = session.generate(path)
+ else:
+ return error(400, f"'{op}' is not a valid operation")
+ except ConfigSessionError as e:
+ return error(400, str(e))
+ except Exception:
+ LOG.critical(traceback.format_exc())
+ return error(500, "An internal error occured. Check the logs for details.")
+
+ return success(res)
+
+
+@router.post('/show')
+def show_op(data: ShowModel):
+ state = SessionState()
+ session = state.session
+
+ op = data.op
+ path = data.path
+
+ try:
+ if op == 'show':
+ res = session.show(path)
+ else:
+ return error(400, f"'{op}' is not a valid operation")
+ except ConfigSessionError as e:
+ return error(400, str(e))
+ except Exception:
+ LOG.critical(traceback.format_exc())
+ return error(500, "An internal error occured. Check the logs for details.")
+
+ return success(res)
+
+
+@router.post('/reboot')
+def reboot_op(data: RebootModel):
+ state = SessionState()
+ session = state.session
+
+ op = data.op
+ path = data.path
+
+ try:
+ if op == 'reboot':
+ res = session.reboot(path)
+ else:
+ return error(400, f"'{op}' is not a valid operation")
+ except ConfigSessionError as e:
+ return error(400, str(e))
+ except Exception:
+ LOG.critical(traceback.format_exc())
+ return error(500, "An internal error occured. Check the logs for details.")
+
+ return success(res)
+
+
+@router.post('/reset')
+def reset_op(data: ResetModel):
+ state = SessionState()
+ session = state.session
+
+ op = data.op
+ path = data.path
+
+ try:
+ if op == 'reset':
+ res = session.reset(path)
+ else:
+ return error(400, f"'{op}' is not a valid operation")
+ except ConfigSessionError as e:
+ return error(400, str(e))
+ except Exception:
+ LOG.critical(traceback.format_exc())
+ return error(500, "An internal error occured. Check the logs for details.")
+
+ return success(res)
+
+
+@router.post('/import-pki')
+def import_pki(data: ImportPkiModel):
+ # pylint: disable=consider-using-with
+
+ state = SessionState()
+ session = state.session
+
+ op = data.op
+ path = data.path
+
+ lock.acquire()
+
+ try:
+ if op == 'import-pki':
+ # need to get rid or interactive mode for private key
+ if len(path) == 5 and path[3] in ['key-file', 'private-key']:
+ path_no_prompt = create_path_import_pki_no_prompt(path)
+ if not path_no_prompt:
+ return error(400, f"Invalid command: {' '.join(path)}")
+ if data.passphrase:
+ path_no_prompt += ['--passphrase', data.passphrase]
+ res = session.import_pki_no_prompt(path_no_prompt)
+ else:
+ res = session.import_pki(path)
+ if not res[0].isdigit():
+ return error(400, res)
+ # commit changes
+ session.commit()
+ res = res.split('. ')[0]
+ else:
+ return error(400, f"'{op}' is not a valid operation")
+ except ConfigSessionError as e:
+ return error(400, str(e))
+ except Exception:
+ LOG.critical(traceback.format_exc())
+ return error(500, "An internal error occured. Check the logs for details.")
+ finally:
+ lock.release()
+
+ return success(res)
+
+
+@router.post('/poweroff')
+def poweroff_op(data: PoweroffModel):
+ state = SessionState()
+ session = state.session
+
+ op = data.op
+ path = data.path
+
+ try:
+ if op == 'poweroff':
+ res = session.poweroff(path)
+ else:
+ return error(400, f"'{op}' is not a valid operation")
+ except ConfigSessionError as e:
+ return error(400, str(e))
+ except Exception:
+ LOG.critical(traceback.format_exc())
+ return error(500, "An internal error occured. Check the logs for details.")
+
+ return success(res)
+
+
+def rest_init(app: "FastAPI"):
+ app.include_router(router)
diff --git a/src/services/api/session.py b/src/services/api/session.py
new file mode 100644
index 000000000..dcdc7246c
--- /dev/null
+++ b/src/services/api/session.py
@@ -0,0 +1,40 @@
+# Copyright 2024 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/>.
+
+class SessionState:
+ # pylint: disable=attribute-defined-outside-init
+ # pylint: disable=too-many-instance-attributes,too-few-public-methods
+
+ _instance = None
+
+ def __new__(cls):
+ if cls._instance is None:
+ cls._instance = super(SessionState, cls).__new__(cls)
+ cls._instance._initialize()
+ return cls._instance
+
+ def _initialize(self):
+ self.session = None
+ self.keys = []
+ self.id = None
+ self.rest = False
+ self.debug = False
+ self.strict = False
+ self.graphql = False
+ self.origins = []
+ self.introspection = False
+ self.auth_type = None
+ self.token_exp = None
+ self.secret_len = None