Merge pull request #2859 from yueguobin/feat/config-study

feat: add server settings API (GET/PUT /v3/settings)
This commit is contained in:
Jeremy Grossmann 2026-08-24 14:53:58 +02:00 committed by GitHub
commit 9a0501936c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1252 additions and 168 deletions

View File

@ -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 `<secrets_dir>/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.

View File

@ -0,0 +1,105 @@
<!--
SPDX-License-Identifier: CC-BY-SA-4.0
See LICENSE file for licensing information.
-->
# 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<br/>(extra=forbid, secret masking)"]
RS --> UC["Config.update_config()<br/>read-modify-write"]
UC --> FILE["gns3_server.conf<br/>(atomic replace, mode 0600)"]
UC --> RN["reload_and_notify()"]
RN --> CB["file-watch callbacks<br/>(runtime hot reload)"]
R --> NOTIF["notification stream:<br/>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 `<secrets_dir>/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.
## 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 |
| `gns3server/config_samples/gns3_server.conf` | annotated sample configuration, human-readable reference kept in sync with the schema |

View File

@ -1,67 +0,0 @@
<!--
SPDX-License-Identifier: CC-BY-SA-4.0
See LICENSE file for licensing information.
-->
> 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

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

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

View File

@ -6,16 +6,21 @@ 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]
; 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 +82,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 +144,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

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

@ -27,63 +27,87 @@ from pydantic import (
field_validator,
model_validator
)
from typing import List
from typing import List, Optional
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: Optional[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="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="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)
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: 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,
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)
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
@ -98,12 +122,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 +165,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: Optional[DirectoryPath] = Field(
None, description="Directory where secrets are stored (e.g. the JWT secret key)")
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: 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_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: Optional[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)
@ -202,28 +271,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":

View File

@ -0,0 +1,230 @@
#
# 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] = 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] = 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,
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] = Field(
None, description="Path of your .iourc file, the file is searched in $HOME/.iourc if not provided")
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

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

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

View File

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

View File

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