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:
YueGuobin 2026-08-23 18:54:31 +08:00
parent 7d3cbf1023
commit e0962a59ed
No known key found for this signature in database
6 changed files with 629 additions and 0 deletions

View File

@ -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",

View 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)

View File

@ -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"
}
]

View File

@ -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

View 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"
)

View 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