diff --git a/README.md b/README.md index 452dfa4..8ba2fcd 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@ # thePlug -Wrapper web per gestire proxy `mitmdump` davanti ai servizi di una competizione -CTF attack/defense. +Wrapper web per gestire proxy `mitmdump` davanti a servizi esposti via Docker +Compose. ## Struttura ```text backend/ FastAPI, MongoDB, Redis, mitmdump manager -frontend/ Vue 3, Vite, Monaco editor +frontend/ Vue 3, Vite, CodeMirror + Python LSP +playground/ servizio locale minimale per test end-to-end ``` ## Setup rapido @@ -24,6 +25,12 @@ Frontend http://localhost:5173 Backend http://localhost:3671 ``` +Questa modalità è buona per smoke test UI/API. Se vuoi provare davvero il +takeover della porta di un servizio Docker locale, fai girare il backend sulla +macchina host. + +Guida completa: [TESTING.md](/home/gabdevele/CTF/thePlug/TESTING.md) + Per installare anche il servizio systemd user: ```bash @@ -40,6 +47,37 @@ npm run dev:backend Docs FastAPI in dev: `http://localhost:3671/docs`. +## Test locale realistico + +Per simulare il deployment reale, usa il backend sulla macchina host: + +```bash +./setup.sh deps +export SERVICE_SCAN_ROOT="$PWD" +npm run dev:backend +npm run dev:frontend +./setup.sh playground +``` + +Poi: + +1. Apri `http://localhost:5173`. +2. Fai login con le credenziali in `backend/.env`. +3. Crea un nuovo proxy. +4. Seleziona il servizio `echo` rilevato dal backend sotto la cartella `playground`. +5. Lascia `mode=reverse`. +6. Verifica che `proxy_port` combaci con la porta del servizio (`9081`) se vuoi che il proxy prenda davvero il posto della route originale. +7. Salva e premi `Start`. +8. Esegui `curl http://127.0.0.1:9081/`. + +Se il proxy è attivo, la route originale è ora posseduta da `mitmdump` e il +servizio reale è stato spostato su una porta upstream temporanea, visibile nella +UI e nello stato runtime. + +`SERVICE_SCAN_ROOT` controls where the backend looks for services. The scan is +intentionally shallow: it inspects only the immediate child directories of that +root, which matches the expected host layout. + ## Dev frontend ```bash @@ -63,6 +101,13 @@ cd backend && uv run ruff check . && uv run pytest cd frontend && npm run build ``` +Per testare solo il playground: + +```bash +npm run playground:up +npm run playground:down +``` + ## API principali - `POST /auth/login` diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..e47a155 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,413 @@ +# Testing Guide + +Questa guida descrive come preparare e testare `thePlug` in tre scenari: + +1. laptop con backend sulla macchina host +2. laptop con stack tutto Dockerizzato +3. vulnbox reale con backend fuori Docker + +L’obiettivo non è solo far partire UI e API, ma verificare il comportamento +reale del proxy: remap della porta del servizio, avvio di `mitmdump`, +healthcheck e ripristino su `Stop`. + +## Concetti Chiave + +### Cosa fa `Save` + +`Save` salva solo la configurazione del proxy nel backend. Non tocca: + +- porte del servizio reale +- processi `mitmdump` +- Docker Compose + +### Cosa fa `Start` + +In `reverse` mode, se il proxy è associato a un servizio Docker Compose e +`mode_spec` non è impostato: + +1. il backend sposta la porta pubblica del servizio reale su una porta libera +2. salva quella nuova porta come upstream live +3. avvia `mitmdump` sulla porta originale + +Quindi, se vuoi che il proxy prenda davvero il posto del servizio originale, +`proxy_port` deve coincidere con la porta pubblica del servizio. + +### Come vengono scoperti i servizi + +Il backend cerca i servizi Docker Compose sotto una root di scansione: + +- di default: `HOME` +- override: `SERVICE_SCAN_ROOT` + +La scansione è volutamente shallow: + +- il backend guarda solo le cartelle immediate sotto la root +- non scende ricorsivamente in profondità + +Questa scelta riflette il layout atteso sulla macchina reale. + +Esempio valido: + +```text +/home/user/service-a/docker-compose.yml +/home/user/service-b/docker-compose.yml +``` + +Esempio non visto se la root è `/home/user`: + +```text +/home/user/projects/lab/service-a/docker-compose.yml +``` + +In quel caso devi cambiare `SERVICE_SCAN_ROOT`. + +## Prerequisiti + +Su tutte le macchine: + +- `docker` +- `docker compose` +- `uv` +- `python 3.14` +- `node 22` + +Il backend installerà anche: + +- `mitmproxy` +- `basedpyright` + +Porte usate tipicamente: + +- `3671` backend +- `5173` frontend +- `27017` mongodb +- `6379` redis +- una o più porte proxy/test + +## Scenario 1: Laptop con Backend sulla Macchina Host + +Questo è il setup migliore per testare davvero il comportamento del proxy. + +### Quando usarlo + +Usa questo scenario se vuoi verificare: + +- discovery servizi +- remap porta Docker Compose +- takeover reale della porta pubblica +- start/stop/restart di `mitmdump` +- esempi script e editor + +### Preparazione + +Dalla root del progetto: + +```bash +cd /home/gabdevele/CTF/thePlug +./setup.sh deps +export SERVICE_SCAN_ROOT="$PWD" +./setup.sh playground +``` + +Questo fa due cose: + +- avvia solo `mongodb` e `redis` in Docker +- avvia un piccolo upstream locale in `playground/` + +Il playground espone: + +```text +http://127.0.0.1:9081 +``` + +### Avvio applicazione + +In un terminale: + +```bash +cd /home/gabdevele/CTF/thePlug +npm run dev:backend +``` + +In un secondo terminale: + +```bash +cd /home/gabdevele/CTF/thePlug +npm run dev:frontend +``` + +### Login + +Le credenziali sono in: + +- [backend/.env](/home/gabdevele/CTF/thePlug/backend/.env) + +Apri: + +```text +http://localhost:5173 +``` + +### Test End-to-End Consigliato + +1. Crea un nuovo proxy. +2. Seleziona il servizio `echo` rilevato dal backend. +3. Lascia `mode=reverse`. +4. Verifica che `proxy_port=9081`. +5. Salva. +6. Premi `Start`. +7. Esegui: + +```bash +curl -i http://127.0.0.1:9081/ +``` + +### Cosa devi vedere + +Prima di `Start`: + +- `9081` risponde direttamente il servizio playground + +Dopo `Start`: + +- `mitmdump` è in ascolto su `9081` +- il servizio reale è stato spostato su una porta upstream temporanea +- la UI mostra il live upstream cambiato +- i log del proxy iniziano a popolarsi + +Dopo `Stop`: + +- la porta pubblica originaria torna al servizio reale +- `service.upstream_port` torna `null` + +### Verifiche Utili + +Controlla i servizi rilevati: + +```bash +curl -H "Authorization: Bearer " http://127.0.0.1:3671/proxy/services +``` + +Controlla i log proxy dalla UI o via API: + +```bash +curl -H "Authorization: Bearer " http://127.0.0.1:3671/proxy/9081/logs +``` + +### Cleanup + +```bash +npm run playground:down +docker compose --env-file backend/.env -f docker-compose.yml down +``` + +## Scenario 2: Laptop con Stack Tutto Dockerizzato + +Questo scenario è adatto soprattutto a smoke test applicativi. + +### Quando usarlo + +Usalo per verificare: + +- login/logout +- CRUD proxy +- examples +- options +- persistenza Mongo/Redis +- build frontend e wiring API + +### Avvio + +```bash +cd /home/gabdevele/CTF/thePlug +./setup.sh +``` + +Apri: + +- frontend: `http://localhost:5173` +- backend: `http://localhost:3671` + +### Cosa testare qui + +Puoi testare bene: + +- creazione proxy +- update/delete +- validazione codice +- examples custom +- health endpoint +- editor e autocomplete + +### Limite importante + +Questo non è il setup migliore per validare il takeover reale delle porte di un +servizio locale, perché il backend è dentro Docker e non replica la topologia +host-native del deployment reale. + +Quindi: + +- bene per smoke test UI/API +- non abbastanza per validare il flusso finale di orchestration + +Per il test realistico del proxy usa lo Scenario 1. + +### Spegnimento + +```bash +docker compose --env-file backend/.env -f docker-compose.yml down +``` + +## Scenario 3: Vulnbox Reale con Backend fuori Docker + +Questo è il modello più vicino al deployment reale. + +### Layout atteso dei servizi + +Caso ideale sotto `HOME`: + +```text +/home/user/service-a/docker-compose.yml +/home/user/service-b/docker-compose.yml +/home/user/service-c/docker-compose.yml +``` + +Se il layout è diverso, imposta `SERVICE_SCAN_ROOT`. + +### Preparazione backend + +```bash +cd /path/to/thePlug +cp backend/.env.example backend/.env +./setup.sh deps +``` + +Se i servizi non sono sotto `HOME`: + +```bash +export SERVICE_SCAN_ROOT="/path/che/contiene/le/cartelle-servizio" +``` + +### Avvio backend + +```bash +cd /path/to/thePlug +npm run dev:backend +``` + +### Avvio frontend + +In sviluppo: + +```bash +cd /path/to/thePlug +npm run dev:frontend +``` + +Oppure build statico: + +```bash +cd /path/to/thePlug/frontend +VITE_API_BASE_URL=http://IP_O_HOST:3671 npm run build +``` + +### Test operativo reale + +1. Apri la UI. +2. Seleziona un servizio reale scoperto dal backend. +3. Se vuoi takeover vero della porta pubblica: + `proxy_port` deve essere uguale alla porta pubblica del servizio. +4. Salva. +5. Premi `Start`. +6. Verifica da terminale o con la tua applicazione client che il traffico passi + ancora sulla porta originale. + +### Cosa aspettarsi + +Su `Start`: + +- il servizio reale viene spostato su una porta temporanea +- `mitmdump` si lega alla porta originaria +- il traffico continua a entrare dalla porta originale +- la UI mostra il nuovo live upstream + +Su `Stop`: + +- la porta originaria torna al servizio reale +- il proxy viene fermato + +## Failure Modes Comuni + +### Il backend non trova nessun servizio + +Controlla: + +- valore di `SERVICE_SCAN_ROOT` +- che i compose siano nelle cartelle immediate sotto quella root +- che i file si chiamino uno tra: + - `compose.yml` + - `compose.yaml` + - `docker-compose.yml` + - `docker-compose.yaml` + +### Il proxy parte ma non prende il posto del servizio + +Controlla: + +- `mode=reverse` +- `mode_spec` vuoto +- `proxy_port` uguale alla porta del servizio +- servizio selezionato dalla UI correttamente + +### `Start` fallisce + +Controlla: + +- `mitmdump` disponibile nel backend host +- conflitti di porta +- errori di sintassi nello script +- log del proxy + +### Il frontend mostra session expired + +Vuol dire che il token JWT è scaduto o non è più valido. Fai login di nuovo. + +## Comandi Utili + +Test backend: + +```bash +cd backend +uv run ruff check . +uv run pytest +``` + +Build frontend: + +```bash +cd frontend +npm run build +``` + +Avvio playground: + +```bash +./setup.sh playground +``` + +Solo dipendenze backend per test host: + +```bash +./setup.sh deps +``` + +## Raccomandazione Finale + +Per lavorare in modo efficiente: + +- usa `./setup.sh` per smoke test rapidi +- usa `backend host + frontend dev + playground` per il test vero del proxy +- usa `backend host` sulla vulnbox reale per validare il comportamento finale + +Se devi testare davvero il remap porta, considera valido solo un setup con +backend fuori Docker. diff --git a/TODO b/TODO index 8d69dd5..501e4b7 100644 --- a/TODO +++ b/TODO @@ -1,35 +1,35 @@ -UI SCHIFOSA -andrebbe rifatto proprio il layout. +Rivedere la parte UI/UX -dovrebbe essere in ordine di priorità, l'editor non può stare così tanto sotto. -card come: -- Runtime mapping -- What start does -vanno eliminati. +L'idea è avere una sezione di creazione del proxy, dove c'è l'autodiscovery e quindi si possono selezionare i vari compose trovati, specificare la cartella dove scannerizzare i vari servizi da frontend, senza dover cambiare la variabile d'ambiente. (il default è in /root) -di default quando si crea un reverse proxy, l'intenzione è quella di spostare la porta del servizio ad un'altra e mettere il proxy al posto della porta del servizio iniziale. +IL proxy su un servizio verrà quindi creato tramite una modale, specificando i vari campi (autocompletato in caso di autodiscovery), con vari step: +- Proxy route +- Healthchecks +- Custom mitmdump options -anche ```Python tools ready``` è inutile +Una volta creato, nella pagina del Proxy ci sarà prima di tutto l'editor del proxy script, poi non si chiameranno più examples ma "template" con la possibilità di leggere i template tramite preview ed essere selezionati. inoltre anche i log devono essere visualizzabili, senza dover cliccare load ogni volta, vengono mostrati e aggiornati in automatico. -Addon editor come titolo non mi piace +Poi cliccando un tasto apposito è possibile riaprire la modale a step per modificare le impostazioni del proxy, e ovviamente salvare. -``` -No proxies configured -Create a draft, save it, then start it when you want mitmdump to own the route. -``` -troppa roba, riassumere. +Questo serve per migliorare l'usabilità e l'esperienza utente, in modo da customizzarlo gestendo l'ordine del flusso di creazione, perchè logicamente certe cose le setti solo la prima volta non sempre, e l'ordine di quante volte usarai una cosa, non di importanza. -Options card buggattissima lato UI, inoltre a questo punto va fatta una scrematura sulle opzioni sulle cose che possono servire veramente, inutile mettere troppe cose. +Altri spunti di miglioramento: +1. Gestione dell'Autodiscovery e dei Caricamenti -gli esempi vanno aggiornati con roba reale utile. +Feedback visivo: La scansione delle cartelle (specialmente /root) potrebbe richiedere qualche secondo. Prevedi uno stato di caricamento esplicito (uno skeleton loader o uno spinner) mentre i compose vengono trovati, per evitare che l'utente pensi che l'app sia bloccata. +2. Navigazione nella Modale a Step -capire come testare davvero sulla mia macchina +Stepper visivo: Inserisci un indicatore di progresso in cima alla modale (es. Step 1 di 3: Proxy Route). -cambiare text sulla pagina di login +Navigazione flessibile: Permetti all'utente di tornare indietro agli step precedenti senza perdere i dati inseriti. -cambiare dove e come viene mostrato alert quando c'è errore di autenticazione (loggati in precedenza con token scaduto.) +3. Sicurezza nell'Editor e nei Template -rendere lo script davvero funzionante +Prevenzione degli errori: Quando l'utente seleziona un "Template" dall'anteprima, se c'è già del codice scritto nell'editor, l'applicazione del template lo sovrascriverà. Inserisci un alert di conferma: "Applicare questo template sovrascriverà il tuo script attuale. Vuoi procedere?" -solo il backend non deve runnare dentro docker ma sulla macchina reale. \ No newline at end of file +4. Controlli avanzati per i Log + +Pausa e Clear: Dato che i log si aggiornano in automatico, lo scorrimento continuo può rendere difficile la lettura di una riga specifica. Aggiungi due piccoli tasti sopra il terminale dei log: Metti in pausa (Pause autoscroll) e Svuota (Clear). + +Filtri di base: Se possibile, un semplice campo di ricerca testuale per filtrare i log al volo farebbe un'enorme differenza. \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example index 295b7db..1917bfa 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,11 +1,17 @@ API_PORT=3671 FRONTEND_PORT=5173 -MONGO_INITDB_ROOT_PASSWORD=pass# esegui 'openssl rand -hex 5' in un terminale e copia qui +# 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 REDIS_URL=redis://localhost:6379 -JWT_SECRET_KEY= # esegui 'openssl rand -hex 20' in un terminale e copia qui +# Use a random value in real environments. +JWT_SECRET_KEY=change-me JWT_ALGORITHM=HS256 -JWT_EXPIRE_MINUTES=30 +JWT_EXPIRE_MINUTES=720 AUTH_USERNAME=admin -AUTH_PASSWORD= # esegui 'openssl rand -hex 20' in un terminale e copia qui +# 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= diff --git a/backend/app/api/proxy/router.py b/backend/app/api/proxy/router.py index bf3276a..b00df1e 100644 --- a/backend/app/api/proxy/router.py +++ b/backend/app/api/proxy/router.py @@ -1,7 +1,10 @@ from pathlib import Path +from core.config import settings from core.security import require_authenticated_user from fastapi import APIRouter, Depends, HTTPException, Request, status +from integrations.docker import get_all_services_in_dir +from integrations.docker.discovery import service_scan_root from models.example import AddonExampleCreateInput from models.proxy import ProxyCreateInput, ProxyUpdateInput from pymongo.errors import DuplicateKeyError @@ -120,8 +123,12 @@ async def list_proxies_handler(): @router.get("/services") -async def list_services(request: Request): - return getattr(request.app.state, "services", []) +async def list_services(request: Request, 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) + request.app.state.services = services + return {"scan_root": str(root), "services": services} @router.get("/mitmproxy/options") diff --git a/backend/app/core/config.py b/backend/app/core/config.py index ad2ee29..a83c7ef 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -15,9 +15,10 @@ class Settings(BaseSettings): REDIS_URL: str JWT_SECRET_KEY: str JWT_ALGORITHM: str = "HS256" - JWT_EXPIRE_MINUTES: int = Field(default=30, gt=0) + JWT_EXPIRE_MINUTES: int = Field(default=720, gt=0) AUTH_USERNAME: str AUTH_PASSWORD: str + SERVICE_SCAN_ROOT: str | None = None settings = Settings() diff --git a/backend/app/integrations/docker/compose.py b/backend/app/integrations/docker/compose.py index 69ca659..d9749f3 100644 --- a/backend/app/integrations/docker/compose.py +++ b/backend/app/integrations/docker/compose.py @@ -3,7 +3,7 @@ from pathlib import Path import yaml from integrations.docker.models import DockerContainer -from integrations.docker.ports import _get_service_port +from integrations.docker.ports import _get_service_binding COMPOSE_NAMES = ( "compose.yml", @@ -50,20 +50,31 @@ def get_services(folder: Path) -> list[DockerContainer] | 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 + bindings = [ + {"published": published, "host": host} + for published, host in ( + _get_service_binding(mapping) for mapping in mappings + ) + if published is not None ] - if not ports: + if not bindings: continue - services.append({"name": service_name, "ports": ports, "folder": folder}) + 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]: +def get_all_services_in_dir( + directory: str | Path, +) -> list[DockerContainer]: path = Path(directory) if not path.is_dir(): return [] diff --git a/backend/app/integrations/docker/discovery.py b/backend/app/integrations/docker/discovery.py new file mode 100644 index 0000000..29d87c2 --- /dev/null +++ b/backend/app/integrations/docker/discovery.py @@ -0,0 +1,10 @@ +from pathlib import Path + +from core.config import settings + + +def service_scan_root() -> Path: + configured = settings.SERVICE_SCAN_ROOT + if configured: + return Path(configured).expanduser() + return Path.home() diff --git a/backend/app/integrations/docker/editor.py b/backend/app/integrations/docker/editor.py index 5c8b85e..e4ce3ca 100644 --- a/backend/app/integrations/docker/editor.py +++ b/backend/app/integrations/docker/editor.py @@ -16,6 +16,8 @@ async def change_port( 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: @@ -38,7 +40,13 @@ async def change_port( if idx is None: return False - mappings[idx] = _replace_port_in_mapping(mappings[idx], port_from, port_to) + mappings[idx] = _replace_port_in_mapping( + mappings[idx], + port_from, + port_to, + host_to=host_to, + rewrite_host=rewrite_host, + ) backup_path = _backup_path_for(compose_path) original_text = compose_path.read_text() diff --git a/backend/app/integrations/docker/models.py b/backend/app/integrations/docker/models.py index 1c75380..76283a0 100644 --- a/backend/app/integrations/docker/models.py +++ b/backend/app/integrations/docker/models.py @@ -2,7 +2,13 @@ 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 diff --git a/backend/app/integrations/docker/ports.py b/backend/app/integrations/docker/ports.py index 0f4ef2b..02efb16 100644 --- a/backend/app/integrations/docker/ports.py +++ b/backend/app/integrations/docker/ports.py @@ -14,48 +14,64 @@ def get_random_port() -> int: raise RuntimeError(f"Could not find an available port: {e}") from e -def _get_service_port(mapping: str | int | Mapping[str, object]) -> int | None: +def _get_service_binding( + mapping: str | int | Mapping[str, object], +) -> tuple[int | None, str | None]: if isinstance(mapping, int): - return mapping + return mapping, None if isinstance(mapping, Mapping): published = mapping.get("published") if published is None: - return None + return None, None try: - return int(published) - except TypeError, ValueError: - return None + port = int(published) + except TypeError: + return None, None + 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 + return None, None case [port] | [port, _]: try: - return int(port) + return int(port), None except ValueError: - return None - case ["localhost" | "127.0.0.1", _, _]: - return None - case [_, port, _]: + return None, None + case [host, port, _]: try: - return int(port) + return int(port), host or None except ValueError: - return None + return None, None case _: - return None + 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(":") @@ -63,8 +79,14 @@ def _replace_port_in_mapping( 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) diff --git a/backend/app/main.py b/backend/app/main.py index 632b5a1..1d9ec84 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,7 +1,6 @@ import os import sys from contextlib import asynccontextmanager -from pathlib import Path from api.auth.router import router as auth_router from api.editor.router import router as editor_router @@ -12,9 +11,11 @@ from core.redis import close_redis, init_redis from core.security import hash_password from fastapi import FastAPI from integrations.docker import get_all_services_in_dir +from integrations.docker.discovery import service_scan_root from repositories.auth import ensure_auth_indexes, ensure_single_account from repositories.examples import ensure_example_indexes from repositories.proxy import ensure_proxy_indexes +from services.mitmproxy.recovery import reconcile_runtime @asynccontextmanager @@ -28,14 +29,27 @@ async def lifespan(_: FastAPI): ) await ensure_proxy_indexes() await ensure_example_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, + ) - home_dir = str(Path.home()) - app.state.services = get_all_services_in_dir(home_dir) or [] + scan_root = service_scan_root() + app.state.services = get_all_services_in_dir(scan_root) 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" + " WARNING: No services found in scan root!\n" + f" Scanned: {scan_root}\n" + "!" * 60 + "\n" ) print(warning, file=sys.stderr) diff --git a/backend/app/models/service.py b/backend/app/models/service.py index 2b3c504..f975f18 100644 --- a/backend/app/models/service.py +++ b/backend/app/models/service.py @@ -6,6 +6,10 @@ class ServiceEndpointDocument(BaseModel): 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, diff --git a/backend/app/services/mitmproxy/examples.py b/backend/app/services/mitmproxy/examples.py index 457086e..7ec3fcc 100644 --- a/backend/app/services/mitmproxy/examples.py +++ b/backend/app/services/mitmproxy/examples.py @@ -4,11 +4,11 @@ DOCS_EXAMPLES_URL = "https://docs.mitmproxy.org/stable/addons/examples/" BUILTIN_EXAMPLES = [ AddonExampleDocument( - id="http-add-response-header", - name="HTTP add response header", + id="http-tag-traffic", + name="Tag proxied traffic", description=( - "Injects a fixed response header on every HTTP response so you can " - "verify that traffic is passing through the proxy response path." + "Adds a visible response header on every HTTP reply so you can tell " + "immediately whether the request crossed the proxy." ), category="http", source_url=DOCS_EXAMPLES_URL, @@ -21,11 +21,11 @@ def response(flow: http.HTTPFlow) -> None: """, ), AddonExampleDocument( - id="http-modify-query", - name="HTTP modify query", + id="http-force-query-flag", + name="Force request flag", description=( - "Adds or overwrites a query parameter before each request is " - "forwarded upstream. Useful for forced debug or feature flags." + "Adds or overwrites a request query flag before the flow is sent " + "upstream. Useful when you need a stable marker on every request." ), category="http", source_url=DOCS_EXAMPLES_URL, @@ -38,11 +38,11 @@ def request(flow: http.HTTPFlow) -> None: """, ), AddonExampleDocument( - id="http-reply-from-proxy", - name="HTTP reply from proxy", + id="http-local-health-response", + name="Serve local health response", description=( - "Serves a local HTTP response for a selected path without touching " - "the upstream service. Useful for probes, maintenance, or stubs." + "Answers one health endpoint directly from the proxy without " + "touching the upstream service. Useful for smoke tests and checks." ), category="http", source_url=DOCS_EXAMPLES_URL, @@ -57,14 +57,32 @@ def request(flow: http.HTTPFlow) -> None: b"ok", {"content-type": "text/plain"}, ) +""", + ), + AddonExampleDocument( + id="http-rewrite-health-path", + name="Rewrite one request path", + description=( + "Rewrites a single incoming path before forwarding it upstream. " + "Useful when the public route and upstream route do not match." + ), + category="http", + source_url=DOCS_EXAMPLES_URL, + built_in=True, + code="""from mitmproxy import http + + +def request(flow: http.HTTPFlow) -> None: + if flow.request.path == "/healthz": + flow.request.path = "/status" """, ), AddonExampleDocument( id="websocket-rewrite", name="WebSocket rewrite", description=( - "Rewrites matching bytes inside live WebSocket frames to alter " - "runtime flags or payload markers in transit." + "Rewrites matching bytes inside live WebSocket frames to flip a " + "runtime marker while the flow is passing through the proxy." ), category="websocket", source_url=DOCS_EXAMPLES_URL, @@ -83,8 +101,8 @@ def websocket_message(flow: http.HTTPFlow) -> None: id="tcp-log-messages", name="TCP log messages", description=( - "Logs raw TCP message direction and size for each frame so you can " - "observe binary traffic without altering it." + "Logs direction and byte size for every TCP frame so you can see " + "what is crossing the route without modifying the payload." ), category="tcp", source_url=DOCS_EXAMPLES_URL, @@ -104,8 +122,8 @@ def tcp_message(flow: tcp.TCPFlow) -> None: id="dns-spoof-a-record", name="DNS spoof A record", description=( - "Overrides one DNS A record while forwarding every other query " - "unchanged. Useful for selective service redirection." + "Overrides one DNS A record while leaving all other queries alone. " + "Useful for selective redirection during local testing." ), category="dns", source_url=DOCS_EXAMPLES_URL, diff --git a/backend/app/services/mitmproxy/manager.py b/backend/app/services/mitmproxy/manager.py index ad60ade..0817996 100644 --- a/backend/app/services/mitmproxy/manager.py +++ b/backend/app/services/mitmproxy/manager.py @@ -20,6 +20,7 @@ class MitmdumpManager: 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] = {} async def start(self, proxy: ProxyDocument) -> dict[str, object]: @@ -67,6 +68,7 @@ class MitmdumpManager: raise MitmdumpError(reason) self.processes[proxy.proxy_port] = process + self.attached_pids.pop(proxy.proxy_port, None) return { "status": "running", "pid": process.pid, @@ -79,6 +81,7 @@ class MitmdumpManager: 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: @@ -98,19 +101,70 @@ class MitmdumpManager: if process.returncode is None: return "running" self.processes.pop(proxy.proxy_port, None) + self.attached_pids.pop(proxy.proxy_port, None) return "stopped" - if proxy.pid is None: + pid = self.attached_pids.get(proxy.proxy_port, proxy.pid) + if pid is None: return "stopped" if proxy.status == "running" else proxy.status - try: - os.kill(proxy.pid, 0) - except ProcessLookupError: + 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" - except PermissionError: - return "running" 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 @@ -126,6 +180,8 @@ class MitmdumpManager: Path(proxy.service.folder), proxy.service.port, upstream_port, + host_to="127.0.0.1", + rewrite_host=True, ) if not changed: raise MitmdumpError("Unable to move Docker service port") @@ -135,19 +191,21 @@ class MitmdumpManager: self, proxy: ProxyDocument, upstream_port: int | None, - ) -> None: + ) -> bool: if upstream_port is None: - return + return True if proxy.service.name is None or proxy.service.folder is None: - return + return True if upstream_port == proxy.service.port: - return + return True - await docker.change_port( + return await docker.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( @@ -200,20 +258,20 @@ class MitmdumpManager: return proxy.mode in {"regular", "socks5", "dns", "transparent"} def write_script(self, proxy: ProxyDocument) -> Path: - path = self.work_dir / "addons" / f"{proxy.proxy_port}.py" + path = self.script_path_for(proxy) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(proxy.code, encoding="utf-8") return path def write_config(self, proxy: ProxyDocument) -> Path: - path = self.work_dir / "conf" / str(proxy.proxy_port) / "config.yaml" + path = self.config_path_for(proxy) path.parent.mkdir(parents=True, exist_ok=True) options = {"block_global": False, **proxy.mitm_options} path.write_text(yaml.safe_dump(options, sort_keys=True), encoding="utf-8") return path def log_path(self, proxy: ProxyDocument) -> Path: - path = self.work_dir / "logs" / f"{proxy.proxy_port}.log" + path = self.log_path_for(proxy) path.parent.mkdir(parents=True, exist_ok=True) return path @@ -227,19 +285,7 @@ class MitmdumpManager: ) async def stop_process(self, process) -> None: - try: - os.killpg(process.pid, signal.SIGTERM) - except ProcessLookupError: - return - - try: - await asyncio.wait_for(process.wait(), timeout=5) - except TimeoutError: - try: - os.killpg(process.pid, signal.SIGKILL) - except ProcessLookupError: - return - await process.wait() + await self.stop_pid(process.pid) async def exited_early(self, process) -> bool: try: @@ -250,21 +296,94 @@ class MitmdumpManager: async def stop_pid(self, pid: int) -> None: try: - os.kill(pid, signal.SIGTERM) + pgid = os.getpgid(pid) except ProcessLookupError: return + except PermissionError: + pgid = pid - for _ in range(10): - await asyncio.sleep(0.2) + try: + os.killpg(pgid, signal.SIGTERM) + except ProcessLookupError: + return + except PermissionError: try: - os.kill(pid, 0) + 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.kill(pid, signal.SIGKILL) + os.killpg(pgid, signal.SIGKILL) except ProcessLookupError: - return + 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]: + 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] + + def script_path_for(self, proxy: ProxyDocument) -> Path: + return self.work_dir / "addons" / f"{proxy.proxy_port}.py" + + def conf_dir_for(self, proxy: ProxyDocument) -> Path: + return self.work_dir / "conf" / str(proxy.proxy_port) + + def config_path_for(self, proxy: ProxyDocument) -> Path: + return self.conf_dir_for(proxy) / "config.yaml" + + def log_path_for(self, proxy: ProxyDocument) -> Path: + return self.work_dir / "logs" / f"{proxy.proxy_port}.log" def read_log_tail(self, path: Path, limit: int = 4000) -> str: if not path.exists(): diff --git a/backend/app/services/mitmproxy/recovery.py b/backend/app/services/mitmproxy/recovery.py new file mode 100644 index 0000000..67b4c07 --- /dev/null +++ b/backend/app/services/mitmproxy/recovery.py @@ -0,0 +1,16 @@ +from repositories.proxy import list_proxies, update_proxy_runtime +from 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 diff --git a/backend/tests/api/test_proxy.py b/backend/tests/api/test_proxy.py index 0abe4b8..fcc8b0b 100644 --- a/backend/tests/api/test_proxy.py +++ b/backend/tests/api/test_proxy.py @@ -52,6 +52,38 @@ def test_list_proxies_returns_documents(client, monkeypatch: object): 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() == { + "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 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index cbf2f42..cda91a4 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -13,7 +13,7 @@ os.environ.setdefault("MONGODB_DB_NAME", "theplug") os.environ.setdefault("REDIS_URL", "redis://localhost:6379") os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key") os.environ.setdefault("JWT_ALGORITHM", "HS256") -os.environ.setdefault("JWT_EXPIRE_MINUTES", "30") +os.environ.setdefault("JWT_EXPIRE_MINUTES", "720") os.environ.setdefault("AUTH_USERNAME", "admin") os.environ.setdefault("AUTH_PASSWORD", "admin") @@ -33,7 +33,7 @@ def auth_context(fake_user): from core.security import AuthContext from models.auth import AccessTokenPayload - exp = int((datetime.now(UTC) + timedelta(minutes=30)).timestamp()) + 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) diff --git a/backend/tests/integrations/test_docker_compose.py b/backend/tests/integrations/test_docker_compose.py new file mode 100644 index 0000000..c44a344 --- /dev/null +++ b/backend/tests/integrations/test_docker_compose.py @@ -0,0 +1,50 @@ +from pathlib import Path + +from integrations.docker.compose import get_all_services_in_dir + + +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 == [] diff --git a/backend/tests/integrations/test_docker_ports.py b/backend/tests/integrations/test_docker_ports.py new file mode 100644 index 0000000..3097cf8 --- /dev/null +++ b/backend/tests/integrations/test_docker_ports.py @@ -0,0 +1,30 @@ +from 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" diff --git a/backend/tests/services/test_mitmproxy_manager.py b/backend/tests/services/test_mitmproxy_manager.py index 296665f..0411d28 100644 --- a/backend/tests/services/test_mitmproxy_manager.py +++ b/backend/tests/services/test_mitmproxy_manager.py @@ -73,3 +73,97 @@ def test_running_document_without_pid_is_treated_as_stopped(): 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, + } diff --git a/backend/tests/services/test_recovery.py b/backend/tests/services/test_recovery.py new file mode 100644 index 0000000..f64960e --- /dev/null +++ b/backend/tests/services/test_recovery.py @@ -0,0 +1,76 @@ +import asyncio + +from models.proxy import ProxyDocument +from models.service import ServiceEndpointDocument +from 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 diff --git a/backend/tests/test_security.py b/backend/tests/test_security.py new file mode 100644 index 0000000..46256dd --- /dev/null +++ b/backend/tests/test_security.py @@ -0,0 +1,16 @@ +from datetime import UTC, datetime + +from core.security import create_access_token, decode_access_token + + +def test_create_access_token_uses_twelve_hour_default_window(monkeypatch): + monkeypatch.setattr("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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b26f438..bc49442 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,7 +17,8 @@ "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.41.1", "codemirror": "^6.0.2", - "vue": "^3.5.26" + "vue": "^3.5.26", + "vue-router": "^4.6.3" }, "devDependencies": { "@vitejs/plugin-vue": "^6.0.1", @@ -1131,6 +1132,12 @@ "@vue/shared": "3.5.33" } }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, "node_modules/@vue/language-core": { "version": "3.2.8", "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.8.tgz", @@ -1643,6 +1650,21 @@ } } }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, "node_modules/vue-tsc": { "version": "3.2.8", "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index 328dbf3..b06ae1b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,7 +19,8 @@ "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.41.1", "codemirror": "^6.0.2", - "vue": "^3.5.26" + "vue": "^3.5.26", + "vue-router": "^4.6.3" }, "devDependencies": { "@vitejs/plugin-vue": "^6.0.1", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a8a395b..ff6473c 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -2,6 +2,7 @@ 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"; @@ -24,15 +25,17 @@ onMounted(() => { v-model:username="plane.username" v-model:password="plane.password" :loading="plane.loading" - :error="plane.error" + :error="plane.authMessage || plane.error" @submit="plane.signIn" />
+ + diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 99513b9..106605f 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,16 +1,26 @@ import type { AddonExample, - DockerService, HealthResult, MitmOption, PythonEditorSession, ProxyDocument, ProxyDraft, + 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); } @@ -52,7 +62,9 @@ async function request(path: string, init: RequestInit = {}): Promise { const body = await response.json().catch(() => null); if (!response.ok) { const detail = body?.detail ?? response.statusText; - throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail)); + const message = + typeof detail === "string" ? detail : JSON.stringify(detail); + throw new ApiError(response.status, message); } return body as T; } @@ -72,7 +84,10 @@ export async function logout() { export const api = { proxies: () => request("/proxy"), - services: () => request("/proxy/services"), + services: (scanRoot?: string) => + request( + `/proxy/services${scanRoot ? `?scan_root=${encodeURIComponent(scanRoot)}` : ""}`, + ), options: () => request("/proxy/mitmproxy/options"), examples: () => request("/proxy/mitmproxy/examples"), editorSession: () => diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index 06fb21b..e241b34 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -1,5 +1,6 @@ + + diff --git a/frontend/src/components/CodeEditor.vue b/frontend/src/components/CodeEditor.vue index 859c16f..406299c 100644 --- a/frontend/src/components/CodeEditor.vue +++ b/frontend/src/components/CodeEditor.vue @@ -12,7 +12,6 @@ const emit = defineEmits<{ save: []; }>(); const host = ref(null); -const status = ref("Connecting Python tools..."); const error = ref(""); let editor: EditorView | null = null; let languageClient: Awaited>["client"] | null = @@ -110,15 +109,9 @@ async function mountEditor() { const connection = await connectPythonLsp(session); languageClient = connection.client; languageSocket = connection.socket; - - void languageClient.initializing.then( - () => { - status.value = "Python tools ready"; - }, - () => { - status.value = "Python tools unavailable"; - }, - ); + void languageClient.initializing.catch(() => { + error.value = "Python language tools are unavailable."; + }); editor = new EditorView({ doc: model.value, @@ -141,17 +134,13 @@ async function mountEditor() { const message = value instanceof Error ? value.message : "Unable to start the editor"; error.value = message; - status.value = "Python tools unavailable"; } } diff --git a/frontend/src/components/EditorPane.vue b/frontend/src/components/EditorPane.vue index dded7be..46769df 100644 --- a/frontend/src/components/EditorPane.vue +++ b/frontend/src/components/EditorPane.vue @@ -11,13 +11,11 @@ const plane = useControlPlaneContext();
-

Code

-

Addon editor

+

Proxy script

{{ plane.codeLineCount }} lines {{ plane.draft.mode }} / {{ plane.draft.protocol }} -
diff --git a/frontend/src/components/ExamplesPanel.vue b/frontend/src/components/ExamplesPanel.vue index c580ff5..5f21da7 100644 --- a/frontend/src/components/ExamplesPanel.vue +++ b/frontend/src/components/ExamplesPanel.vue @@ -7,23 +7,20 @@ const plane = useControlPlaneContext(); diff --git a/frontend/src/components/RuntimePanel.vue b/frontend/src/components/RuntimePanel.vue index 092ad31..8384e18 100644 --- a/frontend/src/components/RuntimePanel.vue +++ b/frontend/src/components/RuntimePanel.vue @@ -1,26 +1,69 @@ diff --git a/frontend/src/components/ServiceDiscoveryPanel.vue b/frontend/src/components/ServiceDiscoveryPanel.vue new file mode 100644 index 0000000..41144bf --- /dev/null +++ b/frontend/src/components/ServiceDiscoveryPanel.vue @@ -0,0 +1,80 @@ + + + diff --git a/frontend/src/components/ServicePanel.vue b/frontend/src/components/ServicePanel.vue deleted file mode 100644 index 0833a46..0000000 --- a/frontend/src/components/ServicePanel.vue +++ /dev/null @@ -1,68 +0,0 @@ - - - diff --git a/frontend/src/components/UiIcon.vue b/frontend/src/components/UiIcon.vue new file mode 100644 index 0000000..9985196 --- /dev/null +++ b/frontend/src/components/UiIcon.vue @@ -0,0 +1,184 @@ + + + diff --git a/frontend/src/composables/useControlPlane.ts b/frontend/src/composables/useControlPlane.ts index ef4e417..21cfd38 100644 --- a/frontend/src/composables/useControlPlane.ts +++ b/frontend/src/composables/useControlPlane.ts @@ -7,19 +7,38 @@ import { watch, type InjectionKey, } from "vue"; +import { useRoute, useRouter } from "vue-router"; -import { api, getToken, login, logout } from "../api"; +import { ApiError, api, getToken, login, logout, setToken } from "../api"; import type { AddonExample, DockerService, + DockerServiceBinding, HealthResult, MitmOption, ProxyDocument, ProxyDraft, } from "../types"; -const editorStorageKey = "theplug.editor.open"; -const toolStorageKey = "theplug.tool.view"; +const optionScopeStorageKey = "theplug.option.scope"; +const scanRootStorageKey = "theplug.scan.root"; + +const focusedMitmOptions = new Set([ + "anticache", + "block_global", + "connection_strategy", + "flow_detail", + "http2", + "http3", + "keep_host_header", + "rawtcp", + "showhost", + "ssl_insecure", + "termlog_verbosity", + "upstream_cert", + "validate_inbound_headers", + "websocket", +]); export const modes = [ "reverse", @@ -46,7 +65,7 @@ export const protocols = [ ]; export type RuntimeAction = "start" | "stop" | "restart"; -export type ToolView = "options" | "examples" | "logs"; +export type OptionScope = "focused" | "all"; export function createDefaultDraft(port = 8080, code?: string): ProxyDraft { return { @@ -82,6 +101,7 @@ def request(flow: http.HTTPFlow) -> None: service: { host: "127.0.0.1", port: 9000, + bind_host: null, name: null, folder: null, upstream_port: null, @@ -99,13 +119,48 @@ function writeSession(key: string, value: string) { window.sessionStorage.setItem(key, value); } +function parseProxyPort(value: unknown): number | null { + const port = Number(value); + return Number.isInteger(port) && port > 0 ? port : null; +} + +function parseWizardMode(value: unknown): "create" | "edit" | null { + return value === "create" || value === "edit" ? value : null; +} + +function parseWizardStep(value: unknown): number { + const step = Number(value); + return step >= 0 && step <= 2 ? step : 0; +} + +function sameQuery( + current: Record, + expected: Record, +) { + const keys = new Set([...Object.keys(current), ...Object.keys(expected)]); + for (const key of keys) { + const currentValue = current[key]; + const normalizedCurrent = Array.isArray(currentValue) + ? String(currentValue[0] ?? "") + : typeof currentValue === "string" + ? currentValue + : undefined; + if (normalizedCurrent !== expected[key]) return false; + } + return true; +} + export function useControlPlane() { + const router = useRouter(); + const route = useRoute(); const token = ref(getToken()); const username = ref("admin"); const password = ref(""); + const authMessage = ref(""); const error = ref(""); const notice = ref(""); const loading = ref(false); + const servicesLoading = ref(false); const proxies = ref([]); const services = ref([]); const options = ref([]); @@ -113,21 +168,153 @@ export function useControlPlane() { const selectedPort = ref(null); const health = ref(null); const logs = ref(""); + const logFilter = ref(""); + const logsPaused = ref(false); const optionSearch = ref(""); const exampleSearch = ref(""); const customExampleName = ref(""); - const editorOpen = ref(readSession(editorStorageKey, "0") === "1"); - const toolView = ref( - readSession(toolStorageKey, "options") as ToolView, + const scanRoot = ref(readSession(scanRootStorageKey, "/root")); + const optionScope = ref( + readSession(optionScopeStorageKey, "focused") as OptionScope, ); const selectedOptionName = ref(""); const selectedExampleId = ref(""); const draft = reactive(createDefaultDraft()); + const loadedPort = ref(null); + const wizardOpen = ref(false); + const wizardMode = ref<"create" | "edit">("create"); + const wizardStep = ref(0); + let noticeTimer: number | null = null; + let logPollTimer: number | null = null; + let syncingRoute = false; const selectedProxy = computed(() => proxies.value.find((proxy) => proxy.proxy_port === selectedPort.value), ); + const runtimeStatus = computed(() => selectedProxy.value?.status ?? "draft"); + const isRunning = computed(() => + ["running", "starting", "restarting"].includes(runtimeStatus.value), + ); + const actionState = computed(() => ({ + canSave: !loading.value && !isRunning.value, + canStart: !loading.value && Boolean(selectedProxy.value) && !isRunning.value, + canStop: !loading.value && Boolean(selectedProxy.value) && isRunning.value, + canRestart: !loading.value && Boolean(selectedProxy.value) && isRunning.value, + canDelete: !loading.value && Boolean(selectedProxy.value), + showSave: !isRunning.value, + showStart: Boolean(selectedProxy.value) && !isRunning.value, + showStop: Boolean(selectedProxy.value) && isRunning.value, + showRestart: Boolean(selectedProxy.value) && isRunning.value, + })); + + const wizardTitle = computed(() => + wizardMode.value === "edit" ? "Edit proxy settings" : "Create proxy", + ); + const wizardStepTitle = computed(() => { + return ["Proxy route", "Healthchecks", "Custom mitmdump options"][ + wizardStep.value + ] ?? "Proxy route"; + }); + const canGoBackWizard = computed(() => wizardStep.value > 0); + const canGoNextWizard = computed(() => wizardStep.value < 2); + const filteredLogs = computed(() => { + const source = logs.value || ""; + const query = logFilter.value.trim().toLowerCase(); + if (!query) return source; + return source + .split("\n") + .filter((line) => line.toLowerCase().includes(query)) + .join("\n"); + }); + + function buildRouteQuery() { + const query: Record = { + scanRoot: scanRoot.value.trim() && scanRoot.value.trim() !== "/root" + ? scanRoot.value.trim() + : undefined, + }; + if (wizardOpen.value) { + query.modal = wizardMode.value; + query.step = String(wizardStep.value); + } + return query; + } + + async function syncRouteState() { + if (syncingRoute) return; + const path = selectedPort.value ? `/proxies/${selectedPort.value}` : "/proxies"; + const query = buildRouteQuery(); + if (route.path === path && sameQuery(route.query as Record, query)) { + return; + } + + syncingRoute = true; + try { + await router.replace({ path, query }); + } finally { + syncingRoute = false; + } + } + + function applyRouteState() { + if (syncingRoute) return; + + syncingRoute = true; + try { + selectedPort.value = parseProxyPort(route.params.proxyPort); + + const routedScanRoot = + typeof route.query.scanRoot === "string" ? route.query.scanRoot : null; + scanRoot.value = routedScanRoot || readSession(scanRootStorageKey, "/root"); + + const modal = parseWizardMode(route.query.modal); + const step = parseWizardStep(route.query.step); + + if (modal === "create") { + prepareDraftForCreate(); + wizardMode.value = "create"; + wizardStep.value = step; + wizardOpen.value = true; + } else if (modal === "edit" && selectedPort.value !== null) { + wizardMode.value = "edit"; + wizardStep.value = step; + wizardOpen.value = true; + } else { + wizardOpen.value = false; + wizardStep.value = 0; + } + } finally { + syncingRoute = false; + } + } + + const serviceEntries = computed(() => + services.value.flatMap((service) => + bindingsFor(service).map((binding) => { + const proxy = + proxies.value.find( + (item) => + item.service.name === service.name && + item.service.folder === service.folder && + item.service.port === binding.published, + ) ?? null; + + return { + id: `${service.folder}:${service.name}:${binding.host ?? "*"}:${binding.published}`, + service, + binding, + proxy, + listenHost: binding.host ?? "0.0.0.0", + }; + }), + ), + ); + + const missingServiceCount = computed( + () => serviceEntries.value.filter((entry) => !entry.proxy).length, + ); + const routeState = computed(() => { const livePort = selectedProxy.value?.service.upstream_port ?? draft.service.upstream_port ?? null; @@ -136,27 +323,43 @@ export function useControlPlane() { const remapped = livePort !== null && livePort !== draft.service.port; const configuredTarget = `${draft.service.host}:${draft.service.port}`; const liveTarget = `${draft.service.host}:${livePort ?? draft.service.port}`; + const takeoverReady = + reverseManaged && dockerManaged && draft.proxy_port === draft.service.port; + const takeoverMismatch = + reverseManaged && dockerManaged && draft.proxy_port !== draft.service.port; - let startNotice = "Save stores configuration only. Start applies runtime routing."; - if (reverseManaged && dockerManaged) { + let startNotice: string | null = null; + if (takeoverReady) { startNotice = - `Starting this reverse proxy will move the service from :${draft.service.port} ` + - `to a temporary upstream port and bind mitmdump on :${draft.proxy_port}.`; + `Start will move the real service from :${draft.service.port} to a temporary upstream port and put mitmdump on :${draft.proxy_port}.`; + } else if (takeoverMismatch) { + startNotice = + `Start will move the real service away from :${draft.service.port}, but this proxy is listening on :${draft.proxy_port}. Set the proxy port to :${draft.service.port} if you want mitmdump to replace the original route.`; } else if (draft.mode_spec) { startNotice = - "This proxy uses a raw mode specification. Start will follow the custom mitmdump mode exactly as configured."; + "Raw mode spec is enabled. Start will use the exact mitmdump mode string you configured."; } else if (draft.mode !== "reverse") { startNotice = - "This mode does not use Docker port remapping. Start only launches the configured mitmdump listener."; + "This mode does not remap the Docker service port. Start only launches the configured mitmdump listener."; } - let liveNotice = "No runtime remap is active."; + let liveNotice: string | null = null; if (remapped) { liveNotice = `Live upstream is currently ${liveTarget}. Stop restores :${draft.service.port}.`; - } else if (reverseManaged && dockerManaged) { - liveNotice = "The original service port stays unchanged until Start is executed."; + } else if (takeoverMismatch) { + liveNotice = "The service will be moved away on Start, but the original route is not taken over unless the proxy port matches the service port."; } + const summaryHint = takeoverMismatch + ? startNotice + : remapped + ? liveNotice + : draft.mode_spec + ? startNotice + : draft.mode !== "reverse" + ? startNotice + : null; + return { configuredListen: `${draft.listen_host}:${draft.proxy_port}`, configuredTarget, @@ -167,26 +370,35 @@ export function useControlPlane() { reverseManaged, remapped, livePort, + takeoverReady, + takeoverMismatch, startNotice, liveNotice, + summaryHint, }; }); const filteredOptions = computed(() => { const query = optionSearch.value.toLowerCase().trim(); const available = options.value.filter((option) => !option.reserved); - const filtered = available + const scoped = query + ? available + : optionScope.value === "all" + ? available + : available.filter((option) => focusedMitmOptions.has(option.name)); + const filtered = scoped .filter((option) => { if (!query) return true; return `${option.name} ${option.description}`.toLowerCase().includes(query); }) .sort((a, b) => a.name.localeCompare(b.name)); - return filtered.slice(0, query ? 80 : 36); + return filtered.slice(0, query ? 80 : optionScope.value === "all" ? 80 : 18); }); const optionCount = computed( () => options.value.filter((option) => !option.reserved).length, ); + const activeOptionCount = computed(() => Object.keys(draft.mitm_options).length); const selectedOption = computed( () => @@ -226,19 +438,43 @@ export function useControlPlane() { const codeLineCount = computed(() => draft.code.split("\n").length); - watch(selectedProxy, (proxy) => { - if (!proxy) return; - Object.assign(draft, structuredClone(proxy)); - health.value = null; - logs.value = ""; + watch( + [selectedPort, proxies], + ([port]) => { + if (port === null || port === loadedPort.value) return; + const proxy = proxies.value.find((item) => item.proxy_port === port); + if (!proxy) return; + hydrateDraft(proxy); + }, + { immediate: true }, + ); + + watch( + () => [ + route.params.proxyPort, + route.query.modal, + route.query.step, + route.query.scanRoot, + ], + () => { + applyRouteState(); + }, + { immediate: true }, + ); + + watch( + [selectedPort, wizardOpen, wizardMode, wizardStep, scanRoot], + () => { + void syncRouteState(); + }, + ); + + watch(optionScope, (value) => { + writeSession(optionScopeStorageKey, value); }); - watch(editorOpen, (value) => { - writeSession(editorStorageKey, value ? "1" : "0"); - }); - - watch(toolView, (value) => { - writeSession(toolStorageKey, value); + watch(scanRoot, (value) => { + writeSession(scanRootStorageKey, value); }); watch(filteredOptions, (items) => { @@ -253,60 +489,196 @@ export function useControlPlane() { } }, { immediate: true }); + watch(selectedProxy, (proxy) => { + if (!proxy) return; + if (!wizardOpen.value) { + hydrateDraft(proxy); + } + }); + + watch( + [selectedPort, token, logsPaused], + ([port, authToken, paused]) => { + stopLogPolling(); + if (!authToken || port === null || paused) return; + void syncLogs(true); + if (typeof window === "undefined") return; + logPollTimer = window.setInterval(() => { + void syncLogs(true); + }, 2000); + }, + { immediate: true }, + ); + async function signIn() { await run(async () => { await login(username.value, password.value); token.value = getToken(); + authMessage.value = ""; password.value = ""; await refreshAll(); }, "Logged in"); } async function signOut() { - await logout(); - token.value = null; - proxies.value = []; + if (token.value) { + await logout(); + } + clearSession(); } async function refreshAll() { await run(async () => { - const [proxyList, serviceList, optionList, exampleList] = await Promise.all([ - api.proxies(), - api.services(), - api.options(), - api.examples(), - ]); - proxies.value = proxyList; - services.value = serviceList; - options.value = optionList; - examples.value = exampleList; - if (!selectedPort.value && proxyList[0]) { - selectedPort.value = proxyList[0].proxy_port; + servicesLoading.value = true; + try { + const [proxyList, serviceResponse, optionList, exampleList] = + await Promise.all([ + api.proxies(), + api.services(scanRoot.value.trim() || undefined), + api.options(), + api.examples(), + ]); + proxies.value = proxyList; + services.value = serviceResponse.services; + scanRoot.value = serviceResponse.scan_root; + options.value = optionList; + examples.value = exampleList; + if (!proxyList.some((proxy) => proxy.proxy_port === selectedPort.value)) { + selectedPort.value = null; + } + } finally { + servicesLoading.value = false; } }); } - function newProxy() { + async function refreshServices() { + servicesLoading.value = true; + error.value = ""; + try { + const response = await api.services(scanRoot.value.trim() || undefined); + scanRoot.value = response.scan_root; + services.value = response.services; + } catch (err) { + error.value = err instanceof Error ? err.message : String(err); + } finally { + servicesLoading.value = false; + } + } + + function prepareDraftForCreate(announce = false) { selectedPort.value = null; + loadedPort.value = null; health.value = null; logs.value = ""; + logFilter.value = ""; Object.assign( draft, createDefaultDraft(nextPort(), examples.value[0]?.code ?? draft.code), ); - notice.value = "New proxy draft created. Save stores configuration only."; - toolView.value = "options"; + if (announce) { + notice.value = "Draft created."; + } + } + + function newProxy() { + prepareDraftForCreate(true); + } + + function openCreateWizard( + service?: DockerService, + binding?: DockerServiceBinding, + ) { + prepareDraftForCreate(); + if (service && binding) { + Object.assign(draft, draftForService(service, binding)); + } + wizardMode.value = "create"; + wizardStep.value = 0; + wizardOpen.value = true; + void syncRouteState(); + } + + function openEditWizard(proxy = selectedProxy.value) { + if (!proxy) return; + selectedPort.value = proxy.proxy_port; + hydrateDraft(proxy); + wizardMode.value = "edit"; + wizardStep.value = 0; + wizardOpen.value = true; + void syncRouteState(); + } + + function closeWizard() { + if (wizardMode.value === "edit" && selectedProxy.value) { + hydrateDraft(selectedProxy.value); + } else if (wizardMode.value === "create") { + prepareDraftForCreate(); + } + wizardOpen.value = false; + wizardStep.value = 0; + void syncRouteState(); + } + + function nextWizardStep() { + wizardStep.value = Math.min(wizardStep.value + 1, 2); + void syncRouteState(); + } + + function previousWizardStep() { + wizardStep.value = Math.max(wizardStep.value - 1, 0); + void syncRouteState(); + } + + async function createProxyFromService( + service: DockerService, + binding: DockerServiceBinding, + ) { + openCreateWizard(service, binding); + } + + async function createMissingServiceProxies() { + await run(async () => { + for (const entry of serviceEntries.value) { + if (entry.proxy) continue; + await api.createProxy(draftForService(entry.service, entry.binding)); + } + await refreshAll(); + const first = proxies.value[0]; + if (first) { + selectedPort.value = first.proxy_port; + hydrateDraft(first); + } + }, "Missing proxies created"); } async function saveProxy() { + let persisted = false; await run(async () => { const payload = cleanDraft(); const saved = selectedProxy.value - ? await api.updateProxy(selectedProxy.value.proxy_port, payload) + ? await api.updateProxy(selectedProxy.value.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, + }) : await api.createProxy(payload); await refreshAll(); selectedPort.value = saved.proxy_port; + const refreshed = proxies.value.find((proxy) => proxy.proxy_port === saved.proxy_port); + hydrateDraft(refreshed ?? saved); + persisted = true; }, "Proxy saved"); + if (persisted) { + wizardOpen.value = false; + wizardStep.value = 0; + } } async function removeProxy() { @@ -315,6 +687,7 @@ export function useControlPlane() { await run(async () => { await api.deleteProxy(proxy.proxy_port); selectedPort.value = null; + loadedPort.value = null; await refreshAll(); }, "Proxy deleted"); } @@ -330,8 +703,10 @@ export function useControlPlane() { : kind === "stop" ? await api.stop(port) : await api.restart(port); - replaceProxy(updated); + await refreshAll(); selectedPort.value = updated.proxy_port; + const refreshed = proxies.value.find((item) => item.proxy_port === updated.proxy_port); + hydrateDraft(refreshed ?? updated); }, `Proxy ${kind} requested`); } @@ -344,11 +719,19 @@ export function useControlPlane() { } async function loadLogs() { + await syncLogs(false); + } + + async function syncLogs(silent = false) { const proxy = selectedProxy.value; if (!proxy) return; - await run(async () => { + try { logs.value = (await api.logs(proxy.proxy_port)).log; - }); + } catch (err) { + if (!silent) { + error.value = err instanceof Error ? err.message : String(err); + } + } } async function saveCurrentAsExample() { @@ -364,7 +747,7 @@ export function useControlPlane() { await api.createExample({ id, name, - description: `Captured from ${draft.mode} ${draft.protocol} proxy ${draft.name}.`, + description: `Saved from ${draft.name} (${draft.mode} / ${draft.protocol}).`, category: "custom", code: draft.code, source_url: null, @@ -383,19 +766,38 @@ export function useControlPlane() { }, "Custom example deleted"); } - function applyService(service: DockerService, port: number) { + function applyService( + service: DockerService, + port: number, + bindHost: string | null = null, + ) { + const shouldFollowServicePort = + !selectedProxy.value || draft.proxy_port === draft.service.port; draft.service.name = service.name; draft.service.folder = service.folder; draft.service.port = port; + draft.service.bind_host = bindHost; draft.service.host = "127.0.0.1"; + draft.listen_host = bindHost ?? "0.0.0.0"; + if (draft.mode === "reverse" && !draft.mode_spec && shouldFollowServicePort) { + draft.proxy_port = port; + } } function applyExample(example: AddonExample) { + if ( + draft.code.trim() && + draft.code.trim() !== example.code.trim() && + typeof window !== "undefined" && + !window.confirm( + "Applying this template will overwrite your current proxy script. Continue?", + ) + ) { + return; + } selectedExampleId.value = example.id; draft.code = example.code; - notice.value = editorOpen.value - ? `Loaded example: ${example.name}` - : `Loaded example: ${example.name}. Open the editor to review or modify the code.`; + notice.value = `Loaded ${example.name}.`; } function selectExample(exampleId: string) { @@ -436,12 +838,20 @@ export function useControlPlane() { draft.mitm_options[name] = value; } - function toggleEditor(force?: boolean) { - editorOpen.value = typeof force === "boolean" ? force : !editorOpen.value; + function matchProxyPortToService() { + draft.proxy_port = draft.service.port; } - function setToolView(view: ToolView) { - toolView.value = view; + function toggleOptionScope(scope: OptionScope) { + optionScope.value = scope; + } + + function toggleLogsPaused() { + logsPaused.value = !logsPaused.value; + } + + function clearLogs() { + logs.value = ""; } function cleanDraft(): ProxyDraft { @@ -463,6 +873,7 @@ export function useControlPlane() { service: { host: draft.service.host, port: draft.service.port, + bind_host: draft.service.bind_host || null, name: draft.service.name || null, folder: draft.service.folder || null, upstream_port: draft.service.upstream_port || null, @@ -471,12 +882,35 @@ export function useControlPlane() { ) as ProxyDraft; } - function replaceProxy(proxy: ProxyDocument) { - const index = proxies.value.findIndex( - (item) => item.proxy_port === proxy.proxy_port, - ); - if (index === -1) proxies.value.push(proxy); - else proxies.value[index] = proxy; + function hydrateDraft(proxy: ProxyDocument) { + Object.assign(draft, JSON.parse(JSON.stringify(proxy)) as ProxyDraft); + loadedPort.value = proxy.proxy_port; + health.value = null; + logs.value = ""; + } + + function bindingsFor(service: DockerService) { + return service.bindings?.length + ? service.bindings + : service.ports.map((published) => ({ published, host: null })); + } + + function draftForService(service: DockerService, binding: DockerServiceBinding) { + const template = createDefaultDraft(binding.published, examples.value[0]?.code); + const portSuffix = bindingsFor(service).length > 1 ? `-${binding.published}` : ""; + return { + ...template, + name: `${service.name}${portSuffix}`, + listen_host: binding.host ?? "0.0.0.0", + service: { + host: "127.0.0.1", + port: binding.published, + bind_host: binding.host ?? null, + name: service.name, + folder: service.folder, + upstream_port: null, + }, + } satisfies ProxyDraft; } function nextPort() { @@ -497,26 +931,83 @@ export function useControlPlane() { async function run(task: () => Promise, success?: string) { loading.value = true; error.value = ""; - notice.value = ""; + clearNotice(); try { await task(); - if (success) notice.value = success; + if (success) showNotice(success); } catch (err) { + if (err instanceof ApiError && err.status === 401) { + clearSession("Session expired. Sign in again."); + return; + } error.value = err instanceof Error ? err.message : String(err); } finally { loading.value = false; } } + function showNotice(message: string, timeout = 2600) { + notice.value = message; + if (typeof window === "undefined") return; + if (noticeTimer !== null) window.clearTimeout(noticeTimer); + noticeTimer = window.setTimeout(() => { + notice.value = ""; + noticeTimer = null; + }, timeout); + } + + function clearNotice() { + notice.value = ""; + if (typeof window === "undefined" || noticeTimer === null) return; + window.clearTimeout(noticeTimer); + noticeTimer = null; + } + + function clearSession(message = "") { + stopLogPolling(); + setToken(null); + token.value = null; + proxies.value = []; + services.value = []; + options.value = []; + examples.value = []; + selectedPort.value = null; + loadedPort.value = null; + health.value = null; + logs.value = ""; + logFilter.value = ""; + wizardOpen.value = false; + wizardStep.value = 0; + wizardMode.value = "create"; + Object.assign(draft, createDefaultDraft()); + authMessage.value = message; + void router.replace({ path: "/proxies", query: {} }); + } + + function stopLogPolling() { + if (typeof window === "undefined" || logPollTimer === null) return; + window.clearInterval(logPollTimer); + logPollTimer = null; + } + return proxyRefs({ + activeOptionCount, activeOptions, applyExample, applyService, + actionState, + authMessage, checkHealth, + clearLogs, + closeWizard, codeLineCount, + filteredLogs, + logFilter, + logsPaused, + createMissingServiceProxies, + createProxyFromService, customExampleName, draft, - editorOpen, error, exampleSearch, filteredExamples, @@ -527,15 +1018,21 @@ export function useControlPlane() { logs, newProxy, notice, + openCreateWizard, + openEditWizard, optionCount, + optionScope, optionSearch, password, + previousWizardStep, proxies, + refreshServices, refreshAll, removeExample, removeOption, removeProxy, routeState, + runtimeStatus, runtimeAction, saveCurrentAsExample, saveProxy, @@ -545,16 +1042,28 @@ export function useControlPlane() { selectedPort, selectedProxy, selectOption, + serviceEntries, services, setOption, - setToolView, signIn, signOut, - toggleEditor, + matchProxyPortToService, + missingServiceCount, + nextWizardStep, + toggleOptionScope, + toggleLogsPaused, token, - toolView, updateOption, username, + wizardMode, + wizardOpen, + wizardStep, + wizardStepTitle, + wizardTitle, + canGoBackWizard, + canGoNextWizard, + scanRoot, + servicesLoading, }); } diff --git a/frontend/src/main.ts b/frontend/src/main.ts index f2c36ba..2238e17 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,6 +1,7 @@ import { createApp } from "vue"; import App from "./App.vue"; +import { router } from "./router"; import "./styles.css"; -createApp(App).mount("#app"); +createApp(App).use(router).mount("#app"); diff --git a/frontend/src/router.ts b/frontend/src/router.ts new file mode 100644 index 0000000..1dff0af --- /dev/null +++ b/frontend/src/router.ts @@ -0,0 +1,13 @@ +import { createRouter, createWebHistory } from "vue-router"; + +const RouteAnchor = { template: "
" }; + +export const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: "/", redirect: "/proxies" }, + { path: "/proxies", component: RouteAnchor }, + { path: "/proxies/:proxyPort(\\d+)", component: RouteAnchor }, + { path: "/:pathMatch(.*)*", redirect: "/proxies" }, + ], +}); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 88cf49b..ec79c1c 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -2,23 +2,23 @@ :root { color-scheme: dark; - --bg: #08090b; - --bg-elevated: #0d1014; - --panel: #11151a; + --bg: #090b0d; + --bg-elevated: #0f1317; + --panel: #11161b; --panel-2: #151b21; - --line: #252d35; - --line-strong: #33404d; - --text: #eef3f7; - --muted: #9da8b2; - --accent: #9be7ff; - --accent-2: #c6f2ff; - --ok: #8ef0b0; - --warn: #ffd88c; - --danger: #ff9f9f; - --radius-lg: 14px; - --radius-md: 10px; - --radius-sm: 8px; - --shadow: 0 20px 40px rgba(0, 0, 0, 0.22); + --line: #252c34; + --line-strong: #36414c; + --text: #f1f5f8; + --muted: #9aa6b2; + --accent: #a8ecff; + --accent-2: #d5f7ff; + --ok: #8ce7af; + --warn: #ffd585; + --danger: #ff9d9d; + --radius-lg: 8px; + --radius-md: 6px; + --radius-sm: 4px; + --shadow: none; } * { @@ -39,11 +39,7 @@ body { margin: 0; color: var(--text); font-family: "IBM Plex Sans", sans-serif; - background: - linear-gradient(rgba(255, 255, 255, 0.015) 1px, transparent 1px), - linear-gradient(90deg, rgba(255, 255, 255, 0.015) 1px, transparent 1px), - var(--bg); - background-size: 28px 28px, 28px 28px, auto; + background: var(--bg); } button, @@ -72,6 +68,24 @@ button { background: #171d24; } +.button-icon { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.ui-icon { + width: 0.95rem; + height: 0.95rem; + fill: none; + stroke: currentColor; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; + flex: 0 0 auto; +} + button:hover:not(:disabled) { border-color: var(--line-strong); background: #1c232b; @@ -96,6 +110,26 @@ button.ghost { background: #12171d; } +button.success { + color: #08120c; + background: var(--ok); + border-color: #5fb37d; +} + +button.success:hover:not(:disabled) { + background: #a6f0bf; +} + +button.warning { + color: #161006; + background: var(--warn); + border-color: #c9a24c; +} + +button.warning:hover:not(:disabled) { + background: #ffe19e; +} + button.danger { color: #ffe1e1; background: #2b1717; @@ -201,7 +235,6 @@ pre { border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--panel); - box-shadow: var(--shadow); } .app-header { @@ -262,7 +295,7 @@ pre { .app-body { display: grid; - grid-template-columns: 280px minmax(0, 1fr); + grid-template-columns: 250px minmax(0, 1fr); gap: 1rem; align-items: start; margin-top: 1rem; @@ -278,7 +311,7 @@ pre { .panel-title h2, .workbench-copy h1, .login-card h1 { - margin: 0.2rem 0 0; + margin: 0; font-size: clamp(1.18rem, 1.7vw, 1.5rem); line-height: 1.1; } @@ -343,8 +376,8 @@ pre { } .proxy-card.selected { - border-color: #4f6b7c; - background: #151c23; + border-color: #4e6272; + background: #161d24; } .proxy-card-head, @@ -402,7 +435,6 @@ pre { } .workbench-head, -.summary-panel, .editor-pane, .stack-panel { padding: 1rem; @@ -454,8 +486,7 @@ pre { .action-row, .doc-actions, -.template-actions, -.tool-switcher { +.template-actions { display: flex; flex-wrap: wrap; gap: 0.6rem; @@ -486,22 +517,27 @@ pre { border-color: #2f4d60; } +.app-toast { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 30; + min-width: 220px; + max-width: 360px; + padding: 0.85rem 1rem; + border: 1px solid #2f4d60; + border-radius: var(--radius-md); + background: rgba(21, 32, 40, 0.96); + color: #ddf7ff; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + backdrop-filter: blur(8px); +} + .compact { display: inline-block; width: fit-content; } -.summary-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 1rem; -} - -.summary-panel { - display: grid; - gap: 0.85rem; -} - .metric-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -523,6 +559,12 @@ pre { line-height: 1.35; } +.route-overview-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; +} + .metric-label { color: var(--muted); font-size: 0.75rem; @@ -530,23 +572,27 @@ pre { letter-spacing: 0.08em; } -.summary-lines { +.workspace-main-row { display: grid; - gap: 0.55rem; - color: var(--muted); -} - -.workbench-grid { - display: grid; - grid-template-columns: minmax(320px, 0.85fr) minmax(0, 1.15fr); + grid-template-columns: minmax(0, 5fr) minmax(320px, 3fr); gap: 1rem; align-items: start; } -.overview-stack, -.utility-panel { +.workspace-editor-pane, +.workspace-logs-pane, +.workspace-templates-row { + min-width: 0; +} + +.workspace-editor-pane, +.workspace-logs-pane { display: grid; - gap: 1rem; +} + +.wizard-step-shell { + display: grid; + gap: 0.9rem; } .compact-panel { @@ -597,37 +643,14 @@ pre { accent-color: var(--accent); } -.service-grid { - display: grid; - gap: 0.7rem; -} - -.service-chip { - display: grid; - gap: 0.5rem; - padding: 0.75rem; - border: 1px solid var(--line); - border-radius: var(--radius-md); - background: var(--bg-elevated); -} - -.port-row { - display: flex; - flex-wrap: wrap; - gap: 0.45rem; -} - -.port-chip { - padding: 0.38rem 0.6rem; - background: #0d1116; +.service-folder { + display: block; + overflow: hidden; + color: var(--muted); font-family: "IBM Plex Mono", monospace; - font-size: 0.8rem; -} - -.port-chip.active { - color: #071117; - background: var(--accent); - border-color: #7dcfe7; + font-size: 0.74rem; + text-overflow: ellipsis; + white-space: nowrap; } .compact-details { @@ -688,14 +711,29 @@ pre { font-size: 0.8rem; } -.tool-switcher button { - background: #0f1318; +.title-actions, +.segmented { + display: flex; + align-items: center; + gap: 0.55rem; } -.tool-switcher button.active { - color: #081016; +.segmented { + padding: 0.2rem; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: #0d1115; +} + +.segmented button { + padding: 0.45rem 0.7rem; + border-color: transparent; + background: transparent; +} + +.segmented button.active { + color: #071116; background: var(--accent); - border-color: #7dcfe7; } .tool-panel { @@ -710,7 +748,7 @@ pre { .table-head, .table-row { display: grid; - grid-template-columns: 180px minmax(0, 1fr) auto; + grid-template-columns: 150px minmax(0, 1fr) auto; gap: 0.7rem; align-items: center; } @@ -729,11 +767,19 @@ pre { overflow: auto; } +.compact-scroll { + max-height: 180px; +} + .table-row { padding: 0.6rem 0; border-top: 1px solid var(--line); } +.table-row input { + min-width: 0; +} + .table-link { justify-self: start; padding: 0; @@ -751,15 +797,62 @@ pre { .option-shell, .example-shell { display: grid; - grid-template-columns: minmax(220px, 0.7fr) minmax(0, 1fr); + grid-template-columns: 260px minmax(0, 1fr); gap: 0.9rem; min-height: 0; } +.option-step-layout { + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + gap: 1rem; + align-items: start; +} + +.option-step-main { + display: grid; + gap: 0.9rem; + min-width: 0; +} + .option-browser, .example-browser, .option-doc, .example-preview { + display: grid; + align-content: start; + gap: 0.75rem; + padding: 0.85rem; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: var(--bg-elevated); +} + +.option-browser-embedded, +.option-doc-embedded, +.option-active-card { + min-height: 0; +} + +.option-toolbar { + display: grid; + gap: 0.75rem; +} + +.option-browser-meta, +.option-active-head { + display: flex; + gap: 0.75rem; + align-items: center; + justify-content: space-between; +} + +.option-browser-meta p, +.option-active-head h3 { + margin: 0; +} + +.option-active-card { display: grid; gap: 0.75rem; padding: 0.85rem; @@ -768,11 +861,39 @@ pre { background: var(--bg-elevated); } +.option-doc-embedded .option-doc-head { + gap: 1rem; +} + +.doc-facts-compact { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.option-search-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.65rem; + align-items: center; +} + +.option-count { + display: inline-grid; + place-items: center; + min-width: 1.8rem; + min-height: 1.8rem; + padding: 0 0.45rem; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + font-family: "IBM Plex Mono", monospace; + font-size: 0.76rem; +} + .option-table, .example-index { display: grid; gap: 0.45rem; - max-height: 380px; + max-height: 320px; overflow: auto; } @@ -780,7 +901,7 @@ pre { .example-entry { width: 100%; display: grid; - gap: 0.18rem; + gap: 0.2rem; text-align: left; padding: 0.65rem 0.75rem; background: #0d1116; @@ -820,25 +941,67 @@ pre { line-height: 1.55; } +.compact-copy { + max-width: 70ch; +} + +.example-snippet { + color: var(--muted); + font-size: 0.78rem; + line-height: 1.45; +} + .doc-facts, .template-meta { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; + display: grid; + gap: 0.55rem; } -.doc-facts span { - padding: 0.22rem 0.45rem; - border-radius: 999px; - background: #0d1116; +.doc-facts { + margin: 0; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.doc-fact { + display: grid; + gap: 0.2rem; + padding: 0.55rem 0.65rem; border: 1px solid var(--line); - color: var(--muted); - font-size: 0.76rem; + border-radius: var(--radius-sm); + background: #0d1116; } -.logs-panel .terminal { - min-height: 300px; - max-height: 420px; +.doc-fact dt { + color: var(--muted); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.doc-fact dd { + margin: 0; + color: var(--text); + font-family: "IBM Plex Mono", monospace; + font-size: 0.8rem; +} + +.doc-actions { + align-items: center; +} + +.button-link { + display: inline-flex; + align-items: center; + gap: 0.45rem; +} + +.table-action { + padding: 0.55rem 0.7rem; +} + +.template-preview-terminal { + min-height: 260px; + max-height: 360px; } .terminal { @@ -855,6 +1018,7 @@ pre { .editor-pane { display: grid; + grid-template-rows: auto minmax(0, 1fr); gap: 0.85rem; } @@ -870,7 +1034,7 @@ pre { } .editor-shell { - min-height: 620px; + min-height: clamp(420px, 68vh, 760px); border: 1px solid var(--line); border-radius: var(--radius-md); overflow: hidden; @@ -879,28 +1043,219 @@ pre { .code-editor-wrap { display: grid; - grid-template-rows: auto 1fr; - min-height: 620px; -} - -.code-editor-status { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.75rem; - padding: 0.7rem 0.9rem; - border-bottom: 1px solid var(--line); - background: #0f1418; - color: var(--muted); - font-family: "IBM Plex Mono", monospace; - font-size: 0.78rem; + height: 100%; + min-height: clamp(420px, 68vh, 760px); } .code-editor { width: 100%; + height: 100%; min-height: 0; } +.logs-panel { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + max-height: clamp(420px, 68vh, 760px); + overflow: hidden; +} + +.logs-panel .terminal { + min-height: 0; + height: 100%; + max-height: 100%; + overflow: auto; +} + +.editor-error, +.field-error { + margin: 0; + padding: 0.7rem 0.85rem; + color: #ffd9d9; + background: #241617; + border-bottom: 1px solid #5c2c2c; + font-size: 0.82rem; +} + +.editor-placeholder { + display: grid; + gap: 0.85rem; + min-height: 180px; + padding: 1rem; +} + +.route-summary { + align-items: center; +} + +.route-strip { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; + align-items: center; +} + +.route-chip { + display: inline-flex; + align-items: center; + min-height: 1.9rem; + padding: 0.35rem 0.6rem; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: #0d1116; + color: var(--text); + font-family: "IBM Plex Mono", monospace; + font-size: 0.81rem; +} + +.route-form-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.single-row-grid { + grid-template-columns: minmax(0, 1fr); +} + +.advanced-inline-action { + align-items: center; +} + +.service-discovery-list { + display: grid; + gap: 0.65rem; +} + +.service-discovery-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.75rem; + align-items: center; + padding: 0.8rem 0.85rem; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: var(--bg-elevated); +} + +.service-discovery-main { + display: grid; + gap: 0.2rem; + min-width: 0; +} + +.service-route-copy { + color: var(--text); + font-family: "IBM Plex Mono", monospace; + font-size: 0.82rem; +} + +.service-discovery-actions { + display: flex; + align-items: center; + gap: 0.55rem; + flex-wrap: wrap; + justify-content: flex-end; +} + +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 40; + display: grid; + place-items: center; + padding: 1rem; + background: rgba(6, 8, 10, 0.72); + backdrop-filter: blur(4px); +} + +.modal-shell { + width: min(980px, 100%); + max-height: calc(100vh - 2rem); + display: grid; + gap: 1rem; + overflow: auto; + padding: 1rem; + border: 1px solid var(--line-strong); + border-radius: var(--radius-lg); + background: var(--panel); +} + +.modal-head, +.modal-actions { + display: flex; + align-items: center; + gap: 0.75rem; + justify-content: space-between; +} + +.modal-head-actions { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + justify-content: flex-end; +} + +.modal-head h2 { + margin: 0; + font-size: 1.3rem; +} + +.modal-step-copy { + margin: 0.35rem 0 0; + color: var(--muted); +} + +.modal-stepper { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; +} + +.modal-step-chip { + display: grid; + gap: 0.25rem; + justify-items: start; + text-align: left; + padding: 0.8rem; + background: #0f1318; +} + +.modal-step-chip span { + display: inline-grid; + place-items: center; + width: 1.7rem; + height: 1.7rem; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + font-family: "IBM Plex Mono", monospace; + font-size: 0.76rem; +} + +.modal-step-chip.active { + border-color: #7dcfe7; + background: #141c22; +} + +.modal-step-chip.active span, +.modal-step-chip.done span { + color: #071117; + background: var(--accent); + border-color: #7dcfe7; +} + +.modal-body { + display: grid; + gap: 1rem; +} + +.modal-action-spacer { + flex: 1 1 auto; +} + +.auth-alert { + margin-top: 0.2rem; +} + .logo { width: 44px; height: 44px; @@ -930,8 +1285,8 @@ pre { background: transparent; } -@media (max-width: 1320px) { - .workbench-grid { +@media (max-width: 1180px) { + .option-step-layout { grid-template-columns: 1fr; } @@ -941,9 +1296,9 @@ pre { } } -@media (max-width: 1120px) { +@media (max-width: 980px) { .app-body, - .summary-grid { + .workspace-main-row { grid-template-columns: 1fr; } @@ -951,6 +1306,10 @@ pre { position: static; max-height: none; } + + .logs-panel .terminal { + min-height: 0; + } } @media (max-width: 860px) { @@ -967,9 +1326,13 @@ pre { .form-grid, .metric-grid, + .route-overview-grid, + .doc-facts-compact, .table-head, .table-row, - .health-row { + .health-row, + .doc-facts, + .route-form-grid { grid-template-columns: 1fr; } @@ -982,6 +1345,21 @@ pre { .code-editor { min-height: 500px; } + + .modal-head, + .modal-actions { + align-items: flex-start; + flex-direction: column; + } + + .modal-head-actions { + width: 100%; + justify-content: flex-start; + } + + .modal-stepper { + grid-template-columns: 1fr; + } } @media (max-width: 640px) { @@ -1000,4 +1378,12 @@ pre { align-items: flex-start; flex-direction: column; } + + .service-discovery-row { + grid-template-columns: 1fr; + } + + .service-discovery-actions { + justify-content: flex-start; + } } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d0e4cb2..ba2be9f 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,11 +1,17 @@ export type ServiceEndpoint = { host: string; port: number; + bind_host?: string | null; name?: string | null; folder?: string | null; upstream_port?: number | null; }; +export type DockerServiceBinding = { + published: number; + host?: string | null; +}; + export type HealthCheck = { enabled: boolean; protocol?: string | null; @@ -42,9 +48,15 @@ export type ProxyDocument = { export type DockerService = { name: string; ports: number[]; + bindings?: DockerServiceBinding[]; folder: string; }; +export type ServiceDiscoveryResponse = { + scan_root: string; + services: DockerService[]; +}; + export type MitmOption = { name: string; type: string; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index b7435f0..ea29105 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ port: 5173, proxy: { "/api": { - target: "http://127.0.0.1:3671", + target: "http://127.0.0.1:3771", changeOrigin: true, ws: true, rewrite: (path) => path.replace(/^\/api/, ""), diff --git a/package.json b/package.json index b986ac3..3f94b4b 100644 --- a/package.json +++ b/package.json @@ -3,14 +3,17 @@ "private": true, "version": "0.1.0", "scripts": { - "dev:backend": "cd backend && PYTHONPATH=app uv run fastapi dev app/main.py", + "dev:backend": "cd backend && PYTHONPATH=app uv run fastapi dev app/main.py --host 0.0.0.0 --port 3671", "dev:frontend": "cd frontend && npm run dev", + "dev:deps": "./setup.sh deps", "test:backend": "cd backend && uv run pytest", "lint:backend": "cd backend && uv run ruff check .", "format:backend": "cd backend && uv run ruff format .", "build:frontend": "cd frontend && npm run build", "test": "npm run lint:backend && npm run test:backend && npm run build:frontend", "compose:up": "./setup.sh", - "compose:down": "docker compose --env-file backend/.env -f docker-compose.yml down" + "compose:down": "docker compose --env-file backend/.env -f docker-compose.yml down", + "playground:up": "docker compose -f playground/docker-compose.yml up -d", + "playground:down": "docker compose -f playground/docker-compose.yml down" } } diff --git a/playground/echo/docker-compose.bak.yml b/playground/echo/docker-compose.bak.yml new file mode 100644 index 0000000..bddcdc0 --- /dev/null +++ b/playground/echo/docker-compose.bak.yml @@ -0,0 +1,8 @@ +services: + echo: + image: hashicorp/http-echo:1.0 + command: + - -listen=:5678 + - -text=theplug playground upstream + ports: + - 9081:5678 diff --git a/playground/echo/docker-compose.yml b/playground/echo/docker-compose.yml new file mode 100644 index 0000000..943b904 --- /dev/null +++ b/playground/echo/docker-compose.yml @@ -0,0 +1,8 @@ +services: + echo: + image: hashicorp/http-echo:1.0 + command: + - -listen=:5678 + - -text=theplug playground upstream + ports: + - 127.0.0.1:54283:5678 diff --git a/setup.sh b/setup.sh index 6aa569c..aa35dbf 100755 --- a/setup.sh +++ b/setup.sh @@ -17,6 +17,22 @@ compose() { docker compose --env-file "$ENV_FILE" -f "$ROOT_DIR/docker-compose.yml" "$@" } +playground() { + docker compose -f "$ROOT_DIR/playground/docker-compose.yml" "$@" +} + +sync_env_values() { + local mongo_pass mongo_db + mongo_pass=$(grep -E '^MONGO_INITDB_ROOT_PASSWORD=' "$ENV_FILE" | cut -d '=' -f2-) + mongo_db=$(grep -E '^MONGODB_DB_NAME=' "$ENV_FILE" | cut -d '=' -f2-) + + [[ -n "$mongo_db" ]] || mongo_db="theplug" + + if [[ -n "$mongo_pass" ]]; then + sed -i "s|^MONGODB_URI=.*|MONGODB_URI=mongodb://theplug:$mongo_pass@localhost:27017/$mongo_db?authSource=admin|" "$ENV_FILE" + fi +} + ensure_env() { if [[ -f "$ENV_FILE" ]]; then return @@ -64,12 +80,29 @@ EOF start_stack() { ensure_env + sync_env_values info "Building and starting backend, frontend, MongoDB and Redis..." compose up -d --build info "Backend: http://localhost:$(grep -E '^API_PORT=' "$ENV_FILE" | cut -d '=' -f2 || echo 3671)" info "Frontend: http://localhost:$(grep -E '^FRONTEND_PORT=' "$ENV_FILE" | cut -d '=' -f2 || echo 5173)" } +start_deps() { + ensure_env + sync_env_values + info "Starting MongoDB and Redis for host-backend testing..." + compose up -d mongodb redis + info "Now run these on the host:" + info " npm run dev:backend" + info " npm run dev:frontend" +} + +start_playground() { + info "Starting local playground upstream service..." + playground up -d + info "Playground service: http://127.0.0.1:9081" +} + if [[ "${1:-}" == "service" ]]; then ensure_env install_service @@ -77,6 +110,16 @@ if [[ "${1:-}" == "service" ]]; then exit 0 fi +if [[ "${1:-}" == "deps" ]]; then + start_deps + exit 0 +fi + +if [[ "${1:-}" == "playground" ]]; then + start_playground + exit 0 +fi + if [[ -d "/srv/theplug" && "$ROOT_DIR" != "/srv/theplug" ]]; then info "thePlug found in /srv/theplug, switching there" cd /srv/theplug