feat: implement Logs endpoints for web dashboard
This commit is contained in:
@@ -3,12 +3,16 @@ from contextlib import asynccontextmanager
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Any, AsyncGenerator
|
from typing import Any, AsyncGenerator
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, status
|
import jwt
|
||||||
|
from fastapi import Depends, FastAPI, HTTPException, Query, status
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from decnet.web.auth import (
|
from decnet.web.auth import (
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES,
|
ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||||
|
ALGORITHM,
|
||||||
|
SECRET_KEY,
|
||||||
create_access_token,
|
create_access_token,
|
||||||
get_password_hash,
|
get_password_hash,
|
||||||
verify_password,
|
verify_password,
|
||||||
@@ -50,6 +54,25 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
oauth2_scheme: OAuth2PasswordBearer = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(token: str = Depends(oauth2_scheme)) -> str:
|
||||||
|
credentials_exception = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload: dict[str, Any] = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
user_uuid: str | None = payload.get("uuid")
|
||||||
|
if user_uuid is None:
|
||||||
|
raise credentials_exception
|
||||||
|
except jwt.PyJWTError:
|
||||||
|
raise credentials_exception
|
||||||
|
return user_uuid
|
||||||
|
|
||||||
|
|
||||||
class Token(BaseModel):
|
class Token(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
token_type: str
|
token_type: str
|
||||||
@@ -60,6 +83,13 @@ class LoginRequest(BaseModel):
|
|||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class LogsResponse(BaseModel):
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
data: list[dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/auth/login", response_model=Token)
|
@app.post("/api/v1/auth/login", response_model=Token)
|
||||||
async def login(request: LoginRequest) -> dict[str, str]:
|
async def login(request: LoginRequest) -> dict[str, str]:
|
||||||
user: dict[str, Any] | None = await repo.get_user_by_username(request.username)
|
user: dict[str, Any] | None = await repo.get_user_by_username(request.username)
|
||||||
@@ -76,3 +106,20 @@ async def login(request: LoginRequest) -> dict[str, str]:
|
|||||||
data={"uuid": user["uuid"]}, expires_delta=access_token_expires
|
data={"uuid": user["uuid"]}, expires_delta=access_token_expires
|
||||||
)
|
)
|
||||||
return {"access_token": access_token, "token_type": "bearer"}
|
return {"access_token": access_token, "token_type": "bearer"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/logs", response_model=LogsResponse)
|
||||||
|
async def get_logs(
|
||||||
|
limit: int = Query(50, ge=1, le=1000),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
search: str | None = None,
|
||||||
|
current_user: str = Depends(get_current_user)
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
logs: list[dict[str, Any]] = await repo.get_logs(limit=limit, offset=offset, search=search)
|
||||||
|
total: int = await repo.get_total_logs(search=search)
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
"data": logs
|
||||||
|
}
|
||||||
|
|||||||
46
decnet/web/repository.py
Normal file
46
decnet/web/repository.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class BaseRepository(ABC):
|
||||||
|
"""Abstract base class for DECNET web dashboard data storage."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
"""Initialize the database schema."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def add_log(self, log_data: dict[str, Any]) -> None:
|
||||||
|
"""Add a new log entry to the database."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_logs(
|
||||||
|
self,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
search: Optional[str] = None
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Retrieve paginated log entries."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_total_logs(self, search: Optional[str] = None) -> int:
|
||||||
|
"""Retrieve the total count of logs, optionally filtered by search."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_stats_summary(self) -> dict[str, Any]:
|
||||||
|
"""Retrieve high-level dashboard metrics."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_user_by_username(self, username: str) -> Optional[dict[str, Any]]:
|
||||||
|
"""Retrieve a user by their username."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def create_user(self, user_data: dict[str, Any]) -> None:
|
||||||
|
"""Create a new dashboard user."""
|
||||||
|
pass
|
||||||
121
decnet/web/sqlite_repository.py
Normal file
121
decnet/web/sqlite_repository.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import aiosqlite
|
||||||
|
from typing import Any, Optional
|
||||||
|
from decnet.web.repository import BaseRepository
|
||||||
|
|
||||||
|
|
||||||
|
class SQLiteRepository(BaseRepository):
|
||||||
|
"""SQLite implementation of the DECNET web repository."""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str = "decnet.db") -> None:
|
||||||
|
self.db_path: str = db_path
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
# Logs table
|
||||||
|
await db.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
decky TEXT,
|
||||||
|
service TEXT,
|
||||||
|
event_type TEXT,
|
||||||
|
attacker_ip TEXT,
|
||||||
|
raw_line TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
# Users table (internal RBAC)
|
||||||
|
await db.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
uuid TEXT PRIMARY KEY,
|
||||||
|
username TEXT UNIQUE,
|
||||||
|
password_hash TEXT,
|
||||||
|
role TEXT DEFAULT 'viewer'
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
async def add_log(self, log_data: dict[str, Any]) -> None:
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO logs (decky, service, event_type, attacker_ip, raw_line) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
log_data.get("decky"),
|
||||||
|
log_data.get("service"),
|
||||||
|
log_data.get("event_type"),
|
||||||
|
log_data.get("attacker_ip"),
|
||||||
|
log_data.get("raw_line")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
async def get_logs(
|
||||||
|
self,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
search: Optional[str] = None
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
query: str = "SELECT * FROM logs"
|
||||||
|
params: list[Any] = []
|
||||||
|
if search:
|
||||||
|
query += " WHERE raw_line LIKE ? OR decky LIKE ? OR service LIKE ? OR attacker_ip LIKE ?"
|
||||||
|
like_val = f"%{search}%"
|
||||||
|
params.extend([like_val, like_val, like_val, like_val])
|
||||||
|
|
||||||
|
query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?"
|
||||||
|
params.extend([limit, offset])
|
||||||
|
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
async with db.execute(query, params) as cursor:
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
async def get_total_logs(self, search: Optional[str] = None) -> int:
|
||||||
|
query: str = "SELECT COUNT(*) as total FROM logs"
|
||||||
|
params: list[Any] = []
|
||||||
|
if search:
|
||||||
|
query += " WHERE raw_line LIKE ? OR decky LIKE ? OR service LIKE ? OR attacker_ip LIKE ?"
|
||||||
|
like_val = f"%{search}%"
|
||||||
|
params.extend([like_val, like_val, like_val, like_val])
|
||||||
|
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
async with db.execute(query, params) as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return row["total"] if row else 0
|
||||||
|
|
||||||
|
async def get_stats_summary(self) -> dict[str, Any]:
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
async with db.execute("SELECT COUNT(*) as total_logs FROM logs") as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
total_logs: int = row["total_logs"] if row else 0
|
||||||
|
|
||||||
|
async with db.execute("SELECT COUNT(DISTINCT attacker_ip) as unique_attackers FROM logs") as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
unique_attackers: int = row["unique_attackers"] if row else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_logs": total_logs,
|
||||||
|
"unique_attackers": unique_attackers
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_user_by_username(self, username: str) -> Optional[dict[str, Any]]:
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
async with db.execute("SELECT * FROM users WHERE username = ?", (username,)) as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
async def create_user(self, user_data: dict[str, Any]) -> None:
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO users (uuid, username, password_hash, role) VALUES (?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
user_data["uuid"],
|
||||||
|
user_data["username"],
|
||||||
|
user_data["password_hash"],
|
||||||
|
user_data["role"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import os
|
import os
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from decnet.web.api import app, repo
|
from decnet.web.api import app, repo
|
||||||
|
|
||||||
|
|
||||||
@@ -45,3 +47,28 @@ def test_login_failure() -> None:
|
|||||||
json={"username": "nonexistent", "password": "wrongpassword"}
|
json={"username": "nonexistent", "password": "wrongpassword"}
|
||||||
)
|
)
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_logs_unauthorized() -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.get("/api/v1/logs")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_logs_success() -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
login_response = client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"username": "admin", "password": "admin"}
|
||||||
|
)
|
||||||
|
token = login_response.json()["access_token"]
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/logs",
|
||||||
|
headers={"Authorization": f"Bearer {token}"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "data" in data
|
||||||
|
assert data["total"] >= 0
|
||||||
|
assert isinstance(data["data"], list)
|
||||||
|
|||||||
Reference in New Issue
Block a user