BIG BIG BIG BIG BIG UPDATES

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
gabdevele
2026-05-04 23:59:48 +02:00
co-authored by Copilot
Unverified
parent 46f7cd6a5f
commit 93ba8e9ac2
88 changed files with 7591 additions and 447 deletions
+10 -1
View File
@@ -4,4 +4,13 @@
.vscode/
.env
__pycache__/
.pytest_cache/
.pytest_cache/
**/__pycache__/
**/.pytest_cache/
**/.ruff_cache/
node_modules/
dist/
*.tsbuildinfo
backend/.venv/
frontend/node_modules/
frontend/dist/
+54 -49
View File
@@ -1,79 +1,84 @@
# thePlug - plugger/proxy Barese
# thePlug
icona di una presa della spina idk
Wrapper web per gestire proxy `mitmdump` davanti ai servizi di una competizione
CTF attack/defense.
## Architettura
## Struttura
```
FastAPI + MongoDB + Redis
```text
backend/ FastAPI, MongoDB, Redis, mitmdump manager
frontend/ Vue 3, Vite, Monaco editor
```
## Quick Setup (prod)
## Setup rapido
```bash
bash <(curl -s https://git.reloia.uk/reloia/thePlug/raw/branch/master/setup.sh)
./setup.sh
```
## Setup Dev
Lo script crea `backend/.env`, genera le credenziali mancanti e avvia lo stack
Docker completo:
imposta le variabili d'ambiente
```text
Frontend http://localhost:5173
Backend http://localhost:3671
```
Per installare anche il servizio systemd user:
```bash
cp .env.example .env
# modifica le variabili
nano .env
./setup.sh service
```
variabili auth richieste in `.env`:
## Dev backend
```bash
JWT_SECRET_KEY=change-this-secret
JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=30
AUTH_USERNAME=admin
AUTH_PASSWORD=change-this-password
cp backend/.env.example backend/.env
docker compose --env-file backend/.env up -d mongodb redis
npm run dev:backend
```
avvia i servizi e il server locale
Docs FastAPI in dev: `http://localhost:3671/docs`.
## Dev frontend
```bash
docker compose up -d mongodb redis
cd frontend
npm install
npm run dev
```
Il dev server usa `/api` come proxy verso `http://127.0.0.1:3671`.
## Test
```bash
fastapi dev app/main.py
npm run test
```
accedi a FastAPI su: [FastAPI docs](http://localhost:3671/docs)
Equivalente a:
## Auth API
```bash
cd backend && uv run ruff check . && uv run pytest
cd frontend && npm run build
```
Gli endpoint auth disponibili sono solo:
## 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`
- `GET /proxy/mitmproxy/options`
- `GET /proxy/mitmproxy/examples`
Esempio login:
```bash
curl -X POST http://localhost:3671/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"change-this-password"}'
```
L'access token ottenuto va inviato come bearer token:
```bash
curl http://localhost:3671/proxy \
-H "Authorization: Bearer <ACCESS_TOKEN>"
```
Esempio logout (revoca server-side del token):
```bash
curl -X POST http://localhost:3671/auth/logout \
-H "Authorization: Bearer <ACCESS_TOKEN>"
```
## Documentazione per Sviluppo
[MitMProxy examples](https://docs.mitmproxy.org/stable/addons/examples/)
## 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/
+35
View File
@@ -0,0 +1,35 @@
UI SCHIFOSA
andrebbe rifatto proprio il layout.
dovrebbe essere in ordine di priorità, l'editor non può stare così tanto sotto.
card come:
- Runtime mapping
- What start does
vanno eliminati.
di default quando si crea un reverse proxy, l'intenzione è quella di spostare la porta del servizio ad un'altra e mettere il proxy al posto della porta del servizio iniziale.
anche ```Python tools ready``` è inutile
Addon editor come titolo non mi piace
```
No proxies configured
Create a draft, save it, then start it when you want mitmdump to own the route.
```
troppa roba, riassumere.
Options card buggattissima lato UI, inoltre a questo punto va fatta una scrematura sulle opzioni sulle cose che possono servire veramente, inutile mettere troppe cose.
gli esempi vanno aggiornati con roba reale utile.
capire come testare davvero sulla mia macchina
cambiare text sulla pagina di login
cambiare dove e come viene mostrato alert quando c'è errore di autenticazione (loggati in precedenza con token scaduto.)
rendere lo script davvero funzionante
solo il backend non deve runnare dentro docker ma sulla macchina reale.
-89
View File
@@ -1,89 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, status
from core.security import require_authenticated_user
from models.proxy import ProxyCreateInput, ProxyUpdateInput
from repositories.proxy import (
create_proxy,
delete_proxy,
get_proxy_by_port,
update_proxy,
)
from repositories.proxy import (
list_proxies as list_proxy_documents,
)
from services.mitmproxy.validator import ProxyCodeValidationError, validate_proxy_code
router = APIRouter(
prefix="/proxy",
tags=["Proxy"],
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
@router.get("")
async def list_proxies_handler():
return await list_proxy_documents()
@router.get("/{proxy_port}")
async def get_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",
)
return proxy
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_proxy_handler(payload: ProxyCreateInput):
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)
return await create_proxy(
proxy_port=payload.proxy_port,
name=payload.name,
service={"port": payload.service.port},
code=payload.code,
)
@router.put("/{proxy_port}")
async def edit_proxy(proxy_port: int, payload: ProxyUpdateInput):
if payload.code is not None:
await _validate_code_or_raise(payload.code)
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.delete("/{proxy_port}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_proxy(proxy_port: int):
deleted = await delete_proxy(proxy_port)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Proxy not found",
)
-47
View File
@@ -1,47 +0,0 @@
from datetime import datetime
from models.service import ServiceEndpointDocument
from pydantic import BaseModel, ConfigDict, Field
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")
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")
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")
service: ServiceEndpointDocument | None = Field(
default=None,
description="Associated upstream service",
)
-7
View File
@@ -1,7 +0,0 @@
from pydantic import BaseModel, ConfigDict, Field
class ServiceEndpointDocument(BaseModel):
model_config = ConfigDict(extra="forbid")
port: int = Field(..., description="Upstream service port")
-42
View File
@@ -1,42 +0,0 @@
from pathlib import Path
from integrations import docker
from integrations.docker import DockerContainer
from integrations.docker.ports import _get_random_port
async def create_proxy(
port: int,
service: DockerContainer,
code: str,
) -> dict[str, object]:
"""
Utility function that will:
- run ``integrations/docker`` utilities to change the old port of original service to a random one
/- write ``code`` into ``/tmp/theplug/{random}.py``
- start a subprocess via asyncio
- return the proxy infos (pid, )
"""
path = Path(f"/tmp/theplug/{service['name']}.py")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(code, encoding="utf-8")
# da qui bisogna:
# scegliere una porta random alla quale spostare il docker
# modificare il docker via integrations.docker.editor.change_port ( service.folder, service.name, port, random_nuova_port )
random_port = _get_random_port()
changed = await docker.change_port(
service["name"],
service["folder"],
port,
random_port,
)
return {
"service": service["name"],
"previous_port": port,
"upstream_port": random_port,
"script_path": str(path),
"port_changed": changed,
}
+2 -1
View File
@@ -1,4 +1,5 @@
API_PORT=3671
FRONTEND_PORT=5173
MONGO_INITDB_ROOT_PASSWORD=pass# esegui 'openssl rand -hex 5' in un terminale e copia qui
MONGODB_URI=mongodb://theplug:pass@localhost:27017/theplug?authSource=admin
MONGODB_DB_NAME=theplug
@@ -7,4 +8,4 @@ JWT_SECRET_KEY= # esegui 'openssl rand -hex 20' in un terminale e copia qui
JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=30
AUTH_USERNAME=admin
AUTH_PASSWORD= # esegui 'openssl rand -hex 20' in un terminale e copia qui
AUTH_PASSWORD= # esegui 'openssl rand -hex 20' in un terminale e copia qui
+8 -3
View File
@@ -1,7 +1,8 @@
FROM python:3.14-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app/src
WORKDIR /app
@@ -9,11 +10,15 @@ COPY pyproject.toml ./
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir \
"authlib>=1.6.0" \
"basedpyright>=1.39.0" \
"fastapi[standard]>=0.135.3" \
"mitmproxy>=12.2.2" \
"motor>=3.7.1" \
"passlib[bcrypt]>=1.7.4" \
"pwdlib[argon2,bcrypt]>=0.3.0" \
"pyyaml>=6.0.3" \
"pydantic-settings>=2.13.1" \
"redis>=7.4.0"
"redis>=7.4.0" \
"ruff>=0.15.10"
COPY app ./src
@@ -1,14 +1,13 @@
from datetime import UTC, datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Response, status
from core.security import (
AuthContext,
create_access_token,
get_auth_context,
verify_password,
)
from fastapi import APIRouter, Depends, HTTPException, Response, status
from models.auth import LoginInput, TokenResponse
from repositories.auth import get_user_by_username, revoke_token
+62
View File
@@ -0,0 +1,62 @@
from core.security import (
InvalidTokenError,
authenticate_token,
require_authenticated_user,
)
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
from services.editor import python_lsp
from starlette.websockets import WebSocketDisconnect
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")
+300
View File
@@ -0,0 +1,300 @@
from pathlib import Path
from core.security import require_authenticated_user
from fastapi import APIRouter, Depends, HTTPException, Request, status
from models.example import AddonExampleCreateInput
from models.proxy import ProxyCreateInput, ProxyUpdateInput
from pymongo.errors import DuplicateKeyError
from repositories.examples import (
create_custom_example,
delete_custom_example,
list_custom_examples,
)
from repositories.proxy import (
create_proxy,
delete_proxy,
get_proxy_by_port,
update_proxy,
update_proxy_runtime,
)
from repositories.proxy import (
list_proxies as list_proxy_documents,
)
from services.mitmproxy.examples import BUILTIN_EXAMPLES
from services.mitmproxy.health import check_proxy
from services.mitmproxy.manager import MitmdumpError, manager
from services.mitmproxy.options import (
MitmOptionError,
load_options,
public_options,
validate_options,
)
from 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",
}
router = APIRouter(
prefix="/proxy",
tags=["Proxy"],
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
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("")
async def list_proxies_handler():
return await list_proxy_documents()
@router.get("/services")
async def list_services(request: Request):
return getattr(request.app.state, "services", [])
@router.get("/mitmproxy/options")
async def list_mitmproxy_options():
return public_options(await load_options())
@router.get("/mitmproxy/examples")
async def list_mitmproxy_examples():
return [*BUILTIN_EXAMPLES, *await list_custom_examples()]
@router.post("/mitmproxy/examples", status_code=status.HTTP_201_CREATED)
async def create_mitmproxy_example(payload: AddonExampleCreateInput):
await _validate_code_or_raise(payload.code)
try:
return await create_custom_example(payload)
except DuplicateKeyError as error:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Example already exists: {payload.id}",
) from error
@router.delete(
"/mitmproxy/examples/{example_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_mitmproxy_example(example_id: str):
if any(example.id == example_id for example in BUILTIN_EXAMPLES):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Built-in examples cannot be deleted",
)
deleted = await delete_custom_example(example_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Example not found",
)
@router.get("/{proxy_port}")
async def get_proxy(proxy_port: int):
return await get_proxy_or_404(proxy_port)
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_proxy_handler(payload: ProxyCreateInput):
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}")
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")
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")
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")
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")
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")
async def proxy_health(proxy_port: int):
proxy = await get_proxy_or_404(proxy_port)
return await check_proxy(proxy)
@router.get("/{proxy_port}/logs")
async def proxy_logs(proxy_port: int):
proxy = await get_proxy_or_404(proxy_port)
if proxy.log_path is None:
return {"proxy_port": proxy.proxy_port, "log": ""}
path = Path(proxy.log_path)
if not path.is_file():
return {"proxy_port": proxy.proxy_port, "log": ""}
return {
"proxy_port": proxy.proxy_port,
"log": path.read_text(encoding="utf-8", errors="replace")[-8000:],
}
@router.delete("/{proxy_port}", status_code=status.HTTP_204_NO_CONTENT)
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)
deleted = await delete_proxy(proxy_port)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Proxy not found",
)
@@ -4,7 +4,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file=(".env", "backend/.env"),
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
@@ -20,4 +20,4 @@ class Settings(BaseSettings):
AUTH_PASSWORD: str
settings = Settings()
settings = Settings()
@@ -10,6 +10,7 @@ class _MongoDB:
raise RuntimeError("MongoDB client has not been initialized")
return self.client[settings.MONGODB_DB_NAME]
mongodb = _MongoDB()
@@ -33,4 +34,4 @@ 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")
await client.admin.command("ping")
@@ -5,13 +5,12 @@ from uuid import uuid4
from authlib.jose import jwt # type: ignore
from authlib.jose.errors import JoseError # type: ignore
from core.config import settings
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from models.auth import AccessTokenPayload, AuthUserDocument
from pwdlib import PasswordHash
from pwdlib.hashers.argon2 import Argon2Hasher
from core.config import settings
from models.auth import AccessTokenPayload, AuthUserDocument
from repositories.auth import get_user_by_username, is_token_revoked
pwd_context = PasswordHash((Argon2Hasher(),))
@@ -42,6 +41,8 @@ def hash_password(password: str) -> str:
def verify_password(plain_password: str, password_hash: str) -> bool:
if hasattr(pwd_context, "verify"):
return pwd_context.verify(plain_password, password_hash)
return pwd_context.check(plain_password, password_hash)
@@ -74,10 +75,26 @@ def decode_access_token(token: str) -> AccessTokenPayload:
claims = jwt.decode(token, settings.JWT_SECRET_KEY)
claims.validate()
return AccessTokenPayload.model_validate(dict(claims))
except (JoseError, ValueError, TypeError):
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,
@@ -87,21 +104,11 @@ async def get_auth_context(
if credentials is None or credentials.scheme.lower() != "bearer":
raise token_auth_error()
token = credentials.credentials
try:
payload = decode_access_token(token)
return await authenticate_token(credentials.credentials)
except InvalidTokenError:
raise token_auth_error() from None
if await is_token_revoked(payload.jti):
raise token_auth_error()
user = await get_user_by_username(payload.sub)
if user is None:
raise token_auth_error()
return AuthContext(token=token, user=user, payload=payload)
async def require_authenticated_user(
auth_context: Annotated[AuthContext, Depends(get_auth_context)],
@@ -7,9 +7,9 @@ from integrations.docker.editor import change_port
from integrations.docker.models import DockerContainer
__all__ = [
"DockerContainer",
"find_docker_compose",
"get_services",
"DockerContainer",
"find_docker_compose",
"get_services",
"get_all_services_in_dir",
"change_port",
"change_port",
]
@@ -2,7 +2,6 @@ import asyncio
from pathlib import Path
import yaml
from integrations.docker.models import DockerContainer
from integrations.docker.ports import _get_service_port
@@ -16,7 +15,9 @@ COMPOSE_NAMES = (
def _get_visible_folders(directory: Path) -> list[Path]:
return [
p for p in Path(directory).iterdir() if p.is_dir() and not p.name.startswith(".")
p
for p in Path(directory).iterdir()
if p.is_dir() and not p.name.startswith(".")
]
@@ -76,10 +77,7 @@ def get_all_services_in_dir(directory: str | Path) -> list[DockerContainer]:
return services
async def run_docker_command(
file: Path,
command: list[str]
) -> dict | None:
async def run_docker_command(file: Path, command: list[str]) -> dict | None:
if not command:
return None
@@ -89,7 +87,7 @@ async def run_docker_command(
*full_command,
cwd=str(file.parent),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
@@ -1,7 +1,6 @@
from pathlib import Path
import yaml
from integrations.docker.compose import find_docker_compose, run_docker_command
from integrations.docker.ports import _get_port_index, _replace_port_in_mapping
@@ -13,10 +12,10 @@ def _backup_path_for(compose_path: Path) -> Path:
async def change_port(
service_name: str,
folder: Path,
port_from: int,
port_to: int,
service_name: str,
folder: Path,
port_from: int,
port_to: int,
) -> bool:
compose_data = find_docker_compose(folder)
if compose_data is None:
@@ -45,7 +44,9 @@ async def change_port(
original_text = compose_path.read_text()
backup_path.write_text(compose_path.read_text())
compose_path.write_text(yaml.dump(docker_compose, default_flow_style=False, sort_keys=False))
compose_path.write_text(
yaml.dump(docker_compose, default_flow_style=False, sort_keys=False)
)
result = await run_docker_command(
compose_path,
@@ -2,16 +2,16 @@ import socket
from collections.abc import Mapping
def _get_random_port() -> int:
# os automatically assigns an available port when binding to port 0, so we can use that to find a free port
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.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}")
raise RuntimeError(f"Could not find an available port: {e}") from e
def _get_service_port(mapping: str | int | Mapping[str, object]) -> int | None:
@@ -24,7 +24,7 @@ def _get_service_port(mapping: str | int | Mapping[str, object]) -> int | None:
return None
try:
return int(published)
except (TypeError, ValueError):
except TypeError, ValueError:
return None
parts = str(mapping).split(":")
@@ -48,9 +48,9 @@ def _get_service_port(mapping: str | int | Mapping[str, object]) -> int | None:
def _replace_port_in_mapping(
mapping: str | int | Mapping[str, object],
port_from: int,
port_to: int,
mapping: str | int | Mapping[str, object],
port_from: int,
port_to: int,
) -> str | dict[str, object]:
if isinstance(mapping, Mapping):
updated = dict(mapping)
@@ -70,9 +70,11 @@ def _replace_port_in_mapping(
return str(port_to)
def _get_port_index(mappings: list[str | int | Mapping[str, object]], port: int) -> int | None:
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
+8 -6
View File
@@ -3,16 +3,17 @@ import sys
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from api.auth.router import router as auth_router
from api.editor.router import router as editor_router
from api.proxy.router import router as proxy_router
from core.config import settings
from core.mongodb import close_mongodb, init_mongodb
from core.redis import close_redis, init_redis
from core.security import hash_password
from fastapi import FastAPI
from integrations.docker import get_all_services_in_dir
from repositories.auth import ensure_auth_indexes, ensure_single_account
from repositories.examples import ensure_example_indexes
from repositories.proxy import ensure_proxy_indexes
@@ -26,15 +27,15 @@ async def lifespan(_: FastAPI):
password_hash=hash_password(settings.AUTH_PASSWORD),
)
await ensure_proxy_indexes()
await ensure_example_indexes()
home_dir = str(Path.home())
app.state.services = get_all_services_in_dir(home_dir) or []
if not app.state.services:
warning = (
"\n" + "!" * 60 + "\n"
" WARNING: No services found in home directory!\n"
f" Scanned: {home_dir}\n"
+ "!" * 60 + "\n"
"\n" + "!" * 60 + "\n"
" WARNING: No services found in home directory!\n"
f" Scanned: {home_dir}\n" + "!" * 60 + "\n"
)
print(warning, file=sys.stderr)
@@ -56,6 +57,7 @@ app = FastAPI(
)
app.include_router(auth_router)
app.include_router(editor_router)
app.include_router(proxy_router)
+28
View File
@@ -0,0 +1,28 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class AddonExampleDocument(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str = Field(..., description="Stable example identifier")
name: str = Field(..., description="Display name")
description: str = Field(default="", description="Short explanation")
category: str = Field(default="custom", description="Example category")
code: str = Field(..., description="Addon source code")
source_url: str | None = Field(default=None, description="Reference URL")
built_in: bool = Field(default=False, description="Provided by thePlug")
created_at: datetime | None = None
updated_at: datetime | None = None
class AddonExampleCreateInput(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str = Field(..., description="Stable example identifier")
name: str = Field(..., description="Display name")
description: str = Field(default="", description="Short explanation")
category: str = Field(default="custom", description="Example category")
code: str = Field(..., description="Addon source code")
source_url: str | None = Field(default=None, description="Reference URL")
+115
View File
@@ -0,0 +1,115 @@
from datetime import datetime
from typing import Any
from models.service import ServiceEndpointDocument
from pydantic import BaseModel, ConfigDict, Field
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",
)
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",
)
+17
View File
@@ -0,0 +1,17 @@
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")
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",
)
@@ -2,10 +2,9 @@ from collections.abc import Mapping
from datetime import UTC, datetime
from typing import Any
from motor.motor_asyncio import AsyncIOMotorCollection
from core.mongodb import get_database
from models.auth import AuthUserDocument
from motor.motor_asyncio import AsyncIOMotorCollection
USERS_COLLECTION_NAME = "auth_users"
REVOKED_COLLECTION_NAME = "revoked_tokens"
+70
View File
@@ -0,0 +1,70 @@
from collections.abc import Mapping
from datetime import UTC, datetime
from typing import Any
from core.mongodb import get_database
from models.example import AddonExampleCreateInput, AddonExampleDocument
from motor.motor_asyncio import AsyncIOMotorCollection
COLLECTION_NAME = "addon_examples"
def utcnow() -> datetime:
return datetime.now(UTC)
async def collection() -> AsyncIOMotorCollection[dict[str, Any]]:
database = await get_database()
return database[COLLECTION_NAME]
def deserialize(raw: Mapping[str, Any] | None) -> AddonExampleDocument | None:
if raw is None:
return None
document = dict(raw)
document.pop("_id", None)
return AddonExampleDocument.model_validate(document)
async def ensure_example_indexes() -> None:
examples = await collection()
await examples.create_index("id", unique=True, name="uq_addon_example_id")
async def list_custom_examples() -> list[AddonExampleDocument]:
examples = await collection()
cursor = examples.find({}).sort("name", 1)
result: list[AddonExampleDocument] = []
async for raw in cursor:
example = deserialize(raw)
if example is not None:
result.append(example)
return result
async def create_custom_example(
payload: AddonExampleCreateInput,
) -> AddonExampleDocument:
now = utcnow()
example = AddonExampleDocument(
id=payload.id,
name=payload.name,
description=payload.description,
category=payload.category,
code=payload.code,
source_url=payload.source_url,
built_in=False,
created_at=now,
updated_at=now,
)
examples = await collection()
await examples.insert_one(example.model_dump(exclude_none=True))
return example
async def delete_custom_example(example_id: str) -> bool:
examples = await collection()
result = await examples.delete_one({"id": example_id})
return result.deleted_count > 0
@@ -2,13 +2,11 @@ from collections.abc import Mapping
from datetime import UTC, datetime
from typing import Any
from core.mongodb import get_database
from models.proxy import ProxyCreateInput, ProxyDocument, ProxyUpdateInput
from motor.motor_asyncio import AsyncIOMotorCollection
from pymongo import ReturnDocument
from core.mongodb import get_database
from models.proxy import ProxyDocument, ProxyUpdateInput
from models.service import ServiceEndpointDocument
COLLECTION_NAME = "proxies"
@@ -34,29 +32,19 @@ async def ensure_proxy_indexes() -> None:
await collection.create_index("proxy_port", unique=True, name="uq_proxy_port")
async def create_proxy(
proxy_port: int,
name: str,
upstream_port: int,
code: str,
) -> ProxyDocument:
"""
Saves a Proxy to the database.
:param proxy_port: the port the proxy is listening on
:param name: the name of the service
:param upstream_port: the upstream port the proxy is forwarding to
:param code: the code of the service
:return:
"""
async def create_proxy(payload: ProxyCreateInput) -> ProxyDocument:
now = _utcnow()
proxy = ProxyDocument(
proxy_port=proxy_port,
name=name,
code=code,
service=ServiceEndpointDocument(
port=upstream_port,
),
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,
)
@@ -103,6 +91,22 @@ async def update_proxy(
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})
+323
View File
@@ -0,0 +1,323 @@
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()
+134
View File
@@ -0,0 +1,134 @@
from models.example import AddonExampleDocument
DOCS_EXAMPLES_URL = "https://docs.mitmproxy.org/stable/addons/examples/"
BUILTIN_EXAMPLES = [
AddonExampleDocument(
id="http-add-response-header",
name="HTTP add response header",
description=(
"Injects a fixed response header on every HTTP response so you can "
"verify that traffic is passing through the proxy response path."
),
category="http",
source_url=DOCS_EXAMPLES_URL,
built_in=True,
code="""from mitmproxy import http
def response(flow: http.HTTPFlow) -> None:
flow.response.headers["x-theplug"] = "enabled"
""",
),
AddonExampleDocument(
id="http-modify-query",
name="HTTP modify query",
description=(
"Adds or overwrites a query parameter before each request is "
"forwarded upstream. Useful for forced debug or feature flags."
),
category="http",
source_url=DOCS_EXAMPLES_URL,
built_in=True,
code="""from mitmproxy import http
def request(flow: http.HTTPFlow) -> None:
flow.request.query["debug"] = "1"
""",
),
AddonExampleDocument(
id="http-reply-from-proxy",
name="HTTP reply from proxy",
description=(
"Serves a local HTTP response for a selected path without touching "
"the upstream service. Useful for probes, maintenance, or stubs."
),
category="http",
source_url=DOCS_EXAMPLES_URL,
built_in=True,
code="""from mitmproxy import http
def request(flow: http.HTTPFlow) -> None:
if flow.request.path == "/healthz":
flow.response = http.Response.make(
200,
b"ok",
{"content-type": "text/plain"},
)
""",
),
AddonExampleDocument(
id="websocket-rewrite",
name="WebSocket rewrite",
description=(
"Rewrites matching bytes inside live WebSocket frames to alter "
"runtime flags or payload markers in transit."
),
category="websocket",
source_url=DOCS_EXAMPLES_URL,
built_in=True,
code="""from mitmproxy import http
def websocket_message(flow: http.HTTPFlow) -> None:
if flow.websocket is None:
return
message = flow.websocket.messages[-1]
message.content = message.content.replace(b"debug=false", b"debug=true")
""",
),
AddonExampleDocument(
id="tcp-log-messages",
name="TCP log messages",
description=(
"Logs raw TCP message direction and size for each frame so you can "
"observe binary traffic without altering it."
),
category="tcp",
source_url=DOCS_EXAMPLES_URL,
built_in=True,
code="""import logging
from mitmproxy import tcp
def tcp_message(flow: tcp.TCPFlow) -> None:
message = flow.messages[-1]
direction = "client" if message.from_client else "server"
logging.info("%s sent %d bytes", direction, len(message.content))
""",
),
AddonExampleDocument(
id="dns-spoof-a-record",
name="DNS spoof A record",
description=(
"Overrides one DNS A record while forwarding every other query "
"unchanged. Useful for selective service redirection."
),
category="dns",
source_url=DOCS_EXAMPLES_URL,
built_in=True,
code="""import ipaddress
from mitmproxy import dns
def dns_request(flow: dns.DNSFlow) -> None:
question = flow.request.question
if question and question.name == "target.local" and question.type == dns.types.A:
flow.response = flow.request.succeed(
[
dns.ResourceRecord(
name="target.local",
type=dns.types.A,
class_=dns.classes.IN,
ttl=60,
data=ipaddress.ip_address("127.0.0.1").packed,
)
]
)
""",
),
]
+185
View File
@@ -0,0 +1,185 @@
import asyncio
import ssl
from dataclasses import dataclass
from models.proxy import HealthCheckDocument, ProxyDocument
@dataclass(slots=True)
class Target:
name: str
host: str
port: int
protocol: str
async def check_proxy(proxy: ProxyDocument) -> dict[str, object]:
if not proxy.healthcheck.enabled:
return {"enabled": False, "overall": "disabled", "checks": []}
upstream_port = proxy.service.upstream_port or proxy.service.port
checks = 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 {"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,
) -> dict[str, object]:
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,
) -> dict[str, object]:
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})
async def check_tcp(
target: Target,
config: HealthCheckDocument,
*,
use_tls: bool,
) -> dict[str, object]:
_reader, writer = await open_connection(target, config, use_tls=use_tls)
writer.close()
await writer.wait_closed()
return result(target, True)
async def check_udp(target: Target, config: HealthCheckDocument) -> dict[str, object]:
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)})
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,
) -> dict[str, object]:
return {
"target": target.name,
"host": target.host,
"port": target.port,
"protocol": target.protocol,
"healthy": healthy,
"error": error,
"details": details or {},
}
+283
View File
@@ -0,0 +1,283 @@
import asyncio
import os
import shutil
import signal
from pathlib import Path
import yaml
from integrations import docker
from integrations.docker.ports import get_random_port
from models.proxy import ProxyDocument
from services.mitmproxy.options import MitmOptionError, validate_options
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.locks: dict[int, asyncio.Lock] = {}
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)
async def start_unlocked(self, proxy: ProxyDocument) -> dict[str, object]:
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 = self.write_script(proxy)
config_path = self.write_config(proxy)
log_path = 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 = self.read_log_tail(log_path) or "mitmdump exited during startup"
raise MitmdumpError(reason)
self.processes[proxy.proxy_port] = process
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)
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)
return "stopped"
if proxy.pid is None:
return "stopped" if proxy.status == "running" else proxy.status
try:
os.kill(proxy.pid, 0)
except ProcessLookupError:
return "stopped"
except PermissionError:
return "running"
return "running"
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
upstream_port = get_random_port()
changed = await docker.change_port(
proxy.service.name,
Path(proxy.service.folder),
proxy.service.port,
upstream_port,
)
if not changed:
raise MitmdumpError("Unable to move Docker service port")
return upstream_port
async def restore_upstream(
self,
proxy: ProxyDocument,
upstream_port: int | None,
) -> None:
if upstream_port is None:
return
if proxy.service.name is None or proxy.service.folder is None:
return
if upstream_port == proxy.service.port:
return
await docker.change_port(
proxy.service.name,
Path(proxy.service.folder),
upstream_port,
proxy.service.port,
)
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 write_script(self, proxy: ProxyDocument) -> Path:
path = self.work_dir / "addons" / f"{proxy.proxy_port}.py"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(proxy.code, encoding="utf-8")
return path
def write_config(self, proxy: ProxyDocument) -> Path:
path = self.work_dir / "conf" / str(proxy.proxy_port) / "config.yaml"
path.parent.mkdir(parents=True, exist_ok=True)
options = {"block_global": False, **proxy.mitm_options}
path.write_text(yaml.safe_dump(options, sort_keys=True), encoding="utf-8")
return path
def log_path(self, proxy: ProxyDocument) -> Path:
path = self.work_dir / "logs" / f"{proxy.proxy_port}.log"
path.parent.mkdir(parents=True, exist_ok=True)
return path
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:
try:
os.killpg(process.pid, signal.SIGTERM)
except ProcessLookupError:
return
try:
await asyncio.wait_for(process.wait(), timeout=5)
except TimeoutError:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
return
await process.wait()
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:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
return
for _ in range(10):
await asyncio.sleep(0.2)
try:
os.kill(pid, 0)
except ProcessLookupError:
return
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
return
def read_log_tail(self, path: Path, limit: int = 4000) -> str:
if not path.exists():
return ""
content = path.read_text(encoding="utf-8", errors="replace")
return content[-limit:].strip()
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
@@ -0,0 +1,156 @@
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()
]
@@ -5,7 +5,7 @@ import shutil
import tempfile
from typing import Any
TARGET_RUFF_CODES = ("F821", "F822", "F823", "E999")
TARGET_RUFF_CODES = ("F821", "F822", "F823")
class ProxyCodeValidationError(Exception):
+3 -1
View File
@@ -5,13 +5,16 @@ description = "Add your description here"
requires-python = ">=3.14"
dependencies = [
"authlib>=1.6.0",
"basedpyright>=1.39.0",
"fastapi[standard]>=0.135.3",
"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",
"redis>=7.4.0",
"ruff>=0.15.10",
]
[tool.ruff]
@@ -33,5 +36,4 @@ addopts = "-q"
[dependency-groups]
dev = [
"pytest>=9.0.3",
"ruff>=0.15.10",
]
+52
View File
@@ -0,0 +1,52 @@
import pytest
from services.editor import python_lsp
from starlette.websockets import WebSocketDisconnect
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("api.editor.router.python_lsp.lsp_binary", lambda: "/bin/lsp")
monkeypatch.setattr("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(
"api.editor.router.authenticate_editor_socket",
fake_authenticate,
)
monkeypatch.setattr("api.editor.router.python_lsp.lsp_binary", lambda: "/bin/lsp")
monkeypatch.setattr("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
@@ -1,8 +1,9 @@
import importlib
from core.security import get_auth_context, require_authenticated_user
from models.proxy import ProxyDocument
from models.proxy import ProxyCreateInput, ProxyDocument
from models.service import ServiceEndpointDocument
from services.mitmproxy.examples import BUILTIN_EXAMPLES
from services.mitmproxy.validator import ProxyCodeValidationError
proxy_router_module = importlib.import_module("api.proxy.router")
@@ -101,22 +102,17 @@ def test_create_proxy_returns_created_document(client, monkeypatch: object):
assert proxy_port == 4040
return None
async def fake_create_proxy(
*,
proxy_port: int,
name: str,
service: dict[str, object],
code: str,
) -> ProxyDocument:
captured["proxy_port"] = proxy_port
captured["name"] = name
captured["service"] = service
captured["code"] = code
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=proxy_port,
name=name,
code=code,
port=int(service["port"]),
proxy_port=payload.proxy_port,
name=payload.name,
code=payload.code,
port=payload.service.port,
)
async def fake_validate_proxy_code(code: str) -> None:
@@ -149,8 +145,9 @@ def test_create_proxy_returns_created_document(client, monkeypatch: object):
assert captured == {
"proxy_port": 4040,
"name": "edge",
"service": {"port": 8181},
"service": {"host": "127.0.0.1", "port": 8181},
"code": "def proxy_handler():\n return 'ok'\n",
"protocol": "http",
}
@@ -261,3 +258,203 @@ def test_update_proxy_returns_422_when_code_validation_fails(
assert body["message"] == "Proxy code has invalid Python syntax"
assert body["errors"][0]["code"] == "COMPILE"
assert update_called is False
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_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_examples_returns_builtins_and_custom(client, monkeypatch):
async def fake_list_custom_examples():
return []
monkeypatch.setattr(
proxy_router_module,
"list_custom_examples",
fake_list_custom_examples,
)
response = client.get("/proxy/mitmproxy/examples")
assert response.status_code == 200
body = response.json()
assert len(body) == len(BUILTIN_EXAMPLES)
assert body[0]["built_in"] is True
def test_create_mitmproxy_example_validates_and_persists(client, monkeypatch):
captured = {}
async def fake_validate_proxy_code(code: str) -> None:
captured["validated"] = code
async def fake_create_custom_example(payload):
captured["payload"] = payload
return {
"id": payload.id,
"name": payload.name,
"description": payload.description,
"category": payload.category,
"code": payload.code,
"source_url": payload.source_url,
"built_in": False,
}
monkeypatch.setattr(
proxy_router_module,
"validate_proxy_code",
fake_validate_proxy_code,
)
monkeypatch.setattr(
proxy_router_module,
"create_custom_example",
fake_create_custom_example,
)
response = client.post(
"/proxy/mitmproxy/examples",
json={
"id": "custom-header",
"name": "Custom header",
"description": "Adds a custom response header",
"category": "http",
"code": "def response(flow):\n pass\n",
},
)
assert response.status_code == 201
assert response.json()["id"] == "custom-header"
assert captured["validated"] == "def response(flow):\n pass\n"
@@ -41,12 +41,14 @@ def auth_context(fake_user):
@pytest.fixture
def app(fake_user, auth_context) -> FastAPI:
from api.auth.router import router as auth_router
from api.editor.router import router as editor_router
from api.proxy.router import router as proxy_router
from core.security import AuthContext, get_auth_context, require_authenticated_user
from 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:
@@ -55,9 +57,9 @@ def app(fake_user, auth_context) -> FastAPI:
async def _override_auth_context() -> AuthContext:
return auth_context
app_instance.dependency_overrides[
require_authenticated_user
] = _override_current_user
app_instance.dependency_overrides[require_authenticated_user] = (
_override_current_user
)
app_instance.dependency_overrides[get_auth_context] = _override_auth_context
return app_instance
+43
View File
@@ -0,0 +1,43 @@
import asyncio
from models.proxy import ProxyDocument
from models.service import ServiceEndpointDocument
from 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 health["overall"] == "healthy"
assert [check["target"] for check in health["checks"]] == [
"upstream",
"proxy",
]
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()
@@ -0,0 +1,75 @@
import asyncio
from pathlib import Path
from models.proxy import ProxyDocument
from models.service import ServiceEndpointDocument
from services.mitmproxy.manager import 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_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"
@@ -0,0 +1,51 @@
import pytest
from 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
@@ -0,0 +1,30 @@
from 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}']
+490 -4
View File
@@ -2,6 +2,28 @@ version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "aioquic"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "cryptography" },
{ name = "pylsqpack" },
{ name = "pyopenssl" },
{ name = "service-identity" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4b/1a/bf10b2c57c06c7452b685368cb1ac90565a6e686e84ec6f84465fb8f78f4/aioquic-1.2.0.tar.gz", hash = "sha256:f91263bb3f71948c5c8915b4d50ee370004f20a416f67fab3dcc90556c7e7199", size = 179891, upload-time = "2024-07-06T23:27:09.301Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/19/03/1c385739e504c70ab2a66a4bc0e7cd95cee084b374dcd4dc97896378400b/aioquic-1.2.0-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3e23964dfb04526ade6e66f5b7cd0c830421b8138303ab60ba6e204015e7cb0b", size = 1753473, upload-time = "2024-07-06T23:26:20.809Z" },
{ url = "https://files.pythonhosted.org/packages/6a/1f/4d1c40714db65be828e1a1e2cce7f8f4b252be67d89f2942f86a1951826c/aioquic-1.2.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:84d733332927b76218a3b246216104116f766f5a9b2308ec306cd017b3049660", size = 2083563, upload-time = "2024-07-06T23:26:24.254Z" },
{ url = "https://files.pythonhosted.org/packages/15/48/56a8c9083d1deea4ccaf1cbf5a91a396b838b4a0f8650f4e9f45c7879a38/aioquic-1.2.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2466499759b31ea4f1d17f4aeb1f8d4297169e05e3c1216d618c9757f4dd740d", size = 2555697, upload-time = "2024-07-06T23:26:26.16Z" },
{ url = "https://files.pythonhosted.org/packages/0f/93/fa4c981a8a8a903648d4cd6e12c0fca7f44e3ef4ba15a8b99a26af05b868/aioquic-1.2.0-cp38-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cd75015462ca5070a888110dc201f35a9f4c7459f9201b77adc3c06013611bb8", size = 2149089, upload-time = "2024-07-06T23:26:28.277Z" },
{ url = "https://files.pythonhosted.org/packages/b0/0f/4a280923313b831892caaa45348abea89e7dd2e4422a86699bb0e506b1dd/aioquic-1.2.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43ae3b11d43400a620ca0b4b4885d12b76a599c2cbddba755f74bebfa65fe587", size = 2205221, upload-time = "2024-07-06T23:26:30.682Z" },
{ url = "https://files.pythonhosted.org/packages/d2/6b/a6a1d1762ce06f13b68f524bb9c5f4d6ca7cda9b072d7e744626b89b77be/aioquic-1.2.0-cp38-abi3-win32.whl", hash = "sha256:910d8c91da86bba003d491d15deaeac3087d1b9d690b9edc1375905d8867b742", size = 1214037, upload-time = "2024-07-06T23:26:32.651Z" },
{ url = "https://files.pythonhosted.org/packages/dd/aa/e8a8a75c93dee0ab229df3c2d17f63cd44d0ad5ee8540e2ec42779ce3a39/aioquic-1.2.0-cp38-abi3-win_amd64.whl", hash = "sha256:e3dcfb941004333d477225a6689b55fc7f905af5ee6a556eb5083be0354e653a", size = 1530339, upload-time = "2024-07-06T23:26:34.753Z" },
]
[[package]]
name = "annotated-doc"
version = "0.0.4"
@@ -75,6 +97,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" },
]
[[package]]
name = "asgiref"
version = "3.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969, upload-time = "2025-11-19T15:32:20.106Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" },
]
[[package]]
name = "attrs"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
]
[[package]]
name = "authlib"
version = "1.6.9"
@@ -87,6 +127,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" },
]
[[package]]
name = "basedpyright"
version = "1.39.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodejs-wheel-binaries" },
]
sdist = { url = "https://files.pythonhosted.org/packages/04/19/5a5b9b9197973da732638957be3a65cf514d2f5a4964eeedbf33b6c65bbd/basedpyright-1.39.3.tar.gz", hash = "sha256:2f794e6b5f4260fb89f614ca6cd23c6f305373bb6b50c4ed7794ff2ae647fb14", size = 25503187, upload-time = "2026-04-20T22:14:47.424Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/5c/f950c1239ad26f3bb453e665428a2cf1893995de725a5eb0b64a2520b366/basedpyright-1.39.3-py3-none-any.whl", hash = "sha256:aba760dc83307727554f936d6b4381caa14482f30dbc2173167710e217c1f7ab", size = 12419181, upload-time = "2026-04-20T22:14:51.975Z" },
]
[[package]]
name = "bcrypt"
version = "5.0.0"
@@ -138,6 +190,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
]
[[package]]
name = "blinker"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
]
[[package]]
name = "brotli"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" },
{ url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" },
{ url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" },
{ url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" },
{ url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" },
{ url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" },
{ url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" },
{ url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" },
{ url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" },
{ url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" },
]
[[package]]
name = "certifi"
version = "2026.2.25"
@@ -383,6 +462,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/71/4f/a5b3cfc3e1779c756c4fef790facef12d63088402a718b167b780d4f2ccc/fastar-0.10.0-cp314-cp314t-win_arm64.whl", hash = "sha256:3152a80835ef11cbfaf153037dd694e0014f25d074792b9785421679acdbe179", size = 461150, upload-time = "2026-04-08T01:02:08.47Z" },
]
[[package]]
name = "flask"
version = "3.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "blinker" },
{ name = "click" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "markupsafe" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
@@ -392,6 +488,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "h2"
version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "hpack" },
{ name = "hyperframe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
]
[[package]]
name = "hpack"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
@@ -435,6 +553,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "hyperframe"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
]
[[package]]
name = "idna"
version = "3.11"
@@ -453,6 +580,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
@@ -465,6 +601,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "kaitaistruct"
version = "0.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/27/b8/ca7319556912f68832daa4b81425314857ec08dfccd8dbc8c0f65c992108/kaitaistruct-0.11.tar.gz", hash = "sha256:053ee764288e78b8e53acf748e9733268acbd579b8d82a427b1805453625d74b", size = 11519, upload-time = "2025-09-08T15:46:25.037Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/4a/cf14bf3b1f5ffb13c69cf5f0ea78031247790558ee88984a8bdd22fae60d/kaitaistruct-0.11-py2.py3-none-any.whl", hash = "sha256:5c6ce79177b4e193a577ecd359e26516d1d6d000a0bffd6e1010f2a46a62a561", size = 11372, upload-time = "2025-09-08T15:46:23.635Z" },
]
[[package]]
name = "ldap3"
version = "2.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/ac/96bd5464e3edbc61595d0d69989f5d9969ae411866427b2500a8e5b812c0/ldap3-2.9.1.tar.gz", hash = "sha256:f3e7fc4718e3f09dda568b57100095e0ce58633bcabbed8667ce3f8fbaa4229f", size = 398830, upload-time = "2021-07-18T06:34:21.786Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/f6/71d6ec9f18da0b2201287ce9db6afb1a1f637dedb3f0703409558981c723/ldap3-2.9.1-py2.py3-none-any.whl", hash = "sha256:5869596fc4948797020d3f03b7939da938778a0f9e2009f7a072ccf92b8e8d70", size = 432192, upload-time = "2021-07-18T06:34:12.905Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
@@ -516,6 +673,85 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "mitmproxy"
version = "12.2.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aioquic" },
{ name = "argon2-cffi" },
{ name = "asgiref" },
{ name = "bcrypt" },
{ name = "brotli" },
{ name = "certifi" },
{ name = "cryptography" },
{ name = "flask" },
{ name = "h11" },
{ name = "h2" },
{ name = "hyperframe" },
{ name = "kaitaistruct" },
{ name = "ldap3" },
{ name = "mitmproxy-rs" },
{ name = "msgpack" },
{ name = "publicsuffix2" },
{ name = "pydivert", marker = "sys_platform == 'win32'" },
{ name = "pyopenssl" },
{ name = "pyparsing" },
{ name = "pyperclip" },
{ name = "ruamel-yaml" },
{ name = "sortedcontainers" },
{ name = "tornado" },
{ name = "urwid" },
{ name = "wsproto" },
{ name = "zstandard" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/7d/14/32b4c3a9c3380e0cf1bed1976951e70510c06f1ba7c0dc46f9e69e3965ae/mitmproxy-12.2.2-py3-none-any.whl", hash = "sha256:7b111ba3b83b34b0d9b653044685db7c3f5fbc63b39b8f06439642da83910713", size = 1651385, upload-time = "2026-04-12T21:49:54.312Z" },
]
[[package]]
name = "mitmproxy-linux"
version = "0.12.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2f/f2/8c776f9bf013752c4521fc8382efc7b55cb238cea69b7963200b4f8da293/mitmproxy_linux-0.12.9.tar.gz", hash = "sha256:94b10fee02aa42287739623cef921e1a53955005d45c9e2fa309ae9f0bf8d37d", size = 1299779, upload-time = "2026-01-30T14:54:13.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/6e/10a2fbcf564e18254293dc7118dc4ec72f3e5897509d7b4f804ab23df5cd/mitmproxy_linux-0.12.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4413e27c692f30036ad6d73432826e728ede026fac8e51651d0c545dd0177f2", size = 987838, upload-time = "2026-01-30T14:53:59.602Z" },
{ url = "https://files.pythonhosted.org/packages/20/c5/2eeb523019b1ad84ec659fc41b007cbc90ac99e2451c4e7ba7a28d910b04/mitmproxy_linux-0.12.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee842865a05f69196004ddcb29d50af0602361d9d6acee04f370f7e01c3674e8", size = 1067258, upload-time = "2026-01-30T14:54:01.872Z" },
]
[[package]]
name = "mitmproxy-macos"
version = "0.12.9"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/71/d5899c5d1593403bccdd4b56306d03a200e14483318f86b882a144f79a32/mitmproxy_macos-0.12.9-py3-none-any.whl", hash = "sha256:20e024fbfeeecbdb4ee2a1e8361d18782146777fdc1e00dcfecd52c22a3219bf", size = 2569740, upload-time = "2026-01-30T14:54:03.379Z" },
]
[[package]]
name = "mitmproxy-rs"
version = "0.12.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mitmproxy-linux", marker = "sys_platform == 'linux'" },
{ name = "mitmproxy-macos", marker = "sys_platform == 'darwin'" },
{ name = "mitmproxy-windows", marker = "os_name == 'nt'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5d/5c/16a61303da76cd34aa6ddbd7ef6ac66d9ef8514c4d3a5b71831169d63236/mitmproxy_rs-0.12.9.tar.gz", hash = "sha256:c6ffc35c002c675cac534442d92d1cdebd66fafd63754ad33b92ae968ea6e449", size = 1334424, upload-time = "2026-01-30T14:54:15.043Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f6/78/dc9f4b4ef894709853407291ab281e478cb122b993633125b858eea523ba/mitmproxy_rs-0.12.9-cp312-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:afeb3a2da2bc26474e1a2febaea4432430c5fde890dfce33bc4c1e65e6baef1b", size = 7145620, upload-time = "2026-01-30T14:54:05.132Z" },
{ url = "https://files.pythonhosted.org/packages/0c/6f/1ebd9ca748bf62eb90657b41692c46716cff03aaf134260a249a2ae2d251/mitmproxy_rs-0.12.9-cp312-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245922663440330c4b5a36d0194ed559b1dbd5e38545db2eb947180ed12a5e92", size = 3084785, upload-time = "2026-01-30T14:54:06.797Z" },
{ url = "https://files.pythonhosted.org/packages/10/af/fc2f2b30a6ade8646d276c4813f68b86d775696d467f12df32613d22c638/mitmproxy_rs-0.12.9-cp312-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fb9fb4aac9ecb82e2c3c5c439ef5e4961be7934d80ade5e9a99c0a944b8ea2f", size = 3252443, upload-time = "2026-01-30T14:54:08.908Z" },
{ url = "https://files.pythonhosted.org/packages/c2/20/b065c6a1eb27effec3368b03bdc842f6f611800ee5f990d994884286f160/mitmproxy_rs-0.12.9-cp312-abi3-win_amd64.whl", hash = "sha256:1fd716e87da8be3c62daa4325a5ff42bedd951fb8614c5f66caa94b7c21e2593", size = 3321769, upload-time = "2026-01-30T14:54:10.735Z" },
]
[[package]]
name = "mitmproxy-windows"
version = "0.12.9"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/29/83/2712af146c5f6a59a7f4658c02356b241c40ba19cb2b16db94235e95b699/mitmproxy_windows-0.12.9-py3-none-any.whl", hash = "sha256:fdec21fb66a5ba237d9106bfdc09d9428f315551bf4b41ba06b261e7beb56417", size = 464363, upload-time = "2026-01-30T14:54:12.531Z" },
]
[[package]]
name = "motor"
version = "3.7.1"
@@ -528,6 +764,48 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/01/9a/35e053d4f442addf751ed20e0e922476508ee580786546d699b0567c4c67/motor-3.7.1-py3-none-any.whl", hash = "sha256:8a63b9049e38eeeb56b4fdd57c3312a6d1f25d01db717fe7d82222393c410298", size = 74996, upload-time = "2025-05-14T18:56:31.665Z" },
]
[[package]]
name = "msgpack"
version = "1.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" },
{ url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" },
{ url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" },
{ url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" },
{ url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" },
{ url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" },
{ url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" },
{ url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" },
{ url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" },
{ url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" },
{ url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" },
{ url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" },
{ url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" },
{ url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" },
{ url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" },
{ url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" },
{ url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" },
]
[[package]]
name = "nodejs-wheel-binaries"
version = "24.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3f/70/a1e4f4d5986768ab90cc860b1cc3660fd2ded74ca175a900a5c29f839c7d/nodejs_wheel_binaries-24.15.0.tar.gz", hash = "sha256:b43f5c4f6e5768d8845b2ae4682eb703a19bf7aadc84187e2d903ed3a611c859", size = 8057, upload-time = "2026-04-19T15:48:16.899Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/85/66/54051d14853d6ab4fb85f8be9b042b530be653357fb9a19557498bc91ab7/nodejs_wheel_binaries-24.15.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:a6232fa8b754220941f52388c8ead923f7c1c7fdf0ea0d98f657523bd9a81ef4", size = 55173485, upload-time = "2026-04-19T15:47:34.561Z" },
{ url = "https://files.pythonhosted.org/packages/ad/5f/66acada164da5ca10a0824db021aa7394ae18396c550cd9280e839a43126/nodejs_wheel_binaries-24.15.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:001a6b62c69d9109c1738163cca00608dd2722e8663af59300054ea02610972d", size = 55348100, upload-time = "2026-04-19T15:47:40.521Z" },
{ url = "https://files.pythonhosted.org/packages/0d/2d/0cbd5ff40c9bb030ca1735d8f8793bd74f08a4cbd49100a1d19313ea57ab/nodejs_wheel_binaries-24.15.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:0fbc48765e60ed0ff30d43898dbf5cadbadf2e5f1e7f204afc2b01493b7ebce6", size = 59668206, upload-time = "2026-04-19T15:47:46.848Z" },
{ url = "https://files.pythonhosted.org/packages/da/d5/91ac63951ec75927a486b83b8cafe650e360fa70ac01dc94adfb32b93b97/nodejs_wheel_binaries-24.15.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:20ee0536809795da8a4942fc1ab4cbdebbcaaf29383eab67ba8874268fb00008", size = 60206736, upload-time = "2026-04-19T15:47:52.668Z" },
{ url = "https://files.pythonhosted.org/packages/db/72/dc22776974d928869c0c30d23ee98ed7df254243c2df68f09f5963e8e8b8/nodejs_wheel_binaries-24.15.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1fade6c214285e72472ca40a631e98ff36559671cd5eefc8bf009471d67f04b4", size = 61720456, upload-time = "2026-04-19T15:47:58.325Z" },
{ url = "https://files.pythonhosted.org/packages/01/0a/34461b9050cb45ee371dccdefc622aef6351506ea2691b08fc761ca67150/nodejs_wheel_binaries-24.15.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3984cb8d87766567aee67a49743227ab40ede6f47734ec990ff90e50b74e7740", size = 62326172, upload-time = "2026-04-19T15:48:04.094Z" },
{ url = "https://files.pythonhosted.org/packages/c9/17/09252bf35672dba926649d59dfe51443a0f6955ad13784e91131d5ec82a2/nodejs_wheel_binaries-24.15.0-py2.py3-none-win_amd64.whl", hash = "sha256:a437601956b532dcb3082046e6978e622733f90edc0932cbb9adb3bb97a16501", size = 41543461, upload-time = "2026-04-19T15:48:09.332Z" },
{ url = "https://files.pythonhosted.org/packages/0a/7e/b649777d148e1e0c2ce349156603cdb12f7ed99921b95d93717393650193/nodejs_wheel_binaries-24.15.0-py2.py3-none-win_arm64.whl", hash = "sha256:bdf4a431e08321a32efc604111c6f23941f87055d796a537e8c4110daecad23f", size = 39233248, upload-time = "2026-04-19T15:48:13.326Z" },
]
[[package]]
name = "packaging"
version = "26.0"
@@ -546,6 +824,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "publicsuffix2"
version = "2.20191221"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/04/1759906c4c5b67b2903f546de234a824d4028ef24eb0b1122daa43376c20/publicsuffix2-2.20191221.tar.gz", hash = "sha256:00f8cc31aa8d0d5592a5ced19cccba7de428ebca985db26ac852d920ddd6fe7b", size = 99592, upload-time = "2019-12-21T11:30:44.863Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/16/053c2945c5e3aebeefb4ccd5c5e7639e38bc30ad1bdc7ce86c6d01707726/publicsuffix2-2.20191221-py2.py3-none-any.whl", hash = "sha256:786b5e36205b88758bd3518725ec8cfe7a8173f5269354641f581c6b80a99893", size = 89033, upload-time = "2019-12-21T11:30:41.744Z" },
]
[[package]]
name = "pwdlib"
version = "0.3.0"
@@ -563,6 +850,27 @@ bcrypt = [
{ name = "bcrypt" },
]
[[package]]
name = "pyasn1"
version = "0.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" },
]
[[package]]
name = "pyasn1-modules"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
@@ -658,6 +966,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
]
[[package]]
name = "pydivert"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cf/71/2da9bcf742df3ab23f75f10fedca074951dd13a84bda8dea3077f68ae9a6/pydivert-2.1.0.tar.gz", hash = "sha256:f0e150f4ff591b78e35f514e319561dadff7f24a82186a171dd4d465483de5b4", size = 91057, upload-time = "2017-10-20T21:36:58.165Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/8f/86d7931c62013a5a7ebf4e1642a87d4a6050c0f570e714f61b0df1984c62/pydivert-2.1.0-py2.py3-none-any.whl", hash = "sha256:382db488e3c37c03ec9ec94e061a0b24334d78dbaeebb7d4e4d32ce4355d9da1", size = 104718, upload-time = "2017-10-20T21:36:56.726Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
@@ -667,6 +984,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pylsqpack"
version = "0.3.24"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7c/a0/20b34e654b911a9abb736b242cc0a11912bc79ea3e911f139ea756e39ea2/pylsqpack-0.3.24.tar.gz", hash = "sha256:8ec455f44614228f89e38d40c1b1e37895620e20ec6b21e3b562fa8b79a23890", size = 677187, upload-time = "2026-03-29T15:42:40.136Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/88/71b79d334f67dd595fbed5f3a337e2aa997a96e452bb1b64120bccf5679d/pylsqpack-0.3.24-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:8edf48d0a023cd3629b2c4aaccac9b79a46d566c0f61e7416b5678228433763d", size = 162525, upload-time = "2026-03-29T15:42:25.436Z" },
{ url = "https://files.pythonhosted.org/packages/4e/96/f0a7625075394e93db42bd476abb7240ff1a474acd1ad404158baf68dc6a/pylsqpack-0.3.24-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e7d956dbc8f7d597b237b9157d0a16bc7c655a1b031239763c18dc8582aff8cc", size = 168643, upload-time = "2026-03-29T15:42:26.744Z" },
{ url = "https://files.pythonhosted.org/packages/42/de/49ec59856ea41468ed879ec143fc429729e37e4860b2119959a2a66fb652/pylsqpack-0.3.24-cp310-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b6a8bb42127d5ece8d301a673c8205df25b73b69f8c46b9f0c3034588de1789a", size = 246930, upload-time = "2026-03-29T15:42:28.136Z" },
{ url = "https://files.pythonhosted.org/packages/cd/d3/3e748fa5317782bfe68a7eaf890524aee48281c59f07e9bdfd7774f158db/pylsqpack-0.3.24-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3f977d419c60c1d6c2240e6d7a52df820d37eb8c36b4057113bcd7859f53e2c", size = 249234, upload-time = "2026-03-29T15:42:29.583Z" },
{ url = "https://files.pythonhosted.org/packages/22/5b/06f5e354ed882ce036ed65f2a393c98d0f6c71a23fa64b53251ddeb40a7b/pylsqpack-0.3.24-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6024854eb16d32803d4890fb90a73b9348c74b61c0770680aefaaa75f8456e8c", size = 250274, upload-time = "2026-03-29T15:42:31.03Z" },
{ url = "https://files.pythonhosted.org/packages/61/0e/c95cae2817a5c272b7a3132376165aa16875efcccbbd3e6608f5082770cc/pylsqpack-0.3.24-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:54978a9879471596d84bbad5e67d727014048926bc5bb2dac0eb3701b48c5ac9", size = 246966, upload-time = "2026-03-29T15:42:32.035Z" },
{ url = "https://files.pythonhosted.org/packages/40/fe/d5e84c3b4b2fa716df9e95aeb40d3bfb4de50c21cccccd66e194cfc084ac/pylsqpack-0.3.24-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:caf63ddc2e581c764d17432893acce02c5c29ff879d77c2abf1e26aa4eeb831b", size = 246546, upload-time = "2026-03-29T15:42:33.105Z" },
{ url = "https://files.pythonhosted.org/packages/65/f5/88e442ced83c0305f50f45bf521bbce3344ef0c29c3442f010086ff0c124/pylsqpack-0.3.24-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc5f146fd456b50b227858aed59faa0ff8445aa426e69bb4e50d46c487aab0", size = 248517, upload-time = "2026-03-29T15:42:34.237Z" },
{ url = "https://files.pythonhosted.org/packages/a6/c2/886348974bba20db2a80cf37e97203d7334223b3c1c1babe4159dd12626d/pylsqpack-0.3.24-cp310-abi3-win32.whl", hash = "sha256:8da12be7b35b7c9a8cf73a4c077f72e5022a311f80a401c79904213376f2d767", size = 153483, upload-time = "2026-03-29T15:42:35.214Z" },
{ url = "https://files.pythonhosted.org/packages/0d/22/adbce7adfb41b8f5f222195f7f4f5e58655aa3e83f525bc5f3882b07d6e8/pylsqpack-0.3.24-cp310-abi3-win_amd64.whl", hash = "sha256:c3e2327af25ee616ce4483a8748f0957cf017cbca82d58ed15efea68f70f94ff", size = 156145, upload-time = "2026-03-29T15:42:36.902Z" },
{ url = "https://files.pythonhosted.org/packages/a5/2e/6fb6d797ce88741a0e18984bbab69160abc0971a41f4478cab6c8255a8dc/pylsqpack-0.3.24-cp310-abi3-win_arm64.whl", hash = "sha256:23b4d8af48836893beac356c10ca268161953de5bf9ed691526a93f5c82433e9", size = 153424, upload-time = "2026-03-29T15:42:38.73Z" },
]
[[package]]
name = "pymongo"
version = "4.16.0"
@@ -698,6 +1034,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/32/cd/ddc794cdc8500f6f28c119c624252fb6dfb19481c6d7ed150f13cf468a6d/pymongo-4.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6b2a20edb5452ac8daa395890eeb076c570790dfce6b7a44d788af74c2f8cf96", size = 1047725, upload-time = "2026-01-07T18:05:28.47Z" },
]
[[package]]
name = "pyopenssl"
version = "25.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/80/be/97b83a464498a79103036bc74d1038df4a7ef0e402cfaf4d5e113fb14759/pyopenssl-25.3.0.tar.gz", hash = "sha256:c981cb0a3fd84e8602d7afc209522773b94c1c2446a3c710a75b06fe1beae329", size = 184073, upload-time = "2025-09-17T00:32:21.037Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/81/ef2b1dfd1862567d573a4fdbc9f969067621764fbb74338496840a1d2977/pyopenssl-25.3.0-py3-none-any.whl", hash = "sha256:1fda6fc034d5e3d179d39e59c1895c9faeaf40a79de5fc4cbbfbe0d36f4a77b6", size = 57268, upload-time = "2025-09-17T00:32:19.474Z" },
]
[[package]]
name = "pyparsing"
version = "3.3.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
]
[[package]]
name = "pyperclip"
version = "1.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
@@ -832,6 +1198,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" },
]
[[package]]
name = "ruamel-yaml"
version = "0.19.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" },
]
[[package]]
name = "ruff"
version = "0.15.10"
@@ -870,6 +1245,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/64/982e07b93219cb52e1cca5d272cb579e2f3eb001956c9e7a9a6d106c9473/sentry_sdk-2.57.0-py2.py3-none-any.whl", hash = "sha256:812c8bf5ff3d2f0e89c82f5ce80ab3a6423e102729c4706af7413fd1eb480585", size = 456489, upload-time = "2026-03-31T09:39:27.524Z" },
]
[[package]]
name = "service-identity"
version = "24.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "cryptography" },
{ name = "pyasn1" },
{ name = "pyasn1-modules" },
]
sdist = { url = "https://files.pythonhosted.org/packages/07/a5/dfc752b979067947261dbbf2543470c58efe735c3c1301dd870ef27830ee/service_identity-24.2.0.tar.gz", hash = "sha256:b8683ba13f0d39c6cd5d625d2c5f65421d6d707b013b375c355751557cbe8e09", size = 39245, upload-time = "2024-10-26T07:21:57.736Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/2c/ca6dd598b384bc1ce581e24aaae0f2bed4ccac57749d5c3befbb5e742081/service_identity-24.2.0-py3-none-any.whl", hash = "sha256:6b047fbd8a84fd0bb0d55ebce4031e400562b9196e1e0d3e0fe2b8a59f6d4a85", size = 11364, upload-time = "2024-10-26T07:21:56.302Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
@@ -879,6 +1269,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "sortedcontainers"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
]
[[package]]
name = "starlette"
version = "1.0.0"
@@ -897,37 +1296,56 @@ version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "authlib" },
{ name = "basedpyright" },
{ name = "fastapi", extra = ["standard"] },
{ name = "mitmproxy" },
{ name = "motor" },
{ name = "pwdlib", extra = ["argon2", "bcrypt"] },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyyaml" },
{ name = "redis" },
{ name = "ruff" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "authlib", specifier = ">=1.6.0" },
{ name = "basedpyright", specifier = ">=1.39.0" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.135.3" },
{ name = "mitmproxy", specifier = ">=12.2.2" },
{ name = "motor", specifier = ">=3.7.1" },
{ name = "pwdlib", extras = ["argon2", "bcrypt"], specifier = ">=0.3.0" },
{ name = "pydantic", specifier = ">=2.12.5" },
{ name = "pydantic-settings", specifier = ">=2.13.1" },
{ name = "pyyaml", specifier = ">=6.0.3" },
{ name = "redis", specifier = ">=7.4.0" },
{ name = "ruff", specifier = ">=0.15.10" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=9.0.3" },
{ name = "ruff", specifier = ">=0.15.10" },
dev = [{ name = "pytest", specifier = ">=9.0.3" }]
[[package]]
name = "tornado"
version = "6.5.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" },
{ url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" },
{ url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" },
{ url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" },
{ url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" },
{ url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" },
{ url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" },
{ url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" },
{ url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" },
]
[[package]]
@@ -975,6 +1393,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
name = "urwid"
version = "3.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wcwidth" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/59/67cd42db7c549c0c106d2b56d2d2ec1915c459e0a92722029efc5359e871/urwid-3.0.5.tar.gz", hash = "sha256:24be27ffafdb68c09cd95dc21b60ccfd02843320b25ce5feee1708b34fad5a23", size = 860746, upload-time = "2026-02-02T14:38:43.958Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/09/6a/efd8ad30ab250a4ad23dc7c07437b374921042cb35215e468d49977822f7/urwid-3.0.5-py3-none-any.whl", hash = "sha256:f188144261224fdfc9b56b4222869bd0eac90fd7895cf1e376129cdc7e13bc84", size = 296957, upload-time = "2026-02-02T14:38:42.044Z" },
]
[[package]]
name = "uvicorn"
version = "0.44.0"
@@ -1053,6 +1483,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
]
[[package]]
name = "wcwidth"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" },
]
[[package]]
name = "websockets"
version = "16.0"
@@ -1079,3 +1518,50 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]
[[package]]
name = "werkzeug"
version = "3.1.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" },
]
[[package]]
name = "wsproto"
version = "1.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" },
]
[[package]]
name = "zstandard"
version = "0.25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" },
{ url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" },
{ url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" },
{ url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" },
{ url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" },
{ url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" },
{ url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" },
{ url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" },
{ url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" },
{ url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" },
{ url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" },
{ url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" },
{ url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" },
]
+36 -6
View File
@@ -1,18 +1,48 @@
services:
backend:
build:
context: ./backend
container_name: theplug-backend
restart: unless-stopped
env_file:
- path: ./backend/.env
required: false
environment:
MONGODB_URI: mongodb://${MONGO_USER:-theplug}:${MONGO_INITDB_ROOT_PASSWORD:-theplug_dev_password}@mongodb:27017/${MONGO_DB:-theplug}?authSource=admin
MONGODB_DB_NAME: ${MONGO_DB:-theplug}
REDIS_URL: redis://redis:6379
ports:
- "${API_PORT:-3671}:3671"
depends_on:
mongodb:
condition: service_healthy
redis:
condition: service_healthy
frontend:
build:
context: ./frontend
container_name: theplug-frontend
restart: unless-stopped
ports:
- "${FRONTEND_PORT:-5173}:80"
depends_on:
- backend
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}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD:-theplug_dev_password}
MONGO_INITDB_DATABASE: ${MONGO_DB:-theplug}
ports:
- "27017:27017"
volumes:
- mongodb_data:/data/db
- theplug_mongodb:/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"]
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"]
interval: 10s
timeout: 5s
retries: 5
@@ -26,7 +56,7 @@ services:
ports:
- "6379:6379"
volumes:
- redis_data:/data
- theplug_redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
@@ -35,5 +65,5 @@ services:
start_period: 5s
volumes:
mongodb_data:
redis_data:
theplug_mongodb:
theplug_redis:
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.vite
npm-debug.log
+16
View File
@@ -0,0 +1,16 @@
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/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+12
View File
@@ -0,0 +1,12 @@
<!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
@@ -0,0 +1,22 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:3671/;
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;
}
}
+1670
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"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"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"typescript": "^5.9.3",
"vite": "^7.2.7",
"vue-tsc": "^3.1.8"
}
}
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { onMounted, provide } from "vue";
import AppHeader from "./components/AppHeader.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.error"
@submit="plane.signIn"
/>
<main v-else class="app-shell">
<AppHeader
:loading="plane.loading"
@refresh="plane.refreshAll"
@create="plane.newProxy"
@logout="plane.signOut"
/>
<div class="app-body">
<ProxyList
:proxies="plane.proxies"
:selected-port="plane.selectedPort"
@select="plane.selectedPort = $event"
/>
<ProxyWorkbench />
</div>
</main>
</template>
+109
View File
@@ -0,0 +1,109 @@
import type {
AddonExample,
DockerService,
HealthResult,
MitmOption,
PythonEditorSession,
ProxyDocument,
ProxyDraft,
} from "./types";
const TOKEN_KEY = "theplug.token";
export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api";
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;
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
}
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: () => request<DockerService[]>("/proxy/services"),
options: () => request<MitmOption[]>("/proxy/mitmproxy/options"),
examples: () => request<AddonExample[]>("/proxy/mitmproxy/examples"),
editorSession: () =>
request<PythonEditorSession>("/proxy/editor/python/session"),
createExample: (payload: AddonExample) =>
request<AddonExample>("/proxy/mitmproxy/examples", {
method: "POST",
body: JSON.stringify(payload),
}),
deleteExample: (id: string) =>
request<void>(`/proxy/mitmproxy/examples/${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`),
logs: (port: number) => request<{ log: string }>(`/proxy/${port}/logs`),
};
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import AppLogo from "./AppLogo.vue";
defineProps<{
loading: boolean;
}>();
const emit = defineEmits<{
refresh: [];
create: [];
logout: [];
}>();
</script>
<template>
<header class="app-header">
<div class="brand">
<AppLogo />
<div>
<strong>thePlug</strong>
<small>proxy control plane</small>
</div>
</div>
<div class="header-meta">
<span class="team">mitmdump</span>
<span v-if="loading" class="muted">applying changes</span>
</div>
<div class="header-actions">
<button class="ghost" :disabled="loading" @click="emit('refresh')">Refresh</button>
<button class="primary" @click="emit('create')">New</button>
<button class="ghost" @click="emit('logout')">Logout</button>
</div>
</header>
</template>
+7
View File
@@ -0,0 +1,7 @@
<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>
+157
View File
@@ -0,0 +1,157 @@
<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 status = ref("Connecting Python tools...");
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%",
color: "#edf2f7",
backgroundColor: "#0b0f12",
fontSize: "14px",
},
".cm-scroller": {
fontFamily: "IBM Plex Mono, JetBrains Mono, ui-monospace, monospace",
lineHeight: "1.6",
},
".cm-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.then(
() => {
status.value = "Python tools ready";
},
() => {
status.value = "Python tools unavailable";
},
);
editor = new EditorView({
doc: model.value,
extensions: [
basicSetup,
python(),
editorTheme,
keymap.of([{ key: "Mod-s", run: emitSave }]),
EditorView.lineWrapping,
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;
status.value = "Python tools unavailable";
}
}
</script>
<template>
<div class="code-editor-wrap">
<div class="code-editor-status">
<span>{{ status }}</span>
<span v-if="error" class="field-error">{{ error }}</span>
</div>
<div ref="host" class="code-editor" />
</div>
</template>
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { defineAsyncComponent } from "vue";
import { useControlPlaneContext } from "../composables/useControlPlane";
const CodeEditor = defineAsyncComponent(() => import("./CodeEditor.vue"));
const plane = useControlPlaneContext();
</script>
<template>
<section class="editor-pane panel">
<div class="panel-title">
<div>
<p class="eyebrow">Code</p>
<h2>Addon editor</h2>
</div>
<div class="editor-meta">
<span>{{ plane.codeLineCount }} lines</span>
<span>{{ plane.draft.mode }} / {{ plane.draft.protocol }}</span>
<button class="ghost" @click="plane.toggleEditor(false)">Close</button>
</div>
</div>
<div class="editor-shell">
<CodeEditor v-model="plane.draft.code" @save="plane.saveProxy" />
</div>
</section>
</template>
+79
View File
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { useControlPlaneContext } from "../composables/useControlPlane";
const plane = useControlPlaneContext();
</script>
<template>
<section class="panel stack-panel tool-panel">
<div class="panel-title">
<div>
<p class="eyebrow">Templates</p>
<h2>Examples</h2>
</div>
</div>
<div class="inline-grid">
<input
v-model="plane.customExampleName"
placeholder="Save current code as..."
/>
<button @click="plane.saveCurrentAsExample">Save</button>
</div>
<div class="example-shell">
<div class="example-browser">
<input v-model="plane.exampleSearch" placeholder="Search examples" />
<div class="example-index">
<button
v-for="example in plane.filteredExamples"
:key="example.id"
class="example-entry"
:class="{ selected: plane.selectedExample?.id === example.id }"
@click="plane.selectExample(example.id)"
>
<strong>{{ example.name }}</strong>
<small>{{ example.category }}</small>
</button>
</div>
</div>
<article v-if="plane.selectedExample" class="example-preview">
<div class="option-doc-head">
<div>
<p class="eyebrow">Selected</p>
<h3>{{ plane.selectedExample.name }}</h3>
</div>
<div class="template-meta">
<span class="type-chip">{{ plane.selectedExample.category }}</span>
<span class="type-chip">
{{ plane.selectedExample.built_in ? "built-in" : "custom" }}
</span>
</div>
</div>
<p class="doc-copy">{{ plane.selectedExample.description }}</p>
<div class="doc-actions">
<button @click="plane.applyExample(plane.selectedExample)">Use template</button>
<a
v-if="plane.selectedExample.source_url"
:href="plane.selectedExample.source_url"
target="_blank"
rel="noreferrer"
>
Source
</a>
<button
v-if="!plane.selectedExample.built_in"
class="ghost"
@click="plane.removeExample(plane.selectedExample.id)"
>
Delete
</button>
</div>
</article>
</div>
</section>
</template>
+111
View File
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { protocols, useControlPlaneContext } from "../composables/useControlPlane";
const plane = useControlPlaneContext();
</script>
<template>
<section class="panel stack-panel compact-panel">
<div class="panel-title">
<div>
<p class="eyebrow">Health</p>
<h2>Checks</h2>
</div>
<button :disabled="!plane.selectedProxy" @click="plane.checkHealth">
Run
</button>
</div>
<div 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>
</div>
<details class="compact-details">
<summary>Advanced check settings</summary>
<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>
</div>
</details>
<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>
+35
View File
@@ -0,0 +1,35 @@
<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>Proxy control plane</h1>
<p class="muted">Manage routes, runtime state, and mitmdump addons from one console.</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">Login</button>
</form>
<p v-if="error" class="error">{{ error }}</p>
</section>
</main>
</template>
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import { useControlPlaneContext } from "../composables/useControlPlane";
const plane = useControlPlaneContext();
</script>
<template>
<section class="panel stack-panel tool-panel logs-panel">
<div class="panel-title">
<div>
<p class="eyebrow">Output</p>
<h2>Logs</h2>
</div>
<button :disabled="!plane.selectedProxy" @click="plane.loadLogs">Load</button>
</div>
<pre class="terminal">{{ plane.logs || "No logs loaded." }}</pre>
</section>
</template>
+142
View File
@@ -0,0 +1,142 @@
<script setup lang="ts">
import type { MitmOption } from "../types";
import { useControlPlaneContext } from "../composables/useControlPlane";
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="panel stack-panel tool-panel">
<div class="panel-title">
<div>
<p class="eyebrow">Mitmdump</p>
<h2>Options</h2>
</div>
<span class="counter">{{ plane.optionCount }}</span>
</div>
<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">
<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" @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">
<input
v-model="plane.optionSearch"
placeholder="Search options"
/>
<div 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>
<article v-if="plane.selectedOption" class="option-doc">
<div class="option-doc-head">
<div>
<p class="eyebrow">Reference</p>
<h3>{{ plane.selectedOption.name }}</h3>
</div>
<span class="type-chip">{{ plane.selectedOption.type }}</span>
</div>
<p class="doc-copy">{{ plane.selectedOption.description }}</p>
<div class="doc-facts">
<span>Default: {{ formatDefault(plane.selectedOption) }}</span>
<span v-if="plane.selectedOption.choices.length">
Choices: {{ plane.selectedOption.choices.join(", ") }}
</span>
<span v-if="plane.selectedOption.reserved">Managed by thePlug</span>
</div>
<div class="doc-actions">
<button
:disabled="plane.selectedOption.reserved"
@click="plane.setOption(plane.selectedOption)"
>
Add option
</button>
<a
v-if="plane.selectedOption.docs_url"
:href="plane.selectedOption.docs_url"
target="_blank"
rel="noreferrer"
>
Official docs
</a>
</div>
</article>
</div>
</section>
</template>
+52
View File
@@ -0,0 +1,52 @@
<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>
<p class="eyebrow">Inventory</p>
<h2>Proxies</h2>
</div>
<span class="counter">{{ proxies.length }}</span>
</div>
<div v-if="!proxies.length" class="empty-state">
<strong>No proxies configured</strong>
<span>Create a draft, save it, then start it when you want mitmdump to own the route.</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>
+156
View File
@@ -0,0 +1,156 @@
<script setup lang="ts">
import { useControlPlaneContext } from "../composables/useControlPlane";
import EditorPane from "./EditorPane.vue";
import ExamplesPanel from "./ExamplesPanel.vue";
import HealthPanel from "./HealthPanel.vue";
import LogsPanel from "./LogsPanel.vue";
import OptionsPanel from "./OptionsPanel.vue";
import RuntimePanel from "./RuntimePanel.vue";
import ServicePanel from "./ServicePanel.vue";
import StatusPill from "./StatusPill.vue";
const plane = useControlPlaneContext();
</script>
<template>
<section class="workbench">
<header class="workbench-head panel">
<div class="workbench-copy">
<p class="eyebrow">Workspace</p>
<h1>{{ plane.draft.name || "Untitled proxy" }}</h1>
<div class="route-row">
<span class="route-token">{{ plane.routeState.configuredListen }}</span>
<span class="route-arrow">to</span>
<span class="route-target">{{ plane.routeState.configuredTarget }}</span>
<StatusPill
v-if="plane.selectedProxy"
:status="plane.selectedProxy.status"
/>
</div>
<p class="summary-copy">{{ plane.routeState.startNotice }}</p>
<p v-if="plane.selectedProxy?.last_error" class="error compact">
{{ plane.selectedProxy.last_error }}
</p>
</div>
<div class="action-row">
<button @click="plane.saveProxy" :disabled="plane.loading">Save</button>
<button
@click="plane.runtimeAction('start')"
:disabled="!plane.selectedProxy || plane.loading"
>
Start
</button>
<button
@click="plane.runtimeAction('stop')"
:disabled="!plane.selectedProxy || plane.loading"
>
Stop
</button>
<button
@click="plane.runtimeAction('restart')"
:disabled="!plane.selectedProxy || plane.loading"
>
Restart
</button>
<button class="ghost" @click="plane.toggleEditor()">
{{ plane.editorOpen ? "Hide editor" : "Open editor" }}
</button>
<button
class="danger"
@click="plane.removeProxy"
:disabled="!plane.selectedProxy || plane.loading"
>
Delete
</button>
</div>
</header>
<p v-if="plane.error" class="error">{{ plane.error }}</p>
<p v-if="plane.notice" class="notice">{{ plane.notice }}</p>
<section class="summary-grid">
<article class="panel summary-panel">
<div class="panel-title">
<div>
<p class="eyebrow">Route</p>
<h2>Runtime mapping</h2>
</div>
</div>
<div class="metric-grid">
<article class="metric-card">
<span class="metric-label">Listen</span>
<strong>{{ plane.routeState.configuredListen }}</strong>
</article>
<article class="metric-card">
<span class="metric-label">Configured upstream</span>
<strong>{{ plane.routeState.configuredTarget }}</strong>
</article>
<article class="metric-card">
<span class="metric-label">Live upstream</span>
<strong>{{ plane.routeState.liveTarget }}</strong>
</article>
<article class="metric-card">
<span class="metric-label">Mode</span>
<strong>{{ plane.routeState.modeLabel }}</strong>
</article>
</div>
<p class="inline-note">{{ plane.routeState.liveNotice }}</p>
</article>
<article class="panel summary-panel">
<div class="panel-title">
<div>
<p class="eyebrow">Behaviour</p>
<h2>What start does</h2>
</div>
</div>
<div class="summary-lines">
<span>Service source: {{ plane.routeState.serviceLabel }}</span>
<span>Save never remaps upstream ports.</span>
<span>Start is the only action that applies runtime routing.</span>
<span v-if="plane.routeState.remapped">
This proxy is currently holding the original public service port.
</span>
</div>
</article>
</section>
<div class="workbench-grid">
<div class="overview-stack">
<RuntimePanel />
<ServicePanel />
<HealthPanel />
</div>
<section class="utility-panel">
<div class="tool-switcher">
<button
:class="{ active: plane.toolView === 'options' }"
@click="plane.setToolView('options')"
>
Options
</button>
<button
:class="{ active: plane.toolView === 'examples' }"
@click="plane.setToolView('examples')"
>
Examples
</button>
<button
:class="{ active: plane.toolView === 'logs' }"
@click="plane.setToolView('logs')"
>
Logs
</button>
</div>
<OptionsPanel v-if="plane.toolView === 'options'" />
<ExamplesPanel v-else-if="plane.toolView === 'examples'" />
<LogsPanel v-else />
</section>
</div>
<EditorPane v-if="plane.editorOpen" />
</section>
</template>
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { modes, protocols, useControlPlaneContext } from "../composables/useControlPlane";
const plane = useControlPlaneContext();
</script>
<template>
<section class="panel stack-panel compact-panel">
<div class="panel-title">
<div>
<p class="eyebrow">Runtime</p>
<h2>Proxy settings</h2>
</div>
</div>
<div class="form-grid tight-grid">
<label>
Name
<input v-model="plane.draft.name" />
</label>
<label>
Proxy port
<input v-model.number="plane.draft.proxy_port" type="number" />
</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>
Listen host
<input v-model="plane.draft.listen_host" />
</label>
<label>
Mode spec
<input
v-model="plane.draft.mode_spec"
placeholder="optional raw mitmproxy mode"
/>
</label>
</div>
<p class="inline-note">
Save stores this configuration. Start launches mitmdump with the selected mode.
</p>
</section>
</template>
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { useControlPlaneContext } from "../composables/useControlPlane";
const plane = useControlPlaneContext();
</script>
<template>
<section class="panel stack-panel compact-panel">
<div class="panel-title">
<div>
<p class="eyebrow">Upstream</p>
<h2>Service route</h2>
</div>
</div>
<div class="metric-grid">
<article class="metric-card">
<span class="metric-label">Configured target</span>
<strong>{{ plane.routeState.configuredTarget }}</strong>
</article>
<article class="metric-card">
<span class="metric-label">Live upstream</span>
<strong>{{ plane.routeState.liveTarget }}</strong>
</article>
</div>
<div class="form-grid tight-grid">
<label>
Host
<input v-model="plane.draft.service.host" />
</label>
<label>
Port
<input v-model.number="plane.draft.service.port" type="number" />
</label>
</div>
<p class="inline-note">{{ plane.routeState.liveNotice }}</p>
<div v-if="plane.services.length" class="service-grid">
<div
v-for="service in plane.services"
:key="service.name + service.folder"
class="service-chip"
>
<strong>{{ service.name }}</strong>
<small>{{ service.folder }}</small>
<div class="port-row">
<button
v-for="port in service.ports"
:key="port"
class="port-chip"
:class="{
active:
plane.draft.service.name === service.name &&
plane.draft.service.folder === service.folder &&
plane.draft.service.port === port,
}"
@click="plane.applyService(service, port)"
>
{{ port }}
</button>
</div>
</div>
</div>
<p v-else class="muted small">No Docker services detected by the backend.</p>
</section>
</template>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
defineProps<{
status: string;
}>();
</script>
<template>
<span class="status-pill" :class="status">{{ status }}</span>
</template>
+571
View File
@@ -0,0 +1,571 @@
import {
computed,
inject,
proxyRefs,
reactive,
ref,
watch,
type InjectionKey,
} from "vue";
import { api, getToken, login, logout } from "../api";
import type {
AddonExample,
DockerService,
HealthResult,
MitmOption,
ProxyDocument,
ProxyDraft,
} from "../types";
const editorStorageKey = "theplug.editor.open";
const toolStorageKey = "theplug.tool.view";
export const modes = [
"reverse",
"regular",
"socks5",
"upstream",
"wireguard",
"transparent",
"local",
"tun",
"dns",
];
export const protocols = [
"http",
"https",
"tcp",
"tls",
"udp",
"dtls",
"dns",
"http3",
"quic",
];
export type RuntimeAction = "start" | "stop" | "restart";
export type ToolView = "options" | "examples" | "logs";
export function createDefaultDraft(port = 8080, code?: string): ProxyDraft {
return {
proxy_port: port,
name: "new-proxy",
code:
code ??
`from mitmproxy import http
def request(flow: http.HTTPFlow) -> None:
pass
`,
mode: "reverse",
mode_spec: null,
protocol: "http",
listen_host: "0.0.0.0",
mitm_options: {
flow_detail: 1,
termlog_verbosity: "info",
},
healthcheck: {
enabled: true,
protocol: null,
path: "/",
method: "GET",
expected_status: null,
udp_payload: "",
expect_response: true,
verify_tls: false,
timeout_seconds: 2,
},
service: {
host: "127.0.0.1",
port: 9000,
name: null,
folder: null,
upstream_port: null,
},
};
}
function readSession(key: string, fallback: string) {
if (typeof window === "undefined") return fallback;
return window.sessionStorage.getItem(key) ?? fallback;
}
function writeSession(key: string, value: string) {
if (typeof window === "undefined") return;
window.sessionStorage.setItem(key, value);
}
export function useControlPlane() {
const token = ref(getToken());
const username = ref("admin");
const password = ref("");
const error = ref("");
const notice = ref("");
const loading = ref(false);
const proxies = ref<ProxyDocument[]>([]);
const services = ref<DockerService[]>([]);
const options = ref<MitmOption[]>([]);
const examples = ref<AddonExample[]>([]);
const selectedPort = ref<number | null>(null);
const health = ref<HealthResult | null>(null);
const logs = ref("");
const optionSearch = ref("");
const exampleSearch = ref("");
const customExampleName = ref("");
const editorOpen = ref(readSession(editorStorageKey, "0") === "1");
const toolView = ref<ToolView>(
readSession(toolStorageKey, "options") as ToolView,
);
const selectedOptionName = ref("");
const selectedExampleId = ref("");
const draft = reactive<ProxyDraft>(createDefaultDraft());
const selectedProxy = computed(() =>
proxies.value.find((proxy) => proxy.proxy_port === selectedPort.value),
);
const routeState = computed(() => {
const livePort =
selectedProxy.value?.service.upstream_port ?? draft.service.upstream_port ?? null;
const dockerManaged = Boolean(draft.service.name && draft.service.folder);
const reverseManaged = draft.mode === "reverse" && !draft.mode_spec;
const remapped = livePort !== null && livePort !== draft.service.port;
const configuredTarget = `${draft.service.host}:${draft.service.port}`;
const liveTarget = `${draft.service.host}:${livePort ?? draft.service.port}`;
let startNotice = "Save stores configuration only. Start applies runtime routing.";
if (reverseManaged && dockerManaged) {
startNotice =
`Starting this reverse proxy will move the service from :${draft.service.port} ` +
`to a temporary upstream port and bind mitmdump on :${draft.proxy_port}.`;
} else if (draft.mode_spec) {
startNotice =
"This proxy uses a raw mode specification. Start will follow the custom mitmdump mode exactly as configured.";
} else if (draft.mode !== "reverse") {
startNotice =
"This mode does not use Docker port remapping. Start only launches the configured mitmdump listener.";
}
let liveNotice = "No runtime remap is active.";
if (remapped) {
liveNotice = `Live upstream is currently ${liveTarget}. Stop restores :${draft.service.port}.`;
} else if (reverseManaged && dockerManaged) {
liveNotice = "The original service port stays unchanged until Start is executed.";
}
return {
configuredListen: `${draft.listen_host}:${draft.proxy_port}`,
configuredTarget,
liveTarget,
modeLabel: draft.mode_spec ?? `${draft.mode} / ${draft.protocol}`,
serviceLabel: draft.service.name ?? "manual upstream",
dockerManaged,
reverseManaged,
remapped,
livePort,
startNotice,
liveNotice,
};
});
const filteredOptions = computed(() => {
const query = optionSearch.value.toLowerCase().trim();
const available = options.value.filter((option) => !option.reserved);
const filtered = available
.filter((option) => {
if (!query) return true;
return `${option.name} ${option.description}`.toLowerCase().includes(query);
})
.sort((a, b) => a.name.localeCompare(b.name));
return filtered.slice(0, query ? 80 : 36);
});
const optionCount = computed(
() => options.value.filter((option) => !option.reserved).length,
);
const selectedOption = computed(
() =>
filteredOptions.value.find((option) => option.name === selectedOptionName.value) ??
filteredOptions.value[0] ??
null,
);
const filteredExamples = computed(() => {
const query = exampleSearch.value.toLowerCase().trim();
const filtered = examples.value.filter((example) => {
if (!query) return true;
return `${example.name} ${example.category} ${example.description}`
.toLowerCase()
.includes(query);
});
const sorted = filtered.sort((a, b) => a.name.localeCompare(b.name));
return query ? sorted : sorted.slice(0, 24);
});
const selectedExample = computed(
() =>
filteredExamples.value.find((example) => example.id === selectedExampleId.value) ??
filteredExamples.value[0] ??
null,
);
const activeOptions = computed(() =>
Object.entries(draft.mitm_options)
.map(([name, value]) => ({
name,
value: Array.isArray(value) ? value.join(", ") : String(value ?? ""),
option: options.value.find((item) => item.name === name),
}))
.sort((a, b) => a.name.localeCompare(b.name)),
);
const codeLineCount = computed(() => draft.code.split("\n").length);
watch(selectedProxy, (proxy) => {
if (!proxy) return;
Object.assign(draft, structuredClone(proxy));
health.value = null;
logs.value = "";
});
watch(editorOpen, (value) => {
writeSession(editorStorageKey, value ? "1" : "0");
});
watch(toolView, (value) => {
writeSession(toolStorageKey, value);
});
watch(filteredOptions, (items) => {
if (!items.some((item) => item.name === selectedOptionName.value)) {
selectedOptionName.value = items[0]?.name ?? "";
}
}, { immediate: true });
watch(filteredExamples, (items) => {
if (!items.some((item) => item.id === selectedExampleId.value)) {
selectedExampleId.value = items[0]?.id ?? "";
}
}, { immediate: true });
async function signIn() {
await run(async () => {
await login(username.value, password.value);
token.value = getToken();
password.value = "";
await refreshAll();
}, "Logged in");
}
async function signOut() {
await logout();
token.value = null;
proxies.value = [];
}
async function refreshAll() {
await run(async () => {
const [proxyList, serviceList, optionList, exampleList] = await Promise.all([
api.proxies(),
api.services(),
api.options(),
api.examples(),
]);
proxies.value = proxyList;
services.value = serviceList;
options.value = optionList;
examples.value = exampleList;
if (!selectedPort.value && proxyList[0]) {
selectedPort.value = proxyList[0].proxy_port;
}
});
}
function newProxy() {
selectedPort.value = null;
health.value = null;
logs.value = "";
Object.assign(
draft,
createDefaultDraft(nextPort(), examples.value[0]?.code ?? draft.code),
);
notice.value = "New proxy draft created. Save stores configuration only.";
toolView.value = "options";
}
async function saveProxy() {
await run(async () => {
const payload = cleanDraft();
const saved = selectedProxy.value
? await api.updateProxy(selectedProxy.value.proxy_port, payload)
: await api.createProxy(payload);
await refreshAll();
selectedPort.value = saved.proxy_port;
}, "Proxy saved");
}
async function removeProxy() {
const proxy = selectedProxy.value;
if (!proxy) return;
await run(async () => {
await api.deleteProxy(proxy.proxy_port);
selectedPort.value = null;
await refreshAll();
}, "Proxy deleted");
}
async function runtimeAction(kind: RuntimeAction) {
const proxy = selectedProxy.value;
if (!proxy) return;
await run(async () => {
const port = proxy.proxy_port;
const updated =
kind === "start"
? await api.start(port)
: kind === "stop"
? await api.stop(port)
: await api.restart(port);
replaceProxy(updated);
selectedPort.value = updated.proxy_port;
}, `Proxy ${kind} requested`);
}
async function checkHealth() {
const proxy = selectedProxy.value;
if (!proxy) return;
await run(async () => {
health.value = await api.health(proxy.proxy_port);
});
}
async function loadLogs() {
const proxy = selectedProxy.value;
if (!proxy) return;
await run(async () => {
logs.value = (await api.logs(proxy.proxy_port)).log;
});
}
async function saveCurrentAsExample() {
const name = customExampleName.value.trim();
if (!name) {
error.value = "Custom example name is required";
return;
}
const id =
name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") ||
`example-${Date.now()}`;
await run(async () => {
await api.createExample({
id,
name,
description: `Captured from ${draft.mode} ${draft.protocol} proxy ${draft.name}.`,
category: "custom",
code: draft.code,
source_url: null,
built_in: false,
});
examples.value = await api.examples();
customExampleName.value = "";
selectedExampleId.value = id;
}, "Custom example saved");
}
async function removeExample(id: string) {
await run(async () => {
await api.deleteExample(id);
examples.value = await api.examples();
}, "Custom example deleted");
}
function applyService(service: DockerService, port: number) {
draft.service.name = service.name;
draft.service.folder = service.folder;
draft.service.port = port;
draft.service.host = "127.0.0.1";
}
function applyExample(example: AddonExample) {
selectedExampleId.value = example.id;
draft.code = example.code;
notice.value = editorOpen.value
? `Loaded example: ${example.name}`
: `Loaded example: ${example.name}. Open the editor to review or modify the code.`;
}
function selectExample(exampleId: string) {
selectedExampleId.value = exampleId;
}
function setOption(option: MitmOption) {
selectedOptionName.value = option.name;
if (!(option.name in draft.mitm_options)) {
draft.mitm_options[option.name] = defaultFor(option);
}
}
function selectOption(optionName: string) {
selectedOptionName.value = optionName;
}
function removeOption(name: string) {
delete draft.mitm_options[name];
}
function updateOption(name: string, value: string, option?: MitmOption) {
if (option?.type === "bool") {
draft.mitm_options[name] = value === "true";
return;
}
if (option?.type === "int" || option?.type === "optional int") {
draft.mitm_options[name] = value === "" ? null : Number(value);
return;
}
if (option?.type === "sequence of str") {
draft.mitm_options[name] = value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
return;
}
draft.mitm_options[name] = value;
}
function toggleEditor(force?: boolean) {
editorOpen.value = typeof force === "boolean" ? force : !editorOpen.value;
}
function setToolView(view: ToolView) {
toolView.value = view;
}
function cleanDraft(): ProxyDraft {
return JSON.parse(
JSON.stringify({
proxy_port: draft.proxy_port,
name: draft.name,
code: draft.code,
mode: draft.mode,
mode_spec: draft.mode_spec || null,
protocol: draft.protocol,
listen_host: draft.listen_host,
mitm_options: draft.mitm_options,
healthcheck: {
...draft.healthcheck,
expected_status: draft.healthcheck.expected_status || null,
protocol: draft.healthcheck.protocol || null,
},
service: {
host: draft.service.host,
port: draft.service.port,
name: draft.service.name || null,
folder: draft.service.folder || null,
upstream_port: draft.service.upstream_port || null,
},
}),
) as ProxyDraft;
}
function replaceProxy(proxy: ProxyDocument) {
const index = proxies.value.findIndex(
(item) => item.proxy_port === proxy.proxy_port,
);
if (index === -1) proxies.value.push(proxy);
else proxies.value[index] = proxy;
}
function nextPort() {
const used = new Set(proxies.value.map((proxy) => proxy.proxy_port));
let port = 8080;
while (used.has(port)) port += 1;
return port;
}
function defaultFor(option: MitmOption) {
if (option.default !== null && option.default !== undefined) return option.default;
if (option.type === "bool") return false;
if (option.type === "int" || option.type === "optional int") return 0;
if (option.type === "sequence of str") return [];
return "";
}
async function run(task: () => Promise<void>, success?: string) {
loading.value = true;
error.value = "";
notice.value = "";
try {
await task();
if (success) notice.value = success;
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
loading.value = false;
}
}
return proxyRefs({
activeOptions,
applyExample,
applyService,
checkHealth,
codeLineCount,
customExampleName,
draft,
editorOpen,
error,
exampleSearch,
filteredExamples,
filteredOptions,
health,
loadLogs,
loading,
logs,
newProxy,
notice,
optionCount,
optionSearch,
password,
proxies,
refreshAll,
removeExample,
removeOption,
removeProxy,
routeState,
runtimeAction,
saveCurrentAsExample,
saveProxy,
selectExample,
selectedExample,
selectedOption,
selectedPort,
selectedProxy,
selectOption,
services,
setOption,
setToolView,
signIn,
signOut,
toggleEditor,
token,
toolView,
updateOption,
username,
});
}
export type ControlPlane = ReturnType<typeof useControlPlane>;
export const controlPlaneKey: InjectionKey<ControlPlane> = Symbol("control-plane");
export function useControlPlaneContext() {
const context = inject(controlPlaneKey);
if (!context) {
throw new Error("Control plane context is not available");
}
return context;
}
+65
View File
@@ -0,0 +1,65 @@
import {
LSPClient,
languageServerExtensions,
type Transport,
} from "@codemirror/lsp-client";
import { buildSocketUrl } from "../api";
import type { PythonEditorSession } from "../types";
export type PythonLspConnection = {
client: LSPClient;
socket: WebSocket;
};
export function connectPythonLsp(
session: PythonEditorSession,
): Promise<PythonLspConnection> {
const url = buildSocketUrl("/proxy/editor/python/ws");
const socket = new WebSocket(url);
return new Promise((resolve, reject) => {
let handlers: Array<(value: string) => void> = [];
let settled = false;
socket.addEventListener("message", (event) => {
const value =
typeof event.data === "string" ? event.data : String(event.data ?? "");
for (const handler of handlers) handler(value);
});
socket.addEventListener("open", () => {
const transport: Transport = {
send(message) {
socket.send(message);
},
subscribe(handler) {
handlers.push(handler);
},
unsubscribe(handler) {
handlers = handlers.filter((value) => value !== handler);
},
};
const client = new LSPClient({
rootUri: session.root_uri,
extensions: languageServerExtensions(),
}).connect(transport);
settled = true;
resolve({ client, socket });
});
socket.addEventListener("error", () => {
if (settled) return;
settled = true;
reject(new Error("Unable to connect to the Python language server"));
});
socket.addEventListener("close", (event) => {
if (settled || socket.readyState !== WebSocket.CLOSED) return;
settled = true;
reject(new Error(event.reason || "Python language server connection closed"));
});
});
}
+8
View File
@@ -0,0 +1,8 @@
/// <reference types="vite/client" />
declare module "*?worker" {
const workerConstructor: {
new (): Worker;
};
export default workerConstructor;
}
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./styles.css";
createApp(App).mount("#app");
File diff suppressed because it is too large Load Diff
+90
View File
@@ -0,0 +1,90 @@
export type ServiceEndpoint = {
host: string;
port: number;
name?: string | null;
folder?: string | null;
upstream_port?: number | null;
};
export type HealthCheck = {
enabled: boolean;
protocol?: string | null;
path: string;
method: string;
expected_status?: number | null;
udp_payload: string;
expect_response: boolean;
verify_tls: boolean;
timeout_seconds: number;
};
export type ProxyDocument = {
proxy_port: number;
name: string;
code: string;
mode: string;
mode_spec?: string | null;
protocol: string;
listen_host: string;
mitm_options: Record<string, unknown>;
healthcheck: HealthCheck;
status: string;
pid?: number | null;
script_path?: string | null;
config_path?: string | null;
log_path?: string | null;
last_error?: string | null;
service: ServiceEndpoint;
created_at?: string | null;
updated_at?: string | null;
};
export type DockerService = {
name: string;
ports: number[];
folder: string;
};
export type MitmOption = {
name: string;
type: string;
default: unknown;
description: string;
choices: string[];
reserved: boolean;
docs_url?: string | null;
};
export type AddonExample = {
id: string;
name: string;
description: string;
category: string;
code: string;
source_url?: string | null;
built_in: boolean;
};
export type HealthResult = {
enabled: boolean;
overall: string;
checks: Array<{
target: string;
host: string;
port: number;
protocol: string;
healthy: boolean;
error?: string | null;
details: Record<string, unknown>;
}>;
};
export type PythonEditorSession = {
root_uri: string;
file_uri: string;
};
export type ProxyDraft = Omit<
ProxyDocument,
"status" | "pid" | "script_path" | "config_path" | "log_path" | "last_error"
>;
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
+20
View File
@@ -0,0 +1,20 @@
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [vue()],
build: {
chunkSizeWarningLimit: 3000,
},
server: {
port: 5173,
proxy: {
"/api": {
target: "http://127.0.0.1:3671",
changeOrigin: true,
ws: true,
rewrite: (path) => path.replace(/^\/api/, ""),
},
},
},
});
+16
View File
@@ -0,0 +1,16 @@
{
"name": "theplug",
"private": true,
"version": "0.1.0",
"scripts": {
"dev:backend": "cd backend && PYTHONPATH=app uv run fastapi dev app/main.py",
"dev:frontend": "cd frontend && npm run dev",
"test:backend": "cd backend && uv run pytest",
"lint:backend": "cd backend && uv run ruff check .",
"format:backend": "cd backend && uv run ruff format .",
"build:frontend": "cd frontend && npm run build",
"test": "npm run lint:backend && npm run test:backend && npm run build:frontend",
"compose:up": "./setup.sh",
"compose:down": "docker compose --env-file backend/.env -f docker-compose.yml down"
}
}
Regular → Executable
+56 -96
View File
@@ -6,121 +6,81 @@ GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
info() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
die() { echo -e "${RED}[✗] FATAL:${NC} $*" >&2; exit 1; }
info() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
die() { echo -e "${RED}[x] FATAL:${NC} $*" >&2; exit 1; }
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$ROOT_DIR/backend/.env"
compose() {
docker compose --env-file "$ENV_FILE" -f "$ROOT_DIR/docker-compose.yml" "$@"
}
ensure_env() {
if [[ -f "$ENV_FILE" ]]; then
return
fi
warn "backend/.env not found, creating it from backend/.env.example"
cp "$ROOT_DIR/backend/.env.example" "$ENV_FILE"
local mongo_pass jwt_secret auth_pass
mongo_pass=$(openssl rand -hex 8)
jwt_secret=$(openssl rand -hex 20)
auth_pass=$(openssl rand -hex 10)
sed -i "s|^MONGO_INITDB_ROOT_PASSWORD=.*|MONGO_INITDB_ROOT_PASSWORD=$mongo_pass|" "$ENV_FILE"
sed -i "s|^MONGODB_URI=.*|MONGODB_URI=mongodb://theplug:$mongo_pass@localhost:27017/theplug?authSource=admin|" "$ENV_FILE"
sed -i "s|^JWT_SECRET_KEY=.*|JWT_SECRET_KEY=$jwt_secret|" "$ENV_FILE"
sed -i "s|^AUTH_PASSWORD=.*|AUTH_PASSWORD=$auth_pass|" "$ENV_FILE"
info "Generated credentials in $ENV_FILE"
info "AUTH_USERNAME: admin"
info "AUTH_PASSWORD: $auth_pass"
}
install_service() {
local uv_bin
uv_bin=$(command -v uv)
local api_port
api_port=$(grep -E '^API_PORT=' .env 2>/dev/null | cut -d '=' -f2)
api_port=${api_port:-3671}
mkdir -p ~/.config/systemd/user
cat > ~/.config/systemd/user/theplug.service <<EOF
[Unit]
Description=thePlug service
Description=thePlug stack
After=network.target
[Service]
WorkingDirectory=/srv/theplug
EnvironmentFile=/srv/theplug/.env
Environment=PYTHONPATH=app
ExecStartPre=/usr/bin/docker compose -f /srv/theplug/compose.yml up -d redis mongodb
ExecStart=$uv_bin run uvicorn app.main:app --host 0.0.0.0 --port $api_port
Restart=always
RestartSec=5
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=$ROOT_DIR
ExecStart=/usr/bin/docker compose --env-file $ENV_FILE -f $ROOT_DIR/docker-compose.yml up -d
ExecStop=/usr/bin/docker compose --env-file $ENV_FILE -f $ROOT_DIR/docker-compose.yml down
[Install]
WantedBy=default.target
EOF
systemctl --user daemon-reload
systemctl --user enable theplug
info "systemd user service installed (port $api_port)"
info "systemd user service installed"
}
start_services() {
info "Starting Redis and MongoDB..."
docker compose up -d redis mongodb || die "docker compose failed — are Redis/MongoDB defined in compose.yml?"
info "Starting thePlug via systemctl..."
start_stack() {
ensure_env
info "Building and starting backend, frontend, MongoDB and Redis..."
compose up -d --build
info "Backend: http://localhost:$(grep -E '^API_PORT=' "$ENV_FILE" | cut -d '=' -f2 || echo 3671)"
info "Frontend: http://localhost:$(grep -E '^FRONTEND_PORT=' "$ENV_FILE" | cut -d '=' -f2 || echo 5173)"
}
if [[ "${1:-}" == "service" ]]; then
ensure_env
install_service
systemctl --user start theplug
warn "Use 'systemctl --user status theplug' to check / 'journalctl --user -u theplug -f' to follow logs"
}
# ── 1. ensure we're in thePlug ────────────────────────────────────────────────
if [[ -d "/srv/theplug" ]]; then
info "thePlug già presente — avvio diretto..."
cd /srv/theplug
start_services
exit 0
fi
cd /srv
git clone https://git.reloia.uk/reloia/thePlug theplug || die "git clone failed"
cd theplug
chmod +x ./setup.sh
PLUG_DIR="$(pwd)"
if [[ ! -L /usr/bin/theplug ]]; then
info "Creating symlink /usr/bin/theplug..."
sudo ln -sf "$PLUG_DIR/setup.sh" /usr/bin/theplug
sudo chmod +x /usr/bin/theplug
if [[ -d "/srv/theplug" && "$ROOT_DIR" != "/srv/theplug" ]]; then
info "thePlug found in /srv/theplug, switching there"
cd /srv/theplug
exec ./setup.sh "$@"
fi
# 2 env
if [[ ! -f .env ]]; then
warn ".env not found — copyinh from .env.example with random stuff..."
cp .env.example .env || die ".env.example not found (impossibile)"
MONGO_PASS=$(openssl rand -hex 8)
JWT_SECRET=$(openssl rand -hex 20)
AUTH_PASS=$(openssl rand -hex 10)
sed -i "s|^MONGO_INITDB_ROOT_PASSWORD=.*|MONGO_INITDB_ROOT_PASSWORD=$MONGO_PASS|" .env
sed -i "s|^MONGODB_URI=.*|MONGODB_URI=mongodb://theplug:$MONGO_PASS@localhost:27017/theplug?authSource=admin|" .env
sed -i "s|^JWT_SECRET_KEY=.*|JWT_SECRET_KEY=$JWT_SECRET|" .env
sed -i "s|^AUTH_PASSWORD=.*|AUTH_PASSWORD=$AUTH_PASS|" .env
info "Resetting docker containers..."
docker compose down -v 2>/dev/null || true
fi
# ── 3. spin up dependencies ───────────────────────────────────────────────────
info "Starting Redis and MongoDB..."
docker compose up -d redis mongodb || die "docker compose failed — are Redis/MongoDB defined in compose.yml?"
# ── 4. ensure uv is available ─────────────────────────────────────────────────
if ! command -v uv &>/dev/null; then
warn "uv not found — installing..."
curl -LsSf https://astral.sh/uv/install.sh | sh || die "uv installation failed"
export PATH="$HOME/.local/bin:$PATH"
command -v uv &>/dev/null || die "uv installed but still not in PATH — open a new shell and retry"
info "uv installed: $(uv --version)"
fi
# ── 5. venv + deps ────────────────────────────────────────────────────────────
info "Pinning Python 3.14..."
uv python pin 3.14
info "Creating venv..."
uv venv
info "Syncing dependencies..."
uv sync
# ── 6. install service + start ────────────────────────────────────────────────
install_service
systemctl --user start theplug
echo -e ""
info "Automatically generated Secrets:"
info " MONGO_PASS: $MONGO_PASS"
info " JWT_SECRET: $JWT_SECRET"
info " AUTH_PASS: $AUTH_PASS - questa è importante"
warn "Save them — these won't be showed again (non è vero basta che leggi $PLUG_DIR/.env)"
echo -e ""
info "Installed thePlug in $PLUG_DIR"
warn "Use 'systemctl --user status theplug' to check / 'journalctl --user -u theplug -f' to follow logs"
echo -e ""
start_stack