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..a99b34074 --- /dev/null +++ b/docs/gns3-copilot/implemented/server-settings-api.md @@ -0,0 +1,105 @@ + + +# 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. +- **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 | 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 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/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/config_samples/gns3_server.conf b/gns3server/config_samples/gns3_server.conf index 50a6ca01a..c3eb76bc9 100644 --- a/gns3server/config_samples/gns3_server.conf +++ b/gns3server/config_samples/gns3_server.conf @@ -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 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/config.py b/gns3server/schemas/config.py index 0c712b351..227129b28 100644 --- a/gns3server/schemas/config.py +++ b/gns3server/schemas/config.py @@ -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": diff --git a/gns3server/schemas/controller/settings.py b/gns3server/schemas/controller/settings.py new file mode 100644 index 000000000..73bace5bf --- /dev/null +++ b/gns3server/schemas/controller/settings.py @@ -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 . +# + +""" +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" + ) 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/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 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