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/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}")