@danieltmn/tamnora 0.9.6 → 0.10.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 +18 -0
- package/README.md +26 -8
- package/SECURITY.md +11 -6
- package/harness/packages/cli/src/commands/bridge.js +98 -18
- package/harness/packages/cli/test/tunnel.test.js +16 -1
- package/harness/packages/core/src/config/app.js +7 -1
- package/harness/packages/core/src/index.js +12 -1
- package/harness/packages/core/src/tunnel.js +125 -3
- package/harness/packages/core/test/home.test.js +9 -0
- package/harness/packages/server/public/index.html +1 -1
- package/harness/packages/server/test/security.test.js +15 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,24 @@ 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.10.0] - 2026-10-09
|
|
9
|
+
|
|
10
|
+
### Agregado
|
|
11
|
+
|
|
12
|
+
- **Acceso remoto con Tailscale**: nuevo proveedor `tailscale` para `tamnora
|
|
13
|
+
bridge` (default). Levanta `tailscale serve` y publica HTTPS dentro del tailnet
|
|
14
|
+
reenviando a loopback, asi que la velocidad es casi local, no hay exposicion
|
|
15
|
+
publica y la consola + tools funcionan como en local (una sola base, sin sync).
|
|
16
|
+
`tunnelState()` ignora este proveedor para que los guards locales sigan
|
|
17
|
+
confiando; `bridge --start/--stop/--status` y el QR lo soportan.
|
|
18
|
+
|
|
19
|
+
### Obsoleto
|
|
20
|
+
|
|
21
|
+
- Los tuneles publicos `tunnelmole` y `relay` quedan como **legacy** (`bridge`
|
|
22
|
+
avisa al usarlos) y **se retiran en la proxima version**. Hasta entonces siguen
|
|
23
|
+
funcionales; el relay (`harness.tamnora.com`) continua online. `tunnelmole` se
|
|
24
|
+
conserva solo para el feature de `preview`.
|
|
25
|
+
|
|
8
26
|
## [0.9.6] - 2026-10-08
|
|
9
27
|
|
|
10
28
|
### Cambios
|
package/README.md
CHANGED
|
@@ -144,17 +144,35 @@ corre en modo local sin login. Al cambiar el usuario se detiene el server
|
|
|
144
144
|
|
|
145
145
|
### Acceso remoto
|
|
146
146
|
|
|
147
|
+
La via recomendada es **Tailscale** (VPN mesh privada, rapida y sin exponer nada
|
|
148
|
+
a Internet). El server sigue escuchando en `127.0.0.1`; `tailscale serve`
|
|
149
|
+
publica HTTPS dentro del tailnet y reenvia por loopback, asi que la consola y
|
|
150
|
+
todas las tools funcionan igual que en local, sin sincronizar datos (una sola
|
|
151
|
+
base SQLite).
|
|
152
|
+
|
|
147
153
|
```bash
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
tamnora
|
|
151
|
-
|
|
154
|
+
# 1. Instalar Tailscale en esta PC y en el dispositivo remoto (misma cuenta)
|
|
155
|
+
# https://tailscale.com/download
|
|
156
|
+
tamnora user --name yo --password unaClaveLarga
|
|
157
|
+
|
|
158
|
+
# 2. Server + acceso remoto
|
|
159
|
+
tamnora bridge --start # proveedor default: tailscale (URL + QR)
|
|
160
|
+
tamnora bridge --status # https://<pc>.<tailnet>.ts.net
|
|
161
|
+
tamnora bridge --stop # tailscale serve reset
|
|
152
162
|
```
|
|
153
163
|
|
|
154
|
-
`tamnora bridge`
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
164
|
+
`tamnora bridge --start` muestra la URL y un **QR** para abrirlo desde el celu.
|
|
165
|
+
Sigue protegido por el login del server: configura un usuario antes de exponerlo
|
|
166
|
+
(ver [`SECURITY.md`](SECURITY.md)).
|
|
167
|
+
|
|
168
|
+
Proveedores **legacy** (tuneles publicos; utiles solo si en el dispositivo remoto
|
|
169
|
+
no podes instalar Tailscale). **Se retiran en la proxima version**; `tunnelmole`
|
|
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
|
+
```
|
|
158
176
|
|
|
159
177
|
### Telegram (canales de mensajeria)
|
|
160
178
|
|
package/SECURITY.md
CHANGED
|
@@ -14,13 +14,18 @@ asi que se lo debe tratar como codigo con acceso a tu usuario.
|
|
|
14
14
|
|
|
15
15
|
- **Local (loopback)**: quien ya tiene tu sesion de usuario ya puede correr
|
|
16
16
|
comandos; el server no agrega una barrera adicional mas alla del login.
|
|
17
|
-
- **Acceso remoto por
|
|
18
|
-
|
|
17
|
+
- **Acceso remoto por Tailscale (recomendado)**: `tailscale serve` publica el
|
|
18
|
+
server **solo dentro de tu tailnet** y reenvia por loopback. No hay exposicion
|
|
19
|
+
publica; quien accede es uno de tus dispositivos. Aun asi configura un
|
|
20
|
+
**usuario** con contrasena fuerte (defensa en profundidad). Como entra por
|
|
21
|
+
loopback, una vez autenticado se alcanza toda la consola y las tools
|
|
22
|
+
(explorador de archivos, shell, git): tratalo como acceso completo a tu PC.
|
|
23
|
+
- **Tuneles publicos (legacy: tunnelmole / relay, se retiran en la proxima
|
|
24
|
+
version)**: la URL es accesible desde Internet. Configura un **usuario** con
|
|
19
25
|
**contrasena fuerte**; nunca expongas un server sin login. El tunnel reenvia
|
|
20
|
-
por loopback, de modo que una vez autenticado se alcanza toda la consola
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
no lo uses.
|
|
26
|
+
por loopback, de modo que una vez autenticado se alcanza toda la consola:
|
|
27
|
+
tratalo como acceso completo a tu maquina. Cierra el tunnel (`tamnora bridge
|
|
28
|
+
--stop`) cuando no lo uses.
|
|
24
29
|
- **Credenciales**: las API keys de proveedores se guardan en la base SQLite de
|
|
25
30
|
la casa local (fuera del repo) **en texto plano**; protege el archivo con los
|
|
26
31
|
permisos del sistema. Para no persistirlas, usa `"apiKey": "env:VAR"` y
|
|
@@ -18,9 +18,20 @@ import {
|
|
|
18
18
|
tailLines,
|
|
19
19
|
bridgeLogPath,
|
|
20
20
|
} from '../lib/daemon.js';
|
|
21
|
-
import { startTunnel } from '../lib/tunnel.js';
|
|
21
|
+
import { startTunnel, probeTailscale, resetTailscaleServe } from '../lib/tunnel.js';
|
|
22
22
|
import { qrTerminal } from '../lib/qr.js';
|
|
23
23
|
|
|
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
|
+
|
|
24
35
|
const OPTIONS = {
|
|
25
36
|
start: { type: 'boolean' },
|
|
26
37
|
reload: { type: 'boolean' },
|
|
@@ -64,7 +75,7 @@ function resolve(values) {
|
|
|
64
75
|
const app = ensureApp();
|
|
65
76
|
return {
|
|
66
77
|
port: num(values.port, app.port || 4517),
|
|
67
|
-
provider: String(values.tunnel || app.tunnel?.provider || '
|
|
78
|
+
provider: String(values.tunnel || app.tunnel?.provider || 'tailscale').toLowerCase(),
|
|
68
79
|
app,
|
|
69
80
|
};
|
|
70
81
|
}
|
|
@@ -87,7 +98,7 @@ function ensureRelayConfig(app) {
|
|
|
87
98
|
async function cmdStart(values) {
|
|
88
99
|
const { port, provider } = resolve(values);
|
|
89
100
|
if (provider === 'none') {
|
|
90
|
-
console.error('El tunel esta desactivado (provider "none"). Usa --tunnel tunnelmole.');
|
|
101
|
+
console.error('El tunel esta desactivado (provider "none"). Usa --tunnel tailscale (recomendado) o --tunnel tunnelmole.');
|
|
91
102
|
return 1;
|
|
92
103
|
}
|
|
93
104
|
|
|
@@ -100,6 +111,20 @@ async function cmdStart(values) {
|
|
|
100
111
|
console.error('Si igual queres abrirlo sin proteccion: tamnora bridge --insecure');
|
|
101
112
|
return 1;
|
|
102
113
|
}
|
|
114
|
+
|
|
115
|
+
if (provider === 'tailscale' || provider === 'ts') {
|
|
116
|
+
try {
|
|
117
|
+
const status = await probeTailscale();
|
|
118
|
+
if (!status.connected) throw new Error('Tailscale no esta conectado. Inicia sesion con `tailscale up`.');
|
|
119
|
+
} catch (error) {
|
|
120
|
+
console.error(error.message);
|
|
121
|
+
console.error('Instala Tailscale (https://tailscale.com/download) o usa --tunnel tunnelmole (legacy).');
|
|
122
|
+
return 1;
|
|
123
|
+
}
|
|
124
|
+
} else {
|
|
125
|
+
warnLegacy(provider);
|
|
126
|
+
}
|
|
127
|
+
|
|
103
128
|
if (provider === 'tunnelmole' || provider === 'tmole') {
|
|
104
129
|
console.log('Aviso: tunnelmole gratuito admite una sola conexion; si abriste el');
|
|
105
130
|
console.log('preview de la consola, cerralo (o el bridge) antes de usar el otro.');
|
|
@@ -117,7 +142,7 @@ async function cmdStart(values) {
|
|
|
117
142
|
if (process.env.TAMNORA_HOME) args.push('--home', process.env.TAMNORA_HOME);
|
|
118
143
|
if (values.verbose) args.push('--verbose');
|
|
119
144
|
const { child, isDead } = spawnDetached(args, { envFlag: 'TAMNORA_BRIDGE_CHILD' });
|
|
120
|
-
console.log(`Abriendo
|
|
145
|
+
console.log(`Abriendo acceso remoto (${provider}) en el puerto ${port}...`);
|
|
121
146
|
|
|
122
147
|
const state = await waitForState(() => readBridgeState(), {
|
|
123
148
|
childPid: child.pid,
|
|
@@ -128,7 +153,7 @@ async function cmdStart(values) {
|
|
|
128
153
|
if (!state) {
|
|
129
154
|
const tail = tailLines(bridgeLogPath(), 8);
|
|
130
155
|
if (tail) console.log(tail);
|
|
131
|
-
console.log('No pude abrir el
|
|
156
|
+
console.log('No pude abrir el acceso remoto. Revisa: tamnora bridge --status');
|
|
132
157
|
return 1;
|
|
133
158
|
}
|
|
134
159
|
printStatus(state, true);
|
|
@@ -141,8 +166,53 @@ function relayConsoleUrl(extra) {
|
|
|
141
166
|
return `${base}/console/?t=${extra.token}`;
|
|
142
167
|
}
|
|
143
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Tailscale es configuracion persistente (`tailscale serve`), no un proceso:
|
|
171
|
+
* no hay health-check ni bucle de reconexion, solo esperar la senal de apagado.
|
|
172
|
+
*/
|
|
173
|
+
async function runTailscaleForeground(values, port) {
|
|
174
|
+
const log = createFileLogger(bridgeLogPath());
|
|
175
|
+
let tun;
|
|
176
|
+
try {
|
|
177
|
+
tun = await startTunnel(port, 'tailscale', { log, verbose: values.verbose });
|
|
178
|
+
} catch (error) {
|
|
179
|
+
log(`ERROR: ${error.message}`);
|
|
180
|
+
console.error(error.message);
|
|
181
|
+
return 1;
|
|
182
|
+
}
|
|
183
|
+
writeBridgeState({
|
|
184
|
+
pid: process.pid,
|
|
185
|
+
url: tun.url,
|
|
186
|
+
provider: 'tailscale',
|
|
187
|
+
port,
|
|
188
|
+
childPid: 0,
|
|
189
|
+
startedAt: new Date().toISOString(),
|
|
190
|
+
});
|
|
191
|
+
printStatus(readBridgeState(), true);
|
|
192
|
+
log(`Tailscale: ${tun.url}`);
|
|
193
|
+
// Sin proceso hijo ni timers, el event loop quedaria vacio y node saldria:
|
|
194
|
+
// un timer referenced mantiene vivo al supervisor hasta la senal de apagado.
|
|
195
|
+
await new Promise((resolve) => {
|
|
196
|
+
const keepAlive = setInterval(() => {}, 1 << 30);
|
|
197
|
+
const stop = () => {
|
|
198
|
+
clearInterval(keepAlive);
|
|
199
|
+
resolve();
|
|
200
|
+
};
|
|
201
|
+
process.once('SIGINT', stop);
|
|
202
|
+
process.once('SIGTERM', stop);
|
|
203
|
+
});
|
|
204
|
+
try {
|
|
205
|
+
await tun.stop?.();
|
|
206
|
+
} catch {
|
|
207
|
+
/* nada */
|
|
208
|
+
}
|
|
209
|
+
clearBridgeState();
|
|
210
|
+
return 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
144
213
|
async function runForeground(values) {
|
|
145
214
|
const { port, provider, app } = resolve(values);
|
|
215
|
+
if (provider === 'tailscale' || provider === 'ts') return runTailscaleForeground(values, port);
|
|
146
216
|
const log = createFileLogger(bridgeLogPath());
|
|
147
217
|
const extra = { provider };
|
|
148
218
|
if (provider === 'relay') {
|
|
@@ -266,6 +336,14 @@ async function cmdReload(values) {
|
|
|
266
336
|
async function cmdStop() {
|
|
267
337
|
const state = readBridgeState();
|
|
268
338
|
const stopped = [];
|
|
339
|
+
if (state?.provider === 'tailscale') {
|
|
340
|
+
try {
|
|
341
|
+
await resetTailscaleServe();
|
|
342
|
+
stopped.push('tailscale serve');
|
|
343
|
+
} catch (error) {
|
|
344
|
+
console.log(`No pude ejecutar tailscale serve reset: ${error.message}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
269
347
|
// Mata el supervisor y tambien el proceso del tunel (aunque el supervisor
|
|
270
348
|
// haya muerto y haya quedado huerfano).
|
|
271
349
|
if (state?.pid && pidAlive(state.pid)) {
|
|
@@ -300,6 +378,7 @@ function printStatus(state, withQr = false) {
|
|
|
300
378
|
console.log(`Bridge corriendo (pid ${state.pid})`);
|
|
301
379
|
console.log(` url : ${shown}`);
|
|
302
380
|
console.log(` proveedor: ${state.provider} puerto local: ${state.port}`);
|
|
381
|
+
if (state.provider === 'tailscale') console.log(' red : privada (tailnet; solo tus dispositivos)');
|
|
303
382
|
console.log(` casa : ${homeDir()}`);
|
|
304
383
|
if (withQr && state.url) {
|
|
305
384
|
const art = qrTerminal(shown, 2);
|
|
@@ -314,22 +393,23 @@ function printStatus(state, withQr = false) {
|
|
|
314
393
|
function printHelp() {
|
|
315
394
|
console.log(`Uso: tamnora bridge [accion] [opciones]
|
|
316
395
|
|
|
317
|
-
|
|
396
|
+
Da acceso remoto al server local y muestra la URL + QR.
|
|
318
397
|
|
|
319
398
|
Acciones:
|
|
320
|
-
--start Abre el
|
|
321
|
-
--reload Reinicia
|
|
322
|
-
--stop Cierra
|
|
323
|
-
--status Estado y URL
|
|
399
|
+
--start Abre el acceso en segundo plano + QR (default)
|
|
400
|
+
--reload Reinicia
|
|
401
|
+
--stop Cierra
|
|
402
|
+
--status Estado y URL
|
|
324
403
|
--foreground Corre en primer plano (debug)
|
|
325
404
|
|
|
326
405
|
Opciones:
|
|
327
|
-
--tunnel <prov> Proveedor (default: el de app.json; hoy:
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
406
|
+
--tunnel <prov> Proveedor (default: el de app.json; hoy: tailscale)
|
|
407
|
+
tailscale VPN privada via \`tailscale serve\` (recomendado)
|
|
408
|
+
tunnelmole tunel publico gratis, URL aleatoria (legacy*)
|
|
409
|
+
relay mailbox propio en harness.tamnora.com (legacy*)
|
|
410
|
+
|
|
411
|
+
Tailscale requiere instalarlo y estar logueado (https://tailscale.com/download).
|
|
412
|
+
|
|
413
|
+
* Los tuneles publicos (tunnelmole/relay) se retiran en la proxima version.
|
|
414
|
+
tunnelmole se conserva solo para el feature de "preview".`);
|
|
335
415
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { parseTunnelmoleUrls } from '../src/lib/tunnel.js';
|
|
3
|
+
import { parseTunnelmoleUrls, parseTailscaleStatus } from '../src/lib/tunnel.js';
|
|
4
4
|
import { qrTerminal } from '../src/lib/qr.js';
|
|
5
5
|
|
|
6
6
|
test('parseTunnelmoleUrls extracts the https URL', () => {
|
|
@@ -13,6 +13,21 @@ test('parseTunnelmoleUrls ignores unrelated output', () => {
|
|
|
13
13
|
assert.deepEqual(parseTunnelmoleUrls('starting tunnelmole...'), { https: '', http: '' });
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
+
test('parseTailscaleStatus lee el DNSName del nodo y su IPv4', () => {
|
|
17
|
+
const out = parseTailscaleStatus(
|
|
18
|
+
JSON.stringify({ Self: { DNSName: 'pc.tailnet.ts.net.', TailscaleIPs: ['100.64.0.5', 'fd7a:115c:a1e0::5'] } })
|
|
19
|
+
);
|
|
20
|
+
assert.equal(out.dnsName, 'pc.tailnet.ts.net');
|
|
21
|
+
assert.equal(out.ipv4, '100.64.0.5');
|
|
22
|
+
assert.ok(out.connected);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('parseTailscaleStatus marca desconectado sin DNSName', () => {
|
|
26
|
+
const out = parseTailscaleStatus(JSON.stringify({ Self: { TailscaleIPs: [] } }));
|
|
27
|
+
assert.equal(out.connected, false);
|
|
28
|
+
assert.equal(out.dnsName, '');
|
|
29
|
+
});
|
|
30
|
+
|
|
16
31
|
test('qrTerminal renders a block matrix for a URL', () => {
|
|
17
32
|
const art = qrTerminal('https://abc-123.tunnelmole.net', 1);
|
|
18
33
|
assert.ok(art.length > 0);
|
|
@@ -9,7 +9,7 @@ import { appConfigPath, ensureHome } from './home.js';
|
|
|
9
9
|
export const DEFAULT_APP = {
|
|
10
10
|
port: 4517,
|
|
11
11
|
host: '127.0.0.1',
|
|
12
|
-
tunnel: { provider: '
|
|
12
|
+
tunnel: { provider: 'tailscale', domain: '' },
|
|
13
13
|
authSecret: '',
|
|
14
14
|
user: null,
|
|
15
15
|
// Datos del usuario para que el agente lo conozca (se inyecta en el prompt).
|
|
@@ -29,6 +29,12 @@ 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 quedo persistido en instalaciones previas:
|
|
33
|
+
// ahora es legacy, asi que se migra a tailscale (seguis pudiendo forzar
|
|
34
|
+
// `--tunnel tunnelmole` en cada corrida).
|
|
35
|
+
if (app.tunnel.provider === 'tunnelmole' || app.tunnel.provider === 'tmole') {
|
|
36
|
+
app.tunnel.provider = 'tailscale';
|
|
37
|
+
}
|
|
32
38
|
return app;
|
|
33
39
|
}
|
|
34
40
|
|
|
@@ -94,7 +94,18 @@ export {
|
|
|
94
94
|
registryServerToConfig,
|
|
95
95
|
normalizeMcpConfig,
|
|
96
96
|
} from './mcp/registry.js';
|
|
97
|
-
export {
|
|
97
|
+
export {
|
|
98
|
+
parseTunnelmoleUrls,
|
|
99
|
+
parseTailscaleStatus,
|
|
100
|
+
probeTailscale,
|
|
101
|
+
resetTailscaleServe,
|
|
102
|
+
startTailscaleServe,
|
|
103
|
+
startTunnelmole,
|
|
104
|
+
startRelay,
|
|
105
|
+
startTunnel,
|
|
106
|
+
tunnelState,
|
|
107
|
+
tunnelActive,
|
|
108
|
+
} from './tunnel.js';
|
|
98
109
|
export {
|
|
99
110
|
TRUSTED_CLIENT_TOKEN,
|
|
100
111
|
EDGE_VERSION,
|
|
@@ -4,8 +4,13 @@ import { bridgeStatePath } from './config/home.js';
|
|
|
4
4
|
import { pidAlive, killPidTree } from './pid.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Acceso remoto. Proveedores:
|
|
8
|
+
* - tailscale: VPN mesh privada + `tailscale serve` (recomendado; sin exposicion).
|
|
9
|
+
* - tunnelmole: tunel publico gratis (URL aleatoria) — LEGACY.
|
|
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
14
|
*/
|
|
10
15
|
|
|
11
16
|
function killTree(child) {
|
|
@@ -32,7 +37,9 @@ export function tunnelState() {
|
|
|
32
37
|
let state = null;
|
|
33
38
|
try {
|
|
34
39
|
const parsed = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
35
|
-
|
|
40
|
+
// Tailscale es una red privada que entra por loopback: no es un tunel
|
|
41
|
+
// publico, asi que no debe degradar los guards locales (`localOk`).
|
|
42
|
+
if (parsed?.provider !== 'tailscale' && parsed?.pid && pidAlive(parsed.pid)) state = parsed;
|
|
36
43
|
} catch {
|
|
37
44
|
/* sin bridge */
|
|
38
45
|
}
|
|
@@ -109,6 +116,7 @@ function spawnTunnel(bin, args, opts = {}) {
|
|
|
109
116
|
});
|
|
110
117
|
}
|
|
111
118
|
|
|
119
|
+
// LEGACY: proveedor legacy de `bridge`; se conserva para el feature `preview`.
|
|
112
120
|
export function startTunnelmole(port, opts = {}) {
|
|
113
121
|
return spawnTunnel('npx', ['--yes', 'tunnelmole', String(port)], {
|
|
114
122
|
...opts,
|
|
@@ -123,7 +131,120 @@ export function startTunnelmole(port, opts = {}) {
|
|
|
123
131
|
* Relay propio (harness.tamnora.com): mailbox HTTP sobre cPanel (solo PHP).
|
|
124
132
|
* El daemon no recibe conexiones: llama hacia afuera (a=poll), ejecuta cada
|
|
125
133
|
* request contra el server local y empuja las respuestas (a=push).
|
|
134
|
+
*
|
|
135
|
+
* LEGACY / condenado: se retira en la proxima version (usar tailscale).
|
|
126
136
|
*/
|
|
137
|
+
/**
|
|
138
|
+
* Tailscale: VPN mesh (WireGuard). `tailscale serve` publica HTTPS dentro del
|
|
139
|
+
* tailnet reenviando a loopback, asi que no hay exposicion publica y las
|
|
140
|
+
* peticiones siguen siendo "locales" para el server. Proveedor recomendado.
|
|
141
|
+
*/
|
|
142
|
+
function runTailscale(args, opts = {}) {
|
|
143
|
+
const timeoutMs = opts.timeoutMs || 15000;
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
let child;
|
|
146
|
+
try {
|
|
147
|
+
child = spawn('tailscale', args, {
|
|
148
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
149
|
+
windowsHide: true,
|
|
150
|
+
shell: process.platform === 'win32',
|
|
151
|
+
});
|
|
152
|
+
} catch (err) {
|
|
153
|
+
reject(err);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
let out = '';
|
|
157
|
+
let err = '';
|
|
158
|
+
let settled = false;
|
|
159
|
+
const timer = setTimeout(() => {
|
|
160
|
+
if (settled) return;
|
|
161
|
+
settled = true;
|
|
162
|
+
killTree(child);
|
|
163
|
+
reject(new Error(`tailscale ${args.join(' ')} no respondio (${Math.round(timeoutMs / 1000)} s).`));
|
|
164
|
+
}, timeoutMs);
|
|
165
|
+
child.stdout.on('data', (data) => {
|
|
166
|
+
out += String(data);
|
|
167
|
+
});
|
|
168
|
+
child.stderr.on('data', (data) => {
|
|
169
|
+
err += String(data);
|
|
170
|
+
});
|
|
171
|
+
child.on('error', (error) => {
|
|
172
|
+
if (settled) return;
|
|
173
|
+
settled = true;
|
|
174
|
+
clearTimeout(timer);
|
|
175
|
+
reject(new Error(`no se pudo ejecutar tailscale (${error.message}). Instalalo: https://tailscale.com/download`));
|
|
176
|
+
});
|
|
177
|
+
child.on('exit', (code) => {
|
|
178
|
+
if (settled) return;
|
|
179
|
+
settled = true;
|
|
180
|
+
clearTimeout(timer);
|
|
181
|
+
if (code === 0) resolve({ stdout: out, stderr: err });
|
|
182
|
+
else {
|
|
183
|
+
const detail = err.trim() ? `: ${err.trim().slice(0, 200)}` : '';
|
|
184
|
+
reject(new Error(`tailscale ${args.join(' ')} fallo (codigo ${code})${detail}`));
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Interpreta `tailscale status --json`: nombre DNS del nodo e IPv4 del tailnet. */
|
|
191
|
+
export function parseTailscaleStatus(json) {
|
|
192
|
+
const data = typeof json === 'string' ? JSON.parse(json) : json ?? {};
|
|
193
|
+
const dnsName = String(data?.Self?.DNSName ?? '').replace(/\.$/, '');
|
|
194
|
+
const ipv4 = String((data?.Self?.TailscaleIPs ?? []).find((ip) => !String(ip).includes(':')) ?? '');
|
|
195
|
+
return { dnsName, ipv4, connected: Boolean(dnsName) };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function startTailscaleServe(port, opts = {}) {
|
|
199
|
+
const log = opts.log ?? (() => {});
|
|
200
|
+
let status;
|
|
201
|
+
try {
|
|
202
|
+
status = await runTailscale(['status', '--json']);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
throw new Error(`tailscale no esta disponible: ${error.message}`);
|
|
205
|
+
}
|
|
206
|
+
let parsed;
|
|
207
|
+
try {
|
|
208
|
+
parsed = parseTailscaleStatus(status.stdout);
|
|
209
|
+
} catch {
|
|
210
|
+
throw new Error('no pude interpretar `tailscale status --json`.');
|
|
211
|
+
}
|
|
212
|
+
if (!parsed.connected) {
|
|
213
|
+
throw new Error('Tailscale no esta conectado. Inicia sesion con `tailscale up`.');
|
|
214
|
+
}
|
|
215
|
+
const backend = `http://127.0.0.1:${port}`;
|
|
216
|
+
try {
|
|
217
|
+
await runTailscale(['serve', '--bg', backend], { timeoutMs: 30000 });
|
|
218
|
+
} catch (first) {
|
|
219
|
+
try {
|
|
220
|
+
await runTailscale(['serve', '--bg', String(port)], { timeoutMs: 30000 });
|
|
221
|
+
} catch {
|
|
222
|
+
throw new Error(`no pude publicar con \`tailscale serve\`: ${first.message}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const url = `https://${parsed.dnsName}`;
|
|
226
|
+
log(`tailscale: ${url} -> ${backend}`);
|
|
227
|
+
return {
|
|
228
|
+
url,
|
|
229
|
+
provider: 'tailscale',
|
|
230
|
+
pid: 0,
|
|
231
|
+
child: null,
|
|
232
|
+
probeUrl: `${url}/api/health`,
|
|
233
|
+
stop: () => runTailscale(['serve', 'reset']).catch(() => {}),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Estado de Tailscale (lanza si no esta instalado o no responde). */
|
|
238
|
+
export async function probeTailscale() {
|
|
239
|
+
const { stdout } = await runTailscale(['status', '--json']);
|
|
240
|
+
return parseTailscaleStatus(stdout);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Quita toda la configuracion de `tailscale serve` de este nodo. */
|
|
244
|
+
export function resetTailscaleServe() {
|
|
245
|
+
return runTailscale(['serve', 'reset'], { timeoutMs: 30000 });
|
|
246
|
+
}
|
|
247
|
+
|
|
127
248
|
export function startRelay(port, opts = {}) {
|
|
128
249
|
const relayUrl = String(opts.relayUrl ?? '').replace(/\/+$/, '');
|
|
129
250
|
const token = String(opts.token ?? '');
|
|
@@ -268,6 +389,7 @@ export function startRelay(port, opts = {}) {
|
|
|
268
389
|
export async function startTunnel(port, provider = 'tunnelmole', opts = {}) {
|
|
269
390
|
const p = String(provider || 'tunnelmole').toLowerCase();
|
|
270
391
|
if (p === 'none' || p === 'off' || p === '') return { url: '', provider: 'none', pid: 0, stop: () => {} };
|
|
392
|
+
if (p === 'tailscale' || p === 'ts') return startTailscaleServe(port, opts);
|
|
271
393
|
if (p === 'tunnelmole' || p === 'tmole') return startTunnelmole(port, opts);
|
|
272
394
|
if (p === 'relay') return startRelay(port, opts);
|
|
273
395
|
throw new Error(`proveedor de tunel no soportado: ${provider}`);
|
|
@@ -48,6 +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 un tunnelmole guardado a tailscale (legacy)', async () => {
|
|
52
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-home-'));
|
|
53
|
+
setHome(dir);
|
|
54
|
+
const app = ensureApp();
|
|
55
|
+
app.tunnel = { ...app.tunnel, provider: 'tunnelmole' };
|
|
56
|
+
writeApp(app);
|
|
57
|
+
assert.equal(readApp().tunnel.provider, 'tailscale');
|
|
58
|
+
});
|
|
59
|
+
|
|
51
60
|
test('migrateLegacyDb copies the legacy project database to the global home', async () => {
|
|
52
61
|
const home = await mkdtemp(path.join(tmpdir(), 'tamnora-home-'));
|
|
53
62
|
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-cwd-'));
|
|
@@ -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.10.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>
|
|
@@ -74,6 +74,21 @@ test('localOk exige loopback en remoteAddress y sin tunel activo', async () => {
|
|
|
74
74
|
}
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
+
test('un bridge de tailscale no degrada localOk (red privada por loopback)', async () => {
|
|
78
|
+
const { instance } = await startServer();
|
|
79
|
+
try {
|
|
80
|
+
await writeFile(
|
|
81
|
+
bridgeStatePath(),
|
|
82
|
+
`${JSON.stringify({ pid: process.pid, url: 'https://pc.tailnet.ts.net', provider: 'tailscale', port: 4517 })}\n`
|
|
83
|
+
);
|
|
84
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
85
|
+
assert.ok(localOk({ socket: { remoteAddress: '127.0.0.1' } }), 'loopback sigue siendo local con tailscale');
|
|
86
|
+
} finally {
|
|
87
|
+
await clearTunnel();
|
|
88
|
+
await instance.close();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
77
92
|
test('POST /api/shell responde 403 mientras el tunel esta activo', async () => {
|
|
78
93
|
const { instance, base } = await startServer();
|
|
79
94
|
try {
|
package/package.json
CHANGED