BIG BIG BIG BIG BIG UPDATES
Co-authored-by: Copilot <[email protected]>
This commit is contained in:
co-authored by
Copilot
Unverified
parent
46f7cd6a5f
commit
93ba8e9ac2
@@ -5,3 +5,12 @@
|
||||
.env
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
**/__pycache__/
|
||||
**/.pytest_cache/
|
||||
**/.ruff_cache/
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
backend/.venv/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
@@ -10,6 +10,7 @@ class _MongoDB:
|
||||
raise RuntimeError("MongoDB client has not been initialized")
|
||||
return self.client[settings.MONGODB_DB_NAME]
|
||||
|
||||
|
||||
mongodb = _MongoDB()
|
||||
|
||||
|
||||
@@ -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)],
|
||||
@@ -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
|
||||
|
||||
@@ -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(":")
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +27,7 @@ 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 []
|
||||
@@ -33,8 +35,7 @@ async def lifespan(_: FastAPI):
|
||||
warning = (
|
||||
"\n" + "!" * 60 + "\n"
|
||||
" WARNING: No services found in home directory!\n"
|
||||
f" Scanned: {home_dir}\n"
|
||||
+ "!" * 60 + "\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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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})
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
]
|
||||
)
|
||||
""",
|
||||
),
|
||||
]
|
||||
@@ -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 {},
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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}']
|
||||
Generated
+490
-4
@@ -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
@@ -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:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
npm-debug.log
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+1670
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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`),
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
status: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="status-pill" :class="status">{{ status }}</span>
|
||||
</template>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
}
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*?worker" {
|
||||
const workerConstructor: {
|
||||
new (): Worker;
|
||||
};
|
||||
export default workerConstructor;
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
>;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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/, ""),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -8,119 +8,79 @@ NC='\033[0m'
|
||||
|
||||
info() { echo -e "${GREEN}[+]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
||||
die() { echo -e "${RED}[✗] FATAL:${NC} $*" >&2; exit 1; }
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user