refactor: modularize API routes into separate files and clean up dependencies

This commit is contained in:
2026-04-09 11:58:57 -04:00
parent 551664bc43
commit 29a2cf2738
45 changed files with 541 additions and 344 deletions

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/web/router/fleet/api_get_deckies.py
# hypothesis_version: 6.151.12
['/deckies', 'Fleet Management']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/web/api.py
# hypothesis_version: 6.151.12
[0.5, '*', '/api/v1', '/docs', '/openapi.json', '/redoc', '1.0.0']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/web/router/stream/api_stream_events.py
# hypothesis_version: 6.151.12
['/stream', 'Observability', 'data', 'histogram', 'id', 'lastEventId', 'logs', 'stats', 'text/event-stream', 'type']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/web/router/logs/api_get_histogram.py
# hypothesis_version: 6.151.12
['/logs/histogram', 'Logs']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/web/dependencies.py
# hypothesis_version: 6.151.12
['/api/v1/auth/login', 'Authorization', 'Bearer', 'Bearer ', 'WWW-Authenticate', 'decnet.db', 'token', 'uuid']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/mutator.py
# hypothesis_version: 6.151.12
[5.0, '--remove-orphans', '-d', '-f', 'compose', 'docker', 'up']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/web/models.py
# hypothesis_version: 6.151.12
[512, 1024]

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/web/router/stats/api_get_stats.py
# hypothesis_version: 6.151.12
['/stats', 'Observability']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/web/router/fleet/api_mutate_decky.py
# hypothesis_version: 6.151.12
[404, 'Fleet Management', 'message']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/web/router/logs/api_get_logs.py
# hypothesis_version: 6.151.12
[1000, '/logs', 'Logs', 'data', 'limit', 'offset', 'total']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/web/router/__init__.py
# hypothesis_version: 6.151.12
[]

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/web/router/fleet/api_mutate_interval.py
# hypothesis_version: 6.151.12
[404, 500, 'Decky not found', 'Fleet Management', 'No active deployment', 'message']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/web/router/auth/api_change_pass.py
# hypothesis_version: 6.151.12
['Authentication', 'message', 'password_hash']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/web/router/bounty/api_get_bounties.py
# hypothesis_version: 6.151.12
[1000, '/bounty', 'Bounty Vault', 'data', 'limit', 'offset', 'total']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/web/router/fleet/api_deploy_deckies.py
# hypothesis_version: 6.151.12
[400, 500, '/deckies/deploy', 'Fleet Management', 'decnet.web.api', 'message', 'unihost']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/web/router/auth/api_login.py
# hypothesis_version: 6.151.12
['/auth/login', 'Authentication', 'Bearer', 'WWW-Authenticate', 'access_token', 'bearer', 'must_change_password', 'password_hash', 'token_type', 'uuid']

View File

