fix: resolve all 64 mypy errors across backend

- Add dict[str, Any] / list[Any] type params throughout (fingerprint, models, schemas, scanner, status_checker)
- Add return type annotations to all route functions (nodes, edges, canvas, scan, auth, status, main)
- Fix no-any-return in security.py: cast pwd/jwt results to bool/str explicitly
- Fix canvas.py: use model_validate() for NodeResponse/EdgeResponse, rename db_node/db_edge upsert vars
- Fix scheduler.py: rename 'result' → 'check_result' to avoid type collision
- Fix get_db() return type: AsyncGenerator[AsyncSession, None]
- Add types-PyYAML for yaml import stubs
- Fix scanner.py: remove unnecessary type: ignore comment (nmap has stubs)
- Fix scan.py: wrap scalars().all() with list() for Sequence→list compatibility
This commit is contained in:
Pouzor
2026-03-07 15:48:41 +01:00
parent 1811afa749
commit 974e782057
17 changed files with 94 additions and 71 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ def _load_credentials() -> tuple[str, str]:
@router.post("/login", response_model=TokenResponse) @router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest): async def login(body: LoginRequest) -> TokenResponse:
username, password_hash = _load_credentials() username, password_hash = _load_credentials()
if body.username != username or not verify_password(body.password, password_hash): if body.username != username or not verify_password(body.password, password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
+19 -10
View File
@@ -1,4 +1,5 @@
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy import select from sqlalchemy import select
@@ -8,21 +9,29 @@ from app.api.deps import get_current_user
from app.db.database import get_db from app.db.database import get_db
from app.db.models import CanvasState, Edge, Node from app.db.models import CanvasState, Edge, Node
from app.schemas.canvas import CanvasSaveRequest, CanvasStateResponse from app.schemas.canvas import CanvasSaveRequest, CanvasStateResponse
from app.schemas.edges import EdgeResponse
from app.schemas.nodes import NodeResponse
router = APIRouter() router = APIRouter()
@router.get("", response_model=CanvasStateResponse) @router.get("", response_model=CanvasStateResponse)
async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> CanvasStateResponse:
nodes = (await db.execute(select(Node))).scalars().all() nodes = (await db.execute(select(Node))).scalars().all()
edges = (await db.execute(select(Edge))).scalars().all() edges = (await db.execute(select(Edge))).scalars().all()
state = await db.get(CanvasState, 1) state = await db.get(CanvasState, 1)
viewport = state.viewport if state else {"x": 0, "y": 0, "zoom": 1} viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
return CanvasStateResponse(nodes=list(nodes), edges=list(edges), viewport=viewport) return CanvasStateResponse(
nodes=[NodeResponse.model_validate(n) for n in nodes],
edges=[EdgeResponse.model_validate(e) for e in edges],
viewport=viewport,
)
@router.post("/save") @router.post("/save")
async def save_canvas(body: CanvasSaveRequest, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): async def save_canvas(
body: CanvasSaveRequest, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
) -> dict[str, bool]:
incoming_node_ids = {n.id for n in body.nodes} incoming_node_ids = {n.id for n in body.nodes}
incoming_edge_ids = {e.id for e in body.edges} incoming_edge_ids = {e.id for e in body.edges}
@@ -42,19 +51,19 @@ async def save_canvas(body: CanvasSaveRequest, db: AsyncSession = Depends(get_db
# Upsert nodes # Upsert nodes
for node_data in body.nodes: for node_data in body.nodes:
node = await db.get(Node, node_data.id) db_node = await db.get(Node, node_data.id)
if node: if db_node:
for field, value in node_data.model_dump().items(): for field, value in node_data.model_dump().items():
setattr(node, field, value) setattr(db_node, field, value)
else: else:
db.add(Node(**node_data.model_dump())) db.add(Node(**node_data.model_dump()))
# Upsert edges # Upsert edges
for edge_data in body.edges: for edge_data in body.edges:
edge = await db.get(Edge, edge_data.id) db_edge = await db.get(Edge, edge_data.id)
if edge: if db_edge:
for field, value in edge_data.model_dump().items(): for field, value in edge_data.model_dump().items():
setattr(edge, field, value) setattr(db_edge, field, value)
else: else:
db.add(Edge(**edge_data.model_dump())) db.add(Edge(**edge_data.model_dump()))
+5 -5
View File
@@ -11,13 +11,13 @@ router = APIRouter()
@router.get("", response_model=list[EdgeResponse]) @router.get("", response_model=list[EdgeResponse])
async def list_edges(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): async def list_edges(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[Edge]:
result = await db.execute(select(Edge)) result = await db.execute(select(Edge))
return result.scalars().all() return list(result.scalars().all())
@router.post("", response_model=EdgeResponse, status_code=status.HTTP_201_CREATED) @router.post("", response_model=EdgeResponse, status_code=status.HTTP_201_CREATED)
async def create_edge(body: EdgeCreate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): async def create_edge(body: EdgeCreate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Edge:
edge = Edge(**body.model_dump()) edge = Edge(**body.model_dump())
db.add(edge) db.add(edge)
await db.commit() await db.commit()
@@ -26,7 +26,7 @@ async def create_edge(body: EdgeCreate, db: AsyncSession = Depends(get_db), _: s
@router.delete("/{edge_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{edge_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_edge(edge_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): async def delete_edge(edge_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> None:
edge = await db.get(Edge, edge_id) edge = await db.get(Edge, edge_id)
if not edge: if not edge:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Edge not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Edge not found")
@@ -37,7 +37,7 @@ async def delete_edge(edge_id: str, db: AsyncSession = Depends(get_db), _: str =
@router.patch("/{edge_id}", response_model=EdgeResponse) @router.patch("/{edge_id}", response_model=EdgeResponse)
async def update_edge( async def update_edge(
edge_id: str, body: EdgeUpdate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user) edge_id: str, body: EdgeUpdate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
): ) -> Edge:
edge = await db.get(Edge, edge_id) edge = await db.get(Edge, edge_id)
if not edge: if not edge:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Edge not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Edge not found")
+6 -6
View File
@@ -11,13 +11,13 @@ router = APIRouter()
@router.get("", response_model=list[NodeResponse]) @router.get("", response_model=list[NodeResponse])
async def list_nodes(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): async def list_nodes(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[Node]:
result = await db.execute(select(Node)) result = await db.execute(select(Node))
return result.scalars().all() return list(result.scalars().all())
@router.post("", response_model=NodeResponse, status_code=status.HTTP_201_CREATED) @router.post("", response_model=NodeResponse, status_code=status.HTTP_201_CREATED)
async def create_node(body: NodeCreate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): async def create_node(body: NodeCreate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Node:
node = Node(**body.model_dump()) node = Node(**body.model_dump())
db.add(node) db.add(node)
await db.commit() await db.commit()
@@ -26,7 +26,7 @@ async def create_node(body: NodeCreate, db: AsyncSession = Depends(get_db), _: s
@router.get("/{node_id}", response_model=NodeResponse) @router.get("/{node_id}", response_model=NodeResponse)
async def get_node(node_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): async def get_node(node_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Node:
node = await db.get(Node, node_id) node = await db.get(Node, node_id)
if not node: if not node:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not found")
@@ -36,7 +36,7 @@ async def get_node(node_id: str, db: AsyncSession = Depends(get_db), _: str = De
@router.patch("/{node_id}", response_model=NodeResponse) @router.patch("/{node_id}", response_model=NodeResponse)
async def update_node( async def update_node(
node_id: str, body: NodeUpdate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user) node_id: str, body: NodeUpdate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
): ) -> Node:
node = await db.get(Node, node_id) node = await db.get(Node, node_id)
if not node: if not node:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not found")
@@ -48,7 +48,7 @@ async def update_node(
@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_node(node_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): async def delete_node(node_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> None:
node = await db.get(Node, node_id) node = await db.get(Node, node_id)
if not node: if not node:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not found")
+19 -14
View File
@@ -1,4 +1,5 @@
import logging import logging
from typing import Any
import yaml import yaml
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
@@ -26,8 +27,8 @@ router = APIRouter()
def _load_ranges() -> list[str]: def _load_ranges() -> list[str]:
try: try:
with open(settings.config_path) as f: with open(settings.config_path) as f:
cfg = yaml.safe_load(f) cfg: dict[str, Any] = yaml.safe_load(f) or {}
return cfg.get("scanner", {}).get("ranges", []) return list(cfg.get("scanner", {}).get("ranges", []))
except Exception: except Exception:
return [] return []
@@ -42,7 +43,7 @@ async def trigger_scan(
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user), _: str = Depends(get_current_user),
): ) -> ScanRun:
ranges = _load_ranges() ranges = _load_ranges()
run = ScanRun(status="running", ranges=ranges) run = ScanRun(status="running", ranges=ranges)
db.add(run) db.add(run)
@@ -53,15 +54,15 @@ async def trigger_scan(
@router.get("/pending", response_model=list[PendingDeviceResponse]) @router.get("/pending", response_model=list[PendingDeviceResponse])
async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending")) result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending"))
return result.scalars().all() return list(result.scalars().all())
@router.get("/hidden", response_model=list[PendingDeviceResponse]) @router.get("/hidden", response_model=list[PendingDeviceResponse])
async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "hidden")) result = await db.execute(select(PendingDevice).where(PendingDevice.status == "hidden"))
return result.scalars().all() return list(result.scalars().all())
@router.post("/pending/{device_id}/approve", response_model=dict) @router.post("/pending/{device_id}/approve", response_model=dict)
@@ -70,7 +71,7 @@ async def approve_device(
node_data: NodeCreate, node_data: NodeCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user), _: str = Depends(get_current_user),
): ) -> dict[str, Any]:
device = await db.get(PendingDevice, device_id) device = await db.get(PendingDevice, device_id)
if device: if device:
device.status = "approved" device.status = "approved"
@@ -82,7 +83,9 @@ async def approve_device(
@router.post("/pending/{device_id}/hide") @router.post("/pending/{device_id}/hide")
async def hide_device(device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): async def hide_device(
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
) -> dict[str, bool]:
device = await db.get(PendingDevice, device_id) device = await db.get(PendingDevice, device_id)
if device: if device:
device.status = "hidden" device.status = "hidden"
@@ -91,7 +94,9 @@ async def hide_device(device_id: str, db: AsyncSession = Depends(get_db), _: str
@router.post("/pending/{device_id}/ignore") @router.post("/pending/{device_id}/ignore")
async def ignore_device(device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): async def ignore_device(
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
) -> dict[str, bool]:
device = await db.get(PendingDevice, device_id) device = await db.get(PendingDevice, device_id)
if device: if device:
await db.delete(device) await db.delete(device)
@@ -100,13 +105,13 @@ async def ignore_device(device_id: str, db: AsyncSession = Depends(get_db), _: s
@router.get("/runs", response_model=list[ScanRunResponse]) @router.get("/runs", response_model=list[ScanRunResponse])
async def list_runs(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): async def list_runs(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[ScanRun]:
result = await db.execute(select(ScanRun).order_by(ScanRun.started_at.desc()).limit(20)) result = await db.execute(select(ScanRun).order_by(ScanRun.started_at.desc()).limit(20))
return result.scalars().all() return list(result.scalars().all())
@router.get("/config", response_model=ScanConfig) @router.get("/config", response_model=ScanConfig)
async def get_scan_config(_: str = Depends(get_current_user)): async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig:
try: try:
with open(settings.config_path) as f: with open(settings.config_path) as f:
cfg = yaml.safe_load(f) cfg = yaml.safe_load(f)
@@ -118,7 +123,7 @@ async def get_scan_config(_: str = Depends(get_current_user)):
@router.post("/config", response_model=ScanConfig) @router.post("/config", response_model=ScanConfig)
async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)): async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig:
try: try:
with open(settings.config_path) as f: with open(settings.config_path) as f:
cfg = yaml.safe_load(f) or {} cfg = yaml.safe_load(f) or {}
+2 -2
View File
@@ -9,7 +9,7 @@ _connections: list[WebSocket] = []
@router.websocket("/ws/status") @router.websocket("/ws/status")
async def ws_status(websocket: WebSocket): async def ws_status(websocket: WebSocket) -> None:
await websocket.accept() await websocket.accept()
_connections.append(websocket) _connections.append(websocket)
try: try:
@@ -19,7 +19,7 @@ async def ws_status(websocket: WebSocket):
_connections.remove(websocket) _connections.remove(websocket)
async def broadcast_status(node_id: str, status: str, checked_at: str, response_time_ms: int | None = None): async def broadcast_status(node_id: str, status: str, checked_at: str, response_time_ms: int | None = None) -> None:
payload = json.dumps({ payload = json.dumps({
"node_id": node_id, "node_id": node_id,
"status": status, "status": status,
+6 -6
View File
@@ -28,19 +28,19 @@ async def _run_status_checks() -> None:
if not node.check_method: if not node.check_method:
continue continue
try: try:
result = await check_node(node.check_method, node.check_target, node.ip) check_result = await check_node(node.check_method, node.check_target, node.ip)
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
n = await db.get(Node, node.id) n = await db.get(Node, node.id)
if n: if n:
n.status = result["status"] n.status = check_result["status"]
n.response_time_ms = result["response_time_ms"] n.response_time_ms = check_result["response_time_ms"]
n.last_seen = datetime.now(UTC) if result["status"] == "online" else n.last_seen n.last_seen = datetime.now(UTC) if check_result["status"] == "online" else n.last_seen
await db.commit() await db.commit()
await broadcast_status( await broadcast_status(
node_id=node.id, node_id=node.id,
status=result["status"], status=check_result["status"],
checked_at=datetime.now(UTC).isoformat(), checked_at=datetime.now(UTC).isoformat(),
response_time_ms=result["response_time_ms"], response_time_ms=check_result["response_time_ms"],
) )
except Exception as exc: except Exception as exc:
logger.error("Status check failed for node %s: %s", node.id, exc) logger.error("Status check failed for node %s: %s", node.id, exc)
+5 -4
View File
@@ -9,22 +9,23 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain: str, hashed: str) -> bool: def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed) return bool(pwd_context.verify(plain, hashed))
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
return pwd_context.hash(password) return str(pwd_context.hash(password))
def create_access_token(subject: str) -> str: def create_access_token(subject: str) -> str:
expire = datetime.now(UTC) + timedelta(minutes=settings.access_token_expire_minutes) expire = datetime.now(UTC) + timedelta(minutes=settings.access_token_expire_minutes)
payload = {"sub": subject, "exp": expire} payload = {"sub": subject, "exp": expire}
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm) return str(jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm))
def decode_token(token: str) -> str | None: def decode_token(token: str) -> str | None:
try: try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
return payload.get("sub") sub = payload.get("sub")
return str(sub) if sub is not None else None
except JWTError: except JWTError:
return None return None
+2 -1
View File
@@ -1,3 +1,4 @@
from collections.abc import AsyncGenerator
from contextlib import suppress from contextlib import suppress
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
@@ -31,6 +32,6 @@ async def init_db() -> None:
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN path_style TEXT") await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN path_style TEXT")
async def get_db(): async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
yield session yield session
+6 -5
View File
@@ -1,5 +1,6 @@
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any
from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, String, Text from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -28,13 +29,13 @@ class Node(Base):
status: Mapped[str] = mapped_column(String, default="unknown") status: Mapped[str] = mapped_column(String, default="unknown")
check_method: Mapped[str | None] = mapped_column(String) check_method: Mapped[str | None] = mapped_column(String)
check_target: Mapped[str | None] = mapped_column(String) check_target: Mapped[str | None] = mapped_column(String)
services: Mapped[list] = mapped_column(JSON, default=list) services: Mapped[list[Any]] = mapped_column(JSON, default=list)
notes: Mapped[str | None] = mapped_column(Text) notes: Mapped[str | None] = mapped_column(Text)
pos_x: Mapped[float] = mapped_column(Float, default=0) pos_x: Mapped[float] = mapped_column(Float, default=0)
pos_y: Mapped[float] = mapped_column(Float, default=0) pos_y: Mapped[float] = mapped_column(Float, default=0)
parent_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id")) parent_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id"))
container_mode: Mapped[bool] = mapped_column(Boolean, default=False) container_mode: Mapped[bool] = mapped_column(Boolean, default=False)
custom_colors: Mapped[dict | None] = mapped_column(JSON, nullable=True) custom_colors: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
response_time_ms: Mapped[int | None] = mapped_column(Integer) response_time_ms: Mapped[int | None] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
@@ -63,7 +64,7 @@ class CanvasState(Base):
__tablename__ = "canvas_state" __tablename__ = "canvas_state"
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1) id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
viewport: Mapped[dict] = mapped_column(JSON, default=dict) viewport: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
@@ -75,7 +76,7 @@ class PendingDevice(Base):
mac: Mapped[str | None] = mapped_column(String) mac: Mapped[str | None] = mapped_column(String)
hostname: Mapped[str | None] = mapped_column(String) hostname: Mapped[str | None] = mapped_column(String)
os: Mapped[str | None] = mapped_column(String) os: Mapped[str | None] = mapped_column(String)
services: Mapped[list] = mapped_column(JSON, default=list) services: Mapped[list[Any]] = mapped_column(JSON, default=list)
suggested_type: Mapped[str | None] = mapped_column(String) suggested_type: Mapped[str | None] = mapped_column(String)
status: Mapped[str] = mapped_column(String, default="pending") status: Mapped[str] = mapped_column(String, default="pending")
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
@@ -86,7 +87,7 @@ class ScanRun(Base):
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid) id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
status: Mapped[str] = mapped_column(String, default="running") status: Mapped[str] = mapped_column(String, default="running")
ranges: Mapped[list] = mapped_column(JSON, default=list) ranges: Mapped[list[str]] = mapped_column(JSON, default=list)
devices_found: Mapped[int] = mapped_column(Integer, default=0) devices_found: Mapped[int] = mapped_column(Integer, default=0)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+4 -2
View File
@@ -1,4 +1,6 @@
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Any
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@@ -10,7 +12,7 @@ from app.db.database import init_db
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
await init_db() await init_db()
start_scheduler() start_scheduler()
yield yield
@@ -40,5 +42,5 @@ app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
@app.get("/api/v1/health") @app.get("/api/v1/health")
async def health(): async def health() -> dict[str, Any]:
return {"status": "ok"} return {"status": "ok"}
+3 -3
View File
@@ -21,7 +21,7 @@ class NodeSave(BaseModel):
notes: str | None = None notes: str | None = None
parent_id: str | None = None parent_id: str | None = None
container_mode: bool = False container_mode: bool = False
custom_colors: dict | None = None custom_colors: dict[str, Any] | None = None
pos_x: float = 0 pos_x: float = 0
pos_y: float = 0 pos_y: float = 0
@@ -41,10 +41,10 @@ class EdgeSave(BaseModel):
class CanvasSaveRequest(BaseModel): class CanvasSaveRequest(BaseModel):
nodes: list[NodeSave] = [] nodes: list[NodeSave] = []
edges: list[EdgeSave] = [] edges: list[EdgeSave] = []
viewport: dict = {} viewport: dict[str, Any] = {}
class CanvasStateResponse(BaseModel): class CanvasStateResponse(BaseModel):
nodes: list[NodeResponse] nodes: list[NodeResponse]
edges: list[EdgeResponse] edges: list[EdgeResponse]
viewport: dict viewport: dict[str, Any]
+2 -2
View File
@@ -20,7 +20,7 @@ class NodeBase(BaseModel):
pos_y: float = 0 pos_y: float = 0
parent_id: str | None = None parent_id: str | None = None
container_mode: bool = False container_mode: bool = False
custom_colors: dict | None = None custom_colors: dict[str, Any] | None = None
class NodeCreate(NodeBase): class NodeCreate(NodeBase):
@@ -42,7 +42,7 @@ class NodeUpdate(BaseModel):
pos_x: float | None = None pos_x: float | None = None
pos_y: float | None = None pos_y: float | None = None
container_mode: bool | None = None container_mode: bool | None = None
custom_colors: dict | None = None custom_colors: dict[str, Any] | None = None
class NodeResponse(NodeBase): class NodeResponse(NodeBase):
+6 -5
View File
@@ -2,11 +2,12 @@
import json import json
import re import re
from pathlib import Path from pathlib import Path
from typing import Any
_SIGNATURES: list[dict] | None = None _SIGNATURES: list[dict[str, Any]] | None = None
def _load() -> list[dict]: def _load() -> list[dict[str, Any]]:
global _SIGNATURES global _SIGNATURES
if _SIGNATURES is None: if _SIGNATURES is None:
path = Path(__file__).parent.parent.parent / "data" / "service_signatures.json" path = Path(__file__).parent.parent.parent / "data" / "service_signatures.json"
@@ -15,7 +16,7 @@ def _load() -> list[dict]:
return _SIGNATURES return _SIGNATURES
def match_port(port: int, protocol: str, banner: str | None = None) -> dict | None: def match_port(port: int, protocol: str, banner: str | None = None) -> dict[str, Any] | None:
"""Return the first signature matching port+protocol, optionally banner.""" """Return the first signature matching port+protocol, optionally banner."""
for sig in _load(): for sig in _load():
if sig["port"] != port or sig["protocol"] != protocol: if sig["port"] != port or sig["protocol"] != protocol:
@@ -26,7 +27,7 @@ def match_port(port: int, protocol: str, banner: str | None = None) -> dict | No
return None return None
def fingerprint_ports(open_ports: list[dict]) -> list[dict]: def fingerprint_ports(open_ports: list[dict[str, Any]]) -> list[dict[str, Any]]:
""" """
Given a list of {port, protocol, banner?} dicts, return matched services. Given a list of {port, protocol, banner?} dicts, return matched services.
Unknown ports are included as unknown_service. Unknown ports are included as unknown_service.
@@ -53,7 +54,7 @@ def fingerprint_ports(open_ports: list[dict]) -> list[dict]:
return results return results
def suggest_node_type(open_ports: list[dict]) -> str: def suggest_node_type(open_ports: list[dict[str, Any]]) -> str:
"""Suggest a node type based on the most specific matched signature.""" """Suggest a node type based on the most specific matched signature."""
priority = ["proxmox", "nas", "router", "lxc", "vm", "server", "ap", "iot", "switch"] priority = ["proxmox", "nas", "router", "lxc", "vm", "server", "ap", "iot", "switch"]
found: set[str] = set() found: set[str] = set()
+5 -4
View File
@@ -2,6 +2,7 @@
import logging import logging
import socket import socket
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -11,14 +12,14 @@ from app.services.fingerprint import fingerprint_ports, suggest_node_type
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
try: try:
import nmap # type: ignore[import-untyped] import nmap
_NMAP_AVAILABLE = True _NMAP_AVAILABLE = True
except ImportError: except ImportError:
_NMAP_AVAILABLE = False _NMAP_AVAILABLE = False
logger.warning("python-nmap not available — scanner will run in mock mode") logger.warning("python-nmap not available — scanner will run in mock mode")
def _nmap_scan(target: str) -> list[dict]: def _nmap_scan(target: str) -> list[dict[str, Any]]:
"""Run nmap -sV --open on target, return list of host dicts.""" """Run nmap -sV --open on target, return list of host dicts."""
if not _NMAP_AVAILABLE: if not _NMAP_AVAILABLE:
return _mock_scan(target) return _mock_scan(target)
@@ -64,13 +65,13 @@ def _extract_os(nm: object, host: str) -> str | None:
try: try:
osmatch = nm[host].get("osmatch", []) # type: ignore[index] osmatch = nm[host].get("osmatch", []) # type: ignore[index]
if osmatch: if osmatch:
return osmatch[0]["name"] return str(osmatch[0]["name"])
except Exception: except Exception:
pass pass
return None return None
def _mock_scan(target: str) -> list[dict]: def _mock_scan(target: str) -> list[dict[str, Any]]:
"""Return fake results for dev/test environments without nmap.""" """Return fake results for dev/test environments without nmap."""
return [ return [
{ {
+2 -1
View File
@@ -3,13 +3,14 @@ import asyncio
import logging import logging
import socket import socket
import time import time
from typing import Any
import httpx import httpx
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def check_node(check_method: str, target: str | None, ip: str | None) -> dict: async def check_node(check_method: str, target: str | None, ip: str | None) -> dict[str, Any]:
""" """
Run the appropriate check and return {status, response_time_ms}. Run the appropriate check and return {status, response_time_ms}.
status is one of: online, offline, unknown. status is one of: online, offline, unknown.
+1
View File
@@ -13,6 +13,7 @@ python-multipart==0.0.22
apscheduler==3.10.4 apscheduler==3.10.4
python-nmap==0.7.1 python-nmap==0.7.1
pyyaml==6.0.2 pyyaml==6.0.2
types-PyYAML==6.0.12.20240917
websockets==13.1 websockets==13.1
httpx==0.27.2 httpx==0.27.2