feat: troppa roba davvero

This commit is contained in:
gabdevele
2026-05-07 09:21:45 +02:00 Unverified
parent d977a953cc
commit 53dcdafdb1
73 changed files with 4772 additions and 960 deletions
+1
View File
@@ -14,3 +14,4 @@ dist/
backend/.venv/ backend/.venv/
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/
playground/
+2
View File
@@ -14,3 +14,5 @@ AUTH_PASSWORD=change-me
# Optional: override the directory whose immediate child folders are scanned # Optional: override the directory whose immediate child folders are scanned
# for docker-compose files. Defaults to the current user's home directory. # for docker-compose files. Defaults to the current user's home directory.
SERVICE_SCAN_ROOT= SERVICE_SCAN_ROOT=
HEALTHCHECK_INTERVAL_SECONDS=15
HEALTH_HISTORY_TTL_SECONDS=86400
+1 -1
View File
@@ -1,3 +1,3 @@
from api.auth.router import router from app.api.auth.router import router
__all__ = ["router"] __all__ = ["router"]
+5 -4
View File
@@ -1,15 +1,16 @@
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Annotated from typing import Annotated
from core.security import ( from fastapi import APIRouter, Depends, HTTPException, Response, status
from app.core.security import (
AuthContext, AuthContext,
create_access_token, create_access_token,
get_auth_context, get_auth_context,
verify_password, verify_password,
) )
from fastapi import APIRouter, Depends, HTTPException, Response, status from app.models.auth import LoginInput, TokenResponse
from models.auth import LoginInput, TokenResponse from app.repositories.auth import get_user_by_username, revoke_token
from repositories.auth import get_user_by_username, revoke_token
router = APIRouter( router = APIRouter(
prefix="/auth", prefix="/auth",
+5 -4
View File
@@ -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, InvalidTokenError,
authenticate_token, authenticate_token,
require_authenticated_user, require_authenticated_user,
) )
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status from app.services.editor import python_lsp
from services.editor import python_lsp
from starlette.websockets import WebSocketDisconnect
router = APIRouter(prefix="/proxy/editor/python", tags=["Editor"]) router = APIRouter(prefix="/proxy/editor/python", tags=["Editor"])
+236 -52
View File
@@ -1,44 +1,61 @@
import asyncio import asyncio
from pathlib import Path from pathlib import Path
from core.config import settings from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
from core.security import ( from pymongo.errors import DuplicateKeyError
from starlette.websockets import WebSocketDisconnect
from app.core.config import settings
from app.core.security import (
InvalidTokenError, InvalidTokenError,
authenticate_token, authenticate_token,
require_authenticated_user, require_authenticated_user,
) )
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, status from app.integrations.docker import get_all_services_in_dir
from integrations.docker import get_all_services_in_dir from app.integrations.docker.discovery import service_scan_root
from integrations.docker.discovery import service_scan_root from app.models.health import HealthHistoryResponse, HealthSnapshot
from models.example import AddonExampleCreateInput from app.models.proxy import ProxyCreateInput, ProxyUpdateInput
from models.proxy import ProxyCreateInput, ProxyUpdateInput from app.models.template import ProxyTemplateCreateInput
from pymongo.errors import DuplicateKeyError from app.repositories.health import (
from repositories.examples import ( delete_health_history,
create_custom_example, get_latest_health_sample,
delete_custom_example, list_health_history,
list_custom_examples, record_health_sample,
) )
from repositories.proxy import ( from app.repositories.proxy import (
create_proxy, create_proxy,
delete_proxy, delete_proxy,
get_proxy_by_port, get_proxy_by_port,
update_proxy, update_proxy,
update_proxy_runtime, update_proxy_runtime,
) )
from repositories.proxy import ( from app.repositories.proxy import (
list_proxies as list_proxy_documents, list_proxies as list_proxy_documents,
) )
from services.mitmproxy.examples import BUILTIN_EXAMPLES from app.repositories.templates import (
from services.mitmproxy.health import check_proxy create_custom_template,
from services.mitmproxy.manager import MitmdumpError, manager delete_custom_template,
from services.mitmproxy.options import ( 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, MitmOptionError,
load_options, load_options,
public_options, public_options,
validate_options, validate_options,
) )
from services.mitmproxy.validator import ProxyCodeValidationError, validate_proxy_code from app.services.mitmproxy.runtime_validation import (
from starlette.websockets import WebSocketDisconnect 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 = { SUPPORTED_MODES = {
"regular", "regular",
@@ -62,6 +79,8 @@ SUPPORTED_PROTOCOLS = {
"http3", "http3",
"quic", "quic",
} }
DEFAULT_HEALTH_HISTORY_LIMIT = 40
MAX_HEALTH_HISTORY_LIMIT = 200
router = APIRouter( router = APIRouter(
prefix="/proxy", prefix="/proxy",
@@ -113,6 +132,16 @@ async def validate_mitm_options(options: dict[str, object]) -> None:
) from error ) 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): async def get_proxy_or_404(proxy_port: int):
proxy = await get_proxy_by_port(proxy_port) proxy = await get_proxy_by_port(proxy_port)
if proxy is None: if proxy is None:
@@ -143,10 +172,46 @@ def proxy_log_path(proxy) -> Path:
return manager.log_path_for(proxy) return manager.log_path_for(proxy)
def read_log_snapshot(path: Path, limit: int = 8000) -> str: def serialize_health_snapshot(
if not path.is_file(): snapshot: HealthSnapshot | None,
return "" ) -> dict[str, object] | None:
return path.read_bytes()[-limit:].decode("utf-8", errors="replace") 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: async def watch_socket_disconnect(websocket: WebSocket) -> None:
@@ -154,28 +219,52 @@ async def watch_socket_disconnect(websocket: WebSocket) -> None:
await websocket.receive_text() await websocket.receive_text()
async def stream_proxy_logs(websocket: WebSocket, path: Path) -> None: async def stream_proxy_logs(
offset = path.stat().st_size if path.is_file() else 0 websocket: WebSocket,
path: Path,
*,
offset: int,
marker: bytes,
) -> None:
while True: while True:
if not path.exists(): if not await asyncio.to_thread(path.exists):
await asyncio.sleep(0.4) await asyncio.sleep(0.4)
continue continue
try: try:
size = path.stat().st_size size = await asyncio.to_thread(lambda: path.stat().st_size)
except OSError: except OSError:
await asyncio.sleep(0.4) await asyncio.sleep(0.4)
continue 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: 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: if size > offset:
with path.open("rb") as handle: def read_log_chunk(
handle.seek(offset) chunk_offset: int = offset,
chunk = handle.read(size - offset).decode("utf-8", errors="replace") 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 offset = size
marker = await read_log_marker(path, offset)
if chunk: if chunk:
await websocket.send_json({"type": "append", "log": chunk}) await websocket.send_json({"type": "append", "log": chunk})
@@ -188,12 +277,15 @@ async def list_proxies_handler():
@router.get("/services", dependencies=auth_dependencies) @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 _ = settings
root = Path(scan_root).expanduser() if scan_root else service_scan_root() root = Path(scan_root).expanduser() if scan_root else service_scan_root()
services = get_all_services_in_dir(root) services = get_all_services_in_dir(root)
request.app.state.services = services return {
return {"scan_root": str(root), "services": services} "scan_root": str(root),
"services": services,
"backend_port": backend_port(),
}
@router.get("/mitmproxy/options", dependencies=auth_dependencies) @router.get("/mitmproxy/options", dependencies=auth_dependencies)
@@ -201,44 +293,44 @@ async def list_mitmproxy_options():
return public_options(await load_options()) return public_options(await load_options())
@router.get("/mitmproxy/examples", dependencies=auth_dependencies) @router.get("/mitmproxy/templates", dependencies=auth_dependencies)
async def list_mitmproxy_examples(): async def list_mitmproxy_templates():
return [*BUILTIN_EXAMPLES, *await list_custom_examples()] return [*BUILTIN_TEMPLATES, *await list_custom_templates()]
@router.post( @router.post(
"/mitmproxy/examples", "/mitmproxy/templates",
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=auth_dependencies, dependencies=auth_dependencies,
) )
async def create_mitmproxy_example(payload: AddonExampleCreateInput): async def create_mitmproxy_template(payload: ProxyTemplateCreateInput):
await _validate_code_or_raise(payload.code) await _validate_code_or_raise(payload.code)
try: try:
return await create_custom_example(payload) return await create_custom_template(payload)
except DuplicateKeyError as error: except DuplicateKeyError as error:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
detail=f"Example already exists: {payload.id}", detail=f"Template already exists: {payload.id}",
) from error ) from error
@router.delete( @router.delete(
"/mitmproxy/examples/{example_id}", "/mitmproxy/templates/{template_id}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=auth_dependencies, dependencies=auth_dependencies,
) )
async def delete_mitmproxy_example(example_id: str): async def delete_mitmproxy_template(template_id: str):
if any(example.id == example_id for example in BUILTIN_EXAMPLES): if any(template.id == template_id for template in BUILTIN_TEMPLATES):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, 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: if not deleted:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, 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) @router.post("", status_code=status.HTTP_201_CREATED, dependencies=auth_dependencies)
async def create_proxy_handler(payload: ProxyCreateInput): async def create_proxy_handler(payload: ProxyCreateInput):
validate_proxy_port(payload.proxy_port)
existing_proxy = await get_proxy_by_port(payload.proxy_port) existing_proxy = await get_proxy_by_port(payload.proxy_port)
if existing_proxy is not None: if existing_proxy is not None:
raise HTTPException( raise HTTPException(
@@ -341,7 +434,28 @@ async def proxy_status(proxy_port: int):
@router.get("/{proxy_port}/health", dependencies=auth_dependencies) @router.get("/{proxy_port}/health", dependencies=auth_dependencies)
async def proxy_health(proxy_port: int): async def proxy_health(proxy_port: int):
proxy = await get_proxy_or_404(proxy_port) 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) @router.get("/{proxy_port}/logs", dependencies=auth_dependencies)
@@ -351,7 +465,7 @@ async def proxy_logs(proxy_port: int):
return { return {
"proxy_port": proxy.proxy_port, "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 return
path = proxy_log_path(proxy) path = proxy_log_path(proxy)
initial_snapshot, offset, marker = await snapshot_log_state(path)
await websocket.accept() 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)) 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( done, pending = await asyncio.wait(
{disconnect_task, stream_task}, {disconnect_task, stream_task},
@@ -408,6 +525,71 @@ async def proxy_logs_socket(websocket: WebSocket, proxy_port: int):
task.cancel() 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( @router.delete(
"/{proxy_port}", "/{proxy_port}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
@@ -421,6 +603,8 @@ async def remove_proxy(proxy_port: int):
detail="Proxy not found", detail="Proxy not found",
) )
await manager.stop(proxy) await manager.stop(proxy)
manager.cleanup_runtime_artifacts(proxy)
await delete_health_history(proxy.proxy_port)
deleted = await delete_proxy(proxy_port) deleted = await delete_proxy(proxy_port)
if not deleted: if not deleted:
raise HTTPException( raise HTTPException(
+4 -1
View File
@@ -17,7 +17,10 @@ class Settings(BaseSettings):
JWT_EXPIRE_MINUTES: int = Field(default=720, gt=0) JWT_EXPIRE_MINUTES: int = Field(default=720, gt=0)
AUTH_USERNAME: str AUTH_USERNAME: str
AUTH_PASSWORD: str AUTH_PASSWORD: str
API_PORT: int = Field(default=3671, ge=1, le=65535)
SERVICE_SCAN_ROOT: str | None = None SERVICE_SCAN_ROOT: str | None = None
HEALTHCHECK_INTERVAL_SECONDS: int = Field(default=15, ge=5, le=300)
HEALTH_HISTORY_TTL_SECONDS: int = Field(default=86400, ge=300)
settings = Settings() settings = Settings() # pyright: ignore[reportCallIssue]
+7 -4
View File
@@ -1,11 +1,14 @@
from core.config import settings from typing import Any
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
from app.core.config import settings
class _MongoDB: class _MongoDB:
client: AsyncIOMotorClient | None = None client: AsyncIOMotorClient[dict[str, Any]] | None = None
def get_database(self) -> AsyncIOMotorDatabase: def get_database(self) -> AsyncIOMotorDatabase[dict[str, Any]]:
if self.client is None: if self.client is None:
raise RuntimeError("MongoDB client has not been initialized") raise RuntimeError("MongoDB client has not been initialized")
return self.client[settings.MONGODB_DB_NAME] return self.client[settings.MONGODB_DB_NAME]
@@ -26,7 +29,7 @@ async def close_mongodb() -> None:
client.close() client.close()
async def get_database() -> AsyncIOMotorDatabase: async def get_database() -> AsyncIOMotorDatabase[dict[str, Any]]:
return mongodb.get_database() return mongodb.get_database()
+5 -6
View File
@@ -5,13 +5,14 @@ from uuid import uuid4
from authlib.jose import jwt # type: ignore from authlib.jose import jwt # type: ignore
from authlib.jose.errors import JoseError # type: ignore from authlib.jose.errors import JoseError # type: ignore
from core.config import settings
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from models.auth import AccessTokenPayload, AuthUserDocument
from pwdlib import PasswordHash from pwdlib import PasswordHash
from pwdlib.hashers.argon2 import Argon2Hasher from pwdlib.hashers.argon2 import Argon2Hasher
from repositories.auth import get_user_by_username, is_token_revoked
from app.core.config import settings
from app.models.auth import AccessTokenPayload, AuthUserDocument
from app.repositories.auth import get_user_by_username, is_token_revoked
pwd_context = PasswordHash((Argon2Hasher(),)) pwd_context = PasswordHash((Argon2Hasher(),))
bearer_scheme = HTTPBearer(auto_error=False) bearer_scheme = HTTPBearer(auto_error=False)
@@ -41,9 +42,7 @@ def hash_password(password: str) -> str:
def verify_password(plain_password: str, password_hash: str) -> bool: def verify_password(plain_password: str, password_hash: str) -> bool:
if hasattr(pwd_context, "verify"): return pwd_context.verify(plain_password, password_hash)
return pwd_context.verify(plain_password, password_hash)
return pwd_context.check(plain_password, password_hash)
def create_access_token(*, subject: str) -> str: def create_access_token(*, subject: str) -> str:
+3 -3
View File
@@ -1,10 +1,10 @@
from integrations.docker.compose import ( from app.integrations.docker.compose import (
find_docker_compose, find_docker_compose,
get_all_services_in_dir, get_all_services_in_dir,
get_services, get_services,
) )
from integrations.docker.editor import change_port from app.integrations.docker.editor import change_port
from integrations.docker.models import DockerContainer from app.integrations.docker.models import DockerContainer
__all__ = [ __all__ = [
"DockerContainer", "DockerContainer",
+42 -16
View File
@@ -2,8 +2,9 @@ import asyncio
from pathlib import Path from pathlib import Path
import yaml import yaml
from integrations.docker.models import DockerContainer
from integrations.docker.ports import _get_service_binding from app.integrations.docker.models import DockerContainer, DockerPortBinding
from app.integrations.docker.ports import _get_service_binding
COMPOSE_NAMES = ( COMPOSE_NAMES = (
"compose.yml", "compose.yml",
@@ -12,24 +13,43 @@ COMPOSE_NAMES = (
"docker-compose.yaml", "docker-compose.yaml",
) )
type ComposeDocument = dict[str, object]
def _get_visible_folders(directory: Path) -> list[Path]: def _get_visible_folders(directory: Path) -> list[Path]:
return [ try:
p entries = list(Path(directory).iterdir())
for p in Path(directory).iterdir() except OSError:
if p.is_dir() and not p.name.startswith(".") return []
]
folders: list[Path] = []
for entry in entries:
if entry.name.startswith("."):
continue
try:
if entry.is_dir():
folders.append(entry)
except OSError:
continue
return folders
def find_docker_compose(path: Path) -> tuple[dict, Path] | None: def find_docker_compose(path: Path) -> tuple[ComposeDocument, Path] | None:
if not path or not path.is_dir(): try:
if not path or not path.is_dir():
return None
except OSError:
return None return None
for name in COMPOSE_NAMES: for name in COMPOSE_NAMES:
file = path / name file = path / name
if file.is_file(): try:
content = yaml.safe_load(file.read_text()) or {} if file.is_file():
return content, file content = yaml.safe_load(file.read_text()) or {}
return content, file
except OSError:
continue
return None return None
@@ -42,7 +62,7 @@ def get_services(folder: Path) -> list[DockerContainer] | None:
docker_compose, _ = compose_data docker_compose, _ = compose_data
compose_services = docker_compose.get("services") or {} compose_services = docker_compose.get("services") or {}
if not compose_services: if not isinstance(compose_services, dict) or not compose_services:
return None return None
for service_name, config in compose_services.items(): for service_name, config in compose_services.items():
@@ -50,7 +70,7 @@ def get_services(folder: Path) -> list[DockerContainer] | None:
if not isinstance(mappings, list) or not mappings: if not isinstance(mappings, list) or not mappings:
continue continue
bindings = [ bindings: list[DockerPortBinding] = [
{"published": published, "host": host} {"published": published, "host": host}
for published, host in ( for published, host in (
_get_service_binding(mapping) for mapping in mappings _get_service_binding(mapping) for mapping in mappings
@@ -76,7 +96,10 @@ def get_all_services_in_dir(
directory: str | Path, directory: str | Path,
) -> list[DockerContainer]: ) -> list[DockerContainer]:
path = Path(directory) path = Path(directory)
if not path.is_dir(): try:
if not path.is_dir():
return []
except OSError:
return [] return []
services: list[DockerContainer] = [] services: list[DockerContainer] = []
@@ -88,7 +111,10 @@ def get_all_services_in_dir(
return services return services
async def run_docker_command(file: Path, command: list[str]) -> dict | None: async def run_docker_command(
file: Path,
command: list[str],
) -> dict[str, str] | None:
if not command: if not command:
return None return None
+1 -1
View File
@@ -1,6 +1,6 @@
from pathlib import Path from pathlib import Path
from core.config import settings from app.core.config import settings
def service_scan_root() -> Path: def service_scan_root() -> Path:
+36 -19
View File
@@ -1,14 +1,26 @@
import asyncio
from pathlib import Path from pathlib import Path
import yaml import yaml
from integrations.docker.compose import find_docker_compose, run_docker_command
from integrations.docker.ports import _get_port_index, _replace_port_in_mapping from app.integrations.docker.compose import (
ComposeDocument,
find_docker_compose,
run_docker_command,
)
from app.integrations.docker.ports import _get_port_index, _replace_port_in_mapping
def _backup_path_for(compose_path: Path) -> Path: def _render_compose(document: ComposeDocument) -> str:
if compose_path.suffix in {".yml", ".yaml"}: return yaml.dump(document, default_flow_style=False, sort_keys=False)
return compose_path.with_name(f"{compose_path.stem}.bak{compose_path.suffix}")
return compose_path.with_name(f"{compose_path.name}.bak")
def _write_compose(path: Path, content: str) -> None:
path.write_text(content, encoding="utf-8")
def _read_compose_text(path: Path) -> str:
return path.read_text(encoding="utf-8")
async def change_port( async def change_port(
@@ -25,7 +37,7 @@ async def change_port(
docker_compose, compose_path = compose_data docker_compose, compose_path = compose_data
compose_services = docker_compose.get("services") or {} compose_services = docker_compose.get("services") or {}
if not compose_services: if not isinstance(compose_services, dict) or not compose_services:
return False return False
service_config = compose_services.get(service_name) service_config = compose_services.get(service_name)
@@ -48,21 +60,26 @@ async def change_port(
rewrite_host=rewrite_host, rewrite_host=rewrite_host,
) )
backup_path = _backup_path_for(compose_path) original_text = await asyncio.to_thread(_read_compose_text, compose_path)
original_text = compose_path.read_text() updated_text = _render_compose(docker_compose)
backup_path.write_text(compose_path.read_text()) try:
compose_path.write_text( await asyncio.to_thread(_write_compose, compose_path, updated_text)
yaml.dump(docker_compose, default_flow_style=False, sort_keys=False) result = await run_docker_command(
) compose_path,
["up", "-d", service_name],
result = await run_docker_command( )
compose_path, except Exception:
["up", "-d", service_name], try:
) await asyncio.to_thread(_write_compose, compose_path, original_text)
except OSError as restore_error:
raise RuntimeError(
f"Failed to restore original compose file: {compose_path}"
) from restore_error
return False
if result is None or result.get("status") != "success": if result is None or result.get("status") != "success":
compose_path.write_text(original_text) await asyncio.to_thread(_write_compose, compose_path, original_text)
return False return False
return True return True
+2 -2
View File
@@ -24,10 +24,10 @@ def _get_service_binding(
published = mapping.get("published") published = mapping.get("published")
if published is None: if published is None:
return None, None return None, None
if not isinstance(published, (str, int)):
return None, None
try: try:
port = int(published) port = int(published)
except TypeError:
return None, None
except ValueError: except ValueError:
return None, None return None, None
host = mapping.get("host_ip") host = mapping.get("host_ip")
+21 -15
View File
@@ -2,19 +2,22 @@ import os
import sys import sys
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from api.auth.router import router as auth_router
from api.editor.router import router as editor_router
from api.proxy.router import router as proxy_router
from core.config import settings
from core.mongodb import close_mongodb, init_mongodb
from core.security import hash_password
from fastapi import FastAPI from fastapi import FastAPI
from integrations.docker import get_all_services_in_dir
from integrations.docker.discovery import service_scan_root from app.api.auth.router import router as auth_router
from repositories.auth import ensure_auth_indexes, ensure_single_account from app.api.editor.router import router as editor_router
from repositories.examples import ensure_example_indexes from app.api.proxy.router import router as proxy_router
from repositories.proxy import ensure_proxy_indexes from app.core.config import settings
from services.mitmproxy.recovery import reconcile_runtime from app.core.mongodb import close_mongodb, init_mongodb
from app.core.security import hash_password
from app.integrations.docker import get_all_services_in_dir
from app.integrations.docker.discovery import service_scan_root
from app.repositories.auth import ensure_auth_indexes, ensure_single_account
from app.repositories.health import ensure_health_indexes
from app.repositories.proxy import ensure_proxy_indexes
from app.repositories.templates import ensure_template_indexes
from app.services.mitmproxy.health_monitor import health_monitor
from app.services.mitmproxy.recovery import reconcile_runtime
@asynccontextmanager @asynccontextmanager
@@ -26,7 +29,8 @@ async def lifespan(_: FastAPI):
password_hash=hash_password(settings.AUTH_PASSWORD), password_hash=hash_password(settings.AUTH_PASSWORD),
) )
await ensure_proxy_indexes() await ensure_proxy_indexes()
await ensure_example_indexes() await ensure_template_indexes()
await ensure_health_indexes()
recovered = await reconcile_runtime() recovered = await reconcile_runtime()
if recovered: if recovered:
recovered_count = len(recovered) recovered_count = len(recovered)
@@ -42,8 +46,8 @@ async def lifespan(_: FastAPI):
) )
scan_root = service_scan_root() scan_root = service_scan_root()
app.state.services = get_all_services_in_dir(scan_root) or [] services = get_all_services_in_dir(scan_root) or []
if not app.state.services: if not services:
warning = ( warning = (
"\n" + "!" * 60 + "\n" "\n" + "!" * 60 + "\n"
" WARNING: No services found in scan root!\n" " WARNING: No services found in scan root!\n"
@@ -52,8 +56,10 @@ async def lifespan(_: FastAPI):
print(warning, file=sys.stderr) print(warning, file=sys.stderr)
try: try:
await health_monitor.start()
yield yield
finally: finally:
await health_monitor.stop()
await close_mongodb() await close_mongodb()
-28
View File
@@ -1,28 +0,0 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class AddonExampleDocument(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str = Field(..., description="Stable example identifier")
name: str = Field(..., description="Display name")
description: str = Field(default="", description="Short explanation")
category: str = Field(default="custom", description="Example category")
code: str = Field(..., description="Addon source code")
source_url: str | None = Field(default=None, description="Reference URL")
built_in: bool = Field(default=False, description="Provided by thePlug")
created_at: datetime | None = None
updated_at: datetime | None = None
class AddonExampleCreateInput(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str = Field(..., description="Stable example identifier")
name: str = Field(..., description="Display name")
description: str = Field(default="", description="Short explanation")
category: str = Field(default="custom", description="Example category")
code: str = Field(..., description="Addon source code")
source_url: str | None = Field(default=None, description="Reference URL")
+41
View File
@@ -0,0 +1,41 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class HealthCheckEntry(BaseModel):
model_config = ConfigDict(extra="forbid")
target: str = Field(..., description="Logical target name")
host: str = Field(..., description="Resolved host checked at runtime")
port: int = Field(..., description="Resolved port checked at runtime")
protocol: str = Field(..., description="Protocol used for the check")
healthy: bool = Field(..., description="Whether the check succeeded")
error: str | None = Field(default=None, description="Optional failure reason")
duration_ms: float | None = Field(
default=None,
description=(
"Measured response latency in milliseconds when benchmarking "
"is enabled"
),
)
details: dict[str, Any] = Field(default_factory=dict)
class HealthSnapshot(BaseModel):
model_config = ConfigDict(extra="forbid")
proxy_port: int = Field(..., description="Proxy port this snapshot belongs to")
timestamp: datetime = Field(..., description="When the snapshot was produced")
enabled: bool = Field(..., description="Whether healthchecks are enabled")
overall: str = Field(..., description="Overall health status")
checks: list[HealthCheckEntry] = Field(default_factory=list)
class HealthHistoryResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
proxy_port: int
latest: HealthSnapshot | None = None
history: list[HealthSnapshot] = Field(default_factory=list)
+6 -1
View File
@@ -1,9 +1,10 @@
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from models.service import ServiceEndpointDocument
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from app.models.service import ServiceEndpointDocument
class HealthCheckDocument(BaseModel): class HealthCheckDocument(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -28,6 +29,10 @@ class HealthCheckDocument(BaseModel):
default=False, default=False,
description="Verify TLS certificates during health checks", description="Verify TLS certificates during health checks",
) )
benchmark_enabled: bool = Field(
default=False,
description="Measure response latency during health checks when supported",
)
timeout_seconds: float = Field( timeout_seconds: float = Field(
default=2.0, default=2.0,
gt=0, gt=0,
+40
View File
@@ -0,0 +1,40 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class ProxyTemplateDocument(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str = Field(..., description="Stable template identifier")
name: str = Field(..., description="Display name")
description: str = Field(default="", description="Short explanation")
category: str = Field(default="custom", description="Template category")
protocol: str = Field(default="custom", description="Primary proxy protocol")
family: str = Field(default="custom", description="Template family")
tags: list[str] = Field(default_factory=list, description="Filter tags")
action: str = Field(default="custom", description="Primary template action")
summary: str = Field(default="", description="Short operational summary")
behavior: str = Field(default="", description="Runtime behavior description")
code: str = Field(..., description="Addon source code")
source_url: str | None = Field(default=None, description="Reference URL")
built_in: bool = Field(default=False, description="Provided by thePlug")
created_at: datetime | None = None
updated_at: datetime | None = None
class ProxyTemplateCreateInput(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str = Field(..., description="Stable template identifier")
name: str = Field(..., description="Display name")
description: str = Field(default="", description="Short explanation")
category: str = Field(default="custom", description="Template category")
protocol: str = Field(default="custom", description="Primary proxy protocol")
family: str = Field(default="custom", description="Template family")
tags: list[str] = Field(default_factory=list, description="Filter tags")
action: str = Field(default="custom", description="Primary template action")
summary: str = Field(default="", description="Short operational summary")
behavior: str = Field(default="", description="Runtime behavior description")
code: str = Field(..., description="Addon source code")
source_url: str | None = Field(default=None, description="Reference URL")
+3 -2
View File
@@ -2,10 +2,11 @@ from collections.abc import Mapping
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
from core.mongodb import get_database
from models.auth import AuthUserDocument
from motor.motor_asyncio import AsyncIOMotorCollection from motor.motor_asyncio import AsyncIOMotorCollection
from app.core.mongodb import get_database
from app.models.auth import AuthUserDocument
USERS_COLLECTION_NAME = "auth_users" USERS_COLLECTION_NAME = "auth_users"
REVOKED_COLLECTION_NAME = "revoked_tokens" REVOKED_COLLECTION_NAME = "revoked_tokens"
-70
View File
@@ -1,70 +0,0 @@
from collections.abc import Mapping
from datetime import UTC, datetime
from typing import Any
from core.mongodb import get_database
from models.example import AddonExampleCreateInput, AddonExampleDocument
from motor.motor_asyncio import AsyncIOMotorCollection
COLLECTION_NAME = "addon_examples"
def utcnow() -> datetime:
return datetime.now(UTC)
async def collection() -> AsyncIOMotorCollection[dict[str, Any]]:
database = await get_database()
return database[COLLECTION_NAME]
def deserialize(raw: Mapping[str, Any] | None) -> AddonExampleDocument | None:
if raw is None:
return None
document = dict(raw)
document.pop("_id", None)
return AddonExampleDocument.model_validate(document)
async def ensure_example_indexes() -> None:
examples = await collection()
await examples.create_index("id", unique=True, name="uq_addon_example_id")
async def list_custom_examples() -> list[AddonExampleDocument]:
examples = await collection()
cursor = examples.find({}).sort("name", 1)
result: list[AddonExampleDocument] = []
async for raw in cursor:
example = deserialize(raw)
if example is not None:
result.append(example)
return result
async def create_custom_example(
payload: AddonExampleCreateInput,
) -> AddonExampleDocument:
now = utcnow()
example = AddonExampleDocument(
id=payload.id,
name=payload.name,
description=payload.description,
category=payload.category,
code=payload.code,
source_url=payload.source_url,
built_in=False,
created_at=now,
updated_at=now,
)
examples = await collection()
await examples.insert_one(example.model_dump(exclude_none=True))
return example
async def delete_custom_example(example_id: str) -> bool:
examples = await collection()
result = await examples.delete_one({"id": example_id})
return result.deleted_count > 0
+80
View File
@@ -0,0 +1,80 @@
from datetime import UTC, datetime, timedelta
from typing import Any
from motor.motor_asyncio import AsyncIOMotorCollection
from app.core.config import settings
from app.core.mongodb import get_database
from app.models.health import HealthSnapshot
COLLECTION_NAME = "health_history"
def utcnow() -> datetime:
return datetime.now(UTC)
async def collection() -> AsyncIOMotorCollection[dict[str, Any]]:
database = await get_database()
return database[COLLECTION_NAME]
async def ensure_health_indexes() -> None:
samples = await collection()
await samples.create_index(
[("proxy_port", 1), ("timestamp", -1)],
name="ix_health_proxy_port_timestamp",
)
await samples.create_index(
"expires_at",
expireAfterSeconds=0,
name="ttl_health_history",
)
def deserialize_health_sample(raw: dict[str, Any] | None) -> HealthSnapshot | None:
if raw is None:
return None
document = dict(raw)
document.pop("_id", None)
document.pop("expires_at", None)
return HealthSnapshot.model_validate(document)
async def record_health_sample(snapshot: HealthSnapshot) -> HealthSnapshot:
samples = await collection()
document = snapshot.model_dump(mode="python")
document["expires_at"] = document["timestamp"] + timedelta(
seconds=settings.HEALTH_HISTORY_TTL_SECONDS,
)
await samples.insert_one(document)
return snapshot
async def get_latest_health_sample(proxy_port: int) -> HealthSnapshot | None:
samples = await collection()
raw = await samples.find_one(
{"proxy_port": proxy_port},
sort=[("timestamp", -1)],
)
return deserialize_health_sample(raw)
async def list_health_history(
proxy_port: int,
limit: int = 40,
) -> list[HealthSnapshot]:
samples = await collection()
cursor = samples.find({"proxy_port": proxy_port}).sort("timestamp", -1).limit(limit)
history: list[HealthSnapshot] = []
async for raw in cursor:
snapshot = deserialize_health_sample(raw)
if snapshot is not None:
history.append(snapshot)
history.reverse()
return history
async def delete_health_history(proxy_port: int) -> None:
samples = await collection()
await samples.delete_many({"proxy_port": proxy_port})
+3 -2
View File
@@ -2,11 +2,12 @@ from collections.abc import Mapping
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
from core.mongodb import get_database
from models.proxy import ProxyCreateInput, ProxyDocument, ProxyUpdateInput
from motor.motor_asyncio import AsyncIOMotorCollection from motor.motor_asyncio import AsyncIOMotorCollection
from pymongo import ReturnDocument from pymongo import ReturnDocument
from app.core.mongodb import get_database
from app.models.proxy import ProxyCreateInput, ProxyDocument, ProxyUpdateInput
COLLECTION_NAME = "proxies" COLLECTION_NAME = "proxies"
+77
View File
@@ -0,0 +1,77 @@
from collections.abc import Mapping
from datetime import UTC, datetime
from typing import Any
from motor.motor_asyncio import AsyncIOMotorCollection
from app.core.mongodb import get_database
from app.models.template import ProxyTemplateCreateInput, ProxyTemplateDocument
COLLECTION_NAME = "addon_templates"
def utcnow() -> datetime:
return datetime.now(UTC)
async def collection() -> AsyncIOMotorCollection[dict[str, Any]]:
database = await get_database()
return database[COLLECTION_NAME]
def deserialize(raw: Mapping[str, Any] | None) -> ProxyTemplateDocument | None:
if raw is None:
return None
document = dict(raw)
document.pop("_id", None)
return ProxyTemplateDocument.model_validate(document)
async def ensure_template_indexes() -> None:
templates = await collection()
await templates.create_index("id", unique=True, name="uq_addon_template_id")
async def list_custom_templates() -> list[ProxyTemplateDocument]:
templates = await collection()
cursor = templates.find({}).sort("name", 1)
result: list[ProxyTemplateDocument] = []
async for raw in cursor:
template = deserialize(raw)
if template is not None:
result.append(template)
return result
async def create_custom_template(
payload: ProxyTemplateCreateInput,
) -> ProxyTemplateDocument:
now = utcnow()
template = ProxyTemplateDocument(
id=payload.id,
name=payload.name,
description=payload.description,
category=payload.category,
protocol=payload.protocol,
family=payload.family,
tags=payload.tags,
action=payload.action,
summary=payload.summary,
behavior=payload.behavior,
code=payload.code,
source_url=payload.source_url,
built_in=False,
created_at=now,
updated_at=now,
)
templates = await collection()
await templates.insert_one(template.model_dump(exclude_none=True))
return template
async def delete_custom_template(template_id: str) -> bool:
templates = await collection()
result = await templates.delete_one({"id": template_id})
return result.deleted_count > 0
-152
View File
@@ -1,152 +0,0 @@
from models.example import AddonExampleDocument
DOCS_EXAMPLES_URL = "https://docs.mitmproxy.org/stable/addons/examples/"
BUILTIN_EXAMPLES = [
AddonExampleDocument(
id="http-tag-traffic",
name="Tag proxied traffic",
description=(
"Adds a visible response header on every HTTP reply so you can tell "
"immediately whether the request crossed the proxy."
),
category="http",
source_url=DOCS_EXAMPLES_URL,
built_in=True,
code="""from mitmproxy import http
def response(flow: http.HTTPFlow) -> None:
flow.response.headers["x-theplug"] = "enabled"
""",
),
AddonExampleDocument(
id="http-force-query-flag",
name="Force request flag",
description=(
"Adds or overwrites a request query flag before the flow is sent "
"upstream. Useful when you need a stable marker on every request."
),
category="http",
source_url=DOCS_EXAMPLES_URL,
built_in=True,
code="""from mitmproxy import http
def request(flow: http.HTTPFlow) -> None:
flow.request.query["debug"] = "1"
""",
),
AddonExampleDocument(
id="http-local-health-response",
name="Serve local health response",
description=(
"Answers one health endpoint directly from the proxy without "
"touching the upstream service. Useful for smoke tests and checks."
),
category="http",
source_url=DOCS_EXAMPLES_URL,
built_in=True,
code="""from mitmproxy import http
def request(flow: http.HTTPFlow) -> None:
if flow.request.path == "/healthz":
flow.response = http.Response.make(
200,
b"ok",
{"content-type": "text/plain"},
)
""",
),
AddonExampleDocument(
id="http-rewrite-health-path",
name="Rewrite one request path",
description=(
"Rewrites a single incoming path before forwarding it upstream. "
"Useful when the public route and upstream route do not match."
),
category="http",
source_url=DOCS_EXAMPLES_URL,
built_in=True,
code="""from mitmproxy import http
def request(flow: http.HTTPFlow) -> None:
if flow.request.path == "/healthz":
flow.request.path = "/status"
""",
),
AddonExampleDocument(
id="websocket-rewrite",
name="WebSocket rewrite",
description=(
"Rewrites matching bytes inside live WebSocket frames to flip a "
"runtime marker while the flow is passing through the proxy."
),
category="websocket",
source_url=DOCS_EXAMPLES_URL,
built_in=True,
code="""from mitmproxy import http
def websocket_message(flow: http.HTTPFlow) -> None:
if flow.websocket is None:
return
message = flow.websocket.messages[-1]
message.content = message.content.replace(b"debug=false", b"debug=true")
""",
),
AddonExampleDocument(
id="tcp-log-messages",
name="TCP log messages",
description=(
"Logs direction and byte size for every TCP frame so you can see "
"what is crossing the route without modifying the payload."
),
category="tcp",
source_url=DOCS_EXAMPLES_URL,
built_in=True,
code="""import logging
from mitmproxy import tcp
def tcp_message(flow: tcp.TCPFlow) -> None:
message = flow.messages[-1]
direction = "client" if message.from_client else "server"
logging.info("%s sent %d bytes", direction, len(message.content))
""",
),
AddonExampleDocument(
id="dns-spoof-a-record",
name="DNS spoof A record",
description=(
"Overrides one DNS A record while leaving all other queries alone. "
"Useful for selective redirection during local testing."
),
category="dns",
source_url=DOCS_EXAMPLES_URL,
built_in=True,
code="""import ipaddress
from mitmproxy import dns
def dns_request(flow: dns.DNSFlow) -> None:
question = flow.request.question
if question and question.name == "target.local" and question.type == dns.types.A:
flow.response = flow.request.succeed(
[
dns.ResourceRecord(
name="target.local",
type=dns.types.A,
class_=dns.classes.IN,
ttl=60,
data=ipaddress.ip_address("127.0.0.1").packed,
)
]
)
""",
),
]
+83 -40
View File
@@ -1,8 +1,11 @@
import asyncio import asyncio
import ssl import ssl
import time
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime
from models.proxy import HealthCheckDocument, ProxyDocument from app.models.health import HealthCheckEntry, HealthSnapshot
from app.models.proxy import HealthCheckDocument, ProxyDocument
@dataclass(slots=True) @dataclass(slots=True)
@@ -13,33 +16,48 @@ class Target:
protocol: str protocol: str
async def check_proxy(proxy: ProxyDocument) -> dict[str, object]: async def check_proxy(proxy: ProxyDocument) -> HealthSnapshot:
timestamp = datetime.now(UTC)
if not proxy.healthcheck.enabled: if not proxy.healthcheck.enabled:
return {"enabled": False, "overall": "disabled", "checks": []} return HealthSnapshot(
proxy_port=proxy.proxy_port,
timestamp=timestamp,
enabled=False,
overall="disabled",
checks=[],
)
upstream_port = proxy.service.upstream_port or proxy.service.port upstream_port = proxy.service.upstream_port or proxy.service.port
checks = await asyncio.gather( checks = list(
check_target( await asyncio.gather(
Target( check_target(
name="upstream", Target(
host=proxy.service.host, name="upstream",
port=upstream_port, host=proxy.service.host,
protocol=health_protocol(proxy), port=upstream_port,
protocol=health_protocol(proxy),
),
proxy.healthcheck,
), ),
proxy.healthcheck, check_target(
), Target(
check_target( name="proxy",
Target( host=listen_host_for_check(proxy.listen_host),
name="proxy", port=proxy.proxy_port,
host=listen_host_for_check(proxy.listen_host), protocol=health_protocol(proxy),
port=proxy.proxy_port, ),
protocol=health_protocol(proxy), proxy.healthcheck,
), ),
proxy.healthcheck, )
), )
overall = "healthy" if all(check.healthy for check in checks) else "unhealthy"
return HealthSnapshot(
proxy_port=proxy.proxy_port,
timestamp=timestamp,
enabled=True,
overall=overall,
checks=checks,
) )
overall = "healthy" if all(check["healthy"] for check in checks) else "unhealthy"
return {"enabled": True, "overall": overall, "checks": checks}
def health_protocol(proxy: ProxyDocument) -> str: def health_protocol(proxy: ProxyDocument) -> str:
@@ -55,7 +73,7 @@ def listen_host_for_check(host: str) -> str:
async def check_target( async def check_target(
target: Target, target: Target,
config: HealthCheckDocument, config: HealthCheckDocument,
) -> dict[str, object]: ) -> HealthCheckEntry:
try: try:
match target.protocol: match target.protocol:
case "http": case "http":
@@ -81,7 +99,8 @@ async def check_http(
config: HealthCheckDocument, config: HealthCheckDocument,
*, *,
use_tls: bool, use_tls: bool,
) -> dict[str, object]: ) -> HealthCheckEntry:
started = time.perf_counter()
reader, writer = await open_connection(target, config, use_tls=use_tls) reader, writer = await open_connection(target, config, use_tls=use_tls)
try: try:
path = config.path if config.path.startswith("/") else f"/{config.path}" path = config.path if config.path.startswith("/") else f"/{config.path}"
@@ -101,7 +120,13 @@ async def check_http(
status = int(parts[1]) if len(parts) >= 2 and parts[1].isdigit() else None status = int(parts[1]) if len(parts) >= 2 and parts[1].isdigit() else None
expected = config.expected_status expected = config.expected_status
healthy = status == expected if expected is not None else status is not None healthy = status == expected if expected is not None else status is not None
return result(target, healthy, None, {"status": status}) return result(
target,
healthy,
None,
{"status": status},
benchmark_ms=elapsed_ms(started) if config.benchmark_enabled else None,
)
async def check_tcp( async def check_tcp(
@@ -109,14 +134,20 @@ async def check_tcp(
config: HealthCheckDocument, config: HealthCheckDocument,
*, *,
use_tls: bool, use_tls: bool,
) -> dict[str, object]: ) -> HealthCheckEntry:
started = time.perf_counter()
_reader, writer = await open_connection(target, config, use_tls=use_tls) _reader, writer = await open_connection(target, config, use_tls=use_tls)
writer.close() writer.close()
await writer.wait_closed() await writer.wait_closed()
return result(target, True) return result(
target,
True,
benchmark_ms=elapsed_ms(started) if config.benchmark_enabled else None,
)
async def check_udp(target: Target, config: HealthCheckDocument) -> dict[str, object]: async def check_udp(target: Target, config: HealthCheckDocument) -> HealthCheckEntry:
started = time.perf_counter()
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
transport, protocol = await loop.create_datagram_endpoint( transport, protocol = await loop.create_datagram_endpoint(
lambda: DatagramProbe(config.expect_response), lambda: DatagramProbe(config.expect_response),
@@ -128,7 +159,13 @@ async def check_udp(target: Target, config: HealthCheckDocument) -> dict[str, ob
if not config.expect_response: if not config.expect_response:
return result(target, True, None, {"mode": "send-only"}) return result(target, True, None, {"mode": "send-only"})
data = await asyncio.wait_for(protocol.response, config.timeout_seconds) data = await asyncio.wait_for(protocol.response, config.timeout_seconds)
return result(target, True, None, {"bytes": len(data)}) return result(
target,
True,
None,
{"bytes": len(data)},
benchmark_ms=elapsed_ms(started) if config.benchmark_enabled else None,
)
finally: finally:
transport.close() transport.close()
@@ -159,7 +196,7 @@ class DatagramProbe(asyncio.DatagramProtocol):
if not expect_response: if not expect_response:
self.response.set_result(b"") self.response.set_result(b"")
def datagram_received(self, data, _addr): def datagram_received(self, data, addr):
if not self.response.done(): if not self.response.done():
self.response.set_result(data) self.response.set_result(data)
@@ -173,13 +210,19 @@ def result(
healthy: bool, healthy: bool,
error: str | None = None, error: str | None = None,
details: dict[str, object] | None = None, details: dict[str, object] | None = None,
) -> dict[str, object]: benchmark_ms: float | None = None,
return { ) -> HealthCheckEntry:
"target": target.name, return HealthCheckEntry(
"host": target.host, target=target.name,
"port": target.port, host=target.host,
"protocol": target.protocol, port=target.port,
"healthy": healthy, protocol=target.protocol,
"error": error, healthy=healthy,
"details": details or {}, error=error,
} duration_ms=benchmark_ms,
details=details or {},
)
def elapsed_ms(started: float) -> float:
return round((time.perf_counter() - started) * 1000, 2)
@@ -0,0 +1,117 @@
import asyncio
import logging
from app.core.config import settings
from app.models.health import HealthSnapshot
from app.models.proxy import ProxyDocument
from app.repositories.health import record_health_sample
from app.repositories.proxy import list_proxies
from app.services.mitmproxy.health import check_proxy
logger = logging.getLogger(__name__)
class HealthMonitor:
def __init__(self):
self.task: asyncio.Task[None] | None = None
self.subscribers: dict[int, set[asyncio.Queue[HealthSnapshot]]] = {}
async def start(self) -> None:
if self.task is None or self.task.done():
self.task = asyncio.create_task(self.run())
async def stop(self) -> None:
if self.task is None:
return
self.task.cancel()
await asyncio.gather(self.task, return_exceptions=True)
self.task = None
async def run(self) -> None:
loop = asyncio.get_running_loop()
next_cycle = loop.time()
while True:
delay = next_cycle - loop.time()
if delay > 0:
await asyncio.sleep(delay)
cycle_started = loop.time()
try:
await self.run_cycle()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Health monitor cycle failed")
next_cycle = loop.time() + min(
1.0,
float(settings.HEALTHCHECK_INTERVAL_SECONDS),
)
continue
next_cycle = cycle_started + settings.HEALTHCHECK_INTERVAL_SECONDS
async def run_cycle(self) -> None:
proxies = [
proxy
for proxy in await list_proxies()
if proxy.status == "running" and proxy.healthcheck.enabled
]
async with asyncio.TaskGroup() as group:
for proxy in proxies:
group.create_task(self.run_check(proxy))
async def run_check(self, proxy: ProxyDocument) -> None:
try:
snapshot = await check_proxy(proxy)
except asyncio.CancelledError:
raise
except Exception:
logger.exception(
"Health check failed for proxy %s",
proxy.proxy_port,
)
return
try:
await record_health_sample(snapshot)
except asyncio.CancelledError:
raise
except Exception:
logger.exception(
"Health snapshot persistence failed for proxy %s",
proxy.proxy_port,
)
await self.publish(proxy.proxy_port, snapshot)
async def publish(self, proxy_port: int, snapshot: HealthSnapshot) -> None:
for queue in list(self.subscribers.get(proxy_port, set())):
try:
queue.put_nowait(snapshot)
except asyncio.QueueFull:
logger.debug(
"Dropped health update for slow subscriber on proxy %s",
proxy_port,
)
continue
def subscribe(self, proxy_port: int) -> asyncio.Queue[HealthSnapshot]:
queue: asyncio.Queue[HealthSnapshot] = asyncio.Queue(maxsize=8)
listeners = self.subscribers.setdefault(proxy_port, set())
listeners.add(queue)
return queue
def unsubscribe(
self,
proxy_port: int,
queue: asyncio.Queue[HealthSnapshot],
) -> None:
listeners = self.subscribers.get(proxy_port)
if listeners is None:
return
listeners.discard(queue)
if not listeners:
self.subscribers.pop(proxy_port, None)
health_monitor = HealthMonitor()
+93 -32
View File
@@ -5,10 +5,15 @@ import signal
from pathlib import Path from pathlib import Path
import yaml import yaml
from integrations import docker
from integrations.docker.ports import get_random_port from app.integrations.docker import change_port
from models.proxy import ProxyDocument from app.integrations.docker.ports import get_random_port
from services.mitmproxy.options import MitmOptionError, validate_options from app.models.proxy import ProxyDocument
from app.services.mitmproxy.options import MitmOptionError, validate_options
from app.services.mitmproxy.runtime_validation import (
ReservedProxyPortError,
ensure_proxy_port_not_reserved,
)
class MitmdumpError(Exception): class MitmdumpError(Exception):
@@ -22,6 +27,7 @@ class MitmdumpManager:
self.processes: dict[int, asyncio.subprocess.Process] = {} self.processes: dict[int, asyncio.subprocess.Process] = {}
self.attached_pids: dict[int, int] = {} self.attached_pids: dict[int, int] = {}
self.locks: dict[int, asyncio.Lock] = {} self.locks: dict[int, asyncio.Lock] = {}
self.upstream_port_attempts = 8
async def start(self, proxy: ProxyDocument) -> dict[str, object]: async def start(self, proxy: ProxyDocument) -> dict[str, object]:
async with self.lock_for(proxy.proxy_port): async with self.lock_for(proxy.proxy_port):
@@ -31,7 +37,30 @@ class MitmdumpManager:
async with self.lock_for(proxy.proxy_port): async with self.lock_for(proxy.proxy_port):
return await self.stop_unlocked(proxy) return await self.stop_unlocked(proxy)
def cleanup_runtime_artifacts(self, proxy: ProxyDocument) -> None:
script_path = (
Path(proxy.script_path)
if proxy.script_path
else self.script_path_for(proxy)
)
log_path = Path(proxy.log_path) if proxy.log_path else self.log_path_for(proxy)
config_path = (
Path(proxy.config_path)
if proxy.config_path
else self.config_path_for(proxy)
)
self.remove_file(script_path)
self.remove_file(log_path)
self.remove_file(config_path)
self.remove_dir(self.conf_dir_for(proxy))
async def start_unlocked(self, proxy: ProxyDocument) -> dict[str, object]: async def start_unlocked(self, proxy: ProxyDocument) -> dict[str, object]:
try:
ensure_proxy_port_not_reserved(proxy.proxy_port)
except ReservedProxyPortError as error:
raise MitmdumpError(str(error)) from error
if await self.status(proxy) == "running": if await self.status(proxy) == "running":
raise MitmdumpError("Proxy is already running") raise MitmdumpError("Proxy is already running")
@@ -44,9 +73,9 @@ class MitmdumpManager:
except MitmOptionError as error: except MitmOptionError as error:
raise MitmdumpError(str(error)) from error raise MitmdumpError(str(error)) from error
upstream_port = await self.prepare_upstream(proxy) upstream_port = await self.prepare_upstream(proxy)
script_path = self.write_script(proxy) script_path = await self.write_script(proxy)
config_path = self.write_config(proxy) config_path = await self.write_config(proxy)
log_path = self.log_path(proxy) log_path = await self.log_path(proxy)
command = self.build_command( command = self.build_command(
command_path, command_path,
proxy, proxy,
@@ -64,7 +93,10 @@ class MitmdumpManager:
failed = await self.exited_early(process) failed = await self.exited_early(process)
if failed: if failed:
await self.restore_upstream(proxy, upstream_port) await self.restore_upstream(proxy, upstream_port)
reason = self.read_log_tail(log_path) or "mitmdump exited during startup" reason = (
await self.read_log_tail(log_path)
or "mitmdump exited during startup"
)
raise MitmdumpError(reason) raise MitmdumpError(reason)
self.processes[proxy.proxy_port] = process self.processes[proxy.proxy_port] = process
@@ -174,18 +206,22 @@ class MitmdumpManager:
if proxy.service.name is None or proxy.service.folder is None: if proxy.service.name is None or proxy.service.folder is None:
return proxy.service.port return proxy.service.port
upstream_port = get_random_port() attempted_ports: set[int] = {proxy.proxy_port, proxy.service.port}
changed = await docker.change_port( for _attempt in range(self.upstream_port_attempts):
proxy.service.name, upstream_port = self.pick_upstream_port(attempted_ports)
Path(proxy.service.folder), attempted_ports.add(upstream_port)
proxy.service.port, changed = await change_port(
upstream_port, proxy.service.name,
host_to="127.0.0.1", Path(proxy.service.folder),
rewrite_host=True, proxy.service.port,
) upstream_port,
if not changed: host_to="127.0.0.1",
raise MitmdumpError("Unable to move Docker service port") rewrite_host=True,
return upstream_port )
if changed:
return upstream_port
raise MitmdumpError("Unable to move Docker service port after retries")
async def restore_upstream( async def restore_upstream(
self, self,
@@ -199,7 +235,7 @@ class MitmdumpManager:
if upstream_port == proxy.service.port: if upstream_port == proxy.service.port:
return True return True
return await docker.change_port( return await change_port(
proxy.service.name, proxy.service.name,
Path(proxy.service.folder), Path(proxy.service.folder),
upstream_port, upstream_port,
@@ -257,22 +293,29 @@ class MitmdumpManager:
return False return False
return proxy.mode in {"regular", "socks5", "dns", "transparent"} return proxy.mode in {"regular", "socks5", "dns", "transparent"}
def write_script(self, proxy: ProxyDocument) -> Path: def pick_upstream_port(self, excluded_ports: set[int]) -> int:
while True:
candidate = get_random_port()
if candidate not in excluded_ports:
return candidate
async def write_script(self, proxy: ProxyDocument) -> Path:
path = self.script_path_for(proxy) path = self.script_path_for(proxy)
path.parent.mkdir(parents=True, exist_ok=True) await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
path.write_text(proxy.code, encoding="utf-8") await asyncio.to_thread(path.write_text, proxy.code, encoding="utf-8")
return path return path
def write_config(self, proxy: ProxyDocument) -> Path: async def write_config(self, proxy: ProxyDocument) -> Path:
path = self.config_path_for(proxy) path = self.config_path_for(proxy)
path.parent.mkdir(parents=True, exist_ok=True) await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
options = {"block_global": False, **proxy.mitm_options} options = {"block_global": False, **proxy.mitm_options}
path.write_text(yaml.safe_dump(options, sort_keys=True), encoding="utf-8") config_text = yaml.safe_dump(options, sort_keys=True)
await asyncio.to_thread(path.write_text, config_text, encoding="utf-8")
return path return path
def log_path(self, proxy: ProxyDocument) -> Path: async def log_path(self, proxy: ProxyDocument) -> Path:
path = self.log_path_for(proxy) path = self.log_path_for(proxy)
path.parent.mkdir(parents=True, exist_ok=True) await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
return path return path
async def spawn(self, command: list[str], log_path: Path): async def spawn(self, command: list[str], log_path: Path):
@@ -385,12 +428,30 @@ class MitmdumpManager:
def log_path_for(self, proxy: ProxyDocument) -> Path: def log_path_for(self, proxy: ProxyDocument) -> Path:
return self.work_dir / "logs" / f"{proxy.proxy_port}.log" return self.work_dir / "logs" / f"{proxy.proxy_port}.log"
def read_log_tail(self, path: Path, limit: int = 4000) -> str: async def read_log_tail(self, path: Path, limit: int = 4000) -> str:
if not path.exists(): if not await asyncio.to_thread(path.exists):
return "" return ""
content = path.read_text(encoding="utf-8", errors="replace") content = await asyncio.to_thread(
path.read_text,
encoding="utf-8",
errors="replace",
)
return content[-limit:].strip() return content[-limit:].strip()
def remove_file(self, path: Path) -> None:
try:
path.unlink(missing_ok=True)
except OSError:
return
def remove_dir(self, path: Path) -> None:
try:
shutil.rmtree(path)
except FileNotFoundError:
return
except OSError:
return
def lock_for(self, proxy_port: int) -> asyncio.Lock: def lock_for(self, proxy_port: int) -> asyncio.Lock:
lock = self.locks.get(proxy_port) lock = self.locks.get(proxy_port)
if lock is None: if lock is None:
+2 -2
View File
@@ -1,5 +1,5 @@
from repositories.proxy import list_proxies, update_proxy_runtime from app.repositories.proxy import list_proxies, update_proxy_runtime
from services.mitmproxy.manager import manager from app.services.mitmproxy.manager import manager
async def reconcile_runtime() -> list[tuple[int, dict[str, object]]]: async def reconcile_runtime() -> list[tuple[int, dict[str, object]]]:
@@ -0,0 +1,21 @@
from app.core.config import settings
class ReservedProxyPortError(ValueError):
pass
def backend_port() -> int:
return settings.API_PORT
def reserved_proxy_port_message(proxy_port: int) -> str:
return (
f"Proxy port {proxy_port} is reserved for the backend API "
"and cannot be used by mitmdump."
)
def ensure_proxy_port_not_reserved(proxy_port: int) -> None:
if proxy_port == backend_port():
raise ReservedProxyPortError(reserved_proxy_port_message(proxy_port))
+524
View File
@@ -0,0 +1,524 @@
from app.models.template import ProxyTemplateDocument
DOCS_TEMPLATES_URL = "https://docs.mitmproxy.org/stable/addons/examples/"
def builtin_template(
*,
id: str,
name: str,
protocol: str,
family: str,
tags: list[str],
action: str,
summary: str,
behavior: str,
code: str,
) -> ProxyTemplateDocument:
category = protocol if protocol in {"http", "tcp"} else family
return ProxyTemplateDocument(
id=id,
name=name,
description=summary,
category=category,
protocol=protocol,
family=family,
tags=tags,
action=action,
summary=summary,
behavior=behavior,
source_url=DOCS_TEMPLATES_URL,
built_in=True,
code=code,
)
BUILTIN_TEMPLATES = [
builtin_template(
id="http-block-path-traversal",
name="Block path traversal",
protocol="http",
family="web-hardening",
tags=["path-traversal", "web", "block"],
action="block",
summary="Stops common path traversal probes in path and query.",
behavior=(
"Logs the matched traversal token and immediately drops the client "
"connection before the request reaches the upstream service."
),
code="""import logging
from urllib.parse import unquote
from mitmproxy import http
TRAVERSAL_MARKERS = (
"../",
"..\\\\",
"%2e%2e",
"%252e%252e",
"..%2f",
"..%5c",
)
def request(flow: http.HTTPFlow) -> None:
raw_target = (
f"{flow.request.path}?{flow.request.query}"
if flow.request.query
else flow.request.path
)
normalized = unquote(unquote(raw_target)).lower()
for marker in TRAVERSAL_MARKERS:
if marker in normalized:
logging.warning(
"[http-block-path-traversal] blocked %s %s from %s",
flow.request.method,
flow.request.pretty_url,
flow.client_conn.peername,
)
flow.kill()
return
""",
),
builtin_template(
id="http-block-blacklisted-substrings",
name="Block blacklisted substrings",
protocol="http",
family="web-hardening",
tags=["blacklist-string", "web", "block"],
action="block",
summary="Blocks requests containing forbidden strings in path, query, or body.",
behavior=(
"Scans URL and request body for blacklisted markers, then serves a "
"blank HTML page with 200 OK and logs the first hit."
),
code="""import logging
from mitmproxy import http
BLOCKED_STRINGS = (
"flag{",
"give_me_shell",
"debug=1",
"/etc/passwd",
)
def request(flow: http.HTTPFlow) -> None:
body = flow.request.get_text(strict=False)
haystack = "\\n".join(
[flow.request.path, str(flow.request.query), body]
).lower()
for marker in BLOCKED_STRINGS:
if marker.lower() in haystack:
logging.warning(
"[http-block-blacklisted-substrings] blocked marker=%r url=%s",
marker,
flow.request.pretty_url,
)
flow.response = http.Response.make(
200,
b"",
{"content-type": "text/html; charset=utf-8"},
)
return
""",
),
builtin_template(
id="http-block-large-body",
name="Block large request bodies",
protocol="http",
family="web-hardening",
tags=["length-limit", "body-size", "web", "block"],
action="block",
summary="Rejects oversized HTTP request bodies before they hit the service.",
behavior=(
"Checks the buffered request body length, returns 413 Payload Too "
"Large, and logs the observed size."
),
code="""import logging
from mitmproxy import http
MAX_BODY_BYTES = 8192
def request(flow: http.HTTPFlow) -> None:
size = len(flow.request.raw_content or b"")
if size > MAX_BODY_BYTES:
logging.warning(
"[http-block-large-body] blocked %s bytes on %s",
size,
flow.request.pretty_url,
)
flow.response = http.Response.make(
413,
b"",
{"content-type": "text/plain"},
)
""",
),
builtin_template(
id="http-block-suspicious-methods",
name="Block suspicious HTTP methods",
protocol="http",
family="web-hardening",
tags=["method-filter", "web", "block"],
action="block",
summary="Denies uncommon or dangerous HTTP methods.",
behavior=(
"Returns 405 Method Not Allowed for methods outside the allowlist "
"and logs the blocked method and target URL."
),
code="""import logging
from mitmproxy import http
ALLOWED_METHODS = {"GET", "POST", "HEAD"}
def request(flow: http.HTTPFlow) -> None:
if flow.request.method not in ALLOWED_METHODS:
logging.warning(
"[http-block-suspicious-methods] blocked method=%s url=%s",
flow.request.method,
flow.request.pretty_url,
)
flow.response = http.Response.make(
405,
b"",
{"allow": "GET, POST, HEAD"},
)
""",
),
builtin_template(
id="http-block-admin-paths",
name="Block admin and debug paths",
protocol="http",
family="web-hardening",
tags=["admin-path", "debug-path", "web", "block"],
action="block",
summary="Protects common admin, console, and debug endpoints.",
behavior=(
"Matches the request path against a small denylist and returns a "
"blank 404 page before the request is forwarded."
),
code="""import logging
from mitmproxy import http
BLOCKED_PREFIXES = (
"/admin",
"/debug",
"/console",
"/actuator",
)
def request(flow: http.HTTPFlow) -> None:
if flow.request.path.startswith(BLOCKED_PREFIXES):
logging.warning(
"[http-block-admin-paths] blocked path=%s",
flow.request.path,
)
flow.response = http.Response.make(
404,
b"",
{"content-type": "text/html; charset=utf-8"},
)
""",
),
builtin_template(
id="http-sanitize-headers-basic",
name="Sanitize risky forwarding headers",
protocol="http",
family="web-hardening",
tags=["rewrite", "headers", "web"],
action="rewrite",
summary=(
"Removes a few risky forwarding headers before the request is "
"sent upstream."
),
behavior=(
"Deletes selected override or forwarding headers and logs what was "
"rewritten without blocking the request."
),
code="""import logging
from mitmproxy import http
STRIP_HEADERS = (
"x-forwarded-host",
"x-http-method-override",
"x-original-url",
"x-rewrite-url",
)
def request(flow: http.HTTPFlow) -> None:
removed = []
for header in STRIP_HEADERS:
if header in flow.request.headers:
removed.append(header)
del flow.request.headers[header]
if removed:
logging.info(
"[http-sanitize-headers-basic] stripped headers=%s url=%s",
",".join(removed),
flow.request.pretty_url,
)
""",
),
builtin_template(
id="http-block-command-injection-markers",
name="Block command injection markers",
protocol="http",
family="web-hardening",
tags=["command-injection", "web", "block"],
action="block",
summary=(
"Rejects obvious shell metacharacters and command-substitution "
"markers."
),
behavior=(
"Scans URL and body for shell-style injection markers, logs the "
"first hit, and drops the client connection."
),
code="""import logging
from mitmproxy import http
BLOCKED_MARKERS = (";", "&&", "||", "$(", "`", "|sh", "|bash")
def request(flow: http.HTTPFlow) -> None:
body = flow.request.get_text(strict=False)
haystack = "\\n".join(
[flow.request.pretty_url, body]
).lower()
for marker in BLOCKED_MARKERS:
if marker in haystack:
logging.warning(
"[http-block-command-injection-markers] blocked marker=%r url=%s",
marker,
flow.request.pretty_url,
)
flow.kill()
return
""",
),
builtin_template(
id="http-block-sqli-markers",
name="Block obvious SQLi markers",
protocol="http",
family="web-hardening",
tags=["sqli", "web", "block"],
action="block",
summary="Stops common SQL injection probes before they reach the app.",
behavior=(
"Matches a short list of high-signal SQLi markers in path, query, "
"and body, then serves a blank HTML page and logs the hit."
),
code="""import logging
from mitmproxy import http
BLOCKED_MARKERS = (
"' or 1=1",
'" or 1=1',
"union select",
"sleep(",
"information_schema",
"--",
)
def request(flow: http.HTTPFlow) -> None:
body = flow.request.get_text(strict=False)
haystack = "\\n".join(
[flow.request.path, str(flow.request.query), body]
).lower()
for marker in BLOCKED_MARKERS:
if marker in haystack:
logging.warning(
"[http-block-sqli-markers] blocked marker=%r url=%s",
marker,
flow.request.pretty_url,
)
flow.response = http.Response.make(
200,
b"",
{"content-type": "text/html; charset=utf-8"},
)
return
""",
),
builtin_template(
id="tcp-block-repeated-byte-threshold",
name="Block repeated-byte flood",
protocol="tcp",
family="tcp-hardening",
tags=["bof", "repeated-byte", "tcp", "block"],
action="block",
summary="Closes connections that send suspiciously long runs of the same byte.",
behavior=(
"Measures the longest repeated-byte run in each client message, logs "
"the byte and run length, and closes the TCP flow on threshold hit."
),
code="""import logging
from mitmproxy import tcp
MAX_REPEAT = 128
def longest_run(data: bytes) -> tuple[int, int]:
best_byte = -1
best_run = 0
current_byte = -1
current_run = 0
for byte in data:
if byte == current_byte:
current_run += 1
else:
current_byte = byte
current_run = 1
if current_run > best_run:
best_byte = byte
best_run = current_run
return best_byte, best_run
def tcp_message(flow: tcp.TCPFlow) -> None:
message = flow.messages[-1]
if not message.from_client:
return
repeated_byte, run_length = longest_run(message.content)
if run_length > MAX_REPEAT:
logging.warning(
"[tcp-block-repeated-byte-threshold] blocked byte=0x%02x run=%d",
repeated_byte,
run_length,
)
message.drop()
flow.kill()
""",
),
builtin_template(
id="tcp-block-message-length-threshold",
name="Block oversized TCP messages",
protocol="tcp",
family="tcp-hardening",
tags=["length-limit", "tcp", "block"],
action="block",
summary="Cuts off client messages that exceed a fixed size threshold.",
behavior=(
"Checks every client TCP message size, logs the offending length, "
"drops the message, and kills the flow when the threshold is hit."
),
code="""import logging
from mitmproxy import tcp
MAX_MESSAGE_BYTES = 4096
def tcp_message(flow: tcp.TCPFlow) -> None:
message = flow.messages[-1]
if not message.from_client:
return
size = len(message.content)
if size > MAX_MESSAGE_BYTES:
logging.warning(
"[tcp-block-message-length-threshold] blocked message size=%d",
size,
)
message.drop()
flow.kill()
""",
),
builtin_template(
id="tcp-block-blacklisted-byte-sequences",
name="Block blacklisted TCP byte sequences",
protocol="tcp",
family="tcp-hardening",
tags=["blacklist-bytes", "tcp", "block"],
action="block",
summary=(
"Blocks payloads containing forbidden byte sequences or marker "
"strings."
),
behavior=(
"Looks for dangerous byte patterns in client messages, then drops "
"the message and closes the flow."
),
code="""import logging
from mitmproxy import tcp
BLOCKED_SEQUENCES = (
b"/bin/sh",
b"cat flag",
b"AAAA%p%p",
b"\\x90\\x90\\x90\\x90",
)
def tcp_message(flow: tcp.TCPFlow) -> None:
message = flow.messages[-1]
if not message.from_client:
return
for sequence in BLOCKED_SEQUENCES:
if sequence in message.content:
logging.warning(
"[tcp-block-blacklisted-byte-sequences] blocked sequence=%r",
sequence,
)
message.drop()
flow.kill()
return
""",
),
builtin_template(
id="tcp-block-non-printable-threshold",
name="Block binary-noise threshold",
protocol="tcp",
family="tcp-hardening",
tags=["non-printable", "binary-noise", "tcp", "block"],
action="block",
summary=(
"Blocks client messages with an excessive amount of non-printable "
"bytes."
),
behavior=(
"Treats unusually binary-heavy client payloads as suspicious, logs "
"the ratio, and closes the flow when the threshold is exceeded."
),
code="""import logging
from mitmproxy import tcp
NON_PRINTABLE_RATIO = 0.35
def tcp_message(flow: tcp.TCPFlow) -> None:
message = flow.messages[-1]
if not message.from_client or not message.content:
return
non_printable = sum(
1
for byte in message.content
if byte < 0x20 and byte not in {0x09, 0x0a, 0x0d}
)
ratio = non_printable / len(message.content)
if ratio > NON_PRINTABLE_RATIO:
logging.warning(
"[tcp-block-non-printable-threshold] blocked ratio=%.2f size=%d",
ratio,
len(message.content),
)
message.drop()
flow.kill()
""",
),
]
+10 -1
View File
@@ -29,8 +29,17 @@ indent-style = "space"
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
pythonpath = ["app"] pythonpath = ["."]
addopts = "-q" addopts = "-q"
markers = [
"unit: fast isolated tests",
"integration: tests using real app wiring and persistence",
"api: HTTP API behavior tests",
"websocket: websocket behavior tests",
"slow: slower-running tests",
"requires_docker: tests that require Docker or containerized dependencies",
"perf: performance and benchmark scenarios",
]
[dependency-groups] [dependency-groups]
dev = [ dev = [
+3 -3
View File
@@ -1,10 +1,10 @@
import importlib import importlib
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from core.security import AuthContext, get_auth_context from app.core.security import AuthContext, get_auth_context
from models.auth import AccessTokenPayload, AuthUserDocument from app.models.auth import AccessTokenPayload, AuthUserDocument
auth_router_module = importlib.import_module("api.auth.router") auth_router_module = importlib.import_module("app.api.auth.router")
def test_login_success_returns_bearer_token(client, monkeypatch: object): def test_login_success_returns_bearer_token(client, monkeypatch: object):
+13 -6
View File
@@ -1,15 +1,20 @@
import pytest import pytest
from services.editor import python_lsp
from starlette.websockets import WebSocketDisconnect from starlette.websockets import WebSocketDisconnect
from app.services.editor import python_lsp
def test_python_editor_session(client, monkeypatch): def test_python_editor_session(client, monkeypatch):
session = python_lsp.PythonEditorSession( session = python_lsp.PythonEditorSession(
root_path=python_lsp.WORKSPACE_ROOT, root_path=python_lsp.WORKSPACE_ROOT,
file_path=python_lsp.WORKSPACE_ROOT / python_lsp.ENTRY_FILE, file_path=python_lsp.WORKSPACE_ROOT / python_lsp.ENTRY_FILE,
) )
monkeypatch.setattr("api.editor.router.python_lsp.lsp_binary", lambda: "/bin/lsp") monkeypatch.setattr(
monkeypatch.setattr("api.editor.router.python_lsp.get_session", lambda: session) "app.api.editor.router.python_lsp.lsp_binary", lambda: "/bin/lsp"
)
monkeypatch.setattr(
"app.api.editor.router.python_lsp.get_session", lambda: session
)
response = client.get("/proxy/editor/python/session") response = client.get("/proxy/editor/python/session")
@@ -40,11 +45,13 @@ def test_python_editor_socket_accepts_token(client, monkeypatch):
await websocket.close() await websocket.close()
monkeypatch.setattr( monkeypatch.setattr(
"api.editor.router.authenticate_editor_socket", "app.api.editor.router.authenticate_editor_socket",
fake_authenticate, fake_authenticate,
) )
monkeypatch.setattr("api.editor.router.python_lsp.lsp_binary", lambda: "/bin/lsp") monkeypatch.setattr(
monkeypatch.setattr("api.editor.router.python_lsp.bridge", fake_bridge) "app.api.editor.router.python_lsp.lsp_binary", lambda: "/bin/lsp"
)
monkeypatch.setattr("app.api.editor.router.python_lsp.bridge", fake_bridge)
with client.websocket_connect("/proxy/editor/python/ws?token=test-token") as socket: with client.websocket_connect("/proxy/editor/python/ws?token=test-token") as socket:
assert socket.receive_text().endswith("/proxy_addon.py") assert socket.receive_text().endswith("/proxy_addon.py")
+385 -21
View File
@@ -1,14 +1,18 @@
import asyncio
import importlib import importlib
from datetime import UTC, datetime
import pytest import pytest
from core.security import get_auth_context, require_authenticated_user
from models.proxy import ProxyCreateInput, ProxyDocument
from models.service import ServiceEndpointDocument
from services.mitmproxy.examples import BUILTIN_EXAMPLES
from services.mitmproxy.validator import ProxyCodeValidationError
from starlette.websockets import WebSocketDisconnect from starlette.websockets import WebSocketDisconnect
proxy_router_module = importlib.import_module("api.proxy.router") from app.core.security import get_auth_context, require_authenticated_user
from app.models.health import HealthSnapshot
from app.models.proxy import ProxyCreateInput, ProxyDocument
from app.models.service import ServiceEndpointDocument
from app.services.mitmproxy.templates import BUILTIN_TEMPLATES
from app.services.mitmproxy.validator import ProxyCodeValidationError
proxy_router_module = importlib.import_module("app.api.proxy.router")
def make_proxy_document( def make_proxy_document(
@@ -28,6 +32,21 @@ def make_proxy_document(
) )
def make_health_snapshot(
proxy_port: int,
*,
overall: str = "healthy",
timestamp: datetime | None = None,
) -> HealthSnapshot:
return HealthSnapshot(
proxy_port=proxy_port,
timestamp=timestamp or datetime.now(UTC),
enabled=True,
overall=overall,
checks=[],
)
def test_proxy_requires_auth_without_dependency_overrides(client): def test_proxy_requires_auth_without_dependency_overrides(client):
client.app.dependency_overrides.pop(require_authenticated_user, None) client.app.dependency_overrides.pop(require_authenticated_user, None)
client.app.dependency_overrides.pop(get_auth_context, None) client.app.dependency_overrides.pop(get_auth_context, None)
@@ -80,6 +99,7 @@ def test_list_services_uses_query_scan_root(client, monkeypatch):
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == { assert response.json() == {
"backend_port": 3671,
"scan_root": "/root", "scan_root": "/root",
"services": [ "services": [
{"name": "echo", "ports": [9081], "bindings": [], "folder": "/root/play"} {"name": "echo", "ports": [9081], "bindings": [], "folder": "/root/play"}
@@ -105,10 +125,10 @@ def test_get_proxy_returns_404_when_not_found(client, monkeypatch: object):
def test_create_proxy_returns_409_on_port_conflict(client, monkeypatch: object): def test_create_proxy_returns_409_on_port_conflict(client, monkeypatch: object):
existing = make_proxy_document(proxy_port=3671) existing = make_proxy_document(proxy_port=4088)
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None: async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
assert proxy_port == 3671 assert proxy_port == 4088
return existing return existing
monkeypatch.setattr( monkeypatch.setattr(
@@ -120,7 +140,7 @@ def test_create_proxy_returns_409_on_port_conflict(client, monkeypatch: object):
response = client.post( response = client.post(
"/proxy", "/proxy",
json={ json={
"proxy_port": 3671, "proxy_port": 4088,
"name": "new", "name": "new",
"code": "abc", "code": "abc",
"service": {"port": 8080}, "service": {"port": 8080},
@@ -128,7 +148,44 @@ def test_create_proxy_returns_409_on_port_conflict(client, monkeypatch: object):
) )
assert response.status_code == 409 assert response.status_code == 409
assert response.json()["detail"] == "Proxy already exists on port 3671" assert response.json()["detail"] == "Proxy already exists on port 4088"
def test_create_proxy_rejects_backend_api_port(client, monkeypatch: object):
create_called = False
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
assert proxy_port == 3671
return None
async def fake_create_proxy(payload: ProxyCreateInput) -> ProxyDocument:
nonlocal create_called
create_called = True
return make_proxy_document(proxy_port=payload.proxy_port)
monkeypatch.setattr(
proxy_router_module,
"get_proxy_by_port",
fake_get_proxy_by_port,
)
monkeypatch.setattr(proxy_router_module, "create_proxy", fake_create_proxy)
response = client.post(
"/proxy",
json={
"proxy_port": 3671,
"name": "backend-clash",
"code": "abc",
"service": {"port": 8080},
},
)
assert response.status_code == 422
assert response.json()["detail"] == (
"Proxy port 3671 is reserved for the backend API "
"and cannot be used by mitmdump."
)
assert create_called is False
def test_create_proxy_returns_created_document(client, monkeypatch: object): def test_create_proxy_returns_created_document(client, monkeypatch: object):
@@ -470,6 +527,297 @@ def test_proxy_logs_socket_streams_snapshot_and_append(client, monkeypatch, tmp_
assert appended == {"type": "append", "log": "line two\n"} assert appended == {"type": "append", "log": "line two\n"}
def test_proxy_logs_socket_resets_snapshot_after_log_rotation(
client,
monkeypatch,
tmp_path,
):
log_path = tmp_path / "proxy.log"
log_path.write_text("line one\n", encoding="utf-8")
proxy = make_proxy_document(proxy_port=4040, log_path=str(log_path))
async def fake_authenticate(_: object) -> None:
return None
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
assert proxy_port == 4040
return proxy
monkeypatch.setattr(
proxy_router_module,
"authenticate_proxy_socket",
fake_authenticate,
)
monkeypatch.setattr(
proxy_router_module,
"get_proxy_by_port",
fake_get_proxy_by_port,
)
with client.websocket_connect("/proxy/4040/logs/ws?token=test-token") as socket:
snapshot = socket.receive_json()
assert snapshot == {"type": "snapshot", "log": "line one\n"}
log_path.write_text("fresh start\n", encoding="utf-8")
rotated = socket.receive_json()
assert rotated == {"type": "snapshot", "log": "fresh start\n"}
with log_path.open("a", encoding="utf-8") as handle:
handle.write("line two\n")
handle.flush()
appended = socket.receive_json()
assert appended == {"type": "append", "log": "line two\n"}
def test_remove_proxy_stops_runtime_and_cleans_artifacts(client, monkeypatch: object):
proxy = make_proxy_document(
proxy_port=4040,
log_path="/tmp/theplug/logs/4040.log",
).model_copy(
update={
"script_path": "/tmp/theplug/addons/4040.py",
"config_path": "/tmp/theplug/conf/4040/config.yaml",
}
)
captured: dict[str, object] = {}
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
assert proxy_port == 4040
return proxy
class FakeManager:
async def stop(self, document: ProxyDocument) -> dict[str, object]:
captured["stopped"] = document.proxy_port
return {
"status": "stopped",
"pid": None,
"service.upstream_port": None,
"last_error": None,
}
def cleanup_runtime_artifacts(self, document: ProxyDocument) -> None:
captured["cleaned"] = {
"proxy_port": document.proxy_port,
"script_path": document.script_path,
"config_path": document.config_path,
"log_path": document.log_path,
}
async def fake_delete_proxy(proxy_port: int) -> bool:
captured["deleted"] = proxy_port
return True
async def fake_delete_health_history(proxy_port: int) -> None:
captured["history_deleted"] = proxy_port
monkeypatch.setattr(
proxy_router_module,
"get_proxy_by_port",
fake_get_proxy_by_port,
)
monkeypatch.setattr(proxy_router_module, "manager", FakeManager())
monkeypatch.setattr(
proxy_router_module,
"delete_health_history",
fake_delete_health_history,
)
monkeypatch.setattr(proxy_router_module, "delete_proxy", fake_delete_proxy)
response = client.delete("/proxy/4040")
assert response.status_code == 204
assert captured == {
"stopped": 4040,
"cleaned": {
"proxy_port": 4040,
"script_path": "/tmp/theplug/addons/4040.py",
"config_path": "/tmp/theplug/conf/4040/config.yaml",
"log_path": "/tmp/theplug/logs/4040.log",
},
"deleted": 4040,
"history_deleted": 4040,
}
def test_proxy_health_runs_immediate_check_and_publishes(client, monkeypatch: object):
proxy = make_proxy_document(proxy_port=4040)
captured: dict[str, object] = {}
snapshot = make_health_snapshot(4040)
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
assert proxy_port == 4040
return proxy
async def fake_check_proxy(document: ProxyDocument):
captured["checked"] = document.proxy_port
return snapshot
async def fake_record_health_sample(document):
captured["recorded"] = document
return document
class FakeHealthMonitor:
async def publish(self, proxy_port: int, document: HealthSnapshot) -> None:
captured["published"] = (proxy_port, document)
monkeypatch.setattr(
proxy_router_module,
"get_proxy_by_port",
fake_get_proxy_by_port,
)
monkeypatch.setattr(proxy_router_module, "check_proxy", fake_check_proxy)
monkeypatch.setattr(
proxy_router_module,
"record_health_sample",
fake_record_health_sample,
)
monkeypatch.setattr(proxy_router_module, "health_monitor", FakeHealthMonitor())
response = client.get("/proxy/4040/health")
assert response.status_code == 200
assert response.json()["overall"] == "healthy"
assert captured["checked"] == 4040
assert captured["recorded"] == snapshot
assert captured["published"] == (4040, snapshot)
def test_proxy_health_history_returns_latest_and_history(client, monkeypatch: object):
proxy = make_proxy_document(proxy_port=4040)
timestamp = datetime.now(UTC)
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
assert proxy_port == 4040
return proxy
async def fake_get_latest_health_sample(proxy_port: int):
assert proxy_port == 4040
return make_health_snapshot(4040, timestamp=timestamp)
async def fake_list_health_history(proxy_port: int, limit: int = 40):
assert proxy_port == 4040
assert limit == 40
return [make_health_snapshot(4040, timestamp=timestamp)]
monkeypatch.setattr(
proxy_router_module,
"get_proxy_by_port",
fake_get_proxy_by_port,
)
monkeypatch.setattr(
proxy_router_module,
"get_latest_health_sample",
fake_get_latest_health_sample,
)
monkeypatch.setattr(
proxy_router_module,
"list_health_history",
fake_list_health_history,
)
response = client.get("/proxy/4040/health/history")
assert response.status_code == 200
body = response.json()
assert body["proxy_port"] == 4040
assert body["latest"]["overall"] == "healthy"
assert len(body["history"]) == 1
def test_proxy_health_history_clamps_requested_limit(client, monkeypatch: object):
proxy = make_proxy_document(proxy_port=4040)
captured: dict[str, int] = {}
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
assert proxy_port == 4040
return proxy
async def fake_get_latest_health_sample(_proxy_port: int) -> HealthSnapshot | None:
return None
async def fake_list_health_history(proxy_port: int, limit: int = 40):
assert proxy_port == 4040
captured["limit"] = limit
return []
monkeypatch.setattr(
proxy_router_module,
"get_proxy_by_port",
fake_get_proxy_by_port,
)
monkeypatch.setattr(
proxy_router_module,
"get_latest_health_sample",
fake_get_latest_health_sample,
)
monkeypatch.setattr(
proxy_router_module,
"list_health_history",
fake_list_health_history,
)
response = client.get("/proxy/4040/health/history", params={"limit": 9999})
assert response.status_code == 200
assert captured["limit"] == 200
def test_proxy_health_socket_streams_latest_and_updates(client, monkeypatch: object):
proxy = make_proxy_document(proxy_port=4040)
timestamp = datetime.now(UTC)
follow_up = make_health_snapshot(4040, overall="unhealthy")
captured: dict[str, object] = {}
async def fake_authenticate(_: object) -> None:
return None
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
assert proxy_port == 4040
return proxy
async def fake_get_latest_health_sample(proxy_port: int):
assert proxy_port == 4040
return make_health_snapshot(4040, timestamp=timestamp)
class FakeHealthMonitor:
def subscribe(self, proxy_port: int):
assert proxy_port == 4040
queue = asyncio.Queue()
queue.put_nowait(follow_up)
return queue
def unsubscribe(self, proxy_port: int, _queue) -> None:
assert proxy_port == 4040
captured["unsubscribed"] = True
monkeypatch.setattr(
proxy_router_module,
"authenticate_proxy_socket",
fake_authenticate,
)
monkeypatch.setattr(
proxy_router_module,
"get_proxy_by_port",
fake_get_proxy_by_port,
)
monkeypatch.setattr(
proxy_router_module,
"get_latest_health_sample",
fake_get_latest_health_sample,
)
monkeypatch.setattr(proxy_router_module, "health_monitor", FakeHealthMonitor())
with client.websocket_connect("/proxy/4040/health/ws?token=test-token") as socket:
first = socket.receive_json()
assert first["overall"] == "healthy"
second = socket.receive_json()
assert second["overall"] == "unhealthy"
assert captured["unsubscribed"] is True
def test_stop_proxy_updates_runtime_state(client, monkeypatch: object): def test_stop_proxy_updates_runtime_state(client, monkeypatch: object):
proxy = make_proxy_document(proxy_port=4040).model_copy( proxy = make_proxy_document(proxy_port=4040).model_copy(
update={"status": "running", "pid": 1234} update={"status": "running", "pid": 1234}
@@ -515,37 +863,46 @@ def test_stop_proxy_updates_runtime_state(client, monkeypatch: object):
assert response.json()["status"] == "stopped" assert response.json()["status"] == "stopped"
def test_list_mitmproxy_examples_returns_builtins_and_custom(client, monkeypatch): def test_list_mitmproxy_templates_returns_builtins_and_custom(client, monkeypatch):
async def fake_list_custom_examples(): async def fake_list_custom_templates():
return [] return []
monkeypatch.setattr( monkeypatch.setattr(
proxy_router_module, proxy_router_module,
"list_custom_examples", "list_custom_templates",
fake_list_custom_examples, fake_list_custom_templates,
) )
response = client.get("/proxy/mitmproxy/examples") response = client.get("/proxy/mitmproxy/templates")
assert response.status_code == 200 assert response.status_code == 200
body = response.json() body = response.json()
assert len(body) == len(BUILTIN_EXAMPLES) assert len(body) == len(BUILTIN_TEMPLATES)
assert body[0]["built_in"] is True assert body[0]["built_in"] is True
assert body[0]["protocol"] in {"http", "tcp"}
assert isinstance(body[0]["tags"], list)
assert body[0]["action"] in {"block", "rewrite"}
def test_create_mitmproxy_example_validates_and_persists(client, monkeypatch): def test_create_mitmproxy_template_validates_and_persists(client, monkeypatch):
captured = {} captured = {}
async def fake_validate_proxy_code(code: str) -> None: async def fake_validate_proxy_code(code: str) -> None:
captured["validated"] = code captured["validated"] = code
async def fake_create_custom_example(payload): async def fake_create_custom_template(payload):
captured["payload"] = payload captured["payload"] = payload
return { return {
"id": payload.id, "id": payload.id,
"name": payload.name, "name": payload.name,
"description": payload.description, "description": payload.description,
"category": payload.category, "category": payload.category,
"protocol": payload.protocol,
"family": payload.family,
"tags": payload.tags,
"action": payload.action,
"summary": payload.summary,
"behavior": payload.behavior,
"code": payload.code, "code": payload.code,
"source_url": payload.source_url, "source_url": payload.source_url,
"built_in": False, "built_in": False,
@@ -558,21 +915,28 @@ def test_create_mitmproxy_example_validates_and_persists(client, monkeypatch):
) )
monkeypatch.setattr( monkeypatch.setattr(
proxy_router_module, proxy_router_module,
"create_custom_example", "create_custom_template",
fake_create_custom_example, fake_create_custom_template,
) )
response = client.post( response = client.post(
"/proxy/mitmproxy/examples", "/proxy/mitmproxy/templates",
json={ json={
"id": "custom-header", "id": "custom-header",
"name": "Custom header", "name": "Custom header",
"description": "Adds a custom response header", "description": "Adds a custom response header",
"category": "http", "category": "http",
"protocol": "http",
"family": "custom",
"tags": ["observe"],
"action": "custom",
"summary": "Saved custom template.",
"behavior": "Applies the current script as-is.",
"code": "def response(flow):\n pass\n", "code": "def response(flow):\n pass\n",
}, },
) )
assert response.status_code == 201 assert response.status_code == 201
assert response.json()["id"] == "custom-header" assert response.json()["id"] == "custom-header"
assert response.json()["protocol"] == "http"
assert captured["validated"] == "def response(flow):\n pass\n" assert captured["validated"] == "def response(flow):\n pass\n"
+110 -8
View File
@@ -1,9 +1,14 @@
import asyncio
import os import os
from collections.abc import Iterator
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from pathlib import Path
from uuid import uuid4
import pytest import pytest
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from motor.motor_asyncio import AsyncIOMotorClient
os.environ.setdefault( os.environ.setdefault(
"MONGODB_URI", "MONGODB_URI",
@@ -19,7 +24,7 @@ os.environ.setdefault("AUTH_PASSWORD", "admin")
@pytest.fixture @pytest.fixture
def fake_user(): def fake_user():
from models.auth import AuthUserDocument from app.models.auth import AuthUserDocument
return AuthUserDocument( return AuthUserDocument(
username="admin", username="admin",
@@ -29,8 +34,8 @@ def fake_user():
@pytest.fixture @pytest.fixture
def auth_context(fake_user): def auth_context(fake_user):
from core.security import AuthContext from app.core.security import AuthContext
from models.auth import AccessTokenPayload from app.models.auth import AccessTokenPayload
exp = int((datetime.now(UTC) + timedelta(minutes=720)).timestamp()) exp = int((datetime.now(UTC) + timedelta(minutes=720)).timestamp())
payload = AccessTokenPayload(sub=fake_user.username, jti="test-jti", exp=exp) payload = AccessTokenPayload(sub=fake_user.username, jti="test-jti", exp=exp)
@@ -39,11 +44,15 @@ def auth_context(fake_user):
@pytest.fixture @pytest.fixture
def app(fake_user, auth_context) -> FastAPI: def app(fake_user, auth_context) -> FastAPI:
from api.auth.router import router as auth_router from app.api.auth.router import router as auth_router
from api.editor.router import router as editor_router from app.api.editor.router import router as editor_router
from api.proxy.router import router as proxy_router from app.api.proxy.router import router as proxy_router
from core.security import AuthContext, get_auth_context, require_authenticated_user from app.core.security import (
from models.auth import AuthUserDocument AuthContext,
get_auth_context,
require_authenticated_user,
)
from app.models.auth import AuthUserDocument
app_instance = FastAPI() app_instance = FastAPI()
app_instance.include_router(auth_router) app_instance.include_router(auth_router)
@@ -67,3 +76,96 @@ def app(fake_user, auth_context) -> FastAPI:
def client(app: FastAPI): def client(app: FastAPI):
with TestClient(app) as test_client: with TestClient(app) as test_client:
yield test_client yield test_client
async def _mongo_ping(uri: str) -> str | None:
client = AsyncIOMotorClient(uri, serverSelectionTimeoutMS=500)
try:
await client.admin.command("ping")
except Exception as error: # pragma: no cover - exercised via skip path
return str(error)
finally:
client.close()
return None
async def _drop_database(uri: str, database_name: str) -> None:
client = AsyncIOMotorClient(uri, serverSelectionTimeoutMS=500)
try:
await client.drop_database(database_name)
finally:
client.close()
@pytest.fixture
def integration_runtime(tmp_path: Path) -> Iterator[dict[str, str]]:
from app.core.config import settings
from app.services.mitmproxy.manager import manager
mongodb_uri = settings.MONGODB_URI
ping_error = asyncio.run(_mongo_ping(mongodb_uri))
if ping_error is not None:
pytest.skip(f"MongoDB is not available for integration tests: {ping_error}")
db_name = f"theplug_test_{uuid4().hex}"
scan_root = tmp_path / "scan-root"
runtime_root = tmp_path / "runtime"
scan_root.mkdir(parents=True, exist_ok=True)
runtime_root.mkdir(parents=True, exist_ok=True)
original_db_name = settings.MONGODB_DB_NAME
original_scan_root = settings.SERVICE_SCAN_ROOT
original_auth_username = settings.AUTH_USERNAME
original_auth_password = settings.AUTH_PASSWORD
original_manager_work_dir = manager.work_dir
settings.MONGODB_DB_NAME = db_name
settings.SERVICE_SCAN_ROOT = str(scan_root)
settings.AUTH_USERNAME = "integration-admin"
settings.AUTH_PASSWORD = "integration-admin"
manager.work_dir = runtime_root
try:
yield {
"db_name": db_name,
"scan_root": str(scan_root),
"runtime_root": str(runtime_root),
}
finally:
manager.work_dir = original_manager_work_dir
manager.processes.clear()
manager.attached_pids.clear()
settings.MONGODB_DB_NAME = original_db_name
settings.SERVICE_SCAN_ROOT = original_scan_root
settings.AUTH_USERNAME = original_auth_username
settings.AUTH_PASSWORD = original_auth_password
asyncio.run(_drop_database(mongodb_uri, db_name))
@pytest.fixture
def real_app(integration_runtime) -> FastAPI:
from app.main import app as real_app_instance
real_app_instance.dependency_overrides.clear()
return real_app_instance
@pytest.fixture
def real_client(real_app: FastAPI):
with TestClient(real_app) as test_client:
yield test_client
@pytest.fixture
def auth_token(real_client: TestClient) -> str:
response = real_client.post(
"/auth/login",
json={"username": "integration-admin", "password": "integration-admin"},
)
assert response.status_code == 200
return response.json()["access_token"]
@pytest.fixture
def auth_headers(auth_token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {auth_token}"}
@@ -0,0 +1,25 @@
import pytest
pytestmark = [pytest.mark.integration, pytest.mark.api]
def test_login_logout_revokes_real_token(real_client):
login_response = real_client.post(
"/auth/login",
json={"username": "integration-admin", "password": "integration-admin"},
)
assert login_response.status_code == 200
token = login_response.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
list_response = real_client.get("/proxy", headers=headers)
assert list_response.status_code == 200
assert list_response.json() == []
logout_response = real_client.post("/auth/logout", headers=headers)
assert logout_response.status_code == 204
revoked_response = real_client.get("/proxy", headers=headers)
assert revoked_response.status_code == 401
assert revoked_response.json()["detail"] == "Could not validate credentials"
@@ -0,0 +1,80 @@
import pytest
pytestmark = [pytest.mark.integration, pytest.mark.api]
VALID_PROXY_CODE = """def request(flow):
return None
"""
def test_proxy_crud_round_trip_uses_real_app_and_mongo(real_client, auth_headers):
create_response = real_client.post(
"/proxy",
headers=auth_headers,
json={
"proxy_port": 49081,
"name": "integration-echo",
"code": VALID_PROXY_CODE,
"protocol": "http",
"service": {
"host": "127.0.0.1",
"port": 49082,
},
"healthcheck": {
"enabled": True,
"path": "/healthz",
"benchmark_enabled": True,
},
},
)
assert create_response.status_code == 201
created = create_response.json()
assert created["proxy_port"] == 49081
assert created["name"] == "integration-echo"
assert created["healthcheck"]["benchmark_enabled"] is True
list_response = real_client.get("/proxy", headers=auth_headers)
assert list_response.status_code == 200
assert [item["proxy_port"] for item in list_response.json()] == [49081]
get_response = real_client.get("/proxy/49081", headers=auth_headers)
assert get_response.status_code == 200
assert get_response.json()["service"]["port"] == 49082
update_response = real_client.put(
"/proxy/49081",
headers=auth_headers,
json={
"name": "integration-echo-updated",
"code": VALID_PROXY_CODE.replace("None", '"updated"'),
"healthcheck": {
"enabled": True,
"path": "/ready",
"benchmark_enabled": False,
},
},
)
assert update_response.status_code == 200
updated = update_response.json()
assert updated["name"] == "integration-echo-updated"
assert updated["code"].endswith('return "updated"\n')
assert updated["healthcheck"]["path"] == "/ready"
assert updated["healthcheck"]["benchmark_enabled"] is False
history_response = real_client.get(
"/proxy/49081/health/history",
headers=auth_headers,
)
assert history_response.status_code == 200
history_payload = history_response.json()
assert history_payload["proxy_port"] == 49081
assert history_payload["latest"] is None
assert history_payload["history"] == []
delete_response = real_client.delete("/proxy/49081", headers=auth_headers)
assert delete_response.status_code == 204
missing_response = real_client.get("/proxy/49081", headers=auth_headers)
assert missing_response.status_code == 404
@@ -1,6 +1,8 @@
import asyncio
from pathlib import Path from pathlib import Path
from integrations.docker.compose import get_all_services_in_dir from app.integrations.docker.compose import get_all_services_in_dir
from app.integrations.docker.editor import change_port
def test_get_all_services_in_dir_scans_immediate_children(tmp_path: Path): def test_get_all_services_in_dir_scans_immediate_children(tmp_path: Path):
@@ -48,3 +50,137 @@ services:
services = get_all_services_in_dir(root) services = get_all_services_in_dir(root)
assert services == [] assert services == []
def test_get_all_services_in_dir_returns_empty_when_root_is_inaccessible(
tmp_path: Path,
monkeypatch,
):
root = tmp_path / "workspace"
root.mkdir()
original_iterdir = Path.iterdir
def fake_iterdir(self: Path):
if self == root:
raise PermissionError("denied")
return original_iterdir(self)
monkeypatch.setattr(Path, "iterdir", fake_iterdir)
assert get_all_services_in_dir(root) == []
def test_get_all_services_in_dir_skips_inaccessible_child_folder(
tmp_path: Path,
monkeypatch,
):
root = tmp_path / "workspace"
allowed = root / "allowed"
blocked = root / "blocked"
allowed.mkdir(parents=True)
blocked.mkdir(parents=True)
(allowed / "docker-compose.yml").write_text(
"""
services:
echo:
image: hashicorp/http-echo:1.0
ports:
- "9081:5678"
""".strip(),
encoding="utf-8",
)
original_is_dir = Path.is_dir
def fake_is_dir(self: Path):
if self == blocked:
raise PermissionError("denied")
return original_is_dir(self)
monkeypatch.setattr(Path, "is_dir", fake_is_dir)
services = get_all_services_in_dir(root)
assert services == [
{
"name": "echo",
"ports": [9081],
"bindings": [{"published": 9081, "host": None}],
"folder": allowed,
}
]
def test_change_port_restores_original_compose_when_reload_fails(
tmp_path: Path,
monkeypatch,
):
folder = tmp_path / "workspace"
folder.mkdir()
compose_path = folder / "docker-compose.yml"
original = """
services:
echo:
image: hashicorp/http-echo:1.0
ports:
- "9081:5678"
""".strip()
compose_path.write_text(original, encoding="utf-8")
async def fake_run_docker_command(
_file: Path,
_command: list[str],
) -> dict | None:
return {"status": "error", "reason": "boom"}
monkeypatch.setattr(
"app.integrations.docker.editor.run_docker_command",
fake_run_docker_command,
)
changed = asyncio.run(change_port("echo", folder, 9081, 54001))
assert changed is False
assert compose_path.read_text(encoding="utf-8") == original
assert (folder / "docker-compose.bak.yml").exists() is False
def test_change_port_restores_original_compose_when_reload_raises(
tmp_path: Path,
monkeypatch,
):
folder = tmp_path / "workspace"
folder.mkdir()
compose_path = folder / "docker-compose.yml"
original = """
services:
echo:
image: hashicorp/http-echo:1.0
ports:
- "127.0.0.1:54001:5678"
""".strip()
compose_path.write_text(original, encoding="utf-8")
async def fake_run_docker_command(
_file: Path,
_command: list[str],
) -> dict | None:
raise RuntimeError("docker unavailable")
monkeypatch.setattr(
"app.integrations.docker.editor.run_docker_command",
fake_run_docker_command,
)
changed = asyncio.run(
change_port(
"echo",
folder,
54001,
9081,
host_to=None,
rewrite_host=True,
)
)
assert changed is False
assert compose_path.read_text(encoding="utf-8") == original
@@ -1,4 +1,4 @@
from integrations.docker.ports import _get_service_binding, _replace_port_in_mapping from app.integrations.docker.ports import _get_service_binding, _replace_port_in_mapping
def test_get_service_binding_includes_loopback_hosts(): def test_get_service_binding_includes_loopback_hosts():
+39 -5
View File
@@ -1,8 +1,9 @@
import asyncio import asyncio
from models.proxy import ProxyDocument from app.models.health import HealthSnapshot
from models.service import ServiceEndpointDocument from app.models.proxy import ProxyDocument
from services.mitmproxy.health import check_proxy from app.models.service import ServiceEndpointDocument
from app.services.mitmproxy.health import check_proxy
def test_check_proxy_reports_upstream_and_proxy_healthy(): def test_check_proxy_reports_upstream_and_proxy_healthy():
@@ -26,11 +27,44 @@ def test_check_proxy_reports_upstream_and_proxy_healthy():
await upstream.wait_closed() await upstream.wait_closed()
await proxy.wait_closed() await proxy.wait_closed()
assert health["overall"] == "healthy" assert isinstance(health, HealthSnapshot)
assert [check["target"] for check in health["checks"]] == [ assert health.overall == "healthy"
assert [check.target for check in health.checks] == [
"upstream", "upstream",
"proxy", "proxy",
] ]
assert all(check.duration_ms is None for check in health.checks)
asyncio.run(run())
def test_check_proxy_reports_benchmark_latency_when_enabled():
async def run():
upstream = await asyncio.start_server(http_handler, "127.0.0.1", 0)
proxy = await asyncio.start_server(http_handler, "127.0.0.1", 0)
try:
upstream_port = upstream.sockets[0].getsockname()[1]
proxy_port = proxy.sockets[0].getsockname()[1]
document = ProxyDocument(
proxy_port=proxy_port,
name="health",
code="",
healthcheck={"benchmark_enabled": True},
service=ServiceEndpointDocument(port=upstream_port),
)
health = await check_proxy(document)
finally:
upstream.close()
proxy.close()
await upstream.wait_closed()
await proxy.wait_closed()
assert isinstance(health, HealthSnapshot)
assert all(
check.duration_ms is not None and check.duration_ms >= 0
for check in health.checks
)
asyncio.run(run()) asyncio.run(run())
@@ -0,0 +1,218 @@
import asyncio
from datetime import UTC, datetime
import pytest
from app.models.health import HealthSnapshot
from app.models.proxy import ProxyDocument
from app.models.service import ServiceEndpointDocument
from app.services.mitmproxy.health_monitor import HealthMonitor
def make_proxy(**updates):
data = {
"proxy_port": 8080,
"name": "svc",
"code": "",
"status": "running",
"service": ServiceEndpointDocument(port=9000),
}
data.update(updates)
return ProxyDocument(**data)
def make_snapshot(proxy_port: int, *, overall: str = "healthy") -> HealthSnapshot:
return HealthSnapshot(
proxy_port=proxy_port,
timestamp=datetime.now(UTC),
enabled=True,
overall=overall,
checks=[],
)
def test_run_cycle_records_and_publishes_running_healthchecks(monkeypatch):
monitor = HealthMonitor()
proxy = make_proxy()
captured: dict[str, object] = {}
snapshot = make_snapshot(8080)
async def fake_list_proxies():
return [proxy]
async def fake_check_proxy(document):
captured["checked"] = document.proxy_port
return snapshot
async def fake_record_health_sample(document):
captured["recorded"] = document
return document
async def fake_publish(proxy_port, document):
captured["published"] = (proxy_port, document)
monkeypatch.setattr(
"app.services.mitmproxy.health_monitor.list_proxies",
fake_list_proxies,
)
monkeypatch.setattr(
"app.services.mitmproxy.health_monitor.check_proxy",
fake_check_proxy,
)
monkeypatch.setattr(
"app.services.mitmproxy.health_monitor.record_health_sample",
fake_record_health_sample,
)
monkeypatch.setattr(monitor, "publish", fake_publish)
asyncio.run(monitor.run_cycle())
assert captured["checked"] == 8080
assert captured["recorded"] == snapshot
assert captured["published"] == (8080, snapshot)
def test_run_cycle_skips_stopped_or_disabled_proxies(monkeypatch):
monitor = HealthMonitor()
running_disabled = make_proxy(healthcheck={"enabled": False})
stopped = make_proxy(proxy_port=8081, status="stopped")
called = False
async def fake_list_proxies():
return [running_disabled, stopped]
async def fake_check_proxy(_document):
nonlocal called
called = True
return make_snapshot(8080)
monkeypatch.setattr(
"app.services.mitmproxy.health_monitor.list_proxies",
fake_list_proxies,
)
monkeypatch.setattr(
"app.services.mitmproxy.health_monitor.check_proxy",
fake_check_proxy,
)
asyncio.run(monitor.run_cycle())
assert called is False
def test_run_cycle_checks_proxies_in_parallel(monkeypatch):
monitor = HealthMonitor()
proxies = [
make_proxy(proxy_port=8080),
make_proxy(proxy_port=8081),
make_proxy(proxy_port=8082),
]
started: list[int] = []
release = asyncio.Event()
async def fake_list_proxies():
return proxies
async def fake_check_proxy(document):
started.append(document.proxy_port)
if len(started) == len(proxies):
release.set()
await release.wait()
return make_snapshot(document.proxy_port)
async def fake_record_health_sample(document):
return document
async def fake_publish(_proxy_port, _document):
return None
monkeypatch.setattr(
"app.services.mitmproxy.health_monitor.list_proxies",
fake_list_proxies,
)
monkeypatch.setattr(
"app.services.mitmproxy.health_monitor.check_proxy",
fake_check_proxy,
)
monkeypatch.setattr(
"app.services.mitmproxy.health_monitor.record_health_sample",
fake_record_health_sample,
)
monkeypatch.setattr(monitor, "publish", fake_publish)
asyncio.run(asyncio.wait_for(monitor.run_cycle(), timeout=1))
assert started == [8080, 8081, 8082]
def test_run_cycle_continues_when_one_proxy_check_fails(monkeypatch):
monitor = HealthMonitor()
proxies = [
make_proxy(proxy_port=8080),
make_proxy(proxy_port=8081),
]
captured: list[int] = []
async def fake_list_proxies():
return proxies
async def fake_check_proxy(document):
if document.proxy_port == 8080:
raise RuntimeError("boom")
return make_snapshot(document.proxy_port)
async def fake_record_health_sample(document):
captured.append(document.proxy_port)
return document
async def fake_publish(proxy_port, _document):
captured.append(proxy_port + 10_000)
monkeypatch.setattr(
"app.services.mitmproxy.health_monitor.list_proxies",
fake_list_proxies,
)
monkeypatch.setattr(
"app.services.mitmproxy.health_monitor.check_proxy",
fake_check_proxy,
)
monkeypatch.setattr(
"app.services.mitmproxy.health_monitor.record_health_sample",
fake_record_health_sample,
)
monkeypatch.setattr(monitor, "publish", fake_publish)
asyncio.run(monitor.run_cycle())
assert captured == [8081, 18081]
def test_run_retries_after_cycle_failure(monkeypatch):
monitor = HealthMonitor()
calls = {"run_cycle": 0}
sleep_calls: list[float] = []
async def fake_run_cycle():
calls["run_cycle"] += 1
if calls["run_cycle"] == 1:
raise RuntimeError("temporary failure")
raise asyncio.CancelledError
async def fake_sleep(seconds):
sleep_calls.append(seconds)
return None
monkeypatch.setattr(monitor, "run_cycle", fake_run_cycle)
monkeypatch.setattr(
"app.services.mitmproxy.health_monitor.asyncio.sleep",
fake_sleep,
)
try:
asyncio.run(monitor.run())
except asyncio.CancelledError:
pass
assert calls["run_cycle"] == 2
assert len(sleep_calls) == 1
assert sleep_calls[0] == pytest.approx(1.0, abs=0.01)
@@ -1,9 +1,11 @@
import asyncio import asyncio
from pathlib import Path from pathlib import Path
from models.proxy import ProxyDocument import pytest
from models.service import ServiceEndpointDocument
from services.mitmproxy.manager import MitmdumpManager from app.models.proxy import ProxyDocument
from app.models.service import ServiceEndpointDocument
from app.services.mitmproxy.manager import MitmdumpError, MitmdumpManager
def make_proxy(**updates): def make_proxy(**updates):
@@ -65,6 +67,20 @@ def test_regular_mode_uses_proxy_port_as_listen_port():
assert command[-2:] == ["--listen-port", "8080"] assert command[-2:] == ["--listen-port", "8080"]
def test_start_rejects_reserved_backend_port():
manager = MitmdumpManager()
proxy = make_proxy(proxy_port=3671)
async def run():
await manager.start(proxy)
with pytest.raises(
MitmdumpError,
match="Proxy port 3671 is reserved for the backend API",
):
asyncio.run(run())
def test_running_document_without_pid_is_treated_as_stopped(): def test_running_document_without_pid_is_treated_as_stopped():
manager = MitmdumpManager() manager = MitmdumpManager()
proxy = make_proxy(status="running", pid=None) proxy = make_proxy(status="running", pid=None)
@@ -167,3 +183,99 @@ def test_reconcile_restores_upstream_when_process_is_gone():
"service.upstream_port": None, "service.upstream_port": None,
"last_error": None, "last_error": None,
} }
def test_cleanup_runtime_artifacts_removes_generated_files(tmp_path: Path):
manager = MitmdumpManager(work_dir=str(tmp_path / "theplug"))
proxy = make_proxy(
script_path=str(manager.script_path_for(make_proxy())),
config_path=str(manager.config_path_for(make_proxy())),
log_path=str(manager.log_path_for(make_proxy())),
)
script_path = Path(proxy.script_path)
config_path = Path(proxy.config_path)
log_path = Path(proxy.log_path)
script_path.parent.mkdir(parents=True, exist_ok=True)
config_path.parent.mkdir(parents=True, exist_ok=True)
log_path.parent.mkdir(parents=True, exist_ok=True)
script_path.write_text("print('hello')", encoding="utf-8")
config_path.write_text("block_global: false\n", encoding="utf-8")
log_path.write_text("old log\n", encoding="utf-8")
manager.cleanup_runtime_artifacts(proxy)
assert script_path.exists() is False
assert log_path.exists() is False
assert config_path.exists() is False
assert manager.conf_dir_for(proxy).exists() is False
def test_prepare_upstream_retries_until_port_remap_succeeds(monkeypatch):
manager = MitmdumpManager()
proxy = make_proxy(
mode="reverse",
service=ServiceEndpointDocument(
port=9000,
name="echo",
folder="/tmp/playground",
),
)
attempted_ports: list[int] = []
candidate_ports = iter([54001, 54002, 54003])
async def fake_change_port(
_service_name,
_folder,
_port_from,
port_to,
**_kwargs,
):
attempted_ports.append(port_to)
return port_to == 54003
monkeypatch.setattr(
"app.services.mitmproxy.manager.get_random_port",
lambda: next(candidate_ports),
)
monkeypatch.setattr(
"app.services.mitmproxy.manager.change_port",
fake_change_port,
)
upstream_port = asyncio.run(manager.prepare_upstream(proxy))
assert upstream_port == 54003
assert attempted_ports == [54001, 54002, 54003]
def test_prepare_upstream_fails_after_retry_budget(monkeypatch):
manager = MitmdumpManager()
manager.upstream_port_attempts = 3
proxy = make_proxy(
mode="reverse",
service=ServiceEndpointDocument(
port=9000,
name="echo",
folder="/tmp/playground",
),
)
candidate_ports = iter([54001, 54002, 54003])
async def fake_change_port(*_args, **_kwargs):
return False
monkeypatch.setattr(
"app.services.mitmproxy.manager.get_random_port",
lambda: next(candidate_ports),
)
monkeypatch.setattr(
"app.services.mitmproxy.manager.change_port",
fake_change_port,
)
with pytest.raises(
MitmdumpError,
match="Unable to move Docker service port after retries",
):
asyncio.run(manager.prepare_upstream(proxy))
@@ -1,5 +1,6 @@
import pytest import pytest
from services.mitmproxy.options import (
from app.services.mitmproxy.options import (
MitmOption, MitmOption,
parse_options, parse_options,
public_options, public_options,
+1 -1
View File
@@ -1,4 +1,4 @@
from services.editor import python_lsp from app.services.editor import python_lsp
def test_encode_message(): def test_encode_message():
+3 -3
View File
@@ -1,8 +1,8 @@
import asyncio import asyncio
from models.proxy import ProxyDocument from app.models.proxy import ProxyDocument
from models.service import ServiceEndpointDocument from app.models.service import ServiceEndpointDocument
from services.mitmproxy import recovery as recovery_module from app.services.mitmproxy import recovery as recovery_module
def make_proxy(**updates): def make_proxy(**updates):
+13
View File
@@ -0,0 +1,13 @@
from app.services.mitmproxy.templates import BUILTIN_TEMPLATES
def test_builtin_templates_use_four_space_indentation():
for template in BUILTIN_TEMPLATES:
for line in template.code.splitlines():
assert "\t" not in line, f"{template.id} contains a tab: {line!r}"
if not line.strip():
continue
indent = len(line) - len(line.lstrip(" "))
assert indent % 4 == 0, (
f"{template.id} has non-4-space indentation: {line!r}"
)
+2 -2
View File
@@ -1,10 +1,10 @@
from datetime import UTC, datetime from datetime import UTC, datetime
from core.security import create_access_token, decode_access_token from app.core.security import create_access_token, decode_access_token
def test_create_access_token_uses_twelve_hour_default_window(monkeypatch): def test_create_access_token_uses_twelve_hour_default_window(monkeypatch):
monkeypatch.setattr("core.security.settings.JWT_EXPIRE_MINUTES", 720) monkeypatch.setattr("app.core.security.settings.JWT_EXPIRE_MINUTES", 720)
issued_at = datetime.now(UTC) issued_at = datetime.now(UTC)
token = create_access_token(subject="admin") token = create_access_token(subject="admin")
+239
View File
@@ -0,0 +1,239 @@
# thePlug Codebase Audit
Date: 2026-05-06
Checks run:
- `cd backend && uv run pytest -q`
- `cd backend && uv run ruff check .`
- `cd backend && uv run basedpyright app`
- `npm --prefix frontend run build`
Summary:
- Functional checks currently pass: backend tests are green, Ruff is clean, frontend builds.
- Structural quality is much weaker than the green CI surface suggests.
- The biggest risks are runtime mutation safety around Docker compose rewrites, false confidence in the backend test suite, packaging/type-check fragility, and a very large amount of stateful coordination concentrated in single files.
## Findings
### Critical
#### 1. Docker port rewrite is not exception-safe and can leave challenge services mutated on disk
- Files:
- `backend/app/integrations/docker/editor.py:14-51`
- `backend/app/services/mitmproxy/manager.py:195-236`
- Why it matters:
- `change_port()` rewrites the compose file in place, then runs `docker compose up -d`.
- There is no `try/finally` around the file mutation path.
- If subprocess startup, YAML serialization, file writes, or any unexpected exception happens after the compose file has been rewritten, the original file is not guaranteed to be restored.
- In practice, a failed proxy start/stop/recovery can leave the challenge service permanently remapped until manual cleanup.
- Recommended fix:
- Make compose rewrite transactional: read once, write temp file, atomically replace, and always restore in `finally` on failure.
- Treat backup creation/restoration as part of a single transaction, not best effort.
- Add integration tests that force failures between rewrite and `docker compose up`.
#### 2. The backend test suite gives false confidence because most API behavior is mocked at the router seam
- Files:
- `backend/tests/conftest.py:40-69`
- `backend/tests/api/test_proxy.py`
- Why it matters:
- The API suite mounts a bespoke `FastAPI()` app instead of the real application lifecycle.
- Authentication is bypassed through dependency overrides.
- Core router behavior is tested by monkeypatching repository/service functions directly on the module under test.
- This catches serialization and status codes, but misses real dependency wiring, startup/shutdown behavior, Mongo access, WebSocket/auth interplay, and real process/runtime interactions.
- Recommended fix:
- Split tests into unit, integration, and system/perf layers.
- Keep monkeypatch for narrow fault injection only.
- Add real-app integration tests with actual lifespan, real Mongo-backed repositories, and realistic temp runtime directories.
### High
#### 3. Log streaming drops data after truncation or rotation
- File:
- `backend/app/api/proxy/router.py:188-213`
- Why it matters:
- On truncation, the stream sets `offset = size`.
- If the log file is rotated or recreated with fresh content smaller than the old offset, the new bytes already present in the new file are skipped forever.
- This is exactly the kind of subtle bug that makes operators distrust live logs.
- Recommended fix:
- When `size < offset`, reset to `0` or resend a fresh snapshot and continue from the new file start.
- Add a regression test that simulates truncation/recreation with immediate new log bytes.
#### 4. The health monitor interval drifts under load
- File:
- `backend/app/services/mitmproxy/health_monitor.py:30-49`
- Why it matters:
- The loop sleeps after the cycle completes.
- Real interval becomes `check duration + HEALTHCHECK_INTERVAL_SECONDS`.
- As proxy count or timeout frequency rises, checks become slower and the dashboard silently becomes stale.
- Recommended fix:
- Switch to fixed-schedule cadence using monotonic deadlines.
- Track cycle duration and lag explicitly for observability.
#### 5. Port reassignment in reverse mode has a race window and no reservation mechanism
- Files:
- `backend/app/integrations/docker/ports.py:4-13`
- `backend/app/services/mitmproxy/manager.py:195-215`
- Why it matters:
- `get_random_port()` discovers a free port and returns it, but does not reserve it.
- Another process can claim the port before Docker rebinds the service.
- The failure mode is intermittent and hard to reproduce, which makes it a good candidate for production-only flakiness.
- Recommended fix:
- Move to a reservation strategy or let Docker select the port and then discover the assigned binding.
- At minimum, retry on bind collision and record the failure reason explicitly.
#### 6. Backend packaging/import structure is fragile and currently fails full static analysis badly
- Files:
- Broadly across `backend/app/**`
- Evidence:
- `uv run basedpyright app` reports `86 errors` and `243 warnings`.
- Why it matters:
- The codebase relies on `pythonpath = ["app"]` and implicit absolute imports such as `from core.config import settings`.
- This is brittle outside the current dev/test invocation shape and blocks meaningful type-checking from being trustworthy.
- Static type health is effectively unusable right now.
- Recommended fix:
- Convert backend imports to package-consistent absolute imports from `app...` or explicit relative imports.
- Add a real basedpyright target/config to CI once import hygiene is fixed.
#### 7. The frontend control plane is a God object that now owns too many concerns
- File:
- `frontend/src/composables/useControlPlane.ts` (`1534` LOC)
- Why it matters:
- One composable currently owns auth, routing, discovery, drafts, editor save flow, templates, logs, health, websocket reconnection, notices, modal state, and error routing.
- This is a high-risk shape for regressions because unrelated fixes share the same reactive graph and watcher set.
- State restoration and race-condition fixes will keep getting harder, not easier.
- Recommended fix:
- Split into focused composables:
- session/auth
- proxy workspace state
- discovery state
- logs stream state
- health stream state
- wizard/settings draft state
- Keep one thin composition root that wires them together.
#### 8. Full refresh is all-or-nothing, so one failing endpoint blocks unrelated UI surfaces
- File:
- `frontend/src/composables/useControlPlane.ts:648-673`
- Why it matters:
- `refreshAll()` uses one `Promise.all()` for proxies, services, options, and templates.
- If one endpoint fails, none of the others update.
- A temporary failure in options or templates can block the basic proxy/discovery view.
- Recommended fix:
- Separate critical and non-critical refreshes.
- Fetch proxies/services independently from options/templates and degrade per panel.
### Medium
#### 9. Template API and UI contract are inconsistent
- Files:
- `backend/app/models/template.py:22-39`
- `frontend/src/composables/useControlPlane.ts:1098-1126`
- `frontend/src/composables/useControlPlane.ts:483-516`
- Why it matters:
- Backend models allow arbitrary `protocol` values and default to `"custom"`.
- The UI only exposes `http` and `tcp` lists.
- A custom template persisted with any other protocol value becomes effectively invisible in the current UI.
- Recommended fix:
- Validate template protocol/category on the backend to match supported UI semantics.
- Or explicitly support additional categories in the UI instead of silently hiding them.
#### 10. Template behavior is inconsistent with a predictable “block” contract
- File:
- `backend/app/services/mitmproxy/templates.py`
- Why it matters:
- Built-in “block” templates do not behave consistently:
- some `flow.kill()`
- some return empty `200`
- some return `404`
- some return `413` or `405`
- This may be intentional per challenge type, but as a product surface it is hard to reason about and document.
- Recommended fix:
- Decide whether built-in templates are “semantic examples” or “operational policies”.
- If they are operational policies, define a small set of standard outcomes and label deviations explicitly.
#### 11. Synchronous filesystem work happens on the event loop in hot backend paths
- Files:
- `backend/app/services/mitmproxy/manager.py:287-303`
- `backend/app/api/proxy/router.py:177-180`
- `backend/app/integrations/docker/editor.py:39-51`
- Why it matters:
- `read_bytes`, `write_text`, YAML dump/write, and similar disk operations happen directly inside async request/runtime paths.
- This is acceptable at tiny scale, but it will block the event loop during heavier operator usage or larger logs/files.
- Recommended fix:
- Move blocking file I/O behind `asyncio.to_thread()` or a thin async wrapper for runtime/log/config file operations.
#### 12. Health history limit is caller-controlled without a server-side cap
- File:
- `backend/app/api/proxy/router.py:387-397`
- Why it matters:
- `limit` is accepted directly from query params and forwarded to Mongo.
- A large limit can create unnecessary memory and serialization pressure.
- Recommended fix:
- Clamp the value server-side, for example `1..200` or similar.
#### 13. `app.state.services` is stale mutable state with no real consumer value
- Files:
- `backend/app/main.py:47-56`
- `backend/app/api/proxy/router.py:221-230`
- Why it matters:
- Service discovery results are written into `app.state.services`, but the state is not used as a trusted cache anywhere meaningful.
- This adds mutable global state without clear ownership and can mislead future maintainers.
- Recommended fix:
- Remove it or turn it into an intentional cache with invalidation and a real read path.
#### 14. Docker compose editor leaves backups behind and does not manage them as lifecycle artifacts
- File:
- `backend/app/integrations/docker/editor.py:7-44`
- Why it matters:
- Backup files are created every time a port rewrite occurs.
- They are never cleaned up or versioned intentionally.
- Over time this pollutes challenge folders and complicates manual debugging.
- Recommended fix:
- Either manage backups explicitly as runtime artifacts or stop generating long-lived `.bak` files per rewrite.
### Low
#### 15. Static Python highlighting in template preview is intentionally lightweight but very naive
- File:
- `frontend/src/components/TemplateCodePreview.vue:1-40`
- Why it matters:
- Regex-based highlighting on escaped HTML will mis-handle more complex Python constructs.
- This is mostly cosmetic today, but it limits confidence in template readability as the library grows.
- Recommended fix:
- Accept it as a lightweight preview or replace it with a read-only CodeMirror/Lezer-based renderer if template complexity grows.
#### 16. Oversized files are now a maintenance signal on their own
- Evidence:
- `frontend/src/composables/useControlPlane.ts`: `1534` LOC
- `frontend/src/styles.css`: `1798` LOC
- `backend/app/api/proxy/router.py`: `549` LOC
- `backend/app/services/mitmproxy/manager.py`: `443` LOC
- `backend/tests/api/test_proxy.py`: `859` LOC
- Why it matters:
- Even when behavior is correct, files of this size hide coupling and slow review/debug cycles.
- Recommended fix:
- Split by responsibility before the next major feature wave.
## Roadmap
### P0 — Correctness and operator safety
- Make Docker compose port rewrites transactional and exception-safe.
- Fix log stream truncation/rotation handling.
- Add a server-side cap for health history limits.
### P1 — Confidence and hardening
- Restructure backend tests into real app/integration layers and reduce router-level monkeypatching.
- Fix import/package hygiene and make basedpyright a meaningful signal.
- Add explicit tests for reverse-mode port reassignment failure paths and orphan recovery.
### P2 — Maintainability and resilience
- Split `useControlPlane.ts` into smaller composables.
- Separate `refreshAll()` into panel-aware refreshes.
- Remove or formalize `app.state.services`.
- Move blocking file I/O off the event loop.
### P3 — Product quality and performance
- Add external load/perf harness for backend APIs, websockets, and lifecycle operations.
- Normalize built-in template contract and document intended operator semantics.
- Clean up runtime artifact and backup-file lifecycle policy.
+215
View File
@@ -0,0 +1,215 @@
# Backend Testing Strategy
## Goal
Replace the current mostly mocked backend suite with a professional layered strategy that:
- keeps fast unit tests
- adds realistic integration coverage with real app wiring
- adds an external performance/load harness
- minimizes monkeypatch use in behavior-level tests
This strategy is backend-only. Frontend automation is not part of this phase.
## Current State
- Tooling:
- pytest only
- no async test helpers
- no realistic HTTP/WebSocket client beyond `TestClient`
- no containerized test infra
- no benchmark/load tooling
- Problems:
- custom `FastAPI()` test app in `backend/tests/conftest.py`
- heavy monkeypatching of router dependencies
- almost no real lifecycle/integration confidence
- no system perf or throughput measurements
## Target Test Pyramid
### 1. Unit tests
Purpose:
- pure logic only
- parser/validator/serialization helpers
- deterministic error mapping
Rules:
- monkeypatch allowed only at narrow technical seams
- no network, no Mongo, no Docker, no filesystem mutation beyond temp dirs
Examples:
- proxy code validation
- mode/protocol validation
- template metadata normalization
- health result shaping helpers
### 2. Integration tests
Purpose:
- real FastAPI app
- real startup/shutdown
- real Mongo-backed repositories
- realistic temp runtime directories
- real HTTP and WebSocket behavior
Recommended tooling:
- `pytest-asyncio`
- `httpx`
- `asgi-lifespan`
- `testcontainers[mongodb]`
Rules:
- use the actual app from `app.main`, not a bespoke test app
- override as little as possible
- prefer seeded fixtures over monkeypatching repositories
- temp runtime dirs are acceptable; fake repositories are not
Coverage to add:
- auth login/logout with real repository behavior
- proxy create/update/delete against real Mongo
- health history endpoints with persisted samples
- logs and health websocket auth and streaming
- startup reconciliation with persisted runtime state
- failure-path integration for runtime validation and cleanup
### 3. System tests
Purpose:
- realistic end-to-end control plane on a running stack
- optional and slower than integration tests
Scope:
- backend process
- Mongo container
- optional playground/docker service fixture
- real HTTP calls and websocket clients
Use for:
- reverse-mode service remap
- mitmdump orphan recovery
- runtime artifact cleanup
- template apply/save behavior at the API level
## Fixture Architecture
### Core fixtures
- `mongo_container`
- real Mongo container started once per session
- `app_settings`
- isolated env/config for test DB, auth credentials, runtime dirs
- `real_app`
- imported from `app.main`
- `lifespan_app`
- app with startup/shutdown active
- `http_client`
- async `httpx.AsyncClient`
- `auth_token`
- produced through real login endpoint
- `runtime_dir`
- temp dir for scripts/config/logs
- `seed_proxy`
- repository-backed fixture that inserts a real proxy document
### Markers
- `unit`
- `integration`
- `api`
- `websocket`
- `slow`
- `requires_docker`
- `perf`
These should drive selective local and CI runs.
## Monkeypatch Policy
Allowed:
- forcing subprocess failure
- forcing clock/timeouts
- fault injection into low-level boundaries
- temp path redirection where isolation is needed
Not the default:
- patching repository functions in router tests
- patching service functions in API behavior tests
- bypassing authentication for general API coverage
Rule of thumb:
- if the test is about endpoint behavior, use the real endpoint stack
- if the test is about pure logic, isolate it completely
## Performance and Load Harness
### Tool choice
Use `k6` as the primary external load tool.
Reason:
- good HTTP load support
- scriptable scenarios
- usable for websocket-related smoke scenarios
- clearer reporting than ad-hoc shell loops
### Perf layout
Add a dedicated non-default perf layer, for example under:
- `backend/perf/`
Include:
- startup script or instructions
- scenario scripts
- seeded data setup
- report capture format
### Baseline scenarios
1. Read-heavy API latency
- `GET /proxy`
- `GET /proxy/{port}/health/history`
- measure latency percentiles and RPS
2. Control-plane mutation loop
- create/update/start/stop/delete proxy in repeated cycles
- measure failure rate and latency
3. WebSocket fan-out
- concurrent clients on logs and health streams
- verify stability under multiple listeners
4. Health monitor cost
- N running proxies with scheduled checks
- measure API latency degradation and monitor lag
### Perf outputs
For each scenario record:
- concurrency
- duration
- warmup
- RPS
- p50/p95/p99 latency
- error rate
- environment notes
These should not be strict CI gates initially; they should establish baselines first.
## Migration Plan
### Phase 1
- Add pytest markers and split directories by layer.
- Introduce async test dependencies and real app client fixtures.
- Keep existing suite running.
### Phase 2
- Convert router tests away from broad monkeypatching.
- Replace bespoke app fixture with real lifespan-backed app in integration tests.
- Add Mongo testcontainer fixture.
### Phase 3
- Add websocket integration tests.
- Add startup/recovery/runtime integration tests.
- Shrink old unit/API tests where they duplicate new integration coverage.
### Phase 4
- Introduce `k6` perf scripts and documentation.
- Add baseline measurement workflow for local runs and CI nightly/optional jobs.
## Acceptance Criteria
- Fast local unit suite remains available.
- Integration suite uses the real app and real Mongo.
- WebSocket paths are tested without bypassing core auth behavior.
- Router-level monkeypatching is no longer the dominant pattern.
- Reverse-mode lifecycle and recovery get realistic coverage.
- Perf/load scripts exist and produce repeatable latency/RPS baselines.
+3 -1
View File
@@ -25,7 +25,7 @@ onMounted(() => {
v-model:username="plane.username" v-model:username="plane.username"
v-model:password="plane.password" v-model:password="plane.password"
:loading="plane.loading" :loading="plane.loading"
:error="plane.authMessage || plane.error" :error="plane.authMessage || plane.loginError"
@submit="plane.signIn" @submit="plane.signIn"
/> />
@@ -34,6 +34,8 @@ onMounted(() => {
<AppHeader <AppHeader
:loading="plane.loading" :loading="plane.loading"
:show-discovery="Boolean(plane.selectedProxy)"
@discovery="plane.openDiscovery"
@refresh="plane.refreshAll" @refresh="plane.refreshAll"
@create="plane.openCreateWizard()" @create="plane.openCreateWizard()"
@logout="plane.signOut" @logout="plane.signOut"
+10 -6
View File
@@ -1,10 +1,12 @@
import type { import type {
AddonExample, HealthHistoryResponse,
HealthResult, HealthResult,
MitmOption, MitmOption,
PythonEditorSession, PythonEditorSession,
ProxyDocument, ProxyDocument,
ProxyDraft, ProxyDraft,
ProxyTemplate,
ProxyTemplateCreateInput,
ServiceDiscoveryResponse, ServiceDiscoveryResponse,
} from "./types"; } from "./types";
@@ -89,16 +91,16 @@ export const api = {
`/proxy/services${scanRoot ? `?scan_root=${encodeURIComponent(scanRoot)}` : ""}`, `/proxy/services${scanRoot ? `?scan_root=${encodeURIComponent(scanRoot)}` : ""}`,
), ),
options: () => request<MitmOption[]>("/proxy/mitmproxy/options"), options: () => request<MitmOption[]>("/proxy/mitmproxy/options"),
examples: () => request<AddonExample[]>("/proxy/mitmproxy/examples"), templates: () => request<ProxyTemplate[]>("/proxy/mitmproxy/templates"),
editorSession: () => editorSession: () =>
request<PythonEditorSession>("/proxy/editor/python/session"), request<PythonEditorSession>("/proxy/editor/python/session"),
createExample: (payload: AddonExample) => createTemplate: (payload: ProxyTemplateCreateInput) =>
request<AddonExample>("/proxy/mitmproxy/examples", { request<ProxyTemplate>("/proxy/mitmproxy/templates", {
method: "POST", method: "POST",
body: JSON.stringify(payload), body: JSON.stringify(payload),
}), }),
deleteExample: (id: string) => deleteTemplate: (id: string) =>
request<void>(`/proxy/mitmproxy/examples/${encodeURIComponent(id)}`, { request<void>(`/proxy/mitmproxy/templates/${encodeURIComponent(id)}`, {
method: "DELETE", method: "DELETE",
}), }),
createProxy: (payload: ProxyDraft) => createProxy: (payload: ProxyDraft) =>
@@ -120,5 +122,7 @@ export const api = {
restart: (port: number) => restart: (port: number) =>
request<ProxyDocument>(`/proxy/${port}/restart`, { method: "POST" }), request<ProxyDocument>(`/proxy/${port}/restart`, { method: "POST" }),
health: (port: number) => request<HealthResult>(`/proxy/${port}/health`), health: (port: number) => request<HealthResult>(`/proxy/${port}/health`),
healthHistory: (port: number) =>
request<HealthHistoryResponse>(`/proxy/${port}/health/history`),
logs: (port: number) => request<{ log: string }>(`/proxy/${port}/logs`), logs: (port: number) => request<{ log: string }>(`/proxy/${port}/logs`),
}; };
+7 -1
View File
@@ -4,9 +4,11 @@ import UiIcon from "./UiIcon.vue";
defineProps<{ defineProps<{
loading: boolean; loading: boolean;
showDiscovery: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
discovery: [];
refresh: []; refresh: [];
create: []; create: [];
logout: []; logout: [];
@@ -19,13 +21,17 @@ const emit = defineEmits<{
<AppLogo /> <AppLogo />
<div> <div>
<strong>thePlug</strong> <strong>thePlug</strong>
<small>proxy control plane</small> <small>il proxy barese</small>
</div> </div>
</div> </div>
<div class="header-meta"> <div class="header-meta">
<span v-if="loading" class="muted">saving</span> <span v-if="loading" class="muted">saving</span>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button v-if="showDiscovery" class="ghost button-icon" :disabled="loading" @click="emit('discovery')">
<UiIcon name="back" />
<span>Services</span>
</button>
<button class="ghost button-icon" :disabled="loading" @click="emit('refresh')"> <button class="ghost button-icon" :disabled="loading" @click="emit('refresh')">
<UiIcon name="refresh" /> <UiIcon name="refresh" />
<span>Refresh</span> <span>Refresh</span>
-80
View File
@@ -1,80 +0,0 @@
<script setup lang="ts">
import { useControlPlaneContext } from "../composables/useControlPlane";
const plane = useControlPlaneContext();
</script>
<template>
<section class="panel stack-panel tool-panel">
<div class="panel-title">
<div><h2>Templates</h2></div>
</div>
<div class="inline-grid">
<input
v-model="plane.customExampleName"
placeholder="Save current script as template..."
/>
<button @click="plane.saveCurrentAsExample">Save</button>
</div>
<div class="example-shell">
<div class="example-browser">
<input v-model="plane.exampleSearch" placeholder="Search templates" />
<div class="example-index">
<button
v-for="example in plane.filteredExamples"
:key="example.id"
class="example-entry"
:class="{ selected: plane.selectedExample?.id === example.id }"
@click="plane.selectExample(example.id)"
>
<strong>{{ example.name }}</strong>
<small>{{ example.category }}</small>
<span class="example-snippet">{{ example.description }}</span>
</button>
</div>
</div>
<article v-if="plane.selectedExample" class="example-preview">
<div class="option-doc-head">
<div>
<h3>{{ plane.selectedExample.name }}</h3>
</div>
<div class="template-meta">
<span class="type-chip">{{ plane.selectedExample.category }}</span>
<span class="type-chip">
{{ plane.selectedExample.built_in ? "built-in" : "custom" }}
</span>
</div>
</div>
<p class="doc-copy">{{ plane.selectedExample.description }}</p>
<pre class="terminal template-preview-terminal">{{
plane.selectedExample.code
}}</pre>
<div class="doc-actions">
<button @click="plane.applyExample(plane.selectedExample)">Use template</button>
<a
v-if="plane.selectedExample.source_url"
:href="plane.selectedExample.source_url"
target="_blank"
rel="noreferrer"
>
Source
</a>
<button
v-if="!plane.selectedExample.built_in"
class="ghost"
@click="plane.removeExample(plane.selectedExample.id)"
>
Delete
</button>
</div>
</article>
</div>
</section>
</template>
@@ -0,0 +1,274 @@
<script setup lang="ts">
import { computed } from "vue";
import { useControlPlaneContext } from "../composables/useControlPlane";
import UiIcon from "./UiIcon.vue";
const plane = useControlPlaneContext();
const latestTimestamp = computed(() => {
if (!plane.health?.timestamp) return "No checks yet";
return new Date(plane.health.timestamp).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
});
const targetRows = computed(() => {
const checks = plane.health?.checks ?? [];
return [
checks.find((check) => check.target === "proxy") ?? null,
checks.find((check) => check.target === "upstream") ?? null,
];
});
const benchmarkEnabled = computed(
() =>
plane.selectedProxy?.healthcheck.benchmark_enabled ??
plane.draft.healthcheck.benchmark_enabled,
);
function benchmarkSeries(target: "proxy" | "upstream") {
return plane.healthHistory
.map((sample, index) => {
const check = sample.checks.find((item) => item.target === target);
const duration =
typeof check?.duration_ms === "number" ? check.duration_ms : null;
if (duration === null) return null;
return {
index,
duration,
};
})
.filter((item): item is { index: number; duration: number } => item !== null);
}
function latestBenchmark(target: "proxy" | "upstream") {
const check = plane.health?.checks.find((item) => item.target === target);
return typeof check?.duration_ms === "number" ? check.duration_ms : null;
}
function averageBenchmark(target: "proxy" | "upstream") {
const values = benchmarkSeries(target).map((item) => item.duration);
if (!values.length) return null;
const total = values.reduce((sum, value) => sum + value, 0);
return Math.round((total / values.length) * 100) / 100;
}
const benchmarkSummary = computed(() => ({
proxyLast: latestBenchmark("proxy"),
proxyAvg: averageBenchmark("proxy"),
upstreamLast: latestBenchmark("upstream"),
upstreamAvg: averageBenchmark("upstream"),
}));
const chartPoints = computed(() => {
const history = plane.healthHistory;
const width = 220;
const height = 54;
const build = (target: "proxy" | "upstream") => {
const points = history
.map((sample, index) => {
const check = sample.checks.find((item) => item.target === target);
if (!check) return null;
const x = history.length === 1 ? width / 2 : (index / (history.length - 1)) * width;
const y = check.healthy ? 10 : height - 10;
return `${x},${y}`;
})
.filter(Boolean);
return points.join(" ");
};
return {
proxy: build("proxy"),
upstream: build("upstream"),
width,
height,
};
});
const benchmarkChart = computed(() => {
const width = 220;
const height = 72;
const proxySeries = benchmarkSeries("proxy");
const upstreamSeries = benchmarkSeries("upstream");
const allValues = [...proxySeries, ...upstreamSeries].map((item) => item.duration);
const maxDuration = allValues.length ? Math.max(...allValues) : 0;
const domainMax = maxDuration > 0 ? maxDuration : 1;
const toPoints = (series: { index: number; duration: number }[]) =>
series
.map((item) => {
const x =
plane.healthHistory.length <= 1
? width / 2
: (item.index / (plane.healthHistory.length - 1)) * width;
const y = height - 10 - (item.duration / domainMax) * (height - 20);
return `${x},${y}`;
})
.join(" ");
return {
width,
height,
proxy: toPoints(proxySeries),
upstream: toPoints(upstreamSeries),
hasData: allValues.length > 0,
maxDuration: Math.round(domainMax * 100) / 100,
};
});
function formatDuration(value: number | null) {
return value === null ? "n/a" : `${value.toFixed(2)} ms`;
}
</script>
<template>
<div v-if="plane.healthModalOpen" class="modal-backdrop" @click.self="plane.closeHealthModal">
<section class="modal-shell health-modal-shell">
<div class="modal-head">
<div>
<h2>Health</h2>
<p class="modal-step-copy">
Runtime checks for the live proxy path and the real upstream service.
</p>
</div>
<div class="modal-head-actions">
<button class="ghost button-icon" :disabled="plane.loading" @click="plane.checkHealth">
<UiIcon name="pulse" />
<span>Run now</span>
</button>
<button class="ghost button-icon" @click="plane.closeHealthModal">
<UiIcon name="close" />
<span>Close</span>
</button>
</div>
</div>
<div class="modal-body health-modal-body">
<div
class="health-runtime-overview"
:class="plane.health?.overall ?? 'disabled'"
>
<strong>{{ plane.health?.overall ?? "pending" }}</strong>
<span>Last check {{ latestTimestamp }}</span>
</div>
<div class="health-runtime-list">
<article
v-for="(row, index) in targetRows"
:key="row?.target ?? `pending-${index}`"
class="health-runtime-card"
:class="row ? (row.healthy ? 'healthy' : 'unhealthy') : 'pending'"
>
<div class="health-runtime-card-head">
<strong>{{ row?.target ?? "pending" }}</strong>
<span>{{ row ? (row.healthy ? "ok" : "issue") : "waiting" }}</span>
</div>
<p class="muted small compact-copy">
{{ row ? `${row.host}:${row.port} · ${row.protocol}` : "No data yet." }}
</p>
<p
v-if="benchmarkEnabled && typeof row?.duration_ms === 'number'"
class="muted small compact-copy"
>
Last response {{ formatDuration(row.duration_ms) }}
</p>
<p v-if="row?.error" class="error compact health-inline-error">
{{ row.error }}
</p>
</article>
</div>
<div class="health-chart-card">
<div class="panel-title">
<h3>Recent timeline</h3>
<span class="muted small">{{ plane.healthHistory.length }} samples</span>
</div>
<div v-if="plane.healthHistory.length" class="health-chart-grid">
<div class="health-chart-row">
<span>Proxy</span>
<svg
class="health-chart"
:viewBox="`0 0 ${chartPoints.width} ${chartPoints.height}`"
preserveAspectRatio="none"
>
<polyline class="health-chart-line proxy" :points="chartPoints.proxy" />
</svg>
</div>
<div class="health-chart-row">
<span>Upstream</span>
<svg
class="health-chart"
:viewBox="`0 0 ${chartPoints.width} ${chartPoints.height}`"
preserveAspectRatio="none"
>
<polyline class="health-chart-line upstream" :points="chartPoints.upstream" />
</svg>
</div>
</div>
<p v-else class="muted small">
Health history will appear after the first scheduled runs.
</p>
</div>
<div v-if="benchmarkEnabled" class="health-chart-card">
<div class="panel-title">
<h3>Benchmark</h3>
<span class="muted small">Response latency</span>
</div>
<div class="benchmark-summary-grid">
<article class="metric-card">
<span class="metric-label">Proxy last</span>
<strong>{{ formatDuration(benchmarkSummary.proxyLast) }}</strong>
</article>
<article class="metric-card">
<span class="metric-label">Proxy avg</span>
<strong>{{ formatDuration(benchmarkSummary.proxyAvg) }}</strong>
</article>
<article class="metric-card">
<span class="metric-label">Upstream last</span>
<strong>{{ formatDuration(benchmarkSummary.upstreamLast) }}</strong>
</article>
<article class="metric-card">
<span class="metric-label">Upstream avg</span>
<strong>{{ formatDuration(benchmarkSummary.upstreamAvg) }}</strong>
</article>
</div>
<div v-if="benchmarkChart.hasData" class="health-chart-grid">
<div class="health-chart-row">
<span>Proxy</span>
<svg
class="health-chart benchmark-chart"
:viewBox="`0 0 ${benchmarkChart.width} ${benchmarkChart.height}`"
preserveAspectRatio="none"
>
<polyline class="health-chart-line proxy" :points="benchmarkChart.proxy" />
</svg>
</div>
<div class="health-chart-row">
<span>Upstream</span>
<svg
class="health-chart benchmark-chart"
:viewBox="`0 0 ${benchmarkChart.width} ${benchmarkChart.height}`"
preserveAspectRatio="none"
>
<polyline class="health-chart-line upstream" :points="benchmarkChart.upstream" />
</svg>
</div>
<p class="muted small compact-copy">
Scale 0 → {{ benchmarkChart.maxDuration.toFixed(2) }} ms
</p>
</div>
<p v-else class="muted small">
Collecting benchmark data from scheduled health runs.
</p>
</div>
</div>
</section>
</div>
</template>
+8
View File
@@ -40,6 +40,10 @@ const plane = useControlPlaneContext();
<span class="metric-label">Timeout</span> <span class="metric-label">Timeout</span>
<strong>{{ plane.draft.healthcheck.timeout_seconds }}s</strong> <strong>{{ plane.draft.healthcheck.timeout_seconds }}s</strong>
</article> </article>
<article class="metric-card">
<span class="metric-label">Benchmark</span>
<strong>{{ plane.draft.healthcheck.benchmark_enabled ? "Enabled" : "Disabled" }}</strong>
</article>
</div> </div>
<div class="form-grid tight-grid"> <div class="form-grid tight-grid">
@@ -98,6 +102,10 @@ const plane = useControlPlaneContext();
<input v-model="plane.draft.healthcheck.verify_tls" type="checkbox" /> <input v-model="plane.draft.healthcheck.verify_tls" type="checkbox" />
<span>Verify TLS</span> <span>Verify TLS</span>
</label> </label>
<label class="checkbox-row">
<input v-model="plane.draft.healthcheck.benchmark_enabled" type="checkbox" />
<span>Benchmark response time</span>
</label>
</div> </div>
<div v-if="plane.health" class="health-box" :class="plane.health.overall"> <div v-if="plane.health" class="health-box" :class="plane.health.overall">
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { computed } from "vue";
import { useControlPlaneContext } from "../composables/useControlPlane";
import UiIcon from "./UiIcon.vue";
const plane = useControlPlaneContext();
const latestTimestamp = computed(() => {
if (!plane.health?.timestamp) return "No checks yet";
return new Date(plane.health.timestamp).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
});
const proxyCheck = computed(() =>
plane.health?.checks.find((check) => check.target === "proxy") ?? null,
);
const upstreamCheck = computed(() =>
plane.health?.checks.find((check) => check.target === "upstream") ?? null,
);
</script>
<template>
<section
v-if="plane.selectedProxy"
class="health-summary-card"
:class="plane.health?.overall ?? 'pending'"
>
<div class="health-summary-line">
<strong class="health-summary-overall">{{ plane.health?.overall ?? "pending" }}</strong>
<span class="health-summary-item">
<small>Proxy</small>
<strong>{{ proxyCheck ? (proxyCheck.healthy ? "ok" : "issue") : "waiting" }}</strong>
</span>
<span class="health-summary-item">
<small>Upstream</small>
<strong>
{{ upstreamCheck ? (upstreamCheck.healthy ? "ok" : "issue") : "waiting" }}
</strong>
</span>
<span class="health-summary-item health-summary-time">
<small>Last check</small>
<strong>{{ latestTimestamp }}</strong>
</span>
<div class="health-summary-actions">
<button
class="ghost button-icon"
:disabled="plane.loading"
@click="plane.checkHealth"
>
<UiIcon name="pulse" />
<span>Run</span>
</button>
<button class="ghost button-icon" @click="plane.openHealthModal">
<span>Details</span>
</button>
</div>
</div>
</section>
</template>
+31 -7
View File
@@ -73,7 +73,11 @@ function formatDefault(option?: MitmOption | null) {
</button> </button>
</div> </div>
</div> </div>
<div class="option-browser-meta"> <div v-if="plane.optionsLoading" class="option-browser-meta">
<span class="option-count">...</span>
<p class="muted small">Loading mitmdump options…</p>
</div>
<div v-else class="option-browser-meta">
<span class="option-count">{{ plane.filteredOptions.length }}</span> <span class="option-count">{{ plane.filteredOptions.length }}</span>
<p class="muted small"> <p class="muted small">
{{ {{
@@ -84,7 +88,10 @@ function formatDefault(option?: MitmOption | null) {
</p> </p>
</div> </div>
<div class="option-table"> <div v-if="plane.optionsLoading" class="option-empty-state">
<p class="muted small">The option catalog is still loading.</p>
</div>
<div v-else-if="plane.filteredOptions.length" class="option-table">
<button <button
v-for="option in plane.filteredOptions" v-for="option in plane.filteredOptions"
:key="option.name" :key="option.name"
@@ -96,10 +103,16 @@ function formatDefault(option?: MitmOption | null) {
<small>{{ option.type }}</small> <small>{{ option.type }}</small>
</button> </button>
</div> </div>
<div v-else class="option-empty-state">
<p class="muted small">No options matched the current filter.</p>
</div>
</aside> </aside>
<div class="option-step-main"> <div class="option-step-main">
<article v-if="plane.selectedOption" class="option-doc option-doc-embedded"> <article
v-if="!plane.optionsLoading && plane.selectedOption"
class="option-doc option-doc-embedded"
>
<div class="option-doc-head"> <div class="option-doc-head">
<div> <div>
<h3>{{ plane.selectedOption.name }}</h3> <h3>{{ plane.selectedOption.name }}</h3>
@@ -254,9 +267,14 @@ function formatDefault(option?: MitmOption | null) {
<div class="option-browser"> <div class="option-browser">
<div class="option-search-row"> <div class="option-search-row">
<input v-model="plane.optionSearch" placeholder="Search options" /> <input v-model="plane.optionSearch" placeholder="Search options" />
<span class="option-count">{{ plane.filteredOptions.length }}</span> <span class="option-count">
{{ plane.optionsLoading ? "..." : plane.filteredOptions.length }}
</span>
</div> </div>
<p class="muted small compact-copy"> <p v-if="plane.optionsLoading" class="muted small compact-copy">
Loading mitmdump options…
</p>
<p v-else class="muted small compact-copy">
{{ {{
plane.optionScope === "focused" plane.optionScope === "focused"
? "Showing the options that are usually useful in this UI." ? "Showing the options that are usually useful in this UI."
@@ -264,7 +282,10 @@ function formatDefault(option?: MitmOption | null) {
}} }}
</p> </p>
<div class="option-table"> <div v-if="plane.optionsLoading" class="option-empty-state">
<p class="muted small">The option catalog is still loading.</p>
</div>
<div v-else-if="plane.filteredOptions.length" class="option-table">
<button <button
v-for="option in plane.filteredOptions" v-for="option in plane.filteredOptions"
:key="option.name" :key="option.name"
@@ -276,9 +297,12 @@ function formatDefault(option?: MitmOption | null) {
<small>{{ option.type }}</small> <small>{{ option.type }}</small>
</button> </button>
</div> </div>
<div v-else class="option-empty-state">
<p class="muted small">No options matched the current filter.</p>
</div>
</div> </div>
<article v-if="plane.selectedOption" class="option-doc"> <article v-if="!plane.optionsLoading && plane.selectedOption" class="option-doc">
<div class="option-doc-head"> <div class="option-doc-head">
<div> <div>
<h3>{{ plane.selectedOption.name }}</h3> <h3>{{ plane.selectedOption.name }}</h3>
+5 -1
View File
@@ -19,7 +19,7 @@ const plane = useControlPlaneContext();
</p> </p>
</div> </div>
<div class="modal-head-actions"> <div class="modal-head-actions">
<button class="primary button-icon" @click="plane.saveProxy"> <button class="primary button-icon" :disabled="!plane.canSaveProxy" @click="plane.saveProxy">
<UiIcon name="save" /> <UiIcon name="save" />
<span>Save & close</span> <span>Save & close</span>
</button> </button>
@@ -30,6 +30,10 @@ const plane = useControlPlaneContext();
</div> </div>
</header> </header>
<p v-if="plane.wizardError" class="error modal-error">
{{ plane.wizardError }}
</p>
<div class="modal-stepper"> <div class="modal-stepper">
<button <button
v-for="(step, index) in ['Proxy route', 'Healthchecks', 'Custom mitmdump options']" v-for="(step, index) in ['Proxy route', 'Healthchecks', 'Custom mitmdump options']"
+12 -8
View File
@@ -1,11 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { useControlPlaneContext } from "../composables/useControlPlane"; import { useControlPlaneContext } from "../composables/useControlPlane";
import EditorPane from "./EditorPane.vue"; import EditorPane from "./EditorPane.vue";
import ExamplesPanel from "./ExamplesPanel.vue"; import HealthDetailsModal from "./HealthDetailsModal.vue";
import HealthSummaryBar from "./HealthSummaryBar.vue";
import LogsPanel from "./LogsPanel.vue"; import LogsPanel from "./LogsPanel.vue";
import ProxyWizardModal from "./ProxyWizardModal.vue"; import ProxyWizardModal from "./ProxyWizardModal.vue";
import ServiceDiscoveryPanel from "./ServiceDiscoveryPanel.vue"; import ServiceDiscoveryPanel from "./ServiceDiscoveryPanel.vue";
import StatusPill from "./StatusPill.vue"; import StatusPill from "./StatusPill.vue";
import TemplatesPanel from "./TemplatesPanel.vue";
import UiIcon from "./UiIcon.vue"; import UiIcon from "./UiIcon.vue";
const plane = useControlPlaneContext(); const plane = useControlPlaneContext();
@@ -17,9 +19,8 @@ const plane = useControlPlaneContext();
<div class="workbench-copy"> <div class="workbench-copy">
<h1>{{ plane.selectedProxy ? plane.draft.name : "Proxy workspace" }}</h1> <h1>{{ plane.selectedProxy ? plane.draft.name : "Proxy workspace" }}</h1>
<div v-if="plane.selectedProxy" class="route-row route-summary"> <div v-if="plane.selectedProxy" class="route-row route-summary">
<span class="route-token">{{ plane.routeState.configuredListen }}</span> <span class="route-token">{{ plane.routeState.proxySummary }}</span>
<span class="route-arrow">to</span> <span class="route-target">{{ plane.routeState.upstreamSummary }}</span>
<span class="route-target">{{ plane.routeState.configuredTarget }}</span>
<StatusPill <StatusPill
:status="plane.selectedProxy.status" :status="plane.selectedProxy.status"
/> />
@@ -27,14 +28,16 @@ const plane = useControlPlaneContext();
<p v-else class="summary-copy"> <p v-else class="summary-copy">
Create a proxy from autodiscovery or open one from the inventory. Create a proxy from autodiscovery or open one from the inventory.
</p> </p>
<p v-if="plane.selectedProxy && plane.routeState.summaryHint" class="summary-copy"> <p v-if="plane.selectedProxy && plane.routeState.proxyPortReservedMessage" class="error compact">
{{ plane.routeState.summaryHint }} {{ plane.routeState.proxyPortReservedMessage }}
</p> </p>
<p v-if="plane.selectedProxy?.last_error" class="error compact"> <p v-if="plane.selectedProxy?.last_error" class="error compact">
{{ plane.selectedProxy.last_error }} {{ plane.selectedProxy.last_error }}
</p> </p>
</div> </div>
<HealthSummaryBar />
<div class="action-row"> <div class="action-row">
<button <button
v-if="plane.selectedProxy" v-if="plane.selectedProxy"
@@ -84,7 +87,7 @@ const plane = useControlPlaneContext();
</div> </div>
</header> </header>
<p v-if="plane.error" class="error">{{ plane.error }}</p> <p v-if="plane.workspaceError" class="error">{{ plane.workspaceError }}</p>
<ServiceDiscoveryPanel v-if="!plane.selectedProxy && !plane.wizardOpen" /> <ServiceDiscoveryPanel v-if="!plane.selectedProxy && !plane.wizardOpen" />
@@ -98,9 +101,10 @@ const plane = useControlPlaneContext();
</div> </div>
<div v-if="plane.selectedProxy" class="workspace-templates-row"> <div v-if="plane.selectedProxy" class="workspace-templates-row">
<ExamplesPanel /> <TemplatesPanel />
</div> </div>
<ProxyWizardModal /> <ProxyWizardModal />
<HealthDetailsModal />
</section> </section>
</template> </template>
+5 -5
View File
@@ -16,11 +16,8 @@ const plane = useControlPlaneContext();
</div> </div>
<div class="route-strip"> <div class="route-strip">
<span class="route-chip">{{ plane.routeState.configuredListen }}</span> <span class="route-chip">{{ plane.routeState.proxySummary }}</span>
<span class="route-arrow">to</span> <span class="route-chip">{{ plane.routeState.upstreamSummary }}</span>
<span class="route-chip">{{ plane.routeState.configuredTarget }}</span>
<span class="route-arrow">live</span>
<span class="route-chip">{{ plane.routeState.liveTarget }}</span>
</div> </div>
<div class="route-overview-grid"> <div class="route-overview-grid">
@@ -52,6 +49,9 @@ const plane = useControlPlaneContext();
<small v-if="plane.selectedProxy"> <small v-if="plane.selectedProxy">
Port is fixed for existing proxies. Create a new proxy to change it. Port is fixed for existing proxies. Create a new proxy to change it.
</small> </small>
<small v-if="plane.routeState.proxyPortReservedMessage" class="route-port-error">
{{ plane.routeState.proxyPortReservedMessage }}
</small>
</label> </label>
<label> <label>
Upstream host Upstream host
@@ -30,6 +30,9 @@ const plane = useControlPlaneContext();
<p v-if="plane.servicesLoading" class="muted small compact-copy"> <p v-if="plane.servicesLoading" class="muted small compact-copy">
Scanning compose folders in {{ plane.scanRoot }}... Scanning compose folders in {{ plane.scanRoot }}...
</p> </p>
<p v-if="plane.discoveryError" class="error compact-copy">
{{ plane.discoveryError }}
</p>
<p v-else-if="!plane.serviceEntries.length" class="muted small compact-copy"> <p v-else-if="!plane.serviceEntries.length" class="muted small compact-copy">
No compose services detected in the scan root. No compose services detected in the scan root.
@@ -37,42 +40,59 @@ const plane = useControlPlaneContext();
<div v-else class="service-discovery-list"> <div v-else class="service-discovery-list">
<article <article
v-for="entry in plane.serviceEntries" v-for="group in plane.serviceGroups"
:key="entry.id" :key="group.id"
class="service-discovery-row" class="service-group-card"
> >
<div class="service-discovery-main"> <div class="service-group-head">
<strong>{{ entry.service.name }}</strong> <div class="service-group-copy">
<span class="service-route-copy"> <h3>{{ group.title }}</h3>
{{ entry.listenHost }}:{{ entry.binding.published }} <small class="service-folder">{{ group.folder }}</small>
</div>
<span class="muted small">
{{ group.entries.length }} {{ group.entries.length === 1 ? "service" : "services" }}
</span> </span>
<small class="service-folder">{{ entry.service.folder }}</small>
</div> </div>
<div class="service-discovery-actions"> <div class="service-group-list">
<template v-if="entry.proxy"> <article
<StatusPill :status="entry.proxy.status" /> v-for="entry in group.entries"
<button class="ghost button-icon" @click="plane.selectedPort = entry.proxy.proxy_port"> :key="entry.id"
<UiIcon name="link" /> class="service-discovery-row"
<span>Open proxy</span>
</button>
<button
class="ghost button-icon"
@click="plane.openEditWizard(entry.proxy)"
>
<UiIcon name="settings" />
<span>Edit</span>
</button>
</template>
<button
v-else
class="primary button-icon"
:disabled="plane.loading"
@click="plane.openCreateWizard(entry.service, entry.binding)"
> >
<UiIcon name="plus" /> <div class="service-discovery-main">
<span>Create proxy</span> <strong>{{ entry.service.name }}</strong>
</button> <span class="service-route-copy">
{{ entry.listenHost }}:{{ entry.binding.published }}
</span>
</div>
<div class="service-discovery-actions">
<template v-if="entry.proxy">
<StatusPill :status="entry.proxy.status" />
<button class="ghost button-icon" @click="plane.selectedPort = entry.proxy.proxy_port">
<UiIcon name="link" />
<span>Open proxy</span>
</button>
<button
class="ghost button-icon"
@click="plane.openEditWizard(entry.proxy)"
>
<UiIcon name="settings" />
<span>Edit</span>
</button>
</template>
<button
v-else
class="primary button-icon"
:disabled="plane.loading"
@click="plane.openCreateWizard(entry.service, entry.binding)"
>
<UiIcon name="plus" />
<span>Create proxy</span>
</button>
</div>
</article>
</div> </div>
</article> </article>
</div> </div>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { computed } from "vue";
const props = defineProps<{
code: string;
}>();
function escapeHtml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function highlightPython(source: string) {
const lines = source.split("\n");
return lines
.map((line) => {
const escaped = escapeHtml(line);
const commentIndex = escaped.indexOf("#");
const codePart = commentIndex >= 0 ? escaped.slice(0, commentIndex) : escaped;
const commentPart = commentIndex >= 0 ? escaped.slice(commentIndex) : "";
let highlighted = codePart
.replace(
/("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g,
'<span class="py-string">$1</span>',
)
.replace(
/\b(from|import|def|return|if|else|elif|for|in|while|try|except|finally|pass|continue|break|as|None|True|False|and|or|not)\b/g,
'<span class="py-keyword">$1</span>',
)
.replace(
/\b([A-Z_][A-Z0-9_]*)\b/g,
'<span class="py-constant">$1</span>',
)
.replace(
/\b(logging|flow|http|tcp|dns)\b/g,
'<span class="py-name">$1</span>',
);
if (commentPart) {
highlighted += `<span class="py-comment">${commentPart}</span>`;
}
return highlighted;
})
.join("\n");
}
const highlightedCode = computed(() => highlightPython(props.code));
</script>
<template>
<pre class="terminal template-detail-code template-code-preview"><code v-html="highlightedCode" /></pre>
</template>
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { useControlPlaneContext } from "../composables/useControlPlane";
import TemplateCodePreview from "./TemplateCodePreview.vue";
const plane = useControlPlaneContext();
</script>
<template>
<section class="panel stack-panel tool-panel templates-panel">
<div class="panel-title">
<h2>Templates</h2>
</div>
<div class="template-savebar">
<input
v-model="plane.customTemplateName"
placeholder="Save current script as template..."
/>
<button @click="plane.saveCurrentAsTemplate">Save</button>
</div>
<p v-if="plane.templatesLoading" class="muted small compact-copy">Refreshing templates...</p>
<div class="template-layout">
<aside class="template-sidebar">
<div class="template-sidebar-controls">
<input v-model="plane.templateSearch" placeholder="Search templates" />
<div class="template-protocol-tabs">
<button
class="ghost"
:class="{ active: plane.templateProtocolFilter === 'http' }"
@click="plane.templateProtocolFilter = 'http'"
>
HTTP
</button>
<button
class="ghost"
:class="{ active: plane.templateProtocolFilter === 'tcp' }"
@click="plane.templateProtocolFilter = 'tcp'"
>
TCP
</button>
</div>
</div>
<div class="template-list">
<button
v-for="template in plane.filteredTemplates"
:key="template.id"
class="template-list-item"
:class="{ selected: plane.selectedTemplate?.id === template.id }"
@click="plane.selectTemplate(template.id)"
>
<strong>{{ template.name }}</strong>
<span class="template-list-summary">
{{ template.behavior || template.summary || template.description }}
</span>
</button>
</div>
</aside>
<article v-if="plane.selectedTemplate" class="template-detail">
<div class="template-detail-head">
<div class="template-detail-copy">
<h3>{{ plane.selectedTemplate.name }}</h3>
<p class="doc-copy compact-copy">
{{
plane.selectedTemplate.behavior ||
plane.selectedTemplate.summary ||
plane.selectedTemplate.description
}}
</p>
</div>
</div>
<TemplateCodePreview :code="plane.selectedTemplate.code" />
<div class="doc-actions">
<button @click="plane.applyTemplate(plane.selectedTemplate)">Use template</button>
<button
v-if="!plane.selectedTemplate.built_in"
class="ghost"
@click="plane.removeTemplate(plane.selectedTemplate.id)"
>
Delete
</button>
</div>
</article>
<article v-else class="template-detail template-detail-empty">
<p class="muted">No template available for this protocol.</p>
</article>
</div>
</section>
</template>
+9
View File
@@ -12,6 +12,7 @@ defineProps<{
| "terminal" | "terminal"
| "link" | "link"
| "spark" | "spark"
| "pulse"
| "close" | "close"
| "back" | "back"
| "next" | "next"
@@ -113,6 +114,14 @@ defineProps<{
<path d="M5 11H4a2.5 2.5 0 010-5h2" /> <path d="M5 11H4a2.5 2.5 0 010-5h2" />
<path d="M11 5h1a2.5 2.5 0 010 5H10" /> <path d="M11 5h1a2.5 2.5 0 010 5H10" />
</svg> </svg>
<svg
v-else-if="name === 'pulse'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M2.5 8h2.2l1.2-2.7 2.2 5.4 1.6-3.2h3.8" />
</svg>
<svg <svg
v-else-if="name === 'close'" v-else-if="name === 'close'"
viewBox="0 0 16 16" viewBox="0 0 16 16"
@@ -0,0 +1,148 @@
import type {
DockerService,
DockerServiceBinding,
ProxyDocument,
ProxyDraft,
} from "../types";
export const modes = [
"reverse",
"regular",
"socks5",
"upstream",
"wireguard",
"transparent",
"local",
"tun",
"dns",
];
export const protocols = [
"http",
"https",
"tcp",
"tls",
"udp",
"dtls",
"dns",
"http3",
"quic",
];
export function createDefaultDraft(port = 8080, code?: string): ProxyDraft {
return {
proxy_port: port,
name: "new-proxy",
code:
code ??
`from mitmproxy import http
def request(flow: http.HTTPFlow) -> None:
pass
`,
mode: "reverse",
mode_spec: null,
protocol: "http",
listen_host: "0.0.0.0",
mitm_options: {
flow_detail: 1,
termlog_verbosity: "info",
},
healthcheck: {
enabled: true,
protocol: null,
path: "/",
method: "GET",
expected_status: null,
udp_payload: "",
expect_response: true,
verify_tls: false,
benchmark_enabled: false,
timeout_seconds: 2,
},
service: {
host: "127.0.0.1",
port: 9000,
bind_host: null,
name: null,
folder: null,
upstream_port: null,
},
};
}
export function folderBasename(folder: string | null | undefined): string {
if (!folder) return "";
const normalized = folder.replace(/\\/g, "/").replace(/\/+$/, "");
const parts = normalized.split("/");
return parts[parts.length - 1] ?? "";
}
export function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export function bindingsFor(service: DockerService) {
return service.bindings?.length
? service.bindings
: service.ports.map((published) => ({ published, host: null }));
}
export function draftPayload(value: ProxyDraft | ProxyDocument): ProxyDraft {
return JSON.parse(
JSON.stringify({
proxy_port: value.proxy_port,
name: value.name,
code: value.code,
mode: value.mode,
mode_spec: value.mode_spec || null,
protocol: value.protocol,
listen_host: value.listen_host,
mitm_options: value.mitm_options,
healthcheck: {
...value.healthcheck,
expected_status: value.healthcheck.expected_status || null,
protocol: value.healthcheck.protocol || null,
},
service: {
host: value.service.host,
port: value.service.port,
bind_host: value.service.bind_host || null,
name: value.service.name || null,
folder: value.service.folder || null,
upstream_port: value.service.upstream_port || null,
},
}),
) as ProxyDraft;
}
export function serializeDraft(value: ProxyDraft | ProxyDocument) {
return JSON.stringify(draftPayload(value));
}
export function draftForService(
service: DockerService,
binding: DockerServiceBinding,
templateCode?: string,
): ProxyDraft {
const template = createDefaultDraft(binding.published, templateCode);
const folderSlug = slugify(folderBasename(service.folder));
const portSuffix = bindingsFor(service).length > 1 ? `-${binding.published}` : "";
return {
...template,
name: `${folderSlug ? `${folderSlug}-` : ""}${service.name}${portSuffix}`,
listen_host: binding.host ?? "0.0.0.0",
service: {
host: "127.0.0.1",
port: binding.published,
bind_host: binding.host ?? null,
name: service.name,
folder: service.folder,
upstream_port: null,
},
} satisfies ProxyDraft;
}
File diff suppressed because it is too large Load Diff
+403 -30
View File
@@ -442,13 +442,15 @@ pre {
.workbench-head { .workbench-head {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto auto;
gap: 1rem; gap: 1rem;
align-items: center;
} }
.workbench-copy { .workbench-copy {
display: grid; display: grid;
gap: 0.7rem; gap: 0.7rem;
min-width: 0;
} }
.route-row { .route-row {
@@ -574,7 +576,7 @@ pre {
.workspace-main-row { .workspace-main-row {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
gap: 1rem; gap: 1rem;
align-items: start; align-items: start;
} }
@@ -586,7 +588,8 @@ pre {
} }
.workspace-editor-pane, .workspace-editor-pane,
.workspace-logs-pane { .workspace-logs-pane,
.workspace-templates-row {
display: grid; display: grid;
} }
@@ -645,12 +648,11 @@ pre {
.service-folder { .service-folder {
display: block; display: block;
overflow: hidden;
color: var(--muted); color: var(--muted);
font-family: "IBM Plex Mono", monospace; font-family: "IBM Plex Mono", monospace;
font-size: 0.74rem; font-size: 0.74rem;
text-overflow: ellipsis; line-height: 1.45;
white-space: nowrap; overflow-wrap: anywhere;
} }
.compact-details { .compact-details {
@@ -711,6 +713,205 @@ pre {
font-size: 0.8rem; font-size: 0.8rem;
} }
.health-runtime-overview {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.8rem 0.9rem;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: #0d1116;
}
.health-runtime-overview strong,
.health-runtime-card-head strong {
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.78rem;
}
.health-runtime-overview.healthy {
border-color: #2a5437;
color: #b8f3c9;
}
.health-runtime-overview.unhealthy {
border-color: #5f2d2d;
color: #ffd7d7;
}
.health-runtime-overview.disabled,
.health-runtime-overview.pending {
color: var(--muted);
}
.health-runtime-list {
display: grid;
gap: 0.75rem;
}
.health-runtime-card {
display: grid;
gap: 0.45rem;
padding: 0.8rem 0.9rem;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--bg-elevated);
}
.health-runtime-card.healthy {
border-color: #2a5437;
}
.health-runtime-card.unhealthy {
border-color: #5f2d2d;
}
.health-runtime-card.pending {
color: var(--muted);
}
.health-runtime-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.health-inline-error {
width: auto;
}
.health-chart-card {
display: grid;
gap: 0.75rem;
padding: 0.85rem;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--bg-elevated);
}
.health-chart-card h3 {
margin: 0;
font-size: 0.96rem;
}
.benchmark-summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.health-chart-grid {
display: grid;
gap: 0.65rem;
}
.health-chart-row {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 0.75rem;
align-items: center;
}
.health-chart-row span {
color: var(--muted);
font-family: "IBM Plex Mono", monospace;
font-size: 0.78rem;
}
.health-chart {
width: 100%;
height: 56px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: #090c0f;
}
.benchmark-chart {
height: 72px;
}
.health-chart-line {
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.health-chart-line.proxy {
stroke: #7dcfe7;
}
.health-chart-line.upstream {
stroke: #f5b55f;
}
.health-summary-card {
display: flex;
align-items: center;
padding: 0.55rem 0.7rem;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: #0d1116;
}
.health-summary-card.healthy {
border-color: #2a5437;
}
.health-summary-card.unhealthy {
border-color: #5f2d2d;
}
.health-summary-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
}
.health-summary-overall,
.health-summary-item strong {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.health-summary-actions {
display: flex;
align-items: center;
gap: 0.55rem;
margin-left: auto;
}
.health-summary-item {
display: inline-grid;
gap: 0.08rem;
}
.health-summary-item small {
color: var(--muted);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.health-summary-time strong {
text-transform: none;
letter-spacing: 0;
}
.health-modal-shell {
width: min(760px, calc(100vw - 2rem));
}
.health-modal-body {
display: grid;
gap: 0.9rem;
}
.title-actions, .title-actions,
.segmented { .segmented {
display: flex; display: flex;
@@ -794,14 +995,137 @@ pre {
text-decoration: underline; text-decoration: underline;
} }
.option-shell, .option-shell {
.example-shell {
display: grid; display: grid;
grid-template-columns: 260px minmax(0, 1fr); grid-template-columns: 260px minmax(0, 1fr);
gap: 0.9rem; gap: 0.9rem;
min-height: 0; min-height: 0;
} }
.templates-panel {
min-width: 0;
}
.template-savebar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
}
.template-layout {
display: grid;
grid-template-columns: 500px minmax(0, 1fr);
gap: 1rem;
min-height: 0;
}
.template-sidebar,
.template-detail {
display: grid;
align-content: start;
gap: 0.9rem;
padding: 0.95rem;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--bg-elevated);
min-width: 0;
}
.template-sidebar-controls,
.template-detail-sections {
display: grid;
gap: 0.75rem;
}
.template-protocol-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
}
.template-protocol-tabs button.active {
border-color: #7dcfe7;
color: #dff8ff;
background: #18232b;
}
.template-sidebar-meta {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
}
.template-list {
display: grid;
gap: 0.55rem;
max-height: 620px;
overflow: auto;
}
.template-list-item {
width: 100%;
display: grid;
gap: 0.35rem;
padding: 0.75rem;
text-align: left;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: #0d1116;
}
.template-list-item.selected {
border-color: #496171;
background: #131920;
}
.template-list-summary {
color: var(--muted);
line-height: 1.45;
}
.template-detail-copy h3 {
margin: 0;
}
.template-detail-copy {
display: grid;
gap: 0.55rem;
}
.template-detail-code {
min-height: 360px;
max-height: 520px;
}
.template-code-preview {
white-space: pre;
}
.template-code-preview .py-keyword {
color: #d88dff;
}
.template-code-preview .py-string {
color: #9ed27c;
}
.template-code-preview .py-comment {
color: #687a88;
}
.template-code-preview .py-constant {
color: #e6c16d;
}
.template-code-preview .py-name {
color: #79c7f0;
}
.template-detail-empty {
align-items: center;
}
.option-step-layout { .option-step-layout {
display: grid; display: grid;
grid-template-columns: 280px minmax(0, 1fr); grid-template-columns: 280px minmax(0, 1fr);
@@ -816,9 +1140,7 @@ pre {
} }
.option-browser, .option-browser,
.example-browser, .option-doc {
.option-doc,
.example-preview {
display: grid; display: grid;
align-content: start; align-content: start;
gap: 0.75rem; gap: 0.75rem;
@@ -852,6 +1174,10 @@ pre {
margin: 0; margin: 0;
} }
.option-empty-state {
padding: 0.4rem 0;
}
.option-active-card { .option-active-card {
display: grid; display: grid;
gap: 0.75rem; gap: 0.75rem;
@@ -889,16 +1215,14 @@ pre {
font-size: 0.76rem; font-size: 0.76rem;
} }
.option-table, .option-table {
.example-index {
display: grid; display: grid;
gap: 0.45rem; gap: 0.45rem;
max-height: 320px; max-height: 320px;
overflow: auto; overflow: auto;
} }
.option-entry, .option-entry {
.example-entry {
width: 100%; width: 100%;
display: grid; display: grid;
gap: 0.2rem; gap: 0.2rem;
@@ -907,8 +1231,7 @@ pre {
background: #0d1116; background: #0d1116;
} }
.option-entry.selected, .option-entry.selected {
.example-entry.selected {
border-color: #496171; border-color: #496171;
background: #131920; background: #131920;
} }
@@ -945,12 +1268,6 @@ pre {
max-width: 70ch; max-width: 70ch;
} }
.example-snippet {
color: var(--muted);
font-size: 0.78rem;
line-height: 1.45;
}
.doc-facts, .doc-facts,
.template-meta { .template-meta {
display: grid; display: grid;
@@ -1020,6 +1337,9 @@ pre {
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
gap: 0.85rem; gap: 0.85rem;
height: calc(clamp(420px, 68vh, 760px) + 72px);
max-height: calc(clamp(420px, 68vh, 760px) + 72px);
overflow: hidden;
} }
.editor-meta { .editor-meta {
@@ -1055,8 +1375,8 @@ pre {
} }
.editor-shell { .editor-shell {
height: clamp(420px, 68vh, 760px); height: 100%;
max-height: clamp(420px, 68vh, 760px); max-height: 100%;
min-height: 0; min-height: 0;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius-md); border-radius: var(--radius-md);
@@ -1081,8 +1401,8 @@ pre {
.logs-panel { .logs-panel {
display: grid; display: grid;
grid-template-rows: auto auto minmax(0, 1fr); grid-template-rows: auto auto minmax(0, 1fr);
height: clamp(420px, 68vh, 760px); height: calc(clamp(420px, 68vh, 760px) + 72px);
max-height: clamp(420px, 68vh, 760px); max-height: calc(clamp(420px, 68vh, 760px) + 72px);
overflow: hidden; overflow: hidden;
} }
@@ -1134,6 +1454,11 @@ pre {
font-size: 0.81rem; font-size: 0.81rem;
} }
.route-port-error {
color: #ffb4b4;
line-height: 1.35;
}
.route-form-grid { .route-form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
@@ -1147,6 +1472,38 @@ pre {
} }
.service-discovery-list { .service-discovery-list {
display: grid;
gap: 0.9rem;
}
.service-group-card {
display: grid;
gap: 0.9rem;
padding: 0.95rem;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--bg-elevated);
}
.service-group-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.service-group-copy {
display: grid;
gap: 0.2rem;
min-width: 0;
}
.service-group-copy h3 {
margin: 0;
font-size: 1rem;
}
.service-group-list {
display: grid; display: grid;
gap: 0.65rem; gap: 0.65rem;
} }
@@ -1159,7 +1516,7 @@ pre {
padding: 0.8rem 0.85rem; padding: 0.8rem 0.85rem;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--bg-elevated); background: #0d1116;
} }
.service-discovery-main { .service-discovery-main {
@@ -1230,6 +1587,10 @@ pre {
color: var(--muted); color: var(--muted);
} }
.modal-error {
margin: 0;
}
.modal-stepper { .modal-stepper {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -1317,7 +1678,7 @@ pre {
} }
.option-shell, .option-shell,
.example-shell { .template-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@@ -1366,13 +1727,21 @@ pre {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.template-savebar {
grid-template-columns: 1fr;
}
.health-summary-actions {
margin-left: 0;
}
.editor-shell, .editor-shell,
.code-editor-wrap, .code-editor-wrap,
.code-editor { .code-editor {
min-height: 0; min-height: 0;
} }
.editor-shell, .editor-pane,
.logs-panel { .logs-panel {
height: 500px; height: 500px;
max-height: 500px; max-height: 500px;
@@ -1422,4 +1791,8 @@ pre {
.service-discovery-actions { .service-discovery-actions {
justify-content: flex-start; justify-content: flex-start;
} }
.service-group-head {
display: grid;
}
} }
+33 -1
View File
@@ -21,6 +21,7 @@ export type HealthCheck = {
udp_payload: string; udp_payload: string;
expect_response: boolean; expect_response: boolean;
verify_tls: boolean; verify_tls: boolean;
benchmark_enabled: boolean;
timeout_seconds: number; timeout_seconds: number;
}; };
@@ -55,6 +56,7 @@ export type DockerService = {
export type ServiceDiscoveryResponse = { export type ServiceDiscoveryResponse = {
scan_root: string; scan_root: string;
services: DockerService[]; services: DockerService[];
backend_port: number;
}; };
export type MitmOption = { export type MitmOption = {
@@ -67,17 +69,40 @@ export type MitmOption = {
docs_url?: string | null; docs_url?: string | null;
}; };
export type AddonExample = { export type ProxyTemplate = {
id: string; id: string;
name: string; name: string;
description: string; description: string;
category: string; category: string;
protocol: string;
family: string;
tags: string[];
action: string;
summary: string;
behavior: string;
code: string; code: string;
source_url?: string | null; source_url?: string | null;
built_in: boolean; built_in: boolean;
}; };
export type ProxyTemplateCreateInput = {
id: string;
name: string;
description: string;
category: string;
protocol: string;
family: string;
tags: string[];
action: string;
summary: string;
behavior: string;
code: string;
source_url?: string | null;
};
export type HealthResult = { export type HealthResult = {
proxy_port: number;
timestamp: string;
enabled: boolean; enabled: boolean;
overall: string; overall: string;
checks: Array<{ checks: Array<{
@@ -87,10 +112,17 @@ export type HealthResult = {
protocol: string; protocol: string;
healthy: boolean; healthy: boolean;
error?: string | null; error?: string | null;
duration_ms?: number | null;
details: Record<string, unknown>; details: Record<string, unknown>;
}>; }>;
}; };
export type HealthHistoryResponse = {
proxy_port: number;
latest: HealthResult | null;
history: HealthResult[];
};
export type PythonEditorSession = { export type PythonEditorSession = {
root_uri: string; root_uri: string;
file_uri: string; file_uri: string;
+1 -1
View File
@@ -10,7 +10,7 @@ export default defineConfig({
port: 5173, port: 5173,
proxy: { proxy: {
"/api": { "/api": {
target: "http://127.0.0.1:3871", target: "http://127.0.0.1:3671",
changeOrigin: true, changeOrigin: true,
ws: true, ws: true,
rewrite: (path) => path.replace(/^\/api/, ""), rewrite: (path) => path.replace(/^\/api/, ""),
+1 -1
View File
@@ -3,7 +3,7 @@
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"scripts": { "scripts": {
"dev:backend": "cd backend && PYTHONPATH=app uv run fastapi dev app/main.py --host 0.0.0.0 --port 3671", "dev:backend": "cd backend && PYTHONPATH=. uv run fastapi dev app/main.py --host 0.0.0.0 --port 3671",
"dev:frontend": "cd frontend && npm run dev", "dev:frontend": "cd frontend && npm run dev",
"dev:deps": "./setup.sh deps", "dev:deps": "./setup.sh deps",
"test:backend": "cd backend && uv run pytest", "test:backend": "cd backend && uv run pytest",
-8
View File
@@ -1,8 +0,0 @@
services:
echo:
image: hashicorp/http-echo:1.0
command:
- -listen=:5678
- -text=theplug playground upstream
ports:
- 127.0.0.1:38039:5678
-8
View File
@@ -1,8 +0,0 @@
services:
echo:
image: hashicorp/http-echo:1.0
command:
- -listen=:5678
- -text=theplug playground upstream
ports:
- 9081:5678