From 7d3cbf10239b450e82688ff2ba1db542cd7c008b Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 23 Aug 2026 18:54:23 +0800 Subject: [PATCH 1/7] feat: add config read-modify-write update and harden file watcher Config.update_config() applies submitted options to the main configuration file via configparser read-modify-write: unknown options are preserved, null removes an option, the merged view of all files is validated as ServerConfig before anything is written (a bad file would kill the FileWatcher polling loop), and the write is atomic (.tmp + os.replace, mode 0600). Options whose effective value is owned by a later configuration file raise ConfigConflictError instead of writing a no-op. The reload logic is factored into reload_and_notify() and the file watcher callback is exception-guarded so polling never dies. --- gns3server/config.py | 122 +++++++++++++++++++++++++++++++ gns3server/schemas/config.py | 20 +++-- gns3server/utils/file_watcher.py | 11 ++- tests/test_config.py | 119 ++++++++++++++++++++++++++++++ tests/utils/test_file_watcher.py | 17 +++++ 5 files changed, 282 insertions(+), 7 deletions(-) diff --git a/gns3server/config.py b/gns3server/config.py index c579a5328..278432358 100644 --- a/gns3server/config.py +++ b/gns3server/config.py @@ -24,6 +24,7 @@ import shutil import secrets import configparser +from enum import Enum from pydantic import ValidationError from .schemas import ServerConfig from .version import __version_info__ @@ -34,6 +35,19 @@ import logging log = logging.getLogger(__name__) +class ConfigConflictError(Exception): + """ + Raised when a configuration option is set in a configuration file that + takes precedence over the main configuration file. + """ + + +# List options written back to the configuration file as semicolon-separated +# values; every other list option is comma-separated (must match the field +# validators splitting them in gns3server.schemas.config). +LIST_OPTION_SEPARATORS = {"additional_images_paths": ";"} + + class Config: """ Configuration file management using configparser. @@ -199,6 +213,13 @@ class Config: """ log.info(f"'{file_path}' has been updated, reloading the config...") + self.reload_and_notify() + + def reload_and_notify(self): + """ + Reload the configuration files and notify registered listeners. + """ + self.read_config() for callback in self._watch_callback: callback() @@ -210,6 +231,107 @@ class Config: self.read_config() + @staticmethod + def _stringify_option(option: str, value) -> str: + """ + Serialize a settings value to its INI string representation. + """ + + if isinstance(value, bool): + return str(value) + if isinstance(value, Enum): + return str(value.value) + if isinstance(value, list): + return LIST_OPTION_SEPARATORS.get(option, ",").join(value) + return str(value) + + def update_config(self, changes: dict) -> list: + """ + Apply setting changes to the main configuration file (read-modify-write). + + Only the submitted options are set or removed, preserving any unknown + options present in the file. The merged configuration is validated + before anything is written to disk, so an invalid change leaves the + file untouched (a file that fails validation would permanently kill + the FileWatcher polling loop when it gets reloaded). + + :param changes: mapping of section name to {option: value}; a value of + None removes the option from the file, restoring its default + :returns: sorted list of changed options as "Section.option" strings + + :raises pydantic.ValidationError: the merged settings are invalid + :raises ConfigConflictError: an option is set in a configuration file + that takes precedence over the main configuration file + :raises OSError: the configuration file could not be written + """ + + if not changes: + return [] + + main_config_file = self._main_config_file + existing_files = [file for file in self._files if os.path.isfile(file)] + + # per-file parsers to find which file wins for an option + # (later files take precedence, mirroring read_config) + per_file_parsers = [] + for file in existing_files: + parser = configparser.ConfigParser(interpolation=None) + parser.read(file, encoding="utf-8") + per_file_parsers.append(parser) + + # view of what gets written: the main configuration file only + write_parser = configparser.ConfigParser(interpolation=None) + if os.path.isfile(main_config_file): + write_parser.read(main_config_file, encoding="utf-8") + + # view of what the server will load: all configuration files merged + merged_parser = configparser.ConfigParser(interpolation=None) + merged_parser.read(existing_files, encoding="utf-8") + + changed = [] + for section, options in changes.items(): + for option, value in options.items(): + winner = None + for file, parser in zip(reversed(existing_files), reversed(per_file_parsers)): + if parser.has_option(section, option): + winner = file + break + if winner is not None and winner != main_config_file: + raise ConfigConflictError( + f"'{section}.{option}' is set in '{winner}' which takes precedence " + f"over the main configuration file '{main_config_file}'" + ) + if value is None: + # explicit null: remove the option to restore its default + if write_parser.has_option(section, option): + write_parser.remove_option(section, option) + if merged_parser.has_option(section, option): + merged_parser.remove_option(section, option) + else: + option_value = self._stringify_option(option, value) + if not write_parser.has_section(section): + write_parser.add_section(section) + write_parser.set(section, option, option_value) + if not merged_parser.has_section(section): + merged_parser.add_section(section) + merged_parser.set(section, option, option_value) + changed.append(f"{section}.{option}") + + # validate the merged settings before touching the file on disk + ServerConfig(**merged_parser._sections) + + directory_name = os.path.dirname(main_config_file) + if directory_name: + os.makedirs(directory_name, exist_ok=True) + tmp_file = main_config_file + ".tmp" + fd = os.open(tmp_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as f: + write_parser.write(f) + os.replace(tmp_file, main_config_file) + + self.reload_and_notify() + return sorted(changed) + def get_config_files(self): """ Return the config files in use. diff --git a/gns3server/schemas/config.py b/gns3server/schemas/config.py index 0c712b351..c9c838513 100644 --- a/gns3server/schemas/config.py +++ b/gns3server/schemas/config.py @@ -202,28 +202,36 @@ class ServerSettings(BaseModel): def split_mcp_allowed_hosts(cls, v): if v and isinstance(v, str): return v.split(",") - return list() + if not v: + return list() + return v @field_validator("mcp_allowed_origins", mode="before") @classmethod def split_mcp_allowed_origins(cls, v): if v and isinstance(v, str): return v.split(",") - return list() + if not v: + return list() + return v @field_validator("additional_images_paths", mode="before") @classmethod def split_additional_images_paths(cls, v): - if v: + if v and isinstance(v, str): return v.split(";") - return list() + if not v: + return list() + return v @field_validator("allowed_interfaces", mode="before") @classmethod def split_allowed_interfaces(cls, v): - if v: + if v and isinstance(v, str): return v.split(",") - return list() + if not v: + return list() + return v @model_validator(mode="after") def check_console_port_range(self) -> "ServerSettings": diff --git a/gns3server/utils/file_watcher.py b/gns3server/utils/file_watcher.py index 94b64626d..508fda67d 100644 --- a/gns3server/utils/file_watcher.py +++ b/gns3server/utils/file_watcher.py @@ -19,6 +19,10 @@ import zlib import asyncio import os +import logging + +log = logging.getLogger(__name__) + class FileWatcher: """ @@ -98,7 +102,12 @@ class FileWatcher: except OSError: self._hashed[path] = None if changed: - self._callback(path) + try: + self._callback(path) + except Exception: + # never let a callback exception kill the polling loop + # (the re-schedule below must always run) + log.exception(f"Error in file watcher callback for '{path}'") asyncio.get_event_loop().call_later(self._delay, self._check_config_file_change) @property diff --git a/tests/test_config.py b/tests/test_config.py index f04ebce36..3537ae76c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -17,9 +17,11 @@ import configparser +import os import pytest from gns3server.config import Config +from gns3server.config import ConfigConflictError from gns3server.config import ServerConfig from pydantic import ValidationError @@ -158,3 +160,120 @@ def test_vmware_settings(settings: dict, exception_expected: bool): ServerConfig(**vmware_settings) else: ServerConfig(**vmware_settings) + + +def test_update_config_writes_ini_types(tmpdir): + + path = str(tmpdir / "server.conf") + with open(path, "w+") as f: + f.write("# a comment\n[Server]\nhost = 127.0.0.1\nfrobnicate = 42\n") + + config = Config(files=[path]) + changed = config.update_config({ + "Server": { + "port": 3081, + "report_errors": False, + "allowed_interfaces": ["eth0", "eth1"], + "default_symbol_theme": "Classic", + "additional_images_paths": ["/path/to/dir1", "/path/to/dir2"], + } + }) + + parsed = configparser.ConfigParser() + parsed.read(path) + assert parsed["Server"]["port"] == "3081" + assert parsed["Server"]["report_errors"] == "False" + assert parsed["Server"]["allowed_interfaces"] == "eth0,eth1" + assert parsed["Server"]["default_symbol_theme"] == "Classic" + assert parsed["Server"]["additional_images_paths"] == "/path/to/dir1;/path/to/dir2" + # options not submitted are left untouched, including unknown ones + assert parsed["Server"]["host"] == "127.0.0.1" + assert parsed["Server"]["frobnicate"] == "42" + + # in-memory settings have been reloaded + assert config.settings.Server.port == 3081 + assert config.settings.Server.report_errors is False + assert config.settings.Server.allowed_interfaces == ["eth0", "eth1"] + assert changed == [ + "Server.additional_images_paths", + "Server.allowed_interfaces", + "Server.default_symbol_theme", + "Server.port", + "Server.report_errors", + ] + + +def test_update_config_null_removes_option(tmpdir): + + path = write_config(tmpdir, {"Server": {"host": "127.0.0.1"}}) + config = Config(files=[path]) + + config.update_config({"Server": {"host": None}}) + + parsed = configparser.ConfigParser() + parsed.read(path) + assert not parsed.has_option("Server", "host") + assert config.settings.Server.host == "0.0.0.0" # default restored + + +def test_update_config_validation_failure_leaves_file_unchanged(tmpdir): + + path = write_config(tmpdir, {"Server": {"console_start_port_range": "5000"}}) + config = Config(files=[path]) + with open(path) as f: + file_content_before = f.read() + + # cross-field violation (console_end_port_range must be > console_start_port_range) + with pytest.raises(ValidationError): + config.update_config({"Server": {"console_start_port_range": 10000, "console_end_port_range": 5000}}) + + with open(path) as f: + assert f.read() == file_content_before + + +def test_update_config_conflict(tmpdir): + + main_path = write_config(tmpdir, {"Server": {"host": "127.0.0.1"}}) + override_path = str(tmpdir / "override.conf") + with open(override_path, "w+") as f: + f.write("[Server]\nhost = 10.0.0.1\n") + + config = Config(files=[main_path, override_path]) + assert config.settings.Server.host == "10.0.0.1" # later file takes precedence + + with pytest.raises(ConfigConflictError): + config.update_config({"Server": {"host": "192.168.1.1"}}) + with pytest.raises(ConfigConflictError): + config.update_config({"Server": {"host": None}}) + + with open(main_path) as f: + assert "host = 127.0.0.1" in f.read() # main file untouched + + +def test_update_config_creates_missing_main_file(tmpdir): + + path = write_config(tmpdir, {"Server": {"host": "127.0.0.1"}}) + config = Config(files=[path]) + os.remove(path) + + config.update_config({"Server": {"port": 3081}}) + + parsed = configparser.ConfigParser() + parsed.read(path) + assert parsed["Server"]["port"] == "3081" + assert not parsed.has_option("Server", "host") # removed file means defaults + + +def test_reload_and_notify(tmpdir): + + path = write_config(tmpdir, {"Server": {"host": "127.0.0.1"}}) + config = Config(files=[path]) + + notified = [] + config.listen_for_config_changes(lambda: notified.append(True)) + + write_config(tmpdir, {"Server": {"host": "192.168.1.2"}}) + config.reload_and_notify() + + assert config.settings.Server.host == "192.168.1.2" + assert notified == [True] diff --git a/tests/utils/test_file_watcher.py b/tests/utils/test_file_watcher.py index 0581cfee6..003d6162c 100644 --- a/tests/utils/test_file_watcher.py +++ b/tests/utils/test_file_watcher.py @@ -66,3 +66,20 @@ async def test_file_watcher_list(tmpdir, strategy): file2.write("b") await asyncio.sleep(0.5) callback.assert_called_with(str(file2)) + + +@pytest.mark.asyncio +async def test_file_watcher_callback_exception_does_not_stop_polling(tmpdir): + + file = tmpdir / "test" + file.write("a") + callback = MagicMock(side_effect=ValueError("callback error")) + FileWatcher(file, callback, delay=0.1) + await asyncio.sleep(0.5) + assert callback.call_count == 0 + file.write("b") + await asyncio.sleep(0.5) + assert callback.call_count == 1 # raised, but must not kill the polling loop + file.write("c") + await asyncio.sleep(0.5) + assert callback.call_count == 2 # polling survived the exception From e0962a59ed69fbe58b6827e09d0e2f080dd72ed3 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 23 Aug 2026 18:54:31 +0800 Subject: [PATCH 2/7] 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. --- gns3server/api/routes/controller/__init__.py | 7 + gns3server/api/routes/controller/settings.py | 162 +++++++++++++ gns3server/db/models/privileges.py | 8 + gns3server/schemas/__init__.py | 1 + gns3server/schemas/controller/settings.py | 221 ++++++++++++++++++ tests/api/routes/controller/test_settings.py | 230 +++++++++++++++++++ 6 files changed, 629 insertions(+) create mode 100644 gns3server/api/routes/controller/settings.py create mode 100644 gns3server/schemas/controller/settings.py create mode 100644 tests/api/routes/controller/test_settings.py 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 From 05fa4ec3760f2e9736a03daa45a5db7a205880df Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 23 Aug 2026 18:54:38 +0800 Subject: [PATCH 3/7] docs: document server settings API and add API test writing skill Move the server settings roadmap to implemented/ rewritten per the documentation standard (architecture and PUT flow diagrams, endpoint table, design notes). Add a skill covering the pytest conftest fixture model and the shared-client order-dependency trap hit while writing the settings tests. --- .claude/skills/gns3-api-test-writing/SKILL.md | 58 ++++++++++ .../implemented/server-settings-api.md | 103 ++++++++++++++++++ .../roadmap/server-settings-api-roadmap.md | 67 ------------ 3 files changed, 161 insertions(+), 67 deletions(-) create mode 100644 .claude/skills/gns3-api-test-writing/SKILL.md create mode 100644 docs/gns3-copilot/implemented/server-settings-api.md delete mode 100644 docs/gns3-copilot/roadmap/server-settings-api-roadmap.md diff --git a/.claude/skills/gns3-api-test-writing/SKILL.md b/.claude/skills/gns3-api-test-writing/SKILL.md new file mode 100644 index 000000000..6149bf21f --- /dev/null +++ b/.claude/skills/gns3-api-test-writing/SKILL.md @@ -0,0 +1,58 @@ +--- +name: gns3-api-test-writing +description: Use this skill when writing pytest tests for gns3-server API routes — the conftest fixture model, auth token variants, config isolation, and the shared-client order-dependency trap. +version: 1.0.0 +--- + +# Writing pytest API Route Tests + +## Test Environment + +- Run tests with the repo venv: `venv/bin/python -m pytest tests/api/routes/controller/test_xxx.py` (there is no system python/pytest). +- The app runs in-process (httpx `ASGIWebSocketTransport`); the database is in-memory sqlite; superadmin `admin` is seeded when the users table is created. +- `pytestmark = pytest.mark.asyncio`. Tests run on function-scoped event loops while class-scoped fixtures bind to the class loop — this works, but don't move class fixtures to function scope casually. + +## The Fixture Model (tests/conftest.py) + +Class-scoped: `app`, `db_session`, `base_client` (**one** shared httpx `AsyncClient`), `test_user` (idempotent `user1` in the "Users" group). + +`client` (admin), `authorized_client` (user1), `unauthorized_client`, `compute_client` are class-scoped wrappers that each **rewrite the shared `base_client.headers` at instantiation time**. They are instantiated lazily — by the first test that requests them. + +### The order-dependency trap + +- `unauthorized_client` is a passthrough: it sets no header and only behaves as "unauthorized" if nothing set a token on `base_client` before it. +- Mixing auth variants in one test class makes the default `Authorization` header depend on fixture instantiation order → tests pass alone but fail in a class run (or the reverse). +- Symptom signature: a 401/403 assertion receives 200. That is fixture pollution, **not** an RBAC/auth bug in the product. + +### Rule: per-request headers for auth variants + +Never rely on the client's default header for 401/403/specific-user tests. Send the token explicitly (request-level headers override client defaults — the `test_users.py` idiom): + +```python +from gns3server.services import auth_service +from gns3server.services.authentication import DEFAULT_JWT_SECRET_KEY + +token = auth_service.create_access_token(test_user.username, secret_key=DEFAULT_JWT_SECRET_KEY) +response = await client.get(url, headers={"Authorization": f"Bearer {token}"}) # specific user +response = await client.get(url, headers={"Authorization": "Bearer invalid_token"}) # 401 +``` + +Note the import: `auth_service` lives in `gns3server.services`, NOT `gns3server.services.authentication` (which only exports `DEFAULT_JWT_SECRET_KEY` and the class). + +Always pass `secret_key=DEFAULT_JWT_SECRET_KEY`: the autouse `run_around_tests` resets `Config` per test and forces the default secret, so a token minted with the config-of-the-moment dies in the next test. + +## Config-Isolated Tests + +- Request the function-scoped `config` fixture whenever the test reads/writes configuration or the endpoint under test reloads it. It points `Config` at `tmpdir/server.conf` (accessible as `config._main_config_file`). +- Any endpoint that triggers a config reload re-reads `/gns3_jwt_secret_key`, which invalidates class-scoped bearer tokens. Fix: write `DEFAULT_JWT_SECRET_KEY` to that file first — see the `stable_jwt_secret` fixture in `tests/api/routes/controller/test_settings.py`. + +## Misc Gotchas + +- Build URLs with `app.url_path_for("route_function_name")`. Router introspection is unreliable (lazy `_IncludedRouter` wrapper) — check `GET /openapi.json` instead. +- pydantic v2: an empty `SecretStr('')` serializes as `""`, **not** the mask — assert `in ("", SECRET_MASK)` for secrets that may be unset in tests. +- New privileges are seeded only at table creation (`gns3server/db/models/privileges.py`); the fresh in-memory test DB always has them, but existing deployments need manual grants. +- Error mapping: `ControllerBadRequestError` → 400, `ControllerError`/`HTTPException(409)` → 409, request schema violations → 422. + +## Failure-Diagnosis Heuristic + +**Passes in isolation, fails in a class run → suspect shared fixture state first** (`base_client.headers`, the `Config` singleton, class-scoped DB rows) — never the product code. Reproduce with `-k "test_a or test_b"` pairs to find the polluting test. Do not add debug prints to product code to chase test-order issues; make the test order-independent with explicit per-request headers instead. diff --git a/docs/gns3-copilot/implemented/server-settings-api.md b/docs/gns3-copilot/implemented/server-settings-api.md new file mode 100644 index 000000000..1e39f9be2 --- /dev/null +++ b/docs/gns3-copilot/implemented/server-settings-api.md @@ -0,0 +1,103 @@ + + +# Server Settings API + +## Overview + +REST API for reading and updating `gns3_server.conf` at runtime, enabling a server settings page in the Web UI. Updates are persisted with a read-modify-write strategy (unknown options in the file are preserved), validated before anything touches disk, and hot-reloaded into the running server; the response tells the caller which changes require a restart. + +## Architecture + +```mermaid +flowchart LR + U["Web UI / Client"] -->|"GET /v3/settings"| R["routes/controller/settings.py"] + U -->|"PUT /v3/settings"| R + R -->|"Server.Audit / Server.Modify"| RBAC["privilege check"] + R --> RS["SettingsResponse / SettingsUpdate schemas
(extra=forbid, secret masking)"] + RS --> UC["Config.update_config()
read-modify-write"] + UC --> FILE["gns3_server.conf
(atomic replace, mode 0600)"] + UC --> RN["reload_and_notify()"] + RN --> CB["file-watch callbacks
(runtime hot reload)"] + R --> NOTIF["notification stream:
settings.updated"] +``` + +- **Exposure** — all sections except the deprecated `VirtualBox`/`VMware`: `Server`, `Controller`, `VPCS`, `Dynamips`, `IOU`, `Qemu`, `WebWireshark`. `Controller.jwt_secret_key` is excluded entirely: it is loaded from `/gns3_jwt_secret_key` and writing it to the configuration file is a no-op. +- **Write strategy** — `Config.update_config()` re-reads the configuration files with `configparser`, applies only the submitted options, and atomically rewrites the main configuration file. Comments and formatting are lost (accepted trade-off); options unknown to the schema are preserved. +- **Validate before write** — the merged view of all configuration files is validated as `ServerConfig` *before* any disk write. A validation error must never reach disk: the `FileWatcher` reload callback would raise and permanently stop polling that file. + +## Business Process (PUT) + +```mermaid +sequenceDiagram + participant C as Client + participant R as PUT /v3/settings + participant U as Config.update_config() + participant D as gns3_server.conf + C->>R: PUT {"Section": {"option": value | null}} + R->>R: schema validation (unknown key/section → 422) + R->>U: changes (masked/empty secrets skipped) + U->>U: locate owning file per option (later file wins) + alt option owned by a non-main configuration file + U-->>R: ConfigConflictError → 409 (nothing written) + else + U->>U: set / remove option (null removes, value falls back to default) + U->>U: validate merged ServerConfig (failure → 400, file untouched) + U->>D: atomic write (.tmp + os.replace, mode 0600) + U->>U: reload_and_notify() → runtime hot reload + R-->>C: 200 — new values + restart_required + R->>C: notification settings.updated (option names only, no values) + end +``` + +## API Endpoints + +| Method | Path | Description | Privilege | +|--------|------|-------------|-----------| +| GET | `/v3/settings` | Return all current server settings | `Server.Audit` | +| PUT | `/v3/settings` | Update and persist server settings | `Server.Modify` | + +Request example: + +```json +{ + "Server": { + "report_errors": true, + "allowed_interfaces": ["eth0", "lo"], + "compute_password": "**********" + }, + "Qemu": { + "enable_monitor": false + } +} +``` + +Response example (abbreviated): + +```json +{ + "Server": { "report_errors": true, "allowed_interfaces": ["eth0", "lo"], "...": "..." }, + "Qemu": { "enable_monitor": false }, + "restart_required": ["Server.port"] +} +``` + +## Notes + +- **Secrets** — `SecretStr` fields are masked in responses. An empty secret (e.g. `compute_password` before the server generates one) serializes as `""` rather than the mask. On PUT, the mask or an empty string means "leave unchanged"; only an explicit new value is written, in clear text like a hand-edited file. +- **`restart_required`** — options that only take effect after a server restart (bind host/port, protocol, TLS and certificates, port ranges, image/symbol/config paths, GNS3 VM credentials, skills paths, …). Everything else hot-reloads via `Config.instance().settings`. +- **GET reflects runtime values** — in-memory settings may differ from disk (e.g. the generated `compute_password`, the resolved `secrets_dir`); the mask/empty skip rule guarantees PUT never writes echoed values back. +- **Privileges** — `Server.Audit`/`Server.Modify` are seeded into the `Administrator` role at table creation only; existing databases need a manual grant. Superadmins bypass RBAC. +- **Hardening** — the file watcher callback is exception-guarded (`utils/file_watcher.py`): a callback failure is logged instead of silently killing the polling loop. + +### Related Files + +| File | Role | +|------|------| +| `gns3server/config.py` | `Config.update_config()` (read-modify-write, validate-before-write), `reload_and_notify()` | +| `gns3server/utils/file_watcher.py` | callback exception hardening | +| `gns3server/schemas/controller/settings.py` | response/update models, `SECRET_MASK` | +| `gns3server/api/routes/controller/settings.py` | GET/PUT endpoints, `restart_required`, notification | +| `gns3server/db/models/privileges.py` | `Server.Audit` / `Server.Modify` privilege seeds | diff --git a/docs/gns3-copilot/roadmap/server-settings-api-roadmap.md b/docs/gns3-copilot/roadmap/server-settings-api-roadmap.md deleted file mode 100644 index 887814374..000000000 --- a/docs/gns3-copilot/roadmap/server-settings-api-roadmap.md +++ /dev/null @@ -1,67 +0,0 @@ - - -> This document is a roadmap/planning document. The described features have not been implemented yet. - - -# Server Settings REST API — Roadmap - -## Problem - -Currently, `gns3_server.conf` can only be modified by directly editing the file on disk. There is no REST API endpoint to read or write server configuration, which prevents the Web UI from offering a settings page for server parameters. - -## Proposed API - -``` -GET /v3/settings → Return all current server settings -PUT /v3/settings → Update and persist server settings -``` - -### Implementation Plan - -**1. Add `save_config()` to `Config` class** (`gns3server/config.py`) - -The `Config` class currently only reads configuration (via `read_config()` / `reload()`). A `save_config()` method is needed to serialize the in-memory `ServerConfig` pydantic model back to INI format and write it to disk. - -Serialization details: -- `bool` → `"True"` / `"False"` (configparser convention) -- `SecretStr` → `get_secret_value()` -- `Enum` → `.value` -- `List[str]` → semi-colon for `additional_images_paths`, comma for `allowed_interfaces` -- `None` → skip - -**2. Add a settings getter/setter** to `Config` to allow programmatic updates to the in-memory settings. - -**3. New route file** (`gns3server/api/routes/controller/settings.py`): - -- `GET /v3/settings` — returns the full `ServerConfig` as JSON (pydantic automatically masks `SecretStr` fields as `"********"`) -- `PUT /v3/settings` — accepts `ServerConfig`, merges existing secrets when placeholder values (`"********"`) are submitted, calls `save_config()`, and triggers runtime config update callbacks - -Both endpoints require `get_current_active_user` for authentication. - -**4. Register the new router** in `gns3server/api/routes/controller/__init__.py` under the `/settings` prefix. - -### Security - -- All settings endpoints require admin authentication (`get_current_active_user`) -- `SecretStr` fields (`compute_password`, `default_admin_password`, `jwt_secret_key`) are masked in responses -- On write, unchanged secrets are preserved via placeholder detection - -### Related Files - -| File | Role | -|------|------| -| `gns3server/config.py` | Config singleton with `read_config()` / `reload()` | -| `gns3server/schemas/config.py` | `ServerConfig` pydantic model with all 9 sub-models | -| `gns3server/api/routes/controller/__init__.py` | Controller router mounting | -| `gns3server/controller/__init__.py` | `Controller._update_config()` for runtime credential sync | - -## Status - -- [ ] gns3server/config.py: add `save_config()` method -- [ ] gns3server/config.py: add settings getter/setter -- [ ] gns3server/api/routes/controller/settings.py: new route file with GET and PUT endpoints -- [ ] gns3server/api/routes/controller/__init__.py: register settings router under `/settings` -- [ ] gns3server/api/routes/controller/controller.py: add notification emission on config change From b7dbf45a90657b341be1f4e00b3fd9bb09688b5e Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 24 Aug 2026 00:35:26 +0800 Subject: [PATCH 4/7] docs: add field descriptions to the settings schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document all 70 configuration fields with pydantic Field descriptions, ported from the config sample comments and verified against the actual consumers (allow_remote_console and local had no documentation anywhere). The descriptions flow into the OpenAPI schema of GET /v3/settings, giving the Web UI tooltips, defaults and validation bounds from a single source. Response models re-declare six path fields as plain strings, which drops the inherited description — restore them explicitly. Sync the config sample: document local and allow_remote_console, drop hardware_virtualization_check which no longer exists in the schema. --- gns3server/config_samples/gns3_server.conf | 10 +- gns3server/schemas/config.py | 235 +++++++++++++-------- gns3server/schemas/controller/settings.py | 23 +- 3 files changed, 174 insertions(+), 94 deletions(-) diff --git a/gns3server/config_samples/gns3_server.conf b/gns3server/config_samples/gns3_server.conf index 50a6ca01a..86c6007e2 100644 --- a/gns3server/config_samples/gns3_server.conf +++ b/gns3server/config_samples/gns3_server.conf @@ -16,6 +16,9 @@ default_admin_password = admin [Server] +; Local server mode, set by the --local command line argument (not meant to be set by hand) +;local = False + ; Server name, default is what is returned by socket.gethostname() name = GNS3_Server @@ -77,6 +80,10 @@ console_start_port_range = 5000 ; Last console port of the range allocated to devices console_end_port_range = 10000 +; Allow console connections from remote machines +; (console ports only accept local connections by default) +;allow_remote_console = False + ; First VNC console port of the range allocated to devices. ; The value MUST BE >= 5900 and <= 65535 vnc_console_start_port_range = 5900 @@ -135,9 +142,6 @@ install_builtin_appliances = True ; Automatically pull updates from the skills repository when reloading ; skills_auto_update = false -; check if hardware virtualization is used by other emulators (KVM, VMware or VirtualBox) -hardware_virtualization_check = True - [VPCS] ; VPCS executable location, default: search in PATH ;vpcs_path = vpcs diff --git a/gns3server/schemas/config.py b/gns3server/schemas/config.py index c9c838513..fdcd5dc99 100644 --- a/gns3server/schemas/config.py +++ b/gns3server/schemas/config.py @@ -32,46 +32,68 @@ from typing import List class ControllerSettings(BaseModel): - jwt_secret_key: str = None - jwt_algorithm: str = "HS256" - jwt_access_token_expire_minutes: int = 1440 # 24 hours - jwt_refresh_token_expire_minutes: int = 43200 # 30 days - default_admin_username: str = "admin" - default_admin_password: SecretStr = SecretStr("admin") + jwt_secret_key: str = Field( + None, + description="Secret key used to sign the JWT authentication tokens " + "(normally managed via the secrets directory, not the configuration file)") + jwt_algorithm: str = Field("HS256", description="Algorithm used to sign the JWT tokens") + jwt_access_token_expire_minutes: int = Field( + 1440, description="Lifetime of the JWT access tokens in minutes (24 hours by default)") + jwt_refresh_token_expire_minutes: int = Field( + 43200, description="Lifetime of the JWT refresh tokens in minutes (30 days by default)") + default_admin_username: str = Field( + "admin", + description="Initial default super admin username, cannot be changed once the controller has started once") + default_admin_password: SecretStr = Field( + SecretStr("admin"), + description="Initial default super admin password, cannot be changed once the controller has started once") model_config = ConfigDict(validate_assignment=True, str_strip_whitespace=True) class VPCSSettings(BaseModel): - vpcs_path: str = "vpcs" + vpcs_path: str = Field("vpcs", description="VPCS executable location, default: search in PATH") model_config = ConfigDict(validate_assignment=True, str_strip_whitespace=True) class DynamipsSettings(BaseModel): - allocate_aux_console_ports: bool = False - mmap_support: bool = True - dynamips_path: str = "dynamips" - sparse_memory_support: bool = True - ghost_ios_support: bool = True + allocate_aux_console_ports: bool = Field( + False, description="Allocate auxiliary console ports on IOS routers") + mmap_support: bool = Field( + True, description="Use memory-mapped flash files (mmap) to lower the memory usage of routers") + dynamips_path: str = Field("dynamips", description="Dynamips executable location, default: search in PATH") + sparse_memory_support: bool = Field( + True, description="Use sparse memory allocation to lower the memory usage of routers") + ghost_ios_support: bool = Field( + True, description="Enable Ghost IOS support to share memory between identical IOS images") model_config = ConfigDict(validate_assignment=True, str_strip_whitespace=True) class IOUSettings(BaseModel): - iourc_path: str = None - license_check: bool = True + iourc_path: str = Field( + None, description="Path of your .iourc file, the file is searched in $HOME/.iourc if not provided") + license_check: bool = Field( + True, + description="Validate the iourc license file (if disabled, IOU will not start and no errors " + "will be shown when the license is invalid)") model_config = ConfigDict(validate_assignment=True, str_strip_whitespace=True) class QemuSettings(BaseModel): - enable_monitor: bool = True - monitor_host: str = "127.0.0.1" - enable_hardware_acceleration: bool = True - require_hardware_acceleration: bool = False - allow_unsafe_options: bool = False - ovmf_firmware_dir: str = "/usr/share/OVMF" + enable_monitor: bool = Field( + True, description="Use the Qemu monitor feature to communicate with Qemu VMs") + monitor_host: str = Field("127.0.0.1", description="IP used to listen for the monitor") + enable_hardware_acceleration: bool = Field( + True, description="Enable hardware acceleration (KVM)") + require_hardware_acceleration: bool = Field( + False, description="Require hardware acceleration in order to start VMs") + allow_unsafe_options: bool = Field( + False, description="Allow unsafe additional command line options") + ovmf_firmware_dir: str = Field( + "/usr/share/OVMF", description="Path to the OVMF firmware directory") model_config = ConfigDict(validate_assignment=True, str_strip_whitespace=True) @@ -98,12 +120,17 @@ class VMwareSettings(BaseModel): class WebWiresharkSettings(BaseModel): - enabled: bool = True - image: str = "gns3/web-wireshark:latest" - network_subnet: str = "172.31.0.0/22" - memory: str = "2g" - cpus: float = 1.0 - pids_limit: int = 1000 + enabled: bool = Field( + True, description="Enable the Web Wireshark feature (container-based Wireshark in the browser)") + image: str = Field( + "gns3/web-wireshark:latest", description="Docker image for the Web Wireshark containers") + network_subnet: str = Field( + "172.31.0.0/22", + description="Docker network subnet for the Web Wireshark containers (change it if it conflicts " + "with your existing network)") + memory: str = Field("2g", description='Memory limit per container (e.g. "512m", "2g")') + cpus: float = Field(1.0, description="CPU cores per container (e.g. 1.0, 2.0)") + pids_limit: int = Field(1000, description="Process limit per container") model_config = ConfigDict(validate_assignment=True, str_strip_whitespace=True) @@ -136,64 +163,104 @@ class BuiltinSymbolTheme(str, Enum): class ServerSettings(BaseModel): - local: bool = False - enable_http_auth: bool = True - name: str = f"{socket.gethostname()} (controller)" - protocol: ServerProtocol = ServerProtocol.http - host: str = "0.0.0.0" - port: int = Field(3080, gt=0, le=65535) - secrets_dir: DirectoryPath = None - certfile: FilePath = None - certkey: FilePath = None - enable_ssl: bool = False - images_path: str = "~/GNS3/images" - projects_path: str = "~/GNS3/projects" - appliances_path: str = "~/GNS3/appliances" - symbols_path: str = "~/GNS3/symbols" - configs_path: str = "~/GNS3/configs" - resources_path: str = None - default_symbol_theme: BuiltinSymbolTheme = BuiltinSymbolTheme.affinity_square_blue - allow_raw_images: bool = True - auto_discover_images: bool = True - report_errors: bool = True - additional_images_paths: List[str] = Field(default_factory=list) - console_start_port_range: int = Field(5000, gt=0, le=65535) - console_end_port_range: int = Field(10000, gt=0, le=65535) - vnc_console_start_port_range: int = Field(5900, ge=5900, le=65535) - vnc_console_end_port_range: int = Field(10000, ge=5900, le=65535) - udp_start_port_range: int = Field(10000, gt=0, le=65535) - udp_end_port_range: int = Field(30000, gt=0, le=65535) - ubridge_path: str = "ubridge" - # Transport for the uBridge hypervisor control channel. "unix" (-U, - # AF_UNIX + SO_PEERCRED) is the default — recommended on Linux for - # kernel-level peer authentication. "tcp" (-H) is retained for backward - # compatibility. - ubridge_control_transport: UbridgeControlTransport = UbridgeControlTransport.unix - # Marker (traffic-insight) UDP sink: one listener per compute process that - # receives ubridge MARK signals from every ubridge on this host. The host - # defaults to loopback because ubridge runs on the same host as the compute. - # port=0 lets the OS choose a free port (read back and handed to ubridge). - marker_listen_host: str = "127.0.0.1" - marker_listen_port: int = Field(3070, ge=0, le=65535) - compute_username: str = "gns3" - compute_password: SecretStr = SecretStr("") - allowed_interfaces: List[str] = Field(default_factory=list) - default_nat_interface: str = None - allow_remote_console: bool = False - enable_builtin_templates: bool = True - install_builtin_appliances: bool = True - skills_repo_url: str = "https://github.com/gns3/gns3-skills.git" - skills_repo_branch: str = "main" - skills_auto_update: bool = True - - # MCP (Model Context Protocol) transport security settings - # DNS rebinding protection is disabled by default to allow connections - # from any host (aligns with GNS3 server's 0.0.0.0 binding). - # Users with security requirements can enable protection and specify - # allowed hosts using "host:*" port wildcard patterns. - mcp_enable_dns_rebinding_protection: bool = False - mcp_allowed_hosts: list[str] = Field(default_factory=list) - mcp_allowed_origins: list[str] = Field(default_factory=list) + local: bool = Field( + False, + description="Local server mode, set by the --local command line argument (not meant to be set by hand)") + enable_http_auth: bool = Field(True, description="Enable compute HTTP authentication") + name: str = Field( + f"{socket.gethostname()} (controller)", + description="Server name, default is what is returned by socket.gethostname()") + protocol: ServerProtocol = Field( + ServerProtocol.http, description="Protocol used by the server: http or https") + host: str = Field("0.0.0.0", description="IP address where the server listens for connections") + port: int = Field(3080, gt=0, le=65535, description="HTTP port used to control the server") + secrets_dir: DirectoryPath = Field( + None, description="Directory where secrets are stored (e.g. the JWT secret key)") + certfile: FilePath = Field(None, description="SSL certificate file, requires enable_ssl") + certkey: FilePath = Field(None, description="SSL key file, requires enable_ssl") + enable_ssl: bool = Field(False, description="Enable SSL encryption") + images_path: str = Field("~/GNS3/images", description="Path where binary images are stored") + projects_path: str = Field("~/GNS3/projects", description="Path where user projects are stored") + appliances_path: str = Field("~/GNS3/appliances", description="Path where custom user appliances are stored") + symbols_path: str = Field("~/GNS3/symbols", description="Path where custom user symbols are stored") + configs_path: str = Field("~/GNS3/configs", description="Path where custom user configs are stored") + resources_path: str = Field( + None, + description="Path where files like built-in appliances and Docker resources are stored " + "(defaults to the local user data directory)") + default_symbol_theme: BuiltinSymbolTheme = Field( + BuiltinSymbolTheme.affinity_square_blue, + description='Default symbol theme, e.g. "Classic" or "Affinity-square-blue"') + allow_raw_images: bool = Field( + True, description="Allow raw images to be uploaded to the server") + auto_discover_images: bool = Field( + True, description="Automatically discover images in the images directory") + report_errors: bool = Field( + True, description="Automatically send crash reports to the GNS3 team") + additional_images_paths: List[str] = Field( + default_factory=list, + description="Additional paths to look for images (semicolon-separated in the configuration file)") + console_start_port_range: int = Field( + 5000, gt=0, le=65535, description="First console port of the range allocated to devices") + console_end_port_range: int = Field( + 10000, gt=0, le=65535, description="Last console port of the range allocated to devices") + vnc_console_start_port_range: int = Field( + 5900, ge=5900, le=65535, description="First VNC console port of the range allocated to devices") + vnc_console_end_port_range: int = Field( + 10000, ge=5900, le=65535, description="Last VNC console port of the range allocated to devices") + udp_start_port_range: int = Field( + 10000, gt=0, le=65535, + description="First UDP port of the range allocated for inter-device communication (two ports per link)") + udp_end_port_range: int = Field( + 30000, gt=0, le=65535, + description="Last UDP port of the range allocated for inter-device communication (two ports per link)") + ubridge_path: str = Field("ubridge", description="uBridge executable location, default: search in PATH") + ubridge_control_transport: UbridgeControlTransport = Field( + UbridgeControlTransport.unix, + description='uBridge control channel transport: "unix" (AF_UNIX + SO_PEERCRED, recommended ' + 'on Linux) or "tcp" (loopback, kept for backward compatibility)') + marker_listen_host: str = Field( + "127.0.0.1", + description="Marker (traffic-insight) UDP sink listen host: one listener per compute process " + "receives uBridge MARK signals from every uBridge on this host") + marker_listen_port: int = Field( + 3070, ge=0, le=65535, + description="Marker UDP sink listen port (0 lets the operating system choose a free port)") + compute_username: str = Field( + "gns3", description='Username for compute HTTP authentication, "gns3" is the default') + compute_password: SecretStr = Field( + SecretStr(""), + description="Password for compute HTTP authentication, a randomly generated password is used if not set") + allowed_interfaces: List[str] = Field( + default_factory=list, + description="Only allow these interfaces to be used by GNS3, for the Cloud node for example " + "(comma-separated; do not forget virbr0 for the NAT node to work)") + default_nat_interface: str = Field( + None, description="Interface used by the NAT node, default is virbr0 on Linux (requires libvirt)") + allow_remote_console: bool = Field( + False, + description="Allow console connections from remote machines " + "(console ports only accept local connections by default)") + enable_builtin_templates: bool = Field(True, description="Enable the built-in templates") + install_builtin_appliances: bool = Field(True, description="Install the built-in appliances") + skills_repo_url: str = Field( + "https://github.com/gns3/gns3-skills.git", + description="Git repository URL for the external GNS3 Copilot skills " + "(injection skills, prompts and device skills)") + skills_repo_branch: str = Field("main", description="Git branch of the skills repository") + skills_auto_update: bool = Field( + True, description="Automatically pull updates from the skills repository when reloading") + mcp_enable_dns_rebinding_protection: bool = Field( + False, + description="Enable MCP transport DNS rebinding protection " + "(allowed hosts and origins must be configured)") + mcp_allowed_hosts: list[str] = Field( + default_factory=list, + description='Allowed hosts for MCP connections, only "host:*" port wildcards are supported ' + '(e.g. "127.0.0.1:*")') + mcp_allowed_origins: list[str] = Field( + default_factory=list, + description='Allowed origins for MCP connections (e.g. "http://localhost:*")') model_config = ConfigDict(validate_assignment=True, str_strip_whitespace=True) diff --git a/gns3server/schemas/controller/settings.py b/gns3server/schemas/controller/settings.py index 9d7f7a233..73bace5bf 100644 --- a/gns3server/schemas/controller/settings.py +++ b/gns3server/schemas/controller/settings.py @@ -50,24 +50,33 @@ 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 + secrets_dir: Optional[str] = Field( + None, description="Directory where secrets are stored (e.g. the JWT secret key)") + certfile: Optional[str] = Field(None, description="SSL certificate file, requires enable_ssl") + certkey: Optional[str] = Field(None, description="SSL key file, requires enable_ssl") # 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 + resources_path: Optional[str] = Field( + None, + description="Path where files like built-in appliances and Docker resources are stored " + "(defaults to the local user data directory)") + default_nat_interface: Optional[str] = Field( + None, description="Interface used by the NAT node, default is virbr0 on Linux (requires libvirt)") class ControllerSettingsResponse(ControllerSettings): # never serialized: managed via the secrets directory, not the configuration file - jwt_secret_key: Optional[str] = Field(default=None, exclude=True) + jwt_secret_key: Optional[str] = Field( + default=None, exclude=True, + description="Secret key used to sign the JWT authentication tokens " + "(normally managed via the secrets directory, not the configuration file)") class IOUSettingsResponse(IOUSettings): - iourc_path: Optional[str] = None + iourc_path: Optional[str] = Field( + None, description="Path of your .iourc file, the file is searched in $HOME/.iourc if not provided") class SettingsResponse(BaseModel): From 27ffc1e7a60c38e780e591999089b0a619ef8e46 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 24 Aug 2026 00:42:48 +0800 Subject: [PATCH 5/7] fix: type optional configuration fields as Optional secrets_dir, certfile and certkey were typed FilePath/DirectoryPath with a None default, and several str fields (jwt_secret_key, iourc_path, resources_path, default_nat_interface, vboxmanage_path, vmrun_path) had the same mismatch. Serializing the unset values emitted PydanticSerializationUnexpectedValue warnings on every GET /v3/settings (two lines per request with SSL disabled). --- gns3server/schemas/config.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/gns3server/schemas/config.py b/gns3server/schemas/config.py index fdcd5dc99..6d1977ed7 100644 --- a/gns3server/schemas/config.py +++ b/gns3server/schemas/config.py @@ -27,12 +27,12 @@ from pydantic import ( field_validator, model_validator ) -from typing import List +from typing import List, Optional class ControllerSettings(BaseModel): - jwt_secret_key: str = Field( + jwt_secret_key: Optional[str] = Field( None, description="Secret key used to sign the JWT authentication tokens " "(normally managed via the secrets directory, not the configuration file)") @@ -72,7 +72,7 @@ class DynamipsSettings(BaseModel): class IOUSettings(BaseModel): - iourc_path: str = Field( + iourc_path: Optional[str] = Field( None, description="Path of your .iourc file, the file is searched in $HOME/.iourc if not provided") license_check: bool = Field( True, @@ -99,13 +99,13 @@ class QemuSettings(BaseModel): class VirtualBoxSettings(BaseModel): - vboxmanage_path: str = None + vboxmanage_path: Optional[str] = None model_config = ConfigDict(validate_assignment=True, str_strip_whitespace=True) class VMwareSettings(BaseModel): - vmrun_path: str = None + vmrun_path: Optional[str] = None vmnet_start_range: int = Field(2, ge=1, le=255) vmnet_end_range: int = Field(255, ge=1, le=255) # should be limited to 19 on Windows block_host_traffic: bool = False @@ -174,17 +174,17 @@ class ServerSettings(BaseModel): ServerProtocol.http, description="Protocol used by the server: http or https") host: str = Field("0.0.0.0", description="IP address where the server listens for connections") port: int = Field(3080, gt=0, le=65535, description="HTTP port used to control the server") - secrets_dir: DirectoryPath = Field( + secrets_dir: Optional[DirectoryPath] = Field( None, description="Directory where secrets are stored (e.g. the JWT secret key)") - certfile: FilePath = Field(None, description="SSL certificate file, requires enable_ssl") - certkey: FilePath = Field(None, description="SSL key file, requires enable_ssl") + certfile: Optional[FilePath] = Field(None, description="SSL certificate file, requires enable_ssl") + certkey: Optional[FilePath] = Field(None, description="SSL key file, requires enable_ssl") enable_ssl: bool = Field(False, description="Enable SSL encryption") images_path: str = Field("~/GNS3/images", description="Path where binary images are stored") projects_path: str = Field("~/GNS3/projects", description="Path where user projects are stored") appliances_path: str = Field("~/GNS3/appliances", description="Path where custom user appliances are stored") symbols_path: str = Field("~/GNS3/symbols", description="Path where custom user symbols are stored") configs_path: str = Field("~/GNS3/configs", description="Path where custom user configs are stored") - resources_path: str = Field( + resources_path: Optional[str] = Field( None, description="Path where files like built-in appliances and Docker resources are stored " "(defaults to the local user data directory)") @@ -235,7 +235,7 @@ class ServerSettings(BaseModel): default_factory=list, description="Only allow these interfaces to be used by GNS3, for the Cloud node for example " "(comma-separated; do not forget virbr0 for the NAT node to work)") - default_nat_interface: str = Field( + default_nat_interface: Optional[str] = Field( None, description="Interface used by the NAT node, default is virbr0 on Linux (requires libvirt)") allow_remote_console: bool = Field( False, From 4dcde5df596a1fbbe9a47f4b8f6587298ee88f11 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 24 Aug 2026 00:45:29 +0800 Subject: [PATCH 6/7] docs: document settings schema metadata in the settings API doc Field descriptions, defaults and validation bounds are exposed via the OpenAPI schema, letting clients render the settings form without a hand-maintained field table; the annotated config sample is the human-readable reference. --- docs/gns3-copilot/implemented/server-settings-api.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/gns3-copilot/implemented/server-settings-api.md b/docs/gns3-copilot/implemented/server-settings-api.md index 1e39f9be2..a99b34074 100644 --- a/docs/gns3-copilot/implemented/server-settings-api.md +++ b/docs/gns3-copilot/implemented/server-settings-api.md @@ -25,6 +25,7 @@ flowchart LR ``` - **Exposure** — all sections except the deprecated `VirtualBox`/`VMware`: `Server`, `Controller`, `VPCS`, `Dynamips`, `IOU`, `Qemu`, `WebWireshark`. `Controller.jwt_secret_key` is excluded entirely: it is loaded from `/gns3_jwt_secret_key` and writing it to the configuration file is a no-op. +- **Schema metadata** — every field carries a pydantic `description`, default value and validation bounds. They flow into `/openapi.json` (the `SettingsResponse` component), so clients can render the settings form — labels, tooltips, initial values, input validation — from the OpenAPI schema alone, without maintaining a field table. The human-readable annotated reference is `gns3server/config_samples/gns3_server.conf`, kept in sync with the schema. - **Write strategy** — `Config.update_config()` re-reads the configuration files with `configparser`, applies only the submitted options, and atomically rewrites the main configuration file. Comments and formatting are lost (accepted trade-off); options unknown to the schema are preserved. - **Validate before write** — the merged view of all configuration files is validated as `ServerConfig` *before* any disk write. A validation error must never reach disk: the `FileWatcher` reload callback would raise and permanently stop polling that file. @@ -101,3 +102,4 @@ Response example (abbreviated): | `gns3server/schemas/controller/settings.py` | response/update models, `SECRET_MASK` | | `gns3server/api/routes/controller/settings.py` | GET/PUT endpoints, `restart_required`, notification | | `gns3server/db/models/privileges.py` | `Server.Audit` / `Server.Modify` privilege seeds | +| `gns3server/config_samples/gns3_server.conf` | annotated sample configuration, human-readable reference kept in sync with the schema | From 317f4d240604758fa477ee552ae08a04c9cc0953 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 24 Aug 2026 01:12:48 +0800 Subject: [PATCH 7/7] docs: clarify the default admin credentials only seed the database The default_admin_username/password descriptions now state that the values are applied when the controller database is created and only take effect again after it is re-created (resetting the account). --- gns3server/config_samples/gns3_server.conf | 10 ++++++---- gns3server/schemas/config.py | 6 ++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/gns3server/config_samples/gns3_server.conf b/gns3server/config_samples/gns3_server.conf index 86c6007e2..c3eb76bc9 100644 --- a/gns3server/config_samples/gns3_server.conf +++ b/gns3server/config_samples/gns3_server.conf @@ -6,12 +6,14 @@ jwt_algorithm = HS256 jwt_access_token_expire_minutes = 1440 jwt_refresh_token_expire_minutes = 43200 -; Initial default super admin username -; It cannot be changed once the controller has started once +; Username of the super admin account seeded when the controller database is +; created. Changing it has no effect until the database is re-created (which +; resets the account back to these values). default_admin_username = admin -; Initial default super admin password -; It cannot be changed once the controller has started once +; Password of the super admin account seeded when the controller database is +; created. Changing it has no effect until the database is re-created (which +; resets the account back to these values). default_admin_password = admin [Server] diff --git a/gns3server/schemas/config.py b/gns3server/schemas/config.py index 6d1977ed7..227129b28 100644 --- a/gns3server/schemas/config.py +++ b/gns3server/schemas/config.py @@ -43,10 +43,12 @@ class ControllerSettings(BaseModel): 43200, description="Lifetime of the JWT refresh tokens in minutes (30 days by default)") default_admin_username: str = Field( "admin", - description="Initial default super admin username, cannot be changed once the controller has started once") + description="Username of the super admin account seeded when the controller database is created; " + "changing it has no effect until the database is re-created (which resets the account)") default_admin_password: SecretStr = Field( SecretStr("admin"), - description="Initial default super admin password, cannot be changed once the controller has started once") + description="Password of the super admin account seeded when the controller database is created; " + "changing it has no effect until the database is re-created (which resets the account)") model_config = ConfigDict(validate_assignment=True, str_strip_whitespace=True)