stackhelx 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
stackhelx/server.py ADDED
@@ -0,0 +1,1786 @@
1
+ """API local que consume la interfaz web.
2
+
3
+ Este servidor ejecuta comandos arbitrarios definidos en stack.yaml. Se trata
4
+ como superficie sensible aunque solo escuche en loopback:
5
+
6
+ - bind exclusivo a 127.0.0.1
7
+ - token obligatorio en Authorization: Bearer, comparado en tiempo constante
8
+ - validacion del header Host contra rebinding de DNS
9
+ - rate limit en todas las rutas
10
+ - headers de seguridad y CSP estricta
11
+ - logging de cierres de procesos, rechazos de auth y excesos de rate limit
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import contextlib
18
+ import json
19
+ import logging
20
+ import os
21
+ import queue
22
+ import secrets
23
+ import shutil
24
+ import subprocess
25
+ import sys
26
+ import threading
27
+ import time
28
+ from collections import defaultdict, deque
29
+ from dataclasses import dataclass, field
30
+ from pathlib import Path
31
+ from typing import Literal
32
+
33
+ import psutil
34
+ from fastapi import Depends, FastAPI, HTTPException, Query, Request
35
+ from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
36
+ from fastapi.staticfiles import StaticFiles
37
+ from pydantic import BaseModel, Field, field_validator
38
+ from rich.console import Console
39
+
40
+ from . import (
41
+ __version__,
42
+ config,
43
+ detect,
44
+ docker,
45
+ doctor,
46
+ history,
47
+ mcp,
48
+ ports,
49
+ registry,
50
+ runner,
51
+ tunnel,
52
+ )
53
+
54
+ # `browse_module` porque el endpoint de /api/browse ya se llama browse.
55
+ from . import browse as browse_module
56
+
57
+ log = logging.getLogger("portmaster.server")
58
+
59
+ WEB = Path(__file__).parent / "web"
60
+ LOG_LINES = 500
61
+ PAGE_SIZE = 4
62
+ # Esperas entre re-sondeos HTTP, en segundos. Cuatro intentos y se abandona: un
63
+ # servicio que no habla HTTP no va a empezar a hacerlo, y lo que se busca cabe
64
+ # en el primer medio minuto de vida del servicio.
65
+ HTTP_RETRIES = (2, 5, 10, 20)
66
+
67
+ # Cuotas por ventana de 15 minutos. La interfaz sondea el estado cada 2s, que da
68
+ # unas 450 peticiones por ventana: un limite global de 100 la romperia en el uso
69
+ # normal. Las rutas que ejecutan o matan procesos si van cortas.
70
+ WINDOW = 900
71
+ QUOTA_READ = 1800
72
+ QUOTA_WRITE = 60
73
+ QUOTA_KILL = 30
74
+
75
+ # Techo de puertos por cierre en lote. La lista real la limita la cantidad de
76
+ # servicios registrados; esto solo evita que un body enorme llegue a recorrerse.
77
+ MAX_TARGETS = 200
78
+
79
+
80
+ class RateLimit:
81
+ """Ventana deslizante en memoria.
82
+
83
+ ponytail: un proceso, un usuario, sin persistencia. Si alguna vez hay mas de
84
+ un worker, esto se cambia por un limiter con almacen compartido.
85
+ """
86
+
87
+ def __init__(self) -> None:
88
+ self._hits: dict[str, deque[float]] = defaultdict(deque)
89
+ self._lock = threading.Lock()
90
+
91
+ def allow(self, key: str, quota: int, window: int = WINDOW) -> bool:
92
+ now = time.monotonic()
93
+ with self._lock:
94
+ hits = self._hits[key]
95
+ while hits and hits[0] <= now - window:
96
+ hits.popleft()
97
+ if len(hits) >= quota:
98
+ return False
99
+ hits.append(now)
100
+ return True
101
+
102
+
103
+ class _Sink:
104
+ """Recibe lo que escribe Rich y lo parte en lineas numeradas."""
105
+
106
+ def __init__(self) -> None:
107
+ self.lines: deque[tuple[int, str]] = deque(maxlen=LOG_LINES)
108
+ self.seq = 0
109
+ self._partial = ""
110
+ self._subscribers: set[queue.Queue[tuple[int, str]]] = set()
111
+ self._lock = threading.Lock()
112
+
113
+ def subscribe(self) -> queue.Queue[tuple[int, str]]:
114
+ q: queue.Queue[tuple[int, str]] = queue.Queue(maxsize=1000)
115
+ with self._lock:
116
+ self._subscribers.add(q)
117
+ return q
118
+
119
+ def unsubscribe(self, q: queue.Queue[tuple[int, str]]) -> None:
120
+ with self._lock:
121
+ self._subscribers.discard(q)
122
+
123
+ def write(self, text: str) -> int:
124
+ items = []
125
+ with self._lock:
126
+ self._partial += text
127
+ *complete, self._partial = self._partial.split("\n")
128
+ for line in complete:
129
+ self.seq += 1
130
+ item = (self.seq, line.rstrip())
131
+ self.lines.append(item)
132
+ items.append(item)
133
+ for item in items:
134
+ self._dispatch(item)
135
+ return len(text)
136
+
137
+ def flush(self) -> None:
138
+ item = None
139
+ with self._lock:
140
+ if self._partial:
141
+ self.seq += 1
142
+ item = (self.seq, self._partial.rstrip())
143
+ self.lines.append(item)
144
+ self._partial = ""
145
+ if item is not None:
146
+ self._dispatch(item)
147
+
148
+ def _dispatch(self, item: tuple[int, str]) -> None:
149
+ with self._lock:
150
+ subs = list(self._subscribers)
151
+ for q in subs:
152
+ try:
153
+ q.put_nowait(item)
154
+ except queue.Full:
155
+ pass
156
+
157
+
158
+ @dataclass
159
+ class Session:
160
+ """Un stack corriendo, arrancado desde la interfaz."""
161
+
162
+ stack: config.Stack
163
+ profile: str | None
164
+ sink: _Sink = field(default_factory=_Sink)
165
+ state: str = "starting"
166
+ error: str | None = None
167
+ engine: runner.Runner | None = None
168
+ thread: threading.Thread | None = None
169
+ # Servicios que no contestaron HTTP al quedar listos y si al re-sondearlos.
170
+ # Vive aca y no en `Proc.http` para que el hilo del sondeo no le escriba
171
+ # estado al runner por atras.
172
+ late_http: set[str] = field(default_factory=set)
173
+ # Un stack detenido porque el usuario apreto Apagar y uno que se murio solo
174
+ # llegan al mismo estado por caminos distintos. Solo el segundo es noticia.
175
+ stopped_by_user: bool = False
176
+
177
+ def start(self) -> None:
178
+ # force_terminal=False no es redundante con no_color: Rich mira FORCE_COLOR
179
+ # del entorno y decide que el sink es una terminal, y no_color saca los
180
+ # colores pero deja el dim, que llega al navegador como "[2m|[0m".
181
+ console = Console(
182
+ file=self.sink, force_terminal=False, no_color=True, width=160, soft_wrap=True
183
+ )
184
+ self.engine = runner.Runner(self.stack, console=console)
185
+ self.thread = threading.Thread(target=self._run, daemon=True)
186
+ self.thread.start()
187
+
188
+ def _run(self) -> None:
189
+ assert self.engine is not None
190
+ pid = registry.project_id(self.stack.root)
191
+ t0 = time.monotonic()
192
+ try:
193
+ self.engine.up(self.profile)
194
+ dur_total = time.monotonic() - t0
195
+
196
+ # Registrar el éxito del arranque
197
+ history.append(pid, {
198
+ "project": self.stack.name,
199
+ "profile": self.profile,
200
+ "duration_s": round(dur_total, 2),
201
+ "result": "running",
202
+ # Duraciones por servicio se obtendrian si runner guardara .start_time.
203
+ # Como es complejo refactorizar runner, guardamos las cuotas simples.
204
+ "services": [p.service.name for p in self.engine.procs if p.ready],
205
+ })
206
+
207
+ self.state = "running"
208
+ threading.Thread(target=self._probe_late_http, daemon=True).start()
209
+ if all(proc.service.detached for proc in self.engine.procs):
210
+ return
211
+ self.engine.follow()
212
+ if self.state != "stopping":
213
+ self.state = "stopped"
214
+ _save_sessions_state()
215
+ except Exception as exc: # el hilo no debe morir en silencio
216
+ dur_total = time.monotonic() - t0
217
+ self.error = runner.clean_error_message(str(exc))
218
+ self.state = "error"
219
+ history.append(pid, {
220
+ "project": self.stack.name,
221
+ "profile": self.profile,
222
+ "duration_s": round(dur_total, 2),
223
+ "result": "error",
224
+ "error": self.error,
225
+ })
226
+ _save_sessions_state()
227
+ log.warning("stack %s fallo: %s", self.stack.name, exc)
228
+
229
+ def stop_async(self) -> None:
230
+ """Apaga sin hacer esperar al request.
231
+
232
+ `docker compose stop` tarda decenas de segundos con varios contenedores.
233
+ La interfaz sondea el estado igual que en el arranque, asi que lo unico
234
+ que hace falta es un estado que sepa mostrar mientras tanto.
235
+ """
236
+ self.state = "stopping"
237
+ threading.Thread(target=self.stop, daemon=True).start()
238
+
239
+ def restart_async(self, name: str) -> None:
240
+ """Reinicia un servicio. Como el apagado, no hace esperar al request."""
241
+ threading.Thread(target=self._restart, args=(name,), daemon=True).start()
242
+
243
+ def _restart(self, name: str) -> None:
244
+ assert self.engine is not None
245
+ # El servicio vuelve con un `Proc` nuevo y su propio sondeo: lo que
246
+ # sabiamos de la vida anterior deja de valer.
247
+ self.late_http.discard(name)
248
+ try:
249
+ self.engine.restart(name)
250
+ except Exception as exc: # el hilo no debe morir en silencio
251
+ self.error = runner.clean_error_message(str(exc))
252
+ self.state = "error"
253
+ log.warning("reinicio de %s fallo: %s", name, exc)
254
+
255
+ def switch_profile_async(self, new_profile: str | None) -> None:
256
+ """Conmuta el perfil en caliente apagando servicios y arrancando el nuevo perfil."""
257
+ self.state = "stopping"
258
+ threading.Thread(target=self._switch_profile, args=(new_profile,), daemon=True).start()
259
+
260
+ def _switch_profile(self, new_profile: str | None) -> None:
261
+ self.stop()
262
+ self.profile = new_profile
263
+ self.error = None
264
+ self.late_http.clear()
265
+ self.stopped_by_user = False
266
+ self.state = "starting"
267
+ self.sink.write(
268
+ f"\n[portmaster] Conmutando al perfil '{new_profile or 'default'}'...\n"
269
+ )
270
+ console = Console(
271
+ file=self.sink, force_terminal=False, no_color=True, width=160, soft_wrap=True
272
+ )
273
+ self.engine = runner.Runner(self.stack, console=console)
274
+ self.thread = threading.current_thread()
275
+ self._run()
276
+
277
+ def stop(self) -> None:
278
+ self.stopped_by_user = True
279
+ if self.engine is not None:
280
+ # Apagar mientras arranca: el hilo de arranque es el que sabe que
281
+ # levanto, asi que se le pide que corte y se lo espera. Despues su
282
+ # `down` ya corrio y este no hace nada.
283
+ self.engine.cancel()
284
+ if (
285
+ self.thread is not None
286
+ and self.thread.is_alive()
287
+ and self.thread != threading.current_thread()
288
+ ):
289
+ # ponytail: un detached en curso (`docker compose up -d`
290
+ # construyendo) no se puede cortar, se espera a que termine. Si
291
+ # alguna vez molesta, matar el arbol del comando detached.
292
+ self.thread.join(runner.STOP_TIMEOUT)
293
+ self.engine.down()
294
+ else:
295
+ # Sin engine: la sesion sobrevivio a un reinicio del servidor, o
296
+ # nadie arranco esto desde aca y solo hay que bajar lo que este vivo.
297
+ # Un contenedor no es hijo nuestro y no se apaga matando el pid del
298
+ # puerto: ese pid es el proxy de Docker, y matarlo deja el
299
+ # contenedor corriendo sin publicar. El `stop:` del servicio es lo
300
+ # unico que lo baja de verdad.
301
+ sueltos = []
302
+ for service in reversed(list(self.stack.services.values())):
303
+ if service.stop:
304
+ runner.run_stop(service)
305
+ elif service.port:
306
+ sueltos.append(service.port)
307
+ scanned = ports.scan_many(sueltos)
308
+ for status in scanned.values():
309
+ if not status.free and status.pid is not None:
310
+ if ports.proxy_owner(status) is not None:
311
+ # El pid es el proxy de Docker: matarlo deja el
312
+ # contenedor corriendo sin publicar.
313
+ continue
314
+ try:
315
+ ports.kill(status.pid, status.create_time, port=status.port)
316
+ except Exception:
317
+ pass
318
+ self.state = "stopped"
319
+ _save_sessions_state()
320
+
321
+ def _probe_late_http(self) -> None:
322
+ """Vuelve a preguntarle a los puertos que no contestaron al arrancar.
323
+
324
+ Un dev server de Next compila recien en la primera peticion: el sondeo
325
+ unico de `runner.speaks_http` da falso negativo y el proyecto se queda
326
+ sin boton Abrir hasta el proximo arranque.
327
+
328
+ Corre en su propio hilo y no en `_project_view` a proposito. La vista
329
+ de estado corre una vez por proyecto y por request, cada 2.5s y por
330
+ pestana abierta, en el threadpool que FastAPI comparte con apagar y con
331
+ matar procesos: un puerto que acepta y no contesta lo bloquea el timeout
332
+ entero, y saturarlo dejaria a la herramienta sin poder apagar nada justo
333
+ cuando algo anda mal.
334
+
335
+ Los intentos son finitos por la misma razon. Un servicio que no habla
336
+ HTTP no va a empezar a hacerlo, y sondearlo para siempre le manda un GET
337
+ a un dev server cada tantos segundos, que en Next dispara una
338
+ compilacion.
339
+ """
340
+ for espera in HTTP_RETRIES:
341
+ time.sleep(espera)
342
+ if self.state != "running" or self.engine is None:
343
+ return
344
+ pendientes = [
345
+ (p.service.name, p.known_port)
346
+ for p in self.engine.procs
347
+ if p.ready and p.known_port and not p.http and p.service.name not in self.late_http
348
+ ]
349
+ if not pendientes:
350
+ return
351
+ for name, port in pendientes:
352
+ if runner.speaks_http(port):
353
+ self.late_http.add(name)
354
+ log.info("%s contesto HTTP al re-sondear el puerto %d", name, port)
355
+
356
+ def service_ports(self) -> dict[str, int]:
357
+ """Puertos descubiertos al arrancar, para los servicios que no los declaran."""
358
+ if self.engine is None:
359
+ return {}
360
+ return {p.service.name: p.port for p in self.engine.procs if p.port}
361
+
362
+ def service_http(self) -> set[str]:
363
+ """Servicios cuyo puerto contesta HTTP: los unicos que tiene sentido abrir."""
364
+ if self.engine is None:
365
+ return set()
366
+ return {p.service.name for p in self.engine.procs if p.http} | self.late_http
367
+
368
+ def service_taken(self) -> set[str]:
369
+ """Servicios cuyo puerto ya estaba ocupado antes de arrancar.
370
+
371
+ El `listo` de esos puede ser de un proceso ajeno. Ver runner._spawn_proc.
372
+ """
373
+ if self.engine is None:
374
+ return set()
375
+ return {p.service.name for p in self.engine.procs if p.port_taken}
376
+
377
+ def service_states(self) -> dict[str, str]:
378
+ if self.state in ("stopped", "error"):
379
+ return {}
380
+ if self.engine is None:
381
+ # Sesion recuperada tras reiniciar el servidor: no tenemos los
382
+ # procesos, tenemos los puertos. Devolver {} dejaba la tarjeta
383
+ # diciendo "corriendo" con la lista de servicios vacia.
384
+ declarados = {n: s.port for n, s in self.stack.services.items() if s.port}
385
+ scanned = ports.scan_many(list(declarados.values()))
386
+ return {
387
+ name: "ready" if not scanned[port].free else "stopped"
388
+ for name, port in declarados.items()
389
+ }
390
+ states = {}
391
+ for proc in self.engine.procs:
392
+ # Durante un reinicio el proceso viejo ya murio y el nuevo todavia no
393
+ # existe. Sin esto, "se cayo solo" se dispararia con cada Reiniciar.
394
+ if self.engine.restarting:
395
+ states[proc.service.name] = "starting"
396
+ elif proc.popen.poll() is not None and not proc.service.detached:
397
+ states[proc.service.name] = "stopped"
398
+ else:
399
+ states[proc.service.name] = "ready" if proc.ready else "starting"
400
+ return states
401
+
402
+
403
+ sessions: dict[str, Session] = {}
404
+ selected_profiles: dict[str, str | None] = {}
405
+ sessions_lock = threading.Lock()
406
+ limiter = RateLimit()
407
+
408
+
409
+ def _current_profile(pid: str, session: Session | None) -> str | None:
410
+ if pid in selected_profiles:
411
+ return selected_profiles[pid]
412
+ if session is not None:
413
+ return session.profile
414
+ return None
415
+
416
+ # Cuanto vale un chequeo del daemon de docker antes de repetirlo.
417
+ DOCKER_TTL = 10.0
418
+ _docker_seen: tuple[float, bool] = (0.0, False)
419
+ _docker_lock = threading.Lock()
420
+
421
+
422
+ def _docker_is_down() -> bool:
423
+ """Si el daemon no contesta, con cache.
424
+
425
+ `doctor._docker` lanza un subproceso y tarda cientos de milisegundos.
426
+ `_project_view` corre una vez por proyecto y por request, y la interfaz
427
+ sondea cada 2.5s por pestana abierta: sin cache, tres proyectos con
428
+ contenedores se llevaban un segundo entero de cada `/api/state` y saturaban
429
+ el threadpool que FastAPI comparte con apagar y con matar procesos. Medido:
430
+ `/api/health` pasaba de 2ms a 9s con la vista de estado bajo carga.
431
+
432
+ El chequeo corre adentro del lock a proposito. Afuera, una tanda de
433
+ requests concurrentes lanza un subproceso cada uno; adentro, el primero lo
434
+ paga y los demas esperan ese mismo resultado.
435
+ """
436
+ global _docker_seen
437
+ with _docker_lock:
438
+ cuando, valor = _docker_seen
439
+ if time.monotonic() - cuando < DOCKER_TTL:
440
+ return valor
441
+ caido = doctor._docker().level == "fail"
442
+ _docker_seen = (time.monotonic(), caido)
443
+ return caido
444
+ # Cuanto vale una deteccion antes de repetirla. Entre los dos TTL que ya hay, y
445
+ # a proposito: `registry.PORTS_TTL` son 30s porque alimenta un aviso de puertos
446
+ # compartidos, donde llegar tarde no molesta. Esto alimenta la fila principal
447
+ # (el estado y la lista de servicios), asi que se parece mas al `DOCKER_TTL`.
448
+ STACK_TTL = 10.0
449
+ _stack_seen: dict[str, tuple[float, config.Stack]] = {}
450
+ _stack_invalidations: dict[str, float] = {}
451
+ _stack_lock = threading.Lock()
452
+
453
+
454
+ def _stack_para_la_vista(path: Path) -> config.Stack:
455
+ """El stack del proyecto para la vista de estado, con cache.
456
+
457
+ `detect.stack_for` relee y reparsea `pom.xml`, `package.json`,
458
+ `compose.yaml` y todo lo demas en cada llamada, y `_project_view` corre una
459
+ vez por proyecto y por request con la interfaz sondeando cada 2.5s por
460
+ pestana. Medido sobre un proyecto poliglota: 6.7ms por llamada, o sea 242ms
461
+ de lectura de disco en cada `/api/state` con doce proyectos y tres
462
+ pestanas, releyendo archivos que casi nunca cambian.
463
+
464
+ Cierra un hueco en vez de inventar un patron: los otros dos caminos que
465
+ resuelven stacks en el mismo request ya estaban cacheados hace rato
466
+ (`registry.declared_ports` y `registry.any_uses_docker`, las dos con
467
+ `max_age=PORTS_TTL`). El de `_project_view` era el unico que quedaba
468
+ releyendo en cada sondeo.
469
+
470
+ **Solo para la vista.** `up`, `switch_profile` y `down` siguen llamando a
471
+ `detect.stack_for` directo, y eso no es una omision: arrancar un stack con
472
+ una version cacheada correria los comandos viejos despues de que el usuario
473
+ edito su `stack.yaml`, que es exactamente lo que nadie espera. La vista
474
+ puede estar diez segundos vieja; el arranque no puede estarlo nunca.
475
+
476
+ `Stack` y `Service` son `frozen=True`, asi que compartir la instancia entre
477
+ requests es seguro.
478
+ """
479
+ ahora = time.monotonic()
480
+ clave = str(path)
481
+ with _stack_lock:
482
+ visto = _stack_seen.get(clave)
483
+ if visto is not None and ahora - visto[0] < STACK_TTL:
484
+ return visto[1]
485
+ # Afuera del lock: la deteccion toca disco, y con doce proyectos adentro
486
+ # del lock la vista se serializa entera contra el proyecto mas lento.
487
+ inicio = time.monotonic()
488
+ stack = detect.stack_for(path)
489
+ with _stack_lock:
490
+ # Si fue invalidado mientras leiamos el disco, no reinyectamos el resultado obsoleto.
491
+ if _stack_invalidations.get(clave, 0.0) <= inicio:
492
+ _stack_seen[clave] = (time.monotonic(), stack)
493
+ return stack
494
+
495
+
496
+ def _olvidar_stack(path: Path) -> None:
497
+ """Saca el proyecto del cache de la vista.
498
+
499
+ Lo llama `freeze` y `drop_project`: sin esto el usuario apretaba "Congelar"
500
+ o borraba el proyecto y la fila no se enteraba hasta diez segundos despues.
501
+ """
502
+ clave = str(path)
503
+ with _stack_lock:
504
+ _stack_seen.pop(clave, None)
505
+ _stack_invalidations[clave] = time.monotonic()
506
+
507
+
508
+ def _sessions_file() -> Path:
509
+ """Resuelto al usarlo y no al importar.
510
+
511
+ Como constante quedaba fijada al `registry.HOME` del momento del import, y
512
+ entonces `mkdir` creaba un directorio y `write_text` escribia en otro. En
513
+ los tests eso mandaba el estado al home real del usuario, y con
514
+ `PORTMASTER_TOKEN` en el entorno, donde nadie llega a crear `~/.portmaster`,
515
+ la persistencia fallaba con un warning que no lee nadie.
516
+ """
517
+ return registry.HOME / "sessions.json"
518
+
519
+
520
+ def _save_sessions_state() -> None:
521
+ try:
522
+ data = {}
523
+ with sessions_lock:
524
+ for pid, session in sessions.items():
525
+ if session.state in ("starting", "running"):
526
+ data[pid] = {
527
+ "path": str(session.stack.path),
528
+ "profile": session.profile,
529
+ "state": session.state,
530
+ }
531
+ registry.HOME.mkdir(parents=True, exist_ok=True)
532
+ _sessions_file().write_text(json.dumps(data, indent=2), encoding="utf-8")
533
+ except Exception as exc:
534
+ log.warning("no se pudo guardar estado de sesiones: %s", exc)
535
+
536
+
537
+ def _load_sessions_state() -> None:
538
+ archivo = _sessions_file()
539
+ if not archivo.is_file():
540
+ return
541
+ import json
542
+ try:
543
+ raw = json.loads(archivo.read_text(encoding="utf-8"))
544
+ if not isinstance(raw, dict):
545
+ return
546
+ known_paths = {registry.project_id(p): p for p in registry.paths()}
547
+ with sessions_lock:
548
+ for pid, item in raw.items():
549
+ path = known_paths.get(pid)
550
+ if not path or not path.is_dir():
551
+ continue
552
+ try:
553
+ stack = detect.stack_for(path)
554
+ except config.ConfigError:
555
+ continue
556
+ scanned = ports.scan_many([s.port for s in stack.services.values() if s.port])
557
+ any_busy = any(not status.free for status in scanned.values())
558
+ if any_busy and pid not in sessions:
559
+ session = Session(stack, item.get("profile"))
560
+ session.state = "running"
561
+ sessions[pid] = session
562
+ except Exception as exc:
563
+ log.warning("no se pudo cargar estado de sesiones: %s", exc)
564
+
565
+
566
+ # seguridad ----------------------------------------------------------------
567
+
568
+
569
+ def require_token(request: Request) -> None:
570
+ header = request.headers.get("authorization", "")
571
+ supplied = header[7:] if header.lower().startswith("bearer ") else ""
572
+ if not supplied:
573
+ supplied = request.cookies.get("stackhelx_token") or request.cookies.get("portmaster_token", "")
574
+ if not supplied:
575
+ supplied = request.query_params.get("token", "")
576
+ if not secrets.compare_digest(supplied, request.app.state.token):
577
+ log.warning("auth rechazada en %s", request.url.path)
578
+ raise HTTPException(401, "token invalido")
579
+
580
+
581
+ def quota(name: str, limit: int):
582
+ def dependency(request: Request) -> None:
583
+ client = request.client.host if request.client else "desconocido"
584
+ if not limiter.allow(f"{name}:{client}", limit):
585
+ log.warning("rate limit excedido en %s desde %s", name, client)
586
+ raise HTTPException(429, "Demasiadas peticiones. Espera un momento.")
587
+
588
+ return Depends(dependency)
589
+
590
+
591
+ # modelos ------------------------------------------------------------------
592
+
593
+
594
+ class AddProject(BaseModel):
595
+ path: str = Field(min_length=1, max_length=4096)
596
+
597
+
598
+ class PathRequest(BaseModel):
599
+ path: str = Field(min_length=1, max_length=4096)
600
+ editor: str | None = Field(default=None, max_length=50)
601
+
602
+
603
+
604
+
605
+ class UpRequest(BaseModel):
606
+ profile: str | None = Field(default=None, max_length=100)
607
+
608
+
609
+ class SwitchProfileRequest(BaseModel):
610
+ profile: str | None = Field(default=None, max_length=100)
611
+
612
+ @field_validator("profile", mode="before")
613
+ def _empty_to_none(cls, v: object) -> object:
614
+ if v == "":
615
+ return None
616
+ return v
617
+
618
+
619
+ class KillAllRequest(BaseModel):
620
+ """Los puertos que el cliente vio en pantalla, como filtro.
621
+
622
+ Obligatorio a proposito. Con un campo opcional, un body mal formado (la
623
+ clave escrita distinto, un null) se leia como "sin filtro" y el endpoint
624
+ pasaba de cerrar lo que el usuario nombro a cerrar todo lo que encontrara.
625
+ Un endpoint que mata procesos falla cerrado: sin lista valida, 422 y nada
626
+ muere. Los puertos son solo un filtro; el pid y el create_time los saca el
627
+ servidor de su propio escaneo.
628
+ """
629
+
630
+ ports: list[int] = Field(max_length=MAX_TARGETS)
631
+
632
+
633
+ class CleanRequest(BaseModel):
634
+ """Que categorias de Docker limpiar.
635
+
636
+ Obligatorio y sin default, por lo mismo que en KillAllRequest: un body mal
637
+ formado no puede leerse como "limpia todo". El `Literal` es la validacion,
638
+ y el comando de cada categoria sale de `docker.TARGETS`, nunca de la red.
639
+ """
640
+
641
+ targets: list[Literal["containers", "images", "networks", "cache", "volumes"]] = Field(
642
+ min_length=1, max_length=5
643
+ )
644
+
645
+
646
+ # app ----------------------------------------------------------------------
647
+
648
+
649
+ # Tuneles abiertos desde la interfaz. `None` marca un puerto reservado mientras
650
+ # el cliente de tuneles arranca, que tarda segundos: sin la reserva, dos pedidos
651
+ # a la vez dejaban uno de los dos procesos fuera del registro y por lo tanto
652
+ # imposible de cerrar.
653
+ _active_tunnels: dict[int, tunnel.Tunnel | None] = {}
654
+ _tunnels_lock = threading.Lock()
655
+
656
+
657
+ def _docker_view() -> dict:
658
+ """Estado de Docker sobre todos los proyectos registrados, no sobre la pagina."""
659
+ usan = registry.any_uses_docker(max_age=registry.PORTS_TTL)
660
+ return {"needed": usan, "down": _docker_is_down() if usan else False}
661
+
662
+
663
+ def tunnels_view() -> list[dict]:
664
+ """Los tuneles abiertos, para que la interfaz pueda mostrarlos y cerrarlos.
665
+
666
+ De paso saca del registro los que se murieron por su cuenta: el cliente de
667
+ tuneles se puede caer solo, y un puerto que figura expuesto sin estarlo es
668
+ una mentira justo en el panel que existe para no mentir sobre eso.
669
+ """
670
+ with _tunnels_lock:
671
+ for port, tun in list(_active_tunnels.items()):
672
+ if tun is not None and tun.proc.poll() is not None:
673
+ del _active_tunnels[port]
674
+ log.info("el tunel del puerto %d se cerro solo", port)
675
+ return [
676
+ {"port": port, "url": tun.url, "provider": tun.provider}
677
+ for port, tun in sorted(_active_tunnels.items())
678
+ if tun is not None
679
+ ]
680
+
681
+
682
+ def _cerrar_tuneles() -> None:
683
+ """Cierra todo tunel que siga abierto."""
684
+ with _tunnels_lock:
685
+ vivos = [t for t in _active_tunnels.values() if t is not None]
686
+ _active_tunnels.clear()
687
+ for tun in vivos:
688
+ try:
689
+ tun.stop()
690
+ except Exception as exc: # un tunel roto no puede frenar el apagado
691
+ log.warning("no se pudo cerrar el tunel del puerto %d: %s", tun.port, exc)
692
+
693
+
694
+ @contextlib.asynccontextmanager
695
+ async def _ciclo_de_vida(app: FastAPI):
696
+ """Al apagar, cierra los tuneles.
697
+
698
+ Sin esto `portmaster serve` terminaba y el cliente de tuneles seguia vivo,
699
+ con el puerto expuesto a internet, sin nada en pantalla que lo dijera y sin
700
+ forma de cerrarlo que no fuera matarlo a mano.
701
+ """
702
+ yield
703
+ _cerrar_tuneles()
704
+
705
+
706
+ def create_app(token: str | None = None) -> FastAPI:
707
+ _load_sessions_state()
708
+ token = token or registry.token()
709
+ app = FastAPI(
710
+ title="PortMaster",
711
+ docs_url=None,
712
+ redoc_url=None,
713
+ openapi_url=None,
714
+ lifespan=_ciclo_de_vida,
715
+ )
716
+ app.state.token = token
717
+ app.state.allowed_hosts = {"127.0.0.1", "localhost", "[::1]"}
718
+
719
+ @app.middleware("http")
720
+ async def guard(request: Request, call_next):
721
+ # Rebinding de DNS: un sitio cualquiera puede resolver su dominio a
722
+ # 127.0.0.1 y hablarle a este servidor desde el navegador de la victima.
723
+ # El token ya lo frena, pero rechazar por Host cierra la puerta antes.
724
+ host = request.headers.get("host", "").rsplit(":", 1)[0]
725
+ if host not in app.state.allowed_hosts:
726
+ log.warning("host rechazado: %r", host)
727
+ return JSONResponse({"detail": "host no permitido"}, status_code=400)
728
+
729
+ response = await call_next(request)
730
+ response.headers["Content-Security-Policy"] = (
731
+ "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
732
+ "base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'"
733
+ )
734
+ response.headers["X-Content-Type-Options"] = "nosniff"
735
+ response.headers["X-Frame-Options"] = "DENY"
736
+ response.headers["Referrer-Policy"] = "no-referrer"
737
+ response.headers["Cache-Control"] = "no-store"
738
+ # Sin Strict-Transport-Security: el servidor es http en loopback y el
739
+ # header obligaria a https a todo 127.0.0.1, rompiendo otros proyectos.
740
+ return response
741
+
742
+ @app.get("/", include_in_schema=False)
743
+ def index(request: Request, token: str = Query("")) -> FileResponse:
744
+ response = FileResponse(WEB / "index.html")
745
+ # Solo lo que trajo quien pide. El fallback a `app.state.token` que habia
746
+ # aca hacia que un GET / pelado se contestara con el token de verdad en
747
+ # un Set-Cookie: cualquier proceso local conseguia la llave con un curl,
748
+ # y con ella corre comandos, porque stack.yaml es ejecutable por diseno.
749
+ tok = token or request.cookies.get("stackhelx_token") or request.cookies.get("portmaster_token", "")
750
+ if tok and secrets.compare_digest(tok, request.app.state.token):
751
+ response.set_cookie(
752
+ "stackhelx_token",
753
+ tok,
754
+ httponly=False,
755
+ samesite="lax",
756
+ path="/",
757
+ )
758
+ return response
759
+
760
+ app.mount("/static", StaticFiles(directory=WEB), name="static")
761
+
762
+ @app.get("/api/state", dependencies=[quota("state", QUOTA_READ), Depends(require_token)])
763
+ def state(
764
+ q: str = Query("", max_length=200),
765
+ status: str = Query("", max_length=20),
766
+ page: int = Query(1, ge=1),
767
+ size: int = Query(PAGE_SIZE, ge=1, le=50),
768
+ ) -> dict:
769
+ known = registry.paths()
770
+ paths = _matching(known, q)
771
+ if status:
772
+ paths = [p for p in paths if _status_match(p, status)]
773
+ total = len(paths)
774
+ pages = max(1, -(-total // size))
775
+ page = min(page, pages)
776
+ # Solo se arma la vista de la pagina pedida: cada una lee la config del
777
+ # proyecto y le escanea los puertos, y eso corre cada 2.5 segundos.
778
+ window = paths[(page - 1) * size : page * size]
779
+ return {
780
+ "projects": [_project_view(path) for path in window],
781
+ "total": total,
782
+ # Registrados en total, filtro aparte: es lo que dice el encabezado, y
783
+ # no tiene por que bajar cuando alguien escribe en el buscador.
784
+ "registered": len(known),
785
+ "page": page,
786
+ "pages": pages,
787
+ # Fuera del paginado a proposito: un tunel expone un puerto a
788
+ # internet y no puede quedar escondido en la pagina 2.
789
+ "tunnels": tunnels_view(),
790
+ # Lo mismo con Docker. La interfaz lo sacaba de los cuatro proyectos
791
+ # de la pagina, asi que pasar a la segunda apagaba la fila entera si
792
+ # ahi no habia ninguno con contenedores. Un control que desaparece
793
+ # no distingue "esta en orden" de "esto dejo de funcionar".
794
+ "docker": _docker_view(),
795
+ }
796
+
797
+ @app.get("/api/browse", dependencies=[quota("state", QUOTA_READ), Depends(require_token)])
798
+ def browse(path: str = "") -> dict:
799
+ try:
800
+ return browse_module.listing(path)
801
+ except ValueError as exc:
802
+ log.info("browse rechazado: %s", exc)
803
+ raise HTTPException(400, str(exc))
804
+
805
+ @app.get("/api/browse/frecuentes", dependencies=[quota("state", QUOTA_READ), Depends(require_token)])
806
+ def browse_frecuentes() -> dict:
807
+ return {"roots": browse_module.frequent_roots(registry.paths())}
808
+
809
+ @app.post("/api/open-folder", dependencies=[quota("write", QUOTA_WRITE), Depends(require_token)])
810
+ def open_folder(req: PathRequest) -> dict:
811
+ p = Path(req.path).expanduser()
812
+ if not p.is_absolute():
813
+ raise HTTPException(400, f"la ruta debe ser absoluta: {req.path}")
814
+ try:
815
+ resolved = p.resolve(strict=True)
816
+ except OSError:
817
+ raise HTTPException(400, f"la carpeta no existe: {req.path}")
818
+ if not resolved.is_dir():
819
+ raise HTTPException(400, f"no es un directorio: {req.path}")
820
+
821
+ p_str = str(resolved)
822
+ try:
823
+ if sys.platform == "win32":
824
+ # explorer.exe fuerza a Windows a crear una ventana en primer plano
825
+ # enfocando la carpeta solicitada en vez de un startfile pasivo.
826
+ subprocess.Popen(["explorer.exe", p_str])
827
+ elif sys.platform == "darwin":
828
+ subprocess.Popen(["open", p_str])
829
+ else:
830
+ subprocess.Popen(["xdg-open", p_str])
831
+ return {"ok": True, "path": p_str}
832
+ except Exception as exc:
833
+ log.warning("fallo al abrir carpeta %s: %s", p_str, exc)
834
+ raise HTTPException(500, f"fallo al abrir el explorador: {exc}")
835
+
836
+ @app.get("/api/editors", dependencies=[quota("state", QUOTA_READ), Depends(require_token)])
837
+ def list_editors() -> dict:
838
+ """Detecta que editores estan instalados en el sistema."""
839
+ available = []
840
+ # VS Code
841
+ if shutil.which("code.cmd") or shutil.which("code"):
842
+ available.append({"id": "code", "name": "VS Code"})
843
+ # Cursor
844
+ if shutil.which("cursor.cmd") or shutil.which("cursor"):
845
+ available.append({"id": "cursor", "name": "Cursor"})
846
+ # Custom
847
+ env_editor = os.environ.get("STACKHELX_EDITOR") or os.environ.get("PORTMASTER_EDITOR") or os.environ.get("EDITOR")
848
+ if env_editor and shutil.which(env_editor):
849
+ available.append({"id": "env", "name": f"Sistema ({env_editor})"})
850
+ return {"editors": available}
851
+
852
+ @app.post("/api/open-editor", dependencies=[quota("write", QUOTA_WRITE), Depends(require_token)])
853
+ def open_editor(req: PathRequest) -> dict:
854
+ p = Path(req.path).expanduser()
855
+ if not p.is_absolute():
856
+ raise HTTPException(400, f"la ruta debe ser absoluta: {req.path}")
857
+ try:
858
+ resolved = p.resolve(strict=True)
859
+ except OSError:
860
+ raise HTTPException(400, f"la carpeta no existe: {req.path}")
861
+ if not resolved.is_dir():
862
+ raise HTTPException(400, f"no es un directorio: {req.path}")
863
+
864
+ # Si el usuario eligio un editor especifico del dropdown
865
+ target = req.editor.lower().strip() if req.editor else ""
866
+ found_editor = None
867
+ editor_display = "Editor"
868
+
869
+ if target == "cursor":
870
+ found_editor = shutil.which("cursor.cmd") or shutil.which("cursor")
871
+ editor_display = "Cursor"
872
+ elif target == "code" or target == "vscode":
873
+ found_editor = shutil.which("code.cmd") or shutil.which("code")
874
+ editor_display = "VS Code"
875
+ elif target == "env":
876
+ cand = os.environ.get("STACKHELX_EDITOR") or os.environ.get("PORTMASTER_EDITOR") or os.environ.get("EDITOR")
877
+ if cand and shutil.which(cand):
878
+ found_editor = cand
879
+ editor_display = cand
880
+
881
+ if not found_editor:
882
+ # Fallback a autodeteccion
883
+ candidates = [
884
+ os.environ.get("STACKHELX_EDITOR"),
885
+ os.environ.get("PORTMASTER_EDITOR"),
886
+ os.environ.get("EDITOR"),
887
+ "cursor.cmd",
888
+ "cursor",
889
+ "code.cmd",
890
+ "code",
891
+ ]
892
+ for cand in candidates:
893
+ if cand and shutil.which(cand):
894
+ found_editor = cand
895
+ editor_display = "Cursor" if "cursor" in cand.lower() else ("VS Code" if "code" in cand.lower() else cand)
896
+ break
897
+
898
+ if not found_editor:
899
+ raise HTTPException(404, "No se detectó el editor solicitado en el sistema.")
900
+
901
+ p_str = str(resolved)
902
+ try:
903
+ # Sin shell=True: en Windows metia la ruta por `cmd.exe /c`, y ahi
904
+ # `list2cmdline` sola no alcanza (ver `scripts._entrecomillar`).
905
+ # `shutil.which` devuelve el .cmd completo y CreateProcess lo corre
906
+ # igual, asi que la capa de shell no aportaba nada y solo abria
907
+ # superficie a los metacaracteres de cmd en el nombre de la carpeta.
908
+ subprocess.Popen([found_editor, p_str])
909
+ return {"ok": True, "editor": editor_display, "path": p_str}
910
+ except Exception as exc:
911
+ log.warning("fallo al abrir editor %s en %s: %s", found_editor, p_str, exc)
912
+ raise HTTPException(500, f"fallo al lanzar el editor: {exc}")
913
+
914
+
915
+
916
+ @app.get("/api/projects/{pid}/history", dependencies=[quota("state", QUOTA_READ), Depends(require_token)])
917
+ def get_history(pid: str) -> dict:
918
+ return {"history": history.read(pid)}
919
+
920
+ @app.get("/api/projects/{pid}/metrics", dependencies=[quota("state", QUOTA_READ), Depends(require_token)])
921
+ def get_metrics(pid: str) -> dict:
922
+ with sessions_lock:
923
+ session = sessions.get(pid)
924
+ if session is None or session.engine is None:
925
+ return {"metrics": {}}
926
+ return {"metrics": session.engine.resource_stats()}
927
+
928
+ @app.get("/api/projects/{pid}/env-audit", dependencies=[quota("state", QUOTA_READ), Depends(require_token)])
929
+ def get_env_audit(pid: str) -> dict:
930
+ path = _lookup(pid)
931
+ examples = [p for p in (path / ".env.example", path / ".env.template") if p.is_file()]
932
+ env_path = path / ".env"
933
+ has_env = env_path.is_file()
934
+ has_example = len(examples) > 0
935
+ example_name = examples[0].name if has_example else None
936
+
937
+ declaradas = doctor._parse_env_keys(examples[0]) if has_example else {}
938
+ propias = doctor._parse_env_keys(env_path) if has_env else {}
939
+
940
+ faltan = [k for k in declaradas if k not in propias]
941
+ vacias = [k for k in declaradas if k in propias and not propias[k]]
942
+ placeholders = [
943
+ k
944
+ for k, v in propias.items()
945
+ if v.strip().strip("'\"").lower() in doctor._PLACEHOLDER_SECRETS
946
+ or v.strip().strip("'\"").lower().startswith(("your_", "your-"))
947
+ ] if has_env else []
948
+
949
+ if has_example:
950
+ ok = has_env and len(faltan) == 0 and len(placeholders) == 0
951
+ else:
952
+ ok = (not has_env) or (len(placeholders) == 0)
953
+
954
+ # Cero secretos: sólo nombres de variables y flags booleanos.
955
+ return {
956
+ "ok": ok,
957
+ "has_env": has_env,
958
+ "has_example": has_example,
959
+ "example_file": example_name,
960
+ "missing_keys": faltan,
961
+ "empty_keys": vacias,
962
+ "placeholder_keys": placeholders,
963
+ }
964
+
965
+ @app.get(
966
+ "/api/projects/{pid}/conflicts",
967
+ dependencies=[quota("state", QUOTA_READ), Depends(require_token)],
968
+ )
969
+ def project_conflicts(pid: str) -> dict:
970
+ path = _lookup(pid)
971
+ try:
972
+ stack = detect.stack_for(path)
973
+ except config.ConfigError as exc:
974
+ raise HTTPException(400, str(exc))
975
+
976
+ with sessions_lock:
977
+ session = sessions.get(pid)
978
+ is_ours = session is not None and session.state in ("starting", "running")
979
+
980
+ declared = registry.declared_ports()
981
+ all_declared = set(declared.keys())
982
+
983
+ conflicts = []
984
+ wanted_ports = [s.port for s in stack.services.values() if s.port]
985
+ scanned = ports.scan_many(wanted_ports)
986
+
987
+ for s in stack.services.values():
988
+ if not s.port:
989
+ continue
990
+ status = scanned.get(s.port)
991
+ if status and not status.free and not is_ours:
992
+ occ = _occupant(status, False)
993
+ if occ is not None:
994
+ suggested = ports.suggest_alternative(s.port, exclude=all_declared)
995
+ can_kill = (
996
+ status.pid is not None
997
+ and status.pid not in ports.PROTECTED_PIDS
998
+ and occ.get("proxy") is None
999
+ )
1000
+ conflicts.append(
1001
+ {
1002
+ "service": s.name,
1003
+ "port": s.port,
1004
+ "occupant": occ,
1005
+ "can_kill": can_kill,
1006
+ "suggested_port": suggested,
1007
+ }
1008
+ )
1009
+
1010
+ return {
1011
+ "has_conflicts": len(conflicts) > 0,
1012
+ "conflicts": conflicts,
1013
+ }
1014
+
1015
+ @app.post("/api/projects", dependencies=[quota("write", QUOTA_WRITE), Depends(require_token)])
1016
+ def add_project(body: AddProject) -> dict:
1017
+ try:
1018
+ path = registry.add(body.path)
1019
+ except registry.RegistryError as exc:
1020
+ log.info("alta de proyecto rechazada: %s", exc)
1021
+ raise HTTPException(400, str(exc))
1022
+ return _project_view(path)
1023
+
1024
+ @app.get("/api/projects/export", dependencies=[quota("state", QUOTA_READ), Depends(require_token)])
1025
+ def export_projects() -> list[str]:
1026
+ return registry.export_data()
1027
+
1028
+ @app.post("/api/projects/import", dependencies=[quota("write", QUOTA_WRITE), Depends(require_token)])
1029
+ def import_projects(paths_list: list[str]) -> dict:
1030
+ imported = registry.import_data(paths_list)
1031
+ return {"imported": imported, "count": len(imported)}
1032
+
1033
+ @app.delete(
1034
+ "/api/projects/{pid}",
1035
+ dependencies=[quota("write", QUOTA_WRITE), Depends(require_token)],
1036
+ )
1037
+ def drop_project(pid: str) -> dict:
1038
+ path = _lookup(pid)
1039
+ with sessions_lock:
1040
+ session = sessions.pop(pid, None)
1041
+ if session is not None:
1042
+ session.stop()
1043
+ if not registry.remove(pid):
1044
+ raise HTTPException(404, "proyecto desconocido")
1045
+ _olvidar_stack(path)
1046
+ return {"ok": True}
1047
+
1048
+ @app.post(
1049
+ "/api/projects/{pid}/freeze",
1050
+ dependencies=[quota("write", QUOTA_WRITE), Depends(require_token)],
1051
+ )
1052
+ def freeze(pid: str) -> dict:
1053
+ """Congela lo detectado en un stack.yaml editable.
1054
+
1055
+ La ruta sale del registro, nunca del cuerpo del request, y el contenido
1056
+ lo genera `detect.to_yaml`. Un stack.yaml es codigo ejecutable por
1057
+ diseno (ver `shell=True` en CLAUDE.md): un endpoint que aceptara
1058
+ cualquiera de las dos cosas del cliente seria ejecucion remota local
1059
+ detras del token, en vez de un boton que hace lo que dice.
1060
+ """
1061
+ path = _lookup(pid)
1062
+ try:
1063
+ target = detect.freeze(path)
1064
+ except config.ConfigError as exc:
1065
+ log.info("congelado rechazado para %s: %s", pid, exc)
1066
+ # Ya existe, o no hay nada que congelar: en los dos casos el estado
1067
+ # del disco es el que manda, no el pedido.
1068
+ raise HTTPException(409, str(exc))
1069
+
1070
+ # El stack.yaml recien escrito es lo que la vista tiene que leer ahora,
1071
+ # no dentro de diez segundos: sin esto se apretaba "Congelar" y la fila
1072
+ # seguia diciendo "detectado" hasta que venciera el cache.
1073
+ _olvidar_stack(path)
1074
+ log.info("stack congelado en %s", target)
1075
+ return {"ok": True, "path": str(target)}
1076
+
1077
+ @app.post(
1078
+ "/api/projects/{pid}/up",
1079
+ dependencies=[quota("write", QUOTA_WRITE), Depends(require_token)],
1080
+ )
1081
+ def up(pid: str, body: UpRequest) -> dict:
1082
+ path = _lookup(pid)
1083
+ try:
1084
+ stack = detect.stack_for(path)
1085
+ stack.resolve(body.profile)
1086
+ except config.ConfigError as exc:
1087
+ log.info("arranque rechazado para %s: %s", pid, exc)
1088
+ raise HTTPException(400, str(exc))
1089
+
1090
+ with sessions_lock:
1091
+ live = sessions.get(pid)
1092
+ if live is not None and live.state in ("starting", "running"):
1093
+ raise HTTPException(409, "el stack ya esta corriendo")
1094
+ if live is not None and live.state == "stopping":
1095
+ raise HTTPException(409, "el stack se esta apagando")
1096
+ selected_profiles[pid] = body.profile
1097
+ session = Session(stack, body.profile)
1098
+ sessions[pid] = session
1099
+ session.start()
1100
+ _save_sessions_state()
1101
+ return {"ok": True}
1102
+
1103
+ @app.post(
1104
+ "/api/projects/{pid}/switch-profile",
1105
+ dependencies=[quota("write", QUOTA_WRITE), Depends(require_token)],
1106
+ )
1107
+ def switch_profile(pid: str, body: SwitchProfileRequest) -> dict:
1108
+ path = _lookup(pid)
1109
+ try:
1110
+ stack = detect.stack_for(path)
1111
+ stack.resolve(body.profile)
1112
+ except config.ConfigError as exc:
1113
+ log.info("cambio de perfil rechazado para %s: %s", pid, exc)
1114
+ raise HTTPException(400, str(exc))
1115
+
1116
+ with sessions_lock:
1117
+ selected_profiles[pid] = body.profile
1118
+ session = sessions.get(pid)
1119
+ is_running = session is not None and session.state in ("starting", "running")
1120
+ if is_running:
1121
+ session.switch_profile_async(body.profile)
1122
+
1123
+ _save_sessions_state()
1124
+ return {"ok": True, "profile": body.profile}
1125
+
1126
+ @app.post(
1127
+ "/api/projects/{pid}/down",
1128
+ dependencies=[quota("write", QUOTA_WRITE), Depends(require_token)],
1129
+ )
1130
+ def down(pid: str) -> dict:
1131
+ path = _lookup(pid)
1132
+ with sessions_lock:
1133
+ session = sessions.get(pid)
1134
+ if session is None:
1135
+ # Los contenedores de un `docker compose up -d` desde la
1136
+ # terminal se ven "listos" en la tarjeta, y Apagar contestaba
1137
+ # 404: la interfaz mostraba algo vivo que no podia bajar.
1138
+ try:
1139
+ stack = detect.stack_for(path)
1140
+ except config.ConfigError as exc:
1141
+ raise HTTPException(400, str(exc))
1142
+ session = Session(stack, None)
1143
+ sessions[pid] = session
1144
+ if session.state != "stopping":
1145
+ session.stop_async()
1146
+ return {"ok": True}
1147
+
1148
+ @app.post(
1149
+ "/api/projects/{pid}/services/{name}/restart",
1150
+ dependencies=[quota("write", QUOTA_WRITE), Depends(require_token)],
1151
+ )
1152
+ def restart(pid: str, name: str) -> dict:
1153
+ with sessions_lock:
1154
+ session = sessions.get(pid)
1155
+ if session is None:
1156
+ raise HTTPException(404, "ese stack no fue arrancado desde aca")
1157
+ if session.state != "running":
1158
+ raise HTTPException(409, "el stack no esta corriendo")
1159
+ if session.engine is None:
1160
+ # Sesion recuperada tras reiniciar el servidor: sabemos que los
1161
+ # puertos estan ocupados, no tenemos los procesos. Reiniciar reventaba
1162
+ # con un AssertionError adentro de un hilo daemon, o sea en silencio.
1163
+ raise HTTPException(
1164
+ 409, "este stack sobrevivio a un reinicio del servidor: apagalo y volve a arrancarlo"
1165
+ )
1166
+ if name not in session.stack.services:
1167
+ log.info("reinicio rechazado en %s: servicio %r desconocido", pid, name)
1168
+ raise HTTPException(404, "ese servicio no esta en el stack")
1169
+ session.restart_async(name)
1170
+ return {"ok": True}
1171
+
1172
+ @app.get(
1173
+ "/api/projects/{pid}/logs",
1174
+ dependencies=[quota("state", QUOTA_READ), Depends(require_token)],
1175
+ )
1176
+ def logs(pid: str, since: int = 0) -> dict:
1177
+ with sessions_lock:
1178
+ session = sessions.get(pid)
1179
+ if session is None:
1180
+ return {"lines": [], "seq": 0}
1181
+ pending = [
1182
+ {"seq": seq, "text": text}
1183
+ for seq, text in list(session.sink.lines)
1184
+ if seq > since
1185
+ ]
1186
+ return {"lines": pending, "seq": session.sink.seq}
1187
+
1188
+ @app.get(
1189
+ "/api/projects/{pid}/logs/stream",
1190
+ dependencies=[quota("state", QUOTA_READ), Depends(require_token)],
1191
+ )
1192
+ async def stream_logs(
1193
+ pid: str,
1194
+ since: int = 0,
1195
+ follow: bool = True,
1196
+ max_duration: float | None = None,
1197
+ ) -> StreamingResponse:
1198
+ with sessions_lock:
1199
+ session = sessions.get(pid)
1200
+ if session is None:
1201
+ raise HTTPException(404, "el proyecto no esta corriendo")
1202
+
1203
+ sub_q = session.sink.subscribe()
1204
+
1205
+ async def event_generator():
1206
+ try:
1207
+ # 1. Emitir líneas pendientes del buffer
1208
+ pending = [
1209
+ (seq, text)
1210
+ for seq, text in list(session.sink.lines)
1211
+ if seq > since
1212
+ ]
1213
+ for seq, text in pending:
1214
+ payload = json.dumps({"seq": seq, "text": text})
1215
+ yield f"data: {payload}\n\n"
1216
+
1217
+ if not follow:
1218
+ return
1219
+
1220
+ start_time = time.monotonic()
1221
+ last_event_time = start_time
1222
+ effective_max = max_duration or 3600.0
1223
+ while True:
1224
+ now = time.monotonic()
1225
+ if (now - start_time) > effective_max:
1226
+ break
1227
+
1228
+ had_event = False
1229
+ while True:
1230
+ try:
1231
+ seq, text = sub_q.get_nowait()
1232
+ payload = json.dumps({"seq": seq, "text": text})
1233
+ yield f"data: {payload}\n\n"
1234
+ had_event = True
1235
+ last_event_time = now
1236
+ except queue.Empty:
1237
+ break
1238
+
1239
+ if not had_event and (now - last_event_time) >= 15.0:
1240
+ yield ": keepalive\n\n"
1241
+ last_event_time = now
1242
+
1243
+ await asyncio.sleep(0.05)
1244
+ except (asyncio.CancelledError, GeneratorExit):
1245
+ pass
1246
+ finally:
1247
+ session.sink.unsubscribe(sub_q)
1248
+
1249
+ return StreamingResponse(
1250
+ event_generator(),
1251
+ media_type="text/event-stream",
1252
+ headers={
1253
+ "Cache-Control": "no-cache",
1254
+ "Connection": "keep-alive",
1255
+ "X-Accel-Buffering": "no",
1256
+ },
1257
+ )
1258
+
1259
+ @app.post(
1260
+ "/api/ports/{port}/kill",
1261
+ dependencies=[quota("kill", QUOTA_KILL), Depends(require_token)],
1262
+ )
1263
+ def kill_port(port: int) -> dict:
1264
+ try:
1265
+ status = ports.scan(port)
1266
+ except ValueError as exc:
1267
+ log.info("puerto rechazado: %s", exc)
1268
+ raise HTTPException(400, str(exc))
1269
+
1270
+ if status.free:
1271
+ return {"ok": True, "detail": "ya estaba libre"}
1272
+ if status.pid is None:
1273
+ raise HTTPException(403, "el proceso no es visible con estos permisos")
1274
+
1275
+ try:
1276
+ ports.kill(status.pid, status.create_time, port=port)
1277
+ except ports.KillRefused as exc:
1278
+ log.warning("kill rechazado en %d: %s", port, exc)
1279
+ raise HTTPException(409, str(exc))
1280
+ except psutil.NoSuchProcess:
1281
+ pass
1282
+ except psutil.AccessDenied:
1283
+ log.warning("kill sin permisos en %d (pid %d)", port, status.pid)
1284
+ raise HTTPException(403, "sin permisos para cerrar ese proceso")
1285
+
1286
+ log.info("proceso cerrado: puerto=%d pid=%d nombre=%s", port, status.pid, status.name)
1287
+ return {"ok": True}
1288
+
1289
+ @app.get(
1290
+ "/api/ports/{port}/suggest",
1291
+ dependencies=[quota("state", QUOTA_READ), Depends(require_token)],
1292
+ )
1293
+ def suggest_port(port: int) -> dict:
1294
+ try:
1295
+ ports.check_port(port)
1296
+ except ValueError as exc:
1297
+ log.info("puerto rechazado para sugerencia: %s", exc)
1298
+ raise HTTPException(400, str(exc))
1299
+
1300
+ status = ports.scan(port)
1301
+ declared = registry.declared_ports()
1302
+ all_declared = set(declared.keys())
1303
+ suggested = ports.suggest_alternative(port, exclude=all_declared)
1304
+ return {
1305
+ "port": port,
1306
+ "free": status.free,
1307
+ "suggested": suggested,
1308
+ "occupant": _occupant(status, False),
1309
+ }
1310
+
1311
+ @app.get(
1312
+ "/api/mcp/activity",
1313
+ dependencies=[quota("state", QUOTA_READ), Depends(require_token)],
1314
+ )
1315
+ def mcp_activity() -> dict:
1316
+ """Telemetría y registro de invocaciones de herramientas MCP para agentes IA."""
1317
+ return mcp.get_telemetry()
1318
+
1319
+ @app.get("/api/version", dependencies=[quota("state", QUOTA_READ), Depends(require_token)])
1320
+ def version() -> dict:
1321
+ """Version y fecha de los estaticos que este servidor sirve.
1322
+
1323
+ Existe para contestar "estoy viendo la pagina nueva o una vieja de la
1324
+ cache del navegador", que sin esto se responde adivinando.
1325
+ """
1326
+ # Todo lo que sirve el mount, y no una lista escrita a mano. La lista se
1327
+ # olvidaba tokens.css, que app.css importa y es donde viven los colores:
1328
+ # un cambio de solo estilos no movia la fecha y el sello quedaba
1329
+ # mintiendo justo en el caso para el que se puso.
1330
+ #
1331
+ # El arbol entero y no solo el primer nivel: StaticFiles sirve los
1332
+ # subdirectorios igual. Hoy `web/` es plano, asi que esto no arregla un
1333
+ # sintoma, desarma la trampa antes de que exista el primer `web/img/`.
1334
+ assets = max(f.stat().st_mtime for f in WEB.rglob("*") if f.is_file())
1335
+ return {
1336
+ "version": __version__,
1337
+ "assets": time.strftime("%Y-%m-%d %H:%M", time.localtime(assets)),
1338
+ }
1339
+
1340
+ @app.get("/api/health", dependencies=[quota("state", QUOTA_READ), Depends(require_token)])
1341
+ def health() -> dict:
1342
+ """Salud de todo lo que arrancamos, sin paginar.
1343
+
1344
+ `/api/state` devuelve una pagina de cuatro proyectos: alimentar de ahi un
1345
+ aviso de "algo se cayo" seria una mentira silenciosa apenas hay una
1346
+ segunda pagina. Aca no hay escaneo de puertos ni lectura de configs, solo
1347
+ un `poll()` por proceso vivo, que es lo que lo hace barato como para
1348
+ sondearlo al mismo ritmo que el estado.
1349
+
1350
+ Solo mira las sesiones: un servicio que no arrancamos nosotros no es algo
1351
+ que se nos "haya caido".
1352
+ """
1353
+ with sessions_lock:
1354
+ live = list(sessions.items())
1355
+
1356
+ corriendo, caidos = 0, []
1357
+ for pid, session in live:
1358
+ entrada = {"project": pid, "stack": session.stack.name}
1359
+ if session.state in ("stopped", "error") and not session.stopped_by_user:
1360
+ # El stack entero se murio. La tarjeta lo dice, pero puede estar
1361
+ # en otra pagina: es justo lo que este endpoint viene a resolver.
1362
+ caidos.append({**entrada, "service": None})
1363
+ continue
1364
+ if session.state not in ("starting", "running"):
1365
+ continue
1366
+ corriendo += 1
1367
+ for name, state in session.service_states().items():
1368
+ if state == "stopped":
1369
+ caidos.append({**entrada, "service": name})
1370
+ return {"running": corriendo, "fallen": caidos}
1371
+
1372
+ def _active_service_ports() -> set[int]:
1373
+ """Puertos que ocupa alguna sesion nuestra, de cualquier proyecto.
1374
+
1375
+ Antes el descarte era por servicio y por proyecto. Con el conjunto de
1376
+ puertos, un puerto que corre en el proyecto A deja de figurar como
1377
+ intruso en el proyecto B que lo declara: quien lo tiene es nuestro, y
1378
+ el aviso de puerto compartido ya cubre ese caso en la tarjeta.
1379
+ """
1380
+ with sessions_lock:
1381
+ active_sessions = list(sessions.values())
1382
+ running_ports = set()
1383
+ for s in active_sessions:
1384
+ # Los que el servicio eligio al arrancar, ademas de los declarados.
1385
+ # Un proyecto con `ready: listen` no declara puerto (un Next, un
1386
+ # vite), asi que el suyo no entraba aca: otro proyecto que si
1387
+ # declarara ese numero lo veia ocupado y acusaba de intruso al
1388
+ # proceso que acabamos de levantar nosotros. Cerrarlo desde la
1389
+ # interfaz mataba el servicio propio, y "Liberar todos" lo hacia de
1390
+ # un click.
1391
+ descubiertos = s.service_ports()
1392
+ for name, state in s.service_states().items():
1393
+ if state == "stopped":
1394
+ continue
1395
+ svc = s.stack.services.get(name)
1396
+ if svc and svc.port:
1397
+ running_ports.add(svc.port)
1398
+ if name in descubiertos:
1399
+ running_ports.add(descubiertos[name])
1400
+ return running_ports
1401
+
1402
+ @app.get("/api/ports/orphans", dependencies=[quota("state", QUOTA_READ), Depends(require_token)])
1403
+ def orphans() -> dict:
1404
+ """Puertos de proyectos registrados ocupados por procesos que no lanzamos."""
1405
+ return {"orphans": registry.find_orphans(_active_service_ports())}
1406
+
1407
+ @app.post(
1408
+ "/api/ports/kill-all",
1409
+ dependencies=[quota("kill", QUOTA_KILL), Depends(require_token)],
1410
+ )
1411
+ def kill_all_ports(body: KillAllRequest) -> dict:
1412
+ """Cierra los procesos intrusos que ocupan los puertos que pide el cliente.
1413
+
1414
+ Del cliente viene una lista de puertos, y nada mas. El servidor vuelve a
1415
+ calcular quienes son intrusos ahora, y de ahi saca el pid y el
1416
+ create_time: un PID que llegara por la red se saltearia el escaneo, la
1417
+ exclusion del proxy de Docker y el recorte a los proyectos registrados.
1418
+
1419
+ Contesta 200 aunque falle alguno. Cerrar seis procesos donde dos dan
1420
+ AccessDenied no es exito ni error, y un solo codigo HTTP no lo puede
1421
+ decir: el detalle va por item.
1422
+ """
1423
+ pedidos = set(body.ports)
1424
+ candidatos = [
1425
+ c for c in registry.find_orphans(_active_service_ports()) if c["port"] in pedidos
1426
+ ]
1427
+
1428
+ killed, failed = [], []
1429
+ for candidato in candidatos:
1430
+ port, pid = candidato["port"], candidato["pid"]
1431
+ try:
1432
+ ports.kill(pid, candidato["create_time"], port=port)
1433
+ except ports.KillRefused as exc:
1434
+ failed.append({"port": port, "reason": str(exc)})
1435
+ log.warning("kill en lote rechazado en %d: %s", port, exc)
1436
+ continue
1437
+ except psutil.NoSuchProcess:
1438
+ pass # se murio entre el escaneo y el kill: el puerto quedo libre igual
1439
+ except psutil.AccessDenied:
1440
+ failed.append({"port": port, "reason": "sin permisos para cerrar ese proceso"})
1441
+ log.warning("kill en lote sin permisos en %d (pid %d)", port, pid)
1442
+ continue
1443
+ except Exception as exc: # un item roto no puede tirar el lote entero
1444
+ failed.append({"port": port, "reason": "no se pudo cerrar"})
1445
+ log.warning("kill en lote fallo en %d (pid %d): %s", port, pid, exc)
1446
+ continue
1447
+ killed.append({"port": port, "pid": pid, "name": candidato["name"]})
1448
+ log.info("intruso cerrado en lote: puerto %d, pid %d", port, pid)
1449
+
1450
+ return {"ok": True, "killed": killed, "failed": failed}
1451
+
1452
+ @app.post(
1453
+ "/api/docker/clean",
1454
+ dependencies=[quota("write", QUOTA_WRITE), Depends(require_token)],
1455
+ )
1456
+ def docker_clean(body: CleanRequest) -> dict:
1457
+ """Limpia las categorias de Docker que pidio el usuario."""
1458
+ ok, detail = docker.prune(body.targets)
1459
+ log.info("docker clean pedido %s: ok=%s (%s)", body.targets, ok, detail)
1460
+ return {"ok": ok, "detail": detail}
1461
+
1462
+ @app.get(
1463
+ "/api/docker/containers",
1464
+ dependencies=[quota("state", QUOTA_READ), Depends(require_token)],
1465
+ )
1466
+ def docker_containers() -> dict:
1467
+ """Los contenedores corriendo, para que Reiniciar diga que se va a llevar.
1468
+
1469
+ Reiniciar el motor los baja a todos, incluidos los de proyectos que no
1470
+ estas mirando, y eso no se puede hacer a medias. Nombrarlos antes es lo
1471
+ unico que se puede hacer por quien aprieta el boton.
1472
+ """
1473
+ return {"running": docker.running()}
1474
+
1475
+ @app.get(
1476
+ "/api/docker/usage",
1477
+ dependencies=[quota("state", QUOTA_READ), Depends(require_token)],
1478
+ )
1479
+ def docker_usage() -> dict:
1480
+ """La tabla de `docker system df`, para elegir con un numero delante.
1481
+
1482
+ "Limpiar Docker?" sin decir cuanto hay no es una pregunta que se pueda
1483
+ contestar. Sale tal cual la formatea Docker: parsearla seria atarnos a
1484
+ su salida a cambio de nada.
1485
+ """
1486
+ return {"table": docker.usage() or ""}
1487
+
1488
+ @app.post(
1489
+ "/api/docker/{action}",
1490
+ dependencies=[quota("write", QUOTA_WRITE), Depends(require_token)],
1491
+ )
1492
+ def docker_action(action: Literal["start", "restart"]) -> dict:
1493
+ """Arranca o reinicia Docker Desktop. Si esta arriba lo dice `doctor`.
1494
+
1495
+ El `Literal` es la validacion: cualquier otra cosa en la ruta la rechaza
1496
+ FastAPI con un 422 antes de llegar aca, y el comando sale igual de
1497
+ `docker.ACTIONS` y nunca de lo que vino por la red.
1498
+
1499
+ 200 tambien cuando no se pudo: que Docker no este instalado no es un
1500
+ error del request. El motivo va en el cuerpo, que es donde la interfaz
1501
+ lo puede mostrar.
1502
+ """
1503
+ ok, detail = docker.run(action)
1504
+ log.info("docker %s pedido: ok=%s (%s)", action, ok, detail)
1505
+ return {"ok": ok, "detail": detail}
1506
+
1507
+ @app.post(
1508
+ "/api/share",
1509
+ dependencies=[quota("write", QUOTA_WRITE), Depends(require_token)],
1510
+ )
1511
+ def share_port(request: Request, port: int, provider: str | None = None) -> dict:
1512
+ """Inicia un tunel efimero para compartir un puerto."""
1513
+ # Antes del candado y de la reserva: un puerto que no existe no puede
1514
+ # dejar una entrada a medias en `_active_tunnels`. `kill` valida gratis
1515
+ # porque llama a `ports.scan`; aca no hay ningun scan que lo traiga, y
1516
+ # sin esto el 0, el -5 y el 99999 contestaban 200.
1517
+ try:
1518
+ ports.check_port(port)
1519
+ except ValueError as exc:
1520
+ log.info("puerto rechazado: %s", exc)
1521
+ raise HTTPException(400, str(exc))
1522
+
1523
+ # PortMaster no se publica a si mismo. Detras de este puerto esta la API
1524
+ # que arranca stack.yaml, o sea ejecucion de comandos: exponerla deja al
1525
+ # token como unica puerta contra internet entero. La validacion de Host
1526
+ # del middleware ya rechaza al cliente de tuneles, asi que hoy el efecto
1527
+ # es una URL publica que solo sabe contestar 400; el usuario cree que
1528
+ # compartio algo y comparte la consola. Se corta aca y se dice por que.
1529
+ #
1530
+ # El puerto sale del scope ASGI, que uvicorn llena con el socket que
1531
+ # bindeo de verdad. `request.url.port` sale del header Host, y un header
1532
+ # lo escribe quien llama: serviria para esquivar justo esta guarda.
1533
+ propio = (request.scope.get("server") or (None, None))[1]
1534
+ if propio is not None and port == propio:
1535
+ raise HTTPException(
1536
+ 400,
1537
+ f"el puerto {port} es el de PortMaster. Publicarlo expone la API "
1538
+ "que ejecuta los comandos de tu stack.yaml, no tu proyecto.",
1539
+ )
1540
+
1541
+ with _tunnels_lock:
1542
+ # Un tunel que se murio solo no puede bloquear el puerto hasta que
1543
+ # pase el sondeo de estado: la limpieza vivia en `tunnels_view`, o
1544
+ # sea que reabrir dependia de que la interfaz hubiera refrescado.
1545
+ previo = _active_tunnels.get(port)
1546
+ if previo is not None and previo.proc.poll() is not None:
1547
+ del _active_tunnels[port]
1548
+ log.info("el tunel del puerto %d se cerro solo", port)
1549
+ if port in _active_tunnels:
1550
+ return {"ok": False, "detail": f"ya hay un tunel para el puerto {port}"}
1551
+ _active_tunnels[port] = None # reserva, ver _active_tunnels
1552
+
1553
+ try:
1554
+ tun = tunnel.start_tunnel(port, provider=provider)
1555
+ except Exception as exc:
1556
+ with _tunnels_lock:
1557
+ _active_tunnels.pop(port, None)
1558
+ if not isinstance(exc, tunnel.TunnelError):
1559
+ raise
1560
+ return {"ok": False, "detail": str(exc)}
1561
+
1562
+ with _tunnels_lock:
1563
+ _active_tunnels[port] = tun
1564
+ log.info("tunel abierto en el puerto %d via %s", port, tun.provider)
1565
+ return {"ok": True, "url": tun.url, "provider": tun.provider, "port": port}
1566
+
1567
+ @app.delete(
1568
+ "/api/share/{port}",
1569
+ dependencies=[quota("write", QUOTA_WRITE), Depends(require_token)],
1570
+ )
1571
+ def stop_share_port(port: int) -> dict:
1572
+ """Detiene un tunel activo."""
1573
+ with _tunnels_lock:
1574
+ # Solo un tunel ya arrancado. Sacar una reserva liberaria el puerto
1575
+ # mientras su proceso todavia esta naciendo, y ese quedaria afuera.
1576
+ tun = _active_tunnels.get(port)
1577
+ if tun is not None:
1578
+ del _active_tunnels[port]
1579
+ if tun is None:
1580
+ return {"ok": False, "detail": f"No hay tunel activo para el puerto {port}."}
1581
+ tun.stop()
1582
+ log.info("tunel del puerto %d cerrado", port)
1583
+ return {"ok": True, "detail": f"Tunel para puerto {port} cerrado."}
1584
+
1585
+ return app
1586
+
1587
+
1588
+ # vistas -------------------------------------------------------------------
1589
+
1590
+
1591
+ def _lookup(pid: str) -> Path:
1592
+ for path in registry.paths():
1593
+ if registry.project_id(path) == pid:
1594
+ return path
1595
+ raise HTTPException(404, "proyecto desconocido")
1596
+
1597
+
1598
+ def _matching(paths: list[Path], needle: str) -> list[Path]:
1599
+ """Filtra por nombre de carpeta o ruta.
1600
+
1601
+ El nombre del stack no entra: sale de leer la config de cada proyecto, que es
1602
+ justo el trabajo que el filtro existe para no hacer.
1603
+ """
1604
+ needle = needle.strip().lower()
1605
+ if not needle:
1606
+ return paths
1607
+ return [p for p in paths if needle in p.name.lower() or needle in str(p).lower()]
1608
+
1609
+
1610
+ def _status_match(path: Path, wanted: str) -> bool:
1611
+ """Filtra por estado general del proyecto."""
1612
+ pid = registry.project_id(path)
1613
+ with sessions_lock:
1614
+ session = sessions.get(pid)
1615
+ state = session.state if session else "stopped"
1616
+ if wanted == "running":
1617
+ return state in ("starting", "running")
1618
+ if wanted == "stopped":
1619
+ return state == "stopped"
1620
+ if wanted == "error":
1621
+ return state in ("error", "invalid")
1622
+ return True
1623
+
1624
+
1625
+ def _project_view(path: Path) -> dict:
1626
+ pid = registry.project_id(path)
1627
+ base = {"id": pid, "path": str(path), "name": path.name}
1628
+
1629
+ try:
1630
+ stack = _stack_para_la_vista(path)
1631
+ except config.ConfigError as exc:
1632
+ # Con el contrato completo: este `return` se salteaba `detected`,
1633
+ # `needs_docker` y `docker_down`, y un proyecto con la config rota
1634
+ # llegaba a la interfaz con la mitad de las claves. En JS eso es
1635
+ # `undefined`, o sea falso silencioso, y el proximo que lea
1636
+ # `p.needs_docker` esperando un booleano se come la trampa.
1637
+ return {
1638
+ **base,
1639
+ "state": "invalid",
1640
+ "error": str(exc),
1641
+ "services": [],
1642
+ "profiles": [],
1643
+ "profile": None,
1644
+ "default": [],
1645
+ "detected": False,
1646
+ "metrics": {},
1647
+ "needs_docker": False,
1648
+ "docker_down": False,
1649
+ "graph": {"levels": [], "nodes": [], "edges": []},
1650
+ }
1651
+
1652
+ with sessions_lock:
1653
+ session = sessions.get(pid)
1654
+ # El estado del proyecto se lee antes que el de sus servicios, y no despues.
1655
+ # `_run` pasa a "running" recien cuando todos quedaron listos: leyendolo al
1656
+ # final, un arranque que termina entre las dos lecturas devuelve el proyecto
1657
+ # "corriendo" con los servicios todavia "arrancando".
1658
+ estado = session.state if session else "stopped"
1659
+ running = session.service_states() if session else {}
1660
+ # Los que ademas tienen un `Proc` vivo detras. None cuando no hay de donde.
1661
+ manejados = running if session and session.engine else None
1662
+ discovered = session.service_ports() if session else {}
1663
+ openable = session.service_http() if session else set()
1664
+ tomados = session.service_taken() if session else set()
1665
+
1666
+ scanned = ports.scan_many([s.port for s in stack.services.values() if s.port])
1667
+ # Cacheado: resolver el stack de cada proyecto registrado cuesta de 1 a 92ms
1668
+ # segun tenga stack.yaml o haya que detectarlo, y esto corre por proyecto y
1669
+ # por request cada 2.5s. Ver registry.declared_ports.
1670
+ compartidos = registry.declared_ports(max_age=registry.PORTS_TTL)
1671
+ services = []
1672
+ for service in stack.services.values():
1673
+ status = scanned.get(service.port) if service.port else None
1674
+ state = running.get(service.name, "stopped")
1675
+ # Un detached vive fuera de nuestro arbol: si alguien lo bajo por afuera
1676
+ # (docker stop), el puerto libre es la verdad y el proceso no dice nada.
1677
+ if state == "ready" and service.detached and status is not None and status.free:
1678
+ state = "stopped"
1679
+ # Y al reves: el puerto lo publica el proxy de Docker, asi que el
1680
+ # contenedor esta arriba lo haya arrancado esta interfaz o no. Sin esto
1681
+ # un stack levantado desde la terminal se ve "detenido" con los tres
1682
+ # puertos en rojo.
1683
+ #
1684
+ # ponytail: asume que el contenedor de ese puerto es el de este
1685
+ # proyecto, la misma apuesta que hace `ready: port` en el runner.
1686
+ # Preguntarle a `docker ps` por cada puerto costaria un subproceso, y
1687
+ # esto se sondea cada 2.5s.
1688
+ if state == "stopped" and service.detached and _published(status):
1689
+ state = "ready"
1690
+ # Solo lo que contesta HTTP se ofrece para abrir: un postgres listo tiene
1691
+ # puerto y no es algo que mandar al navegador.
1692
+ abrible = state != "stopped" and service.name in openable
1693
+ occ = _occupant(status, state != "stopped")
1694
+ suggested_alt = (
1695
+ ports.suggest_alternative(service.port, set(compartidos.keys()))
1696
+ if (status is not None and not status.free and (occ is not None or state == "stopped"))
1697
+ else None
1698
+ )
1699
+ services.append(
1700
+ {
1701
+ "name": service.name,
1702
+ # Contenedor o proceso local: es lo unico que se sabe de verdad
1703
+ # del rol de un servicio. Adivinar "backend" por el nombre no.
1704
+ "kind": "container" if service.detached else "local",
1705
+ "port": service.port or discovered.get(service.name),
1706
+ "openable": abrible,
1707
+ # Adonde lleva "Abrir", si el stack.yaml lo declara. None deja
1708
+ # que la interfaz arme el default con el puerto.
1709
+ #
1710
+ # Se pregunta SOLO si ya es abrible: `runner.service_url` lee
1711
+ # `env.global` y cada `env_file` de disco, y esto corre cada 2.5s
1712
+ # para cada servicio de cada proyecto. Con los stacks apagados,
1713
+ # que es el caso normal, el boton ni se dibuja y no se toca disco.
1714
+ "url": runner.service_url(service) if abrible else None,
1715
+ "needs": list(service.needs),
1716
+ "state": state,
1717
+ "occupant": occ,
1718
+ "suggested_port": suggested_alt,
1719
+ # Otros proyectos registrados que declaran este mismo puerto.
1720
+ # Conviven mientras no corran a la vez, y saberlo antes evita
1721
+ # el "puerto ocupado" que no dice por quien.
1722
+ "shared_with": _shared_with(compartidos, service.port, path),
1723
+ # El puerto ya estaba ocupado cuando arrancamos: el verde puede
1724
+ # ser de otro proceso. Solo mientras corra la sesion que lo vio.
1725
+ "port_taken": state != "stopped" and service.name in tomados,
1726
+ # Lo arranco esta interfaz y hay un proceso del que colgarse.
1727
+ # Sin esto la tarjeta ofrecia "Reiniciar" sobre un contenedor
1728
+ # ajeno, y el boton contestaba 404. El engine hace falta aparte
1729
+ # de la sesion: una que sobrevivio a un reinicio del servidor
1730
+ # deriva sus estados del puerto y no tiene procesos, y ahi
1731
+ # `restart` contesta 409.
1732
+ "managed": manejados is not None and service.name in manejados,
1733
+ }
1734
+ )
1735
+
1736
+ has_docker = any(doctor._program(s.command) == "docker" for s in stack.services.values())
1737
+ docker_down = _docker_is_down() if has_docker else False
1738
+ metrics = session.engine.resource_stats() if session and session.engine else {}
1739
+
1740
+ return {
1741
+ **base,
1742
+ "name": stack.name,
1743
+ "state": estado,
1744
+ "error": session.error if session else None,
1745
+ "detected": stack.detected,
1746
+ "profiles": sorted(stack.profiles),
1747
+ "profile": _current_profile(pid, session),
1748
+ # Que levanta Arrancar sin perfil. Vacio = todos.
1749
+ "default": list(stack.default or ()),
1750
+ "services": services,
1751
+ "metrics": metrics,
1752
+ # Los dos: `docker_down` en False quiere decir "el daemon contesta" y
1753
+ # tambien "este proyecto no usa Docker", y la interfaz necesita
1754
+ # distinguirlos para poder decir "corriendo" en vez de callarse.
1755
+ "needs_docker": has_docker,
1756
+ "docker_down": docker_down,
1757
+ "graph": runner.dependency_graph(stack),
1758
+ }
1759
+
1760
+
1761
+ def _shared_with(mapa: dict[int, list[Path]], port: int | None, mio: Path) -> list[str]:
1762
+ """Nombres de los otros proyectos que declaran `port`. Vacio si no hay."""
1763
+ if not port:
1764
+ return []
1765
+ return [registry.name_of(otro) for otro in mapa.get(port, []) if otro != mio]
1766
+
1767
+
1768
+ def _published(status: ports.PortStatus | None) -> bool:
1769
+ """El puerto lo tiene el proxy de Docker, o sea que hay un contenedor arriba."""
1770
+ return status is not None and not status.free and ports.proxy_owner(status) is not None
1771
+
1772
+
1773
+ def _occupant(status: ports.PortStatus | None, ours: bool) -> dict | None:
1774
+ """Quien ocupa el puerto, solo cuando no somos nosotros."""
1775
+ if status is None or status.free or ours:
1776
+ return None
1777
+ # El dueno del puerto puede ser el proxy de Docker, y entonces lo que escucha
1778
+ # ahi es un contenedor: matar ese pid apaga el backend de Docker Desktop
1779
+ # entero y deja el contenedor corriendo sin publicar. `find_orphans` ya lo
1780
+ # sabe y lo saltea, por eso la tarjeta decia "ocupado" mientras el panel de
1781
+ # intrusos decia "ninguno".
1782
+ return {
1783
+ "pid": status.pid,
1784
+ "name": status.name or "desconocido",
1785
+ "proxy": ports.proxy_owner(status),
1786
+ }