# # Copyright (C) 2015 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 asyncio import logging import os import signal import time from typing import Dict, List import psutil from fastapi import APIRouter, Depends, FastAPI, Request, WebSocket, WebSocketDisconnect, status from fastapi.encoders import jsonable_encoder from fastapi.responses import StreamingResponse from fastapi.routing import Mount from websockets.exceptions import ConnectionClosed, WebSocketException from gns3server import schemas from gns3server.agent.web_wireshark.stats import collect_webwireshark_stats from gns3server.config import Config from gns3server.controller import Controller from gns3server.controller.controller_error import ControllerError, ControllerForbiddenError from gns3server.version import __version__ from .dependencies.authentication import get_current_active_user, get_current_active_user_from_websocket log = logging.getLogger(__name__) router = APIRouter() def get_server_uptime_seconds() -> int: try: return max(0, int(time.time() - psutil.Process().create_time())) except psutil.Error: return 0 @router.get( "/version", response_model=schemas.Version, ) def get_version(request: Request) -> dict: """ Return the server version number. """ # retrieve the controller host information from the mounted # compute subapp controller_host = None for route in request.app.routes: if isinstance(route, Mount) and route.name == "compute" and isinstance(route.app, FastAPI): controller_host = route.app.state.controller_host local_server = Config.instance().settings.Server.local return {"controller_host": controller_host, "version": __version__, "local": local_server} @router.post( "/version", response_model=schemas.Version, response_model_exclude_defaults=True, responses={409: {"model": schemas.ErrorMessage, "description": "Invalid version"}}, ) def check_version(version: schemas.Version) -> dict: """ Check if version is the same as the server. """ if version.version != __version__: raise ControllerError(f"Client version {version.version} is not the same as server version {__version__}") return {"version": __version__} @router.post( "/reload", dependencies=[Depends(get_current_active_user)], status_code=status.HTTP_204_NO_CONTENT, ) async def reload() -> None: """ Reload the controller """ await Controller.instance().reload() @router.post( "/shutdown", dependencies=[Depends(get_current_active_user)], status_code=status.HTTP_204_NO_CONTENT, responses={403: {"model": schemas.ErrorMessage, "description": "Server shutdown not allowed"}}, ) async def shutdown() -> None: """ Shutdown the server """ if Config.instance().settings.Server.local is False: raise ControllerForbiddenError("You can only stop a local server") log.info("Start shutting down the server") # close all the projects first controller = Controller.instance() projects = controller.projects.values() tasks = [] for project in projects: tasks.append(asyncio.ensure_future(project.close())) if tasks: done, _ = await asyncio.wait(tasks) for future in done: try: future.result() except Exception as e: log.error(f"Could not close project: {e}", exc_info=True) continue # then shutdown the server itself os.kill(os.getpid(), signal.SIGTERM) @router.get("/iou_license", dependencies=[Depends(get_current_active_user)], response_model=schemas.IOULicense) def get_iou_license() -> schemas.IOULicense: """ Return the IOU license settings """ return Controller.instance().iou_license @router.put( "/iou_license", dependencies=[Depends(get_current_active_user)], status_code=status.HTTP_201_CREATED, response_model=schemas.IOULicense, ) async def update_iou_license(iou_license: schemas.IOULicense) -> schemas.IOULicense: """ Update the IOU license settings. """ controller = Controller().instance() current_iou_license = controller.iou_license current_iou_license.update(jsonable_encoder(iou_license)) controller.save() return current_iou_license @router.get("/statistics", dependencies=[Depends(get_current_active_user)]) async def statistics() -> dict: """ Return server statistics including compute resources, projects, and nodes. """ controller = Controller.instance() # Compute statistics (existing behavior) compute_statistics = [] for compute in list(controller.computes.values()): try: r = await compute.get("/statistics") compute_statistics.append({"compute_id": compute.id, "compute_name": compute.name, "statistics": r.json}) except ControllerError as e: log.error(f"Could not retrieve statistics on compute {compute.name}: {e}") # Project statistics projects = list(controller.projects.values()) project_stats = { "total": len(projects), "opened": sum(1 for p in projects if p.status == "opened"), "closed": sum(1 for p in projects if p.status == "closed"), } # Node statistics - distinguish open vs closed project nodes open_project_nodes = [] closed_project_nodes = [] node_by_type: Dict[str, int] = {} node_by_status: Dict[str, int] = {} for project in projects: nodes = project.nodes.values() if project.status == "closed": closed_project_nodes.extend(nodes) else: open_project_nodes.extend(nodes) # Open project nodes have real status for node in open_project_nodes: node_type = getattr(node, "node_type", "unknown") node_status = getattr(node, "status", "unknown") node_by_type[node_type] = node_by_type.get(node_type, 0) + 1 node_by_status[node_status] = node_by_status.get(node_status, 0) + 1 # Closed project nodes don't have status, count them separately for node in closed_project_nodes: if isinstance(node, dict): node_type = node.get("node_type", "unknown") else: node_type = getattr(node, "node_type", "unknown") node_by_type[node_type] = node_by_type.get(node_type, 0) + 1 node_stats = { "total": len(open_project_nodes) + len(closed_project_nodes), "open_project_nodes": len(open_project_nodes), "closed_project_nodes": len(closed_project_nodes), "by_type": node_by_type, "by_status": node_by_status, } # Link statistics all_links = [] for project in projects: all_links.extend(project.links.values()) def is_capturing(link): if hasattr(link, "capturing"): return link.capturing if isinstance(link, dict): return link.get("capturing", False) return False link_capturing = sum(1 for link in all_links if is_capturing(link)) link_stats = { "total": len(all_links), "capturing": link_capturing, } # Web Wireshark container statistics webwireshark_stats = await collect_webwireshark_stats(projects) return { "uptime_seconds": get_server_uptime_seconds(), "computes": compute_statistics, "projects": project_stats, "nodes": node_stats, "links": link_stats, "webwireshark": webwireshark_stats, } @router.get("/notifications", dependencies=[Depends(get_current_active_user)]) async def controller_http_notifications(request: Request) -> StreamingResponse: """ Receive controller notifications about the controller from HTTP stream. """ from gns3server.api.server import app client = f"{request.client.host}:{request.client.port}" if request.client else "unknown" log.info(f"New client {client} has connected to controller HTTP notification stream") async def event_stream(): try: with Controller.instance().notification.controller_queue() as queue: while not app.state.exiting: msg = await queue.get_json(5) yield f"{msg}\n".encode() finally: log.info(f"Client {client} has disconnected from controller HTTP notification stream") return StreamingResponse(event_stream(), media_type="application/json") @router.websocket("/notifications/ws") async def controller_ws_notifications( websocket: WebSocket, current_user: schemas.User = Depends(get_current_active_user_from_websocket) ) -> None: """ Receive project notifications about the controller from WebSocket. """ if current_user is None: return client = f"{websocket.client.host}:{websocket.client.port}" if websocket.client else "unknown" log.info(f"New client {client} has connected to controller WebSocket") try: with Controller.instance().notification.controller_queue() as queue: while True: notification = await queue.get_json(5) await websocket.send_text(notification) except (ConnectionClosed, WebSocketDisconnect): log.info(f"Client {client} has disconnected from controller WebSocket") except WebSocketException as e: log.warning(f"Error while sending to controller event to WebSocket client: {e}") # @Route.post( # r"/debug", # description="Dump debug information to disk (debug directory in config directory). Work only for local server", # status_codes={ # 201: "Written" # }) # async def debug(request, response): # # config = Config.instance() # if config.get_section_config("Server").getboolean("local", False) is False: # raise ControllerForbiddenError("You can only debug a local server") # # debug_dir = os.path.join(config.config_dir, "debug") # try: # if os.path.exists(debug_dir): # shutil.rmtree(debug_dir) # os.makedirs(debug_dir) # with open(os.path.join(debug_dir, "controller.txt"), "w+") as f: # f.write(ServerHandler._getDebugData()) # except Exception as e: # # If something is wrong we log the info to the log and we hope the log will be include correctly to the debug export # log.error("Could not export debug information {}".format(e), exc_info=1) # # try: # if Controller.instance().gns3vm.engine == "vmware": # vmx_path = Controller.instance().gns3vm.current_engine().vmx_path # if vmx_path: # shutil.copy(vmx_path, os.path.join(debug_dir, os.path.basename(vmx_path))) # except OSError as e: # # If something is wrong we log the info to the log and we hope the log will be include correctly to the debug export # log.error("Could not copy VMware VMX file {}".format(e), exc_info=1) # # for compute in list(Controller.instance().computes.values()): # try: # r = await compute.get("/debug", raw=True) # data = r.body.decode("utf-8") # except Exception as e: # data = str(e) # with open(os.path.join(debug_dir, "compute_{}.txt".format(compute.id)), "w+") as f: # f.write("Compute ID: {}\n".format(compute.id)) # f.write(data) # # response.set_status(201) # # @staticmethod # def _getDebugData(): # try: # connections = psutil.net_connections() # # You need to be root for OSX # except psutil.AccessDenied: # connections = None # # try: # addrs = ["* {}: {}".format(key, val) for key, val in psutil.net_if_addrs().items()] # except UnicodeDecodeError: # addrs = ["INVALID ADDR WITH UNICODE CHARACTERS"] # # data = """Version: {version} # OS: {os} # Python: {python} # CPU: {cpu} # Memory: {memory} # # Networks: # {addrs} # # Open connections: # {connections} # # Processus: # """.format( # version=__version__, # os=platform.platform(), # python=platform.python_version(), # memory=psutil.virtual_memory(), # cpu=psutil.cpu_times(), # connections=connections, # addrs="\n".join(addrs) # ) # for proc in psutil.process_iter(): # try: # psinfo = proc.as_dict(attrs=["name", "exe"]) # data += "* {} {}\n".format(psinfo["name"], psinfo["exe"]) # except psutil.NoSuchProcess: # pass # # data += "\n\nProjects" # for project in Controller.instance().projects.values(): # data += "\n\nProject name: {}\nProject ID: {}\n".format(project.name, project.id) # if project.status != "closed": # for link in project.links.values(): # data += "Link {}: {}".format(link.id, link.debug_link_data) # # return data