@danieltmn/tamnora 0.9.6 → 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 +29 -0
- package/README.md +19 -8
- package/SECURITY.md +10 -7
- package/harness/packages/cli/src/cli.js +1 -1
- package/harness/packages/cli/src/commands/bridge.js +87 -166
- package/harness/packages/cli/test/tunnel.test.js +20 -1
- package/harness/packages/core/src/config/app.js +5 -1
- package/harness/packages/core/src/index.js +11 -1
- package/harness/packages/core/src/tunnel.js +105 -135
- package/harness/packages/core/test/home.test.js +11 -0
- package/harness/packages/server/public/app.js +3 -30
- 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/harness/packages/server/public/relayClient.js +0 -170
- package/harness/packages/server/test/relay.test.js +0 -219
|
@@ -4,8 +4,9 @@ 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); solo para `preview`.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
function killTree(child) {
|
|
@@ -32,7 +33,9 @@ export function tunnelState() {
|
|
|
32
33
|
let state = null;
|
|
33
34
|
try {
|
|
34
35
|
const parsed = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
35
|
-
|
|
36
|
+
// Tailscale es una red privada que entra por loopback: no es un tunel
|
|
37
|
+
// publico, asi que no debe degradar los guards locales (`localOk`).
|
|
38
|
+
if (parsed?.provider !== 'tailscale' && parsed?.pid && pidAlive(parsed.pid)) state = parsed;
|
|
36
39
|
} catch {
|
|
37
40
|
/* sin bridge */
|
|
38
41
|
}
|
|
@@ -109,6 +112,7 @@ function spawnTunnel(bin, args, opts = {}) {
|
|
|
109
112
|
});
|
|
110
113
|
}
|
|
111
114
|
|
|
115
|
+
// Tunel publico para el feature `preview` (no lo usa `bridge`).
|
|
112
116
|
export function startTunnelmole(port, opts = {}) {
|
|
113
117
|
return spawnTunnel('npx', ['--yes', 'tunnelmole', String(port)], {
|
|
114
118
|
...opts,
|
|
@@ -120,155 +124,121 @@ export function startTunnelmole(port, opts = {}) {
|
|
|
120
124
|
}
|
|
121
125
|
|
|
122
126
|
/**
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
127
|
+
* Tailscale: VPN mesh (WireGuard). `tailscale serve` publica HTTPS dentro del
|
|
128
|
+
* tailnet reenviando a loopback, asi que no hay exposicion publica y las
|
|
129
|
+
* peticiones siguen siendo "locales" para el server. Proveedor recomendado.
|
|
126
130
|
*/
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const res = await fetch(`${relayUrl}?a=${action}`, {
|
|
140
|
-
method: 'POST',
|
|
141
|
-
headers: { 'Content-Type': 'application/json' },
|
|
142
|
-
body: JSON.stringify({ token, ...body }),
|
|
143
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
144
|
-
});
|
|
145
|
-
if (!res.ok) throw new Error(`relay ${action}: HTTP ${res.status}`);
|
|
146
|
-
return res.json();
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
async function push(rid, chunks) {
|
|
150
|
-
await call('push', { rid, chunks }, { timeoutMs: 60000 });
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
async function handleRequest(row) {
|
|
154
|
-
const envelope = typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload;
|
|
155
|
-
const method = envelope.method ?? 'GET';
|
|
156
|
-
const path = String(envelope.path ?? '/');
|
|
157
|
-
const target = `${local}${path.startsWith('/') ? path : `/${path}`}`;
|
|
158
|
-
const init = { method, headers: { ...envelope.headers } };
|
|
159
|
-
delete init.headers.host;
|
|
160
|
-
if (envelope.body != null) init.body = envelope.body;
|
|
161
|
-
const res = await fetch(target, init);
|
|
162
|
-
const contentType = String(res.headers.get('content-type') ?? '');
|
|
163
|
-
const flat = {};
|
|
164
|
-
for (const header of ['content-type']) {
|
|
165
|
-
const value = res.headers.get(header);
|
|
166
|
-
if (value) flat[header] = value;
|
|
167
|
-
}
|
|
168
|
-
const cookies = res.headers.getSetCookie?.() ?? [];
|
|
169
|
-
if (cookies.length) flat['set-cookie'] = cookies;
|
|
170
|
-
|
|
171
|
-
if (!contentType.includes('text/event-stream')) {
|
|
172
|
-
const buffer = Buffer.from(await res.arrayBuffer());
|
|
173
|
-
await push(row.id, [
|
|
174
|
-
{
|
|
175
|
-
status: res.status,
|
|
176
|
-
headers: flat,
|
|
177
|
-
body: buffer.toString('base64'),
|
|
178
|
-
done: true,
|
|
179
|
-
},
|
|
180
|
-
]);
|
|
131
|
+
function runTailscale(args, opts = {}) {
|
|
132
|
+
const timeoutMs = opts.timeoutMs || 15000;
|
|
133
|
+
return new Promise((resolve, reject) => {
|
|
134
|
+
let child;
|
|
135
|
+
try {
|
|
136
|
+
child = spawn('tailscale', args, {
|
|
137
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
138
|
+
windowsHide: true,
|
|
139
|
+
shell: process.platform === 'win32',
|
|
140
|
+
});
|
|
141
|
+
} catch (err) {
|
|
142
|
+
reject(err);
|
|
181
143
|
return;
|
|
182
144
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
let
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
145
|
+
let out = '';
|
|
146
|
+
let err = '';
|
|
147
|
+
let settled = false;
|
|
148
|
+
const timer = setTimeout(() => {
|
|
149
|
+
if (settled) return;
|
|
150
|
+
settled = true;
|
|
151
|
+
killTree(child);
|
|
152
|
+
reject(new Error(`tailscale ${args.join(' ')} no respondio (${Math.round(timeoutMs / 1000)} s).`));
|
|
153
|
+
}, timeoutMs);
|
|
154
|
+
child.stdout.on('data', (data) => {
|
|
155
|
+
out += String(data);
|
|
156
|
+
});
|
|
157
|
+
child.stderr.on('data', (data) => {
|
|
158
|
+
err += String(data);
|
|
159
|
+
});
|
|
160
|
+
child.on('error', (error) => {
|
|
161
|
+
if (settled) return;
|
|
162
|
+
settled = true;
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
reject(new Error(`no se pudo ejecutar tailscale (${error.message}). Instalalo: https://tailscale.com/download`));
|
|
165
|
+
});
|
|
166
|
+
child.on('exit', (code) => {
|
|
167
|
+
if (settled) return;
|
|
168
|
+
settled = true;
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
if (code === 0) resolve({ stdout: out, stderr: err });
|
|
171
|
+
else {
|
|
172
|
+
const detail = err.trim() ? `: ${err.trim().slice(0, 200)}` : '';
|
|
173
|
+
reject(new Error(`tailscale ${args.join(' ')} fallo (codigo ${code})${detail}`));
|
|
208
174
|
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
}
|
|
212
178
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
}
|
|
247
|
-
}
|
|
179
|
+
/** Interpreta `tailscale status --json`: nombre DNS del nodo e IPv4 del tailnet. */
|
|
180
|
+
export function parseTailscaleStatus(json) {
|
|
181
|
+
const data = typeof json === 'string' ? JSON.parse(json) : json ?? {};
|
|
182
|
+
const dnsName = String(data?.Self?.DNSName ?? '').replace(/\.$/, '');
|
|
183
|
+
const ipv4 = String((data?.Self?.TailscaleIPs ?? []).find((ip) => !String(ip).includes(':')) ?? '');
|
|
184
|
+
return { dnsName, ipv4, connected: Boolean(dnsName) };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function startTailscaleServe(port, opts = {}) {
|
|
188
|
+
const log = opts.log ?? (() => {});
|
|
189
|
+
let status;
|
|
190
|
+
try {
|
|
191
|
+
status = await runTailscale(['status', '--json']);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
throw new Error(`tailscale no esta disponible: ${error.message}`);
|
|
194
|
+
}
|
|
195
|
+
let parsed;
|
|
196
|
+
try {
|
|
197
|
+
parsed = parseTailscaleStatus(status.stdout);
|
|
198
|
+
} catch {
|
|
199
|
+
throw new Error('no pude interpretar `tailscale status --json`.');
|
|
200
|
+
}
|
|
201
|
+
if (!parsed.connected) {
|
|
202
|
+
throw new Error('Tailscale no esta conectado. Inicia sesion con `tailscale up`.');
|
|
203
|
+
}
|
|
204
|
+
const backend = `http://127.0.0.1:${port}`;
|
|
205
|
+
try {
|
|
206
|
+
await runTailscale(['serve', '--bg', backend], { timeoutMs: 30000 });
|
|
207
|
+
} catch (first) {
|
|
208
|
+
try {
|
|
209
|
+
await runTailscale(['serve', '--bg', String(port)], { timeoutMs: 30000 });
|
|
210
|
+
} catch {
|
|
211
|
+
throw new Error(`no pude publicar con \`tailscale serve\`: ${first.message}`);
|
|
248
212
|
}
|
|
249
213
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
if (!state.stopped) log(`relay pump: ${error?.message ?? error}`);
|
|
253
|
-
});
|
|
254
|
-
|
|
214
|
+
const url = `https://${parsed.dnsName}`;
|
|
215
|
+
log(`tailscale: ${url} -> ${backend}`);
|
|
255
216
|
return {
|
|
256
|
-
url
|
|
257
|
-
provider: '
|
|
217
|
+
url,
|
|
218
|
+
provider: 'tailscale',
|
|
258
219
|
pid: 0,
|
|
259
220
|
child: null,
|
|
260
|
-
probeUrl: `${
|
|
261
|
-
stop() {
|
|
262
|
-
state.stopped = true;
|
|
263
|
-
},
|
|
221
|
+
probeUrl: `${url}/api/health`,
|
|
222
|
+
stop: () => runTailscale(['serve', 'reset']).catch(() => {}),
|
|
264
223
|
};
|
|
265
224
|
}
|
|
266
225
|
|
|
226
|
+
/** Estado de Tailscale (lanza si no esta instalado o no responde). */
|
|
227
|
+
export async function probeTailscale() {
|
|
228
|
+
const { stdout } = await runTailscale(['status', '--json']);
|
|
229
|
+
return parseTailscaleStatus(stdout);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Quita toda la configuracion de `tailscale serve` de este nodo. */
|
|
233
|
+
export function resetTailscaleServe() {
|
|
234
|
+
return runTailscale(['serve', 'reset'], { timeoutMs: 30000 });
|
|
235
|
+
}
|
|
236
|
+
|
|
267
237
|
/** Devuelve { url, provider, pid, stop }. provider 'none' no abre nada. */
|
|
268
238
|
export async function startTunnel(port, provider = 'tunnelmole', opts = {}) {
|
|
269
239
|
const p = String(provider || 'tunnelmole').toLowerCase();
|
|
270
240
|
if (p === 'none' || p === 'off' || p === '') return { url: '', provider: 'none', pid: 0, stop: () => {} };
|
|
241
|
+
if (p === 'tailscale' || p === 'ts') return startTailscaleServe(port, opts);
|
|
271
242
|
if (p === 'tunnelmole' || p === 'tmole') return startTunnelmole(port, opts);
|
|
272
|
-
if (p === 'relay') return startRelay(port, opts);
|
|
273
243
|
throw new Error(`proveedor de tunel no soportado: ${provider}`);
|
|
274
244
|
}
|
|
@@ -48,6 +48,17 @@ 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 los proveedores retirados (tunnelmole/relay) a tailscale', async () => {
|
|
52
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-home-'));
|
|
53
|
+
setHome(dir);
|
|
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
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
51
62
|
test('migrateLegacyDb copies the legacy project database to the global home', async () => {
|
|
52
63
|
const home = await mkdtemp(path.join(tmpdir(), 'tamnora-home-'));
|
|
53
64
|
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-cwd-'));
|
|
@@ -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>
|
|
@@ -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,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
|
-
}
|