@danieltmn/tamnora 0.10.0 → 0.11.0
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.
- package/CHANGELOG.md +11 -0
- package/README.md +7 -14
- package/SECURITY.md +4 -6
- package/harness/packages/cli/src/cli.js +1 -1
- package/harness/packages/cli/src/commands/bridge.js +51 -210
- package/harness/packages/cli/test/tunnel.test.js +5 -1
- package/harness/packages/core/src/config/app.js +4 -6
- package/harness/packages/core/src/index.js +0 -1
- package/harness/packages/core/src/tunnel.js +2 -154
- package/harness/packages/core/test/home.test.js +7 -5
- package/harness/packages/server/public/app.js +3 -30
- package/harness/packages/server/public/index.html +1 -1
- package/package.json +1 -1
- package/harness/packages/server/public/relayClient.js +0 -170
- package/harness/packages/server/test/relay.test.js +0 -219
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,17 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
|
|
|
5
5
|
El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
|
|
6
6
|
y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
|
|
7
7
|
|
|
8
|
+
## [0.11.0] - 2026-10-09
|
|
9
|
+
|
|
10
|
+
### Quitado
|
|
11
|
+
|
|
12
|
+
- **Tuneles publicos retirados**: `tamnora bridge` ahora soporta unicamente
|
|
13
|
+
**tailscale**. Se elimino el relay propio (`relay/index.php`, el deploy FTPS
|
|
14
|
+
`scripts/deploy-relay.mjs` y el transporte `relayClient.js` de la consola) y el
|
|
15
|
+
proveedor `tunnelmole` dejo de ofrecerse para el acceso remoto (se conserva solo
|
|
16
|
+
para el feature de `preview`). Un `app.json` con `tunnel.provider` en
|
|
17
|
+
`tunnelmole`/`relay` se migra a `tailscale` al leerlo.
|
|
18
|
+
|
|
8
19
|
## [0.10.0] - 2026-10-09
|
|
9
20
|
|
|
10
21
|
### Agregado
|
package/README.md
CHANGED
|
@@ -144,11 +144,10 @@ corre en modo local sin login. Al cambiar el usuario se detiene el server
|
|
|
144
144
|
|
|
145
145
|
### Acceso remoto
|
|
146
146
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
base SQLite).
|
|
147
|
+
El acceso remoto usa **Tailscale** (VPN mesh privada, rapida y sin exponer nada a
|
|
148
|
+
Internet). El server sigue escuchando en `127.0.0.1`; `tailscale serve` publica
|
|
149
|
+
HTTPS dentro del tailnet y reenvia por loopback, asi que la consola y todas las
|
|
150
|
+
tools funcionan igual que en local, sin sincronizar datos (una sola base SQLite).
|
|
152
151
|
|
|
153
152
|
```bash
|
|
154
153
|
# 1. Instalar Tailscale en esta PC y en el dispositivo remoto (misma cuenta)
|
|
@@ -156,7 +155,7 @@ base SQLite).
|
|
|
156
155
|
tamnora user --name yo --password unaClaveLarga
|
|
157
156
|
|
|
158
157
|
# 2. Server + acceso remoto
|
|
159
|
-
tamnora bridge --start #
|
|
158
|
+
tamnora bridge --start # tailscale serve (URL + QR)
|
|
160
159
|
tamnora bridge --status # https://<pc>.<tailnet>.ts.net
|
|
161
160
|
tamnora bridge --stop # tailscale serve reset
|
|
162
161
|
```
|
|
@@ -165,14 +164,8 @@ tamnora bridge --stop # tailscale serve reset
|
|
|
165
164
|
Sigue protegido por el login del server: configura un usuario antes de exponerlo
|
|
166
165
|
(ver [`SECURITY.md`](SECURITY.md)).
|
|
167
166
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
se conservara unicamente para el feature de *preview*:
|
|
171
|
-
|
|
172
|
-
```bash
|
|
173
|
-
tamnora bridge --start --tunnel tunnelmole # tunel publico gratis, URL aleatoria
|
|
174
|
-
tamnora bridge --start --tunnel relay # mailbox propio (harness.tamnora.com)
|
|
175
|
-
```
|
|
167
|
+
Los tuneles publicos (tunnelmole, relay) fueron **retirados**; `tunnelmole` se
|
|
168
|
+
conserva solo para el feature de *preview*.
|
|
176
169
|
|
|
177
170
|
### Telegram (canales de mensajeria)
|
|
178
171
|
|
package/SECURITY.md
CHANGED
|
@@ -20,12 +20,10 @@ asi que se lo debe tratar como codigo con acceso a tu usuario.
|
|
|
20
20
|
**usuario** con contrasena fuerte (defensa en profundidad). Como entra por
|
|
21
21
|
loopback, una vez autenticado se alcanza toda la consola y las tools
|
|
22
22
|
(explorador de archivos, shell, git): tratalo como acceso completo a tu PC.
|
|
23
|
-
- **Tuneles publicos (
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
tratalo como acceso completo a tu maquina. Cierra el tunnel (`tamnora bridge
|
|
28
|
-
--stop`) cuando no lo uses.
|
|
23
|
+
- **Tuneles publicos (retirados)**: las versiones anteriores usaban tunnelmole y
|
|
24
|
+
un relay propio para exponer la consola desde Internet. Se retiraron; el acceso
|
|
25
|
+
remoto hoy es solo por Tailscale. `tunnelmole` sobrevive unicamente para el
|
|
26
|
+
feature de *preview* de dev servers, que no expone la consola.
|
|
29
27
|
- **Credenciales**: las API keys de proveedores se guardan en la base SQLite de
|
|
30
28
|
la casa local (fuera del repo) **en texto plano**; protege el archivo con los
|
|
31
29
|
permisos del sistema. Para no persistirlas, usa `"apiKey": "env:VAR"` y
|
|
@@ -72,7 +72,7 @@ Uso:
|
|
|
72
72
|
tamnora mcp Listar servidores y herramientas MCP
|
|
73
73
|
tamnora server [--init|--start|--reload|--stop|--status|--logs] Server en segundo plano (abre el navegador)
|
|
74
74
|
tamnora user Usuario local (login del server)
|
|
75
|
-
tamnora bridge [--start|--reload|--stop|--status]
|
|
75
|
+
tamnora bridge [--start|--reload|--stop|--status] Acceso remoto con Tailscale + QR
|
|
76
76
|
tamnora autostart install|remove Arranque automatico del server
|
|
77
77
|
tamnora serve Consola web local (primer plano)
|
|
78
78
|
tamnora doctor Verificar Node, casa, puerto y base
|
|
@@ -1,11 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
homeDir,
|
|
3
|
-
ensureHome,
|
|
4
|
-
ensureApp,
|
|
5
|
-
writeApp,
|
|
6
|
-
readApp,
|
|
7
|
-
randomToken,
|
|
8
|
-
} from '../../../core/src/index.js';
|
|
1
|
+
import { homeDir, ensureHome, ensureApp } from '../../../core/src/index.js';
|
|
9
2
|
import { parse, num } from '../lib/util.js';
|
|
10
3
|
import {
|
|
11
4
|
applyHomeOverride,
|
|
@@ -21,17 +14,6 @@ import {
|
|
|
21
14
|
import { startTunnel, probeTailscale, resetTailscaleServe } from '../lib/tunnel.js';
|
|
22
15
|
import { qrTerminal } from '../lib/qr.js';
|
|
23
16
|
|
|
24
|
-
// Tuneles publicos legacy: se retiran en la proxima version. `tunnelmole` se
|
|
25
|
-
// conserva solo para el feature de `preview` (no como proveedor de `bridge`).
|
|
26
|
-
const LEGACY_PROVIDERS = new Set(['tunnelmole', 'tmole', 'relay']);
|
|
27
|
-
|
|
28
|
-
function warnLegacy(provider) {
|
|
29
|
-
const p = String(provider).toLowerCase();
|
|
30
|
-
if (!LEGACY_PROVIDERS.has(p)) return;
|
|
31
|
-
console.log(`Aviso: "${p}" es legacy (tunel publico) y se retirara en la proxima version.`);
|
|
32
|
-
console.log('El camino recomendado es --tunnel tailscale (VPN privada).');
|
|
33
|
-
}
|
|
34
|
-
|
|
35
17
|
const OPTIONS = {
|
|
36
18
|
start: { type: 'boolean' },
|
|
37
19
|
reload: { type: 'boolean' },
|
|
@@ -51,6 +33,8 @@ const readBridgeState = () => bridgeState.read();
|
|
|
51
33
|
const writeBridgeState = (data) => bridgeState.write(data);
|
|
52
34
|
const clearBridgeState = () => bridgeState.clear();
|
|
53
35
|
|
|
36
|
+
const isTailscale = (provider) => provider === 'tailscale' || provider === 'ts';
|
|
37
|
+
|
|
54
38
|
export async function bridgeCommand(argv) {
|
|
55
39
|
const { values } = parse(argv, OPTIONS);
|
|
56
40
|
if (values.help) {
|
|
@@ -73,61 +57,42 @@ export async function bridgeCommand(argv) {
|
|
|
73
57
|
function resolve(values) {
|
|
74
58
|
ensureHome();
|
|
75
59
|
const app = ensureApp();
|
|
60
|
+
const requested = values.tunnel ? String(values.tunnel).toLowerCase() : '';
|
|
76
61
|
return {
|
|
77
62
|
port: num(values.port, app.port || 4517),
|
|
78
|
-
provider:
|
|
63
|
+
provider: requested || String(app.tunnel?.provider || 'tailscale').toLowerCase(),
|
|
79
64
|
app,
|
|
80
65
|
};
|
|
81
66
|
}
|
|
82
67
|
|
|
83
|
-
/** Asegura la configuracion del relay (url + token) en app.json y la devuelve. */
|
|
84
|
-
function ensureRelayConfig(app) {
|
|
85
|
-
const tunnel = { ...app.tunnel };
|
|
86
|
-
if (!tunnel.relayUrl) {
|
|
87
|
-
tunnel.relayUrl = process.env.TAMNORA_RELAY_URL || 'https://harness.tamnora.com/index.php';
|
|
88
|
-
}
|
|
89
|
-
if (!tunnel.token) {
|
|
90
|
-
tunnel.token = process.env.TAMNORA_RELAY_TOKEN || randomToken(24);
|
|
91
|
-
const current = readApp();
|
|
92
|
-
writeApp({ ...current, tunnel: { ...current.tunnel, token: tunnel.token, relayUrl: tunnel.relayUrl } });
|
|
93
|
-
console.log(`Token de relay nuevo (guardado en app.json): ${tunnel.token}`);
|
|
94
|
-
}
|
|
95
|
-
return tunnel;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
68
|
async function cmdStart(values) {
|
|
99
69
|
const { port, provider } = resolve(values);
|
|
100
70
|
if (provider === 'none') {
|
|
101
|
-
console.error('El
|
|
71
|
+
console.error('El acceso remoto esta desactivado (provider "none"). Usa --tunnel tailscale.');
|
|
72
|
+
return 1;
|
|
73
|
+
}
|
|
74
|
+
if (!isTailscale(provider)) {
|
|
75
|
+
console.error(`Proveedor no soportado: "${provider}". El acceso remoto usa Tailscale.`);
|
|
76
|
+
console.error('Los tuneles publicos (tunnelmole/relay) fueron retirados.');
|
|
102
77
|
return 1;
|
|
103
78
|
}
|
|
104
79
|
|
|
105
|
-
//
|
|
106
|
-
// (incluido /api/chat con tool bash): hay que pedirlo explicito.
|
|
80
|
+
// Por la red privada igual conviene un usuario: defensa en profundidad.
|
|
107
81
|
const app = ensureApp();
|
|
108
82
|
if (!app.user?.password && !values.insecure) {
|
|
109
|
-
console.error('No hay usuario configurado:
|
|
83
|
+
console.error('No hay usuario configurado: sin login cualquiera en tu tailnet tendria acceso total.');
|
|
110
84
|
console.error('Crea uno con: tamnora user --name <usuario> --password <clave>');
|
|
111
85
|
console.error('Si igual queres abrirlo sin proteccion: tamnora bridge --insecure');
|
|
112
86
|
return 1;
|
|
113
87
|
}
|
|
114
88
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
return 1;
|
|
123
|
-
}
|
|
124
|
-
} else {
|
|
125
|
-
warnLegacy(provider);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
if (provider === 'tunnelmole' || provider === 'tmole') {
|
|
129
|
-
console.log('Aviso: tunnelmole gratuito admite una sola conexion; si abriste el');
|
|
130
|
-
console.log('preview de la consola, cerralo (o el bridge) antes de usar el otro.');
|
|
89
|
+
try {
|
|
90
|
+
const status = await probeTailscale();
|
|
91
|
+
if (!status.connected) throw new Error('Tailscale no esta conectado. Inicia sesion con `tailscale up`.');
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.error(error.message);
|
|
94
|
+
console.error('Instala Tailscale: https://tailscale.com/download');
|
|
95
|
+
return 1;
|
|
131
96
|
}
|
|
132
97
|
|
|
133
98
|
const current = readBridgeState();
|
|
@@ -138,11 +103,11 @@ async function cmdStart(values) {
|
|
|
138
103
|
}
|
|
139
104
|
|
|
140
105
|
const bin = process.argv[1];
|
|
141
|
-
const args = [bin, 'bridge', '--foreground', '--port', String(port)
|
|
106
|
+
const args = [bin, 'bridge', '--foreground', '--port', String(port)];
|
|
142
107
|
if (process.env.TAMNORA_HOME) args.push('--home', process.env.TAMNORA_HOME);
|
|
143
108
|
if (values.verbose) args.push('--verbose');
|
|
144
109
|
const { child, isDead } = spawnDetached(args, { envFlag: 'TAMNORA_BRIDGE_CHILD' });
|
|
145
|
-
console.log(`Abriendo acceso remoto (
|
|
110
|
+
console.log(`Abriendo acceso remoto (tailscale) en el puerto ${port}...`);
|
|
146
111
|
|
|
147
112
|
const state = await waitForState(() => readBridgeState(), {
|
|
148
113
|
childPid: child.pid,
|
|
@@ -160,17 +125,16 @@ async function cmdStart(values) {
|
|
|
160
125
|
return 0;
|
|
161
126
|
}
|
|
162
127
|
|
|
163
|
-
/** URL publica para el celular: la consola agregada al relay con el token. */
|
|
164
|
-
function relayConsoleUrl(extra) {
|
|
165
|
-
const base = String(extra.relayUrl ?? '').replace(/\/+$/, '').replace(/index\.php$/, '').replace(/\/$/, '');
|
|
166
|
-
return `${base}/console/?t=${extra.token}`;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
128
|
/**
|
|
170
129
|
* Tailscale es configuracion persistente (`tailscale serve`), no un proceso:
|
|
171
130
|
* no hay health-check ni bucle de reconexion, solo esperar la senal de apagado.
|
|
172
131
|
*/
|
|
173
|
-
async function
|
|
132
|
+
async function runForeground(values) {
|
|
133
|
+
const { port, provider } = resolve(values);
|
|
134
|
+
if (!isTailscale(provider)) {
|
|
135
|
+
console.error(`Proveedor no soportado: "${provider}". El acceso remoto usa Tailscale.`);
|
|
136
|
+
return 1;
|
|
137
|
+
}
|
|
174
138
|
const log = createFileLogger(bridgeLogPath());
|
|
175
139
|
let tun;
|
|
176
140
|
try {
|
|
@@ -210,121 +174,9 @@ async function runTailscaleForeground(values, port) {
|
|
|
210
174
|
return 0;
|
|
211
175
|
}
|
|
212
176
|
|
|
213
|
-
async function runForeground(values) {
|
|
214
|
-
const { port, provider, app } = resolve(values);
|
|
215
|
-
if (provider === 'tailscale' || provider === 'ts') return runTailscaleForeground(values, port);
|
|
216
|
-
const log = createFileLogger(bridgeLogPath());
|
|
217
|
-
const extra = { provider };
|
|
218
|
-
if (provider === 'relay') {
|
|
219
|
-
const relay = ensureRelayConfig(app);
|
|
220
|
-
extra.relayUrl = relay.relayUrl;
|
|
221
|
-
extra.token = relay.token;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// Senal de apagado: el supervisor de abajo la consulta entre intentos.
|
|
225
|
-
let interrupted = false;
|
|
226
|
-
let notifyInterrupt = null;
|
|
227
|
-
const interruptPromise = new Promise((r) => {
|
|
228
|
-
notifyInterrupt = r;
|
|
229
|
-
});
|
|
230
|
-
const shutdown = () => {
|
|
231
|
-
interrupted = true;
|
|
232
|
-
notifyInterrupt();
|
|
233
|
-
};
|
|
234
|
-
process.once('SIGINT', shutdown);
|
|
235
|
-
process.once('SIGTERM', shutdown);
|
|
236
|
-
const sleep = (ms) =>
|
|
237
|
-
new Promise((r) => {
|
|
238
|
-
const t = setTimeout(r, ms);
|
|
239
|
-
interruptPromise.then(() => {
|
|
240
|
-
clearTimeout(t);
|
|
241
|
-
r();
|
|
242
|
-
});
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
// Supervision: si el proceso del tunel muere (el servicio gratuito lo hace a
|
|
246
|
-
// menudo), reconecta con backoff y reescribe el estado con la URL nueva.
|
|
247
|
-
let attempt = 0;
|
|
248
|
-
let tun;
|
|
249
|
-
for (;;) {
|
|
250
|
-
try {
|
|
251
|
-
tun = await startTunnel(port, provider, { log, verbose: values.verbose, ...extra });
|
|
252
|
-
} catch (err) {
|
|
253
|
-
if (interrupted) break;
|
|
254
|
-
log(`ERROR: ${err.message}`);
|
|
255
|
-
return 1;
|
|
256
|
-
}
|
|
257
|
-
if (!tun.url) {
|
|
258
|
-
log('ERROR: el tunel no devolvio URL.');
|
|
259
|
-
return 1;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
writeBridgeState({
|
|
263
|
-
pid: process.pid,
|
|
264
|
-
url: tun.url,
|
|
265
|
-
publicUrl: provider === 'relay' ? relayConsoleUrl(extra) : undefined,
|
|
266
|
-
provider: tun.provider,
|
|
267
|
-
port,
|
|
268
|
-
childPid: tun.pid,
|
|
269
|
-
startedAt: new Date().toISOString(),
|
|
270
|
-
});
|
|
271
|
-
printStatus(readBridgeState(), true);
|
|
272
|
-
log(`Tunel publico: ${tun.url}`);
|
|
273
|
-
attempt = 0;
|
|
274
|
-
|
|
275
|
-
const exitCode = await new Promise((r) => {
|
|
276
|
-
// Health-check cada 60 s: si la URL publica deja de responder dos veces
|
|
277
|
-
// seguidas, matamos el tunel y el supervisor abre otro.
|
|
278
|
-
let fails = 0;
|
|
279
|
-
const probe = async () => {
|
|
280
|
-
const probeUrl = tun.probeUrl ?? `${tun.url}/api/health`;
|
|
281
|
-
try {
|
|
282
|
-
const res = await fetch(probeUrl, { signal: AbortSignal.timeout(8000) });
|
|
283
|
-
if (res.ok) fails = 0;
|
|
284
|
-
else fails += 1;
|
|
285
|
-
} catch {
|
|
286
|
-
fails += 1;
|
|
287
|
-
}
|
|
288
|
-
if (fails >= 2) {
|
|
289
|
-
log(`La URL publica no responde (${tun.url}); reiniciando el tunel...`);
|
|
290
|
-
tun.stop();
|
|
291
|
-
}
|
|
292
|
-
};
|
|
293
|
-
const health = setInterval(probe, 60000);
|
|
294
|
-
health.unref?.();
|
|
295
|
-
tun.child?.once('exit', (code) => {
|
|
296
|
-
clearInterval(health);
|
|
297
|
-
r(code ?? 1);
|
|
298
|
-
});
|
|
299
|
-
if (!tun.child) {
|
|
300
|
-
clearInterval(health);
|
|
301
|
-
r(0);
|
|
302
|
-
}
|
|
303
|
-
interruptPromise.then(() => {
|
|
304
|
-
clearInterval(health);
|
|
305
|
-
r('signal');
|
|
306
|
-
});
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
if (interrupted || exitCode === 'signal') break;
|
|
310
|
-
attempt += 1;
|
|
311
|
-
const wait = Math.min(30000, 2000 * attempt);
|
|
312
|
-
log(`El tunel termino (codigo ${exitCode}); reconectando en ${Math.round(wait / 1000)} s...`);
|
|
313
|
-
await sleep(wait);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
try {
|
|
317
|
-
tun?.stop?.();
|
|
318
|
-
} catch {
|
|
319
|
-
/* nada */
|
|
320
|
-
}
|
|
321
|
-
clearBridgeState();
|
|
322
|
-
return 0;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
177
|
async function cmdReload(values) {
|
|
326
178
|
// Leer el estado ANTES de parar: clearBridgeState borra el archivo y despues
|
|
327
|
-
// no habria nada que esperar
|
|
179
|
+
// no habria nada que esperar.
|
|
328
180
|
const previous = readBridgeState();
|
|
329
181
|
await cmdStop();
|
|
330
182
|
for (let i = 0; i < 25 && previous?.pid && pidAlive(previous.pid); i++) {
|
|
@@ -336,29 +188,22 @@ async function cmdReload(values) {
|
|
|
336
188
|
async function cmdStop() {
|
|
337
189
|
const state = readBridgeState();
|
|
338
190
|
const stopped = [];
|
|
339
|
-
if (state
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
191
|
+
if (!state) {
|
|
192
|
+
console.log('El bridge no esta corriendo.');
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
await resetTailscaleServe();
|
|
197
|
+
stopped.push('tailscale serve');
|
|
198
|
+
} catch (error) {
|
|
199
|
+
console.log(`No pude ejecutar tailscale serve reset: ${error.message}`);
|
|
346
200
|
}
|
|
347
|
-
// Mata el supervisor
|
|
348
|
-
|
|
349
|
-
if (state?.pid && pidAlive(state.pid)) {
|
|
201
|
+
// Mata el supervisor si quedo vivo.
|
|
202
|
+
if (state.pid && pidAlive(state.pid)) {
|
|
350
203
|
killPid(state.pid);
|
|
351
204
|
stopped.push(`bridge ${state.pid}`);
|
|
352
205
|
}
|
|
353
|
-
if (state?.childPid && pidAlive(state.childPid)) {
|
|
354
|
-
killPid(state.childPid);
|
|
355
|
-
stopped.push(`tunel ${state.childPid}`);
|
|
356
|
-
}
|
|
357
206
|
clearBridgeState();
|
|
358
|
-
if (!stopped.length) {
|
|
359
|
-
console.log('El bridge no esta corriendo.');
|
|
360
|
-
return 0;
|
|
361
|
-
}
|
|
362
207
|
console.log(`Bridge detenido (${stopped.join(', ')}).`);
|
|
363
208
|
return 0;
|
|
364
209
|
}
|
|
@@ -374,18 +219,17 @@ async function cmdStatus() {
|
|
|
374
219
|
}
|
|
375
220
|
|
|
376
221
|
function printStatus(state, withQr = false) {
|
|
377
|
-
const shown = state.publicUrl || state.url;
|
|
378
222
|
console.log(`Bridge corriendo (pid ${state.pid})`);
|
|
379
|
-
console.log(` url : ${
|
|
223
|
+
console.log(` url : ${state.url}`);
|
|
380
224
|
console.log(` proveedor: ${state.provider} puerto local: ${state.port}`);
|
|
381
|
-
|
|
225
|
+
console.log(' red : privada (tailnet; solo tus dispositivos)');
|
|
382
226
|
console.log(` casa : ${homeDir()}`);
|
|
383
227
|
if (withQr && state.url) {
|
|
384
|
-
const art = qrTerminal(
|
|
228
|
+
const art = qrTerminal(state.url, 2);
|
|
385
229
|
if (art) {
|
|
386
230
|
console.log('');
|
|
387
231
|
console.log(art);
|
|
388
|
-
console.log(` escanea para abrir ${
|
|
232
|
+
console.log(` escanea para abrir ${state.url}`);
|
|
389
233
|
}
|
|
390
234
|
}
|
|
391
235
|
}
|
|
@@ -393,23 +237,20 @@ function printStatus(state, withQr = false) {
|
|
|
393
237
|
function printHelp() {
|
|
394
238
|
console.log(`Uso: tamnora bridge [accion] [opciones]
|
|
395
239
|
|
|
396
|
-
Da acceso remoto al server local y muestra la URL + QR.
|
|
240
|
+
Da acceso remoto al server local con Tailscale (VPN privada) y muestra la URL + QR.
|
|
397
241
|
|
|
398
242
|
Acciones:
|
|
399
243
|
--start Abre el acceso en segundo plano + QR (default)
|
|
400
244
|
--reload Reinicia
|
|
401
|
-
--stop Cierra
|
|
245
|
+
--stop Cierra (tailscale serve reset)
|
|
402
246
|
--status Estado y URL
|
|
403
247
|
--foreground Corre en primer plano (debug)
|
|
404
248
|
|
|
405
249
|
Opciones:
|
|
406
|
-
--
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
relay mailbox propio en harness.tamnora.com (legacy*)
|
|
410
|
-
|
|
411
|
-
Tailscale requiere instalarlo y estar logueado (https://tailscale.com/download).
|
|
250
|
+
--port <n> Puerto local a exponer (default: el del server)
|
|
251
|
+
--home <path> Casa de Tamnora (default: TAMNORA_HOME o datos del sistema)
|
|
252
|
+
--insecure Permitir el acceso sin usuario configurado (peligroso)
|
|
412
253
|
|
|
413
|
-
|
|
414
|
-
|
|
254
|
+
Requiere Tailscale instalado y logueado (https://tailscale.com/download) en la PC
|
|
255
|
+
y en el dispositivo remoto.`);
|
|
415
256
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { parseTunnelmoleUrls, parseTailscaleStatus } from '../src/lib/tunnel.js';
|
|
3
|
+
import { parseTunnelmoleUrls, parseTailscaleStatus, startTunnel } from '../src/lib/tunnel.js';
|
|
4
4
|
import { qrTerminal } from '../src/lib/qr.js';
|
|
5
5
|
|
|
6
6
|
test('parseTunnelmoleUrls extracts the https URL', () => {
|
|
@@ -28,6 +28,10 @@ test('parseTailscaleStatus marca desconectado sin DNSName', () => {
|
|
|
28
28
|
assert.equal(out.dnsName, '');
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
+
test('startTunnel rechaza el proveedor retirado (relay)', async () => {
|
|
32
|
+
await assert.rejects(() => startTunnel(4517, 'relay'), /no soportado/);
|
|
33
|
+
});
|
|
34
|
+
|
|
31
35
|
test('qrTerminal renders a block matrix for a URL', () => {
|
|
32
36
|
const art = qrTerminal('https://abc-123.tunnelmole.net', 1);
|
|
33
37
|
assert.ok(art.length > 0);
|
|
@@ -29,12 +29,10 @@ export function readApp() {
|
|
|
29
29
|
}
|
|
30
30
|
const app = { ...DEFAULT_APP, ...(data && typeof data === 'object' ? data : {}) };
|
|
31
31
|
app.tunnel = { ...DEFAULT_APP.tunnel, ...(app.tunnel || {}) };
|
|
32
|
-
// tunnelmole fue el default viejo y
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
if (app.tunnel.provider
|
|
36
|
-
app.tunnel.provider = 'tailscale';
|
|
37
|
-
}
|
|
32
|
+
// `tunnelmole` fue el default viejo y `relay` fue un proveedor retirado: lo
|
|
33
|
+
// persistido en instalaciones previas se migra a tailscale.
|
|
34
|
+
const legacy = ['tunnelmole', 'tmole', 'relay'];
|
|
35
|
+
if (legacy.includes(app.tunnel.provider)) app.tunnel.provider = 'tailscale';
|
|
38
36
|
return app;
|
|
39
37
|
}
|
|
40
38
|
|
|
@@ -6,11 +6,7 @@ import { pidAlive, killPidTree } from './pid.js';
|
|
|
6
6
|
/**
|
|
7
7
|
* Acceso remoto. Proveedores:
|
|
8
8
|
* - tailscale: VPN mesh privada + `tailscale serve` (recomendado; sin exposicion).
|
|
9
|
-
* - tunnelmole: tunel publico gratis (URL aleatoria)
|
|
10
|
-
* - relay: mailbox HTTP propio (harness.tamnora.com) — LEGACY.
|
|
11
|
-
*
|
|
12
|
-
* Los tuneles publicos se retiran en la proxima version. `tunnelmole` se
|
|
13
|
-
* conserva solo para el feature de `preview`; `startRelay` esta condenado.
|
|
9
|
+
* - tunnelmole: tunel publico gratis (URL aleatoria); solo para `preview`.
|
|
14
10
|
*/
|
|
15
11
|
|
|
16
12
|
function killTree(child) {
|
|
@@ -116,7 +112,7 @@ function spawnTunnel(bin, args, opts = {}) {
|
|
|
116
112
|
});
|
|
117
113
|
}
|
|
118
114
|
|
|
119
|
-
//
|
|
115
|
+
// Tunel publico para el feature `preview` (no lo usa `bridge`).
|
|
120
116
|
export function startTunnelmole(port, opts = {}) {
|
|
121
117
|
return spawnTunnel('npx', ['--yes', 'tunnelmole', String(port)], {
|
|
122
118
|
...opts,
|
|
@@ -127,13 +123,6 @@ export function startTunnelmole(port, opts = {}) {
|
|
|
127
123
|
});
|
|
128
124
|
}
|
|
129
125
|
|
|
130
|
-
/**
|
|
131
|
-
* Relay propio (harness.tamnora.com): mailbox HTTP sobre cPanel (solo PHP).
|
|
132
|
-
* El daemon no recibe conexiones: llama hacia afuera (a=poll), ejecuta cada
|
|
133
|
-
* request contra el server local y empuja las respuestas (a=push).
|
|
134
|
-
*
|
|
135
|
-
* LEGACY / condenado: se retira en la proxima version (usar tailscale).
|
|
136
|
-
*/
|
|
137
126
|
/**
|
|
138
127
|
* Tailscale: VPN mesh (WireGuard). `tailscale serve` publica HTTPS dentro del
|
|
139
128
|
* tailnet reenviando a loopback, asi que no hay exposicion publica y las
|
|
@@ -245,152 +234,11 @@ export function resetTailscaleServe() {
|
|
|
245
234
|
return runTailscale(['serve', 'reset'], { timeoutMs: 30000 });
|
|
246
235
|
}
|
|
247
236
|
|
|
248
|
-
export function startRelay(port, opts = {}) {
|
|
249
|
-
const relayUrl = String(opts.relayUrl ?? '').replace(/\/+$/, '');
|
|
250
|
-
const token = String(opts.token ?? '');
|
|
251
|
-
if (!relayUrl || !/^https?:\/\//.test(relayUrl)) {
|
|
252
|
-
throw new Error('relay: falta tunnel.relayUrl (https://harness.tamnora.com/index.php)');
|
|
253
|
-
}
|
|
254
|
-
if (!token) throw new Error('relay: falta tunnel.token');
|
|
255
|
-
const log = opts.log ?? (() => {});
|
|
256
|
-
const local = `http://127.0.0.1:${port}`;
|
|
257
|
-
const state = { stopped: false };
|
|
258
|
-
|
|
259
|
-
const call = async (action, body = {}, { timeoutMs = 30000 } = {}) => {
|
|
260
|
-
const res = await fetch(`${relayUrl}?a=${action}`, {
|
|
261
|
-
method: 'POST',
|
|
262
|
-
headers: { 'Content-Type': 'application/json' },
|
|
263
|
-
body: JSON.stringify({ token, ...body }),
|
|
264
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
265
|
-
});
|
|
266
|
-
if (!res.ok) throw new Error(`relay ${action}: HTTP ${res.status}`);
|
|
267
|
-
return res.json();
|
|
268
|
-
};
|
|
269
|
-
|
|
270
|
-
async function push(rid, chunks) {
|
|
271
|
-
await call('push', { rid, chunks }, { timeoutMs: 60000 });
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
async function handleRequest(row) {
|
|
275
|
-
const envelope = typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload;
|
|
276
|
-
const method = envelope.method ?? 'GET';
|
|
277
|
-
const path = String(envelope.path ?? '/');
|
|
278
|
-
const target = `${local}${path.startsWith('/') ? path : `/${path}`}`;
|
|
279
|
-
const init = { method, headers: { ...envelope.headers } };
|
|
280
|
-
delete init.headers.host;
|
|
281
|
-
if (envelope.body != null) init.body = envelope.body;
|
|
282
|
-
const res = await fetch(target, init);
|
|
283
|
-
const contentType = String(res.headers.get('content-type') ?? '');
|
|
284
|
-
const flat = {};
|
|
285
|
-
for (const header of ['content-type']) {
|
|
286
|
-
const value = res.headers.get(header);
|
|
287
|
-
if (value) flat[header] = value;
|
|
288
|
-
}
|
|
289
|
-
const cookies = res.headers.getSetCookie?.() ?? [];
|
|
290
|
-
if (cookies.length) flat['set-cookie'] = cookies;
|
|
291
|
-
|
|
292
|
-
if (!contentType.includes('text/event-stream')) {
|
|
293
|
-
const buffer = Buffer.from(await res.arrayBuffer());
|
|
294
|
-
await push(row.id, [
|
|
295
|
-
{
|
|
296
|
-
status: res.status,
|
|
297
|
-
headers: flat,
|
|
298
|
-
body: buffer.toString('base64'),
|
|
299
|
-
done: true,
|
|
300
|
-
},
|
|
301
|
-
]);
|
|
302
|
-
return;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// SSE: reenviamos cada frame tal cual (el cliente sabe reconectar con cursor).
|
|
306
|
-
let seq = 0;
|
|
307
|
-
await push(row.id, [{ seq: seq++, status: res.status, headers: flat, done: false }]);
|
|
308
|
-
const reader = res.body.getReader();
|
|
309
|
-
const decoder = new TextDecoder();
|
|
310
|
-
let buffer = '';
|
|
311
|
-
for (;;) {
|
|
312
|
-
const { value, done } = await reader.read();
|
|
313
|
-
if (done) break;
|
|
314
|
-
buffer += decoder.decode(value, { stream: true });
|
|
315
|
-
let index;
|
|
316
|
-
while ((index = buffer.indexOf('\n\n')) !== -1) {
|
|
317
|
-
const frame = buffer.slice(0, index);
|
|
318
|
-
buffer = buffer.slice(index + 2);
|
|
319
|
-
let ev = '';
|
|
320
|
-
let data = '';
|
|
321
|
-
let id = null;
|
|
322
|
-
for (const line of frame.split('\n')) {
|
|
323
|
-
if (line.startsWith('event:')) ev = line.slice(6).trim();
|
|
324
|
-
else if (line.startsWith('data:')) data += line.slice(5).trim();
|
|
325
|
-
else if (line.startsWith('id:')) id = line.slice(3).trim();
|
|
326
|
-
}
|
|
327
|
-
if (!ev && !data) continue;
|
|
328
|
-
await push(row.id, [{ seq: seq++, ev: ev || 'message', data, id, done: false }]);
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
await push(row.id, [{ done: true }]);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
async function pump() {
|
|
335
|
-
const registered = await call('register', {}, { timeoutMs: 20000 });
|
|
336
|
-
let cursor = Number(registered.cursor) || 0;
|
|
337
|
-
log(`relay: conectado a ${relayUrl} (inbox desde ${cursor})`);
|
|
338
|
-
while (!state.stopped) {
|
|
339
|
-
let rows;
|
|
340
|
-
try {
|
|
341
|
-
const batch = await call('poll', { after: cursor, wait: 20 }, { timeoutMs: 40000 });
|
|
342
|
-
rows = batch.rows ?? [];
|
|
343
|
-
} catch (error) {
|
|
344
|
-
if (state.stopped) break;
|
|
345
|
-
log(`relay poll: ${error?.message ?? error}`);
|
|
346
|
-
await new Promise((r) => setTimeout(r, 3000));
|
|
347
|
-
continue;
|
|
348
|
-
}
|
|
349
|
-
for (const row of rows) {
|
|
350
|
-
cursor = Math.max(cursor, Number(row.id) || 0);
|
|
351
|
-
try {
|
|
352
|
-
await handleRequest(row);
|
|
353
|
-
} catch (error) {
|
|
354
|
-
log(`relay request ${row.id}: ${error?.message ?? error}`);
|
|
355
|
-
try {
|
|
356
|
-
await push(row.id, [
|
|
357
|
-
{
|
|
358
|
-
status: 502,
|
|
359
|
-
headers: { 'content-type': 'application/json; charset=utf-8' },
|
|
360
|
-
body: Buffer.from(JSON.stringify({ error: `harness inaccesible: ${error?.message ?? error}` })).toString('base64'),
|
|
361
|
-
done: true,
|
|
362
|
-
},
|
|
363
|
-
]);
|
|
364
|
-
} catch {
|
|
365
|
-
/* nada */
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
pump().catch((error) => {
|
|
373
|
-
if (!state.stopped) log(`relay pump: ${error?.message ?? error}`);
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
return {
|
|
377
|
-
url: relayUrl,
|
|
378
|
-
provider: 'relay',
|
|
379
|
-
pid: 0,
|
|
380
|
-
child: null,
|
|
381
|
-
probeUrl: `${relayUrl}?a=status`,
|
|
382
|
-
stop() {
|
|
383
|
-
state.stopped = true;
|
|
384
|
-
},
|
|
385
|
-
};
|
|
386
|
-
}
|
|
387
|
-
|
|
388
237
|
/** Devuelve { url, provider, pid, stop }. provider 'none' no abre nada. */
|
|
389
238
|
export async function startTunnel(port, provider = 'tunnelmole', opts = {}) {
|
|
390
239
|
const p = String(provider || 'tunnelmole').toLowerCase();
|
|
391
240
|
if (p === 'none' || p === 'off' || p === '') return { url: '', provider: 'none', pid: 0, stop: () => {} };
|
|
392
241
|
if (p === 'tailscale' || p === 'ts') return startTailscaleServe(port, opts);
|
|
393
242
|
if (p === 'tunnelmole' || p === 'tmole') return startTunnelmole(port, opts);
|
|
394
|
-
if (p === 'relay') return startRelay(port, opts);
|
|
395
243
|
throw new Error(`proveedor de tunel no soportado: ${provider}`);
|
|
396
244
|
}
|
|
@@ -48,13 +48,15 @@ test('ensureApp creates app.json with a secret and roundtrips', async () => {
|
|
|
48
48
|
assert.equal(readApp().port, 4599);
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
-
test('readApp migra
|
|
51
|
+
test('readApp migra los proveedores retirados (tunnelmole/relay) a tailscale', async () => {
|
|
52
52
|
const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-home-'));
|
|
53
53
|
setHome(dir);
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
for (const provider of ['tunnelmole', 'relay']) {
|
|
55
|
+
const app = ensureApp();
|
|
56
|
+
app.tunnel = { ...app.tunnel, provider };
|
|
57
|
+
writeApp(app);
|
|
58
|
+
assert.equal(readApp().tunnel.provider, 'tailscale', provider);
|
|
59
|
+
}
|
|
58
60
|
});
|
|
59
61
|
|
|
60
62
|
test('migrateLegacyDb copies the legacy project database to the global home', async () => {
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
import { renderMarkdown, speechText } from './markdown.js';
|
|
4
4
|
import { streamSse, followRun } from './runStream.js';
|
|
5
5
|
import { diffState, pollDelayMs, localState, summarizeArgs } from './reconcile.js';
|
|
6
|
-
import { installRelayTransport, showRelayTokenGate } from './relayClient.js';
|
|
7
6
|
|
|
8
7
|
const $ = (sel) => document.querySelector(sel);
|
|
9
8
|
let csrfToken = '';
|
|
@@ -97,30 +96,7 @@ function icon(name, size = 16, className = '') {
|
|
|
97
96
|
|
|
98
97
|
/* --- HTTP / SSE ---------------------------------------------------------- */
|
|
99
98
|
|
|
100
|
-
// Tamnora Relay: cuando la consola se sirve desde harness.tamnora.com/console,
|
|
101
|
-
// el page define `window.TAMNORA_RELAY = { base }` y todo el trafico /api pasa
|
|
102
|
-
// por el mailbox del relay (relaClient). Sin esa variable es el harness local.
|
|
103
|
-
const RELAY_CONFIG = window.TAMNORA_RELAY ?? null;
|
|
104
|
-
const relayToken = RELAY_CONFIG
|
|
105
|
-
? new URLSearchParams(location.search).get('t') || localStorage.getItem('tamnora-relay-token') || ''
|
|
106
|
-
: '';
|
|
107
|
-
const relayTransport = RELAY_CONFIG
|
|
108
|
-
? installRelayTransport({ base: RELAY_CONFIG.base, token: relayToken })
|
|
109
|
-
: null;
|
|
110
|
-
|
|
111
|
-
if (RELAY_CONFIG && !relayTransport.enabled) {
|
|
112
|
-
showRelayTokenGate();
|
|
113
|
-
throw new Error('[relay] falta el token de acceso');
|
|
114
|
-
}
|
|
115
|
-
|
|
116
99
|
async function api(path, { method = 'GET', body, headers = {} } = {}) {
|
|
117
|
-
if (relayTransport?.enabled) {
|
|
118
|
-
return relayTransport.request(path, {
|
|
119
|
-
method,
|
|
120
|
-
body,
|
|
121
|
-
headers: method !== 'GET' && csrfToken ? { 'x-csrf': csrfToken, ...headers } : headers,
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
100
|
const init = {};
|
|
125
101
|
if (body) init['Content-Type'] = 'application/json';
|
|
126
102
|
if (method !== 'GET' && csrfToken) init['x-csrf'] = csrfToken;
|
|
@@ -145,13 +121,10 @@ async function api(path, { method = 'GET', body, headers = {} } = {}) {
|
|
|
145
121
|
}
|
|
146
122
|
|
|
147
123
|
const streamPost = (url, body, onEvent, signal) =>
|
|
148
|
-
|
|
149
|
-
? relayTransport.stream(url, { method: 'POST', body, signal, headers: csrfToken ? { 'x-csrf': csrfToken } : {} }, onEvent)
|
|
150
|
-
: streamSse(url, { method: 'POST', body, csrf: csrfToken, signal }, onEvent);
|
|
124
|
+
streamSse(url, { method: 'POST', body, csrf: csrfToken, signal }, onEvent);
|
|
151
125
|
|
|
152
126
|
/** SSE GET de la consola (/api/sessions/:id/stream, /api/events). */
|
|
153
|
-
const openStream = (url, opts, onEvent) =>
|
|
154
|
-
relayTransport?.enabled ? relayTransport.stream(url, opts, onEvent) : streamSse(url, opts, onEvent);
|
|
127
|
+
const openStream = (url, opts, onEvent) => streamSse(url, opts, onEvent);
|
|
155
128
|
|
|
156
129
|
/* --- localStorage (todas las lecturas/escrituras pasan por aca) ------------ */
|
|
157
130
|
|
|
@@ -601,7 +574,7 @@ if (logout) {
|
|
|
601
574
|
} catch {
|
|
602
575
|
// igual salimos
|
|
603
576
|
}
|
|
604
|
-
location.href =
|
|
577
|
+
location.href = '/landing';
|
|
605
578
|
});
|
|
606
579
|
}
|
|
607
580
|
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
</head>
|
|
38
38
|
<body>
|
|
39
39
|
<header class="topbar">
|
|
40
|
-
<div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span class="ver">v0.
|
|
40
|
+
<div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span class="ver">v0.11.0</span></div>
|
|
41
41
|
<nav class="tabs">
|
|
42
42
|
<button class="tab active" data-tab="chat">chat</button>
|
|
43
43
|
<button class="tab" data-tab="providers">proveedores</button>
|
package/package.json
CHANGED
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
// Transporte relay para la consola estatica (harness.tamnora.com/console/).
|
|
2
|
-
//
|
|
3
|
-
// La misma consola corre en el harness local y en el tamnora del sitio: aqui
|
|
4
|
-
// cada llamada a `api()` y cada stream se manda por el mailbox del relay y el
|
|
5
|
-
// daemon local (`tamnora bridge --tunnel relay`) la ejecuta contra su propio
|
|
6
|
-
// server. El token (?t=...) es la credencial compartida con el mailbox.
|
|
7
|
-
//
|
|
8
|
-
// Requiere runStream.js (streamSse/delay) que ya carga la consola.
|
|
9
|
-
|
|
10
|
-
import { streamSse, delay } from './runStream.js';
|
|
11
|
-
|
|
12
|
-
export function installRelayTransport({ base, token }) {
|
|
13
|
-
const cleanToken = String(token ?? '').trim();
|
|
14
|
-
if (!cleanToken) {
|
|
15
|
-
console.warn('[relay] sin token: la consola vera solo la pantalla de login');
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const b64decode = (value) =>
|
|
19
|
-
new TextDecoder().decode(Uint8Array.from(atob(String(value ?? '')), (c) => c.charCodeAt(0)));
|
|
20
|
-
|
|
21
|
-
const relayUrl = (action) => `${base}?a=${action}&t=${encodeURIComponent(cleanToken)}`;
|
|
22
|
-
|
|
23
|
-
async function post(action, payload, signal) {
|
|
24
|
-
const res = await fetch(relayUrl(action), {
|
|
25
|
-
method: 'POST',
|
|
26
|
-
headers: { 'Content-Type': 'application/json' },
|
|
27
|
-
body: JSON.stringify(payload),
|
|
28
|
-
signal,
|
|
29
|
-
});
|
|
30
|
-
if (!res.ok) {
|
|
31
|
-
const error = new Error(`relay HTTP ${res.status}`);
|
|
32
|
-
error.status = res.status;
|
|
33
|
-
throw error;
|
|
34
|
-
}
|
|
35
|
-
return res.json();
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function applyCookies(headers) {
|
|
39
|
-
for (const cookie of headers?.['set-cookie'] ?? []) {
|
|
40
|
-
const kv = String(cookie).split(';')[0];
|
|
41
|
-
if (kv && kv.includes('=')) document.cookie = `${kv}; Path=/`;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Request sincrono: PHP espera inline la respuesta completa del mailbox. */
|
|
46
|
-
async function request(path, { method = 'GET', body, headers = {} } = {}) {
|
|
47
|
-
const envelope = {
|
|
48
|
-
method,
|
|
49
|
-
path,
|
|
50
|
-
headers,
|
|
51
|
-
body: body === undefined ? null : JSON.stringify(body),
|
|
52
|
-
stream: false,
|
|
53
|
-
};
|
|
54
|
-
const data = await post('send', envelope);
|
|
55
|
-
if (data.error) {
|
|
56
|
-
const error = new Error(data.error);
|
|
57
|
-
error.status = Number(data.status) || 0;
|
|
58
|
-
throw error;
|
|
59
|
-
}
|
|
60
|
-
applyCookies(data.headers);
|
|
61
|
-
const text = b64decode(data.body);
|
|
62
|
-
let parsed = null;
|
|
63
|
-
try {
|
|
64
|
-
parsed = JSON.parse(text);
|
|
65
|
-
} catch {
|
|
66
|
-
parsed = { raw: text };
|
|
67
|
-
}
|
|
68
|
-
if (Number(data.status) >= 400) {
|
|
69
|
-
const error = new Error(parsed?.error ?? `HTTP ${data.status}`);
|
|
70
|
-
error.status = Number(data.status);
|
|
71
|
-
throw error;
|
|
72
|
-
}
|
|
73
|
-
return parsed;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const parseOrRaw = (text) => {
|
|
77
|
-
try {
|
|
78
|
-
return JSON.parse(text);
|
|
79
|
-
} catch {
|
|
80
|
-
return { raw: text };
|
|
81
|
-
}
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Stream via mailbox: abre el rid y consume `a=stream` (SSE con cursor).
|
|
86
|
-
* Si el PHP corta (max_execution_time) reconecta y repite desde el cursor:
|
|
87
|
-
* el replay es del mailbox, NO re-ejecuta el request original.
|
|
88
|
-
*/
|
|
89
|
-
async function stream(path, { method = 'POST', body, headers = {}, signal, silenceMs } = {}, onEvent) {
|
|
90
|
-
const envelope = {
|
|
91
|
-
method,
|
|
92
|
-
path,
|
|
93
|
-
headers,
|
|
94
|
-
body: body === undefined ? null : JSON.stringify(body),
|
|
95
|
-
stream: true,
|
|
96
|
-
};
|
|
97
|
-
const opened = await post('send', envelope, signal);
|
|
98
|
-
if (opened.error) {
|
|
99
|
-
const error = new Error(opened.error);
|
|
100
|
-
error.status = Number(opened.status) || 0;
|
|
101
|
-
throw error;
|
|
102
|
-
}
|
|
103
|
-
const rid = opened.rid;
|
|
104
|
-
let cursor = 0;
|
|
105
|
-
let attempt = 0;
|
|
106
|
-
for (;;) {
|
|
107
|
-
if (signal?.aborted) {
|
|
108
|
-
const error = new Error('Aborted', 'AbortError');
|
|
109
|
-
error.name = 'AbortError';
|
|
110
|
-
throw error;
|
|
111
|
-
}
|
|
112
|
-
try {
|
|
113
|
-
let finished = false;
|
|
114
|
-
await streamSse(
|
|
115
|
-
`${base}?a=stream&t=${encodeURIComponent(cleanToken)}&rid=${encodeURIComponent(rid)}&from=${cursor}`,
|
|
116
|
-
{ signal, silenceMs },
|
|
117
|
-
(event, data, id) => {
|
|
118
|
-
if (id != null) cursor = Number(id) || cursor;
|
|
119
|
-
if (event === 'chunk') {
|
|
120
|
-
const payload = data?.payload ?? {};
|
|
121
|
-
if (!payload.done) {
|
|
122
|
-
onEvent(payload.ev || 'message', payload.data != null ? parseOrRaw(payload.data) : {}, payload.id ?? null);
|
|
123
|
-
}
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
// meta: headers del run o cierre del daemon.
|
|
127
|
-
if (data?.payload?.done) finished = true;
|
|
128
|
-
else if (data?.payload?.error) {
|
|
129
|
-
const error = new Error(data.payload.error);
|
|
130
|
-
error.fatalRelay = true;
|
|
131
|
-
throw error;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
);
|
|
135
|
-
return { finished };
|
|
136
|
-
} catch (error) {
|
|
137
|
-
if (error?.name === 'AbortError' || error?.fatalRelay) throw error;
|
|
138
|
-
attempt += 1;
|
|
139
|
-
await delay(Math.min(8000, 1000 * 2 ** (attempt - 1)), signal);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
window.relayTransport = { token: cleanToken, base, request, stream, enabled: Boolean(cleanToken) };
|
|
145
|
-
return window.relayTransport;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Pantalla minima si no hay token: pide uno y recarga. */
|
|
149
|
-
export function showRelayTokenGate() {
|
|
150
|
-
document.body.innerHTML = '';
|
|
151
|
-
document.body.style.visibility = 'visible';
|
|
152
|
-
const form = document.createElement('form');
|
|
153
|
-
form.style.cssText = 'max-width:420px;margin:15vh auto;font-family:ui-sans-serif,system-ui;padding:0 16px';
|
|
154
|
-
form.innerHTML = `
|
|
155
|
-
<h1 style="font-size:20px">Tamnora Console</h1>
|
|
156
|
-
<p style="color:#777;font-size:14px">Pegá el token del bridge (o escaneá el QR del <code>tamnora bridge --tunnel relay</code>).</p>
|
|
157
|
-
<input name="t" placeholder="token" style="width:100%;padding:10px;font-size:14px" autocomplete="off" />
|
|
158
|
-
<button style="margin-top:10px;padding:10px 16px">entrar</button>`;
|
|
159
|
-
form.addEventListener('submit', (event) => {
|
|
160
|
-
event.preventDefault();
|
|
161
|
-
const value = form.elements.t.value.trim();
|
|
162
|
-
if (!value) return;
|
|
163
|
-
localStorage.setItem('tamnora-relay-token', value);
|
|
164
|
-
const params = new URLSearchParams(location.search);
|
|
165
|
-
params.delete('t');
|
|
166
|
-
location.href = location.pathname + (params.toString() ? `?${params}` : '');
|
|
167
|
-
location.reload();
|
|
168
|
-
});
|
|
169
|
-
document.body.append(form);
|
|
170
|
-
}
|
|
@@ -1,219 +0,0 @@
|
|
|
1
|
-
import { test } from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import { spawn } from 'node:child_process';
|
|
4
|
-
import { mkdtemp } from 'node:fs/promises';
|
|
5
|
-
import { execFileSync } from 'node:child_process';
|
|
6
|
-
import { tmpdir } from 'node:os';
|
|
7
|
-
import path from 'node:path';
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
import { createTamnoraServer } from '../src/index.js';
|
|
11
|
-
import { setHome, startRelay, randomToken } from '../../core/src/index.js';
|
|
12
|
-
|
|
13
|
-
const HAS_PHP = (() => {
|
|
14
|
-
try {
|
|
15
|
-
execFileSync('php', ['-v'], { stdio: 'ignore' });
|
|
16
|
-
return true;
|
|
17
|
-
} catch {
|
|
18
|
-
return false;
|
|
19
|
-
}
|
|
20
|
-
})();
|
|
21
|
-
|
|
22
|
-
const fakeProvider = {
|
|
23
|
-
id: 'fake',
|
|
24
|
-
async streamChat(_config, { onEvent }) {
|
|
25
|
-
onEvent?.({ type: 'text', delta: 'hola', full: 'hola' });
|
|
26
|
-
return { content: 'hola', reasoning: '', toolCalls: [] };
|
|
27
|
-
},
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
/** SSE crudo por GET crudo (fetch con streaming del lado Node). */
|
|
31
|
-
async function sseGet(url, { expectFrames = 1, timeoutMs = 30000 } = {}) {
|
|
32
|
-
const frames = [];
|
|
33
|
-
const controller = new AbortController();
|
|
34
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
35
|
-
try {
|
|
36
|
-
const res = await fetch(url, { signal: controller.signal });
|
|
37
|
-
const reader = res.body.getReader();
|
|
38
|
-
const decoder = new TextDecoder();
|
|
39
|
-
let buf = '';
|
|
40
|
-
while (frames.length < expectFrames) {
|
|
41
|
-
const { value, done } = await reader.read();
|
|
42
|
-
if (done) break;
|
|
43
|
-
buf += decoder.decode(value, { stream: true });
|
|
44
|
-
let index;
|
|
45
|
-
while ((index = buf.indexOf('\n\n')) !== -1) {
|
|
46
|
-
const frame = buf.slice(0, index);
|
|
47
|
-
buf = buf.slice(index + 2);
|
|
48
|
-
let event = null;
|
|
49
|
-
let data = '';
|
|
50
|
-
let id = null;
|
|
51
|
-
for (const line of frame.split('\n')) {
|
|
52
|
-
if (line.startsWith('event:')) event = line.slice(6).trim();
|
|
53
|
-
else if (line.startsWith('data:')) data += line.slice(5).trim();
|
|
54
|
-
else if (line.startsWith('id:')) id = line.slice(3).trim();
|
|
55
|
-
}
|
|
56
|
-
if (!event && !data) continue;
|
|
57
|
-
let parsed = {};
|
|
58
|
-
try {
|
|
59
|
-
parsed = JSON.parse(data);
|
|
60
|
-
} catch {
|
|
61
|
-
parsed = { raw: data };
|
|
62
|
-
}
|
|
63
|
-
frames.push({ event, data: parsed, id });
|
|
64
|
-
if (frames.length >= expectFrames) {
|
|
65
|
-
controller.abort();
|
|
66
|
-
break;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
} catch (error) {
|
|
71
|
-
if (error?.name !== 'AbortError' && frames.length < expectFrames) throw error;
|
|
72
|
-
} finally {
|
|
73
|
-
clearTimeout(timer);
|
|
74
|
-
}
|
|
75
|
-
return frames;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
async function phpServer(docroot) {
|
|
79
|
-
const port = 44000 + Math.floor(Math.random() * 4000);
|
|
80
|
-
const args = ['-S', `127.0.0.1:${port}`, '-t', docroot];
|
|
81
|
-
const child = spawn('php', args, {
|
|
82
|
-
env: { ...process.env },
|
|
83
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
84
|
-
windowsHide: true,
|
|
85
|
-
});
|
|
86
|
-
let stderr = '';
|
|
87
|
-
child.stderr.on('data', (d) => (stderr += String(d)));
|
|
88
|
-
let exited = null;
|
|
89
|
-
child.on('exit', (code) => (exited = code));
|
|
90
|
-
const base = `http://127.0.0.1:${port}/index.php`;
|
|
91
|
-
// esperar a que escuche
|
|
92
|
-
let lastError = '';
|
|
93
|
-
for (let i = 0; i < 60; i++) {
|
|
94
|
-
try {
|
|
95
|
-
await fetch(`${base}?a=status`, { signal: AbortSignal.timeout(1000) });
|
|
96
|
-
return { child, base };
|
|
97
|
-
} catch (error) {
|
|
98
|
-
lastError = error?.message ?? String(error);
|
|
99
|
-
if (exited !== null) break;
|
|
100
|
-
await new Promise((r) => setTimeout(r, 250));
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
throw new Error(
|
|
104
|
-
`php server no arranco: ${JSON.stringify({ docroot, exited, lastError, stderr: stderr.slice(0, 400) })}`
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* El PHP built-in en Windows es monohilo (PHP_CLI_SERVER_WORKERS no existe
|
|
110
|
-
* alla); send/poll largos se bloquearian mutuamente. Para el test corremos
|
|
111
|
-
* DOS servers sobre la misma SQLite (WAL es multi-proceso): el agente usa
|
|
112
|
-
* uno, el browser el otro. En cPanel con FPM esto no hace falta.
|
|
113
|
-
*/
|
|
114
|
-
async function relayStack(docroot) {
|
|
115
|
-
const agent = await phpServer(docroot);
|
|
116
|
-
const browser = await phpServer(docroot);
|
|
117
|
-
return { children: [agent.child, browser.child], relayBase: agent.base, browserBase: browser.base };
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
test('el relay PHP mailbox carga y hace GC', async (t) => {
|
|
121
|
-
if (!HAS_PHP) return t.skip('sin PHP disponible');
|
|
122
|
-
const docroot = path.join(import.meta.dirname, '..', '..', '..', '..', 'relay');
|
|
123
|
-
const { child, base } = await phpServer(docroot);
|
|
124
|
-
try {
|
|
125
|
-
const status = await (await fetch(`${base}?a=status`)).json();
|
|
126
|
-
assert.equal(status.ok, true);
|
|
127
|
-
|
|
128
|
-
const token = randomToken(24);
|
|
129
|
-
const reg = await (
|
|
130
|
-
await fetch(`${base}?a=register`, {
|
|
131
|
-
method: 'POST',
|
|
132
|
-
headers: { 'Content-Type': 'application/json' },
|
|
133
|
-
body: JSON.stringify({ token }),
|
|
134
|
-
})
|
|
135
|
-
).json();
|
|
136
|
-
assert.equal(reg.ok, true);
|
|
137
|
-
assert.equal(reg.cursor, 0);
|
|
138
|
-
|
|
139
|
-
const bad = await fetch(`${base}?a=send&t=${token}`, {
|
|
140
|
-
method: 'POST',
|
|
141
|
-
body: JSON.stringify({ path: 'nope' }),
|
|
142
|
-
});
|
|
143
|
-
assert.equal(bad.status, 400);
|
|
144
|
-
} finally {
|
|
145
|
-
child.kill('SIGTERM');
|
|
146
|
-
}
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
test('end-to-end: request no-stream y stream de chat via relay', async (t) => {
|
|
150
|
-
if (!HAS_PHP) return t.skip('sin PHP disponible');
|
|
151
|
-
|
|
152
|
-
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-relay-'));
|
|
153
|
-
setHome(cwd);
|
|
154
|
-
const instance = await createTamnoraServer({
|
|
155
|
-
cwd,
|
|
156
|
-
dbPath: path.join(cwd, 'tamnora.db'),
|
|
157
|
-
loadMcp: false,
|
|
158
|
-
resolveProvider: () => ({ provider: fakeProvider, config: {} }),
|
|
159
|
-
});
|
|
160
|
-
const local = await instance.listen({ port: 0 });
|
|
161
|
-
|
|
162
|
-
const docroot = path.join(import.meta.dirname, '..', '..', '..', '..', 'relay');
|
|
163
|
-
const { children, relayBase, browserBase } = await relayStack(docroot);
|
|
164
|
-
const token = randomToken(24);
|
|
165
|
-
const logs = [];
|
|
166
|
-
const pump = startRelay(local.port, { relayUrl: relayBase, token, log: (line) => logs.push(line) });
|
|
167
|
-
|
|
168
|
-
try {
|
|
169
|
-
// 1) request sincrono: /api/health pasa entero
|
|
170
|
-
const health = await (
|
|
171
|
-
await fetch(`${browserBase}?a=send&t=${token}`, {
|
|
172
|
-
method: 'POST',
|
|
173
|
-
headers: { 'Content-Type': 'application/json' },
|
|
174
|
-
body: JSON.stringify({ method: 'GET', path: '/api/health' }),
|
|
175
|
-
})
|
|
176
|
-
).json();
|
|
177
|
-
assert.equal(health.status, 200);
|
|
178
|
-
assert.equal(JSON.parse(Buffer.from(health.body, 'base64').toString('utf8')).ok, true);
|
|
179
|
-
|
|
180
|
-
// 2) crear sesion y correr un turno SSE via mailbox
|
|
181
|
-
const session = await (
|
|
182
|
-
await fetch(`${browserBase}?a=send&t=${token}`, {
|
|
183
|
-
method: 'POST',
|
|
184
|
-
headers: { 'Content-Type': 'application/json' },
|
|
185
|
-
body: JSON.stringify({ method: 'POST', path: '/api/sessions', body: JSON.stringify({ title: 'relay' }) }),
|
|
186
|
-
})
|
|
187
|
-
).json();
|
|
188
|
-
assert.equal(session.status, 201, `sesion: ${session.body && Buffer.from(session.body, 'base64').toString('utf8')}`);
|
|
189
|
-
const sessionId = JSON.parse(Buffer.from(session.body, 'base64').toString('utf8')).session.id;
|
|
190
|
-
|
|
191
|
-
const opened = await (
|
|
192
|
-
await fetch(`${browserBase}?a=send&t=${token}`, {
|
|
193
|
-
method: 'POST',
|
|
194
|
-
headers: { 'Content-Type': 'application/json' },
|
|
195
|
-
body: JSON.stringify({
|
|
196
|
-
method: 'POST',
|
|
197
|
-
path: '/api/chat',
|
|
198
|
-
body: JSON.stringify({ input: 'hola', sessionId, model: 'm' }),
|
|
199
|
-
stream: true,
|
|
200
|
-
}),
|
|
201
|
-
})
|
|
202
|
-
).json();
|
|
203
|
-
assert.ok(opened.rid, 'a=send stream devuelve rid');
|
|
204
|
-
|
|
205
|
-
const frames = await sseGet(`${browserBase}?a=stream&t=${token}&rid=${opened.rid}&from=0`, { expectFrames: 20 });
|
|
206
|
-
const events = frames.filter((f) => f.event === 'chunk').map((f) => f.data.payload?.ev);
|
|
207
|
-
assert.ok(events.includes('start'), `start en ${JSON.stringify(events)}`);
|
|
208
|
-
assert.ok(events.includes('text'), `text en ${JSON.stringify(events)}`);
|
|
209
|
-
const doneRow = frames.some((f) => f.event === 'meta' && f.data.payload?.done === true);
|
|
210
|
-
assert.ok(doneRow, 'la fila final done cierra el stream');
|
|
211
|
-
} catch (error) {
|
|
212
|
-
error.logs = logs.slice();
|
|
213
|
-
throw error;
|
|
214
|
-
} finally {
|
|
215
|
-
pump.stop();
|
|
216
|
-
for (const child of children) child.kill('SIGTERM');
|
|
217
|
-
await instance.close();
|
|
218
|
-
}
|
|
219
|
-
});
|