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/__init__.py +2 -0
- stackhelx/__main__.py +4 -0
- stackhelx/browse.py +160 -0
- stackhelx/cli.py +1165 -0
- stackhelx/config.py +448 -0
- stackhelx/detect.py +1053 -0
- stackhelx/docker.py +202 -0
- stackhelx/doctor.py +356 -0
- stackhelx/guardrails.py +42 -0
- stackhelx/history.py +86 -0
- stackhelx/mcp.py +467 -0
- stackhelx/ports.py +392 -0
- stackhelx/registry.py +356 -0
- stackhelx/runner.py +877 -0
- stackhelx/scripts.py +123 -0
- stackhelx/server.py +1786 -0
- stackhelx/tunnel.py +207 -0
- stackhelx/web/app.css +1729 -0
- stackhelx/web/app.js +2425 -0
- stackhelx/web/index.html +378 -0
- stackhelx/web/tokens.css +104 -0
- stackhelx-1.0.0.dist-info/METADATA +323 -0
- stackhelx-1.0.0.dist-info/RECORD +26 -0
- stackhelx-1.0.0.dist-info/WHEEL +4 -0
- stackhelx-1.0.0.dist-info/entry_points.txt +3 -0
- stackhelx-1.0.0.dist-info/licenses/LICENSE +21 -0
stackhelx/ports.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""Deteccion de puertos ocupados y cierre seguro del proceso que los usa.
|
|
2
|
+
|
|
3
|
+
Sin dependencias de terminal a proposito: el CLI y la futura API local
|
|
4
|
+
consumen las mismas funciones.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import socket
|
|
10
|
+
import subprocess
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
import psutil
|
|
14
|
+
|
|
15
|
+
# System Idle Process y System en Windows (0, 4), e init/systemd/launchd en POSIX (1). Matarlos no es una opcion.
|
|
16
|
+
PROTECTED_PIDS = {0, 1, 4}
|
|
17
|
+
|
|
18
|
+
# Un puerto publicado por un contenedor no lo escucha el contenedor: lo escucha
|
|
19
|
+
# un proxy que Docker o WSL comparten entre todos. En esta maquina un unico
|
|
20
|
+
# com.docker.backend.exe servia 3000 y 3100, y un unico wslrelay.exe servia 5432
|
|
21
|
+
# y 5433. "Liberar el puerto 5432" terminaba en un terminate() sobre ese proxy,
|
|
22
|
+
# que apaga Docker Desktop entero con todos sus contenedores.
|
|
23
|
+
PROXY_NAMES = {
|
|
24
|
+
"com.docker.backend.exe": "Docker Desktop",
|
|
25
|
+
"com.docker.proxy.exe": "Docker Desktop",
|
|
26
|
+
"docker desktop.exe": "Docker Desktop",
|
|
27
|
+
"vpnkit.exe": "Docker Desktop",
|
|
28
|
+
"dockerd.exe": "Docker",
|
|
29
|
+
"dockerd": "Docker",
|
|
30
|
+
"docker-proxy": "Docker",
|
|
31
|
+
"wslrelay.exe": "WSL",
|
|
32
|
+
"wslhost.exe": "WSL",
|
|
33
|
+
"wslservice.exe": "WSL",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
DOCKER_PS_TIMEOUT = 5
|
|
37
|
+
|
|
38
|
+
TERMINATE_TIMEOUT = 5
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class KillRefused(Exception):
|
|
42
|
+
"""El proceso existe pero PortMaster se niega a matarlo."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class PortStatus:
|
|
47
|
+
port: int
|
|
48
|
+
free: bool
|
|
49
|
+
pid: int | None = None
|
|
50
|
+
name: str | None = None
|
|
51
|
+
cmdline: str | None = None
|
|
52
|
+
create_time: float | None = None
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def owner_unknown(self) -> bool:
|
|
56
|
+
"""Ocupado pero sin permiso para saber por quien."""
|
|
57
|
+
return not self.free and self.pid is None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def check_port(port: int) -> int:
|
|
61
|
+
"""Rechaza lo que no es un puerto TCP. Publica porque no la usa solo este
|
|
62
|
+
modulo: `server.share_port` valida antes de tocar el candado de tuneles, y
|
|
63
|
+
ahi no hay ningun `scan` que la traiga de arrastre."""
|
|
64
|
+
if not isinstance(port, int) or isinstance(port, bool):
|
|
65
|
+
raise ValueError(f"puerto invalido: {port!r}")
|
|
66
|
+
if not 1 <= port <= 65535:
|
|
67
|
+
raise ValueError(f"puerto fuera de rango 1-65535: {port}")
|
|
68
|
+
return port
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _bind_free(port: int) -> bool:
|
|
72
|
+
exclusive = getattr(socket, "SO_EXCLUSIVEADDRUSE", None)
|
|
73
|
+
hosts = [("0.0.0.0", socket.AF_INET), ("127.0.0.1", socket.AF_INET)]
|
|
74
|
+
if getattr(socket, "has_ipv6", False):
|
|
75
|
+
hosts.append(("::1", socket.AF_INET6))
|
|
76
|
+
|
|
77
|
+
for host, family in hosts:
|
|
78
|
+
try:
|
|
79
|
+
with socket.socket(family, socket.SOCK_STREAM) as sock:
|
|
80
|
+
if exclusive is not None and family == socket.AF_INET:
|
|
81
|
+
sock.setsockopt(socket.SOL_SOCKET, exclusive, 1)
|
|
82
|
+
sock.bind((host, port))
|
|
83
|
+
except OSError:
|
|
84
|
+
return False
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _pid_by_process_scan(port: int, cache: dict[int, int] | None = None) -> int | None:
|
|
89
|
+
"""Barrido proceso por proceso. Solo cuando la tabla no dio el dueno.
|
|
90
|
+
|
|
91
|
+
Si se provee un cache del escaneo batch, consulta directamente el dict.
|
|
92
|
+
De lo contrario, construye una tabla en una sola pasada sobre process_iter.
|
|
93
|
+
"""
|
|
94
|
+
if cache is not None:
|
|
95
|
+
return cache.get(port)
|
|
96
|
+
return _process_listeners_cache().get(port)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _process_listeners_cache() -> dict[int, int]:
|
|
100
|
+
"""Mapea cada puerto LISTEN a su PID haciendo una sola pasada por process_iter()."""
|
|
101
|
+
table: dict[int, int] = {}
|
|
102
|
+
for proc in psutil.process_iter():
|
|
103
|
+
try:
|
|
104
|
+
for conn in proc.net_connections(kind="tcp"):
|
|
105
|
+
if conn.laddr and conn.status == psutil.CONN_LISTEN:
|
|
106
|
+
table[conn.laddr.port] = proc.pid
|
|
107
|
+
except (psutil.AccessDenied, psutil.NoSuchProcess):
|
|
108
|
+
continue
|
|
109
|
+
return table
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def is_free(port: int) -> bool:
|
|
113
|
+
"""Libre solo si nadie escucha en la tabla y ademas el bind funciona."""
|
|
114
|
+
check_port(port)
|
|
115
|
+
return port not in _listeners({port}) and _bind_free(port)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# Un connect() a algo que escucha entra al backlog en el acto; contra un puerto
|
|
119
|
+
# cerrado el kernel corta con ECONNREFUSED sin esperar. Solo se agota si hay un
|
|
120
|
+
# firewall tragando paquetes, que en loopback no pasa.
|
|
121
|
+
ACCEPT_TIMEOUT = 0.5
|
|
122
|
+
|
|
123
|
+
# Las dos familias, y no solo IPv4. Node moderno resuelve `localhost` a `::1`,
|
|
124
|
+
# asi que un `vite` o un `next` en Windows escucha solo en IPv6: preguntar por
|
|
125
|
+
# 127.0.0.1 daba que no hay nadie, el servicio no quedaba listo nunca, y el
|
|
126
|
+
# error terminaba acusando de intruso al proceso que acabamos de arrancar. El
|
|
127
|
+
# navegador tampoco pregunta por una sola: resuelve `localhost` y prueba las que
|
|
128
|
+
# le devuelvan. Explicitas y no por nombre, para no depender del DNS ni del
|
|
129
|
+
# archivo hosts en el camino caliente del arranque.
|
|
130
|
+
LOOPBACK = ("127.0.0.1", "::1")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def accepts(port: int, timeout: float = ACCEPT_TIMEOUT) -> bool:
|
|
134
|
+
"""Si algo acepta conexiones en el puerto, aca y ahora.
|
|
135
|
+
|
|
136
|
+
Distinto de `not is_free(port)`: un socket bindeado y todavia sin `listen()`
|
|
137
|
+
hace fallar el bind, asi que cuenta como ocupado, pero no acepta a nadie.
|
|
138
|
+
Esa ventana existe de verdad. En `HTTPServer.__init__` de CPython:
|
|
139
|
+
|
|
140
|
+
server_bind() # bind() y despues socket.getfqdn(host)
|
|
141
|
+
server_activate() # listen()
|
|
142
|
+
|
|
143
|
+
`getfqdn` es una resolucion inversa de DNS, y en macOS sin resolver rapido
|
|
144
|
+
tarda segundos. Un arranque que se declara listo ahi ve el puerto ocupado
|
|
145
|
+
por su propio socket a medio abrir, y despues no consigue hablarle.
|
|
146
|
+
|
|
147
|
+
Para "el servicio ya esta arriba" se pregunta esto. Para "puedo usar este
|
|
148
|
+
puerto" se sigue preguntando `is_free`, donde un bindeado si es un no.
|
|
149
|
+
|
|
150
|
+
Prueba IPv4 y despues IPv6: ver LOOPBACK.
|
|
151
|
+
"""
|
|
152
|
+
check_port(port)
|
|
153
|
+
# El presupuesto es total y se reparte, no uno por direccion: con el timeout
|
|
154
|
+
# entero para cada una, un puerto que no contesta costaba el doble de lo que
|
|
155
|
+
# promete el parametro, y esto lo llama el sondeo del arranque cada 150ms.
|
|
156
|
+
#
|
|
157
|
+
# Repartido y no un deadline que la primera se pueda comer entero: si IPv4
|
|
158
|
+
# se cuelga, IPv6 tiene que conservar su turno. Justo el servicio que solo
|
|
159
|
+
# escucha ahi es el que este chequeo vino a encontrar.
|
|
160
|
+
cada = timeout / len(LOOPBACK)
|
|
161
|
+
for host in LOOPBACK:
|
|
162
|
+
try:
|
|
163
|
+
with socket.create_connection((host, port), timeout=cada):
|
|
164
|
+
return True
|
|
165
|
+
except OSError:
|
|
166
|
+
continue
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _quiet(getter):
|
|
171
|
+
try:
|
|
172
|
+
return getter()
|
|
173
|
+
except (psutil.AccessDenied, psutil.NoSuchProcess):
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _describe(port: int, pid: int | None, cache: dict[int, int] | None = None) -> PortStatus:
|
|
178
|
+
if pid is None:
|
|
179
|
+
pid = _pid_by_process_scan(port, cache)
|
|
180
|
+
if pid is None:
|
|
181
|
+
return PortStatus(port=port, free=False)
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
proc = psutil.Process(pid)
|
|
185
|
+
with proc.oneshot():
|
|
186
|
+
name = _quiet(proc.name)
|
|
187
|
+
cmdline = _quiet(lambda: " ".join(proc.cmdline()))
|
|
188
|
+
created = _quiet(proc.create_time)
|
|
189
|
+
except psutil.NoSuchProcess:
|
|
190
|
+
return PortStatus(port=port, free=False)
|
|
191
|
+
|
|
192
|
+
return PortStatus(port, False, pid, name, cmdline or None, created)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def scan(port: int) -> PortStatus:
|
|
196
|
+
"""Estado del puerto, con el dueno si se puede averiguar."""
|
|
197
|
+
return scan_many([port])[port]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def scan_many(wanted: list[int]) -> dict[int, PortStatus]:
|
|
201
|
+
"""Igual que scan(), pero con una sola lectura de la tabla del sistema.
|
|
202
|
+
|
|
203
|
+
La UI escanea los puertos de todos los proyectos cada pocos segundos; hacer
|
|
204
|
+
una llamada al SO por puerto se nota.
|
|
205
|
+
"""
|
|
206
|
+
for port in wanted:
|
|
207
|
+
check_port(port)
|
|
208
|
+
|
|
209
|
+
listeners = _listeners(set(wanted))
|
|
210
|
+
proc_cache: dict[int, int] | None = None
|
|
211
|
+
result = {}
|
|
212
|
+
for port in wanted:
|
|
213
|
+
if port not in listeners and _bind_free(port):
|
|
214
|
+
result[port] = PortStatus(port=port, free=True)
|
|
215
|
+
else:
|
|
216
|
+
pid = listeners.get(port)
|
|
217
|
+
if pid is None:
|
|
218
|
+
if proc_cache is None:
|
|
219
|
+
proc_cache = _process_listeners_cache()
|
|
220
|
+
pid = proc_cache.get(port)
|
|
221
|
+
result[port] = _describe(port, pid, proc_cache)
|
|
222
|
+
return result
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _listeners(wanted: set[int]) -> dict[int, int | None]:
|
|
226
|
+
"""Puertos de `wanted` con listener, mapeados a su PID si es visible."""
|
|
227
|
+
found: dict[int, int | None] = {}
|
|
228
|
+
try:
|
|
229
|
+
for conn in psutil.net_connections(kind="tcp"):
|
|
230
|
+
if not conn.laddr or conn.status != psutil.CONN_LISTEN:
|
|
231
|
+
continue
|
|
232
|
+
port = conn.laddr.port
|
|
233
|
+
if port in wanted and (conn.pid or port not in found):
|
|
234
|
+
found[port] = conn.pid
|
|
235
|
+
except (psutil.AccessDenied, PermissionError):
|
|
236
|
+
pass # macOS sin root: queda todo en manos de la sonda de bind
|
|
237
|
+
return found
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def opened_by(pid: int) -> list[int]:
|
|
241
|
+
"""Puertos en LISTEN del proceso pid y de sus descendientes, ordenados.
|
|
242
|
+
|
|
243
|
+
El arbol entero porque con shell=True el hijo directo es el shell, y quien
|
|
244
|
+
escucha es un nieto.
|
|
245
|
+
"""
|
|
246
|
+
try:
|
|
247
|
+
parent = psutil.Process(pid)
|
|
248
|
+
tree = [parent, *parent.children(recursive=True)]
|
|
249
|
+
except psutil.NoSuchProcess:
|
|
250
|
+
return []
|
|
251
|
+
|
|
252
|
+
found = set()
|
|
253
|
+
for process in tree:
|
|
254
|
+
try:
|
|
255
|
+
for conn in process.net_connections(kind="inet"):
|
|
256
|
+
if conn.status == psutil.CONN_LISTEN and conn.laddr:
|
|
257
|
+
found.add(conn.laddr.port)
|
|
258
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
259
|
+
continue
|
|
260
|
+
return sorted(found)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def listening(pid: int) -> int | None:
|
|
264
|
+
"""Primer puerto en LISTEN del proceso pid o de sus descendientes.
|
|
265
|
+
|
|
266
|
+
Para servicios cuyo puerto no esta declarado: en vez de adivinarlo parseando
|
|
267
|
+
la config del framework, se arranca el proceso y se le pregunta.
|
|
268
|
+
"""
|
|
269
|
+
found = opened_by(pid)
|
|
270
|
+
return found[0] if found else None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def next_free(start: int, limit: int = 20) -> int:
|
|
274
|
+
"""Primer puerto libre desde start (incluido)."""
|
|
275
|
+
check_port(start)
|
|
276
|
+
candidates = range(start, min(start + limit, 65536))
|
|
277
|
+
taken = _listeners(set(candidates))
|
|
278
|
+
for port in candidates:
|
|
279
|
+
if port not in taken and _bind_free(port):
|
|
280
|
+
return port
|
|
281
|
+
raise RuntimeError(f"sin puerto libre entre {start} y {start + limit - 1}")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def suggest_alternative(port: int, exclude: set[int] | None = None) -> int:
|
|
285
|
+
"""Sugiere el siguiente puerto libre cercano a `port`.
|
|
286
|
+
|
|
287
|
+
Si el puerto original ya está libre y no está en `exclude`, lo devuelve.
|
|
288
|
+
De lo contrario, busca secuencialmente desde `port + 1` descartando
|
|
289
|
+
los puertos de `exclude` y los que no estén libres en el sistema.
|
|
290
|
+
"""
|
|
291
|
+
check_port(port)
|
|
292
|
+
forbidden = set(exclude) if exclude else set()
|
|
293
|
+
if port not in forbidden and is_free(port):
|
|
294
|
+
return port
|
|
295
|
+
|
|
296
|
+
arriba = range(port + 1, min(port + 100, 65536))
|
|
297
|
+
abajo = range(max(1024, port - 100), port)
|
|
298
|
+
candidatos = [c for c in (*arriba, *abajo) if c not in forbidden]
|
|
299
|
+
taken = _listeners(set(candidatos))
|
|
300
|
+
for candidate in candidatos:
|
|
301
|
+
if candidate not in taken and _bind_free(candidate):
|
|
302
|
+
return candidate
|
|
303
|
+
|
|
304
|
+
raise RuntimeError(f"no hay puertos libres alternativos cerca de {port}")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def proxy_owner(status: PortStatus) -> str | None:
|
|
308
|
+
"""Motor que publica el puerto, si el dueno es un proxy de Docker o WSL.
|
|
309
|
+
|
|
310
|
+
Un puerto en manos de ese proxy no es algo que liberar: el contenedor ya
|
|
311
|
+
esta publicado, y `docker compose up -d` sobre uno que ya corre no hace
|
|
312
|
+
nada. Sin esto, arrancar un stack a medio levantar se cancela solo.
|
|
313
|
+
"""
|
|
314
|
+
return PROXY_NAMES.get((status.name or "").lower())
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def containers_on(port: int) -> list[str]:
|
|
318
|
+
"""Contenedores que publican `port`, vacio si docker no contesta.
|
|
319
|
+
|
|
320
|
+
Solo para armar el mensaje de un kill rechazado, nunca en el camino
|
|
321
|
+
caliente: cuesta un subproceso.
|
|
322
|
+
"""
|
|
323
|
+
try:
|
|
324
|
+
done = subprocess.run(
|
|
325
|
+
["docker", "ps", "--filter", f"publish={port}", "--format", "{{.Names}}"],
|
|
326
|
+
capture_output=True,
|
|
327
|
+
text=True,
|
|
328
|
+
timeout=DOCKER_PS_TIMEOUT,
|
|
329
|
+
)
|
|
330
|
+
except (OSError, subprocess.SubprocessError):
|
|
331
|
+
return []
|
|
332
|
+
return done.stdout.split()
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _proxy_message(nombre: str, motor: str, port: int | None) -> str:
|
|
336
|
+
base = (
|
|
337
|
+
f"ese puerto lo publica {motor} a traves de {nombre}, un proxy compartido"
|
|
338
|
+
f" por todos los contenedores: cerrarlo apagaria {motor} entero"
|
|
339
|
+
)
|
|
340
|
+
nombres = containers_on(port) if port is not None else []
|
|
341
|
+
if nombres:
|
|
342
|
+
return f"{base}. Para el contenedor con: docker stop {' '.join(nombres)}"
|
|
343
|
+
return f"{base}. Pararlo desde Docker, no desde el puerto"
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def kill(
|
|
347
|
+
pid: int, create_time: float | None = None, force: bool = False, port: int | None = None
|
|
348
|
+
) -> None:
|
|
349
|
+
"""Cierra el proceso pid. terminate() primero, kill() solo con force.
|
|
350
|
+
|
|
351
|
+
create_time es el valor visto por scan(); si no coincide, el PID fue
|
|
352
|
+
reciclado por otro proceso y se aborta. port solo enriquece el mensaje
|
|
353
|
+
cuando el dueno resulta ser un proxy de Docker o WSL.
|
|
354
|
+
|
|
355
|
+
Lanza KillRefused si el proceso esta protegido, psutil.NoSuchProcess si ya
|
|
356
|
+
no existe, y psutil.AccessDenied si faltan permisos.
|
|
357
|
+
"""
|
|
358
|
+
# psutil.Process(None) es el proceso actual: sin esto, un scan que no vio al
|
|
359
|
+
# dueno del puerto termina en PortMaster matandose a si mismo.
|
|
360
|
+
if pid is None:
|
|
361
|
+
raise KillRefused("no hay PID que cerrar")
|
|
362
|
+
if pid in PROTECTED_PIDS:
|
|
363
|
+
raise KillRefused(f"PID {pid} es un proceso del sistema")
|
|
364
|
+
|
|
365
|
+
me = psutil.Process()
|
|
366
|
+
if pid == me.pid:
|
|
367
|
+
raise KillRefused("ese PID es PortMaster")
|
|
368
|
+
if pid in {ancestor.pid for ancestor in me.parents()}:
|
|
369
|
+
raise KillRefused(f"PID {pid} es un proceso padre de PortMaster (tu terminal)")
|
|
370
|
+
|
|
371
|
+
proc = psutil.Process(pid)
|
|
372
|
+
if create_time is not None and proc.create_time() != create_time:
|
|
373
|
+
raise KillRefused(f"el PID {pid} ya no es el proceso que se escaneo")
|
|
374
|
+
|
|
375
|
+
nombre = _quiet(proc.name) or ""
|
|
376
|
+
motor = PROXY_NAMES.get(nombre.lower())
|
|
377
|
+
if motor is not None:
|
|
378
|
+
raise KillRefused(_proxy_message(nombre, motor, port))
|
|
379
|
+
|
|
380
|
+
proc.terminate()
|
|
381
|
+
try:
|
|
382
|
+
proc.wait(TERMINATE_TIMEOUT)
|
|
383
|
+
return
|
|
384
|
+
except psutil.TimeoutExpired:
|
|
385
|
+
pass
|
|
386
|
+
|
|
387
|
+
if not force:
|
|
388
|
+
raise KillRefused(
|
|
389
|
+
f"PID {pid} ignoro terminate tras {TERMINATE_TIMEOUT}s; usa --force"
|
|
390
|
+
)
|
|
391
|
+
proc.kill()
|
|
392
|
+
proc.wait(TERMINATE_TIMEOUT)
|