mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-09-17 15:30:43 +03:00
feat: add GET/PUT /v3/settings server settings API
GET returns all settings sections except the deprecated VirtualBox/VMware ones (Server.Audit privilege); secrets are masked and Controller.jwt_secret_key is excluded entirely. PUT applies a partial update (Server.Modify privilege): masked or empty secrets mean unchanged, null removes the option, the response carries the new values plus restart_required, and a settings.updated notification is emitted. New Server.Audit/Server.Modify privileges are seeded into the Administrator role at table creation.
This commit is contained in:
parent
7d3cbf1023
commit
e0962a59ed
@ -62,6 +62,7 @@ from . import pools
|
|||||||
from . import privileges
|
from . import privileges
|
||||||
from . import api_keys
|
from . import api_keys
|
||||||
from . import netmiko
|
from . import netmiko
|
||||||
|
from . import settings
|
||||||
|
|
||||||
from .dependencies.authentication import get_current_active_user
|
from .dependencies.authentication import get_current_active_user
|
||||||
|
|
||||||
@ -72,6 +73,12 @@ router.include_router(
|
|||||||
tags=["Controller"]
|
tags=["Controller"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
router.include_router(
|
||||||
|
settings.router,
|
||||||
|
prefix="/settings",
|
||||||
|
tags=["Server settings"]
|
||||||
|
)
|
||||||
|
|
||||||
router.include_router(
|
router.include_router(
|
||||||
users.router,
|
users.router,
|
||||||
prefix="/access/users",
|
prefix="/access/users",
|
||||||
|
|||||||
162
gns3server/api/routes/controller/settings.py
Normal file
162
gns3server/api/routes/controller/settings.py
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
#
|
||||||
|
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# 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/>.
|
||||||
|
|
||||||
|
"""
|
||||||
|
API routes for managing the server settings (gns3_server.conf).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from gns3server import schemas
|
||||||
|
from gns3server.config import Config, ConfigConflictError
|
||||||
|
from gns3server.controller import Controller
|
||||||
|
from gns3server.controller.controller_error import ControllerBadRequestError, ControllerError
|
||||||
|
from gns3server.schemas.controller.settings import SECRET_MASK
|
||||||
|
|
||||||
|
from .dependencies.rbac import has_privilege
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Settings that are only consumed at startup (or once, by singletons) and
|
||||||
|
# therefore require a server restart to take effect:
|
||||||
|
# - host/port/protocol/SSL are bound when the server starts
|
||||||
|
# - paths are used to initialize controller resources
|
||||||
|
# - port ranges are read once by the PortManager singleton
|
||||||
|
# - default admin credentials are only used to seed the users database
|
||||||
|
# - builtin templates/appliances and the skills repository are installed at startup
|
||||||
|
RESTART_REQUIRED = frozenset({
|
||||||
|
"Server.host",
|
||||||
|
"Server.port",
|
||||||
|
"Server.protocol",
|
||||||
|
"Server.enable_ssl",
|
||||||
|
"Server.certfile",
|
||||||
|
"Server.certkey",
|
||||||
|
"Server.secrets_dir",
|
||||||
|
"Server.images_path",
|
||||||
|
"Server.projects_path",
|
||||||
|
"Server.appliances_path",
|
||||||
|
"Server.symbols_path",
|
||||||
|
"Server.configs_path",
|
||||||
|
"Server.resources_path",
|
||||||
|
"Server.console_start_port_range",
|
||||||
|
"Server.console_end_port_range",
|
||||||
|
"Server.vnc_console_start_port_range",
|
||||||
|
"Server.vnc_console_end_port_range",
|
||||||
|
"Server.udp_start_port_range",
|
||||||
|
"Server.udp_end_port_range",
|
||||||
|
"Server.enable_builtin_templates",
|
||||||
|
"Server.install_builtin_appliances",
|
||||||
|
"Server.skills_repo_url",
|
||||||
|
"Server.skills_repo_branch",
|
||||||
|
"Server.skills_auto_update",
|
||||||
|
"Server.ubridge_path",
|
||||||
|
"Controller.default_admin_username",
|
||||||
|
"Controller.default_admin_password",
|
||||||
|
})
|
||||||
|
|
||||||
|
# never expose these sections (deprecated) nor the secret managed outside
|
||||||
|
# the configuration file; must match the response model in schemas.controller.settings
|
||||||
|
_DUMP_EXCLUDE = {
|
||||||
|
"VirtualBox": True,
|
||||||
|
"VMware": True,
|
||||||
|
"Controller": {"jwt_secret_key": True},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _current_settings_response() -> dict:
|
||||||
|
|
||||||
|
settings = Config.instance().settings
|
||||||
|
return settings.model_dump(mode="json", exclude=_DUMP_EXCLUDE)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=schemas.SettingsResponse,
|
||||||
|
dependencies=[Depends(has_privilege("Server.Audit"))],
|
||||||
|
responses={401: {"model": schemas.ErrorMessage}, 403: {"model": schemas.ErrorMessage}})
|
||||||
|
async def get_server_settings() -> schemas.SettingsResponse:
|
||||||
|
"""
|
||||||
|
Return the server settings.
|
||||||
|
|
||||||
|
The values reflect the running configuration (which may include command
|
||||||
|
line overrides). Secret fields are masked.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return schemas.SettingsResponse.model_validate(_current_settings_response())
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("", response_model=schemas.SettingsUpdateResponse,
|
||||||
|
dependencies=[Depends(has_privilege("Server.Modify"))],
|
||||||
|
responses={
|
||||||
|
400: {"model": schemas.ErrorMessage},
|
||||||
|
401: {"model": schemas.ErrorMessage},
|
||||||
|
403: {"model": schemas.ErrorMessage},
|
||||||
|
409: {"model": schemas.ErrorMessage},
|
||||||
|
422: {"model": schemas.ErrorMessage},
|
||||||
|
})
|
||||||
|
async def update_server_settings(settings_update: schemas.SettingsUpdate) -> schemas.SettingsUpdateResponse:
|
||||||
|
"""
|
||||||
|
Update the server settings and persist them to the configuration file.
|
||||||
|
|
||||||
|
Only the submitted options are modified. A JSON null removes an option
|
||||||
|
from the configuration file (restoring its default). Secret fields set
|
||||||
|
to an empty string or left at their masked value are considered unchanged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
changes = {
|
||||||
|
section: options
|
||||||
|
for section, options in settings_update.model_dump(exclude_unset=True).items()
|
||||||
|
if options
|
||||||
|
}
|
||||||
|
|
||||||
|
# masked or empty secrets mean "unchanged": never write them back
|
||||||
|
for section, option in (("Server", "compute_password"), ("Controller", "default_admin_password")):
|
||||||
|
if section in changes and changes[section].get(option) in ("", SECRET_MASK):
|
||||||
|
del changes[section][option]
|
||||||
|
|
||||||
|
if not changes:
|
||||||
|
# nothing to change, don't touch the file
|
||||||
|
data = _current_settings_response()
|
||||||
|
data["restart_required"] = []
|
||||||
|
return schemas.SettingsUpdateResponse.model_validate(data)
|
||||||
|
|
||||||
|
try:
|
||||||
|
changed = Config.instance().update_config(changes)
|
||||||
|
except ValidationError as e:
|
||||||
|
raise ControllerBadRequestError(f"Invalid server settings: {e}")
|
||||||
|
except ConfigConflictError as e:
|
||||||
|
raise HTTPException(status_code=409, detail=str(e))
|
||||||
|
except OSError as e:
|
||||||
|
raise ControllerError(f"Could not write the configuration file: {e}")
|
||||||
|
|
||||||
|
restart_required = sorted(set(changed) & RESTART_REQUIRED)
|
||||||
|
|
||||||
|
# only send metadata, never settings values (they may contain secrets)
|
||||||
|
controller = Controller.instance()
|
||||||
|
if controller is not None:
|
||||||
|
controller.notification.controller_emit(
|
||||||
|
"settings.updated",
|
||||||
|
{"changed": changed, "restart_required": restart_required}
|
||||||
|
)
|
||||||
|
|
||||||
|
data = _current_settings_response()
|
||||||
|
data["restart_required"] = restart_required
|
||||||
|
return schemas.SettingsUpdateResponse.model_validate(data)
|
||||||
@ -238,6 +238,14 @@ def create_default_roles(target, connection, **kw):
|
|||||||
{
|
{
|
||||||
"description": "Update an LLM model configuration",
|
"description": "Update an LLM model configuration",
|
||||||
"name": "LLMConfig.Modify"
|
"name": "LLMConfig.Modify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "View server settings",
|
||||||
|
"name": "Server.Audit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Update server settings",
|
||||||
|
"name": "Server.Modify"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -56,6 +56,7 @@ except ImportError:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
from .controller.rbac import RoleCreate, RoleUpdate, Role, Privilege, ACECreate, ACEUpdate, ACE
|
from .controller.rbac import RoleCreate, RoleUpdate, Role, Privilege, ACECreate, ACEUpdate, ACE
|
||||||
|
from .controller.settings import SettingsResponse, SettingsUpdate, SettingsUpdateResponse
|
||||||
from .controller.pools import Resource, ResourceCreate, ResourcePoolCreate, ResourcePoolUpdate, ResourcePool
|
from .controller.pools import Resource, ResourceCreate, ResourcePoolCreate, ResourcePoolUpdate, ResourcePool
|
||||||
from .controller.tokens import Token, ApiKeyCreate, RefreshTokenRequest
|
from .controller.tokens import Token, ApiKeyCreate, RefreshTokenRequest
|
||||||
from .controller.snapshots import SnapshotCreate, Snapshot
|
from .controller.snapshots import SnapshotCreate, Snapshot
|
||||||
|
|||||||
221
gns3server/schemas/controller/settings.py
Normal file
221
gns3server/schemas/controller/settings.py
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
#
|
||||||
|
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# 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/>.
|
||||||
|
#
|
||||||
|
|
||||||
|
"""
|
||||||
|
Schemas for the server settings endpoints (GET/PUT /v3/settings).
|
||||||
|
|
||||||
|
The VirtualBox and VMware sections are deprecated and intentionally not
|
||||||
|
exposed. Controller.jwt_secret_key is excluded everywhere: it is loaded
|
||||||
|
from the secrets directory and overrides whatever the configuration file
|
||||||
|
says, so exposing or writing it via the API would be useless at best and
|
||||||
|
a secret leak at worst.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from pydantic import ConfigDict, BaseModel, Field
|
||||||
|
|
||||||
|
from ..config import (
|
||||||
|
BuiltinSymbolTheme,
|
||||||
|
ControllerSettings,
|
||||||
|
DynamipsSettings,
|
||||||
|
IOUSettings,
|
||||||
|
QemuSettings,
|
||||||
|
ServerProtocol,
|
||||||
|
ServerSettings,
|
||||||
|
UbridgeControlTransport,
|
||||||
|
VPCSSettings,
|
||||||
|
WebWiresharkSettings,
|
||||||
|
)
|
||||||
|
|
||||||
|
# matches the pydantic v2 SecretStr serialization mask
|
||||||
|
SECRET_MASK = "**********"
|
||||||
|
|
||||||
|
|
||||||
|
class ServerSettingsResponse(ServerSettings):
|
||||||
|
|
||||||
|
# plain strings instead of FilePath/DirectoryPath: paths are validated when
|
||||||
|
# the settings are loaded or updated, not when echoed back to the client
|
||||||
|
secrets_dir: Optional[str] = None
|
||||||
|
certfile: Optional[str] = None
|
||||||
|
certkey: Optional[str] = None
|
||||||
|
# Optional overrides: typed as plain "str = None" in the config schema,
|
||||||
|
# which fails re-validation when the value actually is None
|
||||||
|
resources_path: Optional[str] = None
|
||||||
|
default_nat_interface: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ControllerSettingsResponse(ControllerSettings):
|
||||||
|
|
||||||
|
# never serialized: managed via the secrets directory, not the configuration file
|
||||||
|
jwt_secret_key: Optional[str] = Field(default=None, exclude=True)
|
||||||
|
|
||||||
|
|
||||||
|
class IOUSettingsResponse(IOUSettings):
|
||||||
|
|
||||||
|
iourc_path: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsResponse(BaseModel):
|
||||||
|
|
||||||
|
Server: ServerSettingsResponse
|
||||||
|
Controller: ControllerSettingsResponse
|
||||||
|
VPCS: VPCSSettings
|
||||||
|
Dynamips: DynamipsSettings
|
||||||
|
IOU: IOUSettingsResponse
|
||||||
|
Qemu: QemuSettings
|
||||||
|
WebWireshark: WebWiresharkSettings
|
||||||
|
|
||||||
|
|
||||||
|
class ServerSettingsUpdate(BaseModel):
|
||||||
|
"""
|
||||||
|
Every field optional: JSON null removes the option from the configuration
|
||||||
|
file (restoring its default), missing fields are left untouched.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
||||||
|
|
||||||
|
local: Optional[bool] = None
|
||||||
|
enable_http_auth: Optional[bool] = None
|
||||||
|
name: Optional[str] = None
|
||||||
|
protocol: Optional[ServerProtocol] = None
|
||||||
|
host: Optional[str] = None
|
||||||
|
port: Optional[int] = Field(None, gt=0, le=65535)
|
||||||
|
secrets_dir: Optional[str] = None
|
||||||
|
certfile: Optional[str] = None
|
||||||
|
certkey: Optional[str] = None
|
||||||
|
enable_ssl: Optional[bool] = None
|
||||||
|
images_path: Optional[str] = None
|
||||||
|
projects_path: Optional[str] = None
|
||||||
|
appliances_path: Optional[str] = None
|
||||||
|
symbols_path: Optional[str] = None
|
||||||
|
configs_path: Optional[str] = None
|
||||||
|
resources_path: Optional[str] = None
|
||||||
|
default_symbol_theme: Optional[BuiltinSymbolTheme] = None
|
||||||
|
allow_raw_images: Optional[bool] = None
|
||||||
|
auto_discover_images: Optional[bool] = None
|
||||||
|
report_errors: Optional[bool] = None
|
||||||
|
additional_images_paths: Optional[List[str]] = None
|
||||||
|
console_start_port_range: Optional[int] = Field(None, gt=0, le=65535)
|
||||||
|
console_end_port_range: Optional[int] = Field(None, gt=0, le=65535)
|
||||||
|
vnc_console_start_port_range: Optional[int] = Field(None, ge=5900, le=65535)
|
||||||
|
vnc_console_end_port_range: Optional[int] = Field(None, ge=5900, le=65535)
|
||||||
|
udp_start_port_range: Optional[int] = Field(None, gt=0, le=65535)
|
||||||
|
udp_end_port_range: Optional[int] = Field(None, gt=0, le=65535)
|
||||||
|
ubridge_path: Optional[str] = None
|
||||||
|
ubridge_control_transport: Optional[UbridgeControlTransport] = None
|
||||||
|
marker_listen_host: Optional[str] = None
|
||||||
|
marker_listen_port: Optional[int] = Field(None, ge=0, le=65535)
|
||||||
|
compute_username: Optional[str] = None
|
||||||
|
# plain str so the route can compare against SECRET_MASK / empty string
|
||||||
|
compute_password: Optional[str] = None
|
||||||
|
allowed_interfaces: Optional[List[str]] = None
|
||||||
|
default_nat_interface: Optional[str] = None
|
||||||
|
allow_remote_console: Optional[bool] = None
|
||||||
|
enable_builtin_templates: Optional[bool] = None
|
||||||
|
install_builtin_appliances: Optional[bool] = None
|
||||||
|
skills_repo_url: Optional[str] = None
|
||||||
|
skills_repo_branch: Optional[str] = None
|
||||||
|
skills_auto_update: Optional[bool] = None
|
||||||
|
mcp_enable_dns_rebinding_protection: Optional[bool] = None
|
||||||
|
mcp_allowed_hosts: Optional[List[str]] = None
|
||||||
|
mcp_allowed_origins: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ControllerSettingsUpdate(BaseModel):
|
||||||
|
"""
|
||||||
|
No jwt_secret_key field on purpose (see module docstring).
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
||||||
|
|
||||||
|
jwt_algorithm: Optional[str] = None
|
||||||
|
jwt_access_token_expire_minutes: Optional[int] = None
|
||||||
|
jwt_refresh_token_expire_minutes: Optional[int] = None
|
||||||
|
default_admin_username: Optional[str] = None
|
||||||
|
default_admin_password: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class VPCSSettingsUpdate(BaseModel):
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
||||||
|
|
||||||
|
vpcs_path: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DynamipsSettingsUpdate(BaseModel):
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
||||||
|
|
||||||
|
allocate_aux_console_ports: Optional[bool] = None
|
||||||
|
mmap_support: Optional[bool] = None
|
||||||
|
dynamips_path: Optional[str] = None
|
||||||
|
sparse_memory_support: Optional[bool] = None
|
||||||
|
ghost_ios_support: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class IOUSettingsUpdate(BaseModel):
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
||||||
|
|
||||||
|
iourc_path: Optional[str] = None
|
||||||
|
license_check: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class QemuSettingsUpdate(BaseModel):
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
||||||
|
|
||||||
|
enable_monitor: Optional[bool] = None
|
||||||
|
monitor_host: Optional[str] = None
|
||||||
|
enable_hardware_acceleration: Optional[bool] = None
|
||||||
|
require_hardware_acceleration: Optional[bool] = None
|
||||||
|
allow_unsafe_options: Optional[bool] = None
|
||||||
|
ovmf_firmware_dir: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class WebWiresharkSettingsUpdate(BaseModel):
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
||||||
|
|
||||||
|
enabled: Optional[bool] = None
|
||||||
|
image: Optional[str] = None
|
||||||
|
network_subnet: Optional[str] = None
|
||||||
|
memory: Optional[str] = None
|
||||||
|
cpus: Optional[float] = None
|
||||||
|
pids_limit: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsUpdate(BaseModel):
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
Server: Optional[ServerSettingsUpdate] = None
|
||||||
|
Controller: Optional[ControllerSettingsUpdate] = None
|
||||||
|
VPCS: Optional[VPCSSettingsUpdate] = None
|
||||||
|
Dynamips: Optional[DynamipsSettingsUpdate] = None
|
||||||
|
IOU: Optional[IOUSettingsUpdate] = None
|
||||||
|
Qemu: Optional[QemuSettingsUpdate] = None
|
||||||
|
WebWireshark: Optional[WebWiresharkSettingsUpdate] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsUpdateResponse(SettingsResponse):
|
||||||
|
|
||||||
|
restart_required: List[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Changed 'Section.option' settings that require a server restart to take effect"
|
||||||
|
)
|
||||||
230
tests/api/routes/controller/test_settings.py
Normal file
230
tests/api/routes/controller/test_settings.py
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
#
|
||||||
|
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import os
|
||||||
|
import configparser
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from fastapi import FastAPI, status
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from gns3server.config import Config
|
||||||
|
from gns3server.schemas.controller.settings import SECRET_MASK
|
||||||
|
from gns3server.services import auth_service
|
||||||
|
from gns3server.services.authentication import DEFAULT_JWT_SECRET_KEY
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.asyncio
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def stable_jwt_secret(config):
|
||||||
|
"""
|
||||||
|
A settings update reloads the configuration, which re-reads the JWT
|
||||||
|
secret from <secrets dir>/gns3_jwt_secret_key. Pin it to the default
|
||||||
|
key so the class-scoped bearer token stays valid across PUT tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
path = os.path.join(os.path.dirname(config._main_config_file), "gns3_jwt_secret_key")
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write(DEFAULT_JWT_SECRET_KEY)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
class TestSettingsRoutes:
|
||||||
|
|
||||||
|
async def test_get_settings(self, app: FastAPI, client: AsyncClient) -> None:
|
||||||
|
|
||||||
|
response = await client.get(app.url_path_for("get_server_settings"))
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
body = response.json()
|
||||||
|
assert sorted(body.keys()) == [
|
||||||
|
"Controller", "Dynamips", "IOU", "Qemu", "Server", "VPCS", "WebWireshark"
|
||||||
|
]
|
||||||
|
# deprecated sections are not exposed
|
||||||
|
assert "VirtualBox" not in body
|
||||||
|
assert "VMware" not in body
|
||||||
|
# secret managed outside of the configuration file
|
||||||
|
assert "jwt_secret_key" not in body["Controller"]
|
||||||
|
# secrets are masked (an empty secret serializes as "", it is
|
||||||
|
# only generated when the server actually starts)
|
||||||
|
assert body["Server"]["compute_password"] in ("", SECRET_MASK)
|
||||||
|
assert body["Controller"]["default_admin_password"] in ("", SECRET_MASK)
|
||||||
|
|
||||||
|
async def test_get_settings_unauthorized(self, app: FastAPI, client: AsyncClient) -> None:
|
||||||
|
|
||||||
|
# send an explicit invalid token: the class-scoped clients share the same
|
||||||
|
# underlying httpx client and its default headers depend on the fixture
|
||||||
|
# instantiation order
|
||||||
|
response = await client.get(
|
||||||
|
app.url_path_for("get_server_settings"), headers={"Authorization": "Bearer invalid_token"})
|
||||||
|
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||||
|
|
||||||
|
async def test_get_settings_forbidden(self, app: FastAPI, client: AsyncClient, test_user) -> None:
|
||||||
|
|
||||||
|
# the "User" role has no Server.Audit privilege
|
||||||
|
token = auth_service.create_access_token(test_user.username, secret_key=DEFAULT_JWT_SECRET_KEY)
|
||||||
|
response = await client.get(
|
||||||
|
app.url_path_for("get_server_settings"), headers={"Authorization": f"Bearer {token}"})
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
|
||||||
|
async def test_put_settings(self, app: FastAPI, client: AsyncClient, config: Config,
|
||||||
|
stable_jwt_secret: str) -> None:
|
||||||
|
|
||||||
|
response = await client.put(app.url_path_for("update_server_settings"), json={
|
||||||
|
"Server": {
|
||||||
|
"port": 3081,
|
||||||
|
"allowed_interfaces": ["eth0"],
|
||||||
|
"default_symbol_theme": "Classic",
|
||||||
|
"report_errors": False,
|
||||||
|
},
|
||||||
|
"Qemu": {
|
||||||
|
"enable_monitor": False,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
body = response.json()
|
||||||
|
assert body["Server"]["port"] == 3081
|
||||||
|
assert body["Server"]["allowed_interfaces"] == ["eth0"]
|
||||||
|
assert body["Server"]["default_symbol_theme"] == "Classic"
|
||||||
|
assert body["Server"]["report_errors"] is False
|
||||||
|
assert body["Qemu"]["enable_monitor"] is False
|
||||||
|
assert "Server.port" in body["restart_required"]
|
||||||
|
assert "Server.report_errors" not in body["restart_required"]
|
||||||
|
|
||||||
|
# the configuration file holds the serialized INI values
|
||||||
|
parsed = configparser.ConfigParser()
|
||||||
|
parsed.read(config._main_config_file)
|
||||||
|
assert parsed["Server"]["port"] == "3081"
|
||||||
|
assert parsed["Server"]["allowed_interfaces"] == "eth0"
|
||||||
|
assert parsed["Server"]["default_symbol_theme"] == "Classic"
|
||||||
|
assert parsed["Server"]["report_errors"] == "False"
|
||||||
|
assert parsed["Qemu"]["enable_monitor"] == "False"
|
||||||
|
|
||||||
|
async def test_put_settings_preserves_unknown_options(
|
||||||
|
self, app: FastAPI, client: AsyncClient, config: Config, stable_jwt_secret: str) -> None:
|
||||||
|
|
||||||
|
with open(config._main_config_file, "w") as f:
|
||||||
|
f.write("[Server]\nhost = 127.0.0.1\nfrobnicate = 42\n")
|
||||||
|
|
||||||
|
response = await client.put(app.url_path_for("update_server_settings"), json={"Server": {"port": 3082}})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
|
||||||
|
parsed = configparser.ConfigParser()
|
||||||
|
parsed.read(config._main_config_file)
|
||||||
|
assert parsed["Server"]["frobnicate"] == "42"
|
||||||
|
assert parsed["Server"]["host"] == "127.0.0.1"
|
||||||
|
|
||||||
|
async def test_put_settings_secrets(
|
||||||
|
self, app: FastAPI, client: AsyncClient, config: Config, stable_jwt_secret: str) -> None:
|
||||||
|
|
||||||
|
# masked secret means "unchanged": nothing is written
|
||||||
|
response = await client.put(
|
||||||
|
app.url_path_for("update_server_settings"), json={"Server": {"compute_password": SECRET_MASK}})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
parsed = configparser.ConfigParser()
|
||||||
|
parsed.read(config._main_config_file)
|
||||||
|
assert not parsed.has_option("Server", "compute_password")
|
||||||
|
|
||||||
|
# empty string means "unchanged" too
|
||||||
|
response = await client.put(
|
||||||
|
app.url_path_for("update_server_settings"), json={"Server": {"compute_password": ""}})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
parsed = configparser.ConfigParser()
|
||||||
|
parsed.read(config._main_config_file)
|
||||||
|
assert not parsed.has_option("Server", "compute_password")
|
||||||
|
|
||||||
|
# an explicit new value is written in clear text (like a hand-edited file)
|
||||||
|
response = await client.put(
|
||||||
|
app.url_path_for("update_server_settings"), json={"Server": {"compute_password": "secret123"}})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.json()["Server"]["compute_password"] == SECRET_MASK # masked in the response
|
||||||
|
parsed = configparser.ConfigParser()
|
||||||
|
parsed.read(config._main_config_file)
|
||||||
|
assert parsed["Server"]["compute_password"] == "secret123"
|
||||||
|
|
||||||
|
async def test_put_settings_null_removes_option(
|
||||||
|
self, app: FastAPI, client: AsyncClient, config: Config, stable_jwt_secret: str) -> None:
|
||||||
|
|
||||||
|
with open(config._main_config_file, "w") as f:
|
||||||
|
f.write("[Server]\nhost = 127.0.0.1\n")
|
||||||
|
|
||||||
|
response = await client.put(app.url_path_for("update_server_settings"), json={"Server": {"host": None}})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
|
||||||
|
parsed = configparser.ConfigParser()
|
||||||
|
parsed.read(config._main_config_file)
|
||||||
|
assert not parsed.has_option("Server", "host")
|
||||||
|
assert response.json()["Server"]["host"] == "0.0.0.0" # default restored
|
||||||
|
|
||||||
|
async def test_put_settings_validation_failure(
|
||||||
|
self, app: FastAPI, client: AsyncClient, config: Config, stable_jwt_secret: str) -> None:
|
||||||
|
|
||||||
|
with open(config._main_config_file, "w") as f:
|
||||||
|
f.write("[Server]\nhost = 127.0.0.1\n")
|
||||||
|
with open(config._main_config_file) as f:
|
||||||
|
content_before = f.read()
|
||||||
|
|
||||||
|
# cross-field violation: console_end_port_range must be > console_start_port_range
|
||||||
|
response = await client.put(app.url_path_for("update_server_settings"), json={
|
||||||
|
"Server": {"console_start_port_range": 10000, "console_end_port_range": 5000}})
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
|
||||||
|
with open(config._main_config_file) as f:
|
||||||
|
assert f.read() == content_before
|
||||||
|
|
||||||
|
async def test_put_settings_unknown_option_rejected(
|
||||||
|
self, app: FastAPI, client: AsyncClient, stable_jwt_secret: str) -> None:
|
||||||
|
|
||||||
|
response = await client.put(
|
||||||
|
app.url_path_for("update_server_settings"), json={"Server": {"prot": "http"}})
|
||||||
|
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
|
||||||
|
|
||||||
|
async def test_put_settings_deprecated_sections_rejected(
|
||||||
|
self, app: FastAPI, client: AsyncClient, stable_jwt_secret: str) -> None:
|
||||||
|
|
||||||
|
response = await client.put(app.url_path_for("update_server_settings"), json={"VirtualBox": {}})
|
||||||
|
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
|
||||||
|
response = await client.put(app.url_path_for("update_server_settings"), json={"VMware": {}})
|
||||||
|
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
|
||||||
|
|
||||||
|
async def test_put_settings_jwt_secret_key_rejected(
|
||||||
|
self, app: FastAPI, client: AsyncClient, stable_jwt_secret: str) -> None:
|
||||||
|
|
||||||
|
response = await client.put(
|
||||||
|
app.url_path_for("update_server_settings"), json={"Controller": {"jwt_secret_key": "nope"}})
|
||||||
|
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
|
||||||
|
|
||||||
|
async def test_put_settings_conflict(
|
||||||
|
self, app: FastAPI, client: AsyncClient, config: Config, stable_jwt_secret: str, tmpdir) -> None:
|
||||||
|
|
||||||
|
override_path = str(tmpdir / "override.conf")
|
||||||
|
with open(override_path, "w") as f:
|
||||||
|
f.write("[Server]\nhost = 10.0.0.1\n")
|
||||||
|
# a later configuration file takes precedence over the main one
|
||||||
|
Config.instance()._files.append(override_path)
|
||||||
|
|
||||||
|
response = await client.put(app.url_path_for("update_server_settings"), json={"Server": {"host": "192.168.1.1"}})
|
||||||
|
assert response.status_code == status.HTTP_409_CONFLICT
|
||||||
|
|
||||||
|
async def test_put_settings_empty_body(self, app: FastAPI, client: AsyncClient, config: Config) -> None:
|
||||||
|
|
||||||
|
response = await client.put(app.url_path_for("update_server_settings"), json={})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
body = response.json()
|
||||||
|
assert body["restart_required"] == []
|
||||||
|
assert body["Server"]["host"] # current values are returned
|
||||||
Loading…
x
Reference in New Issue
Block a user