137 changed files with 1029 additions and 17086 deletions
+3
View File
@@ -0,0 +1,3 @@
API_PORT=3671
MONGO_INITDB_ROOT_PASSWORD=pass
MONGODB_URL=mongodb://theplug:pass@mongodb:27017/theplug?authSource=admin
+32
View File
@@ -0,0 +1,32 @@
name: Build and Push to Registry
on:
push:
branches:
- master
jobs:
build:
runs-on: ubuntu-latest
container: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.reloia.uk
username: reloia
password: ${{ secrets.LOGIN_TOKEN }}
- name: Build and Push (Shell)
run: |
IMAGE_NAME="git.reloia.uk/reloia/theplug:latest"
echo "Building $IMAGE_NAME..."
docker build -t "$IMAGE_NAME" .
echo "Pushing $IMAGE_NAME..."
docker push "$IMAGE_NAME"
-15
View File
@@ -1,17 +1,2 @@
.idea
.venv/
.ruff_cache/
.vscode/
.env
__pycache__/
.pytest_cache/
**/__pycache__/
**/.pytest_cache/
**/.ruff_cache/
node_modules/
dist/
*.tsbuildinfo
backend/.venv/
frontend/node_modules/
frontend/dist/
playground/
+16
View File
@@ -0,0 +1,16 @@
FROM python:3.14-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY pyproject.toml ./
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir fastapi uvicorn
COPY app ./src
EXPOSE 3671
CMD ["fastapi", "run", "src/main.py", "--host", "0.0.0.0", "--port", "3671"]
+17 -150
View File
@@ -1,167 +1,34 @@
# thePlug
# thePlug - plugger/proxy Barese
Web UI per gestire proxy `mitmdump` davanti a servizi esposti via Docker
Compose.
icona di una presa della spina idk
## Struttura
## Architettura
```text
backend/ FastAPI, MongoDB, mitmdump manager
frontend/ Vue 3, Vite, CodeMirror + Python LSP
playground/ challenge/service compose locali
templates/ template proxy YAML file-backed
```
FastAPI + MongoDB + Redis
```
## Setup iniziale
### Setup Dev
imposta le variabili d'ambiente
```bash
./setup.sh
cp .env.example .env
# modifica le variabili
nano .env
```
`setup.sh` fa bootstrap della macchina/progetto:
- installa `uv` se manca
- installa Python `3.14` via `uv`
- crea `backend/.env` se manca
- installa dipendenze backend e frontend
- builda il frontend
- prepara MongoDB e l'immagine Docker del frontend
## Runtime reale / vulnbox
Questi sono i comandi pensati per l’ambiente reale di gara.
### MongoDB
avvia i servizi e il server locale
```bash
./run.sh mongo up
./run.sh mongo status
./run.sh mongo logs
./run.sh mongo down
docker compose up -d mongodb redis
```
### Backend
```bash
./run.sh backend
fastapi dev app/main.py
```
### Frontend Docker
accedi a FastAPI su: [FastAPI docs](http://localhost:3671/docs)
```bash
./run.sh frontend up
./run.sh frontend status
./run.sh frontend logs
./run.sh frontend down
```
## Documentazione per Sviluppo
Il frontend in container fa proxy API verso il backend in esecuzione sulla
macchina host.
[MitMProxy examples](https://docs.mitmproxy.org/stable/addons/examples/)
## Workflow development
Questi comandi sono pensati per sviluppo locale, test e playground.
### MongoDB
```bash
./dev.sh mongo up
./dev.sh mongo status
./dev.sh mongo logs
./dev.sh mongo down
```
### Backend dev
```bash
./dev.sh backend
```
Docs FastAPI in dev: `http://localhost:3671/docs`.
### Frontend dev
```bash
./dev.sh frontend
```
Questo avvia Vite normalmente sulla macchina host.
### Playground / servizi locali
Per vedere i challenge/service disponibili:
```bash
./dev.sh services list
```
Per avviarli o fermarli:
```bash
./dev.sh services up GabibbiTowers
./dev.sh services down GabibbiTowers
./dev.sh services up all
./dev.sh services status all
./dev.sh services down all
```
`dev.sh` scopre automaticamente i compose da `playground/*/docker-compose.yml`.
## Flussi consigliati
Runtime reale:
```bash
./setup.sh
./run.sh mongo up
./run.sh backend
./run.sh frontend up
```
Sviluppo locale:
```bash
./setup.sh
./dev.sh mongo up
./dev.sh backend
./dev.sh frontend
./dev.sh services up <challenge>
```
## Test
Backend:
```bash
cd backend
uv run ruff check .
uv run pytest
```
Frontend:
```bash
cd frontend
npm run build
```
## API principali
- `POST /auth/login`
- `POST /auth/logout`
- `GET /proxy`
- `POST /proxy`
- `POST /proxy/{port}/start`
- `POST /proxy/{port}/stop`
- `POST /proxy/{port}/restart`
- `GET /proxy/{port}/health`
- `GET /proxy/{port}/logs`
- `WS /proxy/{port}/logs/ws`
- `GET /proxy/mitmproxy/options`
- `GET /proxy/mitmproxy/templates`
## Riferimenti
- Mitmproxy addons: https://docs.mitmproxy.org/stable/addons/examples/
- Mitmproxy modes: https://docs.mitmproxy.org/stable/concepts/modes/
- Mitmproxy options: https://docs.mitmproxy.org/stable/concepts/options/
-1
View File
@@ -1 +0,0 @@
- fixare i test di unità, di integrazione e aggiungere test reali di benchmark.
+21
View File
@@ -0,0 +1,21 @@
from fastapi import APIRouter
router = APIRouter(
prefix="/proxy",
tags=["Proxy"]
)
@router.get("/")
async def list_proxies():
return
@router.post("/")
async def create_proxy():
return
@router.put("/{proxy_port}/")
async def edit_proxy():
return
+14
View File
@@ -0,0 +1,14 @@
from fastapi import FastAPI
from api.proxy import router as proxy_router
app = FastAPI(
title="thePlugAPI"
)
app.include_router(proxy_router)
@app.get("/")
async def root():
return {"Hello": "World!"}
+10
View File
@@ -0,0 +1,10 @@
from utils.proxy.types import ServiceConfig
async def create_proxy(
port: int,
name: str,
service: ServiceConfig,
code: str
):
return
+6
View File
@@ -0,0 +1,6 @@
from typing import TypedDict
class ServiceConfig(TypedDict):
address: str
port: int
-18
View File
@@ -1,18 +0,0 @@
API_PORT=3671
FRONTEND_PORT=5173
# Use a random value in real environments.
MONGO_INITDB_ROOT_PASSWORD=pass
MONGODB_URI=mongodb://theplug:pass@localhost:27017/theplug?authSource=admin
MONGODB_DB_NAME=theplug
# Use a random value in real environments.
JWT_SECRET_KEY=change-me
JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=720
AUTH_USERNAME=admin
# Use a random value in real environments.
AUTH_PASSWORD=change-me
# Optional: override the directory whose immediate child folders are scanned
# for docker-compose files. Defaults to the current user's home directory.
SERVICE_SCAN_ROOT=
HEALTHCHECK_INTERVAL_SECONDS=15
HEALTH_HISTORY_TTL_SECONDS=86400
-3
View File
@@ -1,3 +0,0 @@
from app.api.auth.router import router
__all__ = ["router"]
-42
View File
@@ -1,42 +0,0 @@
from datetime import UTC, datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Response, status
from app.core.security import (
AuthContext,
create_access_token,
get_auth_context,
verify_password,
)
from app.models.auth import LoginInput, TokenResponse
from app.repositories.auth import get_user_by_username, revoke_token
router = APIRouter(
prefix="/auth",
tags=["Auth"],
)
@router.post("/login", response_model=TokenResponse)
async def login_handler(payload: LoginInput):
user = await get_user_by_username(payload.username)
if user is None or not verify_password(payload.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
headers={"WWW-Authenticate": "Bearer"},
)
token = create_access_token(subject=user.username)
return TokenResponse(access_token=token)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout_handler(
auth_context: Annotated[AuthContext, Depends(get_auth_context)],
) -> Response:
expires_at = datetime.fromtimestamp(auth_context.payload.exp, tz=UTC)
if expires_at > datetime.now(UTC):
await revoke_token(jti=auth_context.payload.jti, expires_at=expires_at)
return Response(status_code=status.HTTP_204_NO_CONTENT)
-63
View File
@@ -1,63 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
from starlette.websockets import WebSocketDisconnect
from app.core.security import (
InvalidTokenError,
authenticate_token,
require_authenticated_user,
)
from app.services.editor import python_lsp
router = APIRouter(prefix="/proxy/editor/python", tags=["Editor"])
@router.get("/session", dependencies=[Depends(require_authenticated_user)])
async def python_editor_session():
if python_lsp.lsp_binary() is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="basedpyright-langserver is not installed",
)
session = python_lsp.get_session()
return {
"root_uri": session.root_uri,
"file_uri": session.file_uri,
}
async def authenticate_editor_socket(websocket: WebSocket) -> None:
token = websocket.query_params.get("token")
if token is None:
authorization = websocket.headers.get("authorization", "")
scheme, _, value = authorization.partition(" ")
if scheme.lower() == "bearer" and value:
token = value
if not token:
raise InvalidTokenError
await authenticate_token(token)
@router.websocket("/ws")
async def python_editor_socket(websocket: WebSocket):
try:
await authenticate_editor_socket(websocket)
except InvalidTokenError:
await websocket.close(code=4401)
return
if python_lsp.lsp_binary() is None:
await websocket.close(code=1011, reason="basedpyright unavailable")
return
session = python_lsp.get_session()
await websocket.accept()
try:
await python_lsp.bridge(websocket, session)
except WebSocketDisconnect:
return
except RuntimeError:
await websocket.close(code=1011, reason="language server failed")
-504
View File
@@ -1,504 +0,0 @@
import asyncio
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
from pymongo.errors import DuplicateKeyError
from starlette.websockets import WebSocketDisconnect
from app.api.proxy.socket_utils import (
authenticate_proxy_socket,
proxy_log_path,
read_log_snapshot,
serialize_health_snapshot,
snapshot_log_state,
stream_proxy_logs,
watch_socket_disconnect,
)
from app.core.config import settings
from app.core.security import InvalidTokenError, require_authenticated_user
from app.integrations.docker import get_all_services_in_dir
from app.integrations.docker.discovery import service_scan_root
from app.models.health import HealthHistoryResponse
from app.models.proxy import ProxyCreateInput, ProxyUpdateInput
from app.models.template import ProxyTemplateCreateInput
from app.repositories.health import (
delete_health_history,
get_latest_health_sample,
list_health_history,
record_health_sample,
)
from app.repositories.proxy import (
create_proxy,
delete_proxy,
get_proxy_by_port,
update_proxy,
update_proxy_runtime,
)
from app.repositories.proxy import (
list_proxies as list_proxy_documents,
)
from app.repositories.templates import (
create_custom_template,
delete_custom_template,
list_custom_templates,
)
from app.services.mitmproxy.health import check_proxy
from app.services.mitmproxy.health_monitor import health_monitor
from app.services.mitmproxy.manager import MitmdumpError, manager
from app.services.mitmproxy.options import (
MitmOptionError,
load_options,
public_options,
validate_options,
)
from app.services.mitmproxy.runtime_validation import (
ReservedProxyPortError,
backend_port,
ensure_proxy_port_not_reserved,
)
from app.services.mitmproxy.templates import file_template_ids, list_file_templates
from app.services.mitmproxy.validator import (
ProxyCodeValidationError,
validate_proxy_code,
)
SUPPORTED_MODES = {
"regular",
"local",
"wireguard",
"reverse",
"transparent",
"tun",
"upstream",
"socks5",
"dns",
}
SUPPORTED_PROTOCOLS = {
"http",
"https",
"tcp",
"tls",
"udp",
"dtls",
"dns",
"http3",
"quic",
}
DEFAULT_HEALTH_HISTORY_LIMIT = 40
MAX_HEALTH_HISTORY_LIMIT = 200
router = APIRouter(
prefix="/proxy",
tags=["Proxy"],
)
auth_dependencies = [Depends(require_authenticated_user)]
async def _validate_code_or_raise(code: str) -> None:
try:
await validate_proxy_code(code)
except ProxyCodeValidationError as error:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=error.to_http_detail(),
) from error
def validate_mode(mode: str) -> None:
if mode not in SUPPORTED_MODES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Unsupported proxy mode: {mode}",
)
def validate_mode_spec(mode_spec: str | None) -> None:
if mode_spec is None:
return
mode = mode_spec.split(":", 1)[0].split("@", 1)[0]
validate_mode(mode)
def validate_protocol(protocol: str) -> None:
if protocol not in SUPPORTED_PROTOCOLS:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Unsupported reverse proxy protocol: {protocol}",
)
async def validate_mitm_options(options: dict[str, object]) -> None:
try:
await validate_options(options)
except MitmOptionError as error:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(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):
proxy = await get_proxy_by_port(proxy_port)
if proxy is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Proxy not found",
)
return proxy
@router.get("", dependencies=auth_dependencies)
async def list_proxies_handler():
return await list_proxy_documents()
@router.get("/services", dependencies=auth_dependencies)
async def list_services(scan_root: str | None = None):
_ = settings
root = Path(scan_root).expanduser() if scan_root else service_scan_root()
services = get_all_services_in_dir(root)
return {
"scan_root": str(root),
"services": services,
"backend_port": backend_port(),
}
@router.get("/mitmproxy/options", dependencies=auth_dependencies)
async def list_mitmproxy_options():
return public_options(await load_options())
@router.get("/mitmproxy/templates", dependencies=auth_dependencies)
async def list_mitmproxy_templates():
return [*list_file_templates(), *await list_custom_templates()]
@router.post(
"/mitmproxy/templates",
status_code=status.HTTP_201_CREATED,
dependencies=auth_dependencies,
)
async def create_mitmproxy_template(payload: ProxyTemplateCreateInput):
await _validate_code_or_raise(payload.code)
if payload.id in file_template_ids():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Template already exists: {payload.id}",
)
try:
return await create_custom_template(payload)
except DuplicateKeyError as error:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Template already exists: {payload.id}",
) from error
@router.delete(
"/mitmproxy/templates/{template_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=auth_dependencies,
)
async def delete_mitmproxy_template(template_id: str):
if template_id in file_template_ids():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Built-in templates cannot be deleted",
)
deleted = await delete_custom_template(template_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Template not found",
)
@router.get("/{proxy_port}", dependencies=auth_dependencies)
async def get_proxy(proxy_port: int):
return await get_proxy_or_404(proxy_port)
@router.post("", status_code=status.HTTP_201_CREATED, dependencies=auth_dependencies)
async def create_proxy_handler(payload: ProxyCreateInput):
validate_proxy_port(payload.proxy_port)
existing_proxy = await get_proxy_by_port(payload.proxy_port)
if existing_proxy is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Proxy already exists on port {payload.proxy_port}",
)
await _validate_code_or_raise(payload.code)
validate_mode(payload.mode)
validate_mode_spec(payload.mode_spec)
validate_protocol(payload.protocol)
await validate_mitm_options(payload.mitm_options)
return await create_proxy(payload)
@router.put("/{proxy_port}", dependencies=auth_dependencies)
async def edit_proxy(proxy_port: int, payload: ProxyUpdateInput):
if payload.code is not None:
await _validate_code_or_raise(payload.code)
if payload.mode is not None:
validate_mode(payload.mode)
if "mode_spec" in payload.model_fields_set:
validate_mode_spec(payload.mode_spec)
if payload.protocol is not None:
validate_protocol(payload.protocol)
if payload.mitm_options is not None:
await validate_mitm_options(payload.mitm_options)
updated_proxy = await update_proxy(proxy_port, payload)
if updated_proxy is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Proxy not found",
)
return updated_proxy
@router.post("/{proxy_port}/start", dependencies=auth_dependencies)
async def start_proxy(proxy_port: int):
proxy = await get_proxy_or_404(proxy_port)
try:
runtime = await manager.start(proxy)
except MitmdumpError as error:
updated = await update_proxy_runtime(
proxy_port,
{"status": "error", "last_error": str(error)},
)
return updated or proxy
return await update_proxy_runtime(proxy_port, runtime)
@router.post("/{proxy_port}/stop", dependencies=auth_dependencies)
async def stop_proxy(proxy_port: int):
proxy = await get_proxy_or_404(proxy_port)
runtime = await manager.stop(proxy)
return await update_proxy_runtime(proxy_port, runtime)
@router.post("/{proxy_port}/restart", dependencies=auth_dependencies)
async def restart_proxy(proxy_port: int):
proxy = await get_proxy_or_404(proxy_port)
stopped = await manager.stop(proxy)
proxy = await update_proxy_runtime(proxy_port, stopped) or proxy
try:
runtime = await manager.start(proxy)
except MitmdumpError as error:
updated = await update_proxy_runtime(
proxy_port,
{"status": "error", "last_error": str(error)},
)
return updated or proxy
return await update_proxy_runtime(proxy_port, runtime)
@router.get("/{proxy_port}/status", dependencies=auth_dependencies)
async def proxy_status(proxy_port: int):
proxy = await get_proxy_or_404(proxy_port)
runtime_status = await manager.status(proxy)
if runtime_status != proxy.status:
values: dict[str, object] = {"status": runtime_status}
if runtime_status != "running":
values["pid"] = None
proxy = await update_proxy_runtime(proxy_port, values) or proxy
return {"proxy_port": proxy.proxy_port, "status": runtime_status, "pid": proxy.pid}
@router.get("/{proxy_port}/health", dependencies=auth_dependencies)
async def proxy_health(proxy_port: int):
proxy = await get_proxy_or_404(proxy_port)
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)
async def proxy_logs(proxy_port: int):
proxy = await get_proxy_or_404(proxy_port)
path = proxy_log_path(proxy)
return {
"proxy_port": proxy.proxy_port,
"log": await read_log_snapshot(path),
}
@router.websocket("/{proxy_port}/logs/ws")
async def proxy_logs_socket(websocket: WebSocket, proxy_port: int):
disconnect_task: asyncio.Task[None] | None = None
stream_task: asyncio.Task[None] | None = None
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
path = proxy_log_path(proxy)
initial_snapshot, offset, marker = await snapshot_log_state(path)
await websocket.accept()
await websocket.send_json({"type": "snapshot", "log": initial_snapshot})
disconnect_task = asyncio.create_task(watch_socket_disconnect(websocket))
stream_task = asyncio.create_task(
stream_proxy_logs(websocket, path, offset=offset, marker=marker)
)
done, pending = await asyncio.wait(
{disconnect_task, stream_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="log stream failed")
return
except asyncio.CancelledError:
return
finally:
for task in (disconnect_task, stream_task):
if task is None or task.done():
continue
task.cancel()
@router.websocket("/{proxy_port}/health/ws")
async def proxy_health_socket(websocket: WebSocket, proxy_port: int):
disconnect_task: asyncio.Task[None] | None = None
queue_task: asyncio.Task[None] | None = None
queue = None
async def forward_health() -> None:
assert queue is not None
while True:
snapshot = await queue.get()
await websocket.send_json(serialize_health_snapshot(snapshot))
try:
try:
await authenticate_proxy_socket(websocket)
except InvalidTokenError:
await websocket.close(code=4401)
return
proxy = await get_proxy_by_port(proxy_port)
if proxy is None:
await websocket.close(code=4404)
return
await websocket.accept()
latest = await get_latest_health_sample(proxy.proxy_port)
if latest is None:
latest = await check_proxy(proxy)
await websocket.send_json(serialize_health_snapshot(latest))
queue = health_monitor.subscribe(proxy.proxy_port)
disconnect_task = asyncio.create_task(watch_socket_disconnect(websocket))
queue_task = asyncio.create_task(forward_health())
done, pending = await asyncio.wait(
{disconnect_task, queue_task},
return_when=asyncio.FIRST_EXCEPTION,
)
for task in pending:
task.cancel()
if pending:
await asyncio.gather(*pending, return_exceptions=True)
for task in done:
try:
task.result()
except asyncio.CancelledError:
return
except WebSocketDisconnect:
return
except RuntimeError:
await websocket.close(code=1011, reason="health stream failed")
return
except asyncio.CancelledError:
return
finally:
if queue is not None:
health_monitor.unsubscribe(proxy_port, queue)
for task in (disconnect_task, queue_task):
if task is None or task.done():
continue
task.cancel()
@router.delete(
"/{proxy_port}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=auth_dependencies,
)
async def remove_proxy(proxy_port: int):
proxy = await get_proxy_by_port(proxy_port)
if proxy is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Proxy not found",
)
await manager.stop(proxy)
manager.cleanup_runtime_artifacts(proxy)
await delete_health_history(proxy.proxy_port)
deleted = await delete_proxy(proxy_port)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Proxy not found",
)
-127
View File
@@ -1,127 +0,0 @@
import asyncio
from pathlib import Path
from fastapi import WebSocket
from app.core.security import InvalidTokenError, authenticate_token
from app.models.health import HealthSnapshot
from app.services.mitmproxy.manager import manager
async def authenticate_proxy_socket(websocket: WebSocket) -> None:
token = websocket.query_params.get("token")
if token is None:
authorization = websocket.headers.get("authorization", "")
scheme, _, value = authorization.partition(" ")
if scheme.lower() == "bearer" and value:
token = value
if not token:
raise InvalidTokenError
await authenticate_token(token)
def proxy_log_path(proxy) -> Path:
if proxy.log_path:
return Path(proxy.log_path)
return manager.log_path_for(proxy)
def serialize_health_snapshot(
snapshot: HealthSnapshot | None,
) -> dict[str, object] | None:
if snapshot is None:
return None
return snapshot.model_dump(mode="json")
async def read_log_snapshot(path: Path, limit: int = 8000) -> str:
snapshot, _offset, _marker = await snapshot_log_state(path, limit=limit)
return snapshot
async def snapshot_log_state(
path: Path,
limit: int = 8000,
marker_width: int = 64,
) -> tuple[str, int, bytes]:
if not await asyncio.to_thread(path.is_file):
return "", 0, b""
data = await asyncio.to_thread(path.read_bytes)
size = len(data)
marker = data[max(0, size - marker_width) : size]
snapshot = data[-limit:].decode("utf-8", errors="replace")
return snapshot, size, marker
def _read_log_marker_bytes(path: Path, offset: int, width: int) -> bytes:
if offset <= 0 or not path.is_file():
return b""
start = max(0, offset - width)
with path.open("rb") as handle:
handle.seek(start)
return handle.read(offset - start)
async def read_log_marker(path: Path, offset: int, width: int = 64) -> bytes:
return await asyncio.to_thread(_read_log_marker_bytes, path, offset, width)
async def watch_socket_disconnect(websocket: WebSocket) -> None:
while True:
await websocket.receive_text()
async def stream_proxy_logs(
websocket: WebSocket,
path: Path,
*,
offset: int,
marker: bytes,
) -> None:
while True:
if not await asyncio.to_thread(path.exists):
await asyncio.sleep(0.4)
continue
try:
size = await asyncio.to_thread(lambda: path.stat().st_size)
except OSError:
await asyncio.sleep(0.4)
continue
if marker and size >= offset and await read_log_marker(path, offset) != marker:
snapshot, offset, marker = await snapshot_log_state(path)
await websocket.send_json({"type": "snapshot", "log": snapshot})
await asyncio.sleep(0.4)
continue
if size < offset:
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:
def read_log_chunk(
chunk_offset: int = offset,
chunk_size: int = size,
) -> str:
with path.open("rb") as handle:
handle.seek(chunk_offset)
return handle.read(chunk_size - chunk_offset).decode(
"utf-8",
errors="replace",
)
chunk = await asyncio.to_thread(read_log_chunk)
offset = size
marker = await read_log_marker(path, offset)
if chunk:
await websocket.send_json({"type": "append", "log": chunk})
await asyncio.sleep(0.4)
-26
View File
@@ -1,26 +0,0 @@
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=(".env", "backend/.env"),
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
MONGODB_URI: str
MONGODB_DB_NAME: str
JWT_SECRET_KEY: str
JWT_ALGORITHM: str = "HS256"
JWT_EXPIRE_MINUTES: int = Field(default=720, gt=0)
AUTH_USERNAME: str
AUTH_PASSWORD: str
API_PORT: int = Field(default=3671, ge=1, le=65535)
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() # pyright: ignore[reportCallIssue]
-40
View File
@@ -1,40 +0,0 @@
from typing import Any
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
from app.core.config import settings
class _MongoDB:
client: AsyncIOMotorClient[dict[str, Any]] | None = None
def get_database(self) -> AsyncIOMotorDatabase[dict[str, Any]]:
if self.client is None:
raise RuntimeError("MongoDB client has not been initialized")
return self.client[settings.MONGODB_DB_NAME]
mongodb = _MongoDB()
async def init_mongodb() -> None:
mongodb.client = AsyncIOMotorClient(settings.MONGODB_URI)
await ensure_mongodb_available()
async def close_mongodb() -> None:
client = mongodb.client
mongodb.client = None
if client is not None:
client.close()
async def get_database() -> AsyncIOMotorDatabase[dict[str, Any]]:
return mongodb.get_database()
async def ensure_mongodb_available() -> None:
client = mongodb.client
if client is None:
raise RuntimeError("MongoDB client has not been initialized")
await client.admin.command("ping")
-115
View File
@@ -1,115 +0,0 @@
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Annotated
from uuid import uuid4
from authlib.jose import jwt # type: ignore
from authlib.jose.errors import JoseError # type: ignore
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pwdlib import PasswordHash
from pwdlib.hashers.argon2 import Argon2Hasher
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(),))
bearer_scheme = HTTPBearer(auto_error=False)
@dataclass(slots=True)
class AuthContext:
token: str
user: AuthUserDocument
payload: AccessTokenPayload
class InvalidTokenError(Exception):
pass
def token_auth_error() -> HTTPException:
return HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password: str, password_hash: str) -> bool:
return pwd_context.verify(plain_password, password_hash)
def create_access_token(*, subject: str) -> str:
now = datetime.now(UTC)
expires_delta = timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
expires_at = int((now + expires_delta).timestamp())
header = {
"alg": settings.JWT_ALGORITHM,
"typ": "JWT",
}
payload = {
"sub": subject,
"jti": str(uuid4()),
"exp": expires_at,
}
token = jwt.encode(header, payload, settings.JWT_SECRET_KEY)
if isinstance(token, bytes):
return token.decode("utf-8")
return token
def decode_access_token(token: str) -> AccessTokenPayload:
try:
claims = jwt.decode(token, settings.JWT_SECRET_KEY)
claims.validate()
return AccessTokenPayload.model_validate(dict(claims))
except JoseError, ValueError, TypeError:
raise InvalidTokenError from None
async def authenticate_token(token: str) -> AuthContext:
try:
payload = decode_access_token(token)
except InvalidTokenError:
raise InvalidTokenError from None
if await is_token_revoked(payload.jti):
raise InvalidTokenError
user = await get_user_by_username(payload.sub)
if user is None:
raise InvalidTokenError
return AuthContext(token=token, user=user, payload=payload)
async def get_auth_context(
credentials: Annotated[
HTTPAuthorizationCredentials | None,
Depends(bearer_scheme),
],
) -> AuthContext:
if credentials is None or credentials.scheme.lower() != "bearer":
raise token_auth_error()
try:
return await authenticate_token(credentials.credentials)
except InvalidTokenError:
raise token_auth_error() from None
async def require_authenticated_user(
auth_context: Annotated[AuthContext, Depends(get_auth_context)],
) -> AuthUserDocument:
return auth_context.user
@@ -1,15 +0,0 @@
from app.integrations.docker.compose import (
find_docker_compose,
get_all_services_in_dir,
get_services,
)
from app.integrations.docker.editor import change_port
from app.integrations.docker.models import DockerContainer
__all__ = [
"DockerContainer",
"find_docker_compose",
"get_services",
"get_all_services_in_dir",
"change_port",
]
-135
View File
@@ -1,135 +0,0 @@
import asyncio
from pathlib import Path
import yaml
from app.integrations.docker.models import DockerContainer, DockerPortBinding
from app.integrations.docker.ports import _get_service_binding
COMPOSE_NAMES = (
"compose.yml",
"compose.yaml",
"docker-compose.yml",
"docker-compose.yaml",
)
type ComposeDocument = dict[str, object]
def _get_visible_folders(directory: Path) -> list[Path]:
try:
entries = list(Path(directory).iterdir())
except OSError:
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[ComposeDocument, Path] | None:
try:
if not path or not path.is_dir():
return None
except OSError:
return None
for name in COMPOSE_NAMES:
file = path / name
try:
if file.is_file():
content = yaml.safe_load(file.read_text()) or {}
return content, file
except OSError:
continue
return None
def get_services(folder: Path) -> list[DockerContainer] | None:
services: list[DockerContainer] = []
compose_data = find_docker_compose(folder)
if compose_data is None:
return None
docker_compose, _ = compose_data
compose_services = docker_compose.get("services") or {}
if not isinstance(compose_services, dict) or not compose_services:
return None
for service_name, config in compose_services.items():
mappings = config.get("ports") if isinstance(config, dict) else None
if not isinstance(mappings, list) or not mappings:
continue
bindings: list[DockerPortBinding] = [
{"published": published, "host": host}
for published, host in (
_get_service_binding(mapping) for mapping in mappings
)
if published is not None
]
if not bindings:
continue
services.append(
{
"name": service_name,
"ports": [binding["published"] for binding in bindings],
"bindings": bindings,
"folder": folder,
}
)
return services
def get_all_services_in_dir(
directory: str | Path,
) -> list[DockerContainer]:
path = Path(directory)
try:
if not path.is_dir():
return []
except OSError:
return []
services: list[DockerContainer] = []
for sub_folder in _get_visible_folders(path):
sub_services = get_services(sub_folder)
if sub_services:
services.extend(sub_services)
return services
async def run_docker_command(
file: Path,
command: list[str],
) -> dict[str, str] | None:
if not command:
return None
full_command = ["docker", "compose", "-f", f"{file}", *command]
process = await asyncio.create_subprocess_exec(
*full_command,
cwd=str(file.parent),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode == 0:
return {"status": "success", "output": stdout.decode()}
else:
return {"status": "error", "reason": stderr.decode()}
@@ -1,10 +0,0 @@
from pathlib import Path
from app.core.config import settings
def service_scan_root() -> Path:
configured = settings.SERVICE_SCAN_ROOT
if configured:
return Path(configured).expanduser()
return Path.home()
-85
View File
@@ -1,85 +0,0 @@
import asyncio
from pathlib import Path
import yaml
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 _render_compose(document: ComposeDocument) -> str:
return yaml.dump(document, default_flow_style=False, sort_keys=False)
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(
service_name: str,
folder: Path,
port_from: int,
port_to: int,
host_to: str | None = None,
rewrite_host: bool = False,
) -> bool:
compose_data = find_docker_compose(folder)
if compose_data is None:
return False
docker_compose, compose_path = compose_data
compose_services = docker_compose.get("services") or {}
if not isinstance(compose_services, dict) or not compose_services:
return False
service_config = compose_services.get(service_name)
if not isinstance(service_config, dict):
return False
mappings = service_config.get("ports")
if not isinstance(mappings, list):
return False
idx = _get_port_index(mappings, port_from)
if idx is None:
return False
mappings[idx] = _replace_port_in_mapping(
mappings[idx],
port_from,
port_to,
host_to=host_to,
rewrite_host=rewrite_host,
)
original_text = await asyncio.to_thread(_read_compose_text, compose_path)
updated_text = _render_compose(docker_compose)
try:
await asyncio.to_thread(_write_compose, compose_path, updated_text)
result = await run_docker_command(
compose_path,
["up", "-d", service_name],
)
except Exception:
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":
await asyncio.to_thread(_write_compose, compose_path, original_text)
return False
return True
-14
View File
@@ -1,14 +0,0 @@
from pathlib import Path
from typing import TypedDict
class DockerPortBinding(TypedDict):
published: int
host: str | None
class DockerContainer(TypedDict):
name: str
ports: list[int]
bindings: list[DockerPortBinding]
folder: Path
-102
View File
@@ -1,102 +0,0 @@
import socket
from collections.abc import Mapping
def get_random_port() -> int:
# Binding to port 0 asks the OS for an available ephemeral port.
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("", 0))
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
port = s.getsockname()[1]
return port
except Exception as e:
raise RuntimeError(f"Could not find an available port: {e}") from e
def _get_service_binding(
mapping: str | int | Mapping[str, object],
) -> tuple[int | None, str | None]:
if isinstance(mapping, int):
return mapping, None
if isinstance(mapping, Mapping):
published = mapping.get("published")
if published is None:
return None, None
if not isinstance(published, (str, int)):
return None, None
try:
port = int(published)
except ValueError:
return None, None
host = mapping.get("host_ip")
return port, str(host) if host not in {None, ""} else None
parts = str(mapping).split(":")
match parts:
case [""]:
return None, None
case [port] | [port, _]:
try:
return int(port), None
except ValueError:
return None, None
case [host, port, _]:
try:
return int(port), host or None
except ValueError:
return None, None
case _:
return None, None
def _get_service_port(mapping: str | int | Mapping[str, object]) -> int | None:
port, _host = _get_service_binding(mapping)
return port
def _replace_port_in_mapping(
mapping: str | int | Mapping[str, object],
port_from: int,
port_to: int,
host_to: str | None = None,
rewrite_host: bool = False,
) -> str | dict[str, object]:
if isinstance(mapping, Mapping):
updated = dict(mapping)
if _get_service_port(updated) == port_from:
updated["published"] = str(port_to)
if rewrite_host:
if host_to is None:
updated.pop("host_ip", None)
else:
updated["host_ip"] = host_to
return updated
parts = str(mapping).split(":")
match parts:
case [_]:
return str(port_to)
case [_, container_port]:
if rewrite_host and host_to:
return f"{host_to}:{port_to}:{container_port}"
return f"{port_to}:{container_port}"
case [ip, _, container_port]:
if rewrite_host:
if host_to:
return f"{host_to}:{port_to}:{container_port}"
return f"{port_to}:{container_port}"
return f"{ip}:{port_to}:{container_port}"
case _:
return str(port_to)
def _get_port_index(
mappings: list[str | int | Mapping[str, object]],
port: int,
) -> int | None:
for i, mapping in enumerate(mappings):
if _get_service_port(mapping) == port:
return i
return None
-83
View File
@@ -1,83 +0,0 @@
import os
import sys
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.api.auth.router import router as auth_router
from app.api.editor.router import router as editor_router
from app.api.proxy.router import router as proxy_router
from app.core.config import settings
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
async def lifespan(_: FastAPI):
await init_mongodb()
await ensure_auth_indexes()
await ensure_single_account(
username=settings.AUTH_USERNAME,
password_hash=hash_password(settings.AUTH_PASSWORD),
)
await ensure_proxy_indexes()
await ensure_template_indexes()
await ensure_health_indexes()
recovered = await reconcile_runtime()
if recovered:
recovered_count = len(recovered)
error_count = sum(
1
for _proxy_port, values in recovered
if values.get("status") == "error"
)
print(
f"[thePlug] Reconciled {recovered_count} persisted proxy runtimes"
f" ({error_count} with errors).",
file=sys.stderr,
)
scan_root = service_scan_root()
services = get_all_services_in_dir(scan_root) or []
if not services:
warning = (
"\n" + "!" * 60 + "\n"
" WARNING: No services found in scan root!\n"
f" Scanned: {scan_root}\n" + "!" * 60 + "\n"
)
print(warning, file=sys.stderr)
try:
await health_monitor.start()
yield
finally:
await health_monitor.stop()
await close_mongodb()
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
app = FastAPI(
title="thePlugAPI",
lifespan=lifespan,
docs_url="/docs" if DEBUG else None,
redoc_url="/redoc" if DEBUG else None,
openapi_url="/openapi.json" if DEBUG else None,
)
app.include_router(auth_router)
app.include_router(editor_router)
app.include_router(proxy_router)
@app.get("/")
async def root():
return {"Hello": "World!"}
View File
-43
View File
@@ -1,43 +0,0 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class AuthUserDocument(BaseModel):
model_config = ConfigDict(extra="forbid")
username: str = Field(
...,
description="Unique username for the single account",
)
password_hash: str = Field(..., description="Hashed password")
created_at: datetime | None = Field(
default=None,
description="Creation timestamp in UTC",
)
updated_at: datetime | None = Field(
default=None,
description="Last update timestamp in UTC",
)
class LoginInput(BaseModel):
model_config = ConfigDict(extra="forbid")
username: str = Field(..., description="Username")
password: str = Field(..., description="Plain text password")
class TokenResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
access_token: str = Field(..., description="JWT access token")
token_type: str = Field(default="bearer", description="Token type")
class AccessTokenPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
sub: str = Field(..., description="Subject username")
jti: str = Field(..., description="Unique token identifier")
exp: int = Field(..., description="Token expiration as unix timestamp")
-41
View File
@@ -1,41 +0,0 @@
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)
-120
View File
@@ -1,120 +0,0 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from app.models.service import ServiceEndpointDocument
class HealthCheckDocument(BaseModel):
model_config = ConfigDict(extra="forbid")
enabled: bool = Field(default=True, description="Enable active health checks")
protocol: str | None = Field(
default=None,
description="Override protocol used by the health check",
)
path: str = Field(default="/", description="HTTP path to check")
method: str = Field(default="GET", description="HTTP method to use")
expected_status: int | None = Field(
default=None,
description="Expected HTTP status code",
)
udp_payload: str = Field(default="", description="UDP payload to send")
expect_response: bool = Field(
default=True,
description="Wait for a response when the protocol supports it",
)
verify_tls: bool = Field(
default=False,
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(
default=2.0,
gt=0,
le=30,
description="Per-target health check timeout",
)
class ProxyDocument(BaseModel):
model_config = ConfigDict(extra="forbid")
proxy_port: int = Field(..., description="Port exposed by the proxy")
name: str = Field(..., description="Human readable proxy name")
code: str = Field(..., description="Proxy source or identifier")
mode: str = Field(default="reverse", description="mitmproxy proxy mode")
mode_spec: str | None = Field(
default=None,
description="Raw mitmproxy mode specification. Overrides mode/protocol target.",
)
protocol: str = Field(default="http", description="mitmproxy reverse protocol")
listen_host: str = Field(default="0.0.0.0", description="Proxy listen host")
mitm_options: dict[str, Any] = Field(
default_factory=dict,
description="mitmdump options passed through --set",
)
healthcheck: HealthCheckDocument = Field(default_factory=HealthCheckDocument)
status: str = Field(default="stopped", description="Runtime proxy status")
pid: int | None = Field(default=None, description="mitmdump process id")
script_path: str | None = Field(default=None, description="Generated addon path")
config_path: str | None = Field(default=None, description="Generated config path")
log_path: str | None = Field(default=None, description="mitmdump log path")
last_error: str | None = Field(default=None, description="Last runtime error")
service: ServiceEndpointDocument = Field(
...,
description="Associated upstream service",
)
created_at: datetime | None = Field(
default=None,
description="Creation timestamp in UTC",
)
updated_at: datetime | None = Field(
default=None,
description="Last update timestamp in UTC",
)
class ProxyCreateInput(BaseModel):
model_config = ConfigDict(extra="forbid")
proxy_port: int = Field(..., description="Port exposed by the proxy")
name: str = Field(..., description="Human readable proxy name")
code: str = Field(..., description="Proxy source or identifier")
mode: str = Field(default="reverse", description="mitmproxy proxy mode")
mode_spec: str | None = Field(
default=None,
description="Raw mitmproxy mode specification. Overrides mode/protocol target.",
)
protocol: str = Field(default="http", description="mitmproxy reverse protocol")
listen_host: str = Field(default="0.0.0.0", description="Proxy listen host")
mitm_options: dict[str, Any] = Field(default_factory=dict)
healthcheck: HealthCheckDocument = Field(default_factory=HealthCheckDocument)
service: ServiceEndpointDocument = Field(
...,
description="Associated upstream service",
)
class ProxyUpdateInput(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str | None = Field(default=None, description="Human readable proxy name")
code: str | None = Field(default=None, description="Proxy source or identifier")
mode: str | None = Field(default=None, description="mitmproxy proxy mode")
mode_spec: str | None = Field(
default=None,
description="Raw mitmproxy mode specification. Overrides mode/protocol target.",
)
protocol: str | None = Field(default=None, description="mitmproxy reverse protocol")
listen_host: str | None = Field(default=None, description="Proxy listen host")
mitm_options: dict[str, Any] | None = Field(default=None)
healthcheck: HealthCheckDocument | None = Field(default=None)
service: ServiceEndpointDocument | None = Field(
default=None,
description="Associated upstream service",
)
-21
View File
@@ -1,21 +0,0 @@
from pydantic import BaseModel, ConfigDict, Field
class ServiceEndpointDocument(BaseModel):
model_config = ConfigDict(extra="forbid")
host: str = Field(default="127.0.0.1", description="Upstream service host")
port: int = Field(..., description="Original upstream service port")
bind_host: str | None = Field(
default=None,
description="Original bind host exposed by Docker Compose",
)
name: str | None = Field(default=None, description="Docker Compose service name")
folder: str | None = Field(
default=None,
description="Docker Compose project folder",
)
upstream_port: int | None = Field(
default=None,
description="Current port used by mitmdump as upstream",
)
-46
View File
@@ -1,46 +0,0 @@
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")
origin: str = Field(default="custom", description="Template source")
created_at: datetime | None = None
updated_at: datetime | None = None
source_path: str | None = Field(
default=None,
description="Filesystem source path",
exclude=True,
)
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")
-103
View File
@@ -1,103 +0,0 @@
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.auth import AuthUserDocument
USERS_COLLECTION_NAME = "auth_users"
REVOKED_COLLECTION_NAME = "revoked_tokens"
def utcnow() -> datetime:
return datetime.now(UTC)
async def users_collection() -> AsyncIOMotorCollection[dict[str, Any]]:
database = await get_database()
return database[USERS_COLLECTION_NAME]
async def revoked_tokens_collection() -> AsyncIOMotorCollection[dict[str, Any]]:
database = await get_database()
return database[REVOKED_COLLECTION_NAME]
def deserialize_user(raw: Mapping[str, Any] | None) -> AuthUserDocument | None:
if raw is None:
return None
document = dict(raw)
document.pop("_id", None)
return AuthUserDocument.model_validate(document)
async def ensure_auth_indexes() -> None:
users = await users_collection()
revoked = await revoked_tokens_collection()
await users.create_index("username", unique=True, name="uq_auth_username")
await revoked.create_index("jti", unique=True, name="uq_revoked_jti")
await revoked.create_index(
"expires_at",
expireAfterSeconds=0,
name="ttl_revoked_tokens",
)
async def ensure_single_account(*, username: str, password_hash: str) -> None:
users = await users_collection()
now = utcnow()
current = await users.find_one({}, sort=[("created_at", 1)])
if current is None:
await users.insert_one(
{
"username": username,
"password_hash": password_hash,
"created_at": now,
"updated_at": now,
}
)
return
current_id = current["_id"]
await users.update_one(
{"_id": current_id},
{
"$set": {
"username": username,
"password_hash": password_hash,
"updated_at": now,
}
},
)
await users.delete_many({"_id": {"$ne": current_id}})
async def get_user_by_username(username: str) -> AuthUserDocument | None:
users = await users_collection()
raw = await users.find_one({"username": username})
return deserialize_user(raw)
async def revoke_token(*, jti: str, expires_at: datetime) -> None:
revoked = await revoked_tokens_collection()
await revoked.update_one(
{"jti": jti},
{
"$set": {
"jti": jti,
"expires_at": expires_at,
"revoked_at": utcnow(),
}
},
upsert=True,
)
async def is_token_revoked(jti: str) -> bool:
revoked = await revoked_tokens_collection()
raw = await revoked.find_one({"jti": jti}, projection={"_id": 1})
return raw is not None
-80
View File
@@ -1,80 +0,0 @@
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})
-114
View File
@@ -1,114 +0,0 @@
from collections.abc import Mapping
from datetime import UTC, datetime
from typing import Any
from motor.motor_asyncio import AsyncIOMotorCollection
from pymongo import ReturnDocument
from app.core.mongodb import get_database
from app.models.proxy import ProxyCreateInput, ProxyDocument, ProxyUpdateInput
COLLECTION_NAME = "proxies"
def _utcnow() -> datetime:
return datetime.now(UTC)
async def _collection() -> AsyncIOMotorCollection[dict[str, Any]]:
database = await get_database()
return database[COLLECTION_NAME]
def _deserialize_proxy(raw: Mapping[str, Any] | None) -> ProxyDocument | None:
if raw is None:
return None
document = dict(raw)
document.pop("_id", None)
return ProxyDocument.model_validate(document)
async def ensure_proxy_indexes() -> None:
collection = await _collection()
await collection.create_index("proxy_port", unique=True, name="uq_proxy_port")
async def create_proxy(payload: ProxyCreateInput) -> ProxyDocument:
now = _utcnow()
proxy = ProxyDocument(
proxy_port=payload.proxy_port,
name=payload.name,
code=payload.code,
mode=payload.mode,
mode_spec=payload.mode_spec,
protocol=payload.protocol,
listen_host=payload.listen_host,
mitm_options=payload.mitm_options,
healthcheck=payload.healthcheck,
service=payload.service,
created_at=now,
updated_at=now,
)
collection = await _collection()
await collection.insert_one(proxy.model_dump(exclude_none=True))
return proxy
async def get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
collection = await _collection()
raw = await collection.find_one({"proxy_port": proxy_port})
return _deserialize_proxy(raw)
async def list_proxies() -> list[ProxyDocument]:
collection = await _collection()
cursor = collection.find({}).sort("proxy_port", 1)
proxies: list[ProxyDocument] = []
async for raw in cursor:
proxy = _deserialize_proxy(raw)
if proxy is not None:
proxies.append(proxy)
return proxies
async def update_proxy(
proxy_port: int,
updates: ProxyUpdateInput,
) -> ProxyDocument | None:
payload = updates.model_dump(exclude_none=True)
if not payload:
return await get_proxy_by_port(proxy_port)
payload["updated_at"] = _utcnow()
collection = await _collection()
raw = await collection.find_one_and_update(
{"proxy_port": proxy_port},
{"$set": payload},
return_document=ReturnDocument.AFTER,
)
return _deserialize_proxy(raw)
async def update_proxy_runtime(
proxy_port: int,
values: dict[str, object],
) -> ProxyDocument | None:
payload = dict(values)
payload["updated_at"] = _utcnow()
collection = await _collection()
raw = await collection.find_one_and_update(
{"proxy_port": proxy_port},
{"$set": payload},
return_document=ReturnDocument.AFTER,
)
return _deserialize_proxy(raw)
async def delete_proxy(proxy_port: int) -> bool:
collection = await _collection()
result = await collection.delete_one({"proxy_port": proxy_port})
return result.deleted_count > 0
-78
View File
@@ -1,78 +0,0 @@
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,
origin="custom",
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
View File
-323
View File
@@ -1,323 +0,0 @@
import asyncio
import json
import shutil
import tempfile
from dataclasses import dataclass
from pathlib import Path
WORKSPACE_ROOT = Path(tempfile.gettempdir()) / "theplug-lsp"
CONFIG_FILE = "pyrightconfig.json"
ENTRY_FILE = "proxy_addon.py"
STUB_FILES = {
"typings/mitmproxy/__init__.pyi": """from . import (
command,
ctx,
dns,
http,
tcp,
tls,
udp,
websocket,
)
__all__ = [
"command",
"ctx",
"dns",
"http",
"tcp",
"tls",
"udp",
"websocket",
]
""",
"typings/mitmproxy/ctx.pyi": """class _Logger:
def debug(self, message: object) -> None: ...
def info(self, message: object) -> None: ...
def warn(self, message: object) -> None: ...
def error(self, message: object) -> None: ...
def alert(self, message: object) -> None: ...
class _Options:
def __getattr__(self, name: str) -> object: ...
log: _Logger
options: _Options
""",
"typings/mitmproxy/http.pyi": """from typing import Any
class Headers(dict[str, str]):
...
class Request:
host: str
path: str
headers: Headers
query: dict[str, str]
content: bytes
class Response:
headers: Headers
status_code: int
content: bytes
@classmethod
def make(
cls,
status_code: int,
content: bytes | str = b"",
headers: dict[str, str] | None = None,
) -> "Response": ...
class WebSocketMessage:
content: bytes
from_client: bool
class WebSocketData:
messages: list[WebSocketMessage]
class HTTPFlow:
request: Request
response: Response | None
websocket: WebSocketData | None
metadata: dict[str, Any]
""",
"typings/mitmproxy/tcp.pyi": """class TCPMessage:
content: bytes
from_client: bool
class TCPFlow:
messages: list[TCPMessage]
""",
"typings/mitmproxy/dns.pyi": """class Question:
name: str
type: int
class ResourceRecord:
def __init__(
self,
*,
name: str,
type: int,
class_: int,
ttl: int,
data: bytes,
) -> None: ...
class Message:
question: Question | None
def succeed(self, records: list[ResourceRecord]) -> "Message": ...
class _Types:
A: int
class _Classes:
IN: int
types: _Types
classes: _Classes
class DNSFlow:
request: Message
response: Message | None
""",
"typings/mitmproxy/command.pyi": """from collections.abc import Callable
from typing import TypeVar
F = TypeVar("F", bound=Callable[..., object])
def command(name: str) -> Callable[[F], F]: ...
""",
"typings/mitmproxy/websocket.pyi": """class WebSocketMessage:
content: bytes
from_client: bool
""",
"typings/mitmproxy/tls.pyi": """class ClientHelloData: ...
""",
"typings/mitmproxy/udp.pyi": """class UDPMessage:
content: bytes
from_client: bool
""",
}
class LspFrameBuffer:
def __init__(self) -> None:
self.buffer = bytearray()
def feed(self, chunk: bytes) -> list[str]:
self.buffer.extend(chunk)
messages: list[str] = []
while True:
header_end = self.buffer.find(b"\r\n\r\n")
if header_end < 0:
return messages
header = self.buffer[:header_end].decode("ascii", errors="ignore")
size = content_length(header)
if size is None:
del self.buffer[: header_end + 4]
continue
frame_end = header_end + 4 + size
if len(self.buffer) < frame_end:
return messages
body = bytes(self.buffer[header_end + 4 : frame_end])
del self.buffer[:frame_end]
messages.append(body.decode("utf-8"))
@dataclass(slots=True)
class PythonEditorSession:
root_path: Path
file_path: Path
@property
def root_uri(self) -> str:
return self.root_path.as_uri()
@property
def file_uri(self) -> str:
return self.file_path.as_uri()
def content_length(header: str) -> int | None:
for line in header.split("\r\n"):
if ":" not in line:
continue
name, value = line.split(":", 1)
if name.lower() == "content-length":
return int(value.strip())
return None
def encode_message(message: str) -> bytes:
payload = message.encode("utf-8")
header = f"Content-Length: {len(payload)}\r\n\r\n".encode("ascii")
return header + payload
def lsp_binary() -> str | None:
return shutil.which("basedpyright-langserver")
def get_session() -> PythonEditorSession:
root_path = WORKSPACE_ROOT
file_path = root_path / ENTRY_FILE
root_path.mkdir(parents=True, exist_ok=True)
file_path.touch(exist_ok=True)
config = {
"include": [ENTRY_FILE],
"stubPath": "typings",
"typeCheckingMode": "basic",
"pythonVersion": "3.14",
"reportMissingTypeStubs": False,
"reportMissingImports": "warning",
"useLibraryCodeForTypes": True,
}
(root_path / CONFIG_FILE).write_text(
json.dumps(config, indent=2),
encoding="utf-8",
)
for relative_path, content in STUB_FILES.items():
path = root_path / relative_path
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return PythonEditorSession(root_path=root_path, file_path=file_path)
async def open_process(session: PythonEditorSession) -> asyncio.subprocess.Process:
binary = lsp_binary()
if binary is None:
raise RuntimeError("basedpyright-langserver is not installed")
return await asyncio.create_subprocess_exec(
binary,
"--stdio",
cwd=session.root_path,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
async def forward_client(websocket, writer: asyncio.StreamWriter) -> None:
while True:
message = await websocket.receive_text()
writer.write(encode_message(message))
await writer.drain()
async def forward_server(reader: asyncio.StreamReader, websocket) -> None:
frames = LspFrameBuffer()
while True:
chunk = await reader.read(65536)
if not chunk:
return
for message in frames.feed(chunk):
await websocket.send_text(message)
async def drain_errors(reader: asyncio.StreamReader) -> None:
while True:
chunk = await reader.read(4096)
if not chunk:
return
async def bridge(websocket, session: PythonEditorSession) -> None:
process = await open_process(session)
stdin = process.stdin
stdout = process.stdout
stderr = process.stderr
if stdin is None or stdout is None or stderr is None:
process.kill()
await process.wait()
raise RuntimeError("basedpyright-langserver did not expose stdio pipes")
send_task = asyncio.create_task(forward_client(websocket, stdin))
receive_task = asyncio.create_task(forward_server(stdout, websocket))
error_task = asyncio.create_task(drain_errors(stderr))
try:
done, pending = await asyncio.wait(
{send_task, receive_task, error_task},
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
for task in done:
task.result()
finally:
if process.returncode is None:
stdin.close()
await stdin.wait_closed()
try:
await asyncio.wait_for(process.wait(), timeout=1)
except TimeoutError:
process.kill()
await process.wait()
-228
View File
@@ -1,228 +0,0 @@
import asyncio
import ssl
import time
from dataclasses import dataclass
from datetime import UTC, datetime
from app.models.health import HealthCheckEntry, HealthSnapshot
from app.models.proxy import HealthCheckDocument, ProxyDocument
@dataclass(slots=True)
class Target:
name: str
host: str
port: int
protocol: str
async def check_proxy(proxy: ProxyDocument) -> HealthSnapshot:
timestamp = datetime.now(UTC)
if not proxy.healthcheck.enabled:
return HealthSnapshot(
proxy_port=proxy.proxy_port,
timestamp=timestamp,
enabled=False,
overall="disabled",
checks=[],
)
upstream_port = proxy.service.upstream_port or proxy.service.port
checks = list(
await asyncio.gather(
check_target(
Target(
name="upstream",
host=proxy.service.host,
port=upstream_port,
protocol=health_protocol(proxy),
),
proxy.healthcheck,
),
check_target(
Target(
name="proxy",
host=listen_host_for_check(proxy.listen_host),
port=proxy.proxy_port,
protocol=health_protocol(proxy),
),
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,
)
def health_protocol(proxy: ProxyDocument) -> str:
return proxy.healthcheck.protocol or proxy.protocol
def listen_host_for_check(host: str) -> str:
if host in {"", "0.0.0.0", "::"}:
return "127.0.0.1"
return host
async def check_target(
target: Target,
config: HealthCheckDocument,
) -> HealthCheckEntry:
try:
match target.protocol:
case "http":
return await check_http(target, config, use_tls=False)
case "https":
return await check_http(target, config, use_tls=True)
case "tcp":
return await check_tcp(target, config, use_tls=False)
case "tls":
return await check_tcp(target, config, use_tls=True)
case "udp" | "dtls" | "quic" | "dns" | "http3":
return await check_udp(target, config)
case _:
return result(target, False, f"Unsupported protocol: {target.protocol}")
except TimeoutError:
return result(target, False, "Timed out")
except OSError as error:
return result(target, False, str(error))
async def check_http(
target: Target,
config: HealthCheckDocument,
*,
use_tls: bool,
) -> HealthCheckEntry:
started = time.perf_counter()
reader, writer = await open_connection(target, config, use_tls=use_tls)
try:
path = config.path if config.path.startswith("/") else f"/{config.path}"
request = (
f"{config.method} {path} HTTP/1.1\r\n"
f"Host: {target.host}\r\n"
"Connection: close\r\n\r\n"
)
writer.write(request.encode("ascii"))
await writer.drain()
line = await asyncio.wait_for(reader.readline(), config.timeout_seconds)
finally:
writer.close()
await writer.wait_closed()
parts = line.decode("iso-8859-1", errors="replace").split()
status = int(parts[1]) if len(parts) >= 2 and parts[1].isdigit() else None
expected = config.expected_status
healthy = status == expected if expected is not None else status is not None
return result(
target,
healthy,
None,
{"status": status},
benchmark_ms=elapsed_ms(started) if config.benchmark_enabled else None,
)
async def check_tcp(
target: Target,
config: HealthCheckDocument,
*,
use_tls: bool,
) -> HealthCheckEntry:
started = time.perf_counter()
_reader, writer = await open_connection(target, config, use_tls=use_tls)
writer.close()
await writer.wait_closed()
return result(
target,
True,
benchmark_ms=elapsed_ms(started) if config.benchmark_enabled else None,
)
async def check_udp(target: Target, config: HealthCheckDocument) -> HealthCheckEntry:
started = time.perf_counter()
loop = asyncio.get_running_loop()
transport, protocol = await loop.create_datagram_endpoint(
lambda: DatagramProbe(config.expect_response),
remote_addr=(target.host, target.port),
)
try:
payload = config.udp_payload.encode()
transport.sendto(payload)
if not config.expect_response:
return result(target, True, None, {"mode": "send-only"})
data = await asyncio.wait_for(protocol.response, config.timeout_seconds)
return result(
target,
True,
None,
{"bytes": len(data)},
benchmark_ms=elapsed_ms(started) if config.benchmark_enabled else None,
)
finally:
transport.close()
async def open_connection(
target: Target,
config: HealthCheckDocument,
*,
use_tls: bool,
):
if use_tls and config.verify_tls:
context = ssl.create_default_context()
elif use_tls:
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
else:
context = None
return await asyncio.wait_for(
asyncio.open_connection(target.host, target.port, ssl=context),
config.timeout_seconds,
)
class DatagramProbe(asyncio.DatagramProtocol):
def __init__(self, expect_response: bool):
self.response = asyncio.get_running_loop().create_future()
if not expect_response:
self.response.set_result(b"")
def datagram_received(self, data, addr):
if not self.response.done():
self.response.set_result(data)
def error_received(self, exc):
if not self.response.done():
self.response.set_exception(exc)
def result(
target: Target,
healthy: bool,
error: str | None = None,
details: dict[str, object] | None = None,
benchmark_ms: float | None = None,
) -> HealthCheckEntry:
return HealthCheckEntry(
target=target.name,
host=target.host,
port=target.port,
protocol=target.protocol,
healthy=healthy,
error=error,
duration_ms=benchmark_ms,
details=details or {},
)
def elapsed_ms(started: float) -> float:
return round((time.perf_counter() - started) * 1000, 2)
@@ -1,117 +0,0 @@
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()
-443
View File
@@ -1,443 +0,0 @@
import asyncio
import os
import shutil
import signal
from pathlib import Path
from app.integrations.docker import change_port
from app.integrations.docker.ports import get_random_port
from app.models.proxy import ProxyDocument
from app.services.mitmproxy.options import MitmOptionError, validate_options
from app.services.mitmproxy.runtime_files import (
conf_dir_for,
config_path_for,
ensure_log_path,
log_path_for,
read_cmdline,
read_log_tail,
remove_dir,
remove_file,
script_path_for,
write_config,
write_script,
)
from app.services.mitmproxy.runtime_validation import (
ReservedProxyPortError,
ensure_proxy_port_not_reserved,
)
class MitmdumpError(Exception):
pass
class MitmdumpManager:
def __init__(self, work_dir: str = "/tmp/theplug", command: str = "mitmdump"):
self.work_dir = Path(work_dir)
self.command = command
self.processes: dict[int, asyncio.subprocess.Process] = {}
self.attached_pids: dict[int, int] = {}
self.locks: dict[int, asyncio.Lock] = {}
self.upstream_port_attempts = 8
async def start(self, proxy: ProxyDocument) -> dict[str, object]:
async with self.lock_for(proxy.proxy_port):
return await self.start_unlocked(proxy)
async def stop(self, proxy: ProxyDocument) -> dict[str, object]:
async with self.lock_for(proxy.proxy_port):
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]:
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":
raise MitmdumpError("Proxy is already running")
command_path = shutil.which(self.command)
if command_path is None:
raise MitmdumpError("mitmdump executable not found in PATH")
try:
await validate_options(proxy.mitm_options)
except MitmOptionError as error:
raise MitmdumpError(str(error)) from error
upstream_port = await self.prepare_upstream(proxy)
script_path = await self.write_script(proxy)
config_path = await self.write_config(proxy)
log_path = await self.log_path(proxy)
command = self.build_command(
command_path,
proxy,
upstream_port,
script_path,
config_path,
)
try:
process = await self.spawn(command, log_path)
except Exception as error:
await self.restore_upstream(proxy, upstream_port)
raise MitmdumpError(str(error)) from error
failed = await self.exited_early(process)
if failed:
await self.restore_upstream(proxy, upstream_port)
reason = (
await self.read_log_tail(log_path)
or "mitmdump exited during startup"
)
raise MitmdumpError(reason)
self.processes[proxy.proxy_port] = process
self.attached_pids.pop(proxy.proxy_port, None)
return {
"status": "running",
"pid": process.pid,
"script_path": str(script_path),
"config_path": str(config_path),
"log_path": str(log_path),
"service.upstream_port": upstream_port,
"last_error": None,
}
async def stop_unlocked(self, proxy: ProxyDocument) -> dict[str, object]:
process = self.processes.pop(proxy.proxy_port, None)
self.attached_pids.pop(proxy.proxy_port, None)
if process is not None and process.returncode is None:
await self.stop_process(process)
elif proxy.pid is not None:
await self.stop_pid(proxy.pid)
await self.restore_upstream(proxy, proxy.service.upstream_port)
return {
"status": "stopped",
"pid": None,
"service.upstream_port": None,
"last_error": None,
}
async def status(self, proxy: ProxyDocument) -> str:
process = self.processes.get(proxy.proxy_port)
if process is not None:
if process.returncode is None:
return "running"
self.processes.pop(proxy.proxy_port, None)
self.attached_pids.pop(proxy.proxy_port, None)
return "stopped"
pid = self.attached_pids.get(proxy.proxy_port, proxy.pid)
if pid is None:
return "stopped" if proxy.status == "running" else proxy.status
if not self.is_process_alive(pid):
self.attached_pids.pop(proxy.proxy_port, None)
return "stopped"
if not self.matches_proxy_process(proxy, pid):
self.attached_pids.pop(proxy.proxy_port, None)
return "stopped"
return "running"
async def reconcile(self, proxy: ProxyDocument) -> dict[str, object] | None:
if not self.needs_recovery(proxy):
return None
if proxy.pid is not None and self.is_process_alive(proxy.pid):
if self.matches_proxy_process(proxy, proxy.pid):
self.reattach(proxy)
return {
"status": "running",
"pid": proxy.pid,
"script_path": str(self.script_path_for(proxy)),
"config_path": str(self.config_path_for(proxy)),
"log_path": str(self.log_path_for(proxy)),
"last_error": None,
}
self.attached_pids.pop(proxy.proxy_port, None)
return {
"status": "error",
"pid": None,
"last_error": (
f"Found a live pid {proxy.pid}, but its command does not match "
"the expected mitmdump runtime for this proxy."
),
}
if self.needs_upstream_restore(proxy):
restored = await self.restore_upstream(proxy, proxy.service.upstream_port)
if not restored:
return {
"status": "error",
"pid": None,
"last_error": (
"Proxy process is gone and the upstream service port "
"could not be restored automatically."
),
}
return {
"status": "stopped",
"pid": None,
"service.upstream_port": None,
"last_error": None,
}
if proxy.status == "running" or proxy.pid is not None:
return {"status": "stopped", "pid": None, "last_error": None}
return None
async def prepare_upstream(self, proxy: ProxyDocument) -> int | None:
if proxy.mode_spec is not None or proxy.mode != "reverse":
return None
if proxy.service.upstream_port is not None:
return proxy.service.upstream_port
if proxy.service.name is None or proxy.service.folder is None:
return proxy.service.port
attempted_ports: set[int] = {proxy.proxy_port, proxy.service.port}
for _attempt in range(self.upstream_port_attempts):
upstream_port = self.pick_upstream_port(attempted_ports)
attempted_ports.add(upstream_port)
changed = await change_port(
proxy.service.name,
Path(proxy.service.folder),
proxy.service.port,
upstream_port,
host_to="127.0.0.1",
rewrite_host=True,
)
if changed:
return upstream_port
raise MitmdumpError("Unable to move Docker service port after retries")
async def restore_upstream(
self,
proxy: ProxyDocument,
upstream_port: int | None,
) -> bool:
if upstream_port is None:
return True
if proxy.service.name is None or proxy.service.folder is None:
return True
if upstream_port == proxy.service.port:
return True
return await change_port(
proxy.service.name,
Path(proxy.service.folder),
upstream_port,
proxy.service.port,
host_to=proxy.service.bind_host,
rewrite_host=True,
)
def build_command(
self,
command_path: str,
proxy: ProxyDocument,
upstream_port: int | None,
script_path: Path,
config_path: Path,
) -> list[str]:
command = [
command_path,
"--mode",
self.mode_for(proxy, upstream_port),
"--listen-host",
proxy.listen_host,
"--set",
f"confdir={config_path.parent}",
"-s",
str(script_path),
]
if self.needs_listen_port(proxy):
command.extend(["--listen-port", str(proxy.proxy_port)])
return command
def mode_for(self, proxy: ProxyDocument, upstream_port: int | None) -> str:
if proxy.mode_spec:
return proxy.mode_spec
match proxy.mode:
case "reverse":
port = upstream_port or proxy.service.port
return (
f"reverse:{proxy.protocol}://{proxy.service.host}:{port}"
f"@{proxy.proxy_port}"
)
case "upstream":
return (
f"upstream:{proxy.protocol}://{proxy.service.host}:"
f"{proxy.service.port}@{proxy.proxy_port}"
)
case "wireguard":
return f"wireguard@{proxy.proxy_port}"
case _:
return proxy.mode
def needs_listen_port(self, proxy: ProxyDocument) -> bool:
if proxy.mode_spec is not None:
return False
return proxy.mode in {"regular", "socks5", "dns", "transparent"}
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:
return await write_script(self.work_dir, proxy)
async def write_config(self, proxy: ProxyDocument) -> Path:
return await write_config(self.work_dir, proxy)
async def log_path(self, proxy: ProxyDocument) -> Path:
return await ensure_log_path(self.work_dir, proxy)
async def spawn(self, command: list[str], log_path: Path):
with log_path.open("ab") as log_file:
return await asyncio.create_subprocess_exec(
*command,
stdout=log_file,
stderr=log_file,
start_new_session=True,
)
async def stop_process(self, process) -> None:
await self.stop_pid(process.pid)
async def exited_early(self, process) -> bool:
try:
await asyncio.wait_for(process.wait(), timeout=0.5)
except TimeoutError:
return False
return True
async def stop_pid(self, pid: int) -> None:
try:
pgid = os.getpgid(pid)
except ProcessLookupError:
return
except PermissionError:
pgid = pid
try:
os.killpg(pgid, signal.SIGTERM)
except ProcessLookupError:
return
except PermissionError:
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
return
for _ in range(10):
await asyncio.sleep(0.2)
if not self.is_process_alive(pid):
return
try:
os.killpg(pgid, signal.SIGKILL)
except ProcessLookupError:
pass
except PermissionError:
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
return
def reattach(self, proxy: ProxyDocument) -> None:
if proxy.pid is not None:
self.attached_pids[proxy.proxy_port] = proxy.pid
def needs_recovery(self, proxy: ProxyDocument) -> bool:
return (
proxy.status == "running"
or proxy.pid is not None
or self.needs_upstream_restore(proxy)
)
def needs_upstream_restore(self, proxy: ProxyDocument) -> bool:
upstream_port = proxy.service.upstream_port
return upstream_port is not None and upstream_port != proxy.service.port
def is_process_alive(self, pid: int) -> bool:
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
def matches_proxy_process(self, proxy: ProxyDocument, pid: int) -> bool:
cmdline = self.read_cmdline(pid)
if not cmdline:
return False
expected_command = Path(self.command).name
if not any(Path(part).name == expected_command for part in cmdline):
return False
expected_script = str(self.script_path_for(proxy))
expected_confdir = f"confdir={self.conf_dir_for(proxy)}"
return expected_script in cmdline and expected_confdir in cmdline
def read_cmdline(self, pid: int) -> list[str]:
return read_cmdline(pid)
def script_path_for(self, proxy: ProxyDocument) -> Path:
return script_path_for(self.work_dir, proxy)
def conf_dir_for(self, proxy: ProxyDocument) -> Path:
return conf_dir_for(self.work_dir, proxy)
def config_path_for(self, proxy: ProxyDocument) -> Path:
return config_path_for(self.work_dir, proxy)
def log_path_for(self, proxy: ProxyDocument) -> Path:
return log_path_for(self.work_dir, proxy)
async def read_log_tail(self, path: Path, limit: int = 4000) -> str:
return await read_log_tail(path, limit=limit)
def remove_file(self, path: Path) -> None:
remove_file(path)
def remove_dir(self, path: Path) -> None:
remove_dir(path)
def lock_for(self, proxy_port: int) -> asyncio.Lock:
lock = self.locks.get(proxy_port)
if lock is None:
lock = asyncio.Lock()
self.locks[proxy_port] = lock
return lock
manager = MitmdumpManager()
-156
View File
@@ -1,156 +0,0 @@
import asyncio
import re
import shutil
from dataclasses import dataclass
from typing import Any
import yaml
RESERVED_OPTIONS = {"mode", "listen_host", "listen_port", "scripts"}
OPTIONS_DOCS_URL = "https://docs.mitmproxy.org/stable/concepts/options/"
@dataclass(slots=True)
class MitmOption:
name: str
value_type: str
default: Any
description: str
choices: list[str]
class MitmOptionError(Exception):
pass
catalog_cache: dict[str, MitmOption] | None = None
async def load_options() -> dict[str, MitmOption]:
global catalog_cache
if catalog_cache is None:
catalog_cache = await read_options()
return catalog_cache
async def read_options(command: str = "mitmdump") -> dict[str, MitmOption]:
command_path = shutil.which(command)
if command_path is None:
raise MitmOptionError("mitmdump executable not found in PATH")
process = await asyncio.create_subprocess_exec(
command_path,
"--options",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
message = stderr.decode("utf-8", errors="replace").strip()
raise MitmOptionError(message or "Unable to load mitmdump options")
text = stdout.decode("utf-8", errors="replace")
defaults = yaml.safe_load(text) or {}
return parse_options(text, defaults)
def parse_options(text: str, defaults: dict[str, Any]) -> dict[str, MitmOption]:
comments: list[str] = []
options: dict[str, MitmOption] = {}
for line in text.splitlines():
stripped = line.strip()
if not stripped:
comments = []
continue
if stripped.startswith("#"):
comments.append(stripped[1:].strip())
continue
if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*:", stripped):
continue
name = stripped.split(":", 1)[0]
description = " ".join(comments)
options[name] = MitmOption(
name=name,
value_type=parse_type(description),
default=defaults.get(name),
description=description,
choices=parse_choices(description),
)
comments = []
return options
def parse_type(description: str) -> str:
match = re.search(r"Type ([^.]+)\.", description)
if match is None:
return "unknown"
return match.group(1).strip()
def parse_choices(description: str) -> list[str]:
match = re.search(r"Valid values are ([^.]+)\.", description)
if match is None:
return []
return re.findall(r"'([^']+)'", match.group(1))
async def validate_options(options: dict[str, Any]) -> None:
catalog = await load_options()
errors: list[str] = []
for name, value in options.items():
option = catalog.get(name)
if option is None:
errors.append(f"Unknown mitmdump option: {name}")
continue
if name in RESERVED_OPTIONS:
errors.append(f"Option is managed by thePlug and cannot be set: {name}")
continue
message = validate_value(option, value)
if message:
errors.append(f"{name}: {message}")
if errors:
raise MitmOptionError("; ".join(errors))
def validate_value(option: MitmOption, value: Any) -> str | None:
value_type = option.value_type
if option.choices and str(value) not in option.choices:
return f"expected one of {', '.join(option.choices)}"
if value is None:
if value_type.startswith("optional "):
return None
return "cannot be null"
if value_type == "bool" and not isinstance(value, bool):
return "expected bool"
if value_type == "int" and not isinstance(value, int):
return "expected int"
if value_type == "str" and not isinstance(value, str):
return "expected str"
if value_type == "sequence of str":
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
return "expected list[str]"
if value_type == "optional str" and not isinstance(value, str):
return "expected str or null"
if value_type == "optional int" and not isinstance(value, int):
return "expected int or null"
return None
def public_options(options: dict[str, MitmOption]) -> list[dict[str, Any]]:
return [
{
"name": option.name,
"type": option.value_type,
"default": option.default,
"description": option.description,
"choices": option.choices,
"reserved": option.name in RESERVED_OPTIONS,
"docs_url": f"{OPTIONS_DOCS_URL}#{option.name}",
}
for option in options.values()
]
@@ -1,16 +0,0 @@
from app.repositories.proxy import list_proxies, update_proxy_runtime
from app.services.mitmproxy.manager import manager
async def reconcile_runtime() -> list[tuple[int, dict[str, object]]]:
changes: list[tuple[int, dict[str, object]]] = []
for proxy in await list_proxies():
updates = await manager.reconcile(proxy)
if updates is None:
continue
await update_proxy_runtime(proxy.proxy_port, updates)
changes.append((proxy.proxy_port, updates))
return changes
@@ -1,82 +0,0 @@
import asyncio
import shutil
from pathlib import Path
import yaml
from app.models.proxy import ProxyDocument
def script_path_for(work_dir: Path, proxy: ProxyDocument) -> Path:
return work_dir / "addons" / f"{proxy.proxy_port}.py"
def conf_dir_for(work_dir: Path, proxy: ProxyDocument) -> Path:
return work_dir / "conf" / str(proxy.proxy_port)
def config_path_for(work_dir: Path, proxy: ProxyDocument) -> Path:
return conf_dir_for(work_dir, proxy) / "config.yaml"
def log_path_for(work_dir: Path, proxy: ProxyDocument) -> Path:
return work_dir / "logs" / f"{proxy.proxy_port}.log"
async def write_script(work_dir: Path, proxy: ProxyDocument) -> Path:
path = script_path_for(work_dir, proxy)
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
await asyncio.to_thread(path.write_text, proxy.code, encoding="utf-8")
return path
async def write_config(work_dir: Path, proxy: ProxyDocument) -> Path:
path = config_path_for(work_dir, proxy)
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
options = {"block_global": False, **proxy.mitm_options}
config_text = yaml.safe_dump(options, sort_keys=True)
await asyncio.to_thread(path.write_text, config_text, encoding="utf-8")
return path
async def ensure_log_path(work_dir: Path, proxy: ProxyDocument) -> Path:
path = log_path_for(work_dir, proxy)
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
return path
async def read_log_tail(path: Path, limit: int = 4000) -> str:
if not await asyncio.to_thread(path.exists):
return ""
content = await asyncio.to_thread(
path.read_text,
encoding="utf-8",
errors="replace",
)
return content[-limit:].strip()
def remove_file(path: Path) -> None:
try:
path.unlink(missing_ok=True)
except OSError:
return
def remove_dir(path: Path) -> None:
try:
shutil.rmtree(path)
except FileNotFoundError:
return
except OSError:
return
def read_cmdline(pid: int) -> list[str]:
path = Path(f"/proc/{pid}/cmdline")
try:
raw = path.read_bytes()
except OSError:
return []
decoded = raw.decode("utf-8", errors="replace")
return [part for part in decoded.split("\x00") if part]
@@ -1,21 +0,0 @@
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))
-139
View File
@@ -1,139 +0,0 @@
from __future__ import annotations
import logging
from collections import defaultdict
from collections.abc import Mapping
from pathlib import Path
import yaml
from pydantic import ValidationError
from app.models.template import ProxyTemplateDocument
logger = logging.getLogger(__name__)
SUPPORTED_FILE_TEMPLATE_PROTOCOLS = frozenset({"http", "tcp"})
TEMPLATES_DIR = Path(__file__).resolve().parents[4] / "templates"
def template_directory() -> Path:
return TEMPLATES_DIR
def template_files(directory: Path | None = None) -> list[Path]:
root = directory or template_directory()
if not root.exists():
return []
return sorted(
path
for path in root.iterdir()
if path.is_file() and path.suffix.lower() in {".yaml", ".yml"}
)
def _default_category(protocol: str, family: str) -> str:
return protocol if protocol in SUPPORTED_FILE_TEMPLATE_PROTOCOLS else family
def _string_value(value: object, default: str = "") -> str:
if isinstance(value, str):
return value
if value is None:
return default
return str(value)
def _normalize_template_payload(
raw: Mapping[str, object],
source: Path,
) -> tuple[dict[str, object], str]:
protocol = _string_value(raw.get("protocol")).strip().lower()
if protocol not in SUPPORTED_FILE_TEMPLATE_PROTOCOLS:
expected = sorted(SUPPORTED_FILE_TEMPLATE_PROTOCOLS)
raise ValueError(
f"unsupported protocol {protocol!r}; expected one of {expected}"
)
if "id" not in raw or "name" not in raw or "code" not in raw:
raise ValueError("missing one of required fields: id, name, code")
description = _string_value(
raw.get("description") or raw.get("summary") or raw.get("behavior")
)
family = _string_value(raw.get("family") or protocol)
source_url = raw.get("source_url")
tags = raw.get("tags")
normalized_tags = [str(tag) for tag in tags] if isinstance(tags, list) else []
payload: dict[str, object] = {
"id": _string_value(raw["id"]).strip(),
"name": _string_value(raw["name"]).strip(),
"description": description,
"category": _string_value(
raw.get("category") or _default_category(protocol, family)
),
"protocol": protocol,
"family": family,
"tags": normalized_tags,
"action": _string_value(raw.get("action") or "custom"),
"summary": _string_value(raw.get("summary") or description),
"behavior": _string_value(raw.get("behavior") or description),
"code": _string_value(raw["code"]),
"source_url": _string_value(source_url) if source_url is not None else None,
"built_in": True,
"origin": "file",
}
return payload, str(source)
def _load_template_from_file(path: Path) -> ProxyTemplateDocument | None:
try:
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError) as error:
logger.warning("Skipping template file %s: %s", path, error)
return None
if not isinstance(raw, dict):
logger.warning("Skipping template file %s: expected mapping root", path)
return None
normalized_raw = {str(key): value for key, value in raw.items()}
try:
payload, source_path = _normalize_template_payload(normalized_raw, path)
document = ProxyTemplateDocument.model_validate(payload)
except (ValidationError, ValueError) as error:
logger.warning("Skipping template file %s: %s", path, error)
return None
document.source_path = source_path
return document
def list_file_templates(directory: Path | None = None) -> list[ProxyTemplateDocument]:
by_id: dict[str, list[ProxyTemplateDocument]] = defaultdict(list)
for path in template_files(directory):
template = _load_template_from_file(path)
if template is None:
continue
by_id[template.id].append(template)
deduplicated: list[ProxyTemplateDocument] = []
duplicate_template_message = (
"Skipping template id %s from %s: duplicate file-backed template id"
)
for template_id, templates in by_id.items():
if len(templates) > 1:
for template in templates:
logger.warning(
duplicate_template_message,
template_id,
template.source_path or "<unknown>",
)
continue
deduplicated.append(templates[0])
return sorted(deduplicated, key=lambda template: template.name.lower())
def file_template_ids(directory: Path | None = None) -> set[str]:
return {template.id for template in list_file_templates(directory)}
-164
View File
@@ -1,164 +0,0 @@
import asyncio
import json
import os
import shutil
import tempfile
from typing import Any
TARGET_RUFF_CODES = ("F821", "F822", "F823")
class ProxyCodeValidationError(Exception):
def __init__(
self,
message: str,
*,
errors: list[dict[str, Any]] | None = None,
) -> None:
super().__init__(message)
self.message = message
self.errors = errors or []
def to_http_detail(self) -> dict[str, Any]:
return {
"message": self.message,
"errors": self.errors,
}
def _validate_python_syntax(code: str) -> None:
try:
compile(code, "<proxy_code>", "exec")
except SyntaxError as error:
line = error.lineno if error.lineno is not None else 0
column = error.offset if error.offset is not None else 0
detail = {
"code": "COMPILE",
"message": error.msg,
"location": {"row": line, "column": column},
}
raise ProxyCodeValidationError(
"Proxy code has invalid Python syntax",
errors=[detail],
) from error
def _get_ruff_executable() -> list[str]:
ruff_path = shutil.which("ruff")
if ruff_path:
return [ruff_path]
uv_path = shutil.which("uv")
if uv_path:
return [uv_path, "run", "ruff"]
raise ProxyCodeValidationError("Unable to run ruff: executable not found")
def _parse_ruff_output(output: str) -> list[dict[str, Any]]:
if not output.strip():
return []
try:
parsed = json.loads(output)
except json.JSONDecodeError as error:
raise ProxyCodeValidationError("Unable to parse ruff JSON output") from error
if not isinstance(parsed, list):
raise ProxyCodeValidationError("Unexpected ruff output format")
errors: list[dict[str, Any]] = []
for item in parsed:
if not isinstance(item, dict):
continue
code = item.get("code")
if code not in TARGET_RUFF_CODES:
continue
message = item.get("message")
raw_location = item.get("location")
location = raw_location if isinstance(raw_location, dict) else {}
row = location.get("row", 0)
column = location.get("column", 0)
errors.append(
{
"code": code,
"message": str(message) if message is not None else "",
"location": {"row": row, "column": column},
}
)
return errors
async def _run_ruff_check(code: str) -> list[dict[str, Any]]:
command = _get_ruff_executable()
file_path = ""
try:
with tempfile.NamedTemporaryFile(
mode="w",
suffix=".py",
encoding="utf-8",
delete=False,
) as handle:
handle.write(code)
file_path = handle.name
process = await asyncio.create_subprocess_exec(
*command,
"check",
"--output-format=json",
"--select",
",".join(TARGET_RUFF_CODES),
file_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
finally:
if file_path:
try:
os.unlink(file_path)
except FileNotFoundError:
pass
stdout_text = stdout.decode("utf-8", errors="replace")
stderr_text = stderr.decode("utf-8", errors="replace")
if process.returncode not in (0, 1):
raise ProxyCodeValidationError(
"Ruff check failed to execute",
errors=[
{
"code": "RUFF",
"message": stderr_text.strip() or "Unknown ruff failure",
"location": {"row": 0, "column": 0},
}
],
)
if stderr_text.strip() and process.returncode == 0:
raise ProxyCodeValidationError(
"Ruff emitted unexpected stderr output",
errors=[
{
"code": "RUFF",
"message": stderr_text.strip(),
"location": {"row": 0, "column": 0},
}
],
)
return _parse_ruff_output(stdout_text)
async def validate_proxy_code(code: str) -> None:
_validate_python_syntax(code)
errors = await _run_ruff_check(code)
if errors:
raise ProxyCodeValidationError(
"Proxy code contains ruff violations",
errors=errors,
)
-48
View File
@@ -1,48 +0,0 @@
[project]
name = "theplug"
version = "0.1.0"
description = "Add your description here"
requires-python = ">=3.14"
dependencies = [
"authlib>=1.6.0",
"basedpyright>=1.39.0",
"fastapi[standard]>=0.135.3",
"locust>=2.43.4",
"mitmproxy>=12.2.2",
"motor>=3.7.1",
"pwdlib[argon2,bcrypt]>=0.3.0",
"pydantic>=2.12.5",
"pydantic-settings>=2.13.1",
"pyyaml>=6.0.3",
"ruff>=0.15.10",
]
[tool.ruff]
target-version = "py314"
line-length = 88
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
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]
dev = [
"pytest>=9.0.3",
]
-112
View File
@@ -1,112 +0,0 @@
import importlib
from datetime import UTC, datetime, timedelta
from app.core.security import AuthContext, get_auth_context
from app.models.auth import AccessTokenPayload, AuthUserDocument
auth_router_module = importlib.import_module("app.api.auth.router")
def test_login_success_returns_bearer_token(client, monkeypatch: object):
user = AuthUserDocument(username="admin", password_hash="stored-hash")
async def fake_get_user_by_username(username: str) -> AuthUserDocument | None:
assert username == "admin"
return user
def fake_verify_password(plain_password: str, password_hash: str) -> bool:
return plain_password == "secret" and password_hash == "stored-hash"
def fake_create_access_token(*, subject: str) -> str:
assert subject == "admin"
return "token-123"
monkeypatch.setattr(
auth_router_module,
"get_user_by_username",
fake_get_user_by_username,
)
monkeypatch.setattr(auth_router_module, "verify_password", fake_verify_password)
monkeypatch.setattr(
auth_router_module,
"create_access_token",
fake_create_access_token,
)
response = client.post(
"/auth/login",
json={"username": "admin", "password": "secret"},
)
assert response.status_code == 200
assert response.json() == {"access_token": "token-123", "token_type": "bearer"}
def test_login_invalid_credentials_returns_401(client, monkeypatch: object):
async def fake_get_user_by_username(_: str) -> None:
return None
monkeypatch.setattr(
auth_router_module,
"get_user_by_username",
fake_get_user_by_username,
)
response = client.post(
"/auth/login",
json={"username": "admin", "password": "wrong"},
)
assert response.status_code == 401
assert response.json()["detail"] == "Invalid username or password"
assert response.headers["www-authenticate"] == "Bearer"
def test_logout_revokes_active_token(client, monkeypatch: object):
captured: dict[str, object] = {}
async def fake_revoke_token(*, jti: str, expires_at: datetime) -> None:
captured["jti"] = jti
captured["expires_at"] = expires_at
monkeypatch.setattr(auth_router_module, "revoke_token", fake_revoke_token)
response = client.post("/auth/logout")
assert response.status_code == 204
assert captured["jti"] == "test-jti"
assert isinstance(captured["expires_at"], datetime)
def test_logout_skips_revocation_when_token_is_expired(
client,
fake_user: AuthUserDocument,
monkeypatch: object,
):
state = {"called": False}
async def fake_revoke_token(*, jti: str, expires_at: datetime) -> None:
state["called"] = True
monkeypatch.setattr(auth_router_module, "revoke_token", fake_revoke_token)
expired_payload = AccessTokenPayload(
sub=fake_user.username,
jti="expired-jti",
exp=int((datetime.now(UTC) - timedelta(minutes=1)).timestamp()),
)
expired_context = AuthContext(
token="expired-token",
user=fake_user,
payload=expired_payload,
)
async def override_auth_context() -> AuthContext:
return expired_context
client.app.dependency_overrides[get_auth_context] = override_auth_context
response = client.post("/auth/logout")
assert response.status_code == 204
assert state["called"] is False
-59
View File
@@ -1,59 +0,0 @@
import pytest
from starlette.websockets import WebSocketDisconnect
from app.services.editor import python_lsp
def test_python_editor_session(client, monkeypatch):
session = python_lsp.PythonEditorSession(
root_path=python_lsp.WORKSPACE_ROOT,
file_path=python_lsp.WORKSPACE_ROOT / python_lsp.ENTRY_FILE,
)
monkeypatch.setattr(
"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")
assert response.status_code == 200
assert response.json() == {
"root_uri": session.root_uri,
"file_uri": session.file_uri,
}
def test_python_editor_socket_rejects_missing_token(client):
with pytest.raises(WebSocketDisconnect) as error:
with client.websocket_connect("/proxy/editor/python/ws"):
pass
assert error.value.code == 4401
def test_python_editor_socket_accepts_token(client, monkeypatch):
accepted = {"value": False}
async def fake_authenticate(_: object) -> None:
return None
async def fake_bridge(websocket, session) -> None:
accepted["value"] = True
await websocket.send_text(session.file_uri)
await websocket.close()
monkeypatch.setattr(
"app.api.editor.router.authenticate_editor_socket",
fake_authenticate,
)
monkeypatch.setattr(
"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:
assert socket.receive_text().endswith("/proxy_addon.py")
assert accepted["value"] is True
-980
View File
@@ -1,980 +0,0 @@
import asyncio
import importlib
from datetime import UTC, datetime
import pytest
from starlette.websockets import WebSocketDisconnect
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 list_file_templates
from app.services.mitmproxy.validator import ProxyCodeValidationError
proxy_router_module = importlib.import_module("app.api.proxy.router")
def make_proxy_document(
*,
proxy_port: int,
name: str = "svc",
code: str = "proxy-code",
port: int = 9000,
log_path: str | None = None,
) -> ProxyDocument:
return ProxyDocument(
proxy_port=proxy_port,
name=name,
code=code,
service=ServiceEndpointDocument(port=port),
log_path=log_path,
)
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):
client.app.dependency_overrides.pop(require_authenticated_user, None)
client.app.dependency_overrides.pop(get_auth_context, None)
response = client.get("/proxy")
assert response.status_code == 401
assert response.json()["detail"] == "Could not validate credentials"
def test_list_proxies_returns_documents(client, monkeypatch: object):
docs = [
make_proxy_document(proxy_port=8080, name="api"),
make_proxy_document(proxy_port=8081, name="worker"),
]
async def fake_list_proxies() -> list[ProxyDocument]:
return docs
monkeypatch.setattr(proxy_router_module, "list_proxy_documents", fake_list_proxies)
response = client.get("/proxy")
assert response.status_code == 200
body = response.json()
assert [item["proxy_port"] for item in body] == [8080, 8081]
def test_list_services_uses_query_scan_root(client, monkeypatch):
captured = {}
def fake_get_all_services_in_dir(root):
captured["root"] = str(root)
return [
{
"name": "echo",
"ports": [9081],
"bindings": [],
"folder": "/root/play",
}
]
monkeypatch.setattr(
proxy_router_module,
"get_all_services_in_dir",
fake_get_all_services_in_dir,
)
response = client.get("/proxy/services", params={"scan_root": "/root"})
assert response.status_code == 200
assert response.json() == {
"backend_port": 3671,
"scan_root": "/root",
"services": [
{"name": "echo", "ports": [9081], "bindings": [], "folder": "/root/play"}
],
}
assert captured["root"] == "/root"
def test_get_proxy_returns_404_when_not_found(client, monkeypatch: object):
async def fake_get_proxy_by_port(proxy_port: int) -> None:
return None
monkeypatch.setattr(
proxy_router_module,
"get_proxy_by_port",
fake_get_proxy_by_port,
)
response = client.get("/proxy/9999")
assert response.status_code == 404
assert response.json()["detail"] == "Proxy not found"
def test_create_proxy_returns_409_on_port_conflict(client, monkeypatch: object):
existing = make_proxy_document(proxy_port=4088)
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
assert proxy_port == 4088
return existing
monkeypatch.setattr(
proxy_router_module,
"get_proxy_by_port",
fake_get_proxy_by_port,
)
response = client.post(
"/proxy",
json={
"proxy_port": 4088,
"name": "new",
"code": "abc",
"service": {"port": 8080},
},
)
assert response.status_code == 409
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):
captured: dict[str, object] = {}
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
assert proxy_port == 4040
return None
async def fake_create_proxy(payload: ProxyCreateInput) -> ProxyDocument:
captured["proxy_port"] = payload.proxy_port
captured["name"] = payload.name
captured["service"] = payload.service.model_dump(exclude_none=True)
captured["code"] = payload.code
captured["protocol"] = payload.protocol
return make_proxy_document(
proxy_port=payload.proxy_port,
name=payload.name,
code=payload.code,
port=payload.service.port,
)
async def fake_validate_proxy_code(code: str) -> None:
assert code == "def proxy_handler():\n return 'ok'\n"
monkeypatch.setattr(
proxy_router_module,
"get_proxy_by_port",
fake_get_proxy_by_port,
)
monkeypatch.setattr(
proxy_router_module,
"validate_proxy_code",
fake_validate_proxy_code,
)
monkeypatch.setattr(proxy_router_module, "create_proxy", fake_create_proxy)
response = client.post(
"/proxy",
json={
"proxy_port": 4040,
"name": "edge",
"code": "def proxy_handler():\n return 'ok'\n",
"service": {"port": 8181},
},
)
assert response.status_code == 201
assert response.json()["proxy_port"] == 4040
assert captured == {
"proxy_port": 4040,
"name": "edge",
"service": {"host": "127.0.0.1", "port": 8181},
"code": "def proxy_handler():\n return 'ok'\n",
"protocol": "http",
}
def test_create_proxy_returns_422_when_code_validation_fails(
client,
monkeypatch: object,
):
create_called = False
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
assert proxy_port == 4000
return None
async def fake_validate_proxy_code(code: str) -> None:
assert code == "invalid-code"
raise ProxyCodeValidationError(
"Proxy code contains ruff violations",
errors=[
{
"code": "F821",
"message": "Undefined name `missing_symbol`",
"location": {"row": 1, "column": 1},
}
],
)
async def fake_create_proxy(
*,
proxy_port: int,
name: str,
service: dict[str, object],
code: str,
) -> ProxyDocument:
nonlocal create_called
create_called = True
return make_proxy_document(proxy_port=proxy_port, name=name, code=code)
monkeypatch.setattr(
proxy_router_module,
"get_proxy_by_port",
fake_get_proxy_by_port,
)
monkeypatch.setattr(
proxy_router_module,
"validate_proxy_code",
fake_validate_proxy_code,
)
monkeypatch.setattr(proxy_router_module, "create_proxy", fake_create_proxy)
response = client.post(
"/proxy",
json={
"proxy_port": 4000,
"name": "broken",
"code": "invalid-code",
"service": {"port": 9000},
},
)
assert response.status_code == 422
body = response.json()["detail"]
assert body["message"] == "Proxy code contains ruff violations"
assert body["errors"][0]["code"] == "F821"
assert create_called is False
def test_update_proxy_returns_422_when_code_validation_fails(
client,
monkeypatch: object,
):
update_called = False
async def fake_validate_proxy_code(code: str) -> None:
assert code == "invalid-code"
raise ProxyCodeValidationError(
"Proxy code has invalid Python syntax",
errors=[
{
"code": "COMPILE",
"message": "invalid syntax",
"location": {"row": 1, "column": 1},
}
],
)
async def fake_update_proxy(
proxy_port: int,
payload: object,
) -> ProxyDocument | None:
nonlocal update_called
update_called = True
return make_proxy_document(proxy_port=proxy_port)
monkeypatch.setattr(
proxy_router_module,
"validate_proxy_code",
fake_validate_proxy_code,
)
monkeypatch.setattr(proxy_router_module, "update_proxy", fake_update_proxy)
response = client.put(
"/proxy/8123",
json={"code": "invalid-code"},
)
assert response.status_code == 422
body = response.json()["detail"]
assert body["message"] == "Proxy code has invalid Python syntax"
assert body["errors"][0]["code"] == "COMPILE"
assert update_called is False
def test_update_proxy_persists_code_changes(client, monkeypatch: object):
captured: dict[str, object] = {}
updated_code = "def response(flow):\n flow.response.text = 'ok'\n"
async def fake_validate_proxy_code(code: str) -> None:
captured["validated"] = code
async def fake_update_proxy(
proxy_port: int,
payload: object,
) -> ProxyDocument | None:
captured["proxy_port"] = proxy_port
captured["code"] = payload.code
return make_proxy_document(
proxy_port=proxy_port,
code=payload.code,
)
monkeypatch.setattr(
proxy_router_module,
"validate_proxy_code",
fake_validate_proxy_code,
)
monkeypatch.setattr(proxy_router_module, "update_proxy", fake_update_proxy)
response = client.put(
"/proxy/8123",
json={"code": updated_code},
)
assert response.status_code == 200
assert response.json()["code"] == updated_code
assert captured == {
"validated": updated_code,
"proxy_port": 8123,
"code": updated_code,
}
def test_start_proxy_updates_runtime_state(client, monkeypatch: object):
proxy = make_proxy_document(proxy_port=4040)
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 start(self, document: ProxyDocument) -> dict[str, object]:
assert document.proxy_port == 4040
return {
"status": "running",
"pid": 1234,
"script_path": "/tmp/theplug/addons/4040.py",
"log_path": "/tmp/theplug/logs/4040.log",
"service.upstream_port": 9000,
"last_error": None,
}
async def fake_update_proxy_runtime(
proxy_port: int,
values: dict[str, object],
) -> ProxyDocument:
captured["proxy_port"] = proxy_port
captured["values"] = values
return proxy.model_copy(update={"status": "running", "pid": 1234})
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,
"update_proxy_runtime",
fake_update_proxy_runtime,
)
response = client.post("/proxy/4040/start")
assert response.status_code == 200
assert response.json()["status"] == "running"
assert captured["proxy_port"] == 4040
assert captured["values"]["pid"] == 1234
def test_start_proxy_records_runtime_error(client, monkeypatch: object):
proxy = make_proxy_document(proxy_port=4040)
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
assert proxy_port == 4040
return proxy
class FakeManager:
async def start(self, document: ProxyDocument) -> dict[str, object]:
assert document.proxy_port == 4040
raise proxy_router_module.MitmdumpError("mitmdump executable not found")
async def fake_update_proxy_runtime(
proxy_port: int,
values: dict[str, object],
) -> ProxyDocument:
assert proxy_port == 4040
assert values == {
"status": "error",
"last_error": "mitmdump executable not found",
}
return proxy.model_copy(
update={"status": "error", "last_error": values["last_error"]}
)
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,
"update_proxy_runtime",
fake_update_proxy_runtime,
)
response = client.post("/proxy/4040/start")
assert response.status_code == 200
assert response.json()["status"] == "error"
assert response.json()["last_error"] == "mitmdump executable not found"
def test_proxy_logs_socket_rejects_missing_token(client):
with pytest.raises(WebSocketDisconnect) as error:
with client.websocket_connect("/proxy/4040/logs/ws"):
pass
assert error.value.code == 4401
def test_proxy_logs_socket_streams_snapshot_and_append(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"}
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_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):
proxy = make_proxy_document(proxy_port=4040).model_copy(
update={"status": "running", "pid": 1234}
)
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]:
assert document.pid == 1234
return {
"status": "stopped",
"pid": None,
"service.upstream_port": None,
"last_error": None,
}
async def fake_update_proxy_runtime(
proxy_port: int,
values: dict[str, object],
) -> ProxyDocument:
assert proxy_port == 4040
assert values["status"] == "stopped"
return proxy.model_copy(update={"status": "stopped", "pid": None})
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,
"update_proxy_runtime",
fake_update_proxy_runtime,
)
response = client.post("/proxy/4040/stop")
assert response.status_code == 200
assert response.json()["status"] == "stopped"
def test_list_mitmproxy_templates_returns_builtins_and_custom(client, monkeypatch):
file_templates = list_file_templates()
async def fake_list_custom_templates():
return []
monkeypatch.setattr(
proxy_router_module,
"list_custom_templates",
fake_list_custom_templates,
)
monkeypatch.setattr(
proxy_router_module,
"list_file_templates",
lambda: file_templates,
)
response = client.get("/proxy/mitmproxy/templates")
assert response.status_code == 200
body = response.json()
assert len(body) == len(file_templates)
assert body[0]["built_in"] is True
assert body[0]["origin"] == "file"
assert body[0]["protocol"] in {"http", "tcp"}
assert isinstance(body[0]["tags"], list)
assert body[0]["action"] in {"block", "rewrite"}
def test_create_mitmproxy_template_validates_and_persists(client, monkeypatch):
captured = {}
async def fake_validate_proxy_code(code: str) -> None:
captured["validated"] = code
async def fake_create_custom_template(payload):
captured["payload"] = payload
return {
"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,
"origin": "custom",
}
monkeypatch.setattr(
proxy_router_module,
"validate_proxy_code",
fake_validate_proxy_code,
)
monkeypatch.setattr(
proxy_router_module,
"create_custom_template",
fake_create_custom_template,
)
response = client.post(
"/proxy/mitmproxy/templates",
json={
"id": "custom-header",
"name": "Custom header",
"description": "Adds a custom response header",
"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",
},
)
assert response.status_code == 201
assert response.json()["id"] == "custom-header"
assert response.json()["protocol"] == "http"
assert response.json()["origin"] == "custom"
assert captured["validated"] == "def response(flow):\n pass\n"
def test_create_mitmproxy_template_rejects_file_backed_id(client, monkeypatch):
monkeypatch.setattr(
proxy_router_module,
"file_template_ids",
lambda: {"http-block-path-traversal"},
)
response = client.post(
"/proxy/mitmproxy/templates",
json={
"id": "http-block-path-traversal",
"name": "Custom header",
"description": "Adds a custom response header",
"category": "http",
"protocol": "http",
"family": "custom",
"tags": [],
"action": "custom",
"summary": "Saved custom template.",
"behavior": "Applies the current script as-is.",
"code": "def response(flow):\n pass\n",
},
)
assert response.status_code == 409
assert "Template already exists" in response.json()["detail"]
-171
View File
@@ -1,171 +0,0 @@
import asyncio
import os
from collections.abc import Iterator
from datetime import UTC, datetime, timedelta
from pathlib import Path
from uuid import uuid4
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from motor.motor_asyncio import AsyncIOMotorClient
os.environ.setdefault(
"MONGODB_URI",
"mongodb://theplug:pass@localhost:27017/theplug?authSource=admin",
)
os.environ.setdefault("MONGODB_DB_NAME", "theplug")
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key")
os.environ.setdefault("JWT_ALGORITHM", "HS256")
os.environ.setdefault("JWT_EXPIRE_MINUTES", "720")
os.environ.setdefault("AUTH_USERNAME", "admin")
os.environ.setdefault("AUTH_PASSWORD", "admin")
@pytest.fixture
def fake_user():
from app.models.auth import AuthUserDocument
return AuthUserDocument(
username="admin",
password_hash="hashed-password",
)
@pytest.fixture
def auth_context(fake_user):
from app.core.security import AuthContext
from app.models.auth import AccessTokenPayload
exp = int((datetime.now(UTC) + timedelta(minutes=720)).timestamp())
payload = AccessTokenPayload(sub=fake_user.username, jti="test-jti", exp=exp)
return AuthContext(token="test-token", user=fake_user, payload=payload)
@pytest.fixture
def app(fake_user, auth_context) -> FastAPI:
from app.api.auth.router import router as auth_router
from app.api.editor.router import router as editor_router
from app.api.proxy.router import router as proxy_router
from app.core.security import (
AuthContext,
get_auth_context,
require_authenticated_user,
)
from app.models.auth import AuthUserDocument
app_instance = FastAPI()
app_instance.include_router(auth_router)
app_instance.include_router(editor_router)
app_instance.include_router(proxy_router)
async def _override_current_user() -> AuthUserDocument:
return fake_user
async def _override_auth_context() -> AuthContext:
return auth_context
app_instance.dependency_overrides[require_authenticated_user] = (
_override_current_user
)
app_instance.dependency_overrides[get_auth_context] = _override_auth_context
return app_instance
@pytest.fixture
def client(app: FastAPI):
with TestClient(app) as 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}"}
@@ -1,25 +0,0 @@
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"
@@ -1,80 +0,0 @@
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,186 +0,0 @@
import asyncio
from pathlib import Path
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):
root = tmp_path / "workspace"
project = root / "playground"
project.mkdir(parents=True)
(project / "docker-compose.yml").write_text(
"""
services:
echo:
image: hashicorp/http-echo:1.0
ports:
- "9081:5678"
""".strip(),
encoding="utf-8",
)
services = get_all_services_in_dir(root)
assert services == [
{
"name": "echo",
"ports": [9081],
"bindings": [{"published": 9081, "host": None}],
"folder": project,
}
]
def test_get_all_services_in_dir_skips_nested_grandchildren(tmp_path: Path):
root = tmp_path / "workspace"
nested = root / "group" / "playground"
nested.mkdir(parents=True)
(nested / "docker-compose.yml").write_text(
"""
services:
echo:
image: hashicorp/http-echo:1.0
ports:
- "9081:5678"
""".strip(),
encoding="utf-8",
)
services = get_all_services_in_dir(root)
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,30 +0,0 @@
from app.integrations.docker.ports import _get_service_binding, _replace_port_in_mapping
def test_get_service_binding_includes_loopback_hosts():
assert _get_service_binding("127.0.0.1:8080:80") == (8080, "127.0.0.1")
assert _get_service_binding("localhost:9081:5678") == (9081, "localhost")
def test_replace_port_in_mapping_can_force_loopback_host():
updated = _replace_port_in_mapping(
"9081:5678",
9081,
54001,
host_to="127.0.0.1",
rewrite_host=True,
)
assert updated == "127.0.0.1:54001:5678"
def test_replace_port_in_mapping_can_restore_unspecified_host():
restored = _replace_port_in_mapping(
"127.0.0.1:54001:5678",
54001,
9081,
host_to=None,
rewrite_host=True,
)
assert restored == "9081:5678"
-77
View File
@@ -1,77 +0,0 @@
import asyncio
from app.models.health import HealthSnapshot
from app.models.proxy import ProxyDocument
from app.models.service import ServiceEndpointDocument
from app.services.mitmproxy.health import check_proxy
def test_check_proxy_reports_upstream_and_proxy_healthy():
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="",
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 health.overall == "healthy"
assert [check.target for check in health.checks] == [
"upstream",
"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())
async def http_handler(reader, writer):
await reader.readline()
writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
await writer.drain()
writer.close()
await writer.wait_closed()
@@ -1,218 +0,0 @@
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,281 +0,0 @@
import asyncio
from pathlib import Path
import pytest
from app.models.proxy import ProxyDocument
from app.models.service import ServiceEndpointDocument
from app.services.mitmproxy.manager import MitmdumpError, MitmdumpManager
def make_proxy(**updates):
data = {
"proxy_port": 8080,
"name": "svc",
"code": "",
"service": ServiceEndpointDocument(port=9000),
}
data.update(updates)
return ProxyDocument(**data)
def test_reverse_mode_is_built_from_service():
manager = MitmdumpManager()
proxy = make_proxy(protocol="tcp")
command = manager.build_command(
"mitmdump",
proxy,
9100,
Path("/tmp/addon.py"),
Path("/tmp/conf/config.yaml"),
)
assert "reverse:tcp://127.0.0.1:9100@8080" in command
assert "--listen-port" not in command
def test_raw_mode_spec_overrides_generated_mode():
manager = MitmdumpManager()
proxy = make_proxy(mode="regular", mode_spec="wireguard:/tmp/wg.conf@51820")
command = manager.build_command(
"mitmdump",
proxy,
None,
Path("/tmp/addon.py"),
Path("/tmp/conf/config.yaml"),
)
assert "wireguard:/tmp/wg.conf@51820" in command
assert "--listen-port" not in command
def test_regular_mode_uses_proxy_port_as_listen_port():
manager = MitmdumpManager()
proxy = make_proxy(mode="regular")
command = manager.build_command(
"mitmdump",
proxy,
None,
Path("/tmp/addon.py"),
Path("/tmp/conf/config.yaml"),
)
assert "regular" in command
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():
manager = MitmdumpManager()
proxy = make_proxy(status="running", pid=None)
async def run():
return await manager.status(proxy)
assert asyncio.run(run()) == "stopped"
def test_matches_proxy_process_uses_expected_script_and_confdir():
manager = MitmdumpManager()
proxy = make_proxy()
manager.read_cmdline = lambda _pid: [
"/home/test/.venv/bin/python",
"/home/test/.venv/bin/mitmdump",
"--mode",
"reverse:http://127.0.0.1:9000@8080",
"--listen-host",
"0.0.0.0",
"--set",
"confdir=/tmp/theplug/conf/8080",
"-s",
"/tmp/theplug/addons/8080.py",
]
assert manager.matches_proxy_process(proxy, 1234) is True
def test_reconcile_reattaches_matching_process():
manager = MitmdumpManager()
proxy = make_proxy(
status="running",
pid=4321,
service=ServiceEndpointDocument(port=9000, upstream_port=54003),
)
manager.is_process_alive = lambda _pid: True
manager.matches_proxy_process = lambda _proxy, _pid: True
async def run():
return await manager.reconcile(proxy)
values = asyncio.run(run())
assert values is not None
assert values["status"] == "running"
assert values["pid"] == 4321
assert manager.attached_pids[8080] == 4321
def test_reconcile_marks_error_for_mismatched_live_process():
manager = MitmdumpManager()
proxy = make_proxy(
status="running",
pid=4321,
service=ServiceEndpointDocument(port=9000, upstream_port=54003),
)
manager.is_process_alive = lambda _pid: True
manager.matches_proxy_process = lambda _proxy, _pid: False
async def run():
return await manager.reconcile(proxy)
values = asyncio.run(run())
assert values is not None
assert values["status"] == "error"
assert values["pid"] is None
assert "does not match" in str(values["last_error"])
def test_reconcile_restores_upstream_when_process_is_gone():
manager = MitmdumpManager()
proxy = make_proxy(
status="running",
pid=4321,
service=ServiceEndpointDocument(
port=9000,
upstream_port=54003,
name="echo",
folder="/tmp/playground",
),
)
manager.is_process_alive = lambda _pid: False
async def fake_restore(_proxy, _upstream_port):
return True
manager.restore_upstream = fake_restore
async def run():
return await manager.reconcile(proxy)
values = asyncio.run(run())
assert values == {
"status": "stopped",
"pid": None,
"service.upstream_port": 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,52 +0,0 @@
import pytest
from app.services.mitmproxy.options import (
MitmOption,
parse_options,
public_options,
validate_value,
)
def test_parse_options_extracts_types_and_choices():
text = """
# Enable feature. Type bool.
enabled: false
# Valid values are 'eager', 'lazy'.
strategy: eager
# Hosts. Type sequence of str.
hosts: []
"""
options = parse_options(
text,
{"enabled": False, "strategy": "eager", "hosts": []},
)
assert options["enabled"].value_type == "bool"
assert options["strategy"].choices == ["eager", "lazy"]
assert options["hosts"].value_type == "sequence of str"
@pytest.mark.parametrize(
("option", "value", "message"),
[
(MitmOption("anticache", "bool", False, "", []), "true", "expected bool"),
(MitmOption("flow_detail", "int", 1, "", []), "1", "expected int"),
(MitmOption("allow_hosts", "sequence of str", [], "", []), "x", "list[str]"),
(MitmOption("strategy", "unknown", "eager", "", ["eager"]), "lazy", "one of"),
],
)
def test_validate_value_rejects_wrong_types(option, value, message):
assert message in validate_value(option, value)
def test_public_options_include_docs_links():
options = {
"flow_detail": MitmOption("flow_detail", "int", 1, "Flow detail.", []),
}
public = public_options(options)
assert public[0]["docs_url"].endswith("#flow_detail")
-30
View File
@@ -1,30 +0,0 @@
from app.services.editor import python_lsp
def test_encode_message():
message = '{"jsonrpc":"2.0"}'
payload = python_lsp.encode_message(message)
assert payload.endswith(message.encode("utf-8"))
assert payload.startswith(
f"Content-Length: {len(message.encode('utf-8'))}\r\n\r\n".encode("ascii")
)
def test_frame_buffer_reads_split_messages():
buffer = python_lsp.LspFrameBuffer()
first = python_lsp.encode_message('{"id":1}')
second = python_lsp.encode_message('{"id":2}')
parts = [
first[:12],
first[12:] + second[:10],
second[10:],
]
messages: list[str] = []
for part in parts:
messages.extend(buffer.feed(part))
assert messages == ['{"id":1}', '{"id":2}']
-76
View File
@@ -1,76 +0,0 @@
import asyncio
from app.models.proxy import ProxyDocument
from app.models.service import ServiceEndpointDocument
from app.services.mitmproxy import recovery as recovery_module
def make_proxy(**updates):
data = {
"proxy_port": 8080,
"name": "svc",
"code": "",
"service": ServiceEndpointDocument(port=9000),
}
data.update(updates)
return ProxyDocument(**data)
def test_reconcile_runtime_persists_manager_updates(monkeypatch):
proxy = make_proxy(status="running", pid=1234)
captured: list[tuple[int, dict[str, object]]] = []
async def fake_list_proxies():
return [proxy]
class FakeManager:
async def reconcile(self, document: ProxyDocument):
assert document.proxy_port == 8080
return {"status": "running", "pid": 1234, "last_error": None}
async def fake_update_proxy_runtime(proxy_port: int, values: dict[str, object]):
captured.append((proxy_port, values))
return proxy
monkeypatch.setattr(recovery_module, "list_proxies", fake_list_proxies)
monkeypatch.setattr(recovery_module, "manager", FakeManager())
monkeypatch.setattr(
recovery_module,
"update_proxy_runtime",
fake_update_proxy_runtime,
)
changes = asyncio.run(recovery_module.reconcile_runtime())
assert changes == [(8080, {"status": "running", "pid": 1234, "last_error": None})]
assert captured == [(8080, {"status": "running", "pid": 1234, "last_error": None})]
def test_reconcile_runtime_skips_unchanged_proxies(monkeypatch):
proxy = make_proxy()
updated = False
async def fake_list_proxies():
return [proxy]
class FakeManager:
async def reconcile(self, _document: ProxyDocument):
return None
async def fake_update_proxy_runtime(_proxy_port: int, _values: dict[str, object]):
nonlocal updated
updated = True
return proxy
monkeypatch.setattr(recovery_module, "list_proxies", fake_list_proxies)
monkeypatch.setattr(recovery_module, "manager", FakeManager())
monkeypatch.setattr(
recovery_module,
"update_proxy_runtime",
fake_update_proxy_runtime,
)
changes = asyncio.run(recovery_module.reconcile_runtime())
assert changes == []
assert updated is False
-56
View File
@@ -1,56 +0,0 @@
import logging
import yaml
from app.services.mitmproxy.templates import list_file_templates
def test_file_templates_use_four_space_indentation():
for template in list_file_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}"
)
def test_list_file_templates_loads_yaml_backed_templates():
templates = list_file_templates()
assert templates
assert all(template.origin == "file" for template in templates)
assert all(template.built_in is True for template in templates)
assert all(template.protocol in {"http", "tcp"} for template in templates)
def test_list_file_templates_skips_invalid_files(tmp_path, caplog):
path = tmp_path / "invalid.yaml"
path.write_text("[]\n", encoding="utf-8")
with caplog.at_level(logging.WARNING):
templates = list_file_templates(tmp_path)
assert templates == []
assert "Skipping template file" in caplog.text
def test_list_file_templates_skips_duplicate_ids(tmp_path, caplog):
payload = {
"id": "duplicate",
"name": "Duplicate",
"protocol": "http",
"description": "Duplicate template",
"code": "from mitmproxy import http\n",
}
document = yaml.safe_dump(payload, sort_keys=False)
(tmp_path / "one.yaml").write_text(document, encoding="utf-8")
(tmp_path / "two.yaml").write_text(document, encoding="utf-8")
with caplog.at_level(logging.WARNING):
templates = list_file_templates(tmp_path)
assert templates == []
assert "duplicate file-backed template id" in caplog.text
-16
View File
@@ -1,16 +0,0 @@
from datetime import UTC, datetime
from app.core.security import create_access_token, decode_access_token
def test_create_access_token_uses_twelve_hour_default_window(monkeypatch):
monkeypatch.setattr("app.core.security.settings.JWT_EXPIRE_MINUTES", 720)
issued_at = datetime.now(UTC)
token = create_access_token(subject="admin")
payload = decode_access_token(token)
expires_at = datetime.fromtimestamp(payload.exp, UTC)
lifetime_seconds = (expires_at - issued_at).total_seconds()
assert 11.9 * 60 * 60 <= lifetime_seconds <= 12.1 * 60 * 60
-1908
View File
File diff suppressed because it is too large Load Diff
-182
View File
@@ -1,182 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
info() { echo -e "${GREEN}[+]${NC} $*"; }
die() { echo -e "${RED}[x] FATAL:${NC} $*" >&2; exit 1; }
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BACKEND_DIR="$ROOT_DIR/backend"
FRONTEND_DIR="$ROOT_DIR/frontend"
PLAYGROUND_DIR="$ROOT_DIR/playground"
ENV_FILE="$BACKEND_DIR/.env"
require_command() {
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
}
require_env() {
[[ -f "$ENV_FILE" ]] || die "Missing $ENV_FILE. Run ./setup.sh first."
}
env_value() {
local key="$1"
local default_value="${2:-}"
local value
value=$(grep -E "^${key}=" "$ENV_FILE" | cut -d '=' -f2- || true)
if [[ -n "$value" ]]; then
printf '%s\n' "$value"
return
fi
printf '%s\n' "$default_value"
}
compose() {
docker compose --env-file "$ENV_FILE" -f "$ROOT_DIR/docker-compose.yml" "$@"
}
service_compose_path() {
local name="$1"
local path="$PLAYGROUND_DIR/$name/docker-compose.yml"
[[ -f "$path" ]] || die "Unknown service group: $name"
printf '%s\n' "$path"
}
service_names() {
find "$PLAYGROUND_DIR" -mindepth 2 -maxdepth 2 -name docker-compose.yml -printf '%h\n' \
| xargs -r -n1 basename \
| sort
}
run_backend() {
require_command uv
require_env
local api_port
api_port=$(env_value API_PORT 3671)
info "Starting backend dev server on http://0.0.0.0:$api_port"
(
cd "$BACKEND_DIR"
uv run fastapi dev app/main.py --host 0.0.0.0 --port "$api_port"
)
}
run_frontend() {
require_command npm
require_env
info "Starting frontend Vite dev server"
(
cd "$FRONTEND_DIR"
npm run dev
)
}
run_mongo() {
require_command docker
require_env
local action="${1:-status}"
case "$action" in
up)
compose up -d mongodb
;;
down)
compose stop mongodb
;;
status)
compose ps mongodb
;;
logs)
compose logs -f mongodb
;;
*)
die "Unknown mongo action: $action"
;;
esac
}
run_services() {
require_command docker
local action="${1:-list}"
shift || true
case "$action" in
list)
service_names
;;
up|down|status)
local target="${1:-all}"
if [[ "$target" == "all" ]]; then
while IFS= read -r name; do
[[ -n "$name" ]] || continue
local compose_file
compose_file=$(service_compose_path "$name")
case "$action" in
up) docker compose -f "$compose_file" up -d ;;
down) docker compose -f "$compose_file" down ;;
status) docker compose -f "$compose_file" ps ;;
esac
done < <(service_names)
return
fi
local compose_file
compose_file=$(service_compose_path "$target")
case "$action" in
up) docker compose -f "$compose_file" up -d ;;
down) docker compose -f "$compose_file" down ;;
status) docker compose -f "$compose_file" ps ;;
esac
;;
*)
die "Unknown services action: $action"
;;
esac
}
show_help() {
cat <<'EOF'
Usage:
./dev.sh backend
./dev.sh frontend
./dev.sh mongo [up|down|status|logs]
./dev.sh services [list|up|down|status] [name|all]
Examples:
./dev.sh mongo up
./dev.sh backend
./dev.sh frontend
./dev.sh services list
./dev.sh services up GabibbiTowers
EOF
}
main() {
local command="${1:-help}"
shift || true
case "$command" in
backend)
run_backend "$@"
;;
frontend)
run_frontend "$@"
;;
mongo)
run_mongo "$@"
;;
services)
run_services "$@"
;;
help|-h|--help)
show_help
;;
*)
die "Unknown command: $command"
;;
esac
}
main "$@"
+50 -22
View File
@@ -1,34 +1,62 @@
services:
frontend:
api:
build:
context: ./frontend
container_name: theplug-frontend
context: .
dockerfile: Dockerfile
container_name: theplug-api
restart: unless-stopped
environment:
API_UPSTREAM: "host.docker.internal:${API_PORT:-3671}"
ports:
- "${FRONTEND_PORT:-5173}:80"
extra_hosts:
- "host.docker.internal:host-gateway"
- "${API_PORT:-3671}:3671"
environment:
MONGODB_URL: mongodb://${MONGO_USER:-theplug}:${MONGO_PASSWORD:-theplug_dev_password}@mongodb:27017/${MONGO_DB:-theplug}?authSource=admin
REDIS_URL: redis://redis:6379/0
depends_on:
mongodb:
image: mongo:8.2
container_name: theplug-mongodb
restart: unless-stopped
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-theplug}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD:-theplug_dev_password}
MONGO_INITDB_DATABASE: ${MONGO_DB:-theplug}
ports:
- "27017:27017"
volumes:
- theplug_mongodb:/data/db
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "mongosh --quiet --username ${MONGO_USER:-theplug} --password ${MONGO_INITDB_ROOT_PASSWORD:-theplug_dev_password} --authenticationDatabase admin --eval \"db.adminCommand({ ping: 1 }).ok\" | grep 1"]
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:3671/').read()\""]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
mongodb:
image: mongo:8
container_name: theplug-mongodb
restart: unless-stopped
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-theplug}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-pass}
MONGO_INITDB_DATABASE: ${MONGO_DB:-theplug}
ports:
- "27017:27017"
volumes:
theplug_mongodb:
- mongodb_data:/data/db
healthcheck:
test: ["CMD-SHELL", "mongosh --quiet --username ${MONGO_USER:-theplug} --password ${MONGO_PASSWORD:-theplug_dev_password} --authenticationDatabase admin --eval \"db.adminCommand({ ping: 1 }).ok\" | grep 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
redis:
image: redis:8-alpine
container_name: theplug-redis
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
volumes:
mongodb_data:
redis_data:
-4
View File
@@ -1,4 +0,0 @@
node_modules
dist
.vite
npm-debug.log
-16
View File
@@ -1,16 +0,0 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.29-alpine
COPY nginx.conf /etc/nginx/templates/default.conf.template
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>thePlug</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
-22
View File
@@ -1,22 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://${API_UPSTREAM}/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}
-1692
View File
File diff suppressed because it is too large Load Diff
-31
View File
@@ -1,31 +0,0 @@
{
"name": "theplug-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vue-tsc -b && vite build",
"preview": "vite preview --host 0.0.0.0",
"typecheck": "vue-tsc -b"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.1",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lint": "^6.9.5",
"@codemirror/lsp-client": "^6.2.3",
"@codemirror/search": "^6.7.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.41.1",
"codemirror": "^6.0.2",
"vue": "^3.5.26",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"typescript": "^5.9.3",
"vite": "^7.2.7",
"vue-tsc": "^3.1.8"
}
}
-54
View File
@@ -1,54 +0,0 @@
<script setup lang="ts">
import { onMounted, provide } from "vue";
import AppHeader from "./components/AppHeader.vue";
import AppToast from "./components/AppToast.vue";
import LoginView from "./components/LoginView.vue";
import ProxyList from "./components/ProxyList.vue";
import ProxyWorkbench from "./components/ProxyWorkbench.vue";
import {
controlPlaneKey,
useControlPlane,
} from "./composables/useControlPlane";
const plane = useControlPlane();
provide(controlPlaneKey, plane);
onMounted(() => {
if (plane.token) void plane.refreshAll();
});
</script>
<template>
<LoginView
v-if="!plane.token"
v-model:username="plane.username"
v-model:password="plane.password"
:loading="plane.loading"
:error="plane.authMessage || plane.loginError"
@submit="plane.signIn"
/>
<main v-else class="app-shell">
<AppToast v-if="plane.notice" :message="plane.notice" />
<AppHeader
:loading="plane.loading"
:show-discovery="Boolean(plane.selectedProxy)"
@discovery="plane.openDiscovery"
@refresh="plane.refreshAll"
@create="plane.openCreateWizard()"
@logout="plane.signOut"
/>
<div class="app-body">
<ProxyList
:proxies="plane.proxies"
:selected-port="plane.selectedPort"
@select="plane.selectedPort = $event"
/>
<ProxyWorkbench />
</div>
</main>
</template>
-128
View File
@@ -1,128 +0,0 @@
import type {
HealthHistoryResponse,
HealthResult,
MitmOption,
PythonEditorSession,
ProxyDocument,
ProxyDraft,
ProxyTemplate,
ProxyTemplateCreateInput,
ServiceDiscoveryResponse,
} from "./types";
const TOKEN_KEY = "theplug.token";
export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api";
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
export function getToken() {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string | null) {
if (token) {
localStorage.setItem(TOKEN_KEY, token);
return;
}
localStorage.removeItem(TOKEN_KEY);
}
export function buildSocketUrl(path: string) {
const token = getToken();
const base = new URL(API_BASE, window.location.origin);
base.protocol = base.protocol === "https:" ? "wss:" : "ws:";
base.pathname = `${base.pathname.replace(/\/$/, "")}${path}`;
if (token) {
base.searchParams.set("token", token);
}
return base.toString();
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
const token = getToken();
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
if (init.body && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const response = await fetch(`${API_BASE}${path}`, { ...init, headers });
if (response.status === 204) {
return undefined as T;
}
const body = await response.json().catch(() => null);
if (!response.ok) {
const detail = body?.detail ?? response.statusText;
const message =
typeof detail === "string" ? detail : JSON.stringify(detail);
throw new ApiError(response.status, message);
}
return body as T;
}
export async function login(username: string, password: string) {
const response = await request<{ access_token: string }>("/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
setToken(response.access_token);
}
export async function logout() {
await request<void>("/auth/logout", { method: "POST" }).catch(() => undefined);
setToken(null);
}
export const api = {
proxies: () => request<ProxyDocument[]>("/proxy"),
services: (scanRoot?: string) =>
request<ServiceDiscoveryResponse>(
`/proxy/services${scanRoot ? `?scan_root=${encodeURIComponent(scanRoot)}` : ""}`,
),
options: () => request<MitmOption[]>("/proxy/mitmproxy/options"),
templates: () => request<ProxyTemplate[]>("/proxy/mitmproxy/templates"),
editorSession: () =>
request<PythonEditorSession>("/proxy/editor/python/session"),
createTemplate: (payload: ProxyTemplateCreateInput) =>
request<ProxyTemplate>("/proxy/mitmproxy/templates", {
method: "POST",
body: JSON.stringify(payload),
}),
deleteTemplate: (id: string) =>
request<void>(`/proxy/mitmproxy/templates/${encodeURIComponent(id)}`, {
method: "DELETE",
}),
createProxy: (payload: ProxyDraft) =>
request<ProxyDocument>("/proxy", {
method: "POST",
body: JSON.stringify(payload),
}),
updateProxy: (port: number, payload: Partial<ProxyDraft>) =>
request<ProxyDocument>(`/proxy/${port}`, {
method: "PUT",
body: JSON.stringify(payload),
}),
deleteProxy: (port: number) =>
request<void>(`/proxy/${port}`, { method: "DELETE" }),
start: (port: number) =>
request<ProxyDocument>(`/proxy/${port}/start`, { method: "POST" }),
stop: (port: number) =>
request<ProxyDocument>(`/proxy/${port}/stop`, { method: "POST" }),
restart: (port: number) =>
request<ProxyDocument>(`/proxy/${port}/restart`, { method: "POST" }),
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`),
};
-49
View File
@@ -1,49 +0,0 @@
<script setup lang="ts">
import AppLogo from "./AppLogo.vue";
import UiIcon from "./UiIcon.vue";
defineProps<{
loading: boolean;
showDiscovery: boolean;
}>();
const emit = defineEmits<{
discovery: [];
refresh: [];
create: [];
logout: [];
}>();
</script>
<template>
<header class="app-header">
<div class="brand">
<AppLogo />
<div>
<strong>thePlug</strong>
<small>il proxy barese</small>
</div>
</div>
<div class="header-meta">
<span v-if="loading" class="muted">saving</span>
</div>
<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')">
<UiIcon name="refresh" />
<span>Refresh</span>
</button>
<button class="primary button-icon" @click="emit('create')">
<UiIcon name="plus" />
<span>New</span>
</button>
<button class="ghost button-icon" @click="emit('logout')">
<UiIcon name="logout" />
<span>Logout</span>
</button>
</div>
</header>
</template>
-7
View File
@@ -1,7 +0,0 @@
<template>
<svg class="logo" viewBox="0 0 64 64" role="img" aria-label="thePlug logo">
<rect x="10" y="10" width="44" height="44" rx="10" />
<path d="M26 19v10M38 19v10" />
<path d="M22 29h20v6a10 10 0 0 1-10 10 10 10 0 0 1-10-10v-6z" />
</svg>
</template>
-11
View File
@@ -1,11 +0,0 @@
<script setup lang="ts">
defineProps<{
message: string;
}>();
</script>
<template>
<div class="app-toast" role="status" aria-live="polite">
{{ message }}
</div>
</template>
-152
View File
@@ -1,152 +0,0 @@
<script setup lang="ts">
import { basicSetup, EditorView } from "codemirror";
import { keymap } from "@codemirror/view";
import { python } from "@codemirror/lang-python";
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { api } from "../api";
import { connectPythonLsp } from "../editor/pythonLsp";
const model = defineModel<string>({ required: true });
const emit = defineEmits<{
save: [];
}>();
const host = ref<HTMLElement | null>(null);
const error = ref("");
let editor: EditorView | null = null;
let languageClient: Awaited<ReturnType<typeof connectPythonLsp>>["client"] | null =
null;
let languageSocket: WebSocket | null = null;
const editorTheme = EditorView.theme(
{
"&": {
height: "100%",
maxHeight: "100%",
color: "#edf2f7",
backgroundColor: "#0b0f12",
fontSize: "14px",
},
".cm-editor": {
height: "100%",
maxHeight: "100%",
},
".cm-scroller": {
overflow: "auto",
fontFamily: "IBM Plex Mono, JetBrains Mono, ui-monospace, monospace",
lineHeight: "1.6",
},
".cm-content": {
minWidth: "fit-content",
padding: "14px 0",
caretColor: "#e7fbff",
},
".cm-gutters": {
border: "none",
backgroundColor: "#0b0f12",
color: "#6f7c86",
},
".cm-activeLine": {
backgroundColor: "#12181d",
},
".cm-activeLineGutter": {
backgroundColor: "#12181d",
},
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
backgroundColor: "#1b2630",
},
".cm-tooltip": {
border: "1px solid #24303a",
backgroundColor: "#10151a",
color: "#edf2f7",
},
".cm-panels": {
backgroundColor: "#10151a",
color: "#edf2f7",
},
".cm-searchMatch": {
backgroundColor: "#23303b",
outline: "1px solid #334455",
},
".cm-cursor": {
borderLeftColor: "#e7fbff",
},
},
{ dark: true },
);
function emitSave() {
emit("save");
return true;
}
onMounted(() => {
if (!host.value) return;
void mountEditor();
});
watch(model, (value) => {
if (!editor) return;
const current = editor.state.doc.toString();
if (current === value) return;
editor.dispatch({
changes: {
from: 0,
to: current.length,
insert: value,
},
});
});
onBeforeUnmount(() => {
languageClient?.disconnect();
languageClient = null;
languageSocket?.close();
languageSocket = null;
editor?.destroy();
editor = null;
});
async function mountEditor() {
if (!host.value) return;
try {
error.value = "";
const session = await api.editorSession();
const connection = await connectPythonLsp(session);
languageClient = connection.client;
languageSocket = connection.socket;
void languageClient.initializing.catch(() => {
error.value = "Python language tools are unavailable.";
});
editor = new EditorView({
doc: model.value,
extensions: [
basicSetup,
python(),
editorTheme,
keymap.of([{ key: "Mod-s", run: emitSave }]),
EditorView.updateListener.of((update) => {
if (!update.docChanged) return;
const value = update.state.doc.toString();
if (value !== model.value) model.value = value;
}),
languageClient.plugin(session.file_uri, "python"),
],
parent: host.value,
});
} catch (value) {
const message =
value instanceof Error ? value.message : "Unable to start the editor";
error.value = message;
}
}
</script>
<template>
<div class="code-editor-wrap">
<p v-if="error" class="field-error editor-error">{{ error }}</p>
<div ref="host" class="code-editor" />
</div>
</template>
-31
View File
@@ -1,31 +0,0 @@
<script setup lang="ts">
import { defineAsyncComponent } from "vue";
import { useControlPlaneContext } from "../composables/useControlPlane";
import UiIcon from "./UiIcon.vue";
const CodeEditor = defineAsyncComponent(() => import("./CodeEditor.vue"));
const plane = useControlPlaneContext();
</script>
<template>
<section class="editor-pane panel">
<div class="panel-title">
<h2>Proxy script</h2>
<div class="editor-tools">
<button
class="ghost button-icon editor-save"
:disabled="!plane.canSaveScript"
@click="plane.saveScript"
>
<UiIcon name="save" />
<span>Save script</span>
<span class="shortcut-hint">Ctrl/Cmd+S</span>
</button>
</div>
</div>
<div class="editor-shell">
<CodeEditor v-model="plane.draft.code" @save="plane.saveScript" />
</div>
</section>
</template>
@@ -1,314 +0,0 @@
<script setup lang="ts">
import { computed } from "vue";
import { useControlPlaneContext } from "../composables/useControlPlane";
import UiIcon from "./UiIcon.vue";
const plane = useControlPlaneContext();
const monitoringActive = computed(() => plane.runtimeStatus === "running");
const overallLabel = computed(() =>
monitoringActive.value ? plane.health?.overall ?? "pending" : "stopped",
);
const latestTimestamp = computed(() => {
if (!monitoringActive.value) return "Monitoring inactive";
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(() => {
if (!monitoringActive.value) {
return [null, null];
}
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 || !monitoringActive"
@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="overallLabel"
>
<strong>{{ overallLabel }}</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'
: monitoringActive
? 'pending'
: 'inactive'
"
>
<div class="health-runtime-card-head">
<strong>{{
row?.target ?? (index === 0 ? "proxy" : "upstream")
}}</strong>
<span>{{
row
? row.healthy
? "ok"
: "issue"
: monitoringActive
? "pending"
: "inactive"
}}</span>
</div>
<p class="muted small compact-copy">
{{
row
? `${row.host}:${row.port} · ${row.protocol}`
: monitoringActive
? "No data yet."
: "Start the proxy to enable active checks."
}}
</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">
{{
monitoringActive
? "Health history will appear after the first scheduled runs."
: "Health history starts once the proxy is running."
}}
</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>
-122
View File
@@ -1,122 +0,0 @@
<script setup lang="ts">
import { protocols, useControlPlaneContext } from "../composables/useControlPlane";
import UiIcon from "./UiIcon.vue";
const props = withDefaults(defineProps<{ embedded?: boolean }>(), {
embedded: false,
});
const plane = useControlPlaneContext();
</script>
<template>
<section :class="props.embedded ? 'wizard-step-shell' : 'panel stack-panel compact-panel'">
<div v-if="!props.embedded" class="panel-title">
<div><h2>Checks</h2></div>
<button
class="button-icon"
:disabled="!plane.selectedProxy"
@click="plane.checkHealth"
>
<UiIcon name="refresh" />
<span>Run</span>
</button>
</div>
<div v-if="!props.embedded" class="metric-grid">
<article class="metric-card">
<span class="metric-label">State</span>
<strong>{{ plane.draft.healthcheck.enabled ? "Enabled" : "Disabled" }}</strong>
</article>
<article class="metric-card">
<span class="metric-label">Path</span>
<strong>{{ plane.draft.healthcheck.path }}</strong>
</article>
<article class="metric-card">
<span class="metric-label">Method</span>
<strong>{{ plane.draft.healthcheck.method }}</strong>
</article>
<article class="metric-card">
<span class="metric-label">Timeout</span>
<strong>{{ plane.draft.healthcheck.timeout_seconds }}s</strong>
</article>
<article class="metric-card">
<span class="metric-label">Benchmark</span>
<strong>{{ plane.draft.healthcheck.benchmark_enabled ? "Enabled" : "Disabled" }}</strong>
</article>
</div>
<div class="form-grid tight-grid">
<label class="checkbox-row">
<input v-model="plane.draft.healthcheck.enabled" type="checkbox" />
<span>Enabled</span>
</label>
<label>
Protocol override
<select v-model="plane.draft.healthcheck.protocol">
<option :value="null">Use proxy protocol</option>
<option v-for="protocol in protocols" :key="protocol" :value="protocol">
{{ protocol }}
</option>
</select>
</label>
<label>
Method
<select v-model="plane.draft.healthcheck.method">
<option>GET</option>
<option>HEAD</option>
<option>POST</option>
</select>
</label>
<label>
Path
<input v-model="plane.draft.healthcheck.path" />
</label>
<label>
Expected status
<input
v-model.number="plane.draft.healthcheck.expected_status"
type="number"
/>
</label>
<label>
Timeout
<input
v-model.number="plane.draft.healthcheck.timeout_seconds"
type="number"
step="0.5"
/>
</label>
<label>
UDP payload
<input
v-model="plane.draft.healthcheck.udp_payload"
placeholder="used for udp-like checks"
/>
</label>
<label class="checkbox-row">
<input v-model="plane.draft.healthcheck.expect_response" type="checkbox" />
<span>Expect response</span>
</label>
<label class="checkbox-row">
<input v-model="plane.draft.healthcheck.verify_tls" type="checkbox" />
<span>Verify TLS</span>
</label>
<label class="checkbox-row">
<input v-model="plane.draft.healthcheck.benchmark_enabled" type="checkbox" />
<span>Benchmark response time</span>
</label>
</div>
<div v-if="plane.health" class="health-box" :class="plane.health.overall">
<strong>{{ plane.health.overall }}</strong>
<div class="health-list">
<div v-for="check in plane.health.checks" :key="check.target" class="health-row">
<span>{{ check.target }}</span>
<span>{{ check.protocol }}</span>
<span>{{ check.healthy ? "ok" : check.error }}</span>
</div>
</div>
</div>
</section>
</template>
@@ -1,78 +0,0 @@
<script setup lang="ts">
import { computed } from "vue";
import { useControlPlaneContext } from "../composables/useControlPlane";
import UiIcon from "./UiIcon.vue";
const plane = useControlPlaneContext();
const monitoringActive = computed(() => plane.runtimeStatus === "running");
const overallLabel = computed(() =>
monitoringActive.value ? plane.health?.overall ?? "pending" : "stopped",
);
const latestTimestamp = computed(() => {
if (!monitoringActive.value) return "Monitoring inactive";
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,
);
const proxyLabel = computed(() => {
if (!monitoringActive.value) return "inactive";
return proxyCheck.value ? (proxyCheck.value.healthy ? "ok" : "issue") : "pending";
});
const upstreamLabel = computed(() => {
if (!monitoringActive.value) return "inactive";
return upstreamCheck.value
? (upstreamCheck.value.healthy ? "ok" : "issue")
: "pending";
});
</script>
<template>
<section
v-if="plane.selectedProxy"
class="health-summary-card"
:class="overallLabel"
>
<div class="health-summary-line">
<strong class="health-summary-overall">{{ overallLabel }}</strong>
<span class="health-summary-item">
<small>Proxy</small>
<strong>{{ proxyLabel }}</strong>
</span>
<span class="health-summary-item">
<small>Upstream</small>
<strong>{{ upstreamLabel }}</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 || !monitoringActive"
@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>
-35
View File
@@ -1,35 +0,0 @@
<script setup lang="ts">
import AppLogo from "./AppLogo.vue";
defineProps<{
loading: boolean;
error: string;
}>();
const username = defineModel<string>("username", { required: true });
const password = defineModel<string>("password", { required: true });
const emit = defineEmits<{
submit: [];
}>();
</script>
<template>
<main class="login-screen">
<section class="login-card">
<div class="login-brand">
<AppLogo />
<div>
<p class="eyebrow">thePlug</p>
<h1>Sign in</h1>
<p class="muted">Manage proxy routes, runtime state, and mitmdump scripts.</p>
</div>
</div>
<form @submit.prevent="emit('submit')">
<label>Username<input v-model="username" autocomplete="username" /></label>
<label>Password<input v-model="password" type="password" autocomplete="current-password" /></label>
<button class="primary" :disabled="loading">Sign in</button>
</form>
<p v-if="error" class="error auth-alert">{{ error }}</p>
</section>
</main>
</template>
-30
View File
@@ -1,30 +0,0 @@
<script setup lang="ts">
import { useControlPlaneContext } from "../composables/useControlPlane";
import UiIcon from "./UiIcon.vue";
const plane = useControlPlaneContext();
</script>
<template>
<section class="panel stack-panel tool-panel logs-panel">
<div class="panel-title">
<div><h2>Logs</h2></div>
<div class="doc-actions">
<button class="ghost button-icon" :disabled="!plane.selectedProxy" @click="plane.toggleLogsPaused">
<UiIcon :name="plane.logsPaused ? 'resume' : 'pause'" />
<span>{{ plane.logsPaused ? "Resume" : "Pause" }}</span>
</button>
<button class="ghost button-icon" @click="plane.clearLogs">
<UiIcon name="clear" />
<span>Clear</span>
</button>
</div>
</div>
<input v-model="plane.logFilter" placeholder="Filter logs" />
<pre class="terminal">{{
plane.filteredLogs || "Logs will appear here automatically."
}}</pre>
</section>
</template>
-357
View File
@@ -1,357 +0,0 @@
<script setup lang="ts">
import type { MitmOption } from "../types";
import { useControlPlaneContext } from "../composables/useControlPlane";
import UiIcon from "./UiIcon.vue";
const props = withDefaults(defineProps<{ embedded?: boolean }>(), {
embedded: false,
});
const plane = useControlPlaneContext();
function isBool(option?: MitmOption | null) {
return option?.type === "bool";
}
function isNumber(option?: MitmOption | null) {
return option?.type === "int" || option?.type === "optional int";
}
function isSequence(option?: MitmOption | null) {
return option?.type === "sequence of str";
}
function formatDefault(option?: MitmOption | null) {
if (!option) return "n/a";
if (Array.isArray(option.default)) {
return option.default.length ? option.default.join(", ") : "[]";
}
if (option.default === null || option.default === "") return "none";
return String(option.default);
}
</script>
<template>
<section :class="props.embedded ? 'wizard-step-shell' : 'panel stack-panel tool-panel'">
<div v-if="!props.embedded" class="panel-title">
<h2>Options</h2>
<div class="title-actions">
<span class="counter">{{ plane.activeOptionCount }}</span>
<div class="segmented">
<button
:class="{ active: plane.optionScope === 'focused' }"
@click="plane.toggleOptionScope('focused')"
>
Common
</button>
<button
:class="{ active: plane.optionScope === 'all' }"
@click="plane.toggleOptionScope('all')"
>
All
</button>
</div>
</div>
</div>
<div v-if="props.embedded" class="option-step-layout">
<aside class="option-browser option-browser-embedded">
<div class="option-toolbar">
<input v-model="plane.optionSearch" placeholder="Search options" />
<div class="segmented">
<button
:class="{ active: plane.optionScope === 'focused' }"
@click="plane.toggleOptionScope('focused')"
>
Common
</button>
<button
:class="{ active: plane.optionScope === 'all' }"
@click="plane.toggleOptionScope('all')"
>
All
</button>
</div>
</div>
<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>
<p class="muted small">
{{
plane.optionScope === "focused"
? "Common options for the usual proxy setup."
: `All ${plane.optionCount} public mitmdump options.`
}}
</p>
</div>
<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
v-for="option in plane.filteredOptions"
:key="option.name"
class="option-entry"
:class="{ selected: plane.selectedOption?.name === option.name }"
@click="plane.selectOption(option.name)"
>
<strong>{{ option.name }}</strong>
<small>{{ option.type }}</small>
</button>
</div>
<div v-else class="option-empty-state">
<p class="muted small">No options matched the current filter.</p>
</div>
</aside>
<div class="option-step-main">
<article
v-if="!plane.optionsLoading && plane.selectedOption"
class="option-doc option-doc-embedded"
>
<div class="option-doc-head">
<div>
<h3>{{ plane.selectedOption.name }}</h3>
<p class="doc-copy">{{ plane.selectedOption.description }}</p>
</div>
<span class="type-chip">{{ plane.selectedOption.type }}</span>
</div>
<dl class="doc-facts doc-facts-compact">
<div class="doc-fact">
<dt>Default</dt>
<dd>{{ formatDefault(plane.selectedOption) }}</dd>
</div>
<div
v-if="plane.selectedOption.choices.length"
class="doc-fact"
>
<dt>Choices</dt>
<dd>{{ plane.selectedOption.choices.join(", ") }}</dd>
</div>
<div v-if="plane.selectedOption.reserved" class="doc-fact">
<dt>State</dt>
<dd>Managed by thePlug</dd>
</div>
</dl>
<div class="doc-actions">
<button
class="button-icon"
:disabled="plane.selectedOption.reserved"
@click="plane.setOption(plane.selectedOption)"
>
<UiIcon name="plus" />
<span>Add option</span>
</button>
<a
class="button-link"
v-if="plane.selectedOption.docs_url"
:href="plane.selectedOption.docs_url"
target="_blank"
rel="noreferrer"
>
<UiIcon name="link" />
Official docs
</a>
</div>
</article>
<section class="option-active-card">
<div class="option-active-head">
<h3>Configured options</h3>
<span class="counter">{{ plane.activeOptionCount }}</span>
</div>
<div v-if="plane.activeOptions.length" class="active-options compact-table">
<div class="table-head">
<span>Option</span>
<span>Value</span>
<span />
</div>
<div class="table-body compact-scroll">
<div v-for="item in plane.activeOptions" :key="item.name" class="table-row">
<button class="table-link" @click="plane.selectOption(item.name)">
{{ item.name }}
</button>
<label v-if="isBool(item.option)" class="inline-toggle">
<input
:checked="item.value === 'true'"
type="checkbox"
@change="
plane.updateOption(
item.name,
String(($event.target as HTMLInputElement).checked),
item.option,
)
"
/>
<span>{{ item.value }}</span>
</label>
<input
v-else
:value="item.value"
:type="isNumber(item.option) ? 'number' : 'text'"
:placeholder="isSequence(item.option) ? 'comma separated values' : ''"
@input="
plane.updateOption(
item.name,
($event.target as HTMLInputElement).value,
item.option,
)
"
/>
<button class="ghost table-action" @click="plane.removeOption(item.name)">
Remove
</button>
</div>
</div>
</div>
<p v-else class="muted small">No custom options configured yet.</p>
</section>
</div>
</div>
<template v-else>
<div class="active-options compact-table">
<div class="table-head">
<span>Active</span>
<span>Value</span>
<span />
</div>
<div v-if="plane.activeOptions.length" class="table-body compact-scroll">
<div v-for="item in plane.activeOptions" :key="item.name" class="table-row">
<button class="table-link" @click="plane.selectOption(item.name)">
{{ item.name }}
</button>
<label v-if="isBool(item.option)" class="inline-toggle">
<input
:checked="item.value === 'true'"
type="checkbox"
@change="
plane.updateOption(
item.name,
String(($event.target as HTMLInputElement).checked),
item.option,
)
"
/>
<span>{{ item.value }}</span>
</label>
<input
v-else
:value="item.value"
:type="isNumber(item.option) ? 'number' : 'text'"
:placeholder="isSequence(item.option) ? 'comma separated values' : ''"
@input="
plane.updateOption(
item.name,
($event.target as HTMLInputElement).value,
item.option,
)
"
/>
<button class="ghost table-action" @click="plane.removeOption(item.name)">
Remove
</button>
</div>
</div>
<p v-else class="muted small">No active options.</p>
</div>
<div class="option-shell">
<div class="option-browser">
<div class="option-search-row">
<input v-model="plane.optionSearch" placeholder="Search options" />
<span class="option-count">
{{ plane.optionsLoading ? "..." : plane.filteredOptions.length }}
</span>
</div>
<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"
? "Showing the options that are usually useful in this UI."
: `Showing all ${plane.optionCount} public mitmdump options.`
}}
</p>
<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
v-for="option in plane.filteredOptions"
:key="option.name"
class="option-entry"
:class="{ selected: plane.selectedOption?.name === option.name }"
@click="plane.selectOption(option.name)"
>
<strong>{{ option.name }}</strong>
<small>{{ option.type }}</small>
</button>
</div>
<div v-else class="option-empty-state">
<p class="muted small">No options matched the current filter.</p>
</div>
</div>
<article v-if="!plane.optionsLoading && plane.selectedOption" class="option-doc">
<div class="option-doc-head">
<div>
<h3>{{ plane.selectedOption.name }}</h3>
</div>
<span class="type-chip">{{ plane.selectedOption.type }}</span>
</div>
<p class="doc-copy">{{ plane.selectedOption.description }}</p>
<dl class="doc-facts">
<div class="doc-fact">
<dt>Default</dt>
<dd>{{ formatDefault(plane.selectedOption) }}</dd>
</div>
<div
v-if="plane.selectedOption.choices.length"
class="doc-fact"
>
<dt>Choices</dt>
<dd>{{ plane.selectedOption.choices.join(", ") }}</dd>
</div>
<div v-if="plane.selectedOption.reserved" class="doc-fact">
<dt>State</dt>
<dd>Managed by thePlug</dd>
</div>
</dl>
<div class="doc-actions">
<button
class="button-icon"
:disabled="plane.selectedOption.reserved"
@click="plane.setOption(plane.selectedOption)"
>
<UiIcon name="plus" />
<span>Add option</span>
</button>
<a
class="button-link"
v-if="plane.selectedOption.docs_url"
:href="plane.selectedOption.docs_url"
target="_blank"
rel="noreferrer"
>
<UiIcon name="link" />
Official docs
</a>
</div>
</article>
</div>
</template>
</section>
</template>
-51
View File
@@ -1,51 +0,0 @@
<script setup lang="ts">
import type { ProxyDocument } from "../types";
import StatusPill from "./StatusPill.vue";
defineProps<{
proxies: ProxyDocument[];
selectedPort: number | null;
}>();
const emit = defineEmits<{
select: [port: number];
}>();
</script>
<template>
<aside class="rail panel">
<div class="panel-title">
<div>
<h2>Proxies</h2>
</div>
<span class="counter">{{ proxies.length }}</span>
</div>
<div v-if="!proxies.length" class="empty-state">
<strong>No proxies yet</strong>
<span>Create one to start.</span>
</div>
<div class="proxy-list">
<button
v-for="proxy in proxies"
:key="proxy.proxy_port"
class="proxy-card"
:class="{ selected: proxy.proxy_port === selectedPort }"
@click="emit('select', proxy.proxy_port)"
>
<div class="proxy-card-head">
<strong>{{ proxy.name }}</strong>
<StatusPill :status="proxy.status" />
</div>
<div class="proxy-meta">
<span>:{{ proxy.proxy_port }}</span>
<span>{{ proxy.mode }} / {{ proxy.protocol }}</span>
</div>
<span class="proxy-route">
{{ proxy.service.host }}:{{ proxy.service.port }}
</span>
</button>
</div>
</aside>
</template>
@@ -1,77 +0,0 @@
<script setup lang="ts">
import HealthPanel from "./HealthPanel.vue";
import OptionsPanel from "./OptionsPanel.vue";
import RuntimePanel from "./RuntimePanel.vue";
import UiIcon from "./UiIcon.vue";
import { useControlPlaneContext } from "../composables/useControlPlane";
const plane = useControlPlaneContext();
</script>
<template>
<div v-if="plane.wizardOpen" class="modal-backdrop" @click.self="plane.closeWizard">
<section class="modal-shell">
<header class="modal-head">
<div>
<h2>{{ plane.wizardTitle }}</h2>
<p class="modal-step-copy">
Step {{ plane.wizardStep + 1 }} of 3: {{ plane.wizardStepTitle }}
</p>
</div>
<div class="modal-head-actions">
<button class="primary button-icon" :disabled="!plane.canSaveProxy" @click="plane.saveProxy">
<UiIcon name="save" />
<span>Save & close</span>
</button>
<button class="ghost button-icon" @click="plane.closeWizard">
<UiIcon name="close" />
<span>Close</span>
</button>
</div>
</header>
<p v-if="plane.wizardError" class="error modal-error">
{{ plane.wizardError }}
</p>
<div class="modal-stepper">
<button
v-for="(step, index) in ['Proxy route', 'Healthchecks', 'Custom mitmdump options']"
:key="step"
class="modal-step-chip"
:class="{ active: plane.wizardStep === index, done: plane.wizardStep > index }"
@click="plane.wizardStep = index"
>
<span>{{ index + 1 }}</span>
<strong>{{ step }}</strong>
</button>
</div>
<div class="modal-body">
<RuntimePanel v-if="plane.wizardStep === 0" embedded />
<HealthPanel v-else-if="plane.wizardStep === 1" embedded />
<OptionsPanel v-else embedded />
</div>
<footer class="modal-actions">
<button
v-if="plane.canGoBackWizard"
class="ghost button-icon"
@click="plane.previousWizardStep"
>
<UiIcon name="back" />
<span>Back</span>
</button>
<div class="modal-action-spacer" />
<button
v-if="plane.canGoNextWizard"
class="primary button-icon"
@click="plane.nextWizardStep"
>
<UiIcon name="next" />
<span>Next</span>
</button>
</footer>
</section>
</div>
</template>
-110
View File
@@ -1,110 +0,0 @@
<script setup lang="ts">
import { useControlPlaneContext } from "../composables/useControlPlane";
import EditorPane from "./EditorPane.vue";
import HealthDetailsModal from "./HealthDetailsModal.vue";
import HealthSummaryBar from "./HealthSummaryBar.vue";
import LogsPanel from "./LogsPanel.vue";
import ProxyWizardModal from "./ProxyWizardModal.vue";
import ServiceDiscoveryPanel from "./ServiceDiscoveryPanel.vue";
import StatusPill from "./StatusPill.vue";
import TemplatesPanel from "./TemplatesPanel.vue";
import UiIcon from "./UiIcon.vue";
const plane = useControlPlaneContext();
</script>
<template>
<section class="workbench">
<header class="workbench-head panel">
<div class="workbench-copy">
<h1>{{ plane.selectedProxy ? plane.draft.name : "Proxy workspace" }}</h1>
<div v-if="plane.selectedProxy" class="route-row route-summary">
<span class="route-token">{{ plane.routeState.proxySummary }}</span>
<span class="route-target">{{ plane.routeState.upstreamSummary }}</span>
<StatusPill
:status="plane.selectedProxy.status"
/>
</div>
<p v-else class="summary-copy">
Create a proxy from autodiscovery or open one from the inventory.
</p>
<p v-if="plane.selectedProxy && plane.routeState.proxyPortReservedMessage" class="error compact">
{{ plane.routeState.proxyPortReservedMessage }}
</p>
<p v-if="plane.selectedProxy?.last_error" class="error compact">
{{ plane.selectedProxy.last_error }}
</p>
</div>
<HealthSummaryBar />
<div class="action-row">
<button
v-if="plane.selectedProxy"
class="ghost button-icon"
@click="plane.openEditWizard()"
:disabled="plane.loading"
>
<UiIcon name="settings" />
<span>Settings</span>
</button>
<button
v-if="plane.actionState.showStart"
class="success button-icon"
@click="plane.runtimeAction('start')"
:disabled="!plane.actionState.canStart"
>
<UiIcon name="play" />
<span>Start</span>
</button>
<button
v-if="plane.actionState.showStop"
class="danger button-icon"
@click="plane.runtimeAction('stop')"
:disabled="!plane.actionState.canStop"
>
<UiIcon name="stop" />
<span>Stop</span>
</button>
<button
v-if="plane.actionState.showRestart"
class="warning button-icon"
@click="plane.runtimeAction('restart')"
:disabled="!plane.actionState.canRestart"
>
<UiIcon name="restart" />
<span>Restart</span>
</button>
<button
v-if="plane.selectedProxy"
class="danger button-icon"
@click="plane.removeProxy"
:disabled="!plane.actionState.canDelete"
>
<UiIcon name="trash" />
<span>Delete</span>
</button>
</div>
</header>
<p v-if="plane.workspaceError" class="error">{{ plane.workspaceError }}</p>
<ServiceDiscoveryPanel v-if="!plane.selectedProxy && !plane.wizardOpen" />
<div v-if="plane.selectedProxy" class="workspace-main-row">
<div class="workspace-editor-pane">
<EditorPane />
</div>
<div class="workspace-logs-pane">
<LogsPanel />
</div>
</div>
<div v-if="plane.selectedProxy" class="workspace-templates-row">
<TemplatesPanel />
</div>
<ProxyWizardModal />
<HealthDetailsModal />
</section>
</template>
-101
View File
@@ -1,101 +0,0 @@
<script setup lang="ts">
import { modes, protocols, useControlPlaneContext } from "../composables/useControlPlane";
import UiIcon from "./UiIcon.vue";
const props = withDefaults(defineProps<{ embedded?: boolean }>(), {
embedded: false,
});
const plane = useControlPlaneContext();
</script>
<template>
<section :class="props.embedded ? 'wizard-step-shell' : 'panel stack-panel compact-panel'">
<div v-if="!props.embedded" class="panel-title">
<h2>Proxy route</h2>
</div>
<div class="route-strip">
<span class="route-chip">{{ plane.routeState.proxySummary }}</span>
<span class="route-chip">{{ plane.routeState.upstreamSummary }}</span>
</div>
<div class="route-overview-grid">
<article class="metric-card">
<span class="metric-label">Service</span>
<strong>{{ plane.routeState.serviceLabel }}</strong>
</article>
<article class="metric-card">
<span class="metric-label">Mode</span>
<strong>{{ plane.routeState.modeLabel }}</strong>
</article>
</div>
<div class="form-grid route-form-grid tight-grid single-row-grid">
<label>
Name
<input v-model="plane.draft.name" />
</label>
</div>
<div class="form-grid tight-grid">
<label>
Proxy port
<input
v-model.number="plane.draft.proxy_port"
:disabled="Boolean(plane.selectedProxy)"
type="number"
/>
<small v-if="plane.selectedProxy">
Port is fixed for existing proxies. Create a new proxy to change it.
</small>
<small v-if="plane.routeState.proxyPortReservedMessage" class="route-port-error">
{{ plane.routeState.proxyPortReservedMessage }}
</small>
</label>
<label>
Upstream host
<input v-model="plane.draft.service.host" />
</label>
<label>
Upstream port
<input v-model.number="plane.draft.service.port" type="number" />
</label>
<label>
Listen host
<input v-model="plane.draft.listen_host" />
</label>
<label>
Mode
<select v-model="plane.draft.mode">
<option v-for="mode in modes" :key="mode">{{ mode }}</option>
</select>
</label>
<label>
Protocol
<select v-model="plane.draft.protocol">
<option v-for="protocol in protocols" :key="protocol">{{ protocol }}</option>
</select>
</label>
<label class="route-mode-spec">
Mode spec
<input
v-model="plane.draft.mode_spec"
placeholder="optional raw mitmdump mode"
/>
</label>
<div
v-if="plane.routeState.takeoverMismatch"
class="inline-grid advanced-inline-action"
>
<p class="muted small compact-copy">
Proxy port differs from the original service port.
</p>
<button class="ghost button-icon" @click="plane.matchProxyPortToService">
<UiIcon name="link" />
<span>Use service port</span>
</button>
</div>
</div>
</section>
</template>
@@ -1,100 +0,0 @@
<script setup lang="ts">
import { useControlPlaneContext } from "../composables/useControlPlane";
import StatusPill from "./StatusPill.vue";
import UiIcon from "./UiIcon.vue";
const plane = useControlPlaneContext();
</script>
<template>
<section class="panel stack-panel compact-panel">
<div class="panel-title">
<h2>Services</h2>
<button class="ghost button-icon" :disabled="plane.servicesLoading" @click="plane.refreshServices">
<UiIcon name="refresh" />
<span>{{ plane.servicesLoading ? "Scanning" : "Refresh" }}</span>
</button>
</div>
<div class="inline-grid">
<label>
Scan root
<input v-model="plane.scanRoot" placeholder="/root" />
</label>
<button class="primary button-icon" :disabled="plane.servicesLoading" @click="plane.refreshServices">
<UiIcon name="refresh" />
<span>Scan</span>
</button>
</div>
<p v-if="plane.servicesLoading" class="muted small compact-copy">
Scanning compose folders in {{ plane.scanRoot }}...
</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">
No compose services detected in the scan root.
</p>
<div v-else class="service-discovery-list">
<article
v-for="group in plane.serviceGroups"
:key="group.id"
class="service-group-card"
>
<div class="service-group-head">
<div class="service-group-copy">
<h3>{{ group.title }}</h3>
<small class="service-folder">{{ group.folder }}</small>
</div>
<span class="muted small">
{{ group.entries.length }} {{ group.entries.length === 1 ? "service" : "services" }}
</span>
</div>
<div class="service-group-list">
<article
v-for="entry in group.entries"
:key="entry.id"
class="service-discovery-row"
>
<div class="service-discovery-main">
<strong>{{ entry.service.name }}</strong>
<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>
</article>
</div>
</section>
</template>
-9
View File
@@ -1,9 +0,0 @@
<script setup lang="ts">
defineProps<{
status: string;
}>();
</script>
<template>
<span class="status-pill" :class="status">{{ status }}</span>
</template>
@@ -1,55 +0,0 @@
<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>

Some files were not shown because too many files have changed in this diff Show More