@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 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
- tamnora bridge --start # abre un tunel publico (tunnelmole) + QR
149
- tamnora bridge --status # URL publica
150
- tamnora bridge --reload # reinicia el tunel
151
- tamnora bridge --stop
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` abre un tunel (tunnelmole) hacia tu server local y muestra la
155
- URL publica **y un QR** para abrirlo desde el celular. Trabaja protegido por el
156
- login del server: configura un usuario antes de exponerlo (ver
157
- [`SECURITY.md`](SECURITY.md)).
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 tunel (tunnelmole)**: la URL publica es accesible desde
18
- Internet. Configura un **usuario** (asi toda `/api/*` pide sesion) con una
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
- (incluidos el explorador de archivos y el selector de carpetas): tratalo como
22
- acceso completo a tu maquina. Cierra el tunnel (`tamnora bridge --stop`) cuando
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 || 'tunnelmole').toLowerCase(),
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 tunel (${provider}) en el puerto ${port}...`);
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 tunel. Revisa: tamnora bridge --status');
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
- Abre un tunel publico (tunnelmole) hacia el server local para trabajar remoto.
396
+ Da acceso remoto al server local y muestra la URL + QR.
318
397
 
319
398
  Acciones:
320
- --start Abre el tunel en segundo plano + QR (default)
321
- --reload Reinicia el tunel
322
- --stop Cierra el tunel
323
- --status Estado y URL publica
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: tunnelmole o relay)
328
- --port <n> Puerto local a exponer (default: el del server)
329
- --home <path> Casa de Tamnora (default: TAMNORA_HOME o datos del sistema)
330
- --insecure Permitir el tunel sin usuario configurado (peligroso)
331
-
332
- Relay propio (app.json -> tunnel: { provider: "relay", relayUrl, token }): URLs
333
- estables via https://harness.tamnora.com; el QR abre la consola agregada con el
334
- token ya puesto.`);
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: 'tunnelmole', domain: '' },
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 { parseTunnelmoleUrls, startTunnelmole, startRelay, startTunnel, tunnelState, tunnelActive } from './tunnel.js';
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
- * Tuneles publicos. v1 soporta tunnelmole (gratis, sin cuenta; URL aleatoria)
8
- * via `npx --yes tunnelmole <puerto>`.
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
- if (parsed?.pid && pidAlive(parsed.pid)) state = parsed;
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.9.6</span></div>
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.9.6",
3
+ "version": "0.10.0",
4
4
  "description": "Harness local para IAs: agente CLI-first con TUI, consola web y evaluacion. Node puro, sin dependencias.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.25.0",