diff --git a/gns3server/api/routes/controller/__init__.py b/gns3server/api/routes/controller/__init__.py index 78d9caf67..25334f81b 100644 --- a/gns3server/api/routes/controller/__init__.py +++ b/gns3server/api/routes/controller/__init__.py @@ -62,6 +62,7 @@ from . import pools from . import privileges from . import api_keys from . import netmiko +from . import settings from .dependencies.authentication import get_current_active_user @@ -72,6 +73,12 @@ router.include_router( tags=["Controller"] ) +router.include_router( + settings.router, + prefix="/settings", + tags=["Server settings"] +) + router.include_router( users.router, prefix="/access/users", diff --git a/gns3server/api/routes/controller/settings.py b/gns3server/api/routes/controller/settings.py new file mode 100644 index 000000000..9924435ac --- /dev/null +++ b/gns3server/api/routes/controller/settings.py @@ -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 . + +""" +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) diff --git a/gns3server/db/models/privileges.py b/gns3server/db/models/privileges.py index 165a9cec9..c446b17ee 100644 --- a/gns3server/db/models/privileges.py +++ b/gns3server/db/models/privileges.py @@ -238,6 +238,14 @@ def create_default_roles(target, connection, **kw): { "description": "Update an LLM model configuration", "name": "LLMConfig.Modify" + }, + { + "description": "View server settings", + "name": "Server.Audit" + }, + { + "description": "Update server settings", + "name": "Server.Modify" } ] diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py index 0d4ed490f..bc8cca5b9 100644 --- a/gns3server/schemas/__init__.py +++ b/gns3server/schemas/__init__.py @@ -56,6 +56,7 @@ except ImportError: pass 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.tokens import Token, ApiKeyCreate, RefreshTokenRequest from .controller.snapshots import SnapshotCreate, Snapshot diff --git a/gns3server/schemas/controller/settings.py b/gns3server/schemas/controller/settings.py new file mode 100644 index 000000000..9d7f7a233 --- /dev/null +++ b/gns3server/schemas/controller/settings.py @@ -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 . +# + +""" +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" + ) diff --git a/tests/api/routes/controller/test_settings.py b/tests/api/routes/controller/test_settings.py new file mode 100644 index 000000000..21f6d6e56 --- /dev/null +++ b/tests/api/routes/controller/test_settings.py @@ -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 . + +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 /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