refactor: modularize API routes into separate files and clean up dependencies
This commit is contained in:
4
.hypothesis/constants/05ca8d3a23e4e828
Normal file
4
.hypothesis/constants/05ca8d3a23e4e828
Normal 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']
|
||||
4
.hypothesis/constants/06cf3ea2c6ce1e47
Normal file
4
.hypothesis/constants/06cf3ea2c6ce1e47
Normal 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']
|
||||
4
.hypothesis/constants/119d69517b6a1211
Normal file
4
.hypothesis/constants/119d69517b6a1211
Normal 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']
|
||||
4
.hypothesis/constants/1b93688452019c2a
Normal file
4
.hypothesis/constants/1b93688452019c2a
Normal 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']
|
||||
4
.hypothesis/constants/281f7247b75633fa
Normal file
4
.hypothesis/constants/281f7247b75633fa
Normal 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']
|
||||
4
.hypothesis/constants/413af714a6aa554a
Normal file
4
.hypothesis/constants/413af714a6aa554a
Normal 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']
|
||||
4
.hypothesis/constants/5c94fe41bb0c3028
Normal file
4
.hypothesis/constants/5c94fe41bb0c3028
Normal file
@@ -0,0 +1,4 @@
|
||||
# file: /home/anti/Tools/DECNET/decnet/web/models.py
|
||||
# hypothesis_version: 6.151.12
|
||||
|
||||
[512, 1024]
|
||||
4
.hypothesis/constants/671699dd596f1b47
Normal file
4
.hypothesis/constants/671699dd596f1b47
Normal 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']
|
||||
4
.hypothesis/constants/74d4bb0b083b64f7
Normal file
4
.hypothesis/constants/74d4bb0b083b64f7
Normal 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']
|
||||
4
.hypothesis/constants/752e3c8f1c8255e2
Normal file
4
.hypothesis/constants/752e3c8f1c8255e2
Normal 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']
|
||||
4
.hypothesis/constants/84a92b4c342503ba
Normal file
4
.hypothesis/constants/84a92b4c342503ba
Normal file
@@ -0,0 +1,4 @@
|
||||
# file: /home/anti/Tools/DECNET/decnet/web/router/__init__.py
|
||||
# hypothesis_version: 6.151.12
|
||||
|
||||
[]
|
||||
4
.hypothesis/constants/a9e88f2e1ab12330
Normal file
4
.hypothesis/constants/a9e88f2e1ab12330
Normal 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']
|
||||
4
.hypothesis/constants/e514b080d6ddcc0c
Normal file
4
.hypothesis/constants/e514b080d6ddcc0c
Normal 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']
|
||||
4
.hypothesis/constants/f4166372b8fe275d
Normal file
4
.hypothesis/constants/f4166372b8fe275d
Normal 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']
|
||||
4
.hypothesis/constants/f7527ac1d5cdef10
Normal file
4
.hypothesis/constants/f7527ac1d5cdef10
Normal 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']
|
||||
4
.hypothesis/constants/fe31031a8c96e163
Normal file
4
.hypothesis/constants/fe31031a8c96e163
Normal 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']
|
||||
Binary file not shown.
@@ -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")
|
||||
|
||||
46
decnet/web/dependencies.py
Normal file
46
decnet/web/dependencies.py
Normal 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
46
decnet/web/models.py
Normal 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)
|
||||
36
decnet/web/router/__init__.py
Normal file
36
decnet/web/router/__init__.py
Normal 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)
|
||||
23
decnet/web/router/auth/api_change_pass.py
Normal file
23
decnet/web/router/auth/api_change_pass.py
Normal 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"}
|
||||
36
decnet/web/router/auth/api_login.py
Normal file
36
decnet/web/router/auth/api_login.py
Normal 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))
|
||||
}
|
||||
27
decnet/web/router/bounty/api_get_bounties.py
Normal file
27
decnet/web/router/bounty/api_get_bounties.py
Normal 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
|
||||
}
|
||||
80
decnet/web/router/fleet/api_deploy_deckies.py
Normal file
80
decnet/web/router/fleet/api_deploy_deckies.py
Normal 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"}
|
||||
12
decnet/web/router/fleet/api_get_deckies.py
Normal file
12
decnet/web/router/fleet/api_get_deckies.py
Normal 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()
|
||||
14
decnet/web/router/fleet/api_mutate_decky.py
Normal file
14
decnet/web/router/fleet/api_mutate_decky.py
Normal 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")
|
||||
21
decnet/web/router/fleet/api_mutate_interval.py
Normal file
21
decnet/web/router/fleet/api_mutate_interval.py
Normal 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"}
|
||||
18
decnet/web/router/logs/api_get_histogram.py
Normal file
18
decnet/web/router/logs/api_get_histogram.py
Normal 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)
|
||||
27
decnet/web/router/logs/api_get_logs.py
Normal file
27
decnet/web/router/logs/api_get_logs.py
Normal 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
|
||||
}
|
||||
13
decnet/web/router/stats/api_get_stats.py
Normal file
13
decnet/web/router/stats/api_get_stats.py
Normal 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()
|
||||
63
decnet/web/router/stream/api_stream_events.py
Normal file
63
decnet/web/router/stream/api_stream_events.py
Normal 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")
|
||||
@@ -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.
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user