fix: issue short-lived console tickets instead of JWTs in node_console MCP tool

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
This commit is contained in:
YueGuobin committed 2026-08-29 01:00:12 +08:00
1 parent 2324dcd744
commit 6d6b5351fa
9 files changed
+431 -30

No files matched your search

+28
View File
@@ -280,10 +280,38 @@ class TestNode:
def test_console(self, ctx):
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_console_info_handler
from gns3server.services import console_ticket_service
with patch(f"{AH}._get_connector") as m:
m.return_value = _mock_conn({"console_url": "ws://host/console"})
result = get_node_console_info_handler({"project_id": "p1", "node_id": "n1"}, ctx)
assert "command" in result
# the URL embeds a short console ticket (not a JWT) redeemable for this node
assert "?token=gns3t_" in result["ws_url"]
ticket = result["ws_url"].split("token=")[1]
assert ticket in result["command"]
assert result["token_ttl_seconds"] == 600
assert len(result["token_sha256_prefix"]) == 8
redeemed = console_ticket_service.redeem(ticket, {"project_id": "p1", "node_id": "n1"})
assert redeemed is not None and redeemed.username == "admin"
assert "vnc_url" not in result # console_type is not vnc
def test_console_vnc_uses_same_ticket(self, ctx):
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_console_info_handler
with patch(f"{AH}._get_connector") as m:
m.return_value = _mock_conn({"console_type": "vnc"})
result = get_node_console_info_handler({"project_id": "p1", "node_id": "n1"}, ctx)
ticket = result["ws_url"].split("token=")[1]
# one node-bound ticket covers both console endpoints (identical path params)
assert result["vnc_url"] == f"/v3/projects/p1/nodes/n1/console/vnc?token={ticket}"
def test_console_without_username_omits_token(self, ctx):
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_console_info_handler
unauthenticated_ctx = {k: v for k, v in ctx.items() if k != "jwt_username"}
with patch(f"{AH}._get_connector") as m:
m.return_value = _mock_conn({"console_url": "ws://host/console"})
result = get_node_console_info_handler({"project_id": "p1", "node_id": "n1"}, unauthenticated_ctx)
assert "token=" not in result["ws_url"]
assert "token_sha256_prefix" not in result
@staticmethod
def _file_conn(text):