diff options
Diffstat (limited to 'src/services/api/rest')
-rw-r--r-- | src/services/api/rest/models.py | 29 | ||||
-rw-r--r-- | src/services/api/rest/routers.py | 168 |
2 files changed, 174 insertions, 23 deletions
diff --git a/src/services/api/rest/models.py b/src/services/api/rest/models.py index dda50010f..70fab03ec 100644 --- a/src/services/api/rest/models.py +++ b/src/services/api/rest/models.py @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -26,6 +26,7 @@ from typing import Self from pydantic import BaseModel from pydantic import StrictStr +from pydantic import StrictInt from pydantic import field_validator from pydantic import model_validator from fastapi.responses import HTMLResponse @@ -71,6 +72,8 @@ class BaseConfigureModel(BasePathModel): class ConfigureModel(ApiModel, BaseConfigureModel): + confirm_time: StrictInt = 0 + class Config: json_schema_extra = { 'example': { @@ -81,8 +84,12 @@ class ConfigureModel(ApiModel, BaseConfigureModel): } +class ConfirmModel(ApiModel): + op: StrictStr + class ConfigureListModel(ApiModel): commands: List[BaseConfigureModel] + confirm_time: StrictInt = 0 class Config: json_schema_extra = { @@ -134,13 +141,17 @@ class RetrieveModel(ApiModel): class ConfigFileModel(ApiModel): op: StrictStr file: StrictStr = None + string: StrictStr = None + confirm_time: StrictInt = 0 + destructive: bool = False class Config: json_schema_extra = { 'example': { 'key': 'id_key', - 'op': 'save | load', + 'op': 'save | load | merge | confirm', 'file': 'filename', + 'string': 'config_string' } } @@ -251,6 +262,20 @@ class RebootModel(ApiModel): } +class RenewModel(ApiModel): + op: StrictStr + path: List[StrictStr] + + class Config: + json_schema_extra = { + 'example': { + 'key': 'id_key', + 'op': 'renew', + 'path': ['op', 'mode', 'path'], + } + } + + class ResetModel(ApiModel): op: StrictStr path: List[StrictStr] diff --git a/src/services/api/rest/routers.py b/src/services/api/rest/routers.py index e52c77fda..329d6e51f 100644 --- a/src/services/api/rest/routers.py +++ b/src/services/api/rest/routers.py @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -34,6 +34,7 @@ from fastapi import HTTPException from fastapi import APIRouter from fastapi import BackgroundTasks from fastapi.routing import APIRoute +from fastapi.concurrency import run_in_threadpool from starlette.datastructures import FormData from starlette.formparsers import FormParser from starlette.formparsers import MultiPartParser @@ -51,6 +52,7 @@ from .models import error from .models import responses from .models import ApiModel from .models import ConfigureModel +from .models import ConfirmModel from .models import ConfigureListModel from .models import ConfigSectionModel from .models import ConfigSectionListModel @@ -66,6 +68,7 @@ from .models import GenerateModel from .models import ShowModel from .models import RebootModel from .models import ResetModel +from .models import RenewModel from .models import ImportPkiModel from .models import PoweroffModel from .models import TracerouteModel @@ -301,8 +304,47 @@ def call_commit(s: SessionState): LOG.warning(f'ConfigSessionError: {e}') -def _configure_op( +def call_commit_confirm(s: SessionState): + env = s.session.get_session_env() + env['IN_COMMIT_CONFIRM'] = 't' + try: + s.session.commit() + s.session.commit_confirm(minutes=s.confirm_time) + except ConfigSessionError as e: + s.session.discard() + if s.debug: + LOG.warning(f'ConfigSessionError:\n {traceback.format_exc()}') + else: + LOG.warning(f'ConfigSessionError: {e}') + finally: + del env['IN_COMMIT_CONFIRM'] + + +def run_commit(s: SessionState): + try: + out = s.session.commit() + return out, None + except Exception as e: + return None, e + + +def run_commit_confirm(s: SessionState): + env = s.session.get_session_env() + env['IN_COMMIT_CONFIRM'] = 't' + try: + out_c = s.session.commit() + out_cc = s.session.commit_confirm(minutes=s.confirm_time) + out = out_c + '\n' + out_cc + return out, None + except Exception as e: + return None, e + finally: + del env['IN_COMMIT_CONFIRM'] + + +async def _configure_op( data: Union[ + ConfirmModel, ConfigureModel, ConfigureListModel, ConfigSectionModel, @@ -319,6 +361,11 @@ def _configure_op( session = state.session env = session.get_session_env() + # A non-zero confirm_time will start commit-confirm timer on commit + confirm_time = 0 + if isinstance(data, (ConfigureModel, ConfigureListModel, ConfigFileModel)): + confirm_time = data.confirm_time + # Allow users to pass just one command if not isinstance(data, (ConfigureListModel, ConfigSectionListModel)): data = [data] @@ -338,10 +385,16 @@ def _configure_op( try: for c in data: op = c.op - if not isinstance(c, BaseConfigSectionTreeModel): + if not isinstance(c, (ConfirmModel, BaseConfigSectionTreeModel)): path = c.path - if isinstance(c, BaseConfigureModel): + if isinstance(c, ConfirmModel): + if op == 'confirm': + msg = session.confirm() + else: + raise ConfigSessionError(f"'{op}' is not a valid operation") + + elif isinstance(c, BaseConfigureModel): if c.value: value = c.value else: @@ -387,16 +440,30 @@ def _configure_op( 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 + state.confirm_time = confirm_time if confirm_time else 0 + + if not d.is_node_changed(['service', 'https']): + if confirm_time: + out, err = await run_in_threadpool(run_commit_confirm, state) + if err: + raise err + msg = msg + out if msg else out + else: + out, err = await run_in_threadpool(run_commit, state) + if err: + raise err + msg = msg + out if msg else out else: - # capture non-fatal warnings - out = session.commit() - msg = out if out else msg + if confirm_time: + background_tasks.add_task(call_commit_confirm, state) + else: + background_tasks.add_task(call_commit, state) + out = self_ref_msg + msg = msg + out if msg else out LOG.info(f"Configuration modified via HTTP API using key '{state.id}'") except ConfigSessionError as e: @@ -413,6 +480,8 @@ def _configure_op( # Don't give the details away to the outer world error_msg = 'An internal error occured. Check the logs for details.' finally: + if 'IN_COMMIT_CONFIRM' in env: + del env['IN_COMMIT_CONFIRM'] lock.release() if status != 200: @@ -431,12 +500,14 @@ def create_path_import_pki_no_prompt(path): @router.post('/configure') -def configure_op( - data: Union[ConfigureModel, ConfigureListModel], +async def configure_op( + data: Union[ConfigureModel, ConfigureListModel, ConfirmModel], request: Request, background_tasks: BackgroundTasks, ): - return _configure_op(data, request, background_tasks) + out = await _configure_op(data, request, background_tasks) + + return out @router.post('/configure-section') @@ -493,13 +564,18 @@ async def retrieve_op(data: RetrieveModel): @router.post('/config-file') -def config_file_op(data: ConfigFileModel, background_tasks: BackgroundTasks): +async def config_file_op(data: ConfigFileModel, background_tasks: BackgroundTasks): state = SessionState() session = state.session env = session.get_session_env() op = data.op msg = None + # A non-zero confirm_time will start commit-confirm timer on commit + confirm_time = data.confirm_time + + lock.acquire() + try: if op == 'save': if data.file: @@ -507,22 +583,48 @@ def config_file_op(data: ConfigFileModel, background_tasks: BackgroundTasks): else: path = '/config/config.boot' msg = session.save_config(path) - elif op == 'load': + elif op in ('load', 'merge'): if data.file: path = data.file + elif data.string: + path = '/tmp/config.file' + with open(path, 'w') as f: + f.write(data.string) else: - return error(400, 'Missing required field "file"') + return error(400, 'Missing required field "file | string"') - session.migrate_and_load_config(path) + match op: + case 'load': + session.migrate_and_load_config(path) + case 'merge': + session.merge_config(path, destructive=data.destructive) 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 + state.confirm_time = confirm_time if confirm_time else 0 + + if not d.is_node_changed(['service', 'https']): + if confirm_time: + out, err = await run_in_threadpool(run_commit_confirm, state) + if err: + raise err + msg = msg + out if msg else out + else: + out, err = await run_in_threadpool(run_commit, state) + if err: + raise err + msg = msg + out if msg else out else: - session.commit() + if confirm_time: + background_tasks.add_task(call_commit_confirm, state) + else: + background_tasks.add_task(call_commit, state) + out = self_ref_msg + msg = msg + out if msg else out + + elif op == 'confirm': + msg = session.confirm() else: return error(400, f"'{op}' is not a valid operation") except ConfigSessionError as e: @@ -530,6 +632,10 @@ def config_file_op(data: ConfigFileModel, background_tasks: BackgroundTasks): except Exception: LOG.critical(traceback.format_exc()) return error(500, 'An internal error occured. Check the logs for details.') + finally: + if 'IN_COMMIT_CONFIRM' in env: + del env['IN_COMMIT_CONFIRM'] + lock.release() return success(msg) @@ -657,6 +763,26 @@ def reboot_op(data: RebootModel): return success(res) +@router.post('/renew') +def renew_op(data: RenewModel): + state = SessionState() + session = state.session + + op = data.op + path = data.path + + try: + if op == 'renew': + res = session.renew(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): |