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/tunnel.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Exposicion de servicios locales via tuneles seguros (Cloudflare, ngrok, localtunnel, tailscale)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from collections import deque
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Callable
|
|
15
|
+
|
|
16
|
+
from . import ports, runner
|
|
17
|
+
|
|
18
|
+
PROVIDERS = ("cloudflared", "ngrok", "lt", "tailscale")
|
|
19
|
+
TAILSCALE_URL = re.compile(r"https://[a-zA-Z0-9.-]+\.ts\.net(?:/\S*)?")
|
|
20
|
+
|
|
21
|
+
CLOUDFLARE_REGEX = re.compile(r"https://[a-zA-Z0-9-]+\.trycloudflare\.com")
|
|
22
|
+
NGROK_REGEX = re.compile(r"https://[a-zA-Z0-9-]+\.ngrok(?:-free)?\.app")
|
|
23
|
+
LOCALTUNNEL_REGEX = re.compile(r"https://[a-zA-Z0-9-]+\.loca\.lt")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TunnelError(Exception):
|
|
27
|
+
"""Falla al iniciar o detectar un proveedor de tuneles."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Tunnel:
|
|
32
|
+
provider: str
|
|
33
|
+
port: int
|
|
34
|
+
url: str
|
|
35
|
+
proc: subprocess.Popen
|
|
36
|
+
|
|
37
|
+
def stop(self) -> None:
|
|
38
|
+
"""Cierra el cliente de tuneles, y no solo el shell que lo lanzo.
|
|
39
|
+
|
|
40
|
+
Con `shell=True` el hijo directo es el shell y el cliente es nieto:
|
|
41
|
+
`self.proc.terminate()` mataba el `cmd` y dejaba el cloudflared vivo,
|
|
42
|
+
con la URL publica funcionando. Todo lo que se hizo contra las fugas de
|
|
43
|
+
tuneles (el cierre al apagar, el boton, el camino del timeout) cerraba
|
|
44
|
+
el shell y no el tunel.
|
|
45
|
+
|
|
46
|
+
`runner._terminate_tree` ya existia con este mismo problema resuelto y
|
|
47
|
+
documentado, y ademas espera acotado: sin timeout, un cliente que ignora
|
|
48
|
+
la señal colgaba el apagado del servidor para siempre.
|
|
49
|
+
"""
|
|
50
|
+
if self.proc.poll() is None:
|
|
51
|
+
runner._terminate_tree(self.proc)
|
|
52
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
53
|
+
self.proc.wait(timeout=3)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def detect_providers() -> list[str]:
|
|
57
|
+
"""Retorna los clientes de tuneles instalados en el sistema."""
|
|
58
|
+
return [p for p in PROVIDERS if shutil.which(p) is not None]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def sirve_stackhelx(port: int) -> bool:
|
|
62
|
+
"""Si detras del puerto hay un `stackhelx serve` (o legacy `portmaster serve`).
|
|
63
|
+
|
|
64
|
+
ponytail: es la linea de comando del dueno del puerto, o sea una heuristica.
|
|
65
|
+
Lo exacto seria que `serve` dejara su puerto en un archivo, y eso es estado
|
|
66
|
+
nuevo que sobrevive a un cierre feo. El costo de equivocarse es chico en las
|
|
67
|
+
dos direcciones: un falso positivo pide otro puerto, un falso negativo deja
|
|
68
|
+
el comportamiento que ya habia. El servidor ademas tiene su propia guarda,
|
|
69
|
+
que sale del socket que bindeo y no de una cadena de texto.
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
estado = ports.scan(port)
|
|
73
|
+
except (ValueError, OSError):
|
|
74
|
+
return False
|
|
75
|
+
if estado.pid == os.getpid():
|
|
76
|
+
return True
|
|
77
|
+
linea = (estado.cmdline or "").lower()
|
|
78
|
+
return ("stackhelx" in linea or "portmaster" in linea) and "serve" in linea
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
sirve_portmaster = sirve_stackhelx
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def start_tunnel(
|
|
85
|
+
port: int,
|
|
86
|
+
provider: str | None = None,
|
|
87
|
+
timeout: float = 15.0,
|
|
88
|
+
) -> Tunnel:
|
|
89
|
+
"""Inicia un tunel efimero hacia el puerto especificado y extrae la URL publica."""
|
|
90
|
+
# StackHelx no se publica a si mismo. Detras de ese puerto esta la API que
|
|
91
|
+
# arranca stack.yaml, o sea ejecucion de comandos, y el token pasaria a ser
|
|
92
|
+
# lo unico entre internet y la consola del usuario. Va aca y no en cada
|
|
93
|
+
# comando porque este es el unico lugar por donde pasan todos los tuneles:
|
|
94
|
+
# el boton de la interfaz, `stackhelx share` y lo que venga despues.
|
|
95
|
+
if sirve_stackhelx(port):
|
|
96
|
+
raise TunnelError(
|
|
97
|
+
f"el puerto {port} es de un `stackhelx serve`. Publicarlo expone la "
|
|
98
|
+
"API que ejecuta los comandos de tu stack.yaml, no tu proyecto."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
available = detect_providers()
|
|
102
|
+
if provider:
|
|
103
|
+
if provider not in PROVIDERS:
|
|
104
|
+
raise TunnelError(f"proveedor desconocido: {provider!r}. Soportados: {', '.join(PROVIDERS)}")
|
|
105
|
+
if shutil.which(provider) is None:
|
|
106
|
+
raise TunnelError(f"el binario '{provider}' no esta instalado o no se encuentra en el PATH")
|
|
107
|
+
chosen = provider
|
|
108
|
+
else:
|
|
109
|
+
if not available:
|
|
110
|
+
raise TunnelError(
|
|
111
|
+
"no se encontro ningun cliente de tuneles en el PATH. "
|
|
112
|
+
"Instala 'cloudflared' (recomendado), 'ngrok' o 'localtunnel'."
|
|
113
|
+
)
|
|
114
|
+
chosen = available[0]
|
|
115
|
+
|
|
116
|
+
cmd, url_extractor = _provider_config(chosen, port)
|
|
117
|
+
proc = subprocess.Popen(
|
|
118
|
+
cmd,
|
|
119
|
+
shell=True,
|
|
120
|
+
stdout=subprocess.PIPE,
|
|
121
|
+
stderr=subprocess.STDOUT,
|
|
122
|
+
text=True,
|
|
123
|
+
bufsize=1,
|
|
124
|
+
errors="replace",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
found_url: list[str] = []
|
|
128
|
+
ready_event = threading.Event()
|
|
129
|
+
# Las ultimas lineas, para que un fallo diga lo que dijo el cliente en vez de
|
|
130
|
+
# "no se pudo": el motivo real suele estar ahi (una cuenta sin autenticar, un
|
|
131
|
+
# puerto ya tomado del lado del proveedor).
|
|
132
|
+
ultimas: deque[str] = deque(maxlen=3)
|
|
133
|
+
|
|
134
|
+
def _reader():
|
|
135
|
+
assert proc.stdout is not None
|
|
136
|
+
for line in proc.stdout:
|
|
137
|
+
if line.strip():
|
|
138
|
+
ultimas.append(line.strip())
|
|
139
|
+
url = url_extractor(line)
|
|
140
|
+
if url and not found_url:
|
|
141
|
+
found_url.append(url)
|
|
142
|
+
ready_event.set()
|
|
143
|
+
|
|
144
|
+
thread = threading.Thread(target=_reader, daemon=True)
|
|
145
|
+
thread.start()
|
|
146
|
+
|
|
147
|
+
# Mirando tambien si el proceso se murio, y no solo el reloj. Un cliente que
|
|
148
|
+
# falla al arrancar (`ngrok` sin autenticar) se va en menos de un segundo, y
|
|
149
|
+
# esperarle el plazo entero dejaba a `portmaster share` pareciendo colgado
|
|
150
|
+
# antes de dar un error que ya se sabia.
|
|
151
|
+
limite = time.monotonic() + timeout
|
|
152
|
+
while not ready_event.wait(0.1):
|
|
153
|
+
if proc.poll() is not None or time.monotonic() >= limite:
|
|
154
|
+
break
|
|
155
|
+
|
|
156
|
+
if not found_url:
|
|
157
|
+
salida = proc.poll()
|
|
158
|
+
# Por `stop` y no un `terminate` suelto: el proceso que no contesto en el
|
|
159
|
+
# plazo puede estar por levantar igual, y si sobrevive al terminate queda
|
|
160
|
+
# un tunel publico que nadie sabe que existe.
|
|
161
|
+
Tunnel(provider=chosen, port=port, url="", proc=proc).stop()
|
|
162
|
+
motivo = f": {ultimas[-1]}" if ultimas else ""
|
|
163
|
+
if salida is not None:
|
|
164
|
+
raise TunnelError(f"'{chosen}' termino con codigo {salida} sin publicar una URL{motivo}")
|
|
165
|
+
raise TunnelError(
|
|
166
|
+
f"no se pudo obtener la URL publica del tunel '{chosen}' en {timeout:.0f}s{motivo}"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
return Tunnel(provider=chosen, port=port, url=found_url[0], proc=proc)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _provider_config(
|
|
173
|
+
provider: str, port: int
|
|
174
|
+
) -> tuple[str, Callable[[str], str | None]]:
|
|
175
|
+
if provider == "cloudflared":
|
|
176
|
+
cmd = f"cloudflared tunnel --url http://127.0.0.1:{port}"
|
|
177
|
+
def extract(line: str) -> str | None:
|
|
178
|
+
m = CLOUDFLARE_REGEX.search(line)
|
|
179
|
+
return m.group(0) if m else None
|
|
180
|
+
return cmd, extract
|
|
181
|
+
|
|
182
|
+
if provider == "ngrok":
|
|
183
|
+
cmd = f"ngrok http {port} --log stdout"
|
|
184
|
+
def extract(line: str) -> str | None:
|
|
185
|
+
m = NGROK_REGEX.search(line)
|
|
186
|
+
return m.group(0) if m else None
|
|
187
|
+
return cmd, extract
|
|
188
|
+
|
|
189
|
+
if provider == "lt":
|
|
190
|
+
cmd = f"lt --port {port}"
|
|
191
|
+
def extract(line: str) -> str | None:
|
|
192
|
+
m = LOCALTUNNEL_REGEX.search(line)
|
|
193
|
+
return m.group(0) if m else None
|
|
194
|
+
return cmd, extract
|
|
195
|
+
|
|
196
|
+
if provider == "tailscale":
|
|
197
|
+
cmd = f"tailscale funnel {port}"
|
|
198
|
+
def extract(line: str) -> str | None:
|
|
199
|
+
# Contra el host de tailscale y no contra "cualquier https": los
|
|
200
|
+
# otros proveedores matchean su propio dominio, y aca un enlace a la
|
|
201
|
+
# documentacion o a la pantalla de login en una linea del log se
|
|
202
|
+
# reportaba como la URL del tunel.
|
|
203
|
+
match = TAILSCALE_URL.search(line)
|
|
204
|
+
return match.group(0) if match else None
|
|
205
|
+
return cmd, extract
|
|
206
|
+
|
|
207
|
+
raise TunnelError(f"configuracion no implementada para: {provider}")
|