mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-09-04 00:55:16 +03:00
LLM clients transcribing the console WebSocket URL into shell commands
reliably corrupted the ~200-char JWT embedded in it (dropped header
segment -> "MissingAlgorithmError: Missing 'alg' value in header" on
every connection attempt). The node_console tool now mints a short
random ticket ("gns3t_" + 16 urlsafe chars, 10 min TTL, multi-use)
stored server-side and bound to the node's console endpoints:
- new ConsoleTicketService (gns3server/services/console_tickets.py),
in-memory store with lazy expiry sweeps
- get_current_active_user_from_websocket redeems tickets through the
existing "token" query parameter, gated on websocket.path_params so a
ticket only authenticates the console/ws and console/vnc routes of
the node it was minted for; the JWT path is unchanged
- redemption reuses the existing user lookup, token_version revocation
and is_active checks, so logging out invalidates outstanding tickets
- vnc_url no longer embeds the full session JWT
- the tool docstring now tells clients to run the returned command
verbatim instead of reconstructing the URL
116 lines
4.1 KiB
Python
116 lines
4.1 KiB
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/>.
|
|
|
|
"""
|
|
Short-lived console tickets.
|
|
|
|
A console ticket is a short random string ("gns3t_" + 16 urlsafe chars) that
|
|
replaces the long JWT previously embedded in console WebSocket URLs returned
|
|
to LLM clients, which reliably corrupted the ~200-char JWT when retyping it
|
|
into a shell command. The ticket carries no information itself — the server
|
|
remembers what it maps to, which is what makes a 96-bit random string a
|
|
sufficient credential.
|
|
|
|
Tickets are bound to a single node's console endpoints (console/ws and
|
|
console/vnc share the same binding) and are multi-use within their TTL, so
|
|
console clients that reconnect after a drop keep working. Redeeming also
|
|
re-checks the minting user's token_version, so logging out invalidates
|
|
outstanding tickets just like it invalidates JWTs.
|
|
"""
|
|
|
|
import logging
|
|
import secrets
|
|
import time
|
|
from dataclasses import dataclass
|
|
from typing import Optional
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# Distinguishes tickets from JWTs (which contain dots) and API keys ("gns3_")
|
|
TICKET_PREFIX = "gns3t_"
|
|
DEFAULT_TICKET_TTL = 600 # seconds
|
|
|
|
|
|
@dataclass
|
|
class ConsoleTicket:
|
|
username: str
|
|
token_version: int
|
|
project_id: str
|
|
node_id: str
|
|
expires_at: float # time.monotonic() based
|
|
|
|
|
|
class ConsoleTicketService:
|
|
"""
|
|
In-memory store for console tickets.
|
|
|
|
Single-process asyncio app: minting (MCP tool handlers, via worker
|
|
threads) and redemption (WS auth dependency, event loop) share one dict.
|
|
dict get/set/del are atomic under the GIL and keys are random, so no
|
|
locking is needed. Tickets vanish on restart — clients just request a
|
|
new one.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._tickets: dict[str, ConsoleTicket] = {}
|
|
|
|
def mint(
|
|
self,
|
|
username: str,
|
|
token_version: int,
|
|
project_id: str,
|
|
node_id: str,
|
|
ttl: int = DEFAULT_TICKET_TTL,
|
|
) -> str:
|
|
# 12 random bytes → 16 urlsafe chars (~96 bits); comfortably
|
|
# unguessable within the 10-minute window and short enough that
|
|
# LLM clients copy it without corruption.
|
|
ticket = TICKET_PREFIX + secrets.token_urlsafe(12)
|
|
self._sweep_expired()
|
|
self._tickets[ticket] = ConsoleTicket(
|
|
username=username,
|
|
token_version=token_version,
|
|
project_id=project_id,
|
|
node_id=node_id,
|
|
expires_at=time.monotonic() + ttl,
|
|
)
|
|
return ticket
|
|
|
|
def redeem(self, ticket: str, path_params: dict) -> Optional[ConsoleTicket]:
|
|
"""
|
|
Validate a ticket against the route it is being used on.
|
|
|
|
Returns the ticket record if valid, None otherwise. The route's path
|
|
parameters must match the ticket's binding, which confines a ticket
|
|
to the node's console endpoints — routes without a node_id path
|
|
parameter (notifications, web wireshark, …) always fail the binding.
|
|
"""
|
|
|
|
record = self._tickets.get(ticket)
|
|
if record is None:
|
|
return None
|
|
if time.monotonic() >= record.expires_at:
|
|
self._tickets.pop(ticket, None)
|
|
return None
|
|
if path_params.get("project_id") != record.project_id or path_params.get("node_id") != record.node_id:
|
|
return None
|
|
return record
|
|
|
|
def _sweep_expired(self) -> None:
|
|
now = time.monotonic()
|
|
for ticket in [t for t, record in self._tickets.items() if now >= record.expires_at]:
|
|
del self._tickets[ticket]
|