Compare commits
2
Commits
@@ -0,0 +1,10 @@
|
||||
API_PORT=3671
|
||||
MONGO_INITDB_ROOT_PASSWORD=change-this-mongo-password
|
||||
MONGODB_URI=mongodb://theplug:change-this-mongo-password@localhost:27017/theplug?authSource=admin
|
||||
MONGODB_DB_NAME=theplug
|
||||
REDIS_URL=redis://localhost:6379
|
||||
JWT_SECRET_KEY=change-this-secret
|
||||
JWT_ALGORITHM=HS256
|
||||
JWT_EXPIRE_MINUTES=30
|
||||
AUTH_USERNAME=admin
|
||||
AUTH_PASSWORD=change-this-password
|
||||
+1
-11
@@ -4,14 +4,4 @@
|
||||
.vscode/
|
||||
.env
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
**/__pycache__/
|
||||
**/.pytest_cache/
|
||||
**/.ruff_cache/
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
backend/.venv/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
playground/
|
||||
.pytest_cache/
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
FROM python:3.14-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml ./
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir \
|
||||
"authlib>=1.6.0" \
|
||||
"fastapi[standard]>=0.135.3" \
|
||||
"motor>=3.7.1" \
|
||||
"passlib[bcrypt]>=1.7.4" \
|
||||
"pydantic-settings>=2.13.1" \
|
||||
"redis>=7.4.0"
|
||||
|
||||
COPY app ./src
|
||||
|
||||
EXPOSE 3671
|
||||
|
||||
CMD ["fastapi", "run", "src/main.py", "--host", "0.0.0.0", "--port", "3671"]
|
||||
@@ -0,0 +1,26 @@
|
||||
FROM python:3.14-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONPATH=/app/app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml ./
|
||||
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir \
|
||||
"authlib>=1.6.0" \
|
||||
"fastapi[standard]>=0.135.3" \
|
||||
"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"
|
||||
|
||||
COPY app ./app
|
||||
|
||||
EXPOSE 3671
|
||||
|
||||
CMD ["sh", "-c", "fastapi run /app/app/main.py --host 0.0.0.0 --port ${API_PORT:-3671}"]
|
||||
@@ -1,167 +1,79 @@
|
||||
# thePlug
|
||||
# thePlug - plugger/proxy Barese
|
||||
|
||||
Web UI per gestire proxy `mitmdump` davanti a servizi esposti via Docker
|
||||
Compose.
|
||||
icona di una presa della spina idk
|
||||
|
||||
## Struttura
|
||||
## Architettura
|
||||
|
||||
```text
|
||||
backend/ FastAPI, MongoDB, mitmdump manager
|
||||
frontend/ Vue 3, Vite, CodeMirror + Python LSP
|
||||
playground/ challenge/service compose locali
|
||||
templates/ template proxy YAML file-backed
|
||||
```
|
||||
FastAPI + MongoDB + Redis
|
||||
```
|
||||
|
||||
## Setup iniziale
|
||||
## Quick Setup (prod)
|
||||
|
||||
```bash
|
||||
./setup.sh
|
||||
bash <(curl -s https://git.reloia.uk/reloia/thePlug/raw/branch/master/setup.sh)
|
||||
```
|
||||
|
||||
`setup.sh` fa bootstrap della macchina/progetto:
|
||||
- installa `uv` se manca
|
||||
- installa Python `3.14` via `uv`
|
||||
- crea `backend/.env` se manca
|
||||
- installa dipendenze backend e frontend
|
||||
- builda il frontend
|
||||
- prepara MongoDB e l'immagine Docker del frontend
|
||||
## Setup Dev
|
||||
|
||||
## Runtime reale / vulnbox
|
||||
|
||||
Questi sono i comandi pensati per l’ambiente reale di gara.
|
||||
|
||||
### MongoDB
|
||||
imposta le variabili d'ambiente
|
||||
|
||||
```bash
|
||||
./run.sh mongo up
|
||||
./run.sh mongo status
|
||||
./run.sh mongo logs
|
||||
./run.sh mongo down
|
||||
cp .env.example .env
|
||||
# modifica le variabili
|
||||
nano .env
|
||||
```
|
||||
|
||||
### Backend
|
||||
variabili auth richieste in `.env`:
|
||||
|
||||
```bash
|
||||
./run.sh backend
|
||||
JWT_SECRET_KEY=change-this-secret
|
||||
JWT_ALGORITHM=HS256
|
||||
JWT_EXPIRE_MINUTES=30
|
||||
AUTH_USERNAME=admin
|
||||
AUTH_PASSWORD=change-this-password
|
||||
```
|
||||
|
||||
### Frontend Docker
|
||||
|
||||
avvia i servizi e il server locale
|
||||
```bash
|
||||
./run.sh frontend up
|
||||
./run.sh frontend status
|
||||
./run.sh frontend logs
|
||||
./run.sh frontend down
|
||||
docker compose up -d mongodb redis
|
||||
```
|
||||
|
||||
Il frontend in container fa proxy API verso il backend in esecuzione sulla
|
||||
macchina host.
|
||||
|
||||
## Workflow development
|
||||
|
||||
Questi comandi sono pensati per sviluppo locale, test e playground.
|
||||
|
||||
### MongoDB
|
||||
|
||||
```bash
|
||||
./dev.sh mongo up
|
||||
./dev.sh mongo status
|
||||
./dev.sh mongo logs
|
||||
./dev.sh mongo down
|
||||
fastapi dev app/main.py
|
||||
```
|
||||
|
||||
### Backend dev
|
||||
accedi a FastAPI su: [FastAPI docs](http://localhost:3671/docs)
|
||||
|
||||
```bash
|
||||
./dev.sh backend
|
||||
```
|
||||
## Auth API
|
||||
|
||||
Docs FastAPI in dev: `http://localhost:3671/docs`.
|
||||
|
||||
### Frontend dev
|
||||
|
||||
```bash
|
||||
./dev.sh frontend
|
||||
```
|
||||
|
||||
Questo avvia Vite normalmente sulla macchina host.
|
||||
|
||||
### Playground / servizi locali
|
||||
|
||||
Per vedere i challenge/service disponibili:
|
||||
|
||||
```bash
|
||||
./dev.sh services list
|
||||
```
|
||||
|
||||
Per avviarli o fermarli:
|
||||
|
||||
```bash
|
||||
./dev.sh services up GabibbiTowers
|
||||
./dev.sh services down GabibbiTowers
|
||||
|
||||
./dev.sh services up all
|
||||
./dev.sh services status all
|
||||
./dev.sh services down all
|
||||
```
|
||||
|
||||
`dev.sh` scopre automaticamente i compose da `playground/*/docker-compose.yml`.
|
||||
|
||||
## Flussi consigliati
|
||||
|
||||
Runtime reale:
|
||||
|
||||
```bash
|
||||
./setup.sh
|
||||
./run.sh mongo up
|
||||
./run.sh backend
|
||||
./run.sh frontend up
|
||||
```
|
||||
|
||||
Sviluppo locale:
|
||||
|
||||
```bash
|
||||
./setup.sh
|
||||
./dev.sh mongo up
|
||||
./dev.sh backend
|
||||
./dev.sh frontend
|
||||
./dev.sh services up <challenge>
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
Backend:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
uv run ruff check .
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
Frontend:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
## API principali
|
||||
Gli endpoint auth disponibili sono solo:
|
||||
|
||||
- `POST /auth/login`
|
||||
- `POST /auth/logout`
|
||||
- `GET /proxy`
|
||||
- `POST /proxy`
|
||||
- `POST /proxy/{port}/start`
|
||||
- `POST /proxy/{port}/stop`
|
||||
- `POST /proxy/{port}/restart`
|
||||
- `GET /proxy/{port}/health`
|
||||
- `GET /proxy/{port}/logs`
|
||||
- `WS /proxy/{port}/logs/ws`
|
||||
- `GET /proxy/mitmproxy/options`
|
||||
- `GET /proxy/mitmproxy/templates`
|
||||
|
||||
## Riferimenti
|
||||
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/)
|
||||
|
||||
- Mitmproxy addons: https://docs.mitmproxy.org/stable/addons/examples/
|
||||
- Mitmproxy modes: https://docs.mitmproxy.org/stable/concepts/modes/
|
||||
- Mitmproxy options: https://docs.mitmproxy.org/stable/concepts/options/
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
- fixare i test di unità, di integrazione e aggiungere test reali di benchmark.
|
||||
@@ -0,0 +1,3 @@
|
||||
from api.auth.router import router
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -3,14 +3,14 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
|
||||
from app.core.security import (
|
||||
from core.security import (
|
||||
AuthContext,
|
||||
create_access_token,
|
||||
get_auth_context,
|
||||
verify_password,
|
||||
)
|
||||
from app.models.auth import LoginInput, TokenResponse
|
||||
from app.repositories.auth import get_user_by_username, revoke_token
|
||||
from models.auth import LoginInput, TokenResponse
|
||||
from repositories.auth import get_user_by_username, revoke_token
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/auth",
|
||||
@@ -0,0 +1,89 @@
|
||||
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",
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from core.security import require_authenticated_user
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/services",
|
||||
tags=["Services"],
|
||||
dependencies=[Depends(require_authenticated_user)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_services(request: Request):
|
||||
return request.app.state.services
|
||||
@@ -4,7 +4,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=(".env", "backend/.env"),
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
@@ -12,15 +12,12 @@ class Settings(BaseSettings):
|
||||
|
||||
MONGODB_URI: str
|
||||
MONGODB_DB_NAME: str
|
||||
REDIS_URL: str
|
||||
JWT_SECRET_KEY: str
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_EXPIRE_MINUTES: int = Field(default=720, gt=0)
|
||||
JWT_EXPIRE_MINUTES: int = Field(default=30, gt=0)
|
||||
AUTH_USERNAME: str
|
||||
AUTH_PASSWORD: str
|
||||
API_PORT: int = Field(default=3671, ge=1, le=65535)
|
||||
SERVICE_SCAN_ROOT: str | None = None
|
||||
HEALTHCHECK_INTERVAL_SECONDS: int = Field(default=15, ge=5, le=300)
|
||||
HEALTH_HISTORY_TTL_SECONDS: int = Field(default=86400, ge=300)
|
||||
|
||||
|
||||
settings = Settings() # pyright: ignore[reportCallIssue]
|
||||
settings = Settings()
|
||||
@@ -1,19 +1,15 @@
|
||||
from typing import Any
|
||||
|
||||
from core.config import settings
|
||||
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class _MongoDB:
|
||||
client: AsyncIOMotorClient[dict[str, Any]] | None = None
|
||||
client: AsyncIOMotorClient | None = None
|
||||
|
||||
def get_database(self) -> AsyncIOMotorDatabase[dict[str, Any]]:
|
||||
def get_database(self) -> AsyncIOMotorDatabase:
|
||||
if self.client is None:
|
||||
raise RuntimeError("MongoDB client has not been initialized")
|
||||
return self.client[settings.MONGODB_DB_NAME]
|
||||
|
||||
|
||||
mongodb = _MongoDB()
|
||||
|
||||
|
||||
@@ -29,7 +25,7 @@ async def close_mongodb() -> None:
|
||||
client.close()
|
||||
|
||||
|
||||
async def get_database() -> AsyncIOMotorDatabase[dict[str, Any]]:
|
||||
async def get_database() -> AsyncIOMotorDatabase:
|
||||
return mongodb.get_database()
|
||||
|
||||
|
||||
@@ -37,4 +33,4 @@ async def ensure_mongodb_available() -> None:
|
||||
client = mongodb.client
|
||||
if client is None:
|
||||
raise RuntimeError("MongoDB client has not been initialized")
|
||||
await client.admin.command("ping")
|
||||
await client.admin.command("ping")
|
||||
@@ -0,0 +1,22 @@
|
||||
import redis.asyncio as aioredis
|
||||
from core.config import settings
|
||||
|
||||
_redis_pool: aioredis.Redis | None = None
|
||||
|
||||
|
||||
async def init_redis() -> None:
|
||||
global _redis_pool
|
||||
_redis_pool = aioredis.from_url(
|
||||
settings.REDIS_URL, encoding="utf-8", decode_responses=True
|
||||
)
|
||||
|
||||
|
||||
async def close_redis() -> None:
|
||||
if _redis_pool:
|
||||
await _redis_pool.aclose()
|
||||
|
||||
|
||||
async def get_redis() -> aioredis.Redis:
|
||||
if _redis_pool is None:
|
||||
raise RuntimeError("Redis pool has not been initialized")
|
||||
return _redis_pool
|
||||
@@ -10,9 +10,9 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from pwdlib import PasswordHash
|
||||
from pwdlib.hashers.argon2 import Argon2Hasher
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.auth import AccessTokenPayload, AuthUserDocument
|
||||
from app.repositories.auth import get_user_by_username, is_token_revoked
|
||||
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(),))
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
@@ -42,7 +42,7 @@ def hash_password(password: str) -> str:
|
||||
|
||||
|
||||
def verify_password(plain_password: str, password_hash: str) -> bool:
|
||||
return pwd_context.verify(plain_password, password_hash)
|
||||
return pwd_context.check(plain_password, password_hash)
|
||||
|
||||
|
||||
def create_access_token(*, subject: str) -> str:
|
||||
@@ -74,26 +74,10 @@ 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,
|
||||
@@ -103,11 +87,21 @@ async def get_auth_context(
|
||||
if credentials is None or credentials.scheme.lower() != "bearer":
|
||||
raise token_auth_error()
|
||||
|
||||
token = credentials.credentials
|
||||
try:
|
||||
return await authenticate_token(credentials.credentials)
|
||||
payload = decode_access_token(token)
|
||||
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)],
|
||||
@@ -0,0 +1,15 @@
|
||||
from integrations.docker.compose import (
|
||||
find_docker_compose,
|
||||
get_all_services,
|
||||
get_services,
|
||||
)
|
||||
from integrations.docker.editor import change_port
|
||||
from integrations.docker.models import DockerContainer
|
||||
|
||||
__all__ = [
|
||||
"DockerContainer",
|
||||
"find_docker_compose",
|
||||
"get_services",
|
||||
"get_all_services",
|
||||
"change_port",
|
||||
]
|
||||
@@ -0,0 +1,75 @@
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from integrations.docker.models import DockerContainer
|
||||
from integrations.docker.ports import _get_service_port
|
||||
|
||||
COMPOSE_NAMES = (
|
||||
"compose.yml",
|
||||
"compose.yaml",
|
||||
"docker-compose.yml",
|
||||
"docker-compose.yaml",
|
||||
)
|
||||
|
||||
|
||||
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(".")
|
||||
]
|
||||
|
||||
|
||||
def find_docker_compose(path: Path) -> tuple[dict, str] | None:
|
||||
if not path or not path.is_dir():
|
||||
return None
|
||||
|
||||
for name in COMPOSE_NAMES:
|
||||
file = path / name
|
||||
if file.is_file():
|
||||
content = yaml.safe_load(file.read_text()) or {}
|
||||
return content, file.name
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_services(folder: Path) -> list[DockerContainer] | None:
|
||||
services: list[DockerContainer] = []
|
||||
compose_data = find_docker_compose(folder)
|
||||
if compose_data is None:
|
||||
return None
|
||||
|
||||
docker_compose, _ = compose_data
|
||||
compose_services = docker_compose.get("services") or {}
|
||||
if not compose_services:
|
||||
return None
|
||||
|
||||
for service_name, config in compose_services.items():
|
||||
mappings = config.get("ports") if isinstance(config, dict) else None
|
||||
if not isinstance(mappings, list) or not mappings:
|
||||
continue
|
||||
|
||||
ports = [
|
||||
p
|
||||
for p in (_get_service_port(mapping) for mapping in mappings)
|
||||
if p is not None
|
||||
]
|
||||
if not ports:
|
||||
continue
|
||||
|
||||
services.append({"name": service_name, "ports": ports, "folder": folder})
|
||||
|
||||
return services
|
||||
|
||||
|
||||
def get_all_services(directory: str | Path) -> list[DockerContainer]:
|
||||
path = Path(directory)
|
||||
if not path.is_dir():
|
||||
return []
|
||||
|
||||
services: list[DockerContainer] = []
|
||||
for sub_folder in _get_visible_folders(path):
|
||||
sub_services = get_services(sub_folder)
|
||||
if sub_services:
|
||||
services.extend(sub_services)
|
||||
|
||||
return services
|
||||
@@ -0,0 +1,50 @@
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from integrations.docker.compose import find_docker_compose
|
||||
from integrations.docker.ports import _get_port_index, _replace_port_in_mapping
|
||||
|
||||
|
||||
def _backup_path_for(compose_path: Path) -> Path:
|
||||
if compose_path.suffix in {".yml", ".yaml"}:
|
||||
return compose_path.with_name(f"{compose_path.stem}.bak{compose_path.suffix}")
|
||||
return compose_path.with_name(f"{compose_path.name}.bak")
|
||||
|
||||
|
||||
def change_port(
|
||||
folder: Path,
|
||||
service_name: str,
|
||||
port_from: int,
|
||||
port_to: int,
|
||||
) -> bool:
|
||||
compose_data = find_docker_compose(folder)
|
||||
if compose_data is None:
|
||||
return False
|
||||
|
||||
docker_compose, filename = compose_data
|
||||
compose_services = docker_compose.get("services") or {}
|
||||
if not compose_services:
|
||||
return False
|
||||
|
||||
service_config = compose_services.get(service_name)
|
||||
if not isinstance(service_config, dict):
|
||||
return False
|
||||
|
||||
mappings = service_config.get("ports")
|
||||
if not isinstance(mappings, list):
|
||||
return False
|
||||
|
||||
idx = _get_port_index(mappings, port_from)
|
||||
if idx is None:
|
||||
return False
|
||||
|
||||
mappings[idx] = _replace_port_in_mapping(mappings[idx], port_from, port_to)
|
||||
|
||||
compose_path = folder / filename
|
||||
backup_path = _backup_path_for(compose_path)
|
||||
backup_path.write_text(compose_path.read_text())
|
||||
compose_path.write_text(yaml.dump(docker_compose, default_flow_style=False, sort_keys=False))
|
||||
|
||||
# TODO: run docker compose commands
|
||||
return True
|
||||
@@ -2,13 +2,7 @@ from pathlib import Path
|
||||
from typing import TypedDict
|
||||
|
||||
|
||||
class DockerPortBinding(TypedDict):
|
||||
published: int
|
||||
host: str | None
|
||||
|
||||
|
||||
class DockerContainer(TypedDict):
|
||||
name: str
|
||||
ports: list[int]
|
||||
bindings: list[DockerPortBinding]
|
||||
folder: Path
|
||||
@@ -0,0 +1,65 @@
|
||||
from collections.abc import Mapping
|
||||
|
||||
|
||||
def _get_service_port(mapping: str | int | Mapping[str, object]) -> int | None:
|
||||
if isinstance(mapping, int):
|
||||
return mapping
|
||||
|
||||
if isinstance(mapping, Mapping):
|
||||
published = mapping.get("published")
|
||||
if published is None:
|
||||
return None
|
||||
try:
|
||||
return int(published)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
parts = str(mapping).split(":")
|
||||
match parts:
|
||||
case [""]:
|
||||
return None
|
||||
case [port] | [port, _]:
|
||||
try:
|
||||
return int(port)
|
||||
except ValueError:
|
||||
return None
|
||||
case ["localhost" | "127.0.0.1", _, _]:
|
||||
return None
|
||||
case [_, port, _]:
|
||||
try:
|
||||
return int(port)
|
||||
except ValueError:
|
||||
return None
|
||||
case _:
|
||||
return None
|
||||
|
||||
|
||||
def _replace_port_in_mapping(
|
||||
mapping: str | int | Mapping[str, object],
|
||||
port_from: int,
|
||||
port_to: int,
|
||||
) -> str | dict[str, object]:
|
||||
if isinstance(mapping, Mapping):
|
||||
updated = dict(mapping)
|
||||
if _get_service_port(updated) == port_from:
|
||||
updated["published"] = str(port_to)
|
||||
return updated
|
||||
|
||||
parts = str(mapping).split(":")
|
||||
match parts:
|
||||
case [_]:
|
||||
return str(port_to)
|
||||
case [_, container_port]:
|
||||
return f"{port_to}:{container_port}"
|
||||
case [ip, _, container_port]:
|
||||
return f"{ip}:{port_to}:{container_port}"
|
||||
case _:
|
||||
return str(port_to)
|
||||
|
||||
|
||||
def _get_port_index(mappings: list[str | int | Mapping[str, object]], port: int) -> int | None:
|
||||
for i, mapping in enumerate(mappings):
|
||||
if _get_service_port(mapping) == port:
|
||||
return i
|
||||
return None
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import os
|
||||
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.proxy.router import router as proxy_router
|
||||
from api.services.router import router as services_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 integrations.docker import get_all_services
|
||||
from repositories.auth import ensure_auth_indexes, ensure_single_account
|
||||
from repositories.proxy import ensure_proxy_indexes
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
await init_mongodb()
|
||||
await init_redis()
|
||||
await ensure_auth_indexes()
|
||||
await ensure_single_account(
|
||||
username=settings.AUTH_USERNAME,
|
||||
password_hash=hash_password(settings.AUTH_PASSWORD),
|
||||
)
|
||||
await ensure_proxy_indexes()
|
||||
|
||||
home_dir = str(Path.home())
|
||||
app.state.services = get_all_services(home_dir) or []
|
||||
if not app.state.services:
|
||||
warning = (
|
||||
"\n" + "!" * 60 + "\n"
|
||||
" WARNING: No services found in home directory!\n"
|
||||
f" Scanned: {home_dir}\n"
|
||||
+ "!" * 60 + "\n"
|
||||
)
|
||||
print(warning, file=sys.stderr)
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await close_redis()
|
||||
await close_mongodb()
|
||||
|
||||
|
||||
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
|
||||
|
||||
app = FastAPI(
|
||||
title="thePlugAPI",
|
||||
lifespan=lifespan,
|
||||
docs_url="/docs" if DEBUG else None,
|
||||
redoc_url="/redoc" if DEBUG else None,
|
||||
openapi_url="/openapi.json" if DEBUG else None,
|
||||
)
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(proxy_router)
|
||||
app.include_router(services_router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"Hello": "World!"}
|
||||
@@ -0,0 +1,47 @@
|
||||
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",
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ServiceEndpointDocument(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
port: int = Field(..., description="Upstream service port")
|
||||
@@ -4,8 +4,8 @@ from typing import Any
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||
|
||||
from app.core.mongodb import get_database
|
||||
from app.models.auth import AuthUserDocument
|
||||
from core.mongodb import get_database
|
||||
from models.auth import AuthUserDocument
|
||||
|
||||
USERS_COLLECTION_NAME = "auth_users"
|
||||
REVOKED_COLLECTION_NAME = "revoked_tokens"
|
||||
@@ -5,8 +5,10 @@ from typing import Any
|
||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||
from pymongo import ReturnDocument
|
||||
|
||||
from app.core.mongodb import get_database
|
||||
from app.models.proxy import ProxyCreateInput, ProxyDocument, ProxyUpdateInput
|
||||
from core.mongodb import get_database
|
||||
from models.proxy import ProxyDocument, ProxyUpdateInput
|
||||
from models.service import ServiceEndpointDocument
|
||||
from services.mitmproxy.models import ServiceConfig
|
||||
|
||||
COLLECTION_NAME = "proxies"
|
||||
|
||||
@@ -33,19 +35,20 @@ async def ensure_proxy_indexes() -> None:
|
||||
await collection.create_index("proxy_port", unique=True, name="uq_proxy_port")
|
||||
|
||||
|
||||
async def create_proxy(payload: ProxyCreateInput) -> ProxyDocument:
|
||||
async def create_proxy(
|
||||
proxy_port: int,
|
||||
name: str,
|
||||
service: ServiceConfig,
|
||||
code: str,
|
||||
) -> ProxyDocument:
|
||||
now = _utcnow()
|
||||
proxy = ProxyDocument(
|
||||
proxy_port=payload.proxy_port,
|
||||
name=payload.name,
|
||||
code=payload.code,
|
||||
mode=payload.mode,
|
||||
mode_spec=payload.mode_spec,
|
||||
protocol=payload.protocol,
|
||||
listen_host=payload.listen_host,
|
||||
mitm_options=payload.mitm_options,
|
||||
healthcheck=payload.healthcheck,
|
||||
service=payload.service,
|
||||
proxy_port=proxy_port,
|
||||
name=name,
|
||||
code=code,
|
||||
service=ServiceEndpointDocument(
|
||||
port=service["port"],
|
||||
),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -92,22 +95,6 @@ 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,27 @@
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from integrations.docker import DockerContainer
|
||||
from services.mitmproxy.models import ServiceConfig
|
||||
|
||||
|
||||
async def create_proxy(
|
||||
port: int,
|
||||
name: str,
|
||||
service: ServiceConfig,
|
||||
code: str,
|
||||
services: list[DockerContainer]
|
||||
) -> int:
|
||||
"""
|
||||
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("/tmp/theplug/{random}.py".format(random=str(uuid.uuid4())))
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
path.write_text(code, encoding="utf-8")
|
||||
|
||||
return 0
|
||||
@@ -0,0 +1,5 @@
|
||||
from typing import TypedDict
|
||||
|
||||
|
||||
class ServiceConfig(TypedDict):
|
||||
port: int
|
||||
@@ -5,7 +5,7 @@ import shutil
|
||||
import tempfile
|
||||
from typing import Any
|
||||
|
||||
TARGET_RUFF_CODES = ("F821", "F822", "F823")
|
||||
TARGET_RUFF_CODES = ("F821", "F822", "F823", "E999")
|
||||
|
||||
|
||||
class ProxyCodeValidationError(Exception):
|
||||
@@ -1,18 +0,0 @@
|
||||
API_PORT=3671
|
||||
FRONTEND_PORT=5173
|
||||
# Use a random value in real environments.
|
||||
MONGO_INITDB_ROOT_PASSWORD=pass
|
||||
MONGODB_URI=mongodb://theplug:pass@localhost:27017/theplug?authSource=admin
|
||||
MONGODB_DB_NAME=theplug
|
||||
# Use a random value in real environments.
|
||||
JWT_SECRET_KEY=change-me
|
||||
JWT_ALGORITHM=HS256
|
||||
JWT_EXPIRE_MINUTES=720
|
||||
AUTH_USERNAME=admin
|
||||
# Use a random value in real environments.
|
||||
AUTH_PASSWORD=change-me
|
||||
# Optional: override the directory whose immediate child folders are scanned
|
||||
# for docker-compose files. Defaults to the current user's home directory.
|
||||
SERVICE_SCAN_ROOT=
|
||||
HEALTHCHECK_INTERVAL_SECONDS=15
|
||||
HEALTH_HISTORY_TTL_SECONDS=86400
|
||||
@@ -1,3 +0,0 @@
|
||||
from app.api.auth.router import router
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -1,63 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from app.core.security import (
|
||||
InvalidTokenError,
|
||||
authenticate_token,
|
||||
require_authenticated_user,
|
||||
)
|
||||
from app.services.editor import python_lsp
|
||||
|
||||
router = APIRouter(prefix="/proxy/editor/python", tags=["Editor"])
|
||||
|
||||
|
||||
@router.get("/session", dependencies=[Depends(require_authenticated_user)])
|
||||
async def python_editor_session():
|
||||
if python_lsp.lsp_binary() is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="basedpyright-langserver is not installed",
|
||||
)
|
||||
|
||||
session = python_lsp.get_session()
|
||||
return {
|
||||
"root_uri": session.root_uri,
|
||||
"file_uri": session.file_uri,
|
||||
}
|
||||
|
||||
|
||||
async def authenticate_editor_socket(websocket: WebSocket) -> None:
|
||||
token = websocket.query_params.get("token")
|
||||
if token is None:
|
||||
authorization = websocket.headers.get("authorization", "")
|
||||
scheme, _, value = authorization.partition(" ")
|
||||
if scheme.lower() == "bearer" and value:
|
||||
token = value
|
||||
|
||||
if not token:
|
||||
raise InvalidTokenError
|
||||
|
||||
await authenticate_token(token)
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def python_editor_socket(websocket: WebSocket):
|
||||
try:
|
||||
await authenticate_editor_socket(websocket)
|
||||
except InvalidTokenError:
|
||||
await websocket.close(code=4401)
|
||||
return
|
||||
|
||||
if python_lsp.lsp_binary() is None:
|
||||
await websocket.close(code=1011, reason="basedpyright unavailable")
|
||||
return
|
||||
|
||||
session = python_lsp.get_session()
|
||||
await websocket.accept()
|
||||
|
||||
try:
|
||||
await python_lsp.bridge(websocket, session)
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
except RuntimeError:
|
||||
await websocket.close(code=1011, reason="language server failed")
|
||||
@@ -1,504 +0,0 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
|
||||
from pymongo.errors import DuplicateKeyError
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from app.api.proxy.socket_utils import (
|
||||
authenticate_proxy_socket,
|
||||
proxy_log_path,
|
||||
read_log_snapshot,
|
||||
serialize_health_snapshot,
|
||||
snapshot_log_state,
|
||||
stream_proxy_logs,
|
||||
watch_socket_disconnect,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.core.security import InvalidTokenError, require_authenticated_user
|
||||
from app.integrations.docker import get_all_services_in_dir
|
||||
from app.integrations.docker.discovery import service_scan_root
|
||||
from app.models.health import HealthHistoryResponse
|
||||
from app.models.proxy import ProxyCreateInput, ProxyUpdateInput
|
||||
from app.models.template import ProxyTemplateCreateInput
|
||||
from app.repositories.health import (
|
||||
delete_health_history,
|
||||
get_latest_health_sample,
|
||||
list_health_history,
|
||||
record_health_sample,
|
||||
)
|
||||
from app.repositories.proxy import (
|
||||
create_proxy,
|
||||
delete_proxy,
|
||||
get_proxy_by_port,
|
||||
update_proxy,
|
||||
update_proxy_runtime,
|
||||
)
|
||||
from app.repositories.proxy import (
|
||||
list_proxies as list_proxy_documents,
|
||||
)
|
||||
from app.repositories.templates import (
|
||||
create_custom_template,
|
||||
delete_custom_template,
|
||||
list_custom_templates,
|
||||
)
|
||||
from app.services.mitmproxy.health import check_proxy
|
||||
from app.services.mitmproxy.health_monitor import health_monitor
|
||||
from app.services.mitmproxy.manager import MitmdumpError, manager
|
||||
from app.services.mitmproxy.options import (
|
||||
MitmOptionError,
|
||||
load_options,
|
||||
public_options,
|
||||
validate_options,
|
||||
)
|
||||
from app.services.mitmproxy.runtime_validation import (
|
||||
ReservedProxyPortError,
|
||||
backend_port,
|
||||
ensure_proxy_port_not_reserved,
|
||||
)
|
||||
from app.services.mitmproxy.templates import file_template_ids, list_file_templates
|
||||
from app.services.mitmproxy.validator import (
|
||||
ProxyCodeValidationError,
|
||||
validate_proxy_code,
|
||||
)
|
||||
|
||||
SUPPORTED_MODES = {
|
||||
"regular",
|
||||
"local",
|
||||
"wireguard",
|
||||
"reverse",
|
||||
"transparent",
|
||||
"tun",
|
||||
"upstream",
|
||||
"socks5",
|
||||
"dns",
|
||||
}
|
||||
SUPPORTED_PROTOCOLS = {
|
||||
"http",
|
||||
"https",
|
||||
"tcp",
|
||||
"tls",
|
||||
"udp",
|
||||
"dtls",
|
||||
"dns",
|
||||
"http3",
|
||||
"quic",
|
||||
}
|
||||
DEFAULT_HEALTH_HISTORY_LIMIT = 40
|
||||
MAX_HEALTH_HISTORY_LIMIT = 200
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/proxy",
|
||||
tags=["Proxy"],
|
||||
)
|
||||
auth_dependencies = [Depends(require_authenticated_user)]
|
||||
|
||||
|
||||
async def _validate_code_or_raise(code: str) -> None:
|
||||
try:
|
||||
await validate_proxy_code(code)
|
||||
except ProxyCodeValidationError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=error.to_http_detail(),
|
||||
) from error
|
||||
|
||||
|
||||
def validate_mode(mode: str) -> None:
|
||||
if mode not in SUPPORTED_MODES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"Unsupported proxy mode: {mode}",
|
||||
)
|
||||
|
||||
|
||||
def validate_mode_spec(mode_spec: str | None) -> None:
|
||||
if mode_spec is None:
|
||||
return
|
||||
mode = mode_spec.split(":", 1)[0].split("@", 1)[0]
|
||||
validate_mode(mode)
|
||||
|
||||
|
||||
def validate_protocol(protocol: str) -> None:
|
||||
if protocol not in SUPPORTED_PROTOCOLS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"Unsupported reverse proxy protocol: {protocol}",
|
||||
)
|
||||
|
||||
|
||||
async def validate_mitm_options(options: dict[str, object]) -> None:
|
||||
try:
|
||||
await validate_options(options)
|
||||
except MitmOptionError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(error),
|
||||
) from error
|
||||
|
||||
|
||||
def validate_proxy_port(proxy_port: int) -> None:
|
||||
try:
|
||||
ensure_proxy_port_not_reserved(proxy_port)
|
||||
except ReservedProxyPortError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(error),
|
||||
) from error
|
||||
|
||||
|
||||
async def get_proxy_or_404(proxy_port: int):
|
||||
proxy = await get_proxy_by_port(proxy_port)
|
||||
if proxy is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Proxy not found",
|
||||
)
|
||||
return proxy
|
||||
|
||||
|
||||
@router.get("", dependencies=auth_dependencies)
|
||||
async def list_proxies_handler():
|
||||
return await list_proxy_documents()
|
||||
|
||||
|
||||
@router.get("/services", dependencies=auth_dependencies)
|
||||
async def list_services(scan_root: str | None = None):
|
||||
_ = settings
|
||||
root = Path(scan_root).expanduser() if scan_root else service_scan_root()
|
||||
services = get_all_services_in_dir(root)
|
||||
return {
|
||||
"scan_root": str(root),
|
||||
"services": services,
|
||||
"backend_port": backend_port(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/mitmproxy/options", dependencies=auth_dependencies)
|
||||
async def list_mitmproxy_options():
|
||||
return public_options(await load_options())
|
||||
|
||||
|
||||
@router.get("/mitmproxy/templates", dependencies=auth_dependencies)
|
||||
async def list_mitmproxy_templates():
|
||||
return [*list_file_templates(), *await list_custom_templates()]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/mitmproxy/templates",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=auth_dependencies,
|
||||
)
|
||||
async def create_mitmproxy_template(payload: ProxyTemplateCreateInput):
|
||||
await _validate_code_or_raise(payload.code)
|
||||
if payload.id in file_template_ids():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Template already exists: {payload.id}",
|
||||
)
|
||||
try:
|
||||
return await create_custom_template(payload)
|
||||
except DuplicateKeyError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Template already exists: {payload.id}",
|
||||
) from error
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/mitmproxy/templates/{template_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=auth_dependencies,
|
||||
)
|
||||
async def delete_mitmproxy_template(template_id: str):
|
||||
if template_id in file_template_ids():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Built-in templates cannot be deleted",
|
||||
)
|
||||
|
||||
deleted = await delete_custom_template(template_id)
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Template not found",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{proxy_port}", dependencies=auth_dependencies)
|
||||
async def get_proxy(proxy_port: int):
|
||||
return await get_proxy_or_404(proxy_port)
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED, dependencies=auth_dependencies)
|
||||
async def create_proxy_handler(payload: ProxyCreateInput):
|
||||
validate_proxy_port(payload.proxy_port)
|
||||
existing_proxy = await get_proxy_by_port(payload.proxy_port)
|
||||
if existing_proxy is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Proxy already exists on port {payload.proxy_port}",
|
||||
)
|
||||
|
||||
await _validate_code_or_raise(payload.code)
|
||||
validate_mode(payload.mode)
|
||||
validate_mode_spec(payload.mode_spec)
|
||||
validate_protocol(payload.protocol)
|
||||
await validate_mitm_options(payload.mitm_options)
|
||||
|
||||
return await create_proxy(payload)
|
||||
|
||||
|
||||
@router.put("/{proxy_port}", dependencies=auth_dependencies)
|
||||
async def edit_proxy(proxy_port: int, payload: ProxyUpdateInput):
|
||||
if payload.code is not None:
|
||||
await _validate_code_or_raise(payload.code)
|
||||
if payload.mode is not None:
|
||||
validate_mode(payload.mode)
|
||||
if "mode_spec" in payload.model_fields_set:
|
||||
validate_mode_spec(payload.mode_spec)
|
||||
if payload.protocol is not None:
|
||||
validate_protocol(payload.protocol)
|
||||
if payload.mitm_options is not None:
|
||||
await validate_mitm_options(payload.mitm_options)
|
||||
|
||||
updated_proxy = await update_proxy(proxy_port, payload)
|
||||
if updated_proxy is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Proxy not found",
|
||||
)
|
||||
return updated_proxy
|
||||
|
||||
|
||||
@router.post("/{proxy_port}/start", dependencies=auth_dependencies)
|
||||
async def start_proxy(proxy_port: int):
|
||||
proxy = await get_proxy_or_404(proxy_port)
|
||||
|
||||
try:
|
||||
runtime = await manager.start(proxy)
|
||||
except MitmdumpError as error:
|
||||
updated = await update_proxy_runtime(
|
||||
proxy_port,
|
||||
{"status": "error", "last_error": str(error)},
|
||||
)
|
||||
return updated or proxy
|
||||
|
||||
return await update_proxy_runtime(proxy_port, runtime)
|
||||
|
||||
|
||||
@router.post("/{proxy_port}/stop", dependencies=auth_dependencies)
|
||||
async def stop_proxy(proxy_port: int):
|
||||
proxy = await get_proxy_or_404(proxy_port)
|
||||
runtime = await manager.stop(proxy)
|
||||
return await update_proxy_runtime(proxy_port, runtime)
|
||||
|
||||
|
||||
@router.post("/{proxy_port}/restart", dependencies=auth_dependencies)
|
||||
async def restart_proxy(proxy_port: int):
|
||||
proxy = await get_proxy_or_404(proxy_port)
|
||||
stopped = await manager.stop(proxy)
|
||||
proxy = await update_proxy_runtime(proxy_port, stopped) or proxy
|
||||
try:
|
||||
runtime = await manager.start(proxy)
|
||||
except MitmdumpError as error:
|
||||
updated = await update_proxy_runtime(
|
||||
proxy_port,
|
||||
{"status": "error", "last_error": str(error)},
|
||||
)
|
||||
return updated or proxy
|
||||
return await update_proxy_runtime(proxy_port, runtime)
|
||||
|
||||
|
||||
@router.get("/{proxy_port}/status", dependencies=auth_dependencies)
|
||||
async def proxy_status(proxy_port: int):
|
||||
proxy = await get_proxy_or_404(proxy_port)
|
||||
runtime_status = await manager.status(proxy)
|
||||
if runtime_status != proxy.status:
|
||||
values: dict[str, object] = {"status": runtime_status}
|
||||
if runtime_status != "running":
|
||||
values["pid"] = None
|
||||
proxy = await update_proxy_runtime(proxy_port, values) or proxy
|
||||
return {"proxy_port": proxy.proxy_port, "status": runtime_status, "pid": proxy.pid}
|
||||
|
||||
|
||||
@router.get("/{proxy_port}/health", dependencies=auth_dependencies)
|
||||
async def proxy_health(proxy_port: int):
|
||||
proxy = await get_proxy_or_404(proxy_port)
|
||||
snapshot = await check_proxy(proxy)
|
||||
if proxy.healthcheck.enabled:
|
||||
await record_health_sample(snapshot)
|
||||
await health_monitor.publish(proxy.proxy_port, snapshot)
|
||||
return serialize_health_snapshot(snapshot)
|
||||
|
||||
|
||||
@router.get("/{proxy_port}/health/history", dependencies=auth_dependencies)
|
||||
async def proxy_health_history(
|
||||
proxy_port: int,
|
||||
limit: int = DEFAULT_HEALTH_HISTORY_LIMIT,
|
||||
):
|
||||
proxy = await get_proxy_or_404(proxy_port)
|
||||
limit = max(1, min(limit, MAX_HEALTH_HISTORY_LIMIT))
|
||||
latest = await get_latest_health_sample(proxy.proxy_port)
|
||||
history = await list_health_history(proxy.proxy_port, limit=limit)
|
||||
response = HealthHistoryResponse(
|
||||
proxy_port=proxy.proxy_port,
|
||||
latest=latest,
|
||||
history=history,
|
||||
)
|
||||
return response.model_dump(mode="json")
|
||||
|
||||
|
||||
@router.get("/{proxy_port}/logs", dependencies=auth_dependencies)
|
||||
async def proxy_logs(proxy_port: int):
|
||||
proxy = await get_proxy_or_404(proxy_port)
|
||||
path = proxy_log_path(proxy)
|
||||
|
||||
return {
|
||||
"proxy_port": proxy.proxy_port,
|
||||
"log": await read_log_snapshot(path),
|
||||
}
|
||||
|
||||
|
||||
@router.websocket("/{proxy_port}/logs/ws")
|
||||
async def proxy_logs_socket(websocket: WebSocket, proxy_port: int):
|
||||
disconnect_task: asyncio.Task[None] | None = None
|
||||
stream_task: asyncio.Task[None] | None = None
|
||||
|
||||
try:
|
||||
try:
|
||||
await authenticate_proxy_socket(websocket)
|
||||
except InvalidTokenError:
|
||||
await websocket.close(code=4401)
|
||||
return
|
||||
|
||||
proxy = await get_proxy_by_port(proxy_port)
|
||||
if proxy is None:
|
||||
await websocket.close(code=4404)
|
||||
return
|
||||
|
||||
path = proxy_log_path(proxy)
|
||||
initial_snapshot, offset, marker = await snapshot_log_state(path)
|
||||
await websocket.accept()
|
||||
await websocket.send_json({"type": "snapshot", "log": initial_snapshot})
|
||||
|
||||
disconnect_task = asyncio.create_task(watch_socket_disconnect(websocket))
|
||||
stream_task = asyncio.create_task(
|
||||
stream_proxy_logs(websocket, path, offset=offset, marker=marker)
|
||||
)
|
||||
|
||||
done, pending = await asyncio.wait(
|
||||
{disconnect_task, stream_task},
|
||||
return_when=asyncio.FIRST_EXCEPTION,
|
||||
)
|
||||
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
if pending:
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
|
||||
for task in done:
|
||||
try:
|
||||
task.result()
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
except RuntimeError:
|
||||
await websocket.close(code=1011, reason="log stream failed")
|
||||
return
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
finally:
|
||||
for task in (disconnect_task, stream_task):
|
||||
if task is None or task.done():
|
||||
continue
|
||||
task.cancel()
|
||||
|
||||
|
||||
@router.websocket("/{proxy_port}/health/ws")
|
||||
async def proxy_health_socket(websocket: WebSocket, proxy_port: int):
|
||||
disconnect_task: asyncio.Task[None] | None = None
|
||||
queue_task: asyncio.Task[None] | None = None
|
||||
queue = None
|
||||
|
||||
async def forward_health() -> None:
|
||||
assert queue is not None
|
||||
while True:
|
||||
snapshot = await queue.get()
|
||||
await websocket.send_json(serialize_health_snapshot(snapshot))
|
||||
|
||||
try:
|
||||
try:
|
||||
await authenticate_proxy_socket(websocket)
|
||||
except InvalidTokenError:
|
||||
await websocket.close(code=4401)
|
||||
return
|
||||
|
||||
proxy = await get_proxy_by_port(proxy_port)
|
||||
if proxy is None:
|
||||
await websocket.close(code=4404)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
latest = await get_latest_health_sample(proxy.proxy_port)
|
||||
if latest is None:
|
||||
latest = await check_proxy(proxy)
|
||||
await websocket.send_json(serialize_health_snapshot(latest))
|
||||
|
||||
queue = health_monitor.subscribe(proxy.proxy_port)
|
||||
disconnect_task = asyncio.create_task(watch_socket_disconnect(websocket))
|
||||
queue_task = asyncio.create_task(forward_health())
|
||||
|
||||
done, pending = await asyncio.wait(
|
||||
{disconnect_task, queue_task},
|
||||
return_when=asyncio.FIRST_EXCEPTION,
|
||||
)
|
||||
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
if pending:
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
|
||||
for task in done:
|
||||
try:
|
||||
task.result()
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
except RuntimeError:
|
||||
await websocket.close(code=1011, reason="health stream failed")
|
||||
return
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
finally:
|
||||
if queue is not None:
|
||||
health_monitor.unsubscribe(proxy_port, queue)
|
||||
for task in (disconnect_task, queue_task):
|
||||
if task is None or task.done():
|
||||
continue
|
||||
task.cancel()
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{proxy_port}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=auth_dependencies,
|
||||
)
|
||||
async def remove_proxy(proxy_port: int):
|
||||
proxy = await get_proxy_by_port(proxy_port)
|
||||
if proxy is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Proxy not found",
|
||||
)
|
||||
await manager.stop(proxy)
|
||||
manager.cleanup_runtime_artifacts(proxy)
|
||||
await delete_health_history(proxy.proxy_port)
|
||||
deleted = await delete_proxy(proxy_port)
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Proxy not found",
|
||||
)
|
||||
@@ -1,127 +0,0 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from app.core.security import InvalidTokenError, authenticate_token
|
||||
from app.models.health import HealthSnapshot
|
||||
from app.services.mitmproxy.manager import manager
|
||||
|
||||
|
||||
async def authenticate_proxy_socket(websocket: WebSocket) -> None:
|
||||
token = websocket.query_params.get("token")
|
||||
if token is None:
|
||||
authorization = websocket.headers.get("authorization", "")
|
||||
scheme, _, value = authorization.partition(" ")
|
||||
if scheme.lower() == "bearer" and value:
|
||||
token = value
|
||||
|
||||
if not token:
|
||||
raise InvalidTokenError
|
||||
|
||||
await authenticate_token(token)
|
||||
|
||||
|
||||
def proxy_log_path(proxy) -> Path:
|
||||
if proxy.log_path:
|
||||
return Path(proxy.log_path)
|
||||
return manager.log_path_for(proxy)
|
||||
|
||||
|
||||
def serialize_health_snapshot(
|
||||
snapshot: HealthSnapshot | None,
|
||||
) -> dict[str, object] | None:
|
||||
if snapshot is None:
|
||||
return None
|
||||
return snapshot.model_dump(mode="json")
|
||||
|
||||
|
||||
async def read_log_snapshot(path: Path, limit: int = 8000) -> str:
|
||||
snapshot, _offset, _marker = await snapshot_log_state(path, limit=limit)
|
||||
return snapshot
|
||||
|
||||
|
||||
async def snapshot_log_state(
|
||||
path: Path,
|
||||
limit: int = 8000,
|
||||
marker_width: int = 64,
|
||||
) -> tuple[str, int, bytes]:
|
||||
if not await asyncio.to_thread(path.is_file):
|
||||
return "", 0, b""
|
||||
|
||||
data = await asyncio.to_thread(path.read_bytes)
|
||||
size = len(data)
|
||||
marker = data[max(0, size - marker_width) : size]
|
||||
snapshot = data[-limit:].decode("utf-8", errors="replace")
|
||||
return snapshot, size, marker
|
||||
|
||||
|
||||
def _read_log_marker_bytes(path: Path, offset: int, width: int) -> bytes:
|
||||
if offset <= 0 or not path.is_file():
|
||||
return b""
|
||||
|
||||
start = max(0, offset - width)
|
||||
with path.open("rb") as handle:
|
||||
handle.seek(start)
|
||||
return handle.read(offset - start)
|
||||
|
||||
|
||||
async def read_log_marker(path: Path, offset: int, width: int = 64) -> bytes:
|
||||
return await asyncio.to_thread(_read_log_marker_bytes, path, offset, width)
|
||||
|
||||
|
||||
async def watch_socket_disconnect(websocket: WebSocket) -> None:
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
|
||||
|
||||
async def stream_proxy_logs(
|
||||
websocket: WebSocket,
|
||||
path: Path,
|
||||
*,
|
||||
offset: int,
|
||||
marker: bytes,
|
||||
) -> None:
|
||||
while True:
|
||||
if not await asyncio.to_thread(path.exists):
|
||||
await asyncio.sleep(0.4)
|
||||
continue
|
||||
|
||||
try:
|
||||
size = await asyncio.to_thread(lambda: path.stat().st_size)
|
||||
except OSError:
|
||||
await asyncio.sleep(0.4)
|
||||
continue
|
||||
|
||||
if marker and size >= offset and await read_log_marker(path, offset) != marker:
|
||||
snapshot, offset, marker = await snapshot_log_state(path)
|
||||
await websocket.send_json({"type": "snapshot", "log": snapshot})
|
||||
await asyncio.sleep(0.4)
|
||||
continue
|
||||
|
||||
if size < offset:
|
||||
snapshot, offset, marker = await snapshot_log_state(path)
|
||||
await websocket.send_json({"type": "snapshot", "log": snapshot})
|
||||
await asyncio.sleep(0.4)
|
||||
continue
|
||||
|
||||
if size > offset:
|
||||
|
||||
def read_log_chunk(
|
||||
chunk_offset: int = offset,
|
||||
chunk_size: int = size,
|
||||
) -> str:
|
||||
with path.open("rb") as handle:
|
||||
handle.seek(chunk_offset)
|
||||
return handle.read(chunk_size - chunk_offset).decode(
|
||||
"utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
|
||||
chunk = await asyncio.to_thread(read_log_chunk)
|
||||
offset = size
|
||||
marker = await read_log_marker(path, offset)
|
||||
if chunk:
|
||||
await websocket.send_json({"type": "append", "log": chunk})
|
||||
|
||||
await asyncio.sleep(0.4)
|
||||
@@ -1,15 +0,0 @@
|
||||
from app.integrations.docker.compose import (
|
||||
find_docker_compose,
|
||||
get_all_services_in_dir,
|
||||
get_services,
|
||||
)
|
||||
from app.integrations.docker.editor import change_port
|
||||
from app.integrations.docker.models import DockerContainer
|
||||
|
||||
__all__ = [
|
||||
"DockerContainer",
|
||||
"find_docker_compose",
|
||||
"get_services",
|
||||
"get_all_services_in_dir",
|
||||
"change_port",
|
||||
]
|
||||
@@ -1,135 +0,0 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from app.integrations.docker.models import DockerContainer, DockerPortBinding
|
||||
from app.integrations.docker.ports import _get_service_binding
|
||||
|
||||
COMPOSE_NAMES = (
|
||||
"compose.yml",
|
||||
"compose.yaml",
|
||||
"docker-compose.yml",
|
||||
"docker-compose.yaml",
|
||||
)
|
||||
|
||||
type ComposeDocument = dict[str, object]
|
||||
|
||||
|
||||
def _get_visible_folders(directory: Path) -> list[Path]:
|
||||
try:
|
||||
entries = list(Path(directory).iterdir())
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
folders: list[Path] = []
|
||||
for entry in entries:
|
||||
if entry.name.startswith("."):
|
||||
continue
|
||||
try:
|
||||
if entry.is_dir():
|
||||
folders.append(entry)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
return folders
|
||||
|
||||
|
||||
def find_docker_compose(path: Path) -> tuple[ComposeDocument, Path] | None:
|
||||
try:
|
||||
if not path or not path.is_dir():
|
||||
return None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
for name in COMPOSE_NAMES:
|
||||
file = path / name
|
||||
try:
|
||||
if file.is_file():
|
||||
content = yaml.safe_load(file.read_text()) or {}
|
||||
return content, file
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_services(folder: Path) -> list[DockerContainer] | None:
|
||||
services: list[DockerContainer] = []
|
||||
compose_data = find_docker_compose(folder)
|
||||
if compose_data is None:
|
||||
return None
|
||||
|
||||
docker_compose, _ = compose_data
|
||||
compose_services = docker_compose.get("services") or {}
|
||||
if not isinstance(compose_services, dict) or not compose_services:
|
||||
return None
|
||||
|
||||
for service_name, config in compose_services.items():
|
||||
mappings = config.get("ports") if isinstance(config, dict) else None
|
||||
if not isinstance(mappings, list) or not mappings:
|
||||
continue
|
||||
|
||||
bindings: list[DockerPortBinding] = [
|
||||
{"published": published, "host": host}
|
||||
for published, host in (
|
||||
_get_service_binding(mapping) for mapping in mappings
|
||||
)
|
||||
if published is not None
|
||||
]
|
||||
if not bindings:
|
||||
continue
|
||||
|
||||
services.append(
|
||||
{
|
||||
"name": service_name,
|
||||
"ports": [binding["published"] for binding in bindings],
|
||||
"bindings": bindings,
|
||||
"folder": folder,
|
||||
}
|
||||
)
|
||||
|
||||
return services
|
||||
|
||||
|
||||
def get_all_services_in_dir(
|
||||
directory: str | Path,
|
||||
) -> list[DockerContainer]:
|
||||
path = Path(directory)
|
||||
try:
|
||||
if not path.is_dir():
|
||||
return []
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
services: list[DockerContainer] = []
|
||||
for sub_folder in _get_visible_folders(path):
|
||||
sub_services = get_services(sub_folder)
|
||||
if sub_services:
|
||||
services.extend(sub_services)
|
||||
|
||||
return services
|
||||
|
||||
|
||||
async def run_docker_command(
|
||||
file: Path,
|
||||
command: list[str],
|
||||
) -> dict[str, str] | None:
|
||||
if not command:
|
||||
return None
|
||||
|
||||
full_command = ["docker", "compose", "-f", f"{file}", *command]
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*full_command,
|
||||
cwd=str(file.parent),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode == 0:
|
||||
return {"status": "success", "output": stdout.decode()}
|
||||
else:
|
||||
return {"status": "error", "reason": stderr.decode()}
|
||||
@@ -1,10 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def service_scan_root() -> Path:
|
||||
configured = settings.SERVICE_SCAN_ROOT
|
||||
if configured:
|
||||
return Path(configured).expanduser()
|
||||
return Path.home()
|
||||
@@ -1,85 +0,0 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from app.integrations.docker.compose import (
|
||||
ComposeDocument,
|
||||
find_docker_compose,
|
||||
run_docker_command,
|
||||
)
|
||||
from app.integrations.docker.ports import _get_port_index, _replace_port_in_mapping
|
||||
|
||||
|
||||
def _render_compose(document: ComposeDocument) -> str:
|
||||
return yaml.dump(document, default_flow_style=False, sort_keys=False)
|
||||
|
||||
|
||||
def _write_compose(path: Path, content: str) -> None:
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def _read_compose_text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
async def change_port(
|
||||
service_name: str,
|
||||
folder: Path,
|
||||
port_from: int,
|
||||
port_to: int,
|
||||
host_to: str | None = None,
|
||||
rewrite_host: bool = False,
|
||||
) -> bool:
|
||||
compose_data = find_docker_compose(folder)
|
||||
if compose_data is None:
|
||||
return False
|
||||
|
||||
docker_compose, compose_path = compose_data
|
||||
compose_services = docker_compose.get("services") or {}
|
||||
if not isinstance(compose_services, dict) or not compose_services:
|
||||
return False
|
||||
|
||||
service_config = compose_services.get(service_name)
|
||||
if not isinstance(service_config, dict):
|
||||
return False
|
||||
|
||||
mappings = service_config.get("ports")
|
||||
if not isinstance(mappings, list):
|
||||
return False
|
||||
|
||||
idx = _get_port_index(mappings, port_from)
|
||||
if idx is None:
|
||||
return False
|
||||
|
||||
mappings[idx] = _replace_port_in_mapping(
|
||||
mappings[idx],
|
||||
port_from,
|
||||
port_to,
|
||||
host_to=host_to,
|
||||
rewrite_host=rewrite_host,
|
||||
)
|
||||
|
||||
original_text = await asyncio.to_thread(_read_compose_text, compose_path)
|
||||
updated_text = _render_compose(docker_compose)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_write_compose, compose_path, updated_text)
|
||||
result = await run_docker_command(
|
||||
compose_path,
|
||||
["up", "-d", service_name],
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
await asyncio.to_thread(_write_compose, compose_path, original_text)
|
||||
except OSError as restore_error:
|
||||
raise RuntimeError(
|
||||
f"Failed to restore original compose file: {compose_path}"
|
||||
) from restore_error
|
||||
return False
|
||||
|
||||
if result is None or result.get("status") != "success":
|
||||
await asyncio.to_thread(_write_compose, compose_path, original_text)
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -1,102 +0,0 @@
|
||||
import socket
|
||||
from collections.abc import Mapping
|
||||
|
||||
|
||||
def get_random_port() -> int:
|
||||
# Binding to port 0 asks the OS for an available ephemeral port.
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("", 0))
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
port = s.getsockname()[1]
|
||||
return port
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Could not find an available port: {e}") from e
|
||||
|
||||
|
||||
def _get_service_binding(
|
||||
mapping: str | int | Mapping[str, object],
|
||||
) -> tuple[int | None, str | None]:
|
||||
if isinstance(mapping, int):
|
||||
return mapping, None
|
||||
|
||||
if isinstance(mapping, Mapping):
|
||||
published = mapping.get("published")
|
||||
if published is None:
|
||||
return None, None
|
||||
if not isinstance(published, (str, int)):
|
||||
return None, None
|
||||
try:
|
||||
port = int(published)
|
||||
except ValueError:
|
||||
return None, None
|
||||
host = mapping.get("host_ip")
|
||||
return port, str(host) if host not in {None, ""} else None
|
||||
|
||||
parts = str(mapping).split(":")
|
||||
match parts:
|
||||
case [""]:
|
||||
return None, None
|
||||
case [port] | [port, _]:
|
||||
try:
|
||||
return int(port), None
|
||||
except ValueError:
|
||||
return None, None
|
||||
case [host, port, _]:
|
||||
try:
|
||||
return int(port), host or None
|
||||
except ValueError:
|
||||
return None, None
|
||||
case _:
|
||||
return None, None
|
||||
|
||||
|
||||
def _get_service_port(mapping: str | int | Mapping[str, object]) -> int | None:
|
||||
port, _host = _get_service_binding(mapping)
|
||||
return port
|
||||
|
||||
|
||||
def _replace_port_in_mapping(
|
||||
mapping: str | int | Mapping[str, object],
|
||||
port_from: int,
|
||||
port_to: int,
|
||||
host_to: str | None = None,
|
||||
rewrite_host: bool = False,
|
||||
) -> str | dict[str, object]:
|
||||
if isinstance(mapping, Mapping):
|
||||
updated = dict(mapping)
|
||||
if _get_service_port(updated) == port_from:
|
||||
updated["published"] = str(port_to)
|
||||
if rewrite_host:
|
||||
if host_to is None:
|
||||
updated.pop("host_ip", None)
|
||||
else:
|
||||
updated["host_ip"] = host_to
|
||||
return updated
|
||||
|
||||
parts = str(mapping).split(":")
|
||||
match parts:
|
||||
case [_]:
|
||||
return str(port_to)
|
||||
case [_, container_port]:
|
||||
if rewrite_host and host_to:
|
||||
return f"{host_to}:{port_to}:{container_port}"
|
||||
return f"{port_to}:{container_port}"
|
||||
case [ip, _, container_port]:
|
||||
if rewrite_host:
|
||||
if host_to:
|
||||
return f"{host_to}:{port_to}:{container_port}"
|
||||
return f"{port_to}:{container_port}"
|
||||
return f"{ip}:{port_to}:{container_port}"
|
||||
case _:
|
||||
return str(port_to)
|
||||
|
||||
|
||||
def _get_port_index(
|
||||
mappings: list[str | int | Mapping[str, object]],
|
||||
port: int,
|
||||
) -> int | None:
|
||||
for i, mapping in enumerate(mappings):
|
||||
if _get_service_port(mapping) == port:
|
||||
return i
|
||||
return None
|
||||
@@ -1,83 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.api.auth.router import router as auth_router
|
||||
from app.api.editor.router import router as editor_router
|
||||
from app.api.proxy.router import router as proxy_router
|
||||
from app.core.config import settings
|
||||
from app.core.mongodb import close_mongodb, init_mongodb
|
||||
from app.core.security import hash_password
|
||||
from app.integrations.docker import get_all_services_in_dir
|
||||
from app.integrations.docker.discovery import service_scan_root
|
||||
from app.repositories.auth import ensure_auth_indexes, ensure_single_account
|
||||
from app.repositories.health import ensure_health_indexes
|
||||
from app.repositories.proxy import ensure_proxy_indexes
|
||||
from app.repositories.templates import ensure_template_indexes
|
||||
from app.services.mitmproxy.health_monitor import health_monitor
|
||||
from app.services.mitmproxy.recovery import reconcile_runtime
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
await init_mongodb()
|
||||
await ensure_auth_indexes()
|
||||
await ensure_single_account(
|
||||
username=settings.AUTH_USERNAME,
|
||||
password_hash=hash_password(settings.AUTH_PASSWORD),
|
||||
)
|
||||
await ensure_proxy_indexes()
|
||||
await ensure_template_indexes()
|
||||
await ensure_health_indexes()
|
||||
recovered = await reconcile_runtime()
|
||||
if recovered:
|
||||
recovered_count = len(recovered)
|
||||
error_count = sum(
|
||||
1
|
||||
for _proxy_port, values in recovered
|
||||
if values.get("status") == "error"
|
||||
)
|
||||
print(
|
||||
f"[thePlug] Reconciled {recovered_count} persisted proxy runtimes"
|
||||
f" ({error_count} with errors).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
scan_root = service_scan_root()
|
||||
services = get_all_services_in_dir(scan_root) or []
|
||||
if not services:
|
||||
warning = (
|
||||
"\n" + "!" * 60 + "\n"
|
||||
" WARNING: No services found in scan root!\n"
|
||||
f" Scanned: {scan_root}\n" + "!" * 60 + "\n"
|
||||
)
|
||||
print(warning, file=sys.stderr)
|
||||
|
||||
try:
|
||||
await health_monitor.start()
|
||||
yield
|
||||
finally:
|
||||
await health_monitor.stop()
|
||||
await close_mongodb()
|
||||
|
||||
|
||||
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
|
||||
|
||||
app = FastAPI(
|
||||
title="thePlugAPI",
|
||||
lifespan=lifespan,
|
||||
docs_url="/docs" if DEBUG else None,
|
||||
redoc_url="/redoc" if DEBUG else None,
|
||||
openapi_url="/openapi.json" if DEBUG else None,
|
||||
)
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(editor_router)
|
||||
app.include_router(proxy_router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"Hello": "World!"}
|
||||
@@ -1,41 +0,0 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class HealthCheckEntry(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
target: str = Field(..., description="Logical target name")
|
||||
host: str = Field(..., description="Resolved host checked at runtime")
|
||||
port: int = Field(..., description="Resolved port checked at runtime")
|
||||
protocol: str = Field(..., description="Protocol used for the check")
|
||||
healthy: bool = Field(..., description="Whether the check succeeded")
|
||||
error: str | None = Field(default=None, description="Optional failure reason")
|
||||
duration_ms: float | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Measured response latency in milliseconds when benchmarking "
|
||||
"is enabled"
|
||||
),
|
||||
)
|
||||
details: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class HealthSnapshot(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
proxy_port: int = Field(..., description="Proxy port this snapshot belongs to")
|
||||
timestamp: datetime = Field(..., description="When the snapshot was produced")
|
||||
enabled: bool = Field(..., description="Whether healthchecks are enabled")
|
||||
overall: str = Field(..., description="Overall health status")
|
||||
checks: list[HealthCheckEntry] = Field(default_factory=list)
|
||||
|
||||
|
||||
class HealthHistoryResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
proxy_port: int
|
||||
latest: HealthSnapshot | None = None
|
||||
history: list[HealthSnapshot] = Field(default_factory=list)
|
||||
@@ -1,120 +0,0 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.service import ServiceEndpointDocument
|
||||
|
||||
|
||||
class HealthCheckDocument(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
enabled: bool = Field(default=True, description="Enable active health checks")
|
||||
protocol: str | None = Field(
|
||||
default=None,
|
||||
description="Override protocol used by the health check",
|
||||
)
|
||||
path: str = Field(default="/", description="HTTP path to check")
|
||||
method: str = Field(default="GET", description="HTTP method to use")
|
||||
expected_status: int | None = Field(
|
||||
default=None,
|
||||
description="Expected HTTP status code",
|
||||
)
|
||||
udp_payload: str = Field(default="", description="UDP payload to send")
|
||||
expect_response: bool = Field(
|
||||
default=True,
|
||||
description="Wait for a response when the protocol supports it",
|
||||
)
|
||||
verify_tls: bool = Field(
|
||||
default=False,
|
||||
description="Verify TLS certificates during health checks",
|
||||
)
|
||||
benchmark_enabled: bool = Field(
|
||||
default=False,
|
||||
description="Measure response latency during health checks when supported",
|
||||
)
|
||||
timeout_seconds: float = Field(
|
||||
default=2.0,
|
||||
gt=0,
|
||||
le=30,
|
||||
description="Per-target health check timeout",
|
||||
)
|
||||
|
||||
|
||||
class ProxyDocument(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
proxy_port: int = Field(..., description="Port exposed by the proxy")
|
||||
name: str = Field(..., description="Human readable proxy name")
|
||||
code: str = Field(..., description="Proxy source or identifier")
|
||||
mode: str = Field(default="reverse", description="mitmproxy proxy mode")
|
||||
mode_spec: str | None = Field(
|
||||
default=None,
|
||||
description="Raw mitmproxy mode specification. Overrides mode/protocol target.",
|
||||
)
|
||||
protocol: str = Field(default="http", description="mitmproxy reverse protocol")
|
||||
listen_host: str = Field(default="0.0.0.0", description="Proxy listen host")
|
||||
mitm_options: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="mitmdump options passed through --set",
|
||||
)
|
||||
healthcheck: HealthCheckDocument = Field(default_factory=HealthCheckDocument)
|
||||
status: str = Field(default="stopped", description="Runtime proxy status")
|
||||
pid: int | None = Field(default=None, description="mitmdump process id")
|
||||
script_path: str | None = Field(default=None, description="Generated addon path")
|
||||
config_path: str | None = Field(default=None, description="Generated config path")
|
||||
log_path: str | None = Field(default=None, description="mitmdump log path")
|
||||
last_error: str | None = Field(default=None, description="Last runtime error")
|
||||
service: ServiceEndpointDocument = Field(
|
||||
...,
|
||||
description="Associated upstream service",
|
||||
)
|
||||
created_at: datetime | None = Field(
|
||||
default=None,
|
||||
description="Creation timestamp in UTC",
|
||||
)
|
||||
updated_at: datetime | None = Field(
|
||||
default=None,
|
||||
description="Last update timestamp in UTC",
|
||||
)
|
||||
|
||||
|
||||
class ProxyCreateInput(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
proxy_port: int = Field(..., description="Port exposed by the proxy")
|
||||
name: str = Field(..., description="Human readable proxy name")
|
||||
code: str = Field(..., description="Proxy source or identifier")
|
||||
mode: str = Field(default="reverse", description="mitmproxy proxy mode")
|
||||
mode_spec: str | None = Field(
|
||||
default=None,
|
||||
description="Raw mitmproxy mode specification. Overrides mode/protocol target.",
|
||||
)
|
||||
protocol: str = Field(default="http", description="mitmproxy reverse protocol")
|
||||
listen_host: str = Field(default="0.0.0.0", description="Proxy listen host")
|
||||
mitm_options: dict[str, Any] = Field(default_factory=dict)
|
||||
healthcheck: HealthCheckDocument = Field(default_factory=HealthCheckDocument)
|
||||
service: ServiceEndpointDocument = Field(
|
||||
...,
|
||||
description="Associated upstream service",
|
||||
)
|
||||
|
||||
|
||||
class ProxyUpdateInput(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str | None = Field(default=None, description="Human readable proxy name")
|
||||
code: str | None = Field(default=None, description="Proxy source or identifier")
|
||||
mode: str | None = Field(default=None, description="mitmproxy proxy mode")
|
||||
mode_spec: str | None = Field(
|
||||
default=None,
|
||||
description="Raw mitmproxy mode specification. Overrides mode/protocol target.",
|
||||
)
|
||||
protocol: str | None = Field(default=None, description="mitmproxy reverse protocol")
|
||||
listen_host: str | None = Field(default=None, description="Proxy listen host")
|
||||
mitm_options: dict[str, Any] | None = Field(default=None)
|
||||
healthcheck: HealthCheckDocument | None = Field(default=None)
|
||||
service: ServiceEndpointDocument | None = Field(
|
||||
default=None,
|
||||
description="Associated upstream service",
|
||||
)
|
||||
@@ -1,21 +0,0 @@
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ServiceEndpointDocument(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
host: str = Field(default="127.0.0.1", description="Upstream service host")
|
||||
port: int = Field(..., description="Original upstream service port")
|
||||
bind_host: str | None = Field(
|
||||
default=None,
|
||||
description="Original bind host exposed by Docker Compose",
|
||||
)
|
||||
name: str | None = Field(default=None, description="Docker Compose service name")
|
||||
folder: str | None = Field(
|
||||
default=None,
|
||||
description="Docker Compose project folder",
|
||||
)
|
||||
upstream_port: int | None = Field(
|
||||
default=None,
|
||||
description="Current port used by mitmdump as upstream",
|
||||
)
|
||||
@@ -1,46 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ProxyTemplateDocument(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: str = Field(..., description="Stable template identifier")
|
||||
name: str = Field(..., description="Display name")
|
||||
description: str = Field(default="", description="Short explanation")
|
||||
category: str = Field(default="custom", description="Template category")
|
||||
protocol: str = Field(default="custom", description="Primary proxy protocol")
|
||||
family: str = Field(default="custom", description="Template family")
|
||||
tags: list[str] = Field(default_factory=list, description="Filter tags")
|
||||
action: str = Field(default="custom", description="Primary template action")
|
||||
summary: str = Field(default="", description="Short operational summary")
|
||||
behavior: str = Field(default="", description="Runtime behavior description")
|
||||
code: str = Field(..., description="Addon source code")
|
||||
source_url: str | None = Field(default=None, description="Reference URL")
|
||||
built_in: bool = Field(default=False, description="Provided by thePlug")
|
||||
origin: str = Field(default="custom", description="Template source")
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
source_path: str | None = Field(
|
||||
default=None,
|
||||
description="Filesystem source path",
|
||||
exclude=True,
|
||||
)
|
||||
|
||||
|
||||
class ProxyTemplateCreateInput(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: str = Field(..., description="Stable template identifier")
|
||||
name: str = Field(..., description="Display name")
|
||||
description: str = Field(default="", description="Short explanation")
|
||||
category: str = Field(default="custom", description="Template category")
|
||||
protocol: str = Field(default="custom", description="Primary proxy protocol")
|
||||
family: str = Field(default="custom", description="Template family")
|
||||
tags: list[str] = Field(default_factory=list, description="Filter tags")
|
||||
action: str = Field(default="custom", description="Primary template action")
|
||||
summary: str = Field(default="", description="Short operational summary")
|
||||
behavior: str = Field(default="", description="Runtime behavior description")
|
||||
code: str = Field(..., description="Addon source code")
|
||||
source_url: str | None = Field(default=None, description="Reference URL")
|
||||
@@ -1,80 +0,0 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.mongodb import get_database
|
||||
from app.models.health import HealthSnapshot
|
||||
|
||||
COLLECTION_NAME = "health_history"
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
async def collection() -> AsyncIOMotorCollection[dict[str, Any]]:
|
||||
database = await get_database()
|
||||
return database[COLLECTION_NAME]
|
||||
|
||||
|
||||
async def ensure_health_indexes() -> None:
|
||||
samples = await collection()
|
||||
await samples.create_index(
|
||||
[("proxy_port", 1), ("timestamp", -1)],
|
||||
name="ix_health_proxy_port_timestamp",
|
||||
)
|
||||
await samples.create_index(
|
||||
"expires_at",
|
||||
expireAfterSeconds=0,
|
||||
name="ttl_health_history",
|
||||
)
|
||||
|
||||
|
||||
def deserialize_health_sample(raw: dict[str, Any] | None) -> HealthSnapshot | None:
|
||||
if raw is None:
|
||||
return None
|
||||
document = dict(raw)
|
||||
document.pop("_id", None)
|
||||
document.pop("expires_at", None)
|
||||
return HealthSnapshot.model_validate(document)
|
||||
|
||||
|
||||
async def record_health_sample(snapshot: HealthSnapshot) -> HealthSnapshot:
|
||||
samples = await collection()
|
||||
document = snapshot.model_dump(mode="python")
|
||||
document["expires_at"] = document["timestamp"] + timedelta(
|
||||
seconds=settings.HEALTH_HISTORY_TTL_SECONDS,
|
||||
)
|
||||
await samples.insert_one(document)
|
||||
return snapshot
|
||||
|
||||
|
||||
async def get_latest_health_sample(proxy_port: int) -> HealthSnapshot | None:
|
||||
samples = await collection()
|
||||
raw = await samples.find_one(
|
||||
{"proxy_port": proxy_port},
|
||||
sort=[("timestamp", -1)],
|
||||
)
|
||||
return deserialize_health_sample(raw)
|
||||
|
||||
|
||||
async def list_health_history(
|
||||
proxy_port: int,
|
||||
limit: int = 40,
|
||||
) -> list[HealthSnapshot]:
|
||||
samples = await collection()
|
||||
cursor = samples.find({"proxy_port": proxy_port}).sort("timestamp", -1).limit(limit)
|
||||
history: list[HealthSnapshot] = []
|
||||
async for raw in cursor:
|
||||
snapshot = deserialize_health_sample(raw)
|
||||
if snapshot is not None:
|
||||
history.append(snapshot)
|
||||
history.reverse()
|
||||
return history
|
||||
|
||||
|
||||
async def delete_health_history(proxy_port: int) -> None:
|
||||
samples = await collection()
|
||||
await samples.delete_many({"proxy_port": proxy_port})
|
||||
@@ -1,78 +0,0 @@
|
||||
from collections.abc import Mapping
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||
|
||||
from app.core.mongodb import get_database
|
||||
from app.models.template import ProxyTemplateCreateInput, ProxyTemplateDocument
|
||||
|
||||
COLLECTION_NAME = "addon_templates"
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
async def collection() -> AsyncIOMotorCollection[dict[str, Any]]:
|
||||
database = await get_database()
|
||||
return database[COLLECTION_NAME]
|
||||
|
||||
|
||||
def deserialize(raw: Mapping[str, Any] | None) -> ProxyTemplateDocument | None:
|
||||
if raw is None:
|
||||
return None
|
||||
document = dict(raw)
|
||||
document.pop("_id", None)
|
||||
return ProxyTemplateDocument.model_validate(document)
|
||||
|
||||
|
||||
async def ensure_template_indexes() -> None:
|
||||
templates = await collection()
|
||||
await templates.create_index("id", unique=True, name="uq_addon_template_id")
|
||||
|
||||
|
||||
async def list_custom_templates() -> list[ProxyTemplateDocument]:
|
||||
templates = await collection()
|
||||
cursor = templates.find({}).sort("name", 1)
|
||||
|
||||
result: list[ProxyTemplateDocument] = []
|
||||
async for raw in cursor:
|
||||
template = deserialize(raw)
|
||||
if template is not None:
|
||||
result.append(template)
|
||||
return result
|
||||
|
||||
|
||||
async def create_custom_template(
|
||||
payload: ProxyTemplateCreateInput,
|
||||
) -> ProxyTemplateDocument:
|
||||
now = utcnow()
|
||||
template = ProxyTemplateDocument(
|
||||
id=payload.id,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
category=payload.category,
|
||||
protocol=payload.protocol,
|
||||
family=payload.family,
|
||||
tags=payload.tags,
|
||||
action=payload.action,
|
||||
summary=payload.summary,
|
||||
behavior=payload.behavior,
|
||||
code=payload.code,
|
||||
source_url=payload.source_url,
|
||||
built_in=False,
|
||||
origin="custom",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
templates = await collection()
|
||||
await templates.insert_one(template.model_dump(exclude_none=True))
|
||||
return template
|
||||
|
||||
|
||||
async def delete_custom_template(template_id: str) -> bool:
|
||||
templates = await collection()
|
||||
result = await templates.delete_one({"id": template_id})
|
||||
return result.deleted_count > 0
|
||||
@@ -1,323 +0,0 @@
|
||||
import asyncio
|
||||
import json
|
||||
import shutil
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
WORKSPACE_ROOT = Path(tempfile.gettempdir()) / "theplug-lsp"
|
||||
CONFIG_FILE = "pyrightconfig.json"
|
||||
ENTRY_FILE = "proxy_addon.py"
|
||||
|
||||
STUB_FILES = {
|
||||
"typings/mitmproxy/__init__.pyi": """from . import (
|
||||
command,
|
||||
ctx,
|
||||
dns,
|
||||
http,
|
||||
tcp,
|
||||
tls,
|
||||
udp,
|
||||
websocket,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"command",
|
||||
"ctx",
|
||||
"dns",
|
||||
"http",
|
||||
"tcp",
|
||||
"tls",
|
||||
"udp",
|
||||
"websocket",
|
||||
]
|
||||
""",
|
||||
"typings/mitmproxy/ctx.pyi": """class _Logger:
|
||||
def debug(self, message: object) -> None: ...
|
||||
def info(self, message: object) -> None: ...
|
||||
def warn(self, message: object) -> None: ...
|
||||
def error(self, message: object) -> None: ...
|
||||
def alert(self, message: object) -> None: ...
|
||||
|
||||
|
||||
class _Options:
|
||||
def __getattr__(self, name: str) -> object: ...
|
||||
|
||||
|
||||
log: _Logger
|
||||
options: _Options
|
||||
""",
|
||||
"typings/mitmproxy/http.pyi": """from typing import Any
|
||||
|
||||
|
||||
class Headers(dict[str, str]):
|
||||
...
|
||||
|
||||
|
||||
class Request:
|
||||
host: str
|
||||
path: str
|
||||
headers: Headers
|
||||
query: dict[str, str]
|
||||
content: bytes
|
||||
|
||||
|
||||
class Response:
|
||||
headers: Headers
|
||||
status_code: int
|
||||
content: bytes
|
||||
|
||||
@classmethod
|
||||
def make(
|
||||
cls,
|
||||
status_code: int,
|
||||
content: bytes | str = b"",
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> "Response": ...
|
||||
|
||||
|
||||
class WebSocketMessage:
|
||||
content: bytes
|
||||
from_client: bool
|
||||
|
||||
|
||||
class WebSocketData:
|
||||
messages: list[WebSocketMessage]
|
||||
|
||||
|
||||
class HTTPFlow:
|
||||
request: Request
|
||||
response: Response | None
|
||||
websocket: WebSocketData | None
|
||||
metadata: dict[str, Any]
|
||||
""",
|
||||
"typings/mitmproxy/tcp.pyi": """class TCPMessage:
|
||||
content: bytes
|
||||
from_client: bool
|
||||
|
||||
|
||||
class TCPFlow:
|
||||
messages: list[TCPMessage]
|
||||
""",
|
||||
"typings/mitmproxy/dns.pyi": """class Question:
|
||||
name: str
|
||||
type: int
|
||||
|
||||
|
||||
class ResourceRecord:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
type: int,
|
||||
class_: int,
|
||||
ttl: int,
|
||||
data: bytes,
|
||||
) -> None: ...
|
||||
|
||||
|
||||
class Message:
|
||||
question: Question | None
|
||||
|
||||
def succeed(self, records: list[ResourceRecord]) -> "Message": ...
|
||||
|
||||
|
||||
class _Types:
|
||||
A: int
|
||||
|
||||
|
||||
class _Classes:
|
||||
IN: int
|
||||
|
||||
|
||||
types: _Types
|
||||
classes: _Classes
|
||||
|
||||
|
||||
class DNSFlow:
|
||||
request: Message
|
||||
response: Message | None
|
||||
""",
|
||||
"typings/mitmproxy/command.pyi": """from collections.abc import Callable
|
||||
from typing import TypeVar
|
||||
|
||||
F = TypeVar("F", bound=Callable[..., object])
|
||||
|
||||
|
||||
def command(name: str) -> Callable[[F], F]: ...
|
||||
""",
|
||||
"typings/mitmproxy/websocket.pyi": """class WebSocketMessage:
|
||||
content: bytes
|
||||
from_client: bool
|
||||
""",
|
||||
"typings/mitmproxy/tls.pyi": """class ClientHelloData: ...
|
||||
""",
|
||||
"typings/mitmproxy/udp.pyi": """class UDPMessage:
|
||||
content: bytes
|
||||
from_client: bool
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
class LspFrameBuffer:
|
||||
def __init__(self) -> None:
|
||||
self.buffer = bytearray()
|
||||
|
||||
def feed(self, chunk: bytes) -> list[str]:
|
||||
self.buffer.extend(chunk)
|
||||
messages: list[str] = []
|
||||
while True:
|
||||
header_end = self.buffer.find(b"\r\n\r\n")
|
||||
if header_end < 0:
|
||||
return messages
|
||||
|
||||
header = self.buffer[:header_end].decode("ascii", errors="ignore")
|
||||
size = content_length(header)
|
||||
if size is None:
|
||||
del self.buffer[: header_end + 4]
|
||||
continue
|
||||
|
||||
frame_end = header_end + 4 + size
|
||||
if len(self.buffer) < frame_end:
|
||||
return messages
|
||||
|
||||
body = bytes(self.buffer[header_end + 4 : frame_end])
|
||||
del self.buffer[:frame_end]
|
||||
messages.append(body.decode("utf-8"))
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PythonEditorSession:
|
||||
root_path: Path
|
||||
file_path: Path
|
||||
|
||||
@property
|
||||
def root_uri(self) -> str:
|
||||
return self.root_path.as_uri()
|
||||
|
||||
@property
|
||||
def file_uri(self) -> str:
|
||||
return self.file_path.as_uri()
|
||||
|
||||
|
||||
def content_length(header: str) -> int | None:
|
||||
for line in header.split("\r\n"):
|
||||
if ":" not in line:
|
||||
continue
|
||||
name, value = line.split(":", 1)
|
||||
if name.lower() == "content-length":
|
||||
return int(value.strip())
|
||||
return None
|
||||
|
||||
|
||||
def encode_message(message: str) -> bytes:
|
||||
payload = message.encode("utf-8")
|
||||
header = f"Content-Length: {len(payload)}\r\n\r\n".encode("ascii")
|
||||
return header + payload
|
||||
|
||||
|
||||
def lsp_binary() -> str | None:
|
||||
return shutil.which("basedpyright-langserver")
|
||||
|
||||
|
||||
def get_session() -> PythonEditorSession:
|
||||
root_path = WORKSPACE_ROOT
|
||||
file_path = root_path / ENTRY_FILE
|
||||
|
||||
root_path.mkdir(parents=True, exist_ok=True)
|
||||
file_path.touch(exist_ok=True)
|
||||
|
||||
config = {
|
||||
"include": [ENTRY_FILE],
|
||||
"stubPath": "typings",
|
||||
"typeCheckingMode": "basic",
|
||||
"pythonVersion": "3.14",
|
||||
"reportMissingTypeStubs": False,
|
||||
"reportMissingImports": "warning",
|
||||
"useLibraryCodeForTypes": True,
|
||||
}
|
||||
(root_path / CONFIG_FILE).write_text(
|
||||
json.dumps(config, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
for relative_path, content in STUB_FILES.items():
|
||||
path = root_path / relative_path
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
return PythonEditorSession(root_path=root_path, file_path=file_path)
|
||||
|
||||
|
||||
async def open_process(session: PythonEditorSession) -> asyncio.subprocess.Process:
|
||||
binary = lsp_binary()
|
||||
if binary is None:
|
||||
raise RuntimeError("basedpyright-langserver is not installed")
|
||||
|
||||
return await asyncio.create_subprocess_exec(
|
||||
binary,
|
||||
"--stdio",
|
||||
cwd=session.root_path,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
|
||||
async def forward_client(websocket, writer: asyncio.StreamWriter) -> None:
|
||||
while True:
|
||||
message = await websocket.receive_text()
|
||||
writer.write(encode_message(message))
|
||||
await writer.drain()
|
||||
|
||||
|
||||
async def forward_server(reader: asyncio.StreamReader, websocket) -> None:
|
||||
frames = LspFrameBuffer()
|
||||
while True:
|
||||
chunk = await reader.read(65536)
|
||||
if not chunk:
|
||||
return
|
||||
for message in frames.feed(chunk):
|
||||
await websocket.send_text(message)
|
||||
|
||||
|
||||
async def drain_errors(reader: asyncio.StreamReader) -> None:
|
||||
while True:
|
||||
chunk = await reader.read(4096)
|
||||
if not chunk:
|
||||
return
|
||||
|
||||
|
||||
async def bridge(websocket, session: PythonEditorSession) -> None:
|
||||
process = await open_process(session)
|
||||
stdin = process.stdin
|
||||
stdout = process.stdout
|
||||
stderr = process.stderr
|
||||
|
||||
if stdin is None or stdout is None or stderr is None:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
raise RuntimeError("basedpyright-langserver did not expose stdio pipes")
|
||||
|
||||
send_task = asyncio.create_task(forward_client(websocket, stdin))
|
||||
receive_task = asyncio.create_task(forward_server(stdout, websocket))
|
||||
error_task = asyncio.create_task(drain_errors(stderr))
|
||||
|
||||
try:
|
||||
done, pending = await asyncio.wait(
|
||||
{send_task, receive_task, error_task},
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
for task in done:
|
||||
task.result()
|
||||
finally:
|
||||
if process.returncode is None:
|
||||
stdin.close()
|
||||
await stdin.wait_closed()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=1)
|
||||
except TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
@@ -1,228 +0,0 @@
|
||||
import asyncio
|
||||
import ssl
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.models.health import HealthCheckEntry, HealthSnapshot
|
||||
from app.models.proxy import HealthCheckDocument, ProxyDocument
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Target:
|
||||
name: str
|
||||
host: str
|
||||
port: int
|
||||
protocol: str
|
||||
|
||||
|
||||
async def check_proxy(proxy: ProxyDocument) -> HealthSnapshot:
|
||||
timestamp = datetime.now(UTC)
|
||||
if not proxy.healthcheck.enabled:
|
||||
return HealthSnapshot(
|
||||
proxy_port=proxy.proxy_port,
|
||||
timestamp=timestamp,
|
||||
enabled=False,
|
||||
overall="disabled",
|
||||
checks=[],
|
||||
)
|
||||
|
||||
upstream_port = proxy.service.upstream_port or proxy.service.port
|
||||
checks = list(
|
||||
await asyncio.gather(
|
||||
check_target(
|
||||
Target(
|
||||
name="upstream",
|
||||
host=proxy.service.host,
|
||||
port=upstream_port,
|
||||
protocol=health_protocol(proxy),
|
||||
),
|
||||
proxy.healthcheck,
|
||||
),
|
||||
check_target(
|
||||
Target(
|
||||
name="proxy",
|
||||
host=listen_host_for_check(proxy.listen_host),
|
||||
port=proxy.proxy_port,
|
||||
protocol=health_protocol(proxy),
|
||||
),
|
||||
proxy.healthcheck,
|
||||
),
|
||||
)
|
||||
)
|
||||
overall = "healthy" if all(check.healthy for check in checks) else "unhealthy"
|
||||
return HealthSnapshot(
|
||||
proxy_port=proxy.proxy_port,
|
||||
timestamp=timestamp,
|
||||
enabled=True,
|
||||
overall=overall,
|
||||
checks=checks,
|
||||
)
|
||||
|
||||
|
||||
def health_protocol(proxy: ProxyDocument) -> str:
|
||||
return proxy.healthcheck.protocol or proxy.protocol
|
||||
|
||||
|
||||
def listen_host_for_check(host: str) -> str:
|
||||
if host in {"", "0.0.0.0", "::"}:
|
||||
return "127.0.0.1"
|
||||
return host
|
||||
|
||||
|
||||
async def check_target(
|
||||
target: Target,
|
||||
config: HealthCheckDocument,
|
||||
) -> HealthCheckEntry:
|
||||
try:
|
||||
match target.protocol:
|
||||
case "http":
|
||||
return await check_http(target, config, use_tls=False)
|
||||
case "https":
|
||||
return await check_http(target, config, use_tls=True)
|
||||
case "tcp":
|
||||
return await check_tcp(target, config, use_tls=False)
|
||||
case "tls":
|
||||
return await check_tcp(target, config, use_tls=True)
|
||||
case "udp" | "dtls" | "quic" | "dns" | "http3":
|
||||
return await check_udp(target, config)
|
||||
case _:
|
||||
return result(target, False, f"Unsupported protocol: {target.protocol}")
|
||||
except TimeoutError:
|
||||
return result(target, False, "Timed out")
|
||||
except OSError as error:
|
||||
return result(target, False, str(error))
|
||||
|
||||
|
||||
async def check_http(
|
||||
target: Target,
|
||||
config: HealthCheckDocument,
|
||||
*,
|
||||
use_tls: bool,
|
||||
) -> HealthCheckEntry:
|
||||
started = time.perf_counter()
|
||||
reader, writer = await open_connection(target, config, use_tls=use_tls)
|
||||
try:
|
||||
path = config.path if config.path.startswith("/") else f"/{config.path}"
|
||||
request = (
|
||||
f"{config.method} {path} HTTP/1.1\r\n"
|
||||
f"Host: {target.host}\r\n"
|
||||
"Connection: close\r\n\r\n"
|
||||
)
|
||||
writer.write(request.encode("ascii"))
|
||||
await writer.drain()
|
||||
line = await asyncio.wait_for(reader.readline(), config.timeout_seconds)
|
||||
finally:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
|
||||
parts = line.decode("iso-8859-1", errors="replace").split()
|
||||
status = int(parts[1]) if len(parts) >= 2 and parts[1].isdigit() else None
|
||||
expected = config.expected_status
|
||||
healthy = status == expected if expected is not None else status is not None
|
||||
return result(
|
||||
target,
|
||||
healthy,
|
||||
None,
|
||||
{"status": status},
|
||||
benchmark_ms=elapsed_ms(started) if config.benchmark_enabled else None,
|
||||
)
|
||||
|
||||
|
||||
async def check_tcp(
|
||||
target: Target,
|
||||
config: HealthCheckDocument,
|
||||
*,
|
||||
use_tls: bool,
|
||||
) -> HealthCheckEntry:
|
||||
started = time.perf_counter()
|
||||
_reader, writer = await open_connection(target, config, use_tls=use_tls)
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
return result(
|
||||
target,
|
||||
True,
|
||||
benchmark_ms=elapsed_ms(started) if config.benchmark_enabled else None,
|
||||
)
|
||||
|
||||
|
||||
async def check_udp(target: Target, config: HealthCheckDocument) -> HealthCheckEntry:
|
||||
started = time.perf_counter()
|
||||
loop = asyncio.get_running_loop()
|
||||
transport, protocol = await loop.create_datagram_endpoint(
|
||||
lambda: DatagramProbe(config.expect_response),
|
||||
remote_addr=(target.host, target.port),
|
||||
)
|
||||
try:
|
||||
payload = config.udp_payload.encode()
|
||||
transport.sendto(payload)
|
||||
if not config.expect_response:
|
||||
return result(target, True, None, {"mode": "send-only"})
|
||||
data = await asyncio.wait_for(protocol.response, config.timeout_seconds)
|
||||
return result(
|
||||
target,
|
||||
True,
|
||||
None,
|
||||
{"bytes": len(data)},
|
||||
benchmark_ms=elapsed_ms(started) if config.benchmark_enabled else None,
|
||||
)
|
||||
finally:
|
||||
transport.close()
|
||||
|
||||
|
||||
async def open_connection(
|
||||
target: Target,
|
||||
config: HealthCheckDocument,
|
||||
*,
|
||||
use_tls: bool,
|
||||
):
|
||||
if use_tls and config.verify_tls:
|
||||
context = ssl.create_default_context()
|
||||
elif use_tls:
|
||||
context = ssl.create_default_context()
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
else:
|
||||
context = None
|
||||
return await asyncio.wait_for(
|
||||
asyncio.open_connection(target.host, target.port, ssl=context),
|
||||
config.timeout_seconds,
|
||||
)
|
||||
|
||||
|
||||
class DatagramProbe(asyncio.DatagramProtocol):
|
||||
def __init__(self, expect_response: bool):
|
||||
self.response = asyncio.get_running_loop().create_future()
|
||||
if not expect_response:
|
||||
self.response.set_result(b"")
|
||||
|
||||
def datagram_received(self, data, addr):
|
||||
if not self.response.done():
|
||||
self.response.set_result(data)
|
||||
|
||||
def error_received(self, exc):
|
||||
if not self.response.done():
|
||||
self.response.set_exception(exc)
|
||||
|
||||
|
||||
def result(
|
||||
target: Target,
|
||||
healthy: bool,
|
||||
error: str | None = None,
|
||||
details: dict[str, object] | None = None,
|
||||
benchmark_ms: float | None = None,
|
||||
) -> HealthCheckEntry:
|
||||
return HealthCheckEntry(
|
||||
target=target.name,
|
||||
host=target.host,
|
||||
port=target.port,
|
||||
protocol=target.protocol,
|
||||
healthy=healthy,
|
||||
error=error,
|
||||
duration_ms=benchmark_ms,
|
||||
details=details or {},
|
||||
)
|
||||
|
||||
|
||||
def elapsed_ms(started: float) -> float:
|
||||
return round((time.perf_counter() - started) * 1000, 2)
|
||||
@@ -1,117 +0,0 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.health import HealthSnapshot
|
||||
from app.models.proxy import ProxyDocument
|
||||
from app.repositories.health import record_health_sample
|
||||
from app.repositories.proxy import list_proxies
|
||||
from app.services.mitmproxy.health import check_proxy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HealthMonitor:
|
||||
def __init__(self):
|
||||
self.task: asyncio.Task[None] | None = None
|
||||
self.subscribers: dict[int, set[asyncio.Queue[HealthSnapshot]]] = {}
|
||||
|
||||
async def start(self) -> None:
|
||||
if self.task is None or self.task.done():
|
||||
self.task = asyncio.create_task(self.run())
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self.task is None:
|
||||
return
|
||||
self.task.cancel()
|
||||
await asyncio.gather(self.task, return_exceptions=True)
|
||||
self.task = None
|
||||
|
||||
async def run(self) -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
next_cycle = loop.time()
|
||||
while True:
|
||||
delay = next_cycle - loop.time()
|
||||
if delay > 0:
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
cycle_started = loop.time()
|
||||
try:
|
||||
await self.run_cycle()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Health monitor cycle failed")
|
||||
next_cycle = loop.time() + min(
|
||||
1.0,
|
||||
float(settings.HEALTHCHECK_INTERVAL_SECONDS),
|
||||
)
|
||||
continue
|
||||
|
||||
next_cycle = cycle_started + settings.HEALTHCHECK_INTERVAL_SECONDS
|
||||
|
||||
async def run_cycle(self) -> None:
|
||||
proxies = [
|
||||
proxy
|
||||
for proxy in await list_proxies()
|
||||
if proxy.status == "running" and proxy.healthcheck.enabled
|
||||
]
|
||||
async with asyncio.TaskGroup() as group:
|
||||
for proxy in proxies:
|
||||
group.create_task(self.run_check(proxy))
|
||||
|
||||
async def run_check(self, proxy: ProxyDocument) -> None:
|
||||
try:
|
||||
snapshot = await check_proxy(proxy)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Health check failed for proxy %s",
|
||||
proxy.proxy_port,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
await record_health_sample(snapshot)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Health snapshot persistence failed for proxy %s",
|
||||
proxy.proxy_port,
|
||||
)
|
||||
|
||||
await self.publish(proxy.proxy_port, snapshot)
|
||||
|
||||
async def publish(self, proxy_port: int, snapshot: HealthSnapshot) -> None:
|
||||
for queue in list(self.subscribers.get(proxy_port, set())):
|
||||
try:
|
||||
queue.put_nowait(snapshot)
|
||||
except asyncio.QueueFull:
|
||||
logger.debug(
|
||||
"Dropped health update for slow subscriber on proxy %s",
|
||||
proxy_port,
|
||||
)
|
||||
continue
|
||||
|
||||
def subscribe(self, proxy_port: int) -> asyncio.Queue[HealthSnapshot]:
|
||||
queue: asyncio.Queue[HealthSnapshot] = asyncio.Queue(maxsize=8)
|
||||
listeners = self.subscribers.setdefault(proxy_port, set())
|
||||
listeners.add(queue)
|
||||
return queue
|
||||
|
||||
def unsubscribe(
|
||||
self,
|
||||
proxy_port: int,
|
||||
queue: asyncio.Queue[HealthSnapshot],
|
||||
) -> None:
|
||||
listeners = self.subscribers.get(proxy_port)
|
||||
if listeners is None:
|
||||
return
|
||||
listeners.discard(queue)
|
||||
if not listeners:
|
||||
self.subscribers.pop(proxy_port, None)
|
||||
|
||||
|
||||
health_monitor = HealthMonitor()
|
||||
@@ -1,443 +0,0 @@
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
from pathlib import Path
|
||||
|
||||
from app.integrations.docker import change_port
|
||||
from app.integrations.docker.ports import get_random_port
|
||||
from app.models.proxy import ProxyDocument
|
||||
from app.services.mitmproxy.options import MitmOptionError, validate_options
|
||||
from app.services.mitmproxy.runtime_files import (
|
||||
conf_dir_for,
|
||||
config_path_for,
|
||||
ensure_log_path,
|
||||
log_path_for,
|
||||
read_cmdline,
|
||||
read_log_tail,
|
||||
remove_dir,
|
||||
remove_file,
|
||||
script_path_for,
|
||||
write_config,
|
||||
write_script,
|
||||
)
|
||||
from app.services.mitmproxy.runtime_validation import (
|
||||
ReservedProxyPortError,
|
||||
ensure_proxy_port_not_reserved,
|
||||
)
|
||||
|
||||
|
||||
class MitmdumpError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MitmdumpManager:
|
||||
def __init__(self, work_dir: str = "/tmp/theplug", command: str = "mitmdump"):
|
||||
self.work_dir = Path(work_dir)
|
||||
self.command = command
|
||||
self.processes: dict[int, asyncio.subprocess.Process] = {}
|
||||
self.attached_pids: dict[int, int] = {}
|
||||
self.locks: dict[int, asyncio.Lock] = {}
|
||||
self.upstream_port_attempts = 8
|
||||
|
||||
async def start(self, proxy: ProxyDocument) -> dict[str, object]:
|
||||
async with self.lock_for(proxy.proxy_port):
|
||||
return await self.start_unlocked(proxy)
|
||||
|
||||
async def stop(self, proxy: ProxyDocument) -> dict[str, object]:
|
||||
async with self.lock_for(proxy.proxy_port):
|
||||
return await self.stop_unlocked(proxy)
|
||||
|
||||
def cleanup_runtime_artifacts(self, proxy: ProxyDocument) -> None:
|
||||
script_path = (
|
||||
Path(proxy.script_path)
|
||||
if proxy.script_path
|
||||
else self.script_path_for(proxy)
|
||||
)
|
||||
log_path = Path(proxy.log_path) if proxy.log_path else self.log_path_for(proxy)
|
||||
config_path = (
|
||||
Path(proxy.config_path)
|
||||
if proxy.config_path
|
||||
else self.config_path_for(proxy)
|
||||
)
|
||||
|
||||
self.remove_file(script_path)
|
||||
self.remove_file(log_path)
|
||||
self.remove_file(config_path)
|
||||
self.remove_dir(self.conf_dir_for(proxy))
|
||||
|
||||
async def start_unlocked(self, proxy: ProxyDocument) -> dict[str, object]:
|
||||
try:
|
||||
ensure_proxy_port_not_reserved(proxy.proxy_port)
|
||||
except ReservedProxyPortError as error:
|
||||
raise MitmdumpError(str(error)) from error
|
||||
|
||||
if await self.status(proxy) == "running":
|
||||
raise MitmdumpError("Proxy is already running")
|
||||
|
||||
command_path = shutil.which(self.command)
|
||||
if command_path is None:
|
||||
raise MitmdumpError("mitmdump executable not found in PATH")
|
||||
|
||||
try:
|
||||
await validate_options(proxy.mitm_options)
|
||||
except MitmOptionError as error:
|
||||
raise MitmdumpError(str(error)) from error
|
||||
upstream_port = await self.prepare_upstream(proxy)
|
||||
script_path = await self.write_script(proxy)
|
||||
config_path = await self.write_config(proxy)
|
||||
log_path = await self.log_path(proxy)
|
||||
command = self.build_command(
|
||||
command_path,
|
||||
proxy,
|
||||
upstream_port,
|
||||
script_path,
|
||||
config_path,
|
||||
)
|
||||
|
||||
try:
|
||||
process = await self.spawn(command, log_path)
|
||||
except Exception as error:
|
||||
await self.restore_upstream(proxy, upstream_port)
|
||||
raise MitmdumpError(str(error)) from error
|
||||
|
||||
failed = await self.exited_early(process)
|
||||
if failed:
|
||||
await self.restore_upstream(proxy, upstream_port)
|
||||
reason = (
|
||||
await self.read_log_tail(log_path)
|
||||
or "mitmdump exited during startup"
|
||||
)
|
||||
raise MitmdumpError(reason)
|
||||
|
||||
self.processes[proxy.proxy_port] = process
|
||||
self.attached_pids.pop(proxy.proxy_port, None)
|
||||
return {
|
||||
"status": "running",
|
||||
"pid": process.pid,
|
||||
"script_path": str(script_path),
|
||||
"config_path": str(config_path),
|
||||
"log_path": str(log_path),
|
||||
"service.upstream_port": upstream_port,
|
||||
"last_error": None,
|
||||
}
|
||||
|
||||
async def stop_unlocked(self, proxy: ProxyDocument) -> dict[str, object]:
|
||||
process = self.processes.pop(proxy.proxy_port, None)
|
||||
self.attached_pids.pop(proxy.proxy_port, None)
|
||||
if process is not None and process.returncode is None:
|
||||
await self.stop_process(process)
|
||||
elif proxy.pid is not None:
|
||||
await self.stop_pid(proxy.pid)
|
||||
|
||||
await self.restore_upstream(proxy, proxy.service.upstream_port)
|
||||
return {
|
||||
"status": "stopped",
|
||||
"pid": None,
|
||||
"service.upstream_port": None,
|
||||
"last_error": None,
|
||||
}
|
||||
|
||||
async def status(self, proxy: ProxyDocument) -> str:
|
||||
process = self.processes.get(proxy.proxy_port)
|
||||
if process is not None:
|
||||
if process.returncode is None:
|
||||
return "running"
|
||||
self.processes.pop(proxy.proxy_port, None)
|
||||
self.attached_pids.pop(proxy.proxy_port, None)
|
||||
return "stopped"
|
||||
|
||||
pid = self.attached_pids.get(proxy.proxy_port, proxy.pid)
|
||||
if pid is None:
|
||||
return "stopped" if proxy.status == "running" else proxy.status
|
||||
|
||||
if not self.is_process_alive(pid):
|
||||
self.attached_pids.pop(proxy.proxy_port, None)
|
||||
return "stopped"
|
||||
if not self.matches_proxy_process(proxy, pid):
|
||||
self.attached_pids.pop(proxy.proxy_port, None)
|
||||
return "stopped"
|
||||
return "running"
|
||||
|
||||
async def reconcile(self, proxy: ProxyDocument) -> dict[str, object] | None:
|
||||
if not self.needs_recovery(proxy):
|
||||
return None
|
||||
|
||||
if proxy.pid is not None and self.is_process_alive(proxy.pid):
|
||||
if self.matches_proxy_process(proxy, proxy.pid):
|
||||
self.reattach(proxy)
|
||||
return {
|
||||
"status": "running",
|
||||
"pid": proxy.pid,
|
||||
"script_path": str(self.script_path_for(proxy)),
|
||||
"config_path": str(self.config_path_for(proxy)),
|
||||
"log_path": str(self.log_path_for(proxy)),
|
||||
"last_error": None,
|
||||
}
|
||||
|
||||
self.attached_pids.pop(proxy.proxy_port, None)
|
||||
return {
|
||||
"status": "error",
|
||||
"pid": None,
|
||||
"last_error": (
|
||||
f"Found a live pid {proxy.pid}, but its command does not match "
|
||||
"the expected mitmdump runtime for this proxy."
|
||||
),
|
||||
}
|
||||
|
||||
if self.needs_upstream_restore(proxy):
|
||||
restored = await self.restore_upstream(proxy, proxy.service.upstream_port)
|
||||
if not restored:
|
||||
return {
|
||||
"status": "error",
|
||||
"pid": None,
|
||||
"last_error": (
|
||||
"Proxy process is gone and the upstream service port "
|
||||
"could not be restored automatically."
|
||||
),
|
||||
}
|
||||
return {
|
||||
"status": "stopped",
|
||||
"pid": None,
|
||||
"service.upstream_port": None,
|
||||
"last_error": None,
|
||||
}
|
||||
|
||||
if proxy.status == "running" or proxy.pid is not None:
|
||||
return {"status": "stopped", "pid": None, "last_error": None}
|
||||
|
||||
return None
|
||||
|
||||
async def prepare_upstream(self, proxy: ProxyDocument) -> int | None:
|
||||
if proxy.mode_spec is not None or proxy.mode != "reverse":
|
||||
return None
|
||||
if proxy.service.upstream_port is not None:
|
||||
return proxy.service.upstream_port
|
||||
|
||||
if proxy.service.name is None or proxy.service.folder is None:
|
||||
return proxy.service.port
|
||||
|
||||
attempted_ports: set[int] = {proxy.proxy_port, proxy.service.port}
|
||||
for _attempt in range(self.upstream_port_attempts):
|
||||
upstream_port = self.pick_upstream_port(attempted_ports)
|
||||
attempted_ports.add(upstream_port)
|
||||
changed = await change_port(
|
||||
proxy.service.name,
|
||||
Path(proxy.service.folder),
|
||||
proxy.service.port,
|
||||
upstream_port,
|
||||
host_to="127.0.0.1",
|
||||
rewrite_host=True,
|
||||
)
|
||||
if changed:
|
||||
return upstream_port
|
||||
|
||||
raise MitmdumpError("Unable to move Docker service port after retries")
|
||||
|
||||
async def restore_upstream(
|
||||
self,
|
||||
proxy: ProxyDocument,
|
||||
upstream_port: int | None,
|
||||
) -> bool:
|
||||
if upstream_port is None:
|
||||
return True
|
||||
if proxy.service.name is None or proxy.service.folder is None:
|
||||
return True
|
||||
if upstream_port == proxy.service.port:
|
||||
return True
|
||||
|
||||
return await change_port(
|
||||
proxy.service.name,
|
||||
Path(proxy.service.folder),
|
||||
upstream_port,
|
||||
proxy.service.port,
|
||||
host_to=proxy.service.bind_host,
|
||||
rewrite_host=True,
|
||||
)
|
||||
|
||||
def build_command(
|
||||
self,
|
||||
command_path: str,
|
||||
proxy: ProxyDocument,
|
||||
upstream_port: int | None,
|
||||
script_path: Path,
|
||||
config_path: Path,
|
||||
) -> list[str]:
|
||||
command = [
|
||||
command_path,
|
||||
"--mode",
|
||||
self.mode_for(proxy, upstream_port),
|
||||
"--listen-host",
|
||||
proxy.listen_host,
|
||||
"--set",
|
||||
f"confdir={config_path.parent}",
|
||||
"-s",
|
||||
str(script_path),
|
||||
]
|
||||
if self.needs_listen_port(proxy):
|
||||
command.extend(["--listen-port", str(proxy.proxy_port)])
|
||||
return command
|
||||
|
||||
def mode_for(self, proxy: ProxyDocument, upstream_port: int | None) -> str:
|
||||
if proxy.mode_spec:
|
||||
return proxy.mode_spec
|
||||
|
||||
match proxy.mode:
|
||||
case "reverse":
|
||||
port = upstream_port or proxy.service.port
|
||||
return (
|
||||
f"reverse:{proxy.protocol}://{proxy.service.host}:{port}"
|
||||
f"@{proxy.proxy_port}"
|
||||
)
|
||||
case "upstream":
|
||||
return (
|
||||
f"upstream:{proxy.protocol}://{proxy.service.host}:"
|
||||
f"{proxy.service.port}@{proxy.proxy_port}"
|
||||
)
|
||||
case "wireguard":
|
||||
return f"wireguard@{proxy.proxy_port}"
|
||||
case _:
|
||||
return proxy.mode
|
||||
|
||||
def needs_listen_port(self, proxy: ProxyDocument) -> bool:
|
||||
if proxy.mode_spec is not None:
|
||||
return False
|
||||
return proxy.mode in {"regular", "socks5", "dns", "transparent"}
|
||||
|
||||
def pick_upstream_port(self, excluded_ports: set[int]) -> int:
|
||||
while True:
|
||||
candidate = get_random_port()
|
||||
if candidate not in excluded_ports:
|
||||
return candidate
|
||||
|
||||
async def write_script(self, proxy: ProxyDocument) -> Path:
|
||||
return await write_script(self.work_dir, proxy)
|
||||
|
||||
async def write_config(self, proxy: ProxyDocument) -> Path:
|
||||
return await write_config(self.work_dir, proxy)
|
||||
|
||||
async def log_path(self, proxy: ProxyDocument) -> Path:
|
||||
return await ensure_log_path(self.work_dir, proxy)
|
||||
|
||||
async def spawn(self, command: list[str], log_path: Path):
|
||||
with log_path.open("ab") as log_file:
|
||||
return await asyncio.create_subprocess_exec(
|
||||
*command,
|
||||
stdout=log_file,
|
||||
stderr=log_file,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
async def stop_process(self, process) -> None:
|
||||
await self.stop_pid(process.pid)
|
||||
|
||||
async def exited_early(self, process) -> bool:
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=0.5)
|
||||
except TimeoutError:
|
||||
return False
|
||||
return True
|
||||
|
||||
async def stop_pid(self, pid: int) -> None:
|
||||
try:
|
||||
pgid = os.getpgid(pid)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
except PermissionError:
|
||||
pgid = pid
|
||||
|
||||
try:
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
except PermissionError:
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0.2)
|
||||
if not self.is_process_alive(pid):
|
||||
return
|
||||
|
||||
try:
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except PermissionError:
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
|
||||
def reattach(self, proxy: ProxyDocument) -> None:
|
||||
if proxy.pid is not None:
|
||||
self.attached_pids[proxy.proxy_port] = proxy.pid
|
||||
|
||||
def needs_recovery(self, proxy: ProxyDocument) -> bool:
|
||||
return (
|
||||
proxy.status == "running"
|
||||
or proxy.pid is not None
|
||||
or self.needs_upstream_restore(proxy)
|
||||
)
|
||||
|
||||
def needs_upstream_restore(self, proxy: ProxyDocument) -> bool:
|
||||
upstream_port = proxy.service.upstream_port
|
||||
return upstream_port is not None and upstream_port != proxy.service.port
|
||||
|
||||
def is_process_alive(self, pid: int) -> bool:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
return True
|
||||
|
||||
def matches_proxy_process(self, proxy: ProxyDocument, pid: int) -> bool:
|
||||
cmdline = self.read_cmdline(pid)
|
||||
if not cmdline:
|
||||
return False
|
||||
|
||||
expected_command = Path(self.command).name
|
||||
if not any(Path(part).name == expected_command for part in cmdline):
|
||||
return False
|
||||
|
||||
expected_script = str(self.script_path_for(proxy))
|
||||
expected_confdir = f"confdir={self.conf_dir_for(proxy)}"
|
||||
return expected_script in cmdline and expected_confdir in cmdline
|
||||
|
||||
def read_cmdline(self, pid: int) -> list[str]:
|
||||
return read_cmdline(pid)
|
||||
|
||||
def script_path_for(self, proxy: ProxyDocument) -> Path:
|
||||
return script_path_for(self.work_dir, proxy)
|
||||
|
||||
def conf_dir_for(self, proxy: ProxyDocument) -> Path:
|
||||
return conf_dir_for(self.work_dir, proxy)
|
||||
|
||||
def config_path_for(self, proxy: ProxyDocument) -> Path:
|
||||
return config_path_for(self.work_dir, proxy)
|
||||
|
||||
def log_path_for(self, proxy: ProxyDocument) -> Path:
|
||||
return log_path_for(self.work_dir, proxy)
|
||||
|
||||
async def read_log_tail(self, path: Path, limit: int = 4000) -> str:
|
||||
return await read_log_tail(path, limit=limit)
|
||||
|
||||
def remove_file(self, path: Path) -> None:
|
||||
remove_file(path)
|
||||
|
||||
def remove_dir(self, path: Path) -> None:
|
||||
remove_dir(path)
|
||||
|
||||
def lock_for(self, proxy_port: int) -> asyncio.Lock:
|
||||
lock = self.locks.get(proxy_port)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
self.locks[proxy_port] = lock
|
||||
return lock
|
||||
|
||||
|
||||
manager = MitmdumpManager()
|
||||
@@ -1,156 +0,0 @@
|
||||
import asyncio
|
||||
import re
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
RESERVED_OPTIONS = {"mode", "listen_host", "listen_port", "scripts"}
|
||||
OPTIONS_DOCS_URL = "https://docs.mitmproxy.org/stable/concepts/options/"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MitmOption:
|
||||
name: str
|
||||
value_type: str
|
||||
default: Any
|
||||
description: str
|
||||
choices: list[str]
|
||||
|
||||
|
||||
class MitmOptionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
catalog_cache: dict[str, MitmOption] | None = None
|
||||
|
||||
|
||||
async def load_options() -> dict[str, MitmOption]:
|
||||
global catalog_cache
|
||||
if catalog_cache is None:
|
||||
catalog_cache = await read_options()
|
||||
return catalog_cache
|
||||
|
||||
|
||||
async def read_options(command: str = "mitmdump") -> dict[str, MitmOption]:
|
||||
command_path = shutil.which(command)
|
||||
if command_path is None:
|
||||
raise MitmOptionError("mitmdump executable not found in PATH")
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
command_path,
|
||||
"--options",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
if process.returncode != 0:
|
||||
message = stderr.decode("utf-8", errors="replace").strip()
|
||||
raise MitmOptionError(message or "Unable to load mitmdump options")
|
||||
|
||||
text = stdout.decode("utf-8", errors="replace")
|
||||
defaults = yaml.safe_load(text) or {}
|
||||
return parse_options(text, defaults)
|
||||
|
||||
|
||||
def parse_options(text: str, defaults: dict[str, Any]) -> dict[str, MitmOption]:
|
||||
comments: list[str] = []
|
||||
options: dict[str, MitmOption] = {}
|
||||
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
comments = []
|
||||
continue
|
||||
if stripped.startswith("#"):
|
||||
comments.append(stripped[1:].strip())
|
||||
continue
|
||||
if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*:", stripped):
|
||||
continue
|
||||
|
||||
name = stripped.split(":", 1)[0]
|
||||
description = " ".join(comments)
|
||||
options[name] = MitmOption(
|
||||
name=name,
|
||||
value_type=parse_type(description),
|
||||
default=defaults.get(name),
|
||||
description=description,
|
||||
choices=parse_choices(description),
|
||||
)
|
||||
comments = []
|
||||
|
||||
return options
|
||||
|
||||
|
||||
def parse_type(description: str) -> str:
|
||||
match = re.search(r"Type ([^.]+)\.", description)
|
||||
if match is None:
|
||||
return "unknown"
|
||||
return match.group(1).strip()
|
||||
|
||||
|
||||
def parse_choices(description: str) -> list[str]:
|
||||
match = re.search(r"Valid values are ([^.]+)\.", description)
|
||||
if match is None:
|
||||
return []
|
||||
return re.findall(r"'([^']+)'", match.group(1))
|
||||
|
||||
|
||||
async def validate_options(options: dict[str, Any]) -> None:
|
||||
catalog = await load_options()
|
||||
errors: list[str] = []
|
||||
|
||||
for name, value in options.items():
|
||||
option = catalog.get(name)
|
||||
if option is None:
|
||||
errors.append(f"Unknown mitmdump option: {name}")
|
||||
continue
|
||||
if name in RESERVED_OPTIONS:
|
||||
errors.append(f"Option is managed by thePlug and cannot be set: {name}")
|
||||
continue
|
||||
message = validate_value(option, value)
|
||||
if message:
|
||||
errors.append(f"{name}: {message}")
|
||||
|
||||
if errors:
|
||||
raise MitmOptionError("; ".join(errors))
|
||||
|
||||
|
||||
def validate_value(option: MitmOption, value: Any) -> str | None:
|
||||
value_type = option.value_type
|
||||
if option.choices and str(value) not in option.choices:
|
||||
return f"expected one of {', '.join(option.choices)}"
|
||||
if value is None:
|
||||
if value_type.startswith("optional "):
|
||||
return None
|
||||
return "cannot be null"
|
||||
if value_type == "bool" and not isinstance(value, bool):
|
||||
return "expected bool"
|
||||
if value_type == "int" and not isinstance(value, int):
|
||||
return "expected int"
|
||||
if value_type == "str" and not isinstance(value, str):
|
||||
return "expected str"
|
||||
if value_type == "sequence of str":
|
||||
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
||||
return "expected list[str]"
|
||||
if value_type == "optional str" and not isinstance(value, str):
|
||||
return "expected str or null"
|
||||
if value_type == "optional int" and not isinstance(value, int):
|
||||
return "expected int or null"
|
||||
return None
|
||||
|
||||
|
||||
def public_options(options: dict[str, MitmOption]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": option.name,
|
||||
"type": option.value_type,
|
||||
"default": option.default,
|
||||
"description": option.description,
|
||||
"choices": option.choices,
|
||||
"reserved": option.name in RESERVED_OPTIONS,
|
||||
"docs_url": f"{OPTIONS_DOCS_URL}#{option.name}",
|
||||
}
|
||||
for option in options.values()
|
||||
]
|
||||
@@ -1,16 +0,0 @@
|
||||
from app.repositories.proxy import list_proxies, update_proxy_runtime
|
||||
from app.services.mitmproxy.manager import manager
|
||||
|
||||
|
||||
async def reconcile_runtime() -> list[tuple[int, dict[str, object]]]:
|
||||
changes: list[tuple[int, dict[str, object]]] = []
|
||||
|
||||
for proxy in await list_proxies():
|
||||
updates = await manager.reconcile(proxy)
|
||||
if updates is None:
|
||||
continue
|
||||
|
||||
await update_proxy_runtime(proxy.proxy_port, updates)
|
||||
changes.append((proxy.proxy_port, updates))
|
||||
|
||||
return changes
|
||||
@@ -1,82 +0,0 @@
|
||||
import asyncio
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from app.models.proxy import ProxyDocument
|
||||
|
||||
|
||||
def script_path_for(work_dir: Path, proxy: ProxyDocument) -> Path:
|
||||
return work_dir / "addons" / f"{proxy.proxy_port}.py"
|
||||
|
||||
|
||||
def conf_dir_for(work_dir: Path, proxy: ProxyDocument) -> Path:
|
||||
return work_dir / "conf" / str(proxy.proxy_port)
|
||||
|
||||
|
||||
def config_path_for(work_dir: Path, proxy: ProxyDocument) -> Path:
|
||||
return conf_dir_for(work_dir, proxy) / "config.yaml"
|
||||
|
||||
|
||||
def log_path_for(work_dir: Path, proxy: ProxyDocument) -> Path:
|
||||
return work_dir / "logs" / f"{proxy.proxy_port}.log"
|
||||
|
||||
|
||||
async def write_script(work_dir: Path, proxy: ProxyDocument) -> Path:
|
||||
path = script_path_for(work_dir, proxy)
|
||||
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
|
||||
await asyncio.to_thread(path.write_text, proxy.code, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
async def write_config(work_dir: Path, proxy: ProxyDocument) -> Path:
|
||||
path = config_path_for(work_dir, proxy)
|
||||
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
|
||||
options = {"block_global": False, **proxy.mitm_options}
|
||||
config_text = yaml.safe_dump(options, sort_keys=True)
|
||||
await asyncio.to_thread(path.write_text, config_text, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
async def ensure_log_path(work_dir: Path, proxy: ProxyDocument) -> Path:
|
||||
path = log_path_for(work_dir, proxy)
|
||||
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
async def read_log_tail(path: Path, limit: int = 4000) -> str:
|
||||
if not await asyncio.to_thread(path.exists):
|
||||
return ""
|
||||
content = await asyncio.to_thread(
|
||||
path.read_text,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
return content[-limit:].strip()
|
||||
|
||||
|
||||
def remove_file(path: Path) -> None:
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
return
|
||||
|
||||
|
||||
def remove_dir(path: Path) -> None:
|
||||
try:
|
||||
shutil.rmtree(path)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except OSError:
|
||||
return
|
||||
|
||||
|
||||
def read_cmdline(pid: int) -> list[str]:
|
||||
path = Path(f"/proc/{pid}/cmdline")
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except OSError:
|
||||
return []
|
||||
decoded = raw.decode("utf-8", errors="replace")
|
||||
return [part for part in decoded.split("\x00") if part]
|
||||
@@ -1,21 +0,0 @@
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class ReservedProxyPortError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def backend_port() -> int:
|
||||
return settings.API_PORT
|
||||
|
||||
|
||||
def reserved_proxy_port_message(proxy_port: int) -> str:
|
||||
return (
|
||||
f"Proxy port {proxy_port} is reserved for the backend API "
|
||||
"and cannot be used by mitmdump."
|
||||
)
|
||||
|
||||
|
||||
def ensure_proxy_port_not_reserved(proxy_port: int) -> None:
|
||||
if proxy_port == backend_port():
|
||||
raise ReservedProxyPortError(reserved_proxy_port_message(proxy_port))
|
||||
@@ -1,139 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.models.template import ProxyTemplateDocument
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SUPPORTED_FILE_TEMPLATE_PROTOCOLS = frozenset({"http", "tcp"})
|
||||
TEMPLATES_DIR = Path(__file__).resolve().parents[4] / "templates"
|
||||
|
||||
|
||||
def template_directory() -> Path:
|
||||
return TEMPLATES_DIR
|
||||
|
||||
|
||||
def template_files(directory: Path | None = None) -> list[Path]:
|
||||
root = directory or template_directory()
|
||||
if not root.exists():
|
||||
return []
|
||||
return sorted(
|
||||
path
|
||||
for path in root.iterdir()
|
||||
if path.is_file() and path.suffix.lower() in {".yaml", ".yml"}
|
||||
)
|
||||
|
||||
|
||||
def _default_category(protocol: str, family: str) -> str:
|
||||
return protocol if protocol in SUPPORTED_FILE_TEMPLATE_PROTOCOLS else family
|
||||
|
||||
|
||||
def _string_value(value: object, default: str = "") -> str:
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if value is None:
|
||||
return default
|
||||
return str(value)
|
||||
|
||||
|
||||
def _normalize_template_payload(
|
||||
raw: Mapping[str, object],
|
||||
source: Path,
|
||||
) -> tuple[dict[str, object], str]:
|
||||
protocol = _string_value(raw.get("protocol")).strip().lower()
|
||||
if protocol not in SUPPORTED_FILE_TEMPLATE_PROTOCOLS:
|
||||
expected = sorted(SUPPORTED_FILE_TEMPLATE_PROTOCOLS)
|
||||
raise ValueError(
|
||||
f"unsupported protocol {protocol!r}; expected one of {expected}"
|
||||
)
|
||||
|
||||
if "id" not in raw or "name" not in raw or "code" not in raw:
|
||||
raise ValueError("missing one of required fields: id, name, code")
|
||||
|
||||
description = _string_value(
|
||||
raw.get("description") or raw.get("summary") or raw.get("behavior")
|
||||
)
|
||||
family = _string_value(raw.get("family") or protocol)
|
||||
source_url = raw.get("source_url")
|
||||
tags = raw.get("tags")
|
||||
normalized_tags = [str(tag) for tag in tags] if isinstance(tags, list) else []
|
||||
|
||||
payload: dict[str, object] = {
|
||||
"id": _string_value(raw["id"]).strip(),
|
||||
"name": _string_value(raw["name"]).strip(),
|
||||
"description": description,
|
||||
"category": _string_value(
|
||||
raw.get("category") or _default_category(protocol, family)
|
||||
),
|
||||
"protocol": protocol,
|
||||
"family": family,
|
||||
"tags": normalized_tags,
|
||||
"action": _string_value(raw.get("action") or "custom"),
|
||||
"summary": _string_value(raw.get("summary") or description),
|
||||
"behavior": _string_value(raw.get("behavior") or description),
|
||||
"code": _string_value(raw["code"]),
|
||||
"source_url": _string_value(source_url) if source_url is not None else None,
|
||||
"built_in": True,
|
||||
"origin": "file",
|
||||
}
|
||||
return payload, str(source)
|
||||
|
||||
|
||||
def _load_template_from_file(path: Path) -> ProxyTemplateDocument | None:
|
||||
try:
|
||||
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
except (OSError, yaml.YAMLError) as error:
|
||||
logger.warning("Skipping template file %s: %s", path, error)
|
||||
return None
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
logger.warning("Skipping template file %s: expected mapping root", path)
|
||||
return None
|
||||
normalized_raw = {str(key): value for key, value in raw.items()}
|
||||
|
||||
try:
|
||||
payload, source_path = _normalize_template_payload(normalized_raw, path)
|
||||
document = ProxyTemplateDocument.model_validate(payload)
|
||||
except (ValidationError, ValueError) as error:
|
||||
logger.warning("Skipping template file %s: %s", path, error)
|
||||
return None
|
||||
|
||||
document.source_path = source_path
|
||||
return document
|
||||
|
||||
|
||||
def list_file_templates(directory: Path | None = None) -> list[ProxyTemplateDocument]:
|
||||
by_id: dict[str, list[ProxyTemplateDocument]] = defaultdict(list)
|
||||
for path in template_files(directory):
|
||||
template = _load_template_from_file(path)
|
||||
if template is None:
|
||||
continue
|
||||
by_id[template.id].append(template)
|
||||
|
||||
deduplicated: list[ProxyTemplateDocument] = []
|
||||
duplicate_template_message = (
|
||||
"Skipping template id %s from %s: duplicate file-backed template id"
|
||||
)
|
||||
for template_id, templates in by_id.items():
|
||||
if len(templates) > 1:
|
||||
for template in templates:
|
||||
logger.warning(
|
||||
duplicate_template_message,
|
||||
template_id,
|
||||
template.source_path or "<unknown>",
|
||||
)
|
||||
continue
|
||||
deduplicated.append(templates[0])
|
||||
|
||||
return sorted(deduplicated, key=lambda template: template.name.lower())
|
||||
|
||||
|
||||
def file_template_ids(directory: Path | None = None) -> set[str]:
|
||||
return {template.id for template in list_file_templates(directory)}
|
||||
@@ -1,59 +0,0 @@
|
||||
import pytest
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from app.services.editor import python_lsp
|
||||
|
||||
|
||||
def test_python_editor_session(client, monkeypatch):
|
||||
session = python_lsp.PythonEditorSession(
|
||||
root_path=python_lsp.WORKSPACE_ROOT,
|
||||
file_path=python_lsp.WORKSPACE_ROOT / python_lsp.ENTRY_FILE,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.api.editor.router.python_lsp.lsp_binary", lambda: "/bin/lsp"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.api.editor.router.python_lsp.get_session", lambda: session
|
||||
)
|
||||
|
||||
response = client.get("/proxy/editor/python/session")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"root_uri": session.root_uri,
|
||||
"file_uri": session.file_uri,
|
||||
}
|
||||
|
||||
|
||||
def test_python_editor_socket_rejects_missing_token(client):
|
||||
with pytest.raises(WebSocketDisconnect) as error:
|
||||
with client.websocket_connect("/proxy/editor/python/ws"):
|
||||
pass
|
||||
|
||||
assert error.value.code == 4401
|
||||
|
||||
|
||||
def test_python_editor_socket_accepts_token(client, monkeypatch):
|
||||
accepted = {"value": False}
|
||||
|
||||
async def fake_authenticate(_: object) -> None:
|
||||
return None
|
||||
|
||||
async def fake_bridge(websocket, session) -> None:
|
||||
accepted["value"] = True
|
||||
await websocket.send_text(session.file_uri)
|
||||
await websocket.close()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.api.editor.router.authenticate_editor_socket",
|
||||
fake_authenticate,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.api.editor.router.python_lsp.lsp_binary", lambda: "/bin/lsp"
|
||||
)
|
||||
monkeypatch.setattr("app.api.editor.router.python_lsp.bridge", fake_bridge)
|
||||
|
||||
with client.websocket_connect("/proxy/editor/python/ws?token=test-token") as socket:
|
||||
assert socket.receive_text().endswith("/proxy_addon.py")
|
||||
|
||||
assert accepted["value"] is True
|
||||
@@ -1,980 +0,0 @@
|
||||
import asyncio
|
||||
import importlib
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from app.core.security import get_auth_context, require_authenticated_user
|
||||
from app.models.health import HealthSnapshot
|
||||
from app.models.proxy import ProxyCreateInput, ProxyDocument
|
||||
from app.models.service import ServiceEndpointDocument
|
||||
from app.services.mitmproxy.templates import list_file_templates
|
||||
from app.services.mitmproxy.validator import ProxyCodeValidationError
|
||||
|
||||
proxy_router_module = importlib.import_module("app.api.proxy.router")
|
||||
|
||||
|
||||
def make_proxy_document(
|
||||
*,
|
||||
proxy_port: int,
|
||||
name: str = "svc",
|
||||
code: str = "proxy-code",
|
||||
port: int = 9000,
|
||||
log_path: str | None = None,
|
||||
) -> ProxyDocument:
|
||||
return ProxyDocument(
|
||||
proxy_port=proxy_port,
|
||||
name=name,
|
||||
code=code,
|
||||
service=ServiceEndpointDocument(port=port),
|
||||
log_path=log_path,
|
||||
)
|
||||
|
||||
|
||||
def make_health_snapshot(
|
||||
proxy_port: int,
|
||||
*,
|
||||
overall: str = "healthy",
|
||||
timestamp: datetime | None = None,
|
||||
) -> HealthSnapshot:
|
||||
return HealthSnapshot(
|
||||
proxy_port=proxy_port,
|
||||
timestamp=timestamp or datetime.now(UTC),
|
||||
enabled=True,
|
||||
overall=overall,
|
||||
checks=[],
|
||||
)
|
||||
|
||||
|
||||
def test_proxy_requires_auth_without_dependency_overrides(client):
|
||||
client.app.dependency_overrides.pop(require_authenticated_user, None)
|
||||
client.app.dependency_overrides.pop(get_auth_context, None)
|
||||
|
||||
response = client.get("/proxy")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Could not validate credentials"
|
||||
|
||||
|
||||
def test_list_proxies_returns_documents(client, monkeypatch: object):
|
||||
docs = [
|
||||
make_proxy_document(proxy_port=8080, name="api"),
|
||||
make_proxy_document(proxy_port=8081, name="worker"),
|
||||
]
|
||||
|
||||
async def fake_list_proxies() -> list[ProxyDocument]:
|
||||
return docs
|
||||
|
||||
monkeypatch.setattr(proxy_router_module, "list_proxy_documents", fake_list_proxies)
|
||||
|
||||
response = client.get("/proxy")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert [item["proxy_port"] for item in body] == [8080, 8081]
|
||||
|
||||
|
||||
def test_list_services_uses_query_scan_root(client, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_get_all_services_in_dir(root):
|
||||
captured["root"] = str(root)
|
||||
return [
|
||||
{
|
||||
"name": "echo",
|
||||
"ports": [9081],
|
||||
"bindings": [],
|
||||
"folder": "/root/play",
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"get_all_services_in_dir",
|
||||
fake_get_all_services_in_dir,
|
||||
)
|
||||
|
||||
response = client.get("/proxy/services", params={"scan_root": "/root"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"backend_port": 3671,
|
||||
"scan_root": "/root",
|
||||
"services": [
|
||||
{"name": "echo", "ports": [9081], "bindings": [], "folder": "/root/play"}
|
||||
],
|
||||
}
|
||||
assert captured["root"] == "/root"
|
||||
|
||||
|
||||
def test_get_proxy_returns_404_when_not_found(client, monkeypatch: object):
|
||||
async def fake_get_proxy_by_port(proxy_port: int) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"get_proxy_by_port",
|
||||
fake_get_proxy_by_port,
|
||||
)
|
||||
|
||||
response = client.get("/proxy/9999")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Proxy not found"
|
||||
|
||||
|
||||
def test_create_proxy_returns_409_on_port_conflict(client, monkeypatch: object):
|
||||
existing = make_proxy_document(proxy_port=4088)
|
||||
|
||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||
assert proxy_port == 4088
|
||||
return existing
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"get_proxy_by_port",
|
||||
fake_get_proxy_by_port,
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/proxy",
|
||||
json={
|
||||
"proxy_port": 4088,
|
||||
"name": "new",
|
||||
"code": "abc",
|
||||
"service": {"port": 8080},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json()["detail"] == "Proxy already exists on port 4088"
|
||||
|
||||
|
||||
def test_create_proxy_rejects_backend_api_port(client, monkeypatch: object):
|
||||
create_called = False
|
||||
|
||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||
assert proxy_port == 3671
|
||||
return None
|
||||
|
||||
async def fake_create_proxy(payload: ProxyCreateInput) -> ProxyDocument:
|
||||
nonlocal create_called
|
||||
create_called = True
|
||||
return make_proxy_document(proxy_port=payload.proxy_port)
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"get_proxy_by_port",
|
||||
fake_get_proxy_by_port,
|
||||
)
|
||||
monkeypatch.setattr(proxy_router_module, "create_proxy", fake_create_proxy)
|
||||
|
||||
response = client.post(
|
||||
"/proxy",
|
||||
json={
|
||||
"proxy_port": 3671,
|
||||
"name": "backend-clash",
|
||||
"code": "abc",
|
||||
"service": {"port": 8080},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json()["detail"] == (
|
||||
"Proxy port 3671 is reserved for the backend API "
|
||||
"and cannot be used by mitmdump."
|
||||
)
|
||||
assert create_called is False
|
||||
|
||||
|
||||
def test_create_proxy_returns_created_document(client, monkeypatch: object):
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||
assert proxy_port == 4040
|
||||
return None
|
||||
|
||||
async def fake_create_proxy(payload: ProxyCreateInput) -> ProxyDocument:
|
||||
captured["proxy_port"] = payload.proxy_port
|
||||
captured["name"] = payload.name
|
||||
captured["service"] = payload.service.model_dump(exclude_none=True)
|
||||
captured["code"] = payload.code
|
||||
captured["protocol"] = payload.protocol
|
||||
return make_proxy_document(
|
||||
proxy_port=payload.proxy_port,
|
||||
name=payload.name,
|
||||
code=payload.code,
|
||||
port=payload.service.port,
|
||||
)
|
||||
|
||||
async def fake_validate_proxy_code(code: str) -> None:
|
||||
assert code == "def proxy_handler():\n return 'ok'\n"
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"get_proxy_by_port",
|
||||
fake_get_proxy_by_port,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"validate_proxy_code",
|
||||
fake_validate_proxy_code,
|
||||
)
|
||||
monkeypatch.setattr(proxy_router_module, "create_proxy", fake_create_proxy)
|
||||
|
||||
response = client.post(
|
||||
"/proxy",
|
||||
json={
|
||||
"proxy_port": 4040,
|
||||
"name": "edge",
|
||||
"code": "def proxy_handler():\n return 'ok'\n",
|
||||
"service": {"port": 8181},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["proxy_port"] == 4040
|
||||
assert captured == {
|
||||
"proxy_port": 4040,
|
||||
"name": "edge",
|
||||
"service": {"host": "127.0.0.1", "port": 8181},
|
||||
"code": "def proxy_handler():\n return 'ok'\n",
|
||||
"protocol": "http",
|
||||
}
|
||||
|
||||
|
||||
def test_create_proxy_returns_422_when_code_validation_fails(
|
||||
client,
|
||||
monkeypatch: object,
|
||||
):
|
||||
create_called = False
|
||||
|
||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||
assert proxy_port == 4000
|
||||
return None
|
||||
|
||||
async def fake_validate_proxy_code(code: str) -> None:
|
||||
assert code == "invalid-code"
|
||||
raise ProxyCodeValidationError(
|
||||
"Proxy code contains ruff violations",
|
||||
errors=[
|
||||
{
|
||||
"code": "F821",
|
||||
"message": "Undefined name `missing_symbol`",
|
||||
"location": {"row": 1, "column": 1},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
async def fake_create_proxy(
|
||||
*,
|
||||
proxy_port: int,
|
||||
name: str,
|
||||
service: dict[str, object],
|
||||
code: str,
|
||||
) -> ProxyDocument:
|
||||
nonlocal create_called
|
||||
create_called = True
|
||||
return make_proxy_document(proxy_port=proxy_port, name=name, code=code)
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"get_proxy_by_port",
|
||||
fake_get_proxy_by_port,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"validate_proxy_code",
|
||||
fake_validate_proxy_code,
|
||||
)
|
||||
monkeypatch.setattr(proxy_router_module, "create_proxy", fake_create_proxy)
|
||||
|
||||
response = client.post(
|
||||
"/proxy",
|
||||
json={
|
||||
"proxy_port": 4000,
|
||||
"name": "broken",
|
||||
"code": "invalid-code",
|
||||
"service": {"port": 9000},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
body = response.json()["detail"]
|
||||
assert body["message"] == "Proxy code contains ruff violations"
|
||||
assert body["errors"][0]["code"] == "F821"
|
||||
assert create_called is False
|
||||
|
||||
|
||||
def test_update_proxy_returns_422_when_code_validation_fails(
|
||||
client,
|
||||
monkeypatch: object,
|
||||
):
|
||||
update_called = False
|
||||
|
||||
async def fake_validate_proxy_code(code: str) -> None:
|
||||
assert code == "invalid-code"
|
||||
raise ProxyCodeValidationError(
|
||||
"Proxy code has invalid Python syntax",
|
||||
errors=[
|
||||
{
|
||||
"code": "COMPILE",
|
||||
"message": "invalid syntax",
|
||||
"location": {"row": 1, "column": 1},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
async def fake_update_proxy(
|
||||
proxy_port: int,
|
||||
payload: object,
|
||||
) -> ProxyDocument | None:
|
||||
nonlocal update_called
|
||||
update_called = True
|
||||
return make_proxy_document(proxy_port=proxy_port)
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"validate_proxy_code",
|
||||
fake_validate_proxy_code,
|
||||
)
|
||||
monkeypatch.setattr(proxy_router_module, "update_proxy", fake_update_proxy)
|
||||
|
||||
response = client.put(
|
||||
"/proxy/8123",
|
||||
json={"code": "invalid-code"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
body = response.json()["detail"]
|
||||
assert body["message"] == "Proxy code has invalid Python syntax"
|
||||
assert body["errors"][0]["code"] == "COMPILE"
|
||||
assert update_called is False
|
||||
|
||||
|
||||
def test_update_proxy_persists_code_changes(client, monkeypatch: object):
|
||||
captured: dict[str, object] = {}
|
||||
updated_code = "def response(flow):\n flow.response.text = 'ok'\n"
|
||||
|
||||
async def fake_validate_proxy_code(code: str) -> None:
|
||||
captured["validated"] = code
|
||||
|
||||
async def fake_update_proxy(
|
||||
proxy_port: int,
|
||||
payload: object,
|
||||
) -> ProxyDocument | None:
|
||||
captured["proxy_port"] = proxy_port
|
||||
captured["code"] = payload.code
|
||||
return make_proxy_document(
|
||||
proxy_port=proxy_port,
|
||||
code=payload.code,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"validate_proxy_code",
|
||||
fake_validate_proxy_code,
|
||||
)
|
||||
monkeypatch.setattr(proxy_router_module, "update_proxy", fake_update_proxy)
|
||||
|
||||
response = client.put(
|
||||
"/proxy/8123",
|
||||
json={"code": updated_code},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["code"] == updated_code
|
||||
assert captured == {
|
||||
"validated": updated_code,
|
||||
"proxy_port": 8123,
|
||||
"code": updated_code,
|
||||
}
|
||||
|
||||
|
||||
def test_start_proxy_updates_runtime_state(client, monkeypatch: object):
|
||||
proxy = make_proxy_document(proxy_port=4040)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||
assert proxy_port == 4040
|
||||
return proxy
|
||||
|
||||
class FakeManager:
|
||||
async def start(self, document: ProxyDocument) -> dict[str, object]:
|
||||
assert document.proxy_port == 4040
|
||||
return {
|
||||
"status": "running",
|
||||
"pid": 1234,
|
||||
"script_path": "/tmp/theplug/addons/4040.py",
|
||||
"log_path": "/tmp/theplug/logs/4040.log",
|
||||
"service.upstream_port": 9000,
|
||||
"last_error": None,
|
||||
}
|
||||
|
||||
async def fake_update_proxy_runtime(
|
||||
proxy_port: int,
|
||||
values: dict[str, object],
|
||||
) -> ProxyDocument:
|
||||
captured["proxy_port"] = proxy_port
|
||||
captured["values"] = values
|
||||
return proxy.model_copy(update={"status": "running", "pid": 1234})
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"get_proxy_by_port",
|
||||
fake_get_proxy_by_port,
|
||||
)
|
||||
monkeypatch.setattr(proxy_router_module, "manager", FakeManager())
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"update_proxy_runtime",
|
||||
fake_update_proxy_runtime,
|
||||
)
|
||||
|
||||
response = client.post("/proxy/4040/start")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "running"
|
||||
assert captured["proxy_port"] == 4040
|
||||
assert captured["values"]["pid"] == 1234
|
||||
|
||||
|
||||
def test_start_proxy_records_runtime_error(client, monkeypatch: object):
|
||||
proxy = make_proxy_document(proxy_port=4040)
|
||||
|
||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||
assert proxy_port == 4040
|
||||
return proxy
|
||||
|
||||
class FakeManager:
|
||||
async def start(self, document: ProxyDocument) -> dict[str, object]:
|
||||
assert document.proxy_port == 4040
|
||||
raise proxy_router_module.MitmdumpError("mitmdump executable not found")
|
||||
|
||||
async def fake_update_proxy_runtime(
|
||||
proxy_port: int,
|
||||
values: dict[str, object],
|
||||
) -> ProxyDocument:
|
||||
assert proxy_port == 4040
|
||||
assert values == {
|
||||
"status": "error",
|
||||
"last_error": "mitmdump executable not found",
|
||||
}
|
||||
return proxy.model_copy(
|
||||
update={"status": "error", "last_error": values["last_error"]}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"get_proxy_by_port",
|
||||
fake_get_proxy_by_port,
|
||||
)
|
||||
monkeypatch.setattr(proxy_router_module, "manager", FakeManager())
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"update_proxy_runtime",
|
||||
fake_update_proxy_runtime,
|
||||
)
|
||||
|
||||
response = client.post("/proxy/4040/start")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "error"
|
||||
assert response.json()["last_error"] == "mitmdump executable not found"
|
||||
|
||||
|
||||
def test_proxy_logs_socket_rejects_missing_token(client):
|
||||
with pytest.raises(WebSocketDisconnect) as error:
|
||||
with client.websocket_connect("/proxy/4040/logs/ws"):
|
||||
pass
|
||||
|
||||
assert error.value.code == 4401
|
||||
|
||||
|
||||
def test_proxy_logs_socket_streams_snapshot_and_append(client, monkeypatch, tmp_path):
|
||||
log_path = tmp_path / "proxy.log"
|
||||
log_path.write_text("line one\n", encoding="utf-8")
|
||||
proxy = make_proxy_document(proxy_port=4040, log_path=str(log_path))
|
||||
|
||||
async def fake_authenticate(_: object) -> None:
|
||||
return None
|
||||
|
||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||
assert proxy_port == 4040
|
||||
return proxy
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"authenticate_proxy_socket",
|
||||
fake_authenticate,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"get_proxy_by_port",
|
||||
fake_get_proxy_by_port,
|
||||
)
|
||||
|
||||
with client.websocket_connect("/proxy/4040/logs/ws?token=test-token") as socket:
|
||||
snapshot = socket.receive_json()
|
||||
assert snapshot == {"type": "snapshot", "log": "line one\n"}
|
||||
|
||||
with log_path.open("a", encoding="utf-8") as handle:
|
||||
handle.write("line two\n")
|
||||
handle.flush()
|
||||
|
||||
appended = socket.receive_json()
|
||||
assert appended == {"type": "append", "log": "line two\n"}
|
||||
|
||||
|
||||
def test_proxy_logs_socket_resets_snapshot_after_log_rotation(
|
||||
client,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
log_path = tmp_path / "proxy.log"
|
||||
log_path.write_text("line one\n", encoding="utf-8")
|
||||
proxy = make_proxy_document(proxy_port=4040, log_path=str(log_path))
|
||||
|
||||
async def fake_authenticate(_: object) -> None:
|
||||
return None
|
||||
|
||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||
assert proxy_port == 4040
|
||||
return proxy
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"authenticate_proxy_socket",
|
||||
fake_authenticate,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"get_proxy_by_port",
|
||||
fake_get_proxy_by_port,
|
||||
)
|
||||
|
||||
with client.websocket_connect("/proxy/4040/logs/ws?token=test-token") as socket:
|
||||
snapshot = socket.receive_json()
|
||||
assert snapshot == {"type": "snapshot", "log": "line one\n"}
|
||||
|
||||
log_path.write_text("fresh start\n", encoding="utf-8")
|
||||
|
||||
rotated = socket.receive_json()
|
||||
assert rotated == {"type": "snapshot", "log": "fresh start\n"}
|
||||
|
||||
with log_path.open("a", encoding="utf-8") as handle:
|
||||
handle.write("line two\n")
|
||||
handle.flush()
|
||||
|
||||
appended = socket.receive_json()
|
||||
assert appended == {"type": "append", "log": "line two\n"}
|
||||
|
||||
|
||||
def test_remove_proxy_stops_runtime_and_cleans_artifacts(client, monkeypatch: object):
|
||||
proxy = make_proxy_document(
|
||||
proxy_port=4040,
|
||||
log_path="/tmp/theplug/logs/4040.log",
|
||||
).model_copy(
|
||||
update={
|
||||
"script_path": "/tmp/theplug/addons/4040.py",
|
||||
"config_path": "/tmp/theplug/conf/4040/config.yaml",
|
||||
}
|
||||
)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||
assert proxy_port == 4040
|
||||
return proxy
|
||||
|
||||
class FakeManager:
|
||||
async def stop(self, document: ProxyDocument) -> dict[str, object]:
|
||||
captured["stopped"] = document.proxy_port
|
||||
return {
|
||||
"status": "stopped",
|
||||
"pid": None,
|
||||
"service.upstream_port": None,
|
||||
"last_error": None,
|
||||
}
|
||||
|
||||
def cleanup_runtime_artifacts(self, document: ProxyDocument) -> None:
|
||||
captured["cleaned"] = {
|
||||
"proxy_port": document.proxy_port,
|
||||
"script_path": document.script_path,
|
||||
"config_path": document.config_path,
|
||||
"log_path": document.log_path,
|
||||
}
|
||||
|
||||
async def fake_delete_proxy(proxy_port: int) -> bool:
|
||||
captured["deleted"] = proxy_port
|
||||
return True
|
||||
|
||||
async def fake_delete_health_history(proxy_port: int) -> None:
|
||||
captured["history_deleted"] = proxy_port
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"get_proxy_by_port",
|
||||
fake_get_proxy_by_port,
|
||||
)
|
||||
monkeypatch.setattr(proxy_router_module, "manager", FakeManager())
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"delete_health_history",
|
||||
fake_delete_health_history,
|
||||
)
|
||||
monkeypatch.setattr(proxy_router_module, "delete_proxy", fake_delete_proxy)
|
||||
|
||||
response = client.delete("/proxy/4040")
|
||||
|
||||
assert response.status_code == 204
|
||||
assert captured == {
|
||||
"stopped": 4040,
|
||||
"cleaned": {
|
||||
"proxy_port": 4040,
|
||||
"script_path": "/tmp/theplug/addons/4040.py",
|
||||
"config_path": "/tmp/theplug/conf/4040/config.yaml",
|
||||
"log_path": "/tmp/theplug/logs/4040.log",
|
||||
},
|
||||
"deleted": 4040,
|
||||
"history_deleted": 4040,
|
||||
}
|
||||
|
||||
|
||||
def test_proxy_health_runs_immediate_check_and_publishes(client, monkeypatch: object):
|
||||
proxy = make_proxy_document(proxy_port=4040)
|
||||
captured: dict[str, object] = {}
|
||||
snapshot = make_health_snapshot(4040)
|
||||
|
||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||
assert proxy_port == 4040
|
||||
return proxy
|
||||
|
||||
async def fake_check_proxy(document: ProxyDocument):
|
||||
captured["checked"] = document.proxy_port
|
||||
return snapshot
|
||||
|
||||
async def fake_record_health_sample(document):
|
||||
captured["recorded"] = document
|
||||
return document
|
||||
|
||||
class FakeHealthMonitor:
|
||||
async def publish(self, proxy_port: int, document: HealthSnapshot) -> None:
|
||||
captured["published"] = (proxy_port, document)
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"get_proxy_by_port",
|
||||
fake_get_proxy_by_port,
|
||||
)
|
||||
monkeypatch.setattr(proxy_router_module, "check_proxy", fake_check_proxy)
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"record_health_sample",
|
||||
fake_record_health_sample,
|
||||
)
|
||||
monkeypatch.setattr(proxy_router_module, "health_monitor", FakeHealthMonitor())
|
||||
|
||||
response = client.get("/proxy/4040/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["overall"] == "healthy"
|
||||
assert captured["checked"] == 4040
|
||||
assert captured["recorded"] == snapshot
|
||||
assert captured["published"] == (4040, snapshot)
|
||||
|
||||
|
||||
def test_proxy_health_history_returns_latest_and_history(client, monkeypatch: object):
|
||||
proxy = make_proxy_document(proxy_port=4040)
|
||||
timestamp = datetime.now(UTC)
|
||||
|
||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||
assert proxy_port == 4040
|
||||
return proxy
|
||||
|
||||
async def fake_get_latest_health_sample(proxy_port: int):
|
||||
assert proxy_port == 4040
|
||||
return make_health_snapshot(4040, timestamp=timestamp)
|
||||
|
||||
async def fake_list_health_history(proxy_port: int, limit: int = 40):
|
||||
assert proxy_port == 4040
|
||||
assert limit == 40
|
||||
return [make_health_snapshot(4040, timestamp=timestamp)]
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"get_proxy_by_port",
|
||||
fake_get_proxy_by_port,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"get_latest_health_sample",
|
||||
fake_get_latest_health_sample,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"list_health_history",
|
||||
fake_list_health_history,
|
||||
)
|
||||
|
||||
response = client.get("/proxy/4040/health/history")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["proxy_port"] == 4040
|
||||
assert body["latest"]["overall"] == "healthy"
|
||||
assert len(body["history"]) == 1
|
||||
|
||||
|
||||
def test_proxy_health_history_clamps_requested_limit(client, monkeypatch: object):
|
||||
proxy = make_proxy_document(proxy_port=4040)
|
||||
captured: dict[str, int] = {}
|
||||
|
||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||
assert proxy_port == 4040
|
||||
return proxy
|
||||
|
||||
async def fake_get_latest_health_sample(_proxy_port: int) -> HealthSnapshot | None:
|
||||
return None
|
||||
|
||||
async def fake_list_health_history(proxy_port: int, limit: int = 40):
|
||||
assert proxy_port == 4040
|
||||
captured["limit"] = limit
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"get_proxy_by_port",
|
||||
fake_get_proxy_by_port,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"get_latest_health_sample",
|
||||
fake_get_latest_health_sample,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"list_health_history",
|
||||
fake_list_health_history,
|
||||
)
|
||||
|
||||
response = client.get("/proxy/4040/health/history", params={"limit": 9999})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert captured["limit"] == 200
|
||||
|
||||
|
||||
def test_proxy_health_socket_streams_latest_and_updates(client, monkeypatch: object):
|
||||
proxy = make_proxy_document(proxy_port=4040)
|
||||
timestamp = datetime.now(UTC)
|
||||
follow_up = make_health_snapshot(4040, overall="unhealthy")
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_authenticate(_: object) -> None:
|
||||
return None
|
||||
|
||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||
assert proxy_port == 4040
|
||||
return proxy
|
||||
|
||||
async def fake_get_latest_health_sample(proxy_port: int):
|
||||
assert proxy_port == 4040
|
||||
return make_health_snapshot(4040, timestamp=timestamp)
|
||||
|
||||
class FakeHealthMonitor:
|
||||
def subscribe(self, proxy_port: int):
|
||||
assert proxy_port == 4040
|
||||
queue = asyncio.Queue()
|
||||
queue.put_nowait(follow_up)
|
||||
return queue
|
||||
|
||||
def unsubscribe(self, proxy_port: int, _queue) -> None:
|
||||
assert proxy_port == 4040
|
||||
captured["unsubscribed"] = True
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"authenticate_proxy_socket",
|
||||
fake_authenticate,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"get_proxy_by_port",
|
||||
fake_get_proxy_by_port,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"get_latest_health_sample",
|
||||
fake_get_latest_health_sample,
|
||||
)
|
||||
monkeypatch.setattr(proxy_router_module, "health_monitor", FakeHealthMonitor())
|
||||
|
||||
with client.websocket_connect("/proxy/4040/health/ws?token=test-token") as socket:
|
||||
first = socket.receive_json()
|
||||
assert first["overall"] == "healthy"
|
||||
second = socket.receive_json()
|
||||
assert second["overall"] == "unhealthy"
|
||||
|
||||
assert captured["unsubscribed"] is True
|
||||
|
||||
|
||||
def test_stop_proxy_updates_runtime_state(client, monkeypatch: object):
|
||||
proxy = make_proxy_document(proxy_port=4040).model_copy(
|
||||
update={"status": "running", "pid": 1234}
|
||||
)
|
||||
|
||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||
assert proxy_port == 4040
|
||||
return proxy
|
||||
|
||||
class FakeManager:
|
||||
async def stop(self, document: ProxyDocument) -> dict[str, object]:
|
||||
assert document.pid == 1234
|
||||
return {
|
||||
"status": "stopped",
|
||||
"pid": None,
|
||||
"service.upstream_port": None,
|
||||
"last_error": None,
|
||||
}
|
||||
|
||||
async def fake_update_proxy_runtime(
|
||||
proxy_port: int,
|
||||
values: dict[str, object],
|
||||
) -> ProxyDocument:
|
||||
assert proxy_port == 4040
|
||||
assert values["status"] == "stopped"
|
||||
return proxy.model_copy(update={"status": "stopped", "pid": None})
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"get_proxy_by_port",
|
||||
fake_get_proxy_by_port,
|
||||
)
|
||||
monkeypatch.setattr(proxy_router_module, "manager", FakeManager())
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"update_proxy_runtime",
|
||||
fake_update_proxy_runtime,
|
||||
)
|
||||
|
||||
response = client.post("/proxy/4040/stop")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "stopped"
|
||||
|
||||
|
||||
def test_list_mitmproxy_templates_returns_builtins_and_custom(client, monkeypatch):
|
||||
file_templates = list_file_templates()
|
||||
|
||||
async def fake_list_custom_templates():
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"list_custom_templates",
|
||||
fake_list_custom_templates,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"list_file_templates",
|
||||
lambda: file_templates,
|
||||
)
|
||||
|
||||
response = client.get("/proxy/mitmproxy/templates")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert len(body) == len(file_templates)
|
||||
assert body[0]["built_in"] is True
|
||||
assert body[0]["origin"] == "file"
|
||||
assert body[0]["protocol"] in {"http", "tcp"}
|
||||
assert isinstance(body[0]["tags"], list)
|
||||
assert body[0]["action"] in {"block", "rewrite"}
|
||||
|
||||
|
||||
def test_create_mitmproxy_template_validates_and_persists(client, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_validate_proxy_code(code: str) -> None:
|
||||
captured["validated"] = code
|
||||
|
||||
async def fake_create_custom_template(payload):
|
||||
captured["payload"] = payload
|
||||
return {
|
||||
"id": payload.id,
|
||||
"name": payload.name,
|
||||
"description": payload.description,
|
||||
"category": payload.category,
|
||||
"protocol": payload.protocol,
|
||||
"family": payload.family,
|
||||
"tags": payload.tags,
|
||||
"action": payload.action,
|
||||
"summary": payload.summary,
|
||||
"behavior": payload.behavior,
|
||||
"code": payload.code,
|
||||
"source_url": payload.source_url,
|
||||
"built_in": False,
|
||||
"origin": "custom",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"validate_proxy_code",
|
||||
fake_validate_proxy_code,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"create_custom_template",
|
||||
fake_create_custom_template,
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/proxy/mitmproxy/templates",
|
||||
json={
|
||||
"id": "custom-header",
|
||||
"name": "Custom header",
|
||||
"description": "Adds a custom response header",
|
||||
"category": "http",
|
||||
"protocol": "http",
|
||||
"family": "custom",
|
||||
"tags": ["observe"],
|
||||
"action": "custom",
|
||||
"summary": "Saved custom template.",
|
||||
"behavior": "Applies the current script as-is.",
|
||||
"code": "def response(flow):\n pass\n",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["id"] == "custom-header"
|
||||
assert response.json()["protocol"] == "http"
|
||||
assert response.json()["origin"] == "custom"
|
||||
assert captured["validated"] == "def response(flow):\n pass\n"
|
||||
|
||||
|
||||
def test_create_mitmproxy_template_rejects_file_backed_id(client, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"file_template_ids",
|
||||
lambda: {"http-block-path-traversal"},
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/proxy/mitmproxy/templates",
|
||||
json={
|
||||
"id": "http-block-path-traversal",
|
||||
"name": "Custom header",
|
||||
"description": "Adds a custom response header",
|
||||
"category": "http",
|
||||
"protocol": "http",
|
||||
"family": "custom",
|
||||
"tags": [],
|
||||
"action": "custom",
|
||||
"summary": "Saved custom template.",
|
||||
"behavior": "Applies the current script as-is.",
|
||||
"code": "def response(flow):\n pass\n",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert "Template already exists" in response.json()["detail"]
|
||||
@@ -1,171 +0,0 @@
|
||||
import asyncio
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from motor.motor_asyncio import AsyncIOMotorClient
|
||||
|
||||
os.environ.setdefault(
|
||||
"MONGODB_URI",
|
||||
"mongodb://theplug:pass@localhost:27017/theplug?authSource=admin",
|
||||
)
|
||||
os.environ.setdefault("MONGODB_DB_NAME", "theplug")
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key")
|
||||
os.environ.setdefault("JWT_ALGORITHM", "HS256")
|
||||
os.environ.setdefault("JWT_EXPIRE_MINUTES", "720")
|
||||
os.environ.setdefault("AUTH_USERNAME", "admin")
|
||||
os.environ.setdefault("AUTH_PASSWORD", "admin")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_user():
|
||||
from app.models.auth import AuthUserDocument
|
||||
|
||||
return AuthUserDocument(
|
||||
username="admin",
|
||||
password_hash="hashed-password",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_context(fake_user):
|
||||
from app.core.security import AuthContext
|
||||
from app.models.auth import AccessTokenPayload
|
||||
|
||||
exp = int((datetime.now(UTC) + timedelta(minutes=720)).timestamp())
|
||||
payload = AccessTokenPayload(sub=fake_user.username, jti="test-jti", exp=exp)
|
||||
return AuthContext(token="test-token", user=fake_user, payload=payload)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(fake_user, auth_context) -> FastAPI:
|
||||
from app.api.auth.router import router as auth_router
|
||||
from app.api.editor.router import router as editor_router
|
||||
from app.api.proxy.router import router as proxy_router
|
||||
from app.core.security import (
|
||||
AuthContext,
|
||||
get_auth_context,
|
||||
require_authenticated_user,
|
||||
)
|
||||
from app.models.auth import AuthUserDocument
|
||||
|
||||
app_instance = FastAPI()
|
||||
app_instance.include_router(auth_router)
|
||||
app_instance.include_router(editor_router)
|
||||
app_instance.include_router(proxy_router)
|
||||
|
||||
async def _override_current_user() -> AuthUserDocument:
|
||||
return fake_user
|
||||
|
||||
async def _override_auth_context() -> AuthContext:
|
||||
return auth_context
|
||||
|
||||
app_instance.dependency_overrides[require_authenticated_user] = (
|
||||
_override_current_user
|
||||
)
|
||||
app_instance.dependency_overrides[get_auth_context] = _override_auth_context
|
||||
return app_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app: FastAPI):
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
async def _mongo_ping(uri: str) -> str | None:
|
||||
client = AsyncIOMotorClient(uri, serverSelectionTimeoutMS=500)
|
||||
try:
|
||||
await client.admin.command("ping")
|
||||
except Exception as error: # pragma: no cover - exercised via skip path
|
||||
return str(error)
|
||||
finally:
|
||||
client.close()
|
||||
return None
|
||||
|
||||
|
||||
async def _drop_database(uri: str, database_name: str) -> None:
|
||||
client = AsyncIOMotorClient(uri, serverSelectionTimeoutMS=500)
|
||||
try:
|
||||
await client.drop_database(database_name)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def integration_runtime(tmp_path: Path) -> Iterator[dict[str, str]]:
|
||||
from app.core.config import settings
|
||||
from app.services.mitmproxy.manager import manager
|
||||
|
||||
mongodb_uri = settings.MONGODB_URI
|
||||
ping_error = asyncio.run(_mongo_ping(mongodb_uri))
|
||||
if ping_error is not None:
|
||||
pytest.skip(f"MongoDB is not available for integration tests: {ping_error}")
|
||||
|
||||
db_name = f"theplug_test_{uuid4().hex}"
|
||||
scan_root = tmp_path / "scan-root"
|
||||
runtime_root = tmp_path / "runtime"
|
||||
scan_root.mkdir(parents=True, exist_ok=True)
|
||||
runtime_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
original_db_name = settings.MONGODB_DB_NAME
|
||||
original_scan_root = settings.SERVICE_SCAN_ROOT
|
||||
original_auth_username = settings.AUTH_USERNAME
|
||||
original_auth_password = settings.AUTH_PASSWORD
|
||||
original_manager_work_dir = manager.work_dir
|
||||
|
||||
settings.MONGODB_DB_NAME = db_name
|
||||
settings.SERVICE_SCAN_ROOT = str(scan_root)
|
||||
settings.AUTH_USERNAME = "integration-admin"
|
||||
settings.AUTH_PASSWORD = "integration-admin"
|
||||
manager.work_dir = runtime_root
|
||||
|
||||
try:
|
||||
yield {
|
||||
"db_name": db_name,
|
||||
"scan_root": str(scan_root),
|
||||
"runtime_root": str(runtime_root),
|
||||
}
|
||||
finally:
|
||||
manager.work_dir = original_manager_work_dir
|
||||
manager.processes.clear()
|
||||
manager.attached_pids.clear()
|
||||
settings.MONGODB_DB_NAME = original_db_name
|
||||
settings.SERVICE_SCAN_ROOT = original_scan_root
|
||||
settings.AUTH_USERNAME = original_auth_username
|
||||
settings.AUTH_PASSWORD = original_auth_password
|
||||
asyncio.run(_drop_database(mongodb_uri, db_name))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def real_app(integration_runtime) -> FastAPI:
|
||||
from app.main import app as real_app_instance
|
||||
|
||||
real_app_instance.dependency_overrides.clear()
|
||||
return real_app_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def real_client(real_app: FastAPI):
|
||||
with TestClient(real_app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_token(real_client: TestClient) -> str:
|
||||
response = real_client.post(
|
||||
"/auth/login",
|
||||
json={"username": "integration-admin", "password": "integration-admin"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()["access_token"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers(auth_token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {auth_token}"}
|
||||
@@ -1,25 +0,0 @@
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.api]
|
||||
|
||||
|
||||
def test_login_logout_revokes_real_token(real_client):
|
||||
login_response = real_client.post(
|
||||
"/auth/login",
|
||||
json={"username": "integration-admin", "password": "integration-admin"},
|
||||
)
|
||||
|
||||
assert login_response.status_code == 200
|
||||
token = login_response.json()["access_token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
list_response = real_client.get("/proxy", headers=headers)
|
||||
assert list_response.status_code == 200
|
||||
assert list_response.json() == []
|
||||
|
||||
logout_response = real_client.post("/auth/logout", headers=headers)
|
||||
assert logout_response.status_code == 204
|
||||
|
||||
revoked_response = real_client.get("/proxy", headers=headers)
|
||||
assert revoked_response.status_code == 401
|
||||
assert revoked_response.json()["detail"] == "Could not validate credentials"
|
||||
@@ -1,80 +0,0 @@
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.api]
|
||||
|
||||
VALID_PROXY_CODE = """def request(flow):
|
||||
return None
|
||||
"""
|
||||
|
||||
|
||||
def test_proxy_crud_round_trip_uses_real_app_and_mongo(real_client, auth_headers):
|
||||
create_response = real_client.post(
|
||||
"/proxy",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"proxy_port": 49081,
|
||||
"name": "integration-echo",
|
||||
"code": VALID_PROXY_CODE,
|
||||
"protocol": "http",
|
||||
"service": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 49082,
|
||||
},
|
||||
"healthcheck": {
|
||||
"enabled": True,
|
||||
"path": "/healthz",
|
||||
"benchmark_enabled": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert create_response.status_code == 201
|
||||
created = create_response.json()
|
||||
assert created["proxy_port"] == 49081
|
||||
assert created["name"] == "integration-echo"
|
||||
assert created["healthcheck"]["benchmark_enabled"] is True
|
||||
|
||||
list_response = real_client.get("/proxy", headers=auth_headers)
|
||||
assert list_response.status_code == 200
|
||||
assert [item["proxy_port"] for item in list_response.json()] == [49081]
|
||||
|
||||
get_response = real_client.get("/proxy/49081", headers=auth_headers)
|
||||
assert get_response.status_code == 200
|
||||
assert get_response.json()["service"]["port"] == 49082
|
||||
|
||||
update_response = real_client.put(
|
||||
"/proxy/49081",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"name": "integration-echo-updated",
|
||||
"code": VALID_PROXY_CODE.replace("None", '"updated"'),
|
||||
"healthcheck": {
|
||||
"enabled": True,
|
||||
"path": "/ready",
|
||||
"benchmark_enabled": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert update_response.status_code == 200
|
||||
updated = update_response.json()
|
||||
assert updated["name"] == "integration-echo-updated"
|
||||
assert updated["code"].endswith('return "updated"\n')
|
||||
assert updated["healthcheck"]["path"] == "/ready"
|
||||
assert updated["healthcheck"]["benchmark_enabled"] is False
|
||||
|
||||
history_response = real_client.get(
|
||||
"/proxy/49081/health/history",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert history_response.status_code == 200
|
||||
history_payload = history_response.json()
|
||||
assert history_payload["proxy_port"] == 49081
|
||||
assert history_payload["latest"] is None
|
||||
assert history_payload["history"] == []
|
||||
|
||||
delete_response = real_client.delete("/proxy/49081", headers=auth_headers)
|
||||
assert delete_response.status_code == 204
|
||||
|
||||
missing_response = real_client.get("/proxy/49081", headers=auth_headers)
|
||||
assert missing_response.status_code == 404
|
||||
@@ -1,186 +0,0 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from app.integrations.docker.compose import get_all_services_in_dir
|
||||
from app.integrations.docker.editor import change_port
|
||||
|
||||
|
||||
def test_get_all_services_in_dir_scans_immediate_children(tmp_path: Path):
|
||||
root = tmp_path / "workspace"
|
||||
project = root / "playground"
|
||||
project.mkdir(parents=True)
|
||||
(project / "docker-compose.yml").write_text(
|
||||
"""
|
||||
services:
|
||||
echo:
|
||||
image: hashicorp/http-echo:1.0
|
||||
ports:
|
||||
- "9081:5678"
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
services = get_all_services_in_dir(root)
|
||||
|
||||
assert services == [
|
||||
{
|
||||
"name": "echo",
|
||||
"ports": [9081],
|
||||
"bindings": [{"published": 9081, "host": None}],
|
||||
"folder": project,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_get_all_services_in_dir_skips_nested_grandchildren(tmp_path: Path):
|
||||
root = tmp_path / "workspace"
|
||||
nested = root / "group" / "playground"
|
||||
nested.mkdir(parents=True)
|
||||
(nested / "docker-compose.yml").write_text(
|
||||
"""
|
||||
services:
|
||||
echo:
|
||||
image: hashicorp/http-echo:1.0
|
||||
ports:
|
||||
- "9081:5678"
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
services = get_all_services_in_dir(root)
|
||||
|
||||
assert services == []
|
||||
|
||||
|
||||
def test_get_all_services_in_dir_returns_empty_when_root_is_inaccessible(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
root = tmp_path / "workspace"
|
||||
root.mkdir()
|
||||
original_iterdir = Path.iterdir
|
||||
|
||||
def fake_iterdir(self: Path):
|
||||
if self == root:
|
||||
raise PermissionError("denied")
|
||||
return original_iterdir(self)
|
||||
|
||||
monkeypatch.setattr(Path, "iterdir", fake_iterdir)
|
||||
|
||||
assert get_all_services_in_dir(root) == []
|
||||
|
||||
|
||||
def test_get_all_services_in_dir_skips_inaccessible_child_folder(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
root = tmp_path / "workspace"
|
||||
allowed = root / "allowed"
|
||||
blocked = root / "blocked"
|
||||
allowed.mkdir(parents=True)
|
||||
blocked.mkdir(parents=True)
|
||||
(allowed / "docker-compose.yml").write_text(
|
||||
"""
|
||||
services:
|
||||
echo:
|
||||
image: hashicorp/http-echo:1.0
|
||||
ports:
|
||||
- "9081:5678"
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
original_is_dir = Path.is_dir
|
||||
|
||||
def fake_is_dir(self: Path):
|
||||
if self == blocked:
|
||||
raise PermissionError("denied")
|
||||
return original_is_dir(self)
|
||||
|
||||
monkeypatch.setattr(Path, "is_dir", fake_is_dir)
|
||||
|
||||
services = get_all_services_in_dir(root)
|
||||
|
||||
assert services == [
|
||||
{
|
||||
"name": "echo",
|
||||
"ports": [9081],
|
||||
"bindings": [{"published": 9081, "host": None}],
|
||||
"folder": allowed,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_change_port_restores_original_compose_when_reload_fails(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
folder = tmp_path / "workspace"
|
||||
folder.mkdir()
|
||||
compose_path = folder / "docker-compose.yml"
|
||||
original = """
|
||||
services:
|
||||
echo:
|
||||
image: hashicorp/http-echo:1.0
|
||||
ports:
|
||||
- "9081:5678"
|
||||
""".strip()
|
||||
compose_path.write_text(original, encoding="utf-8")
|
||||
|
||||
async def fake_run_docker_command(
|
||||
_file: Path,
|
||||
_command: list[str],
|
||||
) -> dict | None:
|
||||
return {"status": "error", "reason": "boom"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.integrations.docker.editor.run_docker_command",
|
||||
fake_run_docker_command,
|
||||
)
|
||||
|
||||
changed = asyncio.run(change_port("echo", folder, 9081, 54001))
|
||||
|
||||
assert changed is False
|
||||
assert compose_path.read_text(encoding="utf-8") == original
|
||||
assert (folder / "docker-compose.bak.yml").exists() is False
|
||||
|
||||
|
||||
def test_change_port_restores_original_compose_when_reload_raises(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
folder = tmp_path / "workspace"
|
||||
folder.mkdir()
|
||||
compose_path = folder / "docker-compose.yml"
|
||||
original = """
|
||||
services:
|
||||
echo:
|
||||
image: hashicorp/http-echo:1.0
|
||||
ports:
|
||||
- "127.0.0.1:54001:5678"
|
||||
""".strip()
|
||||
compose_path.write_text(original, encoding="utf-8")
|
||||
|
||||
async def fake_run_docker_command(
|
||||
_file: Path,
|
||||
_command: list[str],
|
||||
) -> dict | None:
|
||||
raise RuntimeError("docker unavailable")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.integrations.docker.editor.run_docker_command",
|
||||
fake_run_docker_command,
|
||||
)
|
||||
|
||||
changed = asyncio.run(
|
||||
change_port(
|
||||
"echo",
|
||||
folder,
|
||||
54001,
|
||||
9081,
|
||||
host_to=None,
|
||||
rewrite_host=True,
|
||||
)
|
||||
)
|
||||
|
||||
assert changed is False
|
||||
assert compose_path.read_text(encoding="utf-8") == original
|
||||
@@ -1,30 +0,0 @@
|
||||
from app.integrations.docker.ports import _get_service_binding, _replace_port_in_mapping
|
||||
|
||||
|
||||
def test_get_service_binding_includes_loopback_hosts():
|
||||
assert _get_service_binding("127.0.0.1:8080:80") == (8080, "127.0.0.1")
|
||||
assert _get_service_binding("localhost:9081:5678") == (9081, "localhost")
|
||||
|
||||
|
||||
def test_replace_port_in_mapping_can_force_loopback_host():
|
||||
updated = _replace_port_in_mapping(
|
||||
"9081:5678",
|
||||
9081,
|
||||
54001,
|
||||
host_to="127.0.0.1",
|
||||
rewrite_host=True,
|
||||
)
|
||||
|
||||
assert updated == "127.0.0.1:54001:5678"
|
||||
|
||||
|
||||
def test_replace_port_in_mapping_can_restore_unspecified_host():
|
||||
restored = _replace_port_in_mapping(
|
||||
"127.0.0.1:54001:5678",
|
||||
54001,
|
||||
9081,
|
||||
host_to=None,
|
||||
rewrite_host=True,
|
||||
)
|
||||
|
||||
assert restored == "9081:5678"
|
||||
@@ -1,77 +0,0 @@
|
||||
import asyncio
|
||||
|
||||
from app.models.health import HealthSnapshot
|
||||
from app.models.proxy import ProxyDocument
|
||||
from app.models.service import ServiceEndpointDocument
|
||||
from app.services.mitmproxy.health import check_proxy
|
||||
|
||||
|
||||
def test_check_proxy_reports_upstream_and_proxy_healthy():
|
||||
async def run():
|
||||
upstream = await asyncio.start_server(http_handler, "127.0.0.1", 0)
|
||||
proxy = await asyncio.start_server(http_handler, "127.0.0.1", 0)
|
||||
try:
|
||||
upstream_port = upstream.sockets[0].getsockname()[1]
|
||||
proxy_port = proxy.sockets[0].getsockname()[1]
|
||||
document = ProxyDocument(
|
||||
proxy_port=proxy_port,
|
||||
name="health",
|
||||
code="",
|
||||
service=ServiceEndpointDocument(port=upstream_port),
|
||||
)
|
||||
|
||||
health = await check_proxy(document)
|
||||
finally:
|
||||
upstream.close()
|
||||
proxy.close()
|
||||
await upstream.wait_closed()
|
||||
await proxy.wait_closed()
|
||||
|
||||
assert isinstance(health, HealthSnapshot)
|
||||
assert health.overall == "healthy"
|
||||
assert [check.target for check in health.checks] == [
|
||||
"upstream",
|
||||
"proxy",
|
||||
]
|
||||
assert all(check.duration_ms is None for check in health.checks)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_check_proxy_reports_benchmark_latency_when_enabled():
|
||||
async def run():
|
||||
upstream = await asyncio.start_server(http_handler, "127.0.0.1", 0)
|
||||
proxy = await asyncio.start_server(http_handler, "127.0.0.1", 0)
|
||||
try:
|
||||
upstream_port = upstream.sockets[0].getsockname()[1]
|
||||
proxy_port = proxy.sockets[0].getsockname()[1]
|
||||
document = ProxyDocument(
|
||||
proxy_port=proxy_port,
|
||||
name="health",
|
||||
code="",
|
||||
healthcheck={"benchmark_enabled": True},
|
||||
service=ServiceEndpointDocument(port=upstream_port),
|
||||
)
|
||||
|
||||
health = await check_proxy(document)
|
||||
finally:
|
||||
upstream.close()
|
||||
proxy.close()
|
||||
await upstream.wait_closed()
|
||||
await proxy.wait_closed()
|
||||
|
||||
assert isinstance(health, HealthSnapshot)
|
||||
assert all(
|
||||
check.duration_ms is not None and check.duration_ms >= 0
|
||||
for check in health.checks
|
||||
)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
async def http_handler(reader, writer):
|
||||
await reader.readline()
|
||||
writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
@@ -1,218 +0,0 @@
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.health import HealthSnapshot
|
||||
from app.models.proxy import ProxyDocument
|
||||
from app.models.service import ServiceEndpointDocument
|
||||
from app.services.mitmproxy.health_monitor import HealthMonitor
|
||||
|
||||
|
||||
def make_proxy(**updates):
|
||||
data = {
|
||||
"proxy_port": 8080,
|
||||
"name": "svc",
|
||||
"code": "",
|
||||
"status": "running",
|
||||
"service": ServiceEndpointDocument(port=9000),
|
||||
}
|
||||
data.update(updates)
|
||||
return ProxyDocument(**data)
|
||||
|
||||
|
||||
def make_snapshot(proxy_port: int, *, overall: str = "healthy") -> HealthSnapshot:
|
||||
return HealthSnapshot(
|
||||
proxy_port=proxy_port,
|
||||
timestamp=datetime.now(UTC),
|
||||
enabled=True,
|
||||
overall=overall,
|
||||
checks=[],
|
||||
)
|
||||
|
||||
|
||||
def test_run_cycle_records_and_publishes_running_healthchecks(monkeypatch):
|
||||
monitor = HealthMonitor()
|
||||
proxy = make_proxy()
|
||||
captured: dict[str, object] = {}
|
||||
snapshot = make_snapshot(8080)
|
||||
|
||||
async def fake_list_proxies():
|
||||
return [proxy]
|
||||
|
||||
async def fake_check_proxy(document):
|
||||
captured["checked"] = document.proxy_port
|
||||
return snapshot
|
||||
|
||||
async def fake_record_health_sample(document):
|
||||
captured["recorded"] = document
|
||||
return document
|
||||
|
||||
async def fake_publish(proxy_port, document):
|
||||
captured["published"] = (proxy_port, document)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.mitmproxy.health_monitor.list_proxies",
|
||||
fake_list_proxies,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.mitmproxy.health_monitor.check_proxy",
|
||||
fake_check_proxy,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.mitmproxy.health_monitor.record_health_sample",
|
||||
fake_record_health_sample,
|
||||
)
|
||||
monkeypatch.setattr(monitor, "publish", fake_publish)
|
||||
|
||||
asyncio.run(monitor.run_cycle())
|
||||
|
||||
assert captured["checked"] == 8080
|
||||
assert captured["recorded"] == snapshot
|
||||
assert captured["published"] == (8080, snapshot)
|
||||
|
||||
|
||||
def test_run_cycle_skips_stopped_or_disabled_proxies(monkeypatch):
|
||||
monitor = HealthMonitor()
|
||||
running_disabled = make_proxy(healthcheck={"enabled": False})
|
||||
stopped = make_proxy(proxy_port=8081, status="stopped")
|
||||
called = False
|
||||
|
||||
async def fake_list_proxies():
|
||||
return [running_disabled, stopped]
|
||||
|
||||
async def fake_check_proxy(_document):
|
||||
nonlocal called
|
||||
called = True
|
||||
return make_snapshot(8080)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.mitmproxy.health_monitor.list_proxies",
|
||||
fake_list_proxies,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.mitmproxy.health_monitor.check_proxy",
|
||||
fake_check_proxy,
|
||||
)
|
||||
|
||||
asyncio.run(monitor.run_cycle())
|
||||
|
||||
assert called is False
|
||||
|
||||
|
||||
def test_run_cycle_checks_proxies_in_parallel(monkeypatch):
|
||||
monitor = HealthMonitor()
|
||||
proxies = [
|
||||
make_proxy(proxy_port=8080),
|
||||
make_proxy(proxy_port=8081),
|
||||
make_proxy(proxy_port=8082),
|
||||
]
|
||||
started: list[int] = []
|
||||
release = asyncio.Event()
|
||||
|
||||
async def fake_list_proxies():
|
||||
return proxies
|
||||
|
||||
async def fake_check_proxy(document):
|
||||
started.append(document.proxy_port)
|
||||
if len(started) == len(proxies):
|
||||
release.set()
|
||||
await release.wait()
|
||||
return make_snapshot(document.proxy_port)
|
||||
|
||||
async def fake_record_health_sample(document):
|
||||
return document
|
||||
|
||||
async def fake_publish(_proxy_port, _document):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.mitmproxy.health_monitor.list_proxies",
|
||||
fake_list_proxies,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.mitmproxy.health_monitor.check_proxy",
|
||||
fake_check_proxy,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.mitmproxy.health_monitor.record_health_sample",
|
||||
fake_record_health_sample,
|
||||
)
|
||||
monkeypatch.setattr(monitor, "publish", fake_publish)
|
||||
|
||||
asyncio.run(asyncio.wait_for(monitor.run_cycle(), timeout=1))
|
||||
|
||||
assert started == [8080, 8081, 8082]
|
||||
|
||||
|
||||
def test_run_cycle_continues_when_one_proxy_check_fails(monkeypatch):
|
||||
monitor = HealthMonitor()
|
||||
proxies = [
|
||||
make_proxy(proxy_port=8080),
|
||||
make_proxy(proxy_port=8081),
|
||||
]
|
||||
captured: list[int] = []
|
||||
|
||||
async def fake_list_proxies():
|
||||
return proxies
|
||||
|
||||
async def fake_check_proxy(document):
|
||||
if document.proxy_port == 8080:
|
||||
raise RuntimeError("boom")
|
||||
return make_snapshot(document.proxy_port)
|
||||
|
||||
async def fake_record_health_sample(document):
|
||||
captured.append(document.proxy_port)
|
||||
return document
|
||||
|
||||
async def fake_publish(proxy_port, _document):
|
||||
captured.append(proxy_port + 10_000)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.mitmproxy.health_monitor.list_proxies",
|
||||
fake_list_proxies,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.mitmproxy.health_monitor.check_proxy",
|
||||
fake_check_proxy,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.mitmproxy.health_monitor.record_health_sample",
|
||||
fake_record_health_sample,
|
||||
)
|
||||
monkeypatch.setattr(monitor, "publish", fake_publish)
|
||||
|
||||
asyncio.run(monitor.run_cycle())
|
||||
|
||||
assert captured == [8081, 18081]
|
||||
|
||||
|
||||
def test_run_retries_after_cycle_failure(monkeypatch):
|
||||
monitor = HealthMonitor()
|
||||
calls = {"run_cycle": 0}
|
||||
sleep_calls: list[float] = []
|
||||
|
||||
async def fake_run_cycle():
|
||||
calls["run_cycle"] += 1
|
||||
if calls["run_cycle"] == 1:
|
||||
raise RuntimeError("temporary failure")
|
||||
raise asyncio.CancelledError
|
||||
|
||||
async def fake_sleep(seconds):
|
||||
sleep_calls.append(seconds)
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(monitor, "run_cycle", fake_run_cycle)
|
||||
monkeypatch.setattr(
|
||||
"app.services.mitmproxy.health_monitor.asyncio.sleep",
|
||||
fake_sleep,
|
||||
)
|
||||
|
||||
try:
|
||||
asyncio.run(monitor.run())
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
assert calls["run_cycle"] == 2
|
||||
assert len(sleep_calls) == 1
|
||||
assert sleep_calls[0] == pytest.approx(1.0, abs=0.01)
|
||||
@@ -1,281 +0,0 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.proxy import ProxyDocument
|
||||
from app.models.service import ServiceEndpointDocument
|
||||
from app.services.mitmproxy.manager import MitmdumpError, MitmdumpManager
|
||||
|
||||
|
||||
def make_proxy(**updates):
|
||||
data = {
|
||||
"proxy_port": 8080,
|
||||
"name": "svc",
|
||||
"code": "",
|
||||
"service": ServiceEndpointDocument(port=9000),
|
||||
}
|
||||
data.update(updates)
|
||||
return ProxyDocument(**data)
|
||||
|
||||
|
||||
def test_reverse_mode_is_built_from_service():
|
||||
manager = MitmdumpManager()
|
||||
proxy = make_proxy(protocol="tcp")
|
||||
|
||||
command = manager.build_command(
|
||||
"mitmdump",
|
||||
proxy,
|
||||
9100,
|
||||
Path("/tmp/addon.py"),
|
||||
Path("/tmp/conf/config.yaml"),
|
||||
)
|
||||
|
||||
assert "reverse:tcp://127.0.0.1:9100@8080" in command
|
||||
assert "--listen-port" not in command
|
||||
|
||||
|
||||
def test_raw_mode_spec_overrides_generated_mode():
|
||||
manager = MitmdumpManager()
|
||||
proxy = make_proxy(mode="regular", mode_spec="wireguard:/tmp/wg.conf@51820")
|
||||
|
||||
command = manager.build_command(
|
||||
"mitmdump",
|
||||
proxy,
|
||||
None,
|
||||
Path("/tmp/addon.py"),
|
||||
Path("/tmp/conf/config.yaml"),
|
||||
)
|
||||
|
||||
assert "wireguard:/tmp/wg.conf@51820" in command
|
||||
assert "--listen-port" not in command
|
||||
|
||||
|
||||
def test_regular_mode_uses_proxy_port_as_listen_port():
|
||||
manager = MitmdumpManager()
|
||||
proxy = make_proxy(mode="regular")
|
||||
|
||||
command = manager.build_command(
|
||||
"mitmdump",
|
||||
proxy,
|
||||
None,
|
||||
Path("/tmp/addon.py"),
|
||||
Path("/tmp/conf/config.yaml"),
|
||||
)
|
||||
|
||||
assert "regular" in command
|
||||
assert command[-2:] == ["--listen-port", "8080"]
|
||||
|
||||
|
||||
def test_start_rejects_reserved_backend_port():
|
||||
manager = MitmdumpManager()
|
||||
proxy = make_proxy(proxy_port=3671)
|
||||
|
||||
async def run():
|
||||
await manager.start(proxy)
|
||||
|
||||
with pytest.raises(
|
||||
MitmdumpError,
|
||||
match="Proxy port 3671 is reserved for the backend API",
|
||||
):
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_running_document_without_pid_is_treated_as_stopped():
|
||||
manager = MitmdumpManager()
|
||||
proxy = make_proxy(status="running", pid=None)
|
||||
|
||||
async def run():
|
||||
return await manager.status(proxy)
|
||||
|
||||
assert asyncio.run(run()) == "stopped"
|
||||
|
||||
|
||||
def test_matches_proxy_process_uses_expected_script_and_confdir():
|
||||
manager = MitmdumpManager()
|
||||
proxy = make_proxy()
|
||||
|
||||
manager.read_cmdline = lambda _pid: [
|
||||
"/home/test/.venv/bin/python",
|
||||
"/home/test/.venv/bin/mitmdump",
|
||||
"--mode",
|
||||
"reverse:http://127.0.0.1:9000@8080",
|
||||
"--listen-host",
|
||||
"0.0.0.0",
|
||||
"--set",
|
||||
"confdir=/tmp/theplug/conf/8080",
|
||||
"-s",
|
||||
"/tmp/theplug/addons/8080.py",
|
||||
]
|
||||
|
||||
assert manager.matches_proxy_process(proxy, 1234) is True
|
||||
|
||||
|
||||
def test_reconcile_reattaches_matching_process():
|
||||
manager = MitmdumpManager()
|
||||
proxy = make_proxy(
|
||||
status="running",
|
||||
pid=4321,
|
||||
service=ServiceEndpointDocument(port=9000, upstream_port=54003),
|
||||
)
|
||||
manager.is_process_alive = lambda _pid: True
|
||||
manager.matches_proxy_process = lambda _proxy, _pid: True
|
||||
|
||||
async def run():
|
||||
return await manager.reconcile(proxy)
|
||||
|
||||
values = asyncio.run(run())
|
||||
|
||||
assert values is not None
|
||||
assert values["status"] == "running"
|
||||
assert values["pid"] == 4321
|
||||
assert manager.attached_pids[8080] == 4321
|
||||
|
||||
|
||||
def test_reconcile_marks_error_for_mismatched_live_process():
|
||||
manager = MitmdumpManager()
|
||||
proxy = make_proxy(
|
||||
status="running",
|
||||
pid=4321,
|
||||
service=ServiceEndpointDocument(port=9000, upstream_port=54003),
|
||||
)
|
||||
manager.is_process_alive = lambda _pid: True
|
||||
manager.matches_proxy_process = lambda _proxy, _pid: False
|
||||
|
||||
async def run():
|
||||
return await manager.reconcile(proxy)
|
||||
|
||||
values = asyncio.run(run())
|
||||
|
||||
assert values is not None
|
||||
assert values["status"] == "error"
|
||||
assert values["pid"] is None
|
||||
assert "does not match" in str(values["last_error"])
|
||||
|
||||
|
||||
def test_reconcile_restores_upstream_when_process_is_gone():
|
||||
manager = MitmdumpManager()
|
||||
proxy = make_proxy(
|
||||
status="running",
|
||||
pid=4321,
|
||||
service=ServiceEndpointDocument(
|
||||
port=9000,
|
||||
upstream_port=54003,
|
||||
name="echo",
|
||||
folder="/tmp/playground",
|
||||
),
|
||||
)
|
||||
manager.is_process_alive = lambda _pid: False
|
||||
|
||||
async def fake_restore(_proxy, _upstream_port):
|
||||
return True
|
||||
|
||||
manager.restore_upstream = fake_restore
|
||||
|
||||
async def run():
|
||||
return await manager.reconcile(proxy)
|
||||
|
||||
values = asyncio.run(run())
|
||||
|
||||
assert values == {
|
||||
"status": "stopped",
|
||||
"pid": None,
|
||||
"service.upstream_port": None,
|
||||
"last_error": None,
|
||||
}
|
||||
|
||||
|
||||
def test_cleanup_runtime_artifacts_removes_generated_files(tmp_path: Path):
|
||||
manager = MitmdumpManager(work_dir=str(tmp_path / "theplug"))
|
||||
proxy = make_proxy(
|
||||
script_path=str(manager.script_path_for(make_proxy())),
|
||||
config_path=str(manager.config_path_for(make_proxy())),
|
||||
log_path=str(manager.log_path_for(make_proxy())),
|
||||
)
|
||||
|
||||
script_path = Path(proxy.script_path)
|
||||
config_path = Path(proxy.config_path)
|
||||
log_path = Path(proxy.log_path)
|
||||
script_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
script_path.write_text("print('hello')", encoding="utf-8")
|
||||
config_path.write_text("block_global: false\n", encoding="utf-8")
|
||||
log_path.write_text("old log\n", encoding="utf-8")
|
||||
|
||||
manager.cleanup_runtime_artifacts(proxy)
|
||||
|
||||
assert script_path.exists() is False
|
||||
assert log_path.exists() is False
|
||||
assert config_path.exists() is False
|
||||
assert manager.conf_dir_for(proxy).exists() is False
|
||||
|
||||
|
||||
def test_prepare_upstream_retries_until_port_remap_succeeds(monkeypatch):
|
||||
manager = MitmdumpManager()
|
||||
proxy = make_proxy(
|
||||
mode="reverse",
|
||||
service=ServiceEndpointDocument(
|
||||
port=9000,
|
||||
name="echo",
|
||||
folder="/tmp/playground",
|
||||
),
|
||||
)
|
||||
attempted_ports: list[int] = []
|
||||
candidate_ports = iter([54001, 54002, 54003])
|
||||
|
||||
async def fake_change_port(
|
||||
_service_name,
|
||||
_folder,
|
||||
_port_from,
|
||||
port_to,
|
||||
**_kwargs,
|
||||
):
|
||||
attempted_ports.append(port_to)
|
||||
return port_to == 54003
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.mitmproxy.manager.get_random_port",
|
||||
lambda: next(candidate_ports),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.mitmproxy.manager.change_port",
|
||||
fake_change_port,
|
||||
)
|
||||
|
||||
upstream_port = asyncio.run(manager.prepare_upstream(proxy))
|
||||
|
||||
assert upstream_port == 54003
|
||||
assert attempted_ports == [54001, 54002, 54003]
|
||||
|
||||
|
||||
def test_prepare_upstream_fails_after_retry_budget(monkeypatch):
|
||||
manager = MitmdumpManager()
|
||||
manager.upstream_port_attempts = 3
|
||||
proxy = make_proxy(
|
||||
mode="reverse",
|
||||
service=ServiceEndpointDocument(
|
||||
port=9000,
|
||||
name="echo",
|
||||
folder="/tmp/playground",
|
||||
),
|
||||
)
|
||||
candidate_ports = iter([54001, 54002, 54003])
|
||||
|
||||
async def fake_change_port(*_args, **_kwargs):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.mitmproxy.manager.get_random_port",
|
||||
lambda: next(candidate_ports),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.mitmproxy.manager.change_port",
|
||||
fake_change_port,
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
MitmdumpError,
|
||||
match="Unable to move Docker service port after retries",
|
||||
):
|
||||
asyncio.run(manager.prepare_upstream(proxy))
|
||||
@@ -1,52 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from app.services.mitmproxy.options import (
|
||||
MitmOption,
|
||||
parse_options,
|
||||
public_options,
|
||||
validate_value,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_options_extracts_types_and_choices():
|
||||
text = """
|
||||
# Enable feature. Type bool.
|
||||
enabled: false
|
||||
|
||||
# Valid values are 'eager', 'lazy'.
|
||||
strategy: eager
|
||||
|
||||
# Hosts. Type sequence of str.
|
||||
hosts: []
|
||||
"""
|
||||
options = parse_options(
|
||||
text,
|
||||
{"enabled": False, "strategy": "eager", "hosts": []},
|
||||
)
|
||||
|
||||
assert options["enabled"].value_type == "bool"
|
||||
assert options["strategy"].choices == ["eager", "lazy"]
|
||||
assert options["hosts"].value_type == "sequence of str"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("option", "value", "message"),
|
||||
[
|
||||
(MitmOption("anticache", "bool", False, "", []), "true", "expected bool"),
|
||||
(MitmOption("flow_detail", "int", 1, "", []), "1", "expected int"),
|
||||
(MitmOption("allow_hosts", "sequence of str", [], "", []), "x", "list[str]"),
|
||||
(MitmOption("strategy", "unknown", "eager", "", ["eager"]), "lazy", "one of"),
|
||||
],
|
||||
)
|
||||
def test_validate_value_rejects_wrong_types(option, value, message):
|
||||
assert message in validate_value(option, value)
|
||||
|
||||
|
||||
def test_public_options_include_docs_links():
|
||||
options = {
|
||||
"flow_detail": MitmOption("flow_detail", "int", 1, "Flow detail.", []),
|
||||
}
|
||||
|
||||
public = public_options(options)
|
||||
|
||||
assert public[0]["docs_url"].endswith("#flow_detail")
|
||||
@@ -1,30 +0,0 @@
|
||||
from app.services.editor import python_lsp
|
||||
|
||||
|
||||
def test_encode_message():
|
||||
message = '{"jsonrpc":"2.0"}'
|
||||
|
||||
payload = python_lsp.encode_message(message)
|
||||
|
||||
assert payload.endswith(message.encode("utf-8"))
|
||||
assert payload.startswith(
|
||||
f"Content-Length: {len(message.encode('utf-8'))}\r\n\r\n".encode("ascii")
|
||||
)
|
||||
|
||||
|
||||
def test_frame_buffer_reads_split_messages():
|
||||
buffer = python_lsp.LspFrameBuffer()
|
||||
first = python_lsp.encode_message('{"id":1}')
|
||||
second = python_lsp.encode_message('{"id":2}')
|
||||
|
||||
parts = [
|
||||
first[:12],
|
||||
first[12:] + second[:10],
|
||||
second[10:],
|
||||
]
|
||||
|
||||
messages: list[str] = []
|
||||
for part in parts:
|
||||
messages.extend(buffer.feed(part))
|
||||
|
||||
assert messages == ['{"id":1}', '{"id":2}']
|
||||
@@ -1,76 +0,0 @@
|
||||
import asyncio
|
||||
|
||||
from app.models.proxy import ProxyDocument
|
||||
from app.models.service import ServiceEndpointDocument
|
||||
from app.services.mitmproxy import recovery as recovery_module
|
||||
|
||||
|
||||
def make_proxy(**updates):
|
||||
data = {
|
||||
"proxy_port": 8080,
|
||||
"name": "svc",
|
||||
"code": "",
|
||||
"service": ServiceEndpointDocument(port=9000),
|
||||
}
|
||||
data.update(updates)
|
||||
return ProxyDocument(**data)
|
||||
|
||||
|
||||
def test_reconcile_runtime_persists_manager_updates(monkeypatch):
|
||||
proxy = make_proxy(status="running", pid=1234)
|
||||
captured: list[tuple[int, dict[str, object]]] = []
|
||||
|
||||
async def fake_list_proxies():
|
||||
return [proxy]
|
||||
|
||||
class FakeManager:
|
||||
async def reconcile(self, document: ProxyDocument):
|
||||
assert document.proxy_port == 8080
|
||||
return {"status": "running", "pid": 1234, "last_error": None}
|
||||
|
||||
async def fake_update_proxy_runtime(proxy_port: int, values: dict[str, object]):
|
||||
captured.append((proxy_port, values))
|
||||
return proxy
|
||||
|
||||
monkeypatch.setattr(recovery_module, "list_proxies", fake_list_proxies)
|
||||
monkeypatch.setattr(recovery_module, "manager", FakeManager())
|
||||
monkeypatch.setattr(
|
||||
recovery_module,
|
||||
"update_proxy_runtime",
|
||||
fake_update_proxy_runtime,
|
||||
)
|
||||
|
||||
changes = asyncio.run(recovery_module.reconcile_runtime())
|
||||
|
||||
assert changes == [(8080, {"status": "running", "pid": 1234, "last_error": None})]
|
||||
assert captured == [(8080, {"status": "running", "pid": 1234, "last_error": None})]
|
||||
|
||||
|
||||
def test_reconcile_runtime_skips_unchanged_proxies(monkeypatch):
|
||||
proxy = make_proxy()
|
||||
updated = False
|
||||
|
||||
async def fake_list_proxies():
|
||||
return [proxy]
|
||||
|
||||
class FakeManager:
|
||||
async def reconcile(self, _document: ProxyDocument):
|
||||
return None
|
||||
|
||||
async def fake_update_proxy_runtime(_proxy_port: int, _values: dict[str, object]):
|
||||
nonlocal updated
|
||||
updated = True
|
||||
return proxy
|
||||
|
||||
monkeypatch.setattr(recovery_module, "list_proxies", fake_list_proxies)
|
||||
monkeypatch.setattr(recovery_module, "manager", FakeManager())
|
||||
monkeypatch.setattr(
|
||||
recovery_module,
|
||||
"update_proxy_runtime",
|
||||
fake_update_proxy_runtime,
|
||||
)
|
||||
|
||||
changes = asyncio.run(recovery_module.reconcile_runtime())
|
||||
|
||||
assert changes == []
|
||||
assert updated is False
|
||||
@@ -1,56 +0,0 @@
|
||||
import logging
|
||||
|
||||
import yaml
|
||||
|
||||
from app.services.mitmproxy.templates import list_file_templates
|
||||
|
||||
|
||||
def test_file_templates_use_four_space_indentation():
|
||||
for template in list_file_templates():
|
||||
for line in template.code.splitlines():
|
||||
assert "\t" not in line, f"{template.id} contains a tab: {line!r}"
|
||||
if not line.strip():
|
||||
continue
|
||||
indent = len(line) - len(line.lstrip(" "))
|
||||
assert indent % 4 == 0, (
|
||||
f"{template.id} has non-4-space indentation: {line!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_list_file_templates_loads_yaml_backed_templates():
|
||||
templates = list_file_templates()
|
||||
|
||||
assert templates
|
||||
assert all(template.origin == "file" for template in templates)
|
||||
assert all(template.built_in is True for template in templates)
|
||||
assert all(template.protocol in {"http", "tcp"} for template in templates)
|
||||
|
||||
|
||||
def test_list_file_templates_skips_invalid_files(tmp_path, caplog):
|
||||
path = tmp_path / "invalid.yaml"
|
||||
path.write_text("[]\n", encoding="utf-8")
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
templates = list_file_templates(tmp_path)
|
||||
|
||||
assert templates == []
|
||||
assert "Skipping template file" in caplog.text
|
||||
|
||||
|
||||
def test_list_file_templates_skips_duplicate_ids(tmp_path, caplog):
|
||||
payload = {
|
||||
"id": "duplicate",
|
||||
"name": "Duplicate",
|
||||
"protocol": "http",
|
||||
"description": "Duplicate template",
|
||||
"code": "from mitmproxy import http\n",
|
||||
}
|
||||
document = yaml.safe_dump(payload, sort_keys=False)
|
||||
(tmp_path / "one.yaml").write_text(document, encoding="utf-8")
|
||||
(tmp_path / "two.yaml").write_text(document, encoding="utf-8")
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
templates = list_file_templates(tmp_path)
|
||||
|
||||
assert templates == []
|
||||
assert "duplicate file-backed template id" in caplog.text
|
||||
@@ -1,16 +0,0 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.core.security import create_access_token, decode_access_token
|
||||
|
||||
|
||||
def test_create_access_token_uses_twelve_hour_default_window(monkeypatch):
|
||||
monkeypatch.setattr("app.core.security.settings.JWT_EXPIRE_MINUTES", 720)
|
||||
|
||||
issued_at = datetime.now(UTC)
|
||||
token = create_access_token(subject="admin")
|
||||
payload = decode_access_token(token)
|
||||
expires_at = datetime.fromtimestamp(payload.exp, UTC)
|
||||
|
||||
lifetime_seconds = (expires_at - issued_at).total_seconds()
|
||||
|
||||
assert 11.9 * 60 * 60 <= lifetime_seconds <= 12.1 * 60 * 60
|
||||
@@ -1,182 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${GREEN}[+]${NC} $*"; }
|
||||
die() { echo -e "${RED}[x] FATAL:${NC} $*" >&2; exit 1; }
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BACKEND_DIR="$ROOT_DIR/backend"
|
||||
FRONTEND_DIR="$ROOT_DIR/frontend"
|
||||
PLAYGROUND_DIR="$ROOT_DIR/playground"
|
||||
ENV_FILE="$BACKEND_DIR/.env"
|
||||
|
||||
require_command() {
|
||||
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
|
||||
}
|
||||
|
||||
require_env() {
|
||||
[[ -f "$ENV_FILE" ]] || die "Missing $ENV_FILE. Run ./setup.sh first."
|
||||
}
|
||||
|
||||
env_value() {
|
||||
local key="$1"
|
||||
local default_value="${2:-}"
|
||||
local value
|
||||
value=$(grep -E "^${key}=" "$ENV_FILE" | cut -d '=' -f2- || true)
|
||||
if [[ -n "$value" ]]; then
|
||||
printf '%s\n' "$value"
|
||||
return
|
||||
fi
|
||||
printf '%s\n' "$default_value"
|
||||
}
|
||||
|
||||
compose() {
|
||||
docker compose --env-file "$ENV_FILE" -f "$ROOT_DIR/docker-compose.yml" "$@"
|
||||
}
|
||||
|
||||
service_compose_path() {
|
||||
local name="$1"
|
||||
local path="$PLAYGROUND_DIR/$name/docker-compose.yml"
|
||||
[[ -f "$path" ]] || die "Unknown service group: $name"
|
||||
printf '%s\n' "$path"
|
||||
}
|
||||
|
||||
service_names() {
|
||||
find "$PLAYGROUND_DIR" -mindepth 2 -maxdepth 2 -name docker-compose.yml -printf '%h\n' \
|
||||
| xargs -r -n1 basename \
|
||||
| sort
|
||||
}
|
||||
|
||||
run_backend() {
|
||||
require_command uv
|
||||
require_env
|
||||
local api_port
|
||||
api_port=$(env_value API_PORT 3671)
|
||||
info "Starting backend dev server on http://0.0.0.0:$api_port"
|
||||
(
|
||||
cd "$BACKEND_DIR"
|
||||
uv run fastapi dev app/main.py --host 0.0.0.0 --port "$api_port"
|
||||
)
|
||||
}
|
||||
|
||||
run_frontend() {
|
||||
require_command npm
|
||||
require_env
|
||||
info "Starting frontend Vite dev server"
|
||||
(
|
||||
cd "$FRONTEND_DIR"
|
||||
npm run dev
|
||||
)
|
||||
}
|
||||
|
||||
run_mongo() {
|
||||
require_command docker
|
||||
require_env
|
||||
local action="${1:-status}"
|
||||
case "$action" in
|
||||
up)
|
||||
compose up -d mongodb
|
||||
;;
|
||||
down)
|
||||
compose stop mongodb
|
||||
;;
|
||||
status)
|
||||
compose ps mongodb
|
||||
;;
|
||||
logs)
|
||||
compose logs -f mongodb
|
||||
;;
|
||||
*)
|
||||
die "Unknown mongo action: $action"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
run_services() {
|
||||
require_command docker
|
||||
local action="${1:-list}"
|
||||
shift || true
|
||||
|
||||
case "$action" in
|
||||
list)
|
||||
service_names
|
||||
;;
|
||||
up|down|status)
|
||||
local target="${1:-all}"
|
||||
if [[ "$target" == "all" ]]; then
|
||||
while IFS= read -r name; do
|
||||
[[ -n "$name" ]] || continue
|
||||
local compose_file
|
||||
compose_file=$(service_compose_path "$name")
|
||||
case "$action" in
|
||||
up) docker compose -f "$compose_file" up -d ;;
|
||||
down) docker compose -f "$compose_file" down ;;
|
||||
status) docker compose -f "$compose_file" ps ;;
|
||||
esac
|
||||
done < <(service_names)
|
||||
return
|
||||
fi
|
||||
|
||||
local compose_file
|
||||
compose_file=$(service_compose_path "$target")
|
||||
case "$action" in
|
||||
up) docker compose -f "$compose_file" up -d ;;
|
||||
down) docker compose -f "$compose_file" down ;;
|
||||
status) docker compose -f "$compose_file" ps ;;
|
||||
esac
|
||||
;;
|
||||
*)
|
||||
die "Unknown services action: $action"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
show_help() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
./dev.sh backend
|
||||
./dev.sh frontend
|
||||
./dev.sh mongo [up|down|status|logs]
|
||||
./dev.sh services [list|up|down|status] [name|all]
|
||||
|
||||
Examples:
|
||||
./dev.sh mongo up
|
||||
./dev.sh backend
|
||||
./dev.sh frontend
|
||||
./dev.sh services list
|
||||
./dev.sh services up GabibbiTowers
|
||||
EOF
|
||||
}
|
||||
|
||||
main() {
|
||||
local command="${1:-help}"
|
||||
shift || true
|
||||
|
||||
case "$command" in
|
||||
backend)
|
||||
run_backend "$@"
|
||||
;;
|
||||
frontend)
|
||||
run_frontend "$@"
|
||||
;;
|
||||
mongo)
|
||||
run_mongo "$@"
|
||||
;;
|
||||
services)
|
||||
run_services "$@"
|
||||
;;
|
||||
help|-h|--help)
|
||||
show_help
|
||||
;;
|
||||
*)
|
||||
die "Unknown command: $command"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,80 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.test
|
||||
container_name: theplug-app-test
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
MONGODB_URI: mongodb://theplug:${MONGO_INITDB_ROOT_PASSWORD}@mongodb:27017/${MONGO_DB:-theplug}?authSource=admin
|
||||
MONGODB_DB_NAME: ${MONGO_DB:-theplug}
|
||||
REDIS_URL: redis://redis:6379
|
||||
API_PORT: ${API_PORT:-3671}
|
||||
DEBUG: "true"
|
||||
depends_on:
|
||||
mongodb:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${API_PORT:-3671}:${API_PORT:-3671}"
|
||||
volumes:
|
||||
- ./example-http-one:/root/example-http-one:ro
|
||||
- ./example-http-two:/root/example-http-two:ro
|
||||
|
||||
mongodb:
|
||||
image: mongo:8.2
|
||||
container_name: theplug-mongodb-test
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-theplug}
|
||||
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD}
|
||||
MONGO_INITDB_DATABASE: ${MONGO_DB:-theplug}
|
||||
ports:
|
||||
- "27017:27017"
|
||||
volumes:
|
||||
- mongodb_test_data:/data/db
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mongosh --quiet --username ${MONGO_USER:-theplug} --password ${MONGO_INITDB_ROOT_PASSWORD} --authenticationDatabase admin --eval \"db.adminCommand({ ping: 1 }).ok\" | grep 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
redis:
|
||||
image: redis:8-alpine
|
||||
container_name: theplug-redis-test
|
||||
restart: unless-stopped
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_test_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
|
||||
example-http-one:
|
||||
build:
|
||||
context: ./example-http-one
|
||||
container_name: theplug-example-http-one
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18081:8080"
|
||||
|
||||
example-http-two:
|
||||
build:
|
||||
context: ./example-http-two
|
||||
container_name: theplug-example-http-two
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18082:8080"
|
||||
|
||||
volumes:
|
||||
mongodb_test_data:
|
||||
redis_test_data:
|
||||
+21
-16
@@ -1,34 +1,39 @@
|
||||
services:
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
container_name: theplug-frontend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
API_UPSTREAM: "host.docker.internal:${API_PORT:-3671}"
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-5173}:80"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
mongodb:
|
||||
image: mongo:8.2
|
||||
container_name: theplug-mongodb
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-theplug}
|
||||
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD:-theplug_dev_password}
|
||||
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD}
|
||||
MONGO_INITDB_DATABASE: ${MONGO_DB:-theplug}
|
||||
ports:
|
||||
- "27017:27017"
|
||||
volumes:
|
||||
- theplug_mongodb:/data/db
|
||||
- mongodb_data:/data/db
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mongosh --quiet --username ${MONGO_USER:-theplug} --password ${MONGO_INITDB_ROOT_PASSWORD:-theplug_dev_password} --authenticationDatabase admin --eval \"db.adminCommand({ ping: 1 }).ok\" | grep 1"]
|
||||
test: ["CMD-SHELL", "mongosh --quiet --username ${MONGO_USER:-theplug} --password ${MONGO_INITDB_ROOT_PASSWORD} --authenticationDatabase admin --eval \"db.adminCommand({ ping: 1 }).ok\" | grep 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
redis:
|
||||
image: redis:8-alpine
|
||||
container_name: theplug-redis
|
||||
restart: unless-stopped
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
|
||||
volumes:
|
||||
theplug_mongodb:
|
||||
mongodb_data:
|
||||
redis_data:
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
FROM python:3.14-slim
|
||||
|
||||
WORKDIR /srv/www
|
||||
|
||||
COPY index.html ./
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["python", "-m", "http.server", "8080", "--bind", "0.0.0.0", "--directory", "/srv/www"]
|
||||
@@ -0,0 +1,6 @@
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
container_name: example-http-one
|
||||
ports:
|
||||
- "18081:8080"
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Example HTTP One</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Example HTTP One</h1>
|
||||
<p>This container serves a static page with Python's http.server.</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,9 @@
|
||||
FROM python:3.14-slim
|
||||
|
||||
WORKDIR /srv/www
|
||||
|
||||
COPY index.html ./
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["python", "-m", "http.server", "8080", "--bind", "0.0.0.0", "--directory", "/srv/www"]
|
||||
@@ -0,0 +1,6 @@
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
container_name: example-http-two
|
||||
ports:
|
||||
- "18082:8080"
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Example HTTP Two</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Example HTTP Two</h1>
|
||||
<p>This container serves a static page with Python's http.server.</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,4 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
npm-debug.log
|
||||
@@ -1,16 +0,0 @@
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.29-alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/templates/default.conf.template
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
@@ -1,12 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>thePlug</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,22 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://${API_UPSTREAM}/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
Generated
-1692
File diff suppressed because it is too large
Load Diff
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"name": "theplug-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview --host 0.0.0.0",
|
||||
"typecheck": "vue-tsc -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.1",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/lint": "^6.9.5",
|
||||
"@codemirror/lsp-client": "^6.2.3",
|
||||
"@codemirror/search": "^6.7.0",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.41.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"vue": "^3.5.26",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.7",
|
||||
"vue-tsc": "^3.1.8"
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, provide } from "vue";
|
||||
|
||||
import AppHeader from "./components/AppHeader.vue";
|
||||
import AppToast from "./components/AppToast.vue";
|
||||
import LoginView from "./components/LoginView.vue";
|
||||
import ProxyList from "./components/ProxyList.vue";
|
||||
import ProxyWorkbench from "./components/ProxyWorkbench.vue";
|
||||
import {
|
||||
controlPlaneKey,
|
||||
useControlPlane,
|
||||
} from "./composables/useControlPlane";
|
||||
|
||||
const plane = useControlPlane();
|
||||
provide(controlPlaneKey, plane);
|
||||
|
||||
onMounted(() => {
|
||||
if (plane.token) void plane.refreshAll();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LoginView
|
||||
v-if="!plane.token"
|
||||
v-model:username="plane.username"
|
||||
v-model:password="plane.password"
|
||||
:loading="plane.loading"
|
||||
:error="plane.authMessage || plane.loginError"
|
||||
@submit="plane.signIn"
|
||||
/>
|
||||
|
||||
<main v-else class="app-shell">
|
||||
<AppToast v-if="plane.notice" :message="plane.notice" />
|
||||
|
||||
<AppHeader
|
||||
:loading="plane.loading"
|
||||
:show-discovery="Boolean(plane.selectedProxy)"
|
||||
@discovery="plane.openDiscovery"
|
||||
@refresh="plane.refreshAll"
|
||||
@create="plane.openCreateWizard()"
|
||||
@logout="plane.signOut"
|
||||
/>
|
||||
|
||||
<div class="app-body">
|
||||
<ProxyList
|
||||
:proxies="plane.proxies"
|
||||
:selected-port="plane.selectedPort"
|
||||
@select="plane.selectedPort = $event"
|
||||
/>
|
||||
|
||||
<ProxyWorkbench />
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
@@ -1,128 +0,0 @@
|
||||
import type {
|
||||
HealthHistoryResponse,
|
||||
HealthResult,
|
||||
MitmOption,
|
||||
PythonEditorSession,
|
||||
ProxyDocument,
|
||||
ProxyDraft,
|
||||
ProxyTemplate,
|
||||
ProxyTemplateCreateInput,
|
||||
ServiceDiscoveryResponse,
|
||||
} from "./types";
|
||||
|
||||
const TOKEN_KEY = "theplug.token";
|
||||
export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export function getToken() {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setToken(token: string | null) {
|
||||
if (token) {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
return;
|
||||
}
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function buildSocketUrl(path: string) {
|
||||
const token = getToken();
|
||||
const base = new URL(API_BASE, window.location.origin);
|
||||
base.protocol = base.protocol === "https:" ? "wss:" : "ws:";
|
||||
base.pathname = `${base.pathname.replace(/\/$/, "")}${path}`;
|
||||
if (token) {
|
||||
base.searchParams.set("token", token);
|
||||
}
|
||||
return base.toString();
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
const token = getToken();
|
||||
|
||||
if (token) {
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
if (init.body && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${path}`, { ...init, headers });
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
const body = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const detail = body?.detail ?? response.statusText;
|
||||
const message =
|
||||
typeof detail === "string" ? detail : JSON.stringify(detail);
|
||||
throw new ApiError(response.status, message);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string) {
|
||||
const response = await request<{ access_token: string }>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
setToken(response.access_token);
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
await request<void>("/auth/logout", { method: "POST" }).catch(() => undefined);
|
||||
setToken(null);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
proxies: () => request<ProxyDocument[]>("/proxy"),
|
||||
services: (scanRoot?: string) =>
|
||||
request<ServiceDiscoveryResponse>(
|
||||
`/proxy/services${scanRoot ? `?scan_root=${encodeURIComponent(scanRoot)}` : ""}`,
|
||||
),
|
||||
options: () => request<MitmOption[]>("/proxy/mitmproxy/options"),
|
||||
templates: () => request<ProxyTemplate[]>("/proxy/mitmproxy/templates"),
|
||||
editorSession: () =>
|
||||
request<PythonEditorSession>("/proxy/editor/python/session"),
|
||||
createTemplate: (payload: ProxyTemplateCreateInput) =>
|
||||
request<ProxyTemplate>("/proxy/mitmproxy/templates", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
deleteTemplate: (id: string) =>
|
||||
request<void>(`/proxy/mitmproxy/templates/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
createProxy: (payload: ProxyDraft) =>
|
||||
request<ProxyDocument>("/proxy", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
updateProxy: (port: number, payload: Partial<ProxyDraft>) =>
|
||||
request<ProxyDocument>(`/proxy/${port}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
deleteProxy: (port: number) =>
|
||||
request<void>(`/proxy/${port}`, { method: "DELETE" }),
|
||||
start: (port: number) =>
|
||||
request<ProxyDocument>(`/proxy/${port}/start`, { method: "POST" }),
|
||||
stop: (port: number) =>
|
||||
request<ProxyDocument>(`/proxy/${port}/stop`, { method: "POST" }),
|
||||
restart: (port: number) =>
|
||||
request<ProxyDocument>(`/proxy/${port}/restart`, { method: "POST" }),
|
||||
health: (port: number) => request<HealthResult>(`/proxy/${port}/health`),
|
||||
healthHistory: (port: number) =>
|
||||
request<HealthHistoryResponse>(`/proxy/${port}/health/history`),
|
||||
logs: (port: number) => request<{ log: string }>(`/proxy/${port}/logs`),
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import AppLogo from "./AppLogo.vue";
|
||||
import UiIcon from "./UiIcon.vue";
|
||||
|
||||
defineProps<{
|
||||
loading: boolean;
|
||||
showDiscovery: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
discovery: [];
|
||||
refresh: [];
|
||||
create: [];
|
||||
logout: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<AppLogo />
|
||||
<div>
|
||||
<strong>thePlug</strong>
|
||||
<small>il proxy barese</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-meta">
|
||||
<span v-if="loading" class="muted">saving</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button v-if="showDiscovery" class="ghost button-icon" :disabled="loading" @click="emit('discovery')">
|
||||
<UiIcon name="back" />
|
||||
<span>Services</span>
|
||||
</button>
|
||||
<button class="ghost button-icon" :disabled="loading" @click="emit('refresh')">
|
||||
<UiIcon name="refresh" />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
<button class="primary button-icon" @click="emit('create')">
|
||||
<UiIcon name="plus" />
|
||||
<span>New</span>
|
||||
</button>
|
||||
<button class="ghost button-icon" @click="emit('logout')">
|
||||
<UiIcon name="logout" />
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<svg class="logo" viewBox="0 0 64 64" role="img" aria-label="thePlug logo">
|
||||
<rect x="10" y="10" width="44" height="44" rx="10" />
|
||||
<path d="M26 19v10M38 19v10" />
|
||||
<path d="M22 29h20v6a10 10 0 0 1-10 10 10 10 0 0 1-10-10v-6z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,11 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
message: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-toast" role="status" aria-live="polite">
|
||||
{{ message }}
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,152 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { basicSetup, EditorView } from "codemirror";
|
||||
import { keymap } from "@codemirror/view";
|
||||
import { python } from "@codemirror/lang-python";
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
|
||||
import { api } from "../api";
|
||||
import { connectPythonLsp } from "../editor/pythonLsp";
|
||||
|
||||
const model = defineModel<string>({ required: true });
|
||||
const emit = defineEmits<{
|
||||
save: [];
|
||||
}>();
|
||||
const host = ref<HTMLElement | null>(null);
|
||||
const error = ref("");
|
||||
let editor: EditorView | null = null;
|
||||
let languageClient: Awaited<ReturnType<typeof connectPythonLsp>>["client"] | null =
|
||||
null;
|
||||
let languageSocket: WebSocket | null = null;
|
||||
|
||||
const editorTheme = EditorView.theme(
|
||||
{
|
||||
"&": {
|
||||
height: "100%",
|
||||
maxHeight: "100%",
|
||||
color: "#edf2f7",
|
||||
backgroundColor: "#0b0f12",
|
||||
fontSize: "14px",
|
||||
},
|
||||
".cm-editor": {
|
||||
height: "100%",
|
||||
maxHeight: "100%",
|
||||
},
|
||||
".cm-scroller": {
|
||||
overflow: "auto",
|
||||
fontFamily: "IBM Plex Mono, JetBrains Mono, ui-monospace, monospace",
|
||||
lineHeight: "1.6",
|
||||
},
|
||||
".cm-content": {
|
||||
minWidth: "fit-content",
|
||||
padding: "14px 0",
|
||||
caretColor: "#e7fbff",
|
||||
},
|
||||
".cm-gutters": {
|
||||
border: "none",
|
||||
backgroundColor: "#0b0f12",
|
||||
color: "#6f7c86",
|
||||
},
|
||||
".cm-activeLine": {
|
||||
backgroundColor: "#12181d",
|
||||
},
|
||||
".cm-activeLineGutter": {
|
||||
backgroundColor: "#12181d",
|
||||
},
|
||||
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
|
||||
backgroundColor: "#1b2630",
|
||||
},
|
||||
".cm-tooltip": {
|
||||
border: "1px solid #24303a",
|
||||
backgroundColor: "#10151a",
|
||||
color: "#edf2f7",
|
||||
},
|
||||
".cm-panels": {
|
||||
backgroundColor: "#10151a",
|
||||
color: "#edf2f7",
|
||||
},
|
||||
".cm-searchMatch": {
|
||||
backgroundColor: "#23303b",
|
||||
outline: "1px solid #334455",
|
||||
},
|
||||
".cm-cursor": {
|
||||
borderLeftColor: "#e7fbff",
|
||||
},
|
||||
},
|
||||
{ dark: true },
|
||||
);
|
||||
|
||||
function emitSave() {
|
||||
emit("save");
|
||||
return true;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!host.value) return;
|
||||
void mountEditor();
|
||||
});
|
||||
|
||||
watch(model, (value) => {
|
||||
if (!editor) return;
|
||||
const current = editor.state.doc.toString();
|
||||
if (current === value) return;
|
||||
editor.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: current.length,
|
||||
insert: value,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
languageClient?.disconnect();
|
||||
languageClient = null;
|
||||
languageSocket?.close();
|
||||
languageSocket = null;
|
||||
editor?.destroy();
|
||||
editor = null;
|
||||
});
|
||||
|
||||
async function mountEditor() {
|
||||
if (!host.value) return;
|
||||
|
||||
try {
|
||||
error.value = "";
|
||||
const session = await api.editorSession();
|
||||
const connection = await connectPythonLsp(session);
|
||||
languageClient = connection.client;
|
||||
languageSocket = connection.socket;
|
||||
void languageClient.initializing.catch(() => {
|
||||
error.value = "Python language tools are unavailable.";
|
||||
});
|
||||
|
||||
editor = new EditorView({
|
||||
doc: model.value,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
python(),
|
||||
editorTheme,
|
||||
keymap.of([{ key: "Mod-s", run: emitSave }]),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (!update.docChanged) return;
|
||||
const value = update.state.doc.toString();
|
||||
if (value !== model.value) model.value = value;
|
||||
}),
|
||||
languageClient.plugin(session.file_uri, "python"),
|
||||
],
|
||||
parent: host.value,
|
||||
});
|
||||
} catch (value) {
|
||||
const message =
|
||||
value instanceof Error ? value.message : "Unable to start the editor";
|
||||
error.value = message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="code-editor-wrap">
|
||||
<p v-if="error" class="field-error editor-error">{{ error }}</p>
|
||||
<div ref="host" class="code-editor" />
|
||||
</div>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user