feat: troppa roba davvero
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
from api.auth.router import router
|
||||
from app.api.auth.router import router
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
from datetime import UTC, datetime
|
||||
from typing import Annotated
|
||||
|
||||
from core.security import (
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
|
||||
from app.core.security import (
|
||||
AuthContext,
|
||||
create_access_token,
|
||||
get_auth_context,
|
||||
verify_password,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from models.auth import LoginInput, TokenResponse
|
||||
from repositories.auth import get_user_by_username, revoke_token
|
||||
from app.models.auth import LoginInput, TokenResponse
|
||||
from app.repositories.auth import get_user_by_username, revoke_token
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/auth",
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from core.security import (
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from app.core.security import (
|
||||
InvalidTokenError,
|
||||
authenticate_token,
|
||||
require_authenticated_user,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
|
||||
from services.editor import python_lsp
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
from app.services.editor import python_lsp
|
||||
|
||||
router = APIRouter(prefix="/proxy/editor/python", tags=["Editor"])
|
||||
|
||||
|
||||
+236
-52
@@ -1,44 +1,61 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from core.config import settings
|
||||
from core.security import (
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
|
||||
from pymongo.errors import DuplicateKeyError
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import (
|
||||
InvalidTokenError,
|
||||
authenticate_token,
|
||||
require_authenticated_user,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, status
|
||||
from integrations.docker import get_all_services_in_dir
|
||||
from integrations.docker.discovery import service_scan_root
|
||||
from models.example import AddonExampleCreateInput
|
||||
from models.proxy import ProxyCreateInput, ProxyUpdateInput
|
||||
from pymongo.errors import DuplicateKeyError
|
||||
from repositories.examples import (
|
||||
create_custom_example,
|
||||
delete_custom_example,
|
||||
list_custom_examples,
|
||||
from app.integrations.docker import get_all_services_in_dir
|
||||
from app.integrations.docker.discovery import service_scan_root
|
||||
from app.models.health import HealthHistoryResponse, HealthSnapshot
|
||||
from app.models.proxy import ProxyCreateInput, ProxyUpdateInput
|
||||
from app.models.template import ProxyTemplateCreateInput
|
||||
from app.repositories.health import (
|
||||
delete_health_history,
|
||||
get_latest_health_sample,
|
||||
list_health_history,
|
||||
record_health_sample,
|
||||
)
|
||||
from repositories.proxy import (
|
||||
from app.repositories.proxy import (
|
||||
create_proxy,
|
||||
delete_proxy,
|
||||
get_proxy_by_port,
|
||||
update_proxy,
|
||||
update_proxy_runtime,
|
||||
)
|
||||
from repositories.proxy import (
|
||||
from app.repositories.proxy import (
|
||||
list_proxies as list_proxy_documents,
|
||||
)
|
||||
from services.mitmproxy.examples import BUILTIN_EXAMPLES
|
||||
from services.mitmproxy.health import check_proxy
|
||||
from services.mitmproxy.manager import MitmdumpError, manager
|
||||
from services.mitmproxy.options import (
|
||||
from app.repositories.templates import (
|
||||
create_custom_template,
|
||||
delete_custom_template,
|
||||
list_custom_templates,
|
||||
)
|
||||
from app.services.mitmproxy.health import check_proxy
|
||||
from app.services.mitmproxy.health_monitor import health_monitor
|
||||
from app.services.mitmproxy.manager import MitmdumpError, manager
|
||||
from app.services.mitmproxy.options import (
|
||||
MitmOptionError,
|
||||
load_options,
|
||||
public_options,
|
||||
validate_options,
|
||||
)
|
||||
from services.mitmproxy.validator import ProxyCodeValidationError, validate_proxy_code
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
from app.services.mitmproxy.runtime_validation import (
|
||||
ReservedProxyPortError,
|
||||
backend_port,
|
||||
ensure_proxy_port_not_reserved,
|
||||
)
|
||||
from app.services.mitmproxy.templates import BUILTIN_TEMPLATES
|
||||
from app.services.mitmproxy.validator import (
|
||||
ProxyCodeValidationError,
|
||||
validate_proxy_code,
|
||||
)
|
||||
|
||||
SUPPORTED_MODES = {
|
||||
"regular",
|
||||
@@ -62,6 +79,8 @@ SUPPORTED_PROTOCOLS = {
|
||||
"http3",
|
||||
"quic",
|
||||
}
|
||||
DEFAULT_HEALTH_HISTORY_LIMIT = 40
|
||||
MAX_HEALTH_HISTORY_LIMIT = 200
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/proxy",
|
||||
@@ -113,6 +132,16 @@ async def validate_mitm_options(options: dict[str, object]) -> None:
|
||||
) from error
|
||||
|
||||
|
||||
def validate_proxy_port(proxy_port: int) -> None:
|
||||
try:
|
||||
ensure_proxy_port_not_reserved(proxy_port)
|
||||
except ReservedProxyPortError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(error),
|
||||
) from error
|
||||
|
||||
|
||||
async def get_proxy_or_404(proxy_port: int):
|
||||
proxy = await get_proxy_by_port(proxy_port)
|
||||
if proxy is None:
|
||||
@@ -143,10 +172,46 @@ def proxy_log_path(proxy) -> Path:
|
||||
return manager.log_path_for(proxy)
|
||||
|
||||
|
||||
def read_log_snapshot(path: Path, limit: int = 8000) -> str:
|
||||
if not path.is_file():
|
||||
return ""
|
||||
return path.read_bytes()[-limit:].decode("utf-8", errors="replace")
|
||||
def serialize_health_snapshot(
|
||||
snapshot: HealthSnapshot | None,
|
||||
) -> dict[str, object] | None:
|
||||
if snapshot is None:
|
||||
return None
|
||||
return snapshot.model_dump(mode="json")
|
||||
|
||||
|
||||
async def read_log_snapshot(path: Path, limit: int = 8000) -> str:
|
||||
snapshot, _offset, _marker = await snapshot_log_state(path, limit=limit)
|
||||
return snapshot
|
||||
|
||||
|
||||
async def snapshot_log_state(
|
||||
path: Path,
|
||||
limit: int = 8000,
|
||||
marker_width: int = 64,
|
||||
) -> tuple[str, int, bytes]:
|
||||
if not await asyncio.to_thread(path.is_file):
|
||||
return "", 0, b""
|
||||
|
||||
data = await asyncio.to_thread(path.read_bytes)
|
||||
size = len(data)
|
||||
marker = data[max(0, size - marker_width) : size]
|
||||
snapshot = data[-limit:].decode("utf-8", errors="replace")
|
||||
return snapshot, size, marker
|
||||
|
||||
|
||||
def _read_log_marker_bytes(path: Path, offset: int, width: int) -> bytes:
|
||||
if offset <= 0 or not path.is_file():
|
||||
return b""
|
||||
|
||||
start = max(0, offset - width)
|
||||
with path.open("rb") as handle:
|
||||
handle.seek(start)
|
||||
return handle.read(offset - start)
|
||||
|
||||
|
||||
async def read_log_marker(path: Path, offset: int, width: int = 64) -> bytes:
|
||||
return await asyncio.to_thread(_read_log_marker_bytes, path, offset, width)
|
||||
|
||||
|
||||
async def watch_socket_disconnect(websocket: WebSocket) -> None:
|
||||
@@ -154,28 +219,52 @@ async def watch_socket_disconnect(websocket: WebSocket) -> None:
|
||||
await websocket.receive_text()
|
||||
|
||||
|
||||
async def stream_proxy_logs(websocket: WebSocket, path: Path) -> None:
|
||||
offset = path.stat().st_size if path.is_file() else 0
|
||||
async def stream_proxy_logs(
|
||||
websocket: WebSocket,
|
||||
path: Path,
|
||||
*,
|
||||
offset: int,
|
||||
marker: bytes,
|
||||
) -> None:
|
||||
|
||||
while True:
|
||||
if not path.exists():
|
||||
if not await asyncio.to_thread(path.exists):
|
||||
await asyncio.sleep(0.4)
|
||||
continue
|
||||
|
||||
try:
|
||||
size = path.stat().st_size
|
||||
size = await asyncio.to_thread(lambda: path.stat().st_size)
|
||||
except OSError:
|
||||
await asyncio.sleep(0.4)
|
||||
continue
|
||||
|
||||
if marker and size >= offset and await read_log_marker(path, offset) != marker:
|
||||
snapshot, offset, marker = await snapshot_log_state(path)
|
||||
await websocket.send_json({"type": "snapshot", "log": snapshot})
|
||||
await asyncio.sleep(0.4)
|
||||
continue
|
||||
|
||||
if size < offset:
|
||||
offset = size
|
||||
snapshot, offset, marker = await snapshot_log_state(path)
|
||||
await websocket.send_json({"type": "snapshot", "log": snapshot})
|
||||
await asyncio.sleep(0.4)
|
||||
continue
|
||||
|
||||
if size > offset:
|
||||
with path.open("rb") as handle:
|
||||
handle.seek(offset)
|
||||
chunk = handle.read(size - offset).decode("utf-8", errors="replace")
|
||||
def read_log_chunk(
|
||||
chunk_offset: int = offset,
|
||||
chunk_size: int = size,
|
||||
) -> str:
|
||||
with path.open("rb") as handle:
|
||||
handle.seek(chunk_offset)
|
||||
return handle.read(chunk_size - chunk_offset).decode(
|
||||
"utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
|
||||
chunk = await asyncio.to_thread(read_log_chunk)
|
||||
offset = size
|
||||
marker = await read_log_marker(path, offset)
|
||||
if chunk:
|
||||
await websocket.send_json({"type": "append", "log": chunk})
|
||||
|
||||
@@ -188,12 +277,15 @@ async def list_proxies_handler():
|
||||
|
||||
|
||||
@router.get("/services", dependencies=auth_dependencies)
|
||||
async def list_services(request: Request, scan_root: str | None = None):
|
||||
async def list_services(scan_root: str | None = None):
|
||||
_ = settings
|
||||
root = Path(scan_root).expanduser() if scan_root else service_scan_root()
|
||||
services = get_all_services_in_dir(root)
|
||||
request.app.state.services = services
|
||||
return {"scan_root": str(root), "services": services}
|
||||
return {
|
||||
"scan_root": str(root),
|
||||
"services": services,
|
||||
"backend_port": backend_port(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/mitmproxy/options", dependencies=auth_dependencies)
|
||||
@@ -201,44 +293,44 @@ async def list_mitmproxy_options():
|
||||
return public_options(await load_options())
|
||||
|
||||
|
||||
@router.get("/mitmproxy/examples", dependencies=auth_dependencies)
|
||||
async def list_mitmproxy_examples():
|
||||
return [*BUILTIN_EXAMPLES, *await list_custom_examples()]
|
||||
@router.get("/mitmproxy/templates", dependencies=auth_dependencies)
|
||||
async def list_mitmproxy_templates():
|
||||
return [*BUILTIN_TEMPLATES, *await list_custom_templates()]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/mitmproxy/examples",
|
||||
"/mitmproxy/templates",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=auth_dependencies,
|
||||
)
|
||||
async def create_mitmproxy_example(payload: AddonExampleCreateInput):
|
||||
async def create_mitmproxy_template(payload: ProxyTemplateCreateInput):
|
||||
await _validate_code_or_raise(payload.code)
|
||||
try:
|
||||
return await create_custom_example(payload)
|
||||
return await create_custom_template(payload)
|
||||
except DuplicateKeyError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Example already exists: {payload.id}",
|
||||
detail=f"Template already exists: {payload.id}",
|
||||
) from error
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/mitmproxy/examples/{example_id}",
|
||||
"/mitmproxy/templates/{template_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=auth_dependencies,
|
||||
)
|
||||
async def delete_mitmproxy_example(example_id: str):
|
||||
if any(example.id == example_id for example in BUILTIN_EXAMPLES):
|
||||
async def delete_mitmproxy_template(template_id: str):
|
||||
if any(template.id == template_id for template in BUILTIN_TEMPLATES):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Built-in examples cannot be deleted",
|
||||
detail="Built-in templates cannot be deleted",
|
||||
)
|
||||
|
||||
deleted = await delete_custom_example(example_id)
|
||||
deleted = await delete_custom_template(template_id)
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Example not found",
|
||||
detail="Template not found",
|
||||
)
|
||||
|
||||
|
||||
@@ -249,6 +341,7 @@ async def get_proxy(proxy_port: int):
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED, dependencies=auth_dependencies)
|
||||
async def create_proxy_handler(payload: ProxyCreateInput):
|
||||
validate_proxy_port(payload.proxy_port)
|
||||
existing_proxy = await get_proxy_by_port(payload.proxy_port)
|
||||
if existing_proxy is not None:
|
||||
raise HTTPException(
|
||||
@@ -341,7 +434,28 @@ async def proxy_status(proxy_port: int):
|
||||
@router.get("/{proxy_port}/health", dependencies=auth_dependencies)
|
||||
async def proxy_health(proxy_port: int):
|
||||
proxy = await get_proxy_or_404(proxy_port)
|
||||
return await check_proxy(proxy)
|
||||
snapshot = await check_proxy(proxy)
|
||||
if proxy.healthcheck.enabled:
|
||||
await record_health_sample(snapshot)
|
||||
await health_monitor.publish(proxy.proxy_port, snapshot)
|
||||
return serialize_health_snapshot(snapshot)
|
||||
|
||||
|
||||
@router.get("/{proxy_port}/health/history", dependencies=auth_dependencies)
|
||||
async def proxy_health_history(
|
||||
proxy_port: int,
|
||||
limit: int = DEFAULT_HEALTH_HISTORY_LIMIT,
|
||||
):
|
||||
proxy = await get_proxy_or_404(proxy_port)
|
||||
limit = max(1, min(limit, MAX_HEALTH_HISTORY_LIMIT))
|
||||
latest = await get_latest_health_sample(proxy.proxy_port)
|
||||
history = await list_health_history(proxy.proxy_port, limit=limit)
|
||||
response = HealthHistoryResponse(
|
||||
proxy_port=proxy.proxy_port,
|
||||
latest=latest,
|
||||
history=history,
|
||||
)
|
||||
return response.model_dump(mode="json")
|
||||
|
||||
|
||||
@router.get("/{proxy_port}/logs", dependencies=auth_dependencies)
|
||||
@@ -351,7 +465,7 @@ async def proxy_logs(proxy_port: int):
|
||||
|
||||
return {
|
||||
"proxy_port": proxy.proxy_port,
|
||||
"log": read_log_snapshot(path),
|
||||
"log": await read_log_snapshot(path),
|
||||
}
|
||||
|
||||
|
||||
@@ -373,11 +487,14 @@ async def proxy_logs_socket(websocket: WebSocket, proxy_port: int):
|
||||
return
|
||||
|
||||
path = proxy_log_path(proxy)
|
||||
initial_snapshot, offset, marker = await snapshot_log_state(path)
|
||||
await websocket.accept()
|
||||
await websocket.send_json({"type": "snapshot", "log": read_log_snapshot(path)})
|
||||
await websocket.send_json({"type": "snapshot", "log": initial_snapshot})
|
||||
|
||||
disconnect_task = asyncio.create_task(watch_socket_disconnect(websocket))
|
||||
stream_task = asyncio.create_task(stream_proxy_logs(websocket, path))
|
||||
stream_task = asyncio.create_task(
|
||||
stream_proxy_logs(websocket, path, offset=offset, marker=marker)
|
||||
)
|
||||
|
||||
done, pending = await asyncio.wait(
|
||||
{disconnect_task, stream_task},
|
||||
@@ -408,6 +525,71 @@ async def proxy_logs_socket(websocket: WebSocket, proxy_port: int):
|
||||
task.cancel()
|
||||
|
||||
|
||||
@router.websocket("/{proxy_port}/health/ws")
|
||||
async def proxy_health_socket(websocket: WebSocket, proxy_port: int):
|
||||
disconnect_task: asyncio.Task[None] | None = None
|
||||
queue_task: asyncio.Task[None] | None = None
|
||||
queue = None
|
||||
|
||||
async def forward_health() -> None:
|
||||
assert queue is not None
|
||||
while True:
|
||||
snapshot = await queue.get()
|
||||
await websocket.send_json(serialize_health_snapshot(snapshot))
|
||||
|
||||
try:
|
||||
try:
|
||||
await authenticate_proxy_socket(websocket)
|
||||
except InvalidTokenError:
|
||||
await websocket.close(code=4401)
|
||||
return
|
||||
|
||||
proxy = await get_proxy_by_port(proxy_port)
|
||||
if proxy is None:
|
||||
await websocket.close(code=4404)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
latest = await get_latest_health_sample(proxy.proxy_port)
|
||||
if latest is None:
|
||||
latest = await check_proxy(proxy)
|
||||
await websocket.send_json(serialize_health_snapshot(latest))
|
||||
|
||||
queue = health_monitor.subscribe(proxy.proxy_port)
|
||||
disconnect_task = asyncio.create_task(watch_socket_disconnect(websocket))
|
||||
queue_task = asyncio.create_task(forward_health())
|
||||
|
||||
done, pending = await asyncio.wait(
|
||||
{disconnect_task, queue_task},
|
||||
return_when=asyncio.FIRST_EXCEPTION,
|
||||
)
|
||||
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
if pending:
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
|
||||
for task in done:
|
||||
try:
|
||||
task.result()
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
except RuntimeError:
|
||||
await websocket.close(code=1011, reason="health stream failed")
|
||||
return
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
finally:
|
||||
if queue is not None:
|
||||
health_monitor.unsubscribe(proxy_port, queue)
|
||||
for task in (disconnect_task, queue_task):
|
||||
if task is None or task.done():
|
||||
continue
|
||||
task.cancel()
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{proxy_port}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
@@ -421,6 +603,8 @@ async def remove_proxy(proxy_port: int):
|
||||
detail="Proxy not found",
|
||||
)
|
||||
await manager.stop(proxy)
|
||||
manager.cleanup_runtime_artifacts(proxy)
|
||||
await delete_health_history(proxy.proxy_port)
|
||||
deleted = await delete_proxy(proxy_port)
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
|
||||
Reference in New Issue
Block a user