mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-08-27 12:30:13 +03:00
Merge pull request #2859 from yueguobin/feat/config-study
feat: add server settings API (GET/PUT /v3/settings)
This commit is contained in:
commit
9a0501936c
58
.claude/skills/gns3-api-test-writing/SKILL.md
Normal file
58
.claude/skills/gns3-api-test-writing/SKILL.md
Normal 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.
|
||||
105
docs/gns3-copilot/implemented/server-settings-api.md
Normal file
105
docs/gns3-copilot/implemented/server-settings-api.md
Normal 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 |
|
||||
@ -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
|
||||
@ -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",
|
||||
|
||||
162
gns3server/api/routes/controller/settings.py
Normal file
162
gns3server/api/routes/controller/settings.py
Normal 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)
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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":
|
||||
|
||||
230
gns3server/schemas/controller/settings.py
Normal file
230
gns3server/schemas/controller/settings.py
Normal 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"
|
||||
)
|
||||
@ -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
|
||||
|
||||
230
tests/api/routes/controller/test_settings.py
Normal file
230
tests/api/routes/controller/test_settings.py
Normal 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
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user