@@ -1,28 +1,15 @@
import asyncio
from contextlib import asynccontextmanager
from datetime import timedelta
from typing import Any, AsyncGenerator, Optional
import jwt
from fastapi import Depends, FastAPI, HTTPException, Query, status, Request
from fastapi.responses import StreamingResponse
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import OAuth2PasswordBearer
from pydantic import BaseModel, Field
from decnet.web.auth import (
ACCESS_TOKEN_EXPIRE_MINUTES,
ALGORITHM,
SECRET_KEY,
create_access_token,
get_password_hash,
verify_password,
)
from decnet.web.sqlite_repository import SQLiteRepository
from decnet.web.ingester import log_ingestion_worker
from decnet.env import DECNET_DEVELOPER
import asyncio
from decnet.web.dependencies import repo
from decnet.web.ingester import log_ingestion_worker
from decnet.web.router import api_router
repo: SQLiteRepository = SQLiteRepository()
ingestion_task: Optional[asyncio.Task[Any]] = None
@@ -66,324 +53,5 @@ app.add_middleware(
allow_headers=["*"],
)
oauth2_scheme: OAuth2PasswordBearer = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def get_current_user(request: Request) -> str:
_credentials_exception: HTTPException = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
# Extract token from header or query param
token: str | None = None
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header.split(" ")[1]
elif request.query_params.get("token"):
token = request.query_params.get("token")
if not token:
raise _credentials_exception
try:
_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
class Token(BaseModel):
access_token: str
token_type: str
must_change_password: bool = False
class LoginRequest(BaseModel):
username: str
password: str = Field(..., max_length=72)
class ChangePasswordRequest(BaseModel):
old_password: str = Field(..., max_length=72)
new_password: str = Field(..., max_length=72)
class LogsResponse(BaseModel):
total: int
limit: int
offset: int
data: list[dict[str, Any]]
class BountyResponse(BaseModel):
total: int
limit: int
offset: int
data: list[dict[str, Any]]
@app.post("/api/v1/auth/login", response_model=Token, tags=["Authentication"])
async def login(request: LoginRequest) -> dict[str, Any]:
_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)
# Token uses uuid instead of sub
_access_token: str = create_access_token(
data={"uuid": _user["uuid"]}, expires_delta=_access_token_expires
)
return {
"access_token": _access_token,
"token_type": "bearer", # nosec B105
"must_change_password": bool(_user.get("must_change_password", False))
}
@app.post("/api/v1/auth/change-password", tags=["Authentication"])
async def change_password(request: ChangePasswordRequest, current_user: str = Depends(get_current_user)) -> dict[str, str]:
_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: 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"}
@app.get("/api/v1/logs", response_model=LogsResponse, tags=["Logs"])
async def get_logs(
limit: int = Query(50, ge=1, le=1000),
offset: int = Query(0, ge=0),
search: Optional[str] = None,
start_time: Optional[str] = None,
end_time: 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, start_time=start_time, end_time=end_time)
_total: int = await repo.get_total_logs(search=search, start_time=start_time, end_time=end_time)
return {
"total": _total,
"limit": limit,
"offset": offset,
"data": _logs
}
@app.get("/api/v1/bounty", response_model=BountyResponse, tags=["Bounty Vault"])
async def get_bounties(
limit: int = Query(50, ge=1, le=1000),
offset: int = Query(0, ge=0),
bounty_type: Optional[str] = None,
search: Optional[str] = None,
current_user: str = Depends(get_current_user)
) -> dict[str, Any]:
"""Retrieve collected bounties (harvested credentials, payloads, etc.)."""
_data = await repo.get_bounties(limit=limit, offset=offset, bounty_type=bounty_type, search=search)
_total = await repo.get_total_bounties(bounty_type=bounty_type, search=search)
return {
"total": _total,
"limit": limit,
"offset": offset,
"data": _data
}
@app.get("/api/v1/logs/histogram", tags=["Logs"])
async def get_logs_histogram(
search: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
interval_minutes: int = Query(15, ge=1),
current_user: str = Depends(get_current_user)
) -> list[dict[str, Any]]:
return await repo.get_log_histogram(search=search, start_time=start_time, end_time=end_time, interval_minutes=interval_minutes)
class StatsResponse(BaseModel):
total_logs: int
unique_attackers: int
active_deckies: int
deployed_deckies: int
@app.get("/api/v1/stats", response_model=StatsResponse, tags=["Observability"])
async def get_stats(current_user: str = Depends(get_current_user)) -> dict[str, Any]:
return await repo.get_stats_summary()
@app.get("/api/v1/deckies", tags=["Fleet Management"])
async def get_deckies(current_user: str = Depends(get_current_user)) -> list[dict[str, Any]]:
return await repo.get_deckies()
class MutateIntervalRequest(BaseModel):
mutate_interval: int | None
@app.post("/api/v1/deckies/{decky_name}/mutate", tags=["Fleet Management"])
async def api_mutate_decky(decky_name: str, current_user: str = Depends(get_current_user)) -> dict[str, str]:
from decnet.mutator import mutate_decky
success = mutate_decky(decky_name)
if success:
return {"message": f"Successfully mutated {decky_name}"}
raise HTTPException(status_code=404, detail=f"Decky {decky_name} not found or failed to mutate")
@app.put("/api/v1/deckies/{decky_name}/mutate-interval", tags=["Fleet Management"])
async def api_update_mutate_interval(decky_name: str, req: MutateIntervalRequest, current_user: str = Depends(get_current_user)) -> dict[str, str]:
from decnet.config import load_state, save_state
state = load_state()
if not state:
raise HTTPException(status_code=500, detail="No active deployment")
config, compose_path = state
decky = next((d for d in config.deckies if d.name == decky_name), None)
if not decky:
raise HTTPException(status_code=404, detail="Decky not found")
decky.mutate_interval = req.mutate_interval
save_state(config, compose_path)
return {"message": "Mutation interval updated"}
@app.get("/api/v1/stream", tags=["Observability"])
async def stream_events(
request: Request,
last_event_id: int = Query(0, alias="lastEventId"),
search: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
current_user: str = Depends(get_current_user)
) -> StreamingResponse:
import json
import asyncio
async def event_generator() -> AsyncGenerator[str, None]:
# Start tracking from the provided ID, or current max if 0
last_id = last_event_id
if last_id == 0:
last_id = await repo.get_max_log_id()
stats_interval_sec = 10
loops_since_stats = 0
while True:
if await request.is_disconnected():
break
# Poll for new logs
new_logs = await repo.get_logs_after_id(last_id, limit=50, search=search, start_time=start_time, end_time=end_time)
if new_logs:
# Update last_id to the max id in the fetched batch
last_id = max(log["id"] for log in new_logs)
payload = json.dumps({"type": "logs", "data": new_logs})
yield f"event: message\ndata: {payload}\n\n"
# If we have new logs, stats probably changed, so force a stats update
loops_since_stats = stats_interval_sec
# Periodically poll for stats
if loops_since_stats >= stats_interval_sec:
stats = await repo.get_stats_summary()
payload = json.dumps({"type": "stats", "data": stats})
yield f"event: message\ndata: {payload}\n\n"
# Also yield histogram
histogram = await repo.get_log_histogram(search=search, start_time=start_time, end_time=end_time, interval_minutes=15)
hist_payload = json.dumps({"type": "histogram", "data": histogram})
yield f"event: message\ndata: {hist_payload}\n\n"
loops_since_stats = 0
loops_since_stats += 1
await asyncio.sleep(1)
return StreamingResponse(event_generator(), media_type="text/event-stream")
class DeployIniRequest(BaseModel):
ini_content: str = Field(..., min_length=5, max_length=512 * 1024)
@app.post("/api/v1/deckies/deploy", tags=["Fleet Management"])
async def api_deploy_deckies(req: DeployIniRequest, current_user: str = Depends(get_current_user)) -> dict[str, str]:
from decnet.ini_loader import load_ini_from_string
from decnet.cli import _build_deckies_from_ini
from decnet.config import load_state, DecnetConfig, DEFAULT_MUTATE_INTERVAL
from decnet.network import detect_interface, detect_subnet, get_host_ip
from decnet.deployer import deploy as _deploy
import logging
import os
try:
ini = load_ini_from_string(req.ini_content)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to parse INI: {e}")
state = load_state()
ingest_log_file = os.environ.get("DECNET_INGEST_LOG_FILE")
if state:
config, _ = state
subnet_cidr = ini.subnet or config.subnet
gateway = ini.gateway or config.gateway
host_ip = get_host_ip(config.interface)
randomize_services = False
# Always sync config log_file with current API ingestion target
if ingest_log_file:
config.log_file = ingest_log_file
else:
# If no state exists, we need to infer network details
iface = ini.interface or detect_interface()
subnet_cidr, gateway = ini.subnet, ini.gateway
if not subnet_cidr or not gateway:
detected_subnet, detected_gateway = detect_subnet(iface)
subnet_cidr = subnet_cidr or detected_subnet
gateway = gateway or detected_gateway
host_ip = get_host_ip(iface)
randomize_services = False
config = DecnetConfig(
mode="unihost",
interface=iface,
subnet=subnet_cidr,
gateway=gateway,
deckies=[],
log_target=ini.log_target,
log_file=ingest_log_file,
ipvlan=False,
mutate_interval=ini.mutate_interval or DEFAULT_MUTATE_INTERVAL
)
try:
new_decky_configs = _build_deckies_from_ini(
ini, subnet_cidr, gateway, host_ip, randomize_services, cli_mutate_interval=None
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Merge deckies
existing_deckies_map = {d.name: d for d in config.deckies}
for new_decky in new_decky_configs:
existing_deckies_map[new_decky.name] = new_decky
config.deckies = list(existing_deckies_map.values())
# We call deploy(config) which regenerates docker-compose and runs `up -d --remove-orphans`.
try:
_deploy(config)
except Exception as e:
logging.getLogger("decnet.web.api").error(f"Deployment failed: {e}")
raise HTTPException(status_code=500, detail=f"Deployment failed: {e}")
return {"message": "Deckies deployed successfully"}
# Include the modular API router
app.include_router(api_router, prefix="/api/v1")

View File

@@ -0,0 +1,46 @@
from typing import Any, Optional
from pathlib import Path
import jwt
from fastapi import HTTPException, status, Request
from fastapi.security import OAuth2PasswordBearer
from decnet.web.auth import ALGORITHM, SECRET_KEY
from decnet.web.sqlite_repository import SQLiteRepository
# Root directory for database
_ROOT_DIR = Path(__file__).parent.parent.parent.absolute()
DB_PATH = _ROOT_DIR / "decnet.db"
# Shared repository instance
repo = SQLiteRepository(db_path=str(DB_PATH))
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def get_current_user(request: Request) -> str:
_credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
# Extract token from header or query param
token: str | None = None
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header.split(" ")[1]
elif request.query_params.get("token"):
token = request.query_params.get("token")
if not token:
raise _credentials_exception
try:
_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

46
decnet/web/models.py Normal file
View File

@@ -0,0 +1,46 @@
from typing import Any
from pydantic import BaseModel, Field
class Token(BaseModel):
access_token: str
token_type: str
must_change_password: bool = False
class LoginRequest(BaseModel):
username: str
password: str = Field(..., max_length=72)
class ChangePasswordRequest(BaseModel):
old_password: str = Field(..., max_length=72)
new_password: str = Field(..., max_length=72)
class LogsResponse(BaseModel):
total: int
limit: int
offset: int
data: list[dict[str, Any]]
class BountyResponse(BaseModel):
total: int
limit: int
offset: int
data: list[dict[str, Any]]
class StatsResponse(BaseModel):
total_logs: int
unique_attackers: int
active_deckies: int
deployed_deckies: int
class MutateIntervalRequest(BaseModel):
mutate_interval: int | None
class DeployIniRequest(BaseModel):
ini_content: str = Field(..., min_length=5, max_length=512 * 1024)

View File

@@ -0,0 +1,36 @@
from fastapi import APIRouter
from .auth.api_login import router as login_router
from .auth.api_change_pass import router as change_pass_router
from .logs.api_get_logs import router as logs_router
from .logs.api_get_histogram import router as histogram_router
from .bounty.api_get_bounties import router as bounty_router
from .stats.api_get_stats import router as stats_router
from .fleet.api_get_deckies import router as get_deckies_router
from .fleet.api_mutate_decky import router as mutate_decky_router
from .fleet.api_mutate_interval import router as mutate_interval_router
from .fleet.api_deploy_deckies import router as deploy_deckies_router
from .stream.api_stream_events import router as stream_router
api_router = APIRouter()
# Authentication
api_router.include_router(login_router)
api_router.include_router(change_pass_router)
# Logs & Analytics
api_router.include_router(logs_router)
api_router.include_router(histogram_router)
# Bounty Vault
api_router.include_router(bounty_router)
# Fleet Management
api_router.include_router(get_deckies_router)
api_router.include_router(mutate_decky_router)
api_router.include_router(mutate_interval_router)
api_router.include_router(deploy_deckies_router)
# Observability
api_router.include_router(stats_router)
api_router.include_router(stream_router)

View File

@@ -0,0 +1,23 @@
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from decnet.web.auth import get_password_hash, verify_password
from decnet.web.dependencies import get_current_user, repo
from decnet.web.models import ChangePasswordRequest
router = APIRouter()
@router.post("/auth/change-password", tags=["Authentication"])
async def change_password(request: ChangePasswordRequest, current_user: str = Depends(get_current_user)) -> dict[str, str]:
_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: 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"}

View File

@@ -0,0 +1,36 @@
from datetime import timedelta
from typing import Any, Optional
from fastapi import APIRouter, HTTPException, status
from decnet.web.auth import (
ACCESS_TOKEN_EXPIRE_MINUTES,
create_access_token,
verify_password,
)
from decnet.web.dependencies import repo
from decnet.web.models import LoginRequest, Token
router = APIRouter()
@router.post("/auth/login", response_model=Token, tags=["Authentication"])
async def login(request: LoginRequest) -> dict[str, Any]:
_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)
# Token uses uuid instead of sub
_access_token: str = create_access_token(
data={"uuid": _user["uuid"]}, expires_delta=_access_token_expires
)
return {
"access_token": _access_token,
"token_type": "bearer", # nosec B105
"must_change_password": bool(_user.get("must_change_password", False))
}

View File

@@ -0,0 +1,27 @@
from typing import Any, Optional
from fastapi import APIRouter, Depends, Query
from decnet.web.dependencies import get_current_user, repo
from decnet.web.models import BountyResponse
router = APIRouter()
@router.get("/bounty", response_model=BountyResponse, tags=["Bounty Vault"])
async def get_bounties(
limit: int = Query(50, ge=1, le=1000),
offset: int = Query(0, ge=0),
bounty_type: Optional[str] = None,
search: Optional[str] = None,
current_user: str = Depends(get_current_user)
) -> dict[str, Any]:
"""Retrieve collected bounties (harvested credentials, payloads, etc.)."""
_data = await repo.get_bounties(limit=limit, offset=offset, bounty_type=bounty_type, search=search)
_total = await repo.get_total_bounties(bounty_type=bounty_type, search=search)
return {
"total": _total,
"limit": limit,
"offset": offset,
"data": _data
}

View File

@@ -0,0 +1,80 @@
import logging
import os
from fastapi import APIRouter, Depends, HTTPException
from decnet.config import DEFAULT_MUTATE_INTERVAL, DecnetConfig, load_state
from decnet.deployer import deploy as _deploy
from decnet.ini_loader import load_ini_from_string
from decnet.network import detect_interface, detect_subnet, get_host_ip
from decnet.web.dependencies import get_current_user
from decnet.web.models import DeployIniRequest
router = APIRouter()
@router.post("/deckies/deploy", tags=["Fleet Management"])
async def api_deploy_deckies(req: DeployIniRequest, current_user: str = Depends(get_current_user)) -> dict[str, str]:
from decnet.cli import _build_deckies_from_ini
try:
ini = load_ini_from_string(req.ini_content)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to parse INI: {e}")
state = load_state()
ingest_log_file = os.environ.get("DECNET_INGEST_LOG_FILE")
if state:
config, _ = state
subnet_cidr = ini.subnet or config.subnet
gateway = ini.gateway or config.gateway
host_ip = get_host_ip(config.interface)
randomize_services = False
# Always sync config log_file with current API ingestion target
if ingest_log_file:
config.log_file = ingest_log_file
else:
# If no state exists, we need to infer network details
iface = ini.interface or detect_interface()
subnet_cidr, gateway = ini.subnet, ini.gateway
if not subnet_cidr or not gateway:
detected_subnet, detected_gateway = detect_subnet(iface)
subnet_cidr = subnet_cidr or detected_subnet
gateway = gateway or detected_gateway
host_ip = get_host_ip(iface)
randomize_services = False
config = DecnetConfig(
mode="unihost",
interface=iface,
subnet=subnet_cidr,
gateway=gateway,
deckies=[],
log_target=ini.log_target,
log_file=ingest_log_file,
ipvlan=False,
mutate_interval=ini.mutate_interval or DEFAULT_MUTATE_INTERVAL
)
try:
new_decky_configs = _build_deckies_from_ini(
ini, subnet_cidr, gateway, host_ip, randomize_services, cli_mutate_interval=None
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Merge deckies
existing_deckies_map = {d.name: d for d in config.deckies}
for new_decky in new_decky_configs:
existing_deckies_map[new_decky.name] = new_decky
config.deckies = list(existing_deckies_map.values())
# We call deploy(config) which regenerates docker-compose and runs `up -d --remove-orphans`.
try:
_deploy(config)
except Exception as e:
logging.getLogger("decnet.web.api").error(f"Deployment failed: {e}")
raise HTTPException(status_code=500, detail=f"Deployment failed: {e}")
return {"message": "Deckies deployed successfully"}

View File

@@ -0,0 +1,12 @@
from typing import Any
from fastapi import APIRouter, Depends
from decnet.web.dependencies import get_current_user, repo
router = APIRouter()
@router.get("/deckies", tags=["Fleet Management"])
async def get_deckies(current_user: str = Depends(get_current_user)) -> list[dict[str, Any]]:
return await repo.get_deckies()

View File

@@ -0,0 +1,14 @@
from fastapi import APIRouter, Depends, HTTPException
from decnet.mutator import mutate_decky
from decnet.web.dependencies import get_current_user
router = APIRouter()
@router.post("/deckies/{decky_name}/mutate", tags=["Fleet Management"])
async def api_mutate_decky(decky_name: str, current_user: str = Depends(get_current_user)) -> dict[str, str]:
success = mutate_decky(decky_name)
if success:
return {"message": f"Successfully mutated {decky_name}"}
raise HTTPException(status_code=404, detail=f"Decky {decky_name} not found or failed to mutate")

View File

@@ -0,0 +1,21 @@
from fastapi import APIRouter, Depends, HTTPException
from decnet.config import load_state, save_state
from decnet.web.dependencies import get_current_user
from decnet.web.models import MutateIntervalRequest
router = APIRouter()
@router.put("/deckies/{decky_name}/mutate-interval", tags=["Fleet Management"])
async def api_update_mutate_interval(decky_name: str, req: MutateIntervalRequest, current_user: str = Depends(get_current_user)) -> dict[str, str]:
state = load_state()
if not state:
raise HTTPException(status_code=500, detail="No active deployment")
config, compose_path = state
decky = next((d for d in config.deckies if d.name == decky_name), None)
if not decky:
raise HTTPException(status_code=404, detail="Decky not found")
decky.mutate_interval = req.mutate_interval
save_state(config, compose_path)
return {"message": "Mutation interval updated"}

View File

@@ -0,0 +1,18 @@
from typing import Any, Optional
from fastapi import APIRouter, Depends, Query
from decnet.web.dependencies import get_current_user, repo
router = APIRouter()
@router.get("/logs/histogram", tags=["Logs"])
async def get_logs_histogram(
search: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
interval_minutes: int = Query(15, ge=1),
current_user: str = Depends(get_current_user)
) -> list[dict[str, Any]]:
return await repo.get_log_histogram(search=search, start_time=start_time, end_time=end_time, interval_minutes=interval_minutes)

View File

@@ -0,0 +1,27 @@
from typing import Any, Optional
from fastapi import APIRouter, Depends, Query
from decnet.web.dependencies import get_current_user, repo
from decnet.web.models import LogsResponse
router = APIRouter()
@router.get("/logs", response_model=LogsResponse, tags=["Logs"])
async def get_logs(
limit: int = Query(50, ge=1, le=1000),
offset: int = Query(0, ge=0),
search: Optional[str] = None,
start_time: Optional[str] = None,
end_time: 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, start_time=start_time, end_time=end_time)
_total: int = await repo.get_total_logs(search=search, start_time=start_time, end_time=end_time)
return {
"total": _total,
"limit": limit,
"offset": offset,
"data": _logs
}

View File

@@ -0,0 +1,13 @@
from typing import Any
from fastapi import APIRouter, Depends
from decnet.web.dependencies import get_current_user, repo
from decnet.web.models import StatsResponse
router = APIRouter()
@router.get("/stats", response_model=StatsResponse, tags=["Observability"])
async def get_stats(current_user: str = Depends(get_current_user)) -> dict[str, Any]:
return await repo.get_stats_summary()

View File

@@ -0,0 +1,63 @@
import json
import asyncio
from typing import AsyncGenerator, Optional
from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import StreamingResponse
from decnet.web.dependencies import get_current_user, repo
router = APIRouter()
@router.get("/stream", tags=["Observability"])
async def stream_events(
request: Request,
last_event_id: int = Query(0, alias="lastEventId"),
search: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
current_user: str = Depends(get_current_user)
) -> StreamingResponse:
async def event_generator() -> AsyncGenerator[str, None]:
# Start tracking from the provided ID, or current max if 0
last_id = last_event_id
if last_id == 0:
last_id = await repo.get_max_log_id()
stats_interval_sec = 10
loops_since_stats = 0
while True:
if await request.is_disconnected():
break
# Poll for new logs
new_logs = await repo.get_logs_after_id(last_id, limit=50, search=search, start_time=start_time, end_time=end_time)
if new_logs:
# Update last_id to the max id in the fetched batch
last_id = max(log["id"] for log in new_logs)
payload = json.dumps({"type": "logs", "data": new_logs})
yield f"event: message\ndata: {payload}\n\n"
# If we have new logs, stats probably changed, so force a stats update
loops_since_stats = stats_interval_sec
# Periodically poll for stats
if loops_since_stats >= stats_interval_sec:
stats = await repo.get_stats_summary()
payload = json.dumps({"type": "stats", "data": stats})
yield f"event: message\ndata: {payload}\n\n"
# Also yield histogram
histogram = await repo.get_log_histogram(search=search, start_time=start_time, end_time=end_time, interval_minutes=15)
hist_payload = json.dumps({"type": "histogram", "data": histogram})
yield f"event: message\ndata: {hist_payload}\n\n"
loops_since_stats = 0
loops_since_stats += 1
await asyncio.sleep(1)
return StreamingResponse(event_generator(), media_type="text/event-stream")

View File

@@ -410,7 +410,7 @@ class SQLiteRepository(BaseRepository):
_d = dict(_row)
try:
_d["payload"] = json.loads(_d["payload"])
except Exception:
except Exception: # nosec B110
pass
_results.append(_d)
return _results

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -2,7 +2,8 @@ import os
from typing import Generator
import pytest
from fastapi.testclient import TestClient
from decnet.web.api import app, repo
from decnet.web.api import app
from decnet.web.dependencies import repo
from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD
@pytest.fixture(autouse=True)

View File

@@ -5,7 +5,7 @@ from decnet.web.api import app
import decnet.config
from pathlib import Path
from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD
from decnet.web.api import repo
from decnet.web.dependencies import repo
@pytest.fixture(autouse=True)
def setup_db():

View File

@@ -4,7 +4,8 @@ from typing import Generator
import pytest
from fastapi.testclient import TestClient
from decnet.web.api import app, repo
from decnet.web.api import app
from decnet.web.dependencies import repo
@pytest.fixture(autouse=True)

View File

@@ -6,7 +6,8 @@ from fastapi.testclient import TestClient
from hypothesis import given, strategies as st, settings, HealthCheck
import httpx
from decnet.web.api import app, repo
from decnet.web.api import app
from decnet.web.dependencies import repo
# Re-use setup from test_web_api
@pytest.fixture(scope="function", autouse=True)