chore: enforce strict typing and internal naming conventions across web components

This commit is contained in:
2026-04-07 19:56:15 -04:00
parent 950280a97b
commit ba2faba5d5
52 changed files with 4967 additions and 934 deletions

View File

@@ -353,12 +353,12 @@ def deploy(
import subprocess
import sys
console.print(f"[green]Starting DECNET API on port {api_port}...[/]")
env = os.environ.copy()
env["DECNET_INGEST_LOG_FILE"] = effective_log_file
_env: dict[str, str] = os.environ.copy()
_env["DECNET_INGEST_LOG_FILE"] = str(effective_log_file)
try:
subprocess.Popen(
[sys.executable, "-m", "uvicorn", "decnet.web.api:app", "--host", "0.0.0.0", "--port", str(api_port)],
env=env,
env=_env,
stdout=subprocess.DEVNULL,
stderr=subprocess.STDOUT
)

View File

@@ -1,7 +1,7 @@
import uuid
from contextlib import asynccontextmanager
from datetime import timedelta
from typing import Any, AsyncGenerator
from typing import Any, AsyncGenerator, Optional
import jwt
from fastapi import Depends, FastAPI, HTTPException, Query, status
@@ -22,7 +22,7 @@ from decnet.web.ingester import log_ingestion_worker
import asyncio
repo: SQLiteRepository = SQLiteRepository()
ingestion_task: asyncio.Task | None = None
ingestion_task: Optional[asyncio.Task[Any]] = None
@asynccontextmanager
@@ -30,8 +30,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
global ingestion_task
await repo.initialize()
# Create default admin if no users exist
admin_user: dict[str, Any] | None = await repo.get_user_by_username("admin")
if not admin_user:
_admin_user: Optional[dict[str, Any]] = await repo.get_user_by_username("admin")
if not _admin_user:
await repo.create_user(
{
"uuid": str(uuid.uuid4()),
@@ -71,19 +71,19 @@ oauth2_scheme: OAuth2PasswordBearer = OAuth2PasswordBearer(tokenUrl="/api/v1/aut
async def get_current_user(token: str = Depends(oauth2_scheme)) -> str:
credentials_exception = HTTPException(
_credentials_exception: HTTPException = 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
_payload: dict[str, Any] = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
_user_uuid: Optional[str] = _payload.get("uuid")
if _user_uuid is None:
raise _credentials_exception
return _user_uuid
except jwt.PyJWTError:
raise credentials_exception
return user_uuid
raise _credentials_exception
class Token(BaseModel):
@@ -111,37 +111,37 @@ class LogsResponse(BaseModel):
@app.post("/api/v1/auth/login", response_model=Token)
async def login(request: LoginRequest) -> dict[str, Any]:
user: dict[str, Any] | None = await repo.get_user_by_username(request.username)
if not user or not verify_password(request.password, user["password_hash"]):
_user: Optional[dict[str, Any]] = await repo.get_user_by_username(request.username)
if not _user or not verify_password(request.password, _user["password_hash"]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires: timedelta = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
_access_token_expires: timedelta = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
# Token uses uuid instead of sub
access_token: str = create_access_token(
data={"uuid": user["uuid"]}, expires_delta=access_token_expires
_access_token: str = create_access_token(
data={"uuid": _user["uuid"]}, expires_delta=_access_token_expires
)
return {
"access_token": access_token,
"access_token": _access_token,
"token_type": "bearer",
"must_change_password": bool(user.get("must_change_password", False))
"must_change_password": bool(_user.get("must_change_password", False))
}
@app.post("/api/v1/auth/change-password")
async def change_password(request: ChangePasswordRequest, current_user: str = Depends(get_current_user)) -> dict[str, str]:
user: dict[str, Any] | None = await repo.get_user_by_uuid(current_user)
if not user or not verify_password(request.old_password, user["password_hash"]):
_user: Optional[dict[str, Any]] = await repo.get_user_by_uuid(current_user)
if not _user or not verify_password(request.old_password, _user["password_hash"]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect old password",
)
new_hash = get_password_hash(request.new_password)
await repo.update_user_password(current_user, new_hash, must_change_password=False)
_new_hash: str = get_password_hash(request.new_password)
await repo.update_user_password(current_user, _new_hash, must_change_password=False)
return {"message": "Password updated successfully"}
@@ -149,16 +149,16 @@ async def change_password(request: ChangePasswordRequest, current_user: str = De
async def get_logs(
limit: int = Query(50, ge=1, le=1000),
offset: int = Query(0, ge=0),
search: str | None = None,
search: Optional[str] = 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)
_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,
"total": _total,
"limit": limit,
"offset": offset,
"data": logs
"data": _logs
}

View File

@@ -18,20 +18,20 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
def get_password_hash(password: str) -> str:
# Use a cost factor of 12 (default for passlib/bcrypt)
salt = bcrypt.gensalt(rounds=12)
hashed = bcrypt.hashpw(password.encode("utf-8"), salt)
return hashed.decode("utf-8")
_salt: bytes = bcrypt.gensalt(rounds=12)
_hashed: bytes = bcrypt.hashpw(password.encode("utf-8"), _salt)
return _hashed.decode("utf-8")
def create_access_token(data: dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
to_encode: dict[str, Any] = data.copy()
expire: datetime
_to_encode: dict[str, Any] = data.copy()
_expire: datetime
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
_expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
_expire = datetime.now(timezone.utc) + timedelta(minutes=15)
to_encode.update({"exp": expire})
to_encode.update({"iat": datetime.now(timezone.utc)})
encoded_jwt: str = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
_to_encode.update({"exp": _expire})
_to_encode.update({"iat": datetime.now(timezone.utc)})
_encoded_jwt: str = jwt.encode(_to_encode, SECRET_KEY, algorithm=ALGORITHM)
return _encoded_jwt

View File

@@ -7,62 +7,62 @@ from pathlib import Path
from decnet.web.repository import BaseRepository
logger = logging.getLogger("decnet.web.ingester")
logger: logging.Logger = logging.getLogger("decnet.web.ingester")
async def log_ingestion_worker(repo: BaseRepository) -> None:
"""
Background task that tails the DECNET_INGEST_LOG_FILE.json and
inserts structured JSON logs into the SQLite repository.
"""
base_log_file = os.environ.get("DECNET_INGEST_LOG_FILE")
if not base_log_file:
_base_log_file: str | None = os.environ.get("DECNET_INGEST_LOG_FILE")
if not _base_log_file:
logger.warning("DECNET_INGEST_LOG_FILE not set. Log ingestion disabled.")
return
json_log_path = Path(base_log_file).with_suffix(".json")
position = 0
_json_log_path: Path = Path(_base_log_file).with_suffix(".json")
_position: int = 0
logger.info(f"Starting JSON log ingestion from {json_log_path}")
logger.info(f"Starting JSON log ingestion from {_json_log_path}")
while True:
try:
if not json_log_path.exists():
if not _json_log_path.exists():
await asyncio.sleep(2)
continue
stat = json_log_path.stat()
if stat.st_size < position:
_stat: os.stat_result = _json_log_path.stat()
if _stat.st_size < _position:
# File rotated or truncated
position = 0
_position = 0
if stat.st_size == position:
if _stat.st_size == _position:
# No new data
await asyncio.sleep(1)
continue
with open(json_log_path, "r", encoding="utf-8", errors="replace") as f:
f.seek(position)
with open(_json_log_path, "r", encoding="utf-8", errors="replace") as _f:
_f.seek(_position)
while True:
line = f.readline()
if not line:
_line: str = _f.readline()
if not _line:
break # EOF reached
if not line.endswith('\n'):
if not _line.endswith('\n'):
# Partial line read, don't process yet, don't advance position
break
try:
log_data = json.loads(line.strip())
await repo.add_log(log_data)
_log_data: dict[str, Any] = json.loads(_line.strip())
await repo.add_log(_log_data)
except json.JSONDecodeError:
logger.error(f"Failed to decode JSON log line: {line}")
logger.error(f"Failed to decode JSON log line: {_line}")
continue
# Update position after successful line read
position = f.tell()
_position = _f.tell()
except Exception as e:
logger.error(f"Error in log ingestion worker: {e}")
except Exception as _e:
logger.error(f"Error in log ingestion worker: {_e}")
await asyncio.sleep(5)
await asyncio.sleep(1)

View File

@@ -10,9 +10,9 @@ class SQLiteRepository(BaseRepository):
self.db_path: str = db_path
async def initialize(self) -> None:
async with aiosqlite.connect(self.db_path) as db:
async with aiosqlite.connect(self.db_path) as _db:
# Logs table
await db.execute("""
await _db.execute("""
CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -26,15 +26,15 @@ class SQLiteRepository(BaseRepository):
)
""")
try:
await db.execute("ALTER TABLE logs ADD COLUMN fields TEXT")
await _db.execute("ALTER TABLE logs ADD COLUMN fields TEXT")
except aiosqlite.OperationalError:
pass
try:
await db.execute("ALTER TABLE logs ADD COLUMN msg TEXT")
await _db.execute("ALTER TABLE logs ADD COLUMN msg TEXT")
except aiosqlite.OperationalError:
pass
# Users table (internal RBAC)
await db.execute("""
await _db.execute("""
CREATE TABLE IF NOT EXISTS users (
uuid TEXT PRIMARY KEY,
username TEXT UNIQUE,
@@ -44,19 +44,19 @@ class SQLiteRepository(BaseRepository):
)
""")
try:
await db.execute("ALTER TABLE users ADD COLUMN must_change_password BOOLEAN DEFAULT 0")
await _db.execute("ALTER TABLE users ADD COLUMN must_change_password BOOLEAN DEFAULT 0")
except aiosqlite.OperationalError:
pass # Column already exists
await db.commit()
await _db.commit()
async def add_log(self, log_data: dict[str, Any]) -> None:
async with aiosqlite.connect(self.db_path) as db:
timestamp = log_data.get("timestamp")
if timestamp:
await db.execute(
async with aiosqlite.connect(self.db_path) as _db:
_timestamp: Any = log_data.get("timestamp")
if _timestamp:
await _db.execute(
"INSERT INTO logs (timestamp, decky, service, event_type, attacker_ip, raw_line, fields, msg) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
timestamp,
_timestamp,
log_data.get("decky"),
log_data.get("service"),
log_data.get("event_type"),
@@ -67,7 +67,7 @@ class SQLiteRepository(BaseRepository):
)
)
else:
await db.execute(
await _db.execute(
"INSERT INTO logs (decky, service, event_type, attacker_ip, raw_line, fields, msg) VALUES (?, ?, ?, ?, ?, ?, ?)",
(
log_data.get("decky"),
@@ -79,7 +79,7 @@ class SQLiteRepository(BaseRepository):
log_data.get("msg")
)
)
await db.commit()
await _db.commit()
async def get_logs(
self,
@@ -87,74 +87,74 @@ class SQLiteRepository(BaseRepository):
offset: int = 0,
search: Optional[str] = None
) -> list[dict[str, Any]]:
query: str = "SELECT * FROM logs"
params: list[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 += " WHERE raw_line LIKE ? OR decky LIKE ? OR service LIKE ? OR attacker_ip LIKE ?"
_like_val: str = f"%{search}%"
_params.extend([_like_val, _like_val, _like_val, _like_val])
query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
_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 with aiosqlite.connect(self.db_path) as _db:
_db.row_factory = aiosqlite.Row
async with _db.execute(_query, _params) as _cursor:
_rows: list[aiosqlite.Row] = 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] = []
_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])
_query += " WHERE raw_line LIKE ? OR decky LIKE ? OR service LIKE ? OR attacker_ip LIKE ?"
_like_val: str = 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 with aiosqlite.connect(self.db_path) as _db:
_db.row_factory = aiosqlite.Row
async with _db.execute(_query, _params) as _cursor:
_row: Optional[aiosqlite.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 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: Optional[aiosqlite.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
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
async with db.execute("SELECT COUNT(DISTINCT decky) as active_deckies FROM logs") as cursor:
row = await cursor.fetchone()
active_deckies: int = row["active_deckies"] if row else 0
async with _db.execute("SELECT COUNT(DISTINCT decky) as active_deckies FROM logs") as _cursor:
_row = await _cursor.fetchone()
_active_deckies: int = _row["active_deckies"] if _row else 0
return {
"total_logs": total_logs,
"unique_attackers": unique_attackers,
"active_deckies": active_deckies
"total_logs": _total_logs,
"unique_attackers": _unique_attackers,
"active_deckies": _active_deckies
}
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 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: Optional[aiosqlite.Row] = await _cursor.fetchone()
return dict(_row) if _row else None
async def get_user_by_uuid(self, uuid: 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 uuid = ?", (uuid,)) as cursor:
row = await cursor.fetchone()
return dict(row) if row else None
async with aiosqlite.connect(self.db_path) as _db:
_db.row_factory = aiosqlite.Row
async with _db.execute("SELECT * FROM users WHERE uuid = ?", (uuid,)) as _cursor:
_row: Optional[aiosqlite.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(
async with aiosqlite.connect(self.db_path) as _db:
await _db.execute(
"INSERT INTO users (uuid, username, password_hash, role, must_change_password) VALUES (?, ?, ?, ?, ?)",
(
user_data["uuid"],
@@ -164,12 +164,12 @@ class SQLiteRepository(BaseRepository):
user_data.get("must_change_password", False)
)
)
await db.commit()
await _db.commit()
async def update_user_password(self, uuid: str, password_hash: str, must_change_password: bool = False) -> None:
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
async with aiosqlite.connect(self.db_path) as _db:
await _db.execute(
"UPDATE users SET password_hash = ?, must_change_password = ? WHERE uuid = ?",
(password_hash, must_change_password, uuid)
)
await db.commit()
await _db.commit()