12 Commits
29 changed files with 808 additions and 128 deletions
+5 -5
View File
@@ -1,10 +1,10 @@
API_PORT=3671 API_PORT=3671
MONGO_INITDB_ROOT_PASSWORD=pass MONGO_INITDB_ROOT_PASSWORD=pass# esegui 'openssl rand -hex 5' in un terminale e copia qui
MONGODB_URI=mongodb://theplug:pass@mongodb:27017/theplug?authSource=admin MONGODB_URI=mongodb://theplug:pass@localhost:27017/theplug?authSource=admin
MONGODB_DB_NAME=theplug MONGODB_DB_NAME=theplug
REDIS_URL=redis://redis:6379 REDIS_URL=redis://localhost:6379
JWT_SECRET_KEY=change-this-secret JWT_SECRET_KEY= # esegui 'openssl rand -hex 20' in un terminale e copia qui
JWT_ALGORITHM=HS256 JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=30 JWT_EXPIRE_MINUTES=30
AUTH_USERNAME=admin AUTH_USERNAME=admin
AUTH_PASSWORD=change-this-password AUTH_PASSWORD= # esegui 'openssl rand -hex 20' in un terminale e copia qui
-32
View File
@@ -1,32 +0,0 @@
name: Build and Push to Registry
on:
push:
branches:
- master
jobs:
build:
runs-on: ubuntu-latest
container: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.reloia.uk
username: reloia
password: ${{ secrets.LOGIN_TOKEN }}
- name: Build and Push (Shell)
run: |
IMAGE_NAME="git.reloia.uk/reloia/theplug:latest"
echo "Building $IMAGE_NAME..."
docker build -t "$IMAGE_NAME" .
echo "Pushing $IMAGE_NAME..."
docker push "$IMAGE_NAME"
+1
View File
@@ -4,3 +4,4 @@
.vscode/ .vscode/
.env .env
__pycache__/ __pycache__/
.pytest_cache/
+7 -1
View File
@@ -8,7 +8,13 @@ icona di una presa della spina idk
FastAPI + MongoDB + Redis FastAPI + MongoDB + Redis
``` ```
### Setup Dev ## Quick Setup (prod)
```bash
bash <(curl -s https://git.reloia.uk/reloia/thePlug/raw/branch/master/setup.sh)
```
## Setup Dev
imposta le variabili d'ambiente imposta le variabili d'ambiente
+3 -2
View File
@@ -1,15 +1,16 @@
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Response, status
from core.security import ( from core.security import (
AuthContext, AuthContext,
create_access_token, create_access_token,
get_auth_context, get_auth_context,
verify_password, verify_password,
) )
from db.auth import get_user_by_username, revoke_token
from fastapi import APIRouter, Depends, HTTPException, Response, status
from models.auth import LoginInput, TokenResponse from models.auth import LoginInput, TokenResponse
from repositories.auth import get_user_by_username, revoke_token
router = APIRouter( router = APIRouter(
prefix="/auth", prefix="/auth",
+22 -5
View File
@@ -1,15 +1,17 @@
from fastapi import APIRouter, Depends, HTTPException, status
from core.security import require_authenticated_user from core.security import require_authenticated_user
from db.proxy import ( from models.proxy import ProxyCreateInput, ProxyUpdateInput
from repositories.proxy import (
create_proxy, create_proxy,
delete_proxy, delete_proxy,
get_proxy_by_port, get_proxy_by_port,
update_proxy, update_proxy,
) )
from db.proxy import ( from repositories.proxy import (
list_proxies as list_proxy_documents, list_proxies as list_proxy_documents,
) )
from fastapi import APIRouter, Depends, HTTPException, status from services.mitmproxy.validator import ProxyCodeValidationError, validate_proxy_code
from models.proxy import ProxyCreateInput, ProxyUpdateInput
router = APIRouter( router = APIRouter(
prefix="/proxy", prefix="/proxy",
@@ -18,6 +20,16 @@ router = APIRouter(
) )
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("") @router.get("")
async def list_proxies_handler(): async def list_proxies_handler():
return await list_proxy_documents() return await list_proxy_documents()
@@ -43,16 +55,21 @@ async def create_proxy_handler(payload: ProxyCreateInput):
detail=f"Proxy already exists on port {payload.proxy_port}", detail=f"Proxy already exists on port {payload.proxy_port}",
) )
await _validate_code_or_raise(payload.code)
return await create_proxy( return await create_proxy(
proxy_port=payload.proxy_port, proxy_port=payload.proxy_port,
name=payload.name, name=payload.name,
service={"address": payload.service.address, "port": payload.service.port}, service={"port": payload.service.port},
code=payload.code, code=payload.code,
) )
@router.put("/{proxy_port}") @router.put("/{proxy_port}")
async def edit_proxy(proxy_port: int, payload: ProxyUpdateInput): 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) updated_proxy = await update_proxy(proxy_port, payload)
if updated_proxy is None: if updated_proxy is None:
raise HTTPException( raise HTTPException(
+12 -6
View File
@@ -5,14 +5,16 @@ from uuid import uuid4
from authlib.jose import jwt # type: ignore from authlib.jose import jwt # type: ignore
from authlib.jose.errors import JoseError # type: ignore from authlib.jose.errors import JoseError # type: ignore
from core.config import settings
from db.auth import get_user_by_username, is_token_revoked
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from models.auth import AccessTokenPayload, AuthUserDocument from pwdlib import PasswordHash
from passlib.context import CryptContext from pwdlib.hashers.argon2 import Argon2Hasher
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 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) bearer_scheme = HTTPBearer(auto_error=False)
@@ -40,7 +42,7 @@ def hash_password(password: str) -> str:
def verify_password(plain_password: str, password_hash: str) -> bool: def verify_password(plain_password: str, password_hash: str) -> bool:
return pwd_context.verify(plain_password, password_hash) return pwd_context.check(plain_password, password_hash)
def create_access_token(*, subject: str) -> str: def create_access_token(*, subject: str) -> str:
@@ -52,14 +54,18 @@ def create_access_token(*, subject: str) -> str:
"alg": settings.JWT_ALGORITHM, "alg": settings.JWT_ALGORITHM,
"typ": "JWT", "typ": "JWT",
} }
payload = { payload = {
"sub": subject, "sub": subject,
"jti": str(uuid4()), "jti": str(uuid4()),
"exp": expires_at, "exp": expires_at,
} }
token = jwt.encode(header, payload, settings.JWT_SECRET_KEY) token = jwt.encode(header, payload, settings.JWT_SECRET_KEY)
if isinstance(token, bytes): if isinstance(token, bytes):
return token.decode("utf-8") return token.decode("utf-8")
return token return token
+15
View File
@@ -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",
]
+75
View File
@@ -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
+50
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
from pathlib import Path
from typing import TypedDict
class DockerContainer(TypedDict):
name: str
ports: list[int]
folder: Path
+65
View File
@@ -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
+26 -3
View File
@@ -1,4 +1,9 @@
import os
import sys
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from api.auth.router import router as auth_router from api.auth.router import router as auth_router
from api.proxy.router import router as proxy_router from api.proxy.router import router as proxy_router
@@ -6,9 +11,9 @@ from core.config import settings
from core.mongodb import close_mongodb, init_mongodb from core.mongodb import close_mongodb, init_mongodb
from core.redis import close_redis, init_redis from core.redis import close_redis, init_redis
from core.security import hash_password from core.security import hash_password
from db.auth import ensure_auth_indexes, ensure_single_account from integrations.docker import get_all_services
from db.proxy import ensure_proxy_indexes from repositories.auth import ensure_auth_indexes, ensure_single_account
from fastapi import FastAPI from repositories.proxy import ensure_proxy_indexes
@asynccontextmanager @asynccontextmanager
@@ -21,15 +26,33 @@ async def lifespan(_: FastAPI):
password_hash=hash_password(settings.AUTH_PASSWORD), password_hash=hash_password(settings.AUTH_PASSWORD),
) )
await ensure_proxy_indexes() await ensure_proxy_indexes()
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: try:
yield yield
finally: finally:
await close_redis() await close_redis()
await close_mongodb() await close_mongodb()
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
app = FastAPI( app = FastAPI(
title="thePlugAPI", title="thePlugAPI",
lifespan=lifespan, 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(auth_router)
-1
View File
@@ -4,5 +4,4 @@ from pydantic import BaseModel, ConfigDict, Field
class ServiceEndpointDocument(BaseModel): class ServiceEndpointDocument(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
address: str = Field(..., description="Upstream service address")
port: int = Field(..., description="Upstream service port") port: int = Field(..., description="Upstream service port")
+2 -1
View File
@@ -2,9 +2,10 @@ from collections.abc import Mapping
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
from motor.motor_asyncio import AsyncIOMotorCollection
from core.mongodb import get_database from core.mongodb import get_database
from models.auth import AuthUserDocument from models.auth import AuthUserDocument
from motor.motor_asyncio import AsyncIOMotorCollection
USERS_COLLECTION_NAME = "auth_users" USERS_COLLECTION_NAME = "auth_users"
REVOKED_COLLECTION_NAME = "revoked_tokens" REVOKED_COLLECTION_NAME = "revoked_tokens"
@@ -2,12 +2,13 @@ from collections.abc import Mapping
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
from motor.motor_asyncio import AsyncIOMotorCollection
from pymongo import ReturnDocument
from core.mongodb import get_database from core.mongodb import get_database
from models.proxy import ProxyDocument, ProxyUpdateInput from models.proxy import ProxyDocument, ProxyUpdateInput
from models.service import ServiceEndpointDocument from models.service import ServiceEndpointDocument
from motor.motor_asyncio import AsyncIOMotorCollection from services.mitmproxy.models import ServiceConfig
from pymongo import ReturnDocument
from utils.proxy.types import ServiceConfig
COLLECTION_NAME = "proxies" COLLECTION_NAME = "proxies"
@@ -46,7 +47,6 @@ async def create_proxy(
name=name, name=name,
code=code, code=code,
service=ServiceEndpointDocument( service=ServiceEndpointDocument(
address=service["address"],
port=service["port"], port=service["port"],
), ),
created_at=now, created_at=now,
View File
View File
+27
View File
@@ -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
@@ -2,5 +2,4 @@ from typing import TypedDict
class ServiceConfig(TypedDict): class ServiceConfig(TypedDict):
address: str
port: int port: int
+164
View File
@@ -0,0 +1,164 @@
import asyncio
import json
import os
import shutil
import tempfile
from typing import Any
TARGET_RUFF_CODES = ("F821", "F822", "F823", "E999")
class ProxyCodeValidationError(Exception):
def __init__(
self,
message: str,
*,
errors: list[dict[str, Any]] | None = None,
) -> None:
super().__init__(message)
self.message = message
self.errors = errors or []
def to_http_detail(self) -> dict[str, Any]:
return {
"message": self.message,
"errors": self.errors,
}
def _validate_python_syntax(code: str) -> None:
try:
compile(code, "<proxy_code>", "exec")
except SyntaxError as error:
line = error.lineno if error.lineno is not None else 0
column = error.offset if error.offset is not None else 0
detail = {
"code": "COMPILE",
"message": error.msg,
"location": {"row": line, "column": column},
}
raise ProxyCodeValidationError(
"Proxy code has invalid Python syntax",
errors=[detail],
) from error
def _get_ruff_executable() -> list[str]:
ruff_path = shutil.which("ruff")
if ruff_path:
return [ruff_path]
uv_path = shutil.which("uv")
if uv_path:
return [uv_path, "run", "ruff"]
raise ProxyCodeValidationError("Unable to run ruff: executable not found")
def _parse_ruff_output(output: str) -> list[dict[str, Any]]:
if not output.strip():
return []
try:
parsed = json.loads(output)
except json.JSONDecodeError as error:
raise ProxyCodeValidationError("Unable to parse ruff JSON output") from error
if not isinstance(parsed, list):
raise ProxyCodeValidationError("Unexpected ruff output format")
errors: list[dict[str, Any]] = []
for item in parsed:
if not isinstance(item, dict):
continue
code = item.get("code")
if code not in TARGET_RUFF_CODES:
continue
message = item.get("message")
raw_location = item.get("location")
location = raw_location if isinstance(raw_location, dict) else {}
row = location.get("row", 0)
column = location.get("column", 0)
errors.append(
{
"code": code,
"message": str(message) if message is not None else "",
"location": {"row": row, "column": column},
}
)
return errors
async def _run_ruff_check(code: str) -> list[dict[str, Any]]:
command = _get_ruff_executable()
file_path = ""
try:
with tempfile.NamedTemporaryFile(
mode="w",
suffix=".py",
encoding="utf-8",
delete=False,
) as handle:
handle.write(code)
file_path = handle.name
process = await asyncio.create_subprocess_exec(
*command,
"check",
"--output-format=json",
"--select",
",".join(TARGET_RUFF_CODES),
file_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
finally:
if file_path:
try:
os.unlink(file_path)
except FileNotFoundError:
pass
stdout_text = stdout.decode("utf-8", errors="replace")
stderr_text = stderr.decode("utf-8", errors="replace")
if process.returncode not in (0, 1):
raise ProxyCodeValidationError(
"Ruff check failed to execute",
errors=[
{
"code": "RUFF",
"message": stderr_text.strip() or "Unknown ruff failure",
"location": {"row": 0, "column": 0},
}
],
)
if stderr_text.strip() and process.returncode == 0:
raise ProxyCodeValidationError(
"Ruff emitted unexpected stderr output",
errors=[
{
"code": "RUFF",
"message": stderr_text.strip(),
"location": {"row": 0, "column": 0},
}
],
)
return _parse_ruff_output(stdout_text)
async def validate_proxy_code(code: str) -> None:
_validate_python_syntax(code)
errors = await _run_ruff_check(code)
if errors:
raise ProxyCodeValidationError(
"Proxy code contains ruff violations",
errors=errors,
)
-10
View File
@@ -1,10 +0,0 @@
from utils.proxy.types import ServiceConfig
async def create_proxy(
port: int,
name: str,
service: ServiceConfig,
code: str
):
return
+2 -31
View File
@@ -1,40 +1,11 @@
services: services:
api:
build:
context: .
dockerfile: Dockerfile
container_name: theplug-api
restart: unless-stopped
ports:
- "${API_PORT:-3671}:3671"
environment:
MONGODB_URI: mongodb://${MONGO_USER:-theplug}:${MONGO_PASSWORD:-theplug_dev_password}@mongodb:27017/${MONGO_DB:-theplug}?authSource=admin
MONGODB_DB_NAME: ${MONGO_DB:-theplug}
REDIS_URL: redis://redis:6379/0
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change-this-secret}
JWT_ALGORITHM: ${JWT_ALGORITHM:-HS256}
JWT_EXPIRE_MINUTES: ${JWT_EXPIRE_MINUTES:-30}
AUTH_USERNAME: ${AUTH_USERNAME:-admin}
AUTH_PASSWORD: ${AUTH_PASSWORD:-change-this-password}
depends_on:
mongodb:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:3671/').read()\""]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
mongodb: mongodb:
image: mongo:8 image: mongo:8.2
container_name: theplug-mongodb container_name: theplug-mongodb
restart: unless-stopped restart: unless-stopped
environment: environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-theplug} MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-theplug}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-pass} MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD}
MONGO_INITDB_DATABASE: ${MONGO_DB:-theplug} MONGO_INITDB_DATABASE: ${MONGO_DB:-theplug}
ports: ports:
- "27017:27017" - "27017:27017"
+3 -1
View File
@@ -7,8 +7,10 @@ dependencies = [
"authlib>=1.6.0", "authlib>=1.6.0",
"fastapi[standard]>=0.135.3", "fastapi[standard]>=0.135.3",
"motor>=3.7.1", "motor>=3.7.1",
"passlib[bcrypt]>=1.7.4", "pwdlib[argon2,bcrypt]>=0.3.0",
"pydantic>=2.12.5",
"pydantic-settings>=2.13.1", "pydantic-settings>=2.13.1",
"pyyaml>=6.0.3",
"redis>=7.4.0", "redis>=7.4.0",
] ]
+126
View File
@@ -0,0 +1,126 @@
#!/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} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
die() { echo -e "${RED}[✗] FATAL:${NC} $*" >&2; exit 1; }
install_service() {
local uv_bin
uv_bin=$(command -v uv)
local api_port
api_port=$(grep -E '^API_PORT=' .env 2>/dev/null | cut -d '=' -f2)
api_port=${api_port:-3671}
mkdir -p ~/.config/systemd/user
cat > ~/.config/systemd/user/theplug.service <<EOF
[Unit]
Description=thePlug service
After=network.target
[Service]
WorkingDirectory=/srv/theplug
EnvironmentFile=/srv/theplug/.env
Environment=PYTHONPATH=app
ExecStartPre=/usr/bin/docker compose -f /srv/theplug/compose.yml up -d redis mongodb
ExecStart=$uv_bin run uvicorn app.main:app --host 0.0.0.0 --port $api_port
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
EOF
systemctl --user daemon-reload
systemctl --user enable theplug
info "systemd user service installed (port $api_port)"
}
start_services() {
info "Starting Redis and MongoDB..."
docker compose up -d redis mongodb || die "docker compose failed — are Redis/MongoDB defined in compose.yml?"
info "Starting thePlug via systemctl..."
systemctl --user start theplug
warn "Use 'systemctl --user status theplug' to check / 'journalctl --user -u theplug -f' to follow logs"
}
# ── 1. ensure we're in thePlug ────────────────────────────────────────────────
if [[ -d "/srv/theplug" ]]; then
info "thePlug già presente — avvio diretto..."
cd /srv/theplug
start_services
exit 0
fi
cd /srv
git clone https://git.reloia.uk/reloia/thePlug theplug || die "git clone failed"
cd theplug
chmod +x ./setup.sh
PLUG_DIR="$(pwd)"
if [[ ! -L /usr/bin/theplug ]]; then
info "Creating symlink /usr/bin/theplug..."
sudo ln -sf "$PLUG_DIR/setup.sh" /usr/bin/theplug
sudo chmod +x /usr/bin/theplug
fi
# 2 env
if [[ ! -f .env ]]; then
warn ".env not found — copyinh from .env.example with random stuff..."
cp .env.example .env || die ".env.example not found (impossibile)"
MONGO_PASS=$(openssl rand -hex 8)
JWT_SECRET=$(openssl rand -hex 20)
AUTH_PASS=$(openssl rand -hex 10)
sed -i "s|^MONGO_INITDB_ROOT_PASSWORD=.*|MONGO_INITDB_ROOT_PASSWORD=$MONGO_PASS|" .env
sed -i "s|^MONGODB_URI=.*|MONGODB_URI=mongodb://theplug:$MONGO_PASS@localhost:27017/theplug?authSource=admin|" .env
sed -i "s|^JWT_SECRET_KEY=.*|JWT_SECRET_KEY=$JWT_SECRET|" .env
sed -i "s|^AUTH_PASSWORD=.*|AUTH_PASSWORD=$AUTH_PASS|" .env
info "Resetting docker containers..."
docker compose down -v 2>/dev/null || true
fi
# ── 3. spin up dependencies ───────────────────────────────────────────────────
info "Starting Redis and MongoDB..."
docker compose up -d redis mongodb || die "docker compose failed — are Redis/MongoDB defined in compose.yml?"
# ── 4. ensure uv is available ─────────────────────────────────────────────────
if ! command -v uv &>/dev/null; then
warn "uv not found — installing..."
curl -LsSf https://astral.sh/uv/install.sh | sh || die "uv installation failed"
export PATH="$HOME/.local/bin:$PATH"
command -v uv &>/dev/null || die "uv installed but still not in PATH — open a new shell and retry"
info "uv installed: $(uv --version)"
fi
# ── 5. venv + deps ────────────────────────────────────────────────────────────
info "Pinning Python 3.14..."
uv python pin 3.14
info "Creating venv..."
uv venv
info "Syncing dependencies..."
uv sync
# ── 6. install service + start ────────────────────────────────────────────────
install_service
systemctl --user start theplug
echo -e ""
info "Automatically generated Secrets:"
info " MONGO_PASS: $MONGO_PASS"
info " JWT_SECRET: $JWT_SECRET"
info " AUTH_PASS: $AUTH_PASS - questa è importante"
warn "Save them — these won't be showed again (non è vero basta che leggi $PLUG_DIR/.env)"
echo -e ""
info "Installed thePlug in $PLUG_DIR"
warn "Use 'systemctl --user status theplug' to check / 'journalctl --user -u theplug -f' to follow logs"
echo -e ""
+124 -8
View File
@@ -3,6 +3,7 @@ import importlib
from core.security import get_auth_context, require_authenticated_user from core.security import get_auth_context, require_authenticated_user
from models.proxy import ProxyDocument from models.proxy import ProxyDocument
from models.service import ServiceEndpointDocument from models.service import ServiceEndpointDocument
from services.mitmproxy.validator import ProxyCodeValidationError
proxy_router_module = importlib.import_module("api.proxy.router") proxy_router_module = importlib.import_module("api.proxy.router")
@@ -12,14 +13,13 @@ def make_proxy_document(
proxy_port: int, proxy_port: int,
name: str = "svc", name: str = "svc",
code: str = "proxy-code", code: str = "proxy-code",
address: str = "127.0.0.1",
port: int = 9000, port: int = 9000,
) -> ProxyDocument: ) -> ProxyDocument:
return ProxyDocument( return ProxyDocument(
proxy_port=proxy_port, proxy_port=proxy_port,
name=name, name=name,
code=code, code=code,
service=ServiceEndpointDocument(address=address, port=port), service=ServiceEndpointDocument(port=port),
) )
@@ -86,7 +86,7 @@ def test_create_proxy_returns_409_on_port_conflict(client, monkeypatch: object):
"proxy_port": 3671, "proxy_port": 3671,
"name": "new", "name": "new",
"code": "abc", "code": "abc",
"service": {"address": "localhost", "port": 8080}, "service": {"port": 8080},
}, },
) )
@@ -116,15 +116,22 @@ def test_create_proxy_returns_created_document(client, monkeypatch: object):
proxy_port=proxy_port, proxy_port=proxy_port,
name=name, name=name,
code=code, code=code,
address=str(service["address"]),
port=int(service["port"]), port=int(service["port"]),
) )
async def fake_validate_proxy_code(code: str) -> None:
assert code == "def proxy_handler():\n return 'ok'\n"
monkeypatch.setattr( monkeypatch.setattr(
proxy_router_module, proxy_router_module,
"get_proxy_by_port", "get_proxy_by_port",
fake_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) monkeypatch.setattr(proxy_router_module, "create_proxy", fake_create_proxy)
response = client.post( response = client.post(
@@ -132,8 +139,8 @@ def test_create_proxy_returns_created_document(client, monkeypatch: object):
json={ json={
"proxy_port": 4040, "proxy_port": 4040,
"name": "edge", "name": "edge",
"code": "edge-code", "code": "def proxy_handler():\n return 'ok'\n",
"service": {"address": "service.local", "port": 8181}, "service": {"port": 8181},
}, },
) )
@@ -142,6 +149,115 @@ def test_create_proxy_returns_created_document(client, monkeypatch: object):
assert captured == { assert captured == {
"proxy_port": 4040, "proxy_port": 4040,
"name": "edge", "name": "edge",
"service": {"address": "service.local", "port": 8181}, "service": {"port": 8181},
"code": "edge-code", "code": "def proxy_handler():\n return 'ok'\n",
} }
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
Generated
+66 -16
View File
@@ -32,6 +32,49 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
] ]
[[package]]
name = "argon2-cffi"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "argon2-cffi-bindings" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" },
]
[[package]]
name = "argon2-cffi-bindings"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" },
{ url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" },
{ url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" },
{ url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" },
{ url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" },
{ url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" },
{ url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" },
{ url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" },
{ url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" },
{ url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" },
{ url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" },
{ url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" },
{ url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" },
{ url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" },
{ url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" },
{ url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" },
{ url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" },
{ url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" },
{ url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" },
{ url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" },
]
[[package]] [[package]]
name = "authlib" name = "authlib"
version = "1.6.9" version = "1.6.9"
@@ -494,20 +537,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
] ]
[[package]]
name = "passlib"
version = "1.7.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" },
]
[package.optional-dependencies]
bcrypt = [
{ name = "bcrypt" },
]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.6.0" version = "1.6.0"
@@ -517,6 +546,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
] ]
[[package]]
name = "pwdlib"
version = "0.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/41/a7c0d8a003c36ce3828ae3ed0391fe6a15aad65f082dbd6bec817ea95c0b/pwdlib-0.3.0.tar.gz", hash = "sha256:6ca30f9642a1467d4f5d0a4d18619de1c77f17dfccb42dd200b144127d3c83fc", size = 215810, upload-time = "2025-10-25T12:44:24.395Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/0c/9086a357d02a050fbb3270bf5043ac284dbfb845670e16c9389a41defc9e/pwdlib-0.3.0-py3-none-any.whl", hash = "sha256:f86c15c138858c09f3bba0a10984d4f9178158c55deaa72eac0210849b1a140d", size = 8633, upload-time = "2025-10-25T12:44:23.406Z" },
]
[package.optional-dependencies]
argon2 = [
{ name = "argon2-cffi" },
]
bcrypt = [
{ name = "bcrypt" },
]
[[package]] [[package]]
name = "pycparser" name = "pycparser"
version = "3.0" version = "3.0"
@@ -853,8 +899,10 @@ dependencies = [
{ name = "authlib" }, { name = "authlib" },
{ name = "fastapi", extra = ["standard"] }, { name = "fastapi", extra = ["standard"] },
{ name = "motor" }, { name = "motor" },
{ name = "passlib", extra = ["bcrypt"] }, { name = "pwdlib", extra = ["argon2", "bcrypt"] },
{ name = "pydantic" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "pyyaml" },
{ name = "redis" }, { name = "redis" },
] ]
@@ -869,8 +917,10 @@ requires-dist = [
{ name = "authlib", specifier = ">=1.6.0" }, { name = "authlib", specifier = ">=1.6.0" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.135.3" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.135.3" },
{ name = "motor", specifier = ">=3.7.1" }, { name = "motor", specifier = ">=3.7.1" },
{ name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, { name = "pwdlib", extras = ["argon2", "bcrypt"], specifier = ">=0.3.0" },
{ name = "pydantic", specifier = ">=2.12.5" },
{ name = "pydantic-settings", specifier = ">=2.13.1" }, { name = "pydantic-settings", specifier = ">=2.13.1" },
{ name = "pyyaml", specifier = ">=6.0.3" },
{ name = "redis", specifier = ">=7.4.0" }, { name = "redis", specifier = ">=7.4.0" },
] ]