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