nio-cat 0.5.1 → 0.6.1

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/src/server.js CHANGED
@@ -13,6 +13,7 @@ const github = require('./github');
13
13
  const { addServer, knownHosts } = require('./ssh');
14
14
  const { agentCommands } = require('./commands');
15
15
  const { pairLink, qrSvg, makeCode, codeLink } = require('./pair');
16
+ const directory = require('./directory');
16
17
 
17
18
  // L'app web: copiada dins del paquet en publicar-lo, o l'arrel del repositori en desenvolupament.
18
19
  const WEB_DIRS = [path.join(__dirname, '..', 'web'), path.join(__dirname, '..', '..')];
@@ -42,14 +43,6 @@ function originAllowed(cfg, origin) {
42
43
  });
43
44
  }
44
45
 
45
- function tokenOk(cfg, req) {
46
- const h = req.headers.authorization || '';
47
- const t = h.startsWith('Bearer ') ? h.slice(7) : '';
48
- const a = Buffer.from(t);
49
- const b = Buffer.from(cfg.token);
50
- return a.length === b.length && crypto.timingSafeEqual(a, b);
51
- }
52
-
53
46
  function isLoopbackDirect(req) {
54
47
  const ip = req.socket.remoteAddress || '';
55
48
  const local = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
@@ -141,26 +134,53 @@ function listDir(cfg, raw) {
141
134
  // Noms de model d'Ollama: «qwen3:4b», «hf.co/usuari/model:Q4_K_M»…
142
135
  const MODEL_NAME = /^[\w][\w.\-/]{0,100}(:[\w.\-]{1,60})?$/;
143
136
 
144
- function createServer({ cfg, hub, getPublicUrl, getApp }) {
145
- const streams = new Set();
137
+ // Rutes que afegeixen altres mòduls (memòria, feines, Telegram, veu, navegador…).
138
+ // Cada ruta: { method, path: RegExp|string, handler: async ({ req, res, url, params, body, device }), raw?, limit? }.
139
+ function matchRoute(routes, method, p) {
140
+ for (const r of routes) {
141
+ if (r.method !== method) continue;
142
+ if (typeof r.path === 'string') {
143
+ if (r.path === p) return { r, params: [] };
144
+ } else {
145
+ const m = p.match(r.path);
146
+ if (m) return { r, params: m.slice(1).map((x) => decodeURIComponent(x)) };
147
+ }
148
+ }
149
+ return null;
150
+ }
151
+
152
+ function createServer({ cfg, hub, auth, audit, getPublicUrl, getApp, routes = [], info = () => ({}) }) {
153
+ const streams = new Map(); // res -> id del dispositiu
154
+ auth.onRevoke((d) => {
155
+ for (const [res, dev] of streams) if (dev === d.id) { res.end(); streams.delete(res); }
156
+ const before = (cfg.pushSubs || []).length;
157
+ cfg.pushSubs = (cfg.pushSubs || []).filter((x) => x.device !== d.id);
158
+ if (cfg.pushSubs.length !== before) save(cfg);
159
+ });
146
160
  const seenDevices = new Map(); // mòbils que s'han connectat (per avisar l'ordinador que mostra el QR)
147
161
  const pulls = new Map(); // descàrregues de models d'Ollama en curs
148
162
 
149
163
  hub.on('out', (msg) => {
150
164
  const data = `data: ${JSON.stringify(msg)}\n\n`;
151
- for (const res of streams) res.write(data);
165
+ for (const res of streams.keys()) res.write(data);
152
166
  });
153
167
  setInterval(() => {
154
- for (const res of streams) res.write(': ping\n\n');
168
+ for (const res of streams.keys()) res.write(': ping\n\n');
155
169
  }, 15000).unref();
156
170
 
157
171
  const hookUrl = () => `http://127.0.0.1:${cfg.port}/internal/approval`;
158
172
 
159
- async function api(req, res, url) {
173
+ async function api(req, res, url, device) {
160
174
  const p = url.pathname;
161
175
  const m = (re) => p.match(re);
162
176
  const method = req.method;
163
177
 
178
+ const hit = matchRoute(routes, method, p);
179
+ if (hit) {
180
+ const body = method === 'GET' || method === 'DELETE' || hit.r.raw ? undefined : await readBody(req, hit.r.limit);
181
+ return hit.r.handler({ req, res, url, params: hit.params, body, device });
182
+ }
183
+
164
184
  if (p === '/api/info' && method === 'GET') {
165
185
  return {
166
186
  name: 'nio',
@@ -173,8 +193,32 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
173
193
  roots: cfg.roots,
174
194
  vapidKey: cfg.vapid.publicKey,
175
195
  id: cfg.id,
196
+ device: { id: device.id, name: device.name, primary: !!device.primary },
197
+ identity: directory.identity(cfg),
198
+ ...(await info()),
176
199
  };
177
200
  }
201
+ if (p === '/api/devices' && method === 'GET') return auth.list(device.id);
202
+ let dv;
203
+ if ((dv = m(/^\/api\/devices\/([\w-]+)$/))) {
204
+ if (method === 'PATCH') {
205
+ const b = await readBody(req);
206
+ const r = auth.rename(dv[1], b.name);
207
+ if (!r) throw httpErr(404, 'Aquest dispositiu no existeix');
208
+ return r;
209
+ }
210
+ if (method === 'DELETE') {
211
+ const r = auth.revoke(dv[1]);
212
+ if (!r) throw httpErr(404, 'Aquest dispositiu no existeix');
213
+ audit.add({ type: 'device', what: `Accés revocat: ${r.name}`, by: device.name });
214
+ return r;
215
+ }
216
+ }
217
+ if (p === '/api/audit' && method === 'GET') {
218
+ const limit = Math.min(1000, Number(url.searchParams.get('limit')) || 200);
219
+ const type = url.searchParams.get('type');
220
+ return audit.recent(limit, type ? (e) => e.type === type : null);
221
+ }
178
222
  if (p === '/api/ssh/hosts' && method === 'GET') return knownHosts();
179
223
  if (p === '/api/projects' && method === 'GET') return listProjects(cfg);
180
224
  if (p === '/api/fs' && method === 'GET') return listDir(cfg, url.searchParams.get('path'));
@@ -236,9 +280,11 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
236
280
  // Enllaç i QR per connectar un altre dispositiu (el mòbil) amb aquest ordinador.
237
281
  const pub = getPublicUrl();
238
282
  if (!pub || /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:|\/|$)/i.test(pub)) return { link: null };
239
- const link = pairLink(getApp ? getApp() : cfg.app, pub, cfg.token);
240
- const code = makeCode(pub, cfg.token);
241
- return { link, code, svg: qrSvg(codeLink(getApp ? getApp() : cfg.app, code)), url: pub, lan: /^http:\/\//.test(pub) };
283
+ // Un secret d'un sol ús (no la clau principal): el mòbil el canvia per una clau pròpia i revocable.
284
+ const { code: secret, expires } = auth.createCode({ secret: true });
285
+ const link = pairLink(getApp ? getApp() : cfg.app, pub, secret);
286
+ const code = makeCode(pub, secret);
287
+ return { link, code, svg: qrSvg(codeLink(getApp ? getApp() : cfg.app, code)), url: pub, lan: /^http:\/\//.test(pub), expires };
242
288
  }
243
289
  if (p === '/api/github' && method === 'GET') return github.status();
244
290
  if (p === '/api/github/repos' && method === 'GET') return github.repos(url.searchParams.get('owner') || '');
@@ -293,7 +339,7 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
293
339
  if (p === '/api/approvals' && method === 'GET') return hub.pendingApprovals();
294
340
  if ((r = m(/^\/api\/approvals\/([\w-]+)$/)) && method === 'POST') {
295
341
  const b = await readBody(req);
296
- const a = hub.decide(r[1], b.decision === 'allow' ? 'allow' : 'deny', b.reason);
342
+ const a = hub.decide(r[1], b.decision === 'allow' ? 'allow' : 'deny', b.reason, device.name);
297
343
  if (!a) throw httpErr(404, 'Aquesta petició ja s’ha resolt');
298
344
  return a;
299
345
  }
@@ -301,9 +347,10 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
301
347
  if (p === '/api/push' && method === 'POST') {
302
348
  const b = await readBody(req);
303
349
  if (!b.endpoint || !/^https:\/\//.test(b.endpoint)) throw httpErr(400, 'La subscripció als avisos no és vàlida');
350
+ const keys = b.keys && typeof b.keys.p256dh === 'string' && typeof b.keys.auth === 'string' ? { p256dh: b.keys.p256dh.slice(0, 200), auth: b.keys.auth.slice(0, 100) } : null;
304
351
  cfg.pushSubs = (cfg.pushSubs || []).filter((x) => x.endpoint !== b.endpoint);
305
- cfg.pushSubs.push({ endpoint: b.endpoint, added: Date.now() });
306
- cfg.pushSubs = cfg.pushSubs.slice(-10);
352
+ cfg.pushSubs.push({ endpoint: String(b.endpoint).slice(0, 1000), keys, device: device.id, added: Date.now() });
353
+ cfg.pushSubs = cfg.pushSubs.slice(-20);
307
354
  save(cfg);
308
355
  return { ok: true };
309
356
  }
@@ -376,11 +423,26 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
376
423
  return res.end(JSON.stringify({ nio: true, id: cfg.id, name: os.hostname() }));
377
424
  }
378
425
 
379
- if (url.pathname.startsWith('/api/')) {
380
- if (!tokenOk(cfg, req)) {
381
- res.writeHead(401, { 'content-type': 'application/json' });
382
- return res.end(JSON.stringify({ error: 'La clau no és vàlida. Torna a connectar el mòbil amb el codi QR.' }));
426
+ const json = (status, obj) => {
427
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
428
+ res.end(JSON.stringify(obj));
429
+ };
430
+
431
+ // Aparellament: canvia un codi d'un sol ús per una clau pròpia d'aquest dispositiu. No demana clau.
432
+ if (url.pathname === '/api/pair' && req.method === 'POST') {
433
+ try {
434
+ const b = await readBody(req, 4096);
435
+ const r = auth.redeem(b.code, b.name);
436
+ audit.add({ type: 'device', what: `Dispositiu aparellat: ${r.device.name}`, by: r.device.name });
437
+ return json(200, { ...r, id: cfg.id, host: os.hostname(), identity: directory.identity(cfg) });
438
+ } catch (e) {
439
+ return json(e.status || 500, { error: e.message });
383
440
  }
441
+ }
442
+
443
+ if (url.pathname.startsWith('/api/')) {
444
+ const device = auth.fromRequest(req);
445
+ if (!device) return json(401, { error: 'Aquest dispositiu no té accés (o l’has revocat). Torna a aparellar-lo amb un codi nou.' });
384
446
  if (url.pathname === '/api/stream') {
385
447
  res.writeHead(200, {
386
448
  'content-type': 'text/event-stream; charset=utf-8',
@@ -392,10 +454,10 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
392
454
  const dev = String(url.searchParams.get('d') || '').slice(0, 40), kind = url.searchParams.get('k') === 'phone' ? 'phone' : 'desktop';
393
455
  if (dev && kind === 'phone' && Date.now() - (seenDevices.get(dev) || 0) > 10 * 60 * 1000) {
394
456
  const data = `data: ${JSON.stringify({ type: 'device', device: dev, kind })}\n\n`;
395
- for (const o of streams) o.write(data);
457
+ for (const o of streams.keys()) o.write(data);
396
458
  }
397
459
  if (dev) seenDevices.set(dev, Date.now());
398
- streams.add(res);
460
+ streams.set(res, device.id);
399
461
  req.on('close', () => streams.delete(res));
400
462
  return;
401
463
  }
@@ -414,12 +476,12 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
414
476
  return res.end();
415
477
  }
416
478
  try {
417
- const out = await api(req, res, url);
418
- res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
419
- res.end(JSON.stringify(out));
479
+ const out = await api(req, res, url, device);
480
+ if (res.headersSent) return; // la ruta ja ha respost (p. ex. àudio o imatges)
481
+ json(200, out);
420
482
  } catch (e) {
421
- res.writeHead(e.status || 500, { 'content-type': 'application/json; charset=utf-8' });
422
- res.end(JSON.stringify({ error: e.message }));
483
+ if (res.headersSent) return res.end();
484
+ json(e.status || 500, { error: e.message });
423
485
  }
424
486
  return;
425
487
  }
@@ -433,4 +495,4 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
433
495
  return server;
434
496
  }
435
497
 
436
- module.exports = { createServer, originAllowed };
498
+ module.exports = { createServer, originAllowed, readBody };
package/src/service.js ADDED
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+ // Servei del sistema perquè nio arrenqui sol amb l'ordinador (systemd a Linux, launchd a macOS).
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { spawnSync } = require('child_process');
7
+ const { HOME } = require('./config');
8
+ const { searchPath } = require('./proc');
9
+
10
+ const LABEL = 'cat.nio.daemon';
11
+
12
+ function entry() {
13
+ return fs.realpathSync(path.join(__dirname, '..', 'bin', 'nio.js'));
14
+ }
15
+
16
+ function q(s) {
17
+ // Cometes per a ExecStart de systemd.
18
+ return /[\s"\\]/.test(s) ? `"${String(s).replace(/(["\\])/g, '\\$1')}"` : s;
19
+ }
20
+
21
+ function systemdUnit({ roots, system }) {
22
+ const env = [`PATH=${searchPath().join(':')}`, process.env.NIO_HOME ? `NIO_HOME=${process.env.NIO_HOME}` : null].filter(Boolean);
23
+ return `[Unit]
24
+ Description=nio · els teus agents, des del mòbil
25
+ After=network-online.target
26
+ Wants=network-online.target
27
+
28
+ [Service]
29
+ Type=simple
30
+ ExecStart=${[process.execPath, entry(), '--no-open', ...roots].map(q).join(' ')}
31
+ WorkingDirectory=${roots[0] || os.homedir()}
32
+ ${env.map((e) => `Environment=${q(e)}`).join('\n')}
33
+ Environment=NO_COLOR=1
34
+ Restart=always
35
+ RestartSec=3
36
+ ${system ? `User=${os.userInfo().username}\n` : ''}
37
+ [Install]
38
+ WantedBy=${system ? 'multi-user.target' : 'default.target'}
39
+ `;
40
+ }
41
+
42
+ function launchdPlist({ roots }) {
43
+ const x = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;');
44
+ const args = [process.execPath, entry(), '--no-open', ...roots].map((a) => ` <string>${x(a)}</string>`).join('\n');
45
+ return `<?xml version="1.0" encoding="UTF-8"?>
46
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
47
+ <plist version="1.0">
48
+ <dict>
49
+ <key>Label</key><string>${LABEL}</string>
50
+ <key>ProgramArguments</key>
51
+ <array>
52
+ ${args}
53
+ </array>
54
+ <key>WorkingDirectory</key><string>${x(roots[0] || os.homedir())}</string>
55
+ <key>EnvironmentVariables</key>
56
+ <dict>
57
+ <key>PATH</key><string>${x(searchPath().join(':'))}</string>
58
+ <key>NO_COLOR</key><string>1</string>${process.env.NIO_HOME ? `\n <key>NIO_HOME</key><string>${x(process.env.NIO_HOME)}</string>` : ''}
59
+ </dict>
60
+ <key>RunAtLoad</key><true/>
61
+ <key>KeepAlive</key><true/>
62
+ <key>StandardOutPath</key><string>${x(path.join(HOME, 'nio.log'))}</string>
63
+ <key>StandardErrorPath</key><string>${x(path.join(HOME, 'nio.log'))}</string>
64
+ </dict>
65
+ </plist>
66
+ `;
67
+ }
68
+
69
+ function sh(cmd, args) {
70
+ const r = spawnSync(cmd, args, { encoding: 'utf8' });
71
+ return { ok: r.status === 0, out: `${r.stdout || ''}${r.stderr || ''}`.trim() };
72
+ }
73
+
74
+ function install({ roots, log = console.log }) {
75
+ if (entry().includes(`${path.sep}_npx${path.sep}`)) {
76
+ log(' Avís: nio s’executa des de la memòria cau de npx, que es pot esborrar.');
77
+ log(' Per a un servei estable, instal·la’l primer: npm install -g https://www.nio.cat/nio.tgz\n');
78
+ }
79
+ if (process.platform === 'darwin') {
80
+ const file = path.join(os.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
81
+ fs.mkdirSync(path.dirname(file), { recursive: true });
82
+ sh('launchctl', ['unload', file]);
83
+ fs.writeFileSync(file, launchdPlist({ roots }));
84
+ const r = sh('launchctl', ['load', '-w', file]);
85
+ if (!r.ok) throw new Error(`launchctl no l’ha pogut carregar: ${r.out}`);
86
+ return { file, logs: `tail -f ${path.join(HOME, 'nio.log')}` };
87
+ }
88
+ if (process.platform !== 'linux') throw new Error('El servei automàtic només funciona a Linux (systemd) i macOS (launchd).');
89
+ if (!sh('systemctl', ['--version']).ok) throw new Error('Aquest sistema no té systemd.');
90
+ const system = process.getuid && process.getuid() === 0;
91
+ const file = system ? '/etc/systemd/system/nio.service' : path.join(os.homedir(), '.config', 'systemd', 'user', 'nio.service');
92
+ fs.mkdirSync(path.dirname(file), { recursive: true });
93
+ fs.writeFileSync(file, systemdUnit({ roots, system }));
94
+ const ctl = system ? ['systemctl'] : ['systemctl', '--user'];
95
+ sh(ctl[0], [...ctl.slice(1), 'daemon-reload']);
96
+ const r = sh(ctl[0], [...ctl.slice(1), 'enable', '--now', 'nio.service']);
97
+ if (!r.ok) throw new Error(`systemctl no l’ha pogut engegar: ${r.out}`);
98
+ sh(ctl[0], [...ctl.slice(1), 'restart', 'nio.service']);
99
+ let linger = null;
100
+ if (!system) {
101
+ // Sense «linger», el servei d'usuari s'atura quan tanques la sessió (i no arrenca en reiniciar).
102
+ linger = sh('loginctl', ['enable-linger', os.userInfo().username]).ok;
103
+ }
104
+ return { file, linger, logs: system ? 'journalctl -u nio -f' : 'journalctl --user -u nio -f' };
105
+ }
106
+
107
+ function uninstall() {
108
+ if (process.platform === 'darwin') {
109
+ const file = path.join(os.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
110
+ sh('launchctl', ['unload', '-w', file]);
111
+ fs.rmSync(file, { force: true });
112
+ return file;
113
+ }
114
+ const system = process.getuid && process.getuid() === 0;
115
+ const ctl = system ? ['systemctl'] : ['systemctl', '--user'];
116
+ sh(ctl[0], [...ctl.slice(1), 'disable', '--now', 'nio.service']);
117
+ const file = system ? '/etc/systemd/system/nio.service' : path.join(os.homedir(), '.config', 'systemd', 'user', 'nio.service');
118
+ fs.rmSync(file, { force: true });
119
+ sh(ctl[0], [...ctl.slice(1), 'daemon-reload']);
120
+ return file;
121
+ }
122
+
123
+ module.exports = { install, uninstall, systemdUnit, launchdPlist };
package/src/tunnel.js CHANGED
@@ -1,49 +1,165 @@
1
1
  'use strict';
2
- // Túnel gratuït amb Cloudflare (quick tunnel, sense compte):
3
- // el mòbil arriba al daemon des de qualsevol xarxa i sense obrir ports.
4
- const { spawn } = require('child_process');
2
+ // Túnels de Cloudflare.
3
+ // · Túnel amb nom (named tunnel): adreça fixa (p. ex. https://vps.nio.cat). Cal un compte de
4
+ // Cloudflare i que la zona DNS del domini sigui a Cloudflare. Es configura un cop amb
5
+ // `nio tunnel setup <host>` i a partir d'aquí el mòbil s'hi torna a connectar sol.
6
+ // · Quick tunnel: sense compte, però l'adreça (…trycloudflare.com) canvia a cada reinici.
7
+ // És l'opció de reserva.
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const { spawn, spawnSync } = require('child_process');
5
12
  const { which, childEnv } = require('./proc');
13
+ const { HOME } = require('./config');
6
14
 
15
+ const HOST_RE = /^(?=.{4,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
16
+ const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,62}$/i;
17
+
18
+ // Manté cloudflared engegat: si cau, el torna a obrir amb una espera creixent.
19
+ function supervise(bin, args, { onLine, onExit } = {}) {
20
+ let child = null;
21
+ let stopped = false;
22
+ let tries = 0;
23
+ const open = () => {
24
+ const startedAt = Date.now();
25
+ child = spawn(bin, args, { env: childEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
26
+ const scan = (d) => String(d).split('\n').forEach((l) => l && onLine && onLine(l));
27
+ child.stdout.on('data', scan);
28
+ child.stderr.on('data', scan);
29
+ child.on('error', () => {});
30
+ child.on('close', (code) => {
31
+ if (stopped) return;
32
+ if (Date.now() - startedAt > 60000) tries = 0;
33
+ const wait = Math.min(60000, 2000 * 2 ** tries++);
34
+ if (onExit) onExit(code, wait);
35
+ setTimeout(() => !stopped && open(), wait).unref();
36
+ });
37
+ };
38
+ open();
39
+ const stop = () => {
40
+ stopped = true;
41
+ try { child && child.kill(); } catch { /* ja ha acabat */ }
42
+ };
43
+ process.on('exit', stop);
44
+ return { stop, get child() { return child; } };
45
+ }
46
+
47
+ // Quick tunnel: l'adreça arriba pel registre de cloudflared. Si es reinicia, n'arriba una altra.
7
48
  function startTunnel(port, onUrl) {
8
49
  const bin = which('cloudflared');
9
50
  if (!bin) return null;
10
- const child = spawn(bin, ['tunnel', '--no-autoupdate', '--url', `http://127.0.0.1:${port}`], {
11
- env: childEnv(),
12
- stdio: ['ignore', 'pipe', 'pipe'],
51
+ let last = null;
52
+ return supervise(bin, ['tunnel', '--no-autoupdate', '--url', `http://127.0.0.1:${port}`], {
53
+ onLine: (l) => {
54
+ const m = l.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
55
+ if (m && m[0] !== last) {
56
+ last = m[0];
57
+ onUrl(m[0]);
58
+ }
59
+ },
13
60
  });
14
- let found = false;
15
- const scan = (d) => {
16
- const m = String(d).match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
17
- if (m && !found) {
18
- found = true;
19
- onUrl(m[0]);
20
- }
21
- };
22
- child.stdout.on('data', scan);
23
- child.stderr.on('data', scan);
24
- process.on('exit', () => child.kill());
25
- return child;
26
61
  }
27
62
 
28
- // Túnel amb nom (compte gratuït de Cloudflare): l'adreça no canvia entre reinicis.
29
- function startNamedTunnel(token, onReady) {
63
+ // Túnel amb nom configurat amb «nio tunnel setup»: l'adreça és fixa i ja la sabem abans d'obrir-lo.
64
+ function startConfiguredTunnel(t, port, { onReady, onDown } = {}) {
30
65
  const bin = which('cloudflared');
31
66
  if (!bin) return null;
32
- const child = spawn(bin, ['tunnel', '--no-autoupdate', 'run', '--token', token], {
33
- env: childEnv(),
34
- stdio: ['ignore', 'pipe', 'pipe'],
67
+ const file = writeConfig(t, port);
68
+ let ready = false;
69
+ return supervise(bin, ['tunnel', '--no-autoupdate', '--config', file, 'run', t.id || t.name], {
70
+ onLine: (l) => {
71
+ if (!ready && /Registered tunnel connection/i.test(l)) {
72
+ ready = true;
73
+ onReady && onReady();
74
+ }
75
+ },
76
+ onExit: (code, wait) => {
77
+ ready = false;
78
+ onDown && onDown(code, wait);
79
+ },
35
80
  });
81
+ }
82
+
83
+ function configFile() {
84
+ return path.join(HOME, 'cloudflared.yml');
85
+ }
86
+
87
+ function writeConfig(t, port) {
88
+ const yml = [
89
+ '# Generat per nio. Es torna a escriure a cada arrencada.',
90
+ `tunnel: ${t.id || t.name}`,
91
+ t.credentials ? `credentials-file: ${t.credentials}` : null,
92
+ 'ingress:',
93
+ ` - hostname: ${t.host}`,
94
+ ` service: http://127.0.0.1:${port}`,
95
+ ' - service: http_status:404',
96
+ '',
97
+ ].filter((x) => x !== null).join('\n');
98
+ fs.writeFileSync(configFile(), yml, { mode: 0o600 });
99
+ return configFile();
100
+ }
101
+
102
+ // Busca l'identificador d'un túnel que ja existeix.
103
+ function findTunnel(bin, name) {
104
+ const r = spawnSync(bin, ['tunnel', 'list', '--output', 'json', '--name', name], { env: childEnv(), encoding: 'utf8' });
105
+ if (r.status !== 0) return null;
106
+ try {
107
+ const list = JSON.parse(r.stdout || '[]');
108
+ const t = list.find((x) => x.name === name && !x.deleted_at);
109
+ return t ? t.id : null;
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ // Configuració d'un sol cop: login → create → route dns. Tot passa al compte de Cloudflare
116
+ // de l'usuari i amb les seves credencials (~/.cloudflared). nio només en desa el nom i l'host.
117
+ function setupNamedTunnel({ host, name, log = console.log }) {
118
+ const bin = which('cloudflared');
119
+ if (!bin) throw new Error('Falta cloudflared. Instal·la’l primer (https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/).');
120
+ if (!HOST_RE.test(host || '')) throw new Error(`«${host}» no és un nom de domini vàlid (p. ex. vps.nio.cat)`);
121
+ name = name || `nio-${host.split('.')[0]}`;
122
+ if (!NAME_RE.test(name)) throw new Error(`«${name}» no és un nom de túnel vàlid`);
123
+ const run = (args, inherit) => spawnSync(bin, args, { env: childEnv(), stdio: inherit ? 'inherit' : 'pipe', encoding: 'utf8' });
124
+
125
+ const cert = path.join(os.homedir(), '.cloudflared', 'cert.pem');
126
+ if (!fs.existsSync(cert)) {
127
+ log('\n 1/3 · Inicia la sessió a Cloudflare. S’obrirà (o et mostrarà) un enllaç: tria la zona del domini.\n');
128
+ const r = run(['tunnel', 'login'], true);
129
+ if (r.status !== 0) throw new Error('No s’ha pogut iniciar la sessió a Cloudflare');
130
+ } else log(' 1/3 · Ja hi ha una sessió de Cloudflare en aquest ordinador.');
131
+
132
+ let id = findTunnel(bin, name);
133
+ if (!id) {
134
+ log(` 2/3 · Creo el túnel «${name}»…`);
135
+ const r = run(['tunnel', 'create', name]);
136
+ const m = `${r.stdout}${r.stderr}`.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
137
+ id = m ? m[0] : findTunnel(bin, name);
138
+ if (r.status !== 0 || !id) throw new Error(`cloudflared no ha pogut crear el túnel:\n${r.stderr || r.stdout}`);
139
+ } else log(` 2/3 · El túnel «${name}» ja existeix (${id}).`);
140
+
141
+ log(` 3/3 · Apunto ${host} cap al túnel (registre CNAME a Cloudflare)…`);
142
+ const r = run(['tunnel', 'route', 'dns', name, host]);
143
+ if (r.status !== 0 && !/already exists|already configured/i.test(`${r.stderr}${r.stdout}`)) {
144
+ throw new Error(`No s’ha pogut crear el registre DNS de ${host}:\n${r.stderr || r.stdout}\nComprova que la zona del domini és al teu compte de Cloudflare.`);
145
+ }
146
+ const credentials = path.join(os.homedir(), '.cloudflared', `${id}.json`);
147
+ return { mode: 'named', name, id, host: host.toLowerCase(), credentials: fs.existsSync(credentials) ? credentials : null };
148
+ }
149
+
150
+ // Túnel amb nom creat al tauler de Cloudflare (nio --tunnel-token … --url https://…).
151
+ function startNamedTunnel(token, onReady) {
152
+ const bin = which('cloudflared');
153
+ if (!bin) return null;
36
154
  let ready = false;
37
- const scan = (d) => {
38
- if (!ready && /Registered tunnel connection/i.test(String(d))) {
39
- ready = true;
40
- onReady();
41
- }
42
- };
43
- child.stdout.on('data', scan);
44
- child.stderr.on('data', scan);
45
- process.on('exit', () => child.kill());
46
- return child;
155
+ return supervise(bin, ['tunnel', '--no-autoupdate', 'run', '--token', token], {
156
+ onLine: (l) => {
157
+ if (!ready && /Registered tunnel connection/i.test(l)) {
158
+ ready = true;
159
+ onReady();
160
+ }
161
+ },
162
+ });
47
163
  }
48
164
 
49
- module.exports = { startTunnel, startNamedTunnel };
165
+ module.exports = { startTunnel, startNamedTunnel, startConfiguredTunnel, setupNamedTunnel, writeConfig, HOST_RE };