import asyncio import logging from typing import Annotated import sqlalchemy from fastapi import APIRouter, Body, Query from fastapi import HTTPException, WebSocket from fastapi_pagination.ext.sqlalchemy import paginate from fastapi_pagination.links import Page from starlette.websockets import WebSocketDisconnect, WebSocketState from grpclib.exceptions import StreamTerminatedError, GRPCError from app import marznode from app.db import crud, get_tls_certificate from app.db.models import Node from app.dependencies import ( DBDep, SudoAdminDep, EndDateDep, StartDateDep, get_admin, ) from app.models.node import ( NodeCreate, NodeModify, NodeResponse, NodeSettings, NodeStatus, BackendConfig, BackendStats, ) from app.models.system import TrafficUsageSeries logger = logging.getLogger(__name__) router = APIRouter(prefix="/nodes", tags=["Node"]) @router.get("", response_model=Page[NodeResponse]) def get_nodes( db: DBDep, admin: SudoAdminDep, status: list[NodeStatus] = Query(None), name: str = Query(None), ): query = db.query(Node) if name: query = query.filter(Node.name.ilike(f"%{name}%")) if status: query = query.filter(Node.status.in_(status)) return paginate(db, query) @router.post("", response_model=NodeResponse) async def add_node(new_node: NodeCreate, db: DBDep, admin: SudoAdminDep): try: db_node = crud.create_node(db, new_node) except sqlalchemy.exc.IntegrityError: db.rollback() raise HTTPException( status_code=409, detail=f'Node "{new_node.name}" already exists' ) certificate = get_tls_certificate(db) await marznode.operations.add_node(db_node, certificate) logger.info("New node `%s` added", db_node.name) return db_node @router.get("/settings", response_model=NodeSettings) def get_node_settings(db: DBDep, admin: SudoAdminDep): tls = crud.get_tls_certificate(db) return NodeSettings(certificate=tls.certificate) @router.get("/{node_id}", response_model=NodeResponse) def get_node(node_id: int, db: DBDep, admin: SudoAdminDep): db_node = crud.get_node_by_id(db, node_id) if not db_node: raise HTTPException(status_code=404, detail="Node not found") return db_node @router.websocket("/{node_id}/{backend}/logs") async def node_logs( node_id: int, backend: str, websocket: WebSocket, db: DBDep, include_buffer: bool = True, ): token = websocket.query_params.get("token", "") or websocket.headers.get( "Authorization", "" ).removeprefix("Bearer ") admin = get_admin(db, token) if not admin or not admin.is_sudo: return await websocket.close(reason="You're not allowed", code=4403) if not marznode.nodes.get(node_id): return await websocket.close(reason="Node not found", code=4404) await websocket.accept() try: async for line in marznode.nodes[node_id].get_logs( name=backend, include_buffer=include_buffer ): await websocket.send_text(line) except WebSocketDisconnect: logger.debug("websocket disconnected") except (StreamTerminatedError, GRPCError): logger.info("node %i detached", node_id) finally: if websocket.state == WebSocketState.CONNECTED: await websocket.close() @router.put("/{node_id}", response_model=NodeResponse) async def modify_node( node_id: int, modified_node: NodeModify, db: DBDep, admin: SudoAdminDep ): db_node = crud.get_node_by_id(db, node_id) if not db_node: raise HTTPException(status_code=404, detail="Node not found") db_node = crud.update_node(db, db_node, modified_node) await marznode.operations.remove_node(db_node.id) if db_node.status != NodeStatus.disabled: certificate = get_tls_certificate(db) await marznode.operations.add_node(db_node, certificate) logger.info("Node `%s` modified", db_node.name) return db_node @router.delete("/{node_id}") async def remove_node(node_id: int, db: DBDep, admin: SudoAdminDep): db_node = crud.get_node_by_id(db, node_id) if not db_node: raise HTTPException(status_code=404, detail="Node not found") crud.remove_node(db, db_node) await marznode.operations.remove_node(db_node.id) logger.info(f"Node `%s` deleted", db_node.name) return {} @router.post("/{node_id}/resync") async def reconnect_node(node_id: int, db: DBDep, admin: SudoAdminDep): db_node = crud.get_node_by_id(db, node_id) if not db_node: raise HTTPException(status_code=404, detail="Node not found") return {} @router.get("/{node_id}/usage", response_model=TrafficUsageSeries) def get_usage( node_id: int, db: DBDep, admin: SudoAdminDep, start_date: StartDateDep, end_date: EndDateDep, ): """ Get nodes usage """ node = crud.get_node_by_id(db, node_id) if not node: raise HTTPException(status_code=404, detail="Node not found") return crud.get_node_usage(db, start_date, end_date, node) @router.get("/{node_id}/{backend}/stats", response_model=BackendStats) async def get_backend_stats( node_id: int, backend: str, db: DBDep, admin: SudoAdminDep ): if not (node := marznode.nodes.get(node_id)): raise HTTPException(status_code=404, detail="Node not found") try: stats = await node.get_backend_stats(backend) except Exception: raise HTTPException(502) else: return BackendStats(running=stats.running) @router.get("/{node_id}/{backend}/config", response_model=BackendConfig) async def get_node_xray_config( node_id: int, backend: str, admin: SudoAdminDep ): if not (node := marznode.nodes.get(node_id)): raise HTTPException(status_code=404, detail="Node not found") try: config, config_format = await node.get_backend_config(name=backend) except Exception: raise HTTPException(status_code=502, detail="Node isn't responsive") else: return {"config": config, "format": config_format} @router.put("/{node_id}/{backend}/config") async def alter_node_xray_config( node_id: int, backend: str, admin: SudoAdminDep, config: Annotated[BackendConfig, Body()], ): if not (node := marznode.nodes.get(node_id)): raise HTTPException(status_code=404, detail="Node not found") try: await asyncio.wait_for( node.restart_backend( name=backend, config=config.config, config_format=config.format.value, ), 5, ) except: raise HTTPException( status_code=502, detail="No response from the node." ) return {}