@danieltmn/openbridge 0.2.0 → 0.4.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/src/auth.js CHANGED
@@ -38,8 +38,16 @@ function parseCookies(header) {
38
38
  return out;
39
39
  }
40
40
 
41
+ // Detras del tunel el server recibe los pedidos desde loopback (el tunel corre
42
+ // en la misma maquina y reenvia a 127.0.0.1). Solo en ese caso confiamos en
43
+ // X-Forwarded-Proto: un cliente que llegara directo por la red no puede forzar
44
+ // el flag Secure de la cookie. Sin info de socket (tests), se confia.
41
45
  function isSecure(req) {
42
- const proto = req.headers['x-forwarded-proto'];
46
+ if (req.socket && req.socket.encrypted) return true;
47
+ const remote = req.socket && req.socket.remoteAddress;
48
+ const loopback = !remote || remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1';
49
+ if (!loopback) return false;
50
+ const proto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim().toLowerCase();
43
51
  return proto === 'https';
44
52
  }
45
53
 
@@ -51,10 +59,10 @@ function serializeCookie(name, value, req, maxAge) {
51
59
  }
52
60
 
53
61
  // ---------------------------------------------------------------------------
54
- // Sesion
62
+ // Sesion (ligada al id del usuario; el rol se lee siempre del server)
55
63
  // ---------------------------------------------------------------------------
56
- function makeSession(app, username, csrf, exp) {
57
- const payload = b64url(JSON.stringify({ u: username, c: csrf, e: exp }));
64
+ function makeSession(app, user, csrf, exp) {
65
+ const payload = b64url(JSON.stringify({ u: user.id, pv: parseInt(user.pv, 10) || 1, c: csrf, e: exp }));
58
66
  return payload + '.' + sign(payload, app.csrfSecret);
59
67
  }
60
68
  function readSession(app, req) {
@@ -68,8 +76,11 @@ function readSession(app, req) {
68
76
  if (!safeEqual(sign(payload, app.csrfSecret), sig)) return null;
69
77
  let data;
70
78
  try { data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); } catch (e) { return null; }
71
- if (!data || data.u !== app.username || typeof data.e !== 'number' || data.e < Math.floor(Date.now() / 1000)) return null;
72
- return data;
79
+ if (!data || typeof data.e !== 'number' || data.e < Math.floor(Date.now() / 1000)) return null;
80
+ const user = config.userById(app, data.u);
81
+ if (!user || user.disabled) return null;
82
+ if ((parseInt(user.pv, 10) || 1) !== (parseInt(data.pv, 10) || 1)) return null;
83
+ return { id: user.id, name: user.name, role: user.role, u: user.id, c: data.c, e: data.e };
73
84
  }
74
85
  function currentCsrf(app, req) {
75
86
  const s = readSession(app, req);
@@ -80,6 +91,14 @@ function requireLogin(app, req, res) {
80
91
  if (!s) { json(res, 401, { ok: false, error: 'No autorizado' }); return null; }
81
92
  return s;
82
93
  }
94
+ // Exige que el usuario tenga uno de los roles indicados.
95
+ function requireRole(app, req, res, roles) {
96
+ const s = readSession(app, req);
97
+ if (!s) { json(res, 401, { ok: false, error: 'No autorizado' }); return null; }
98
+ const allowed = Array.isArray(roles) ? roles : [roles];
99
+ if (!allowed.includes(s.role)) { json(res, 403, { ok: false, error: 'Permiso insuficiente' }); return null; }
100
+ return s;
101
+ }
83
102
  function requireCsrf(app, req, res) {
84
103
  const s = readSession(app, req);
85
104
  const given = String(req.headers['x-csrf'] || '').trim();
@@ -90,13 +109,13 @@ function requireCsrf(app, req, res) {
90
109
  return s;
91
110
  }
92
111
 
93
- function startSession(app, req, res, username) {
112
+ function startSession(app, req, res, user) {
94
113
  const csrf = crypto.randomBytes(16).toString('hex');
95
114
  const exp = Math.floor(Date.now() / 1000) + REMEMBER_DAYS * 86400;
96
- const token = makeSession(app, username, csrf, exp);
115
+ const token = makeSession(app, user, csrf, exp);
97
116
  res.setHeader('Set-Cookie', [
98
117
  serializeCookie(SESSION_COOKIE, token, req, REMEMBER_DAYS * 86400),
99
- serializeCookie(REMEMBER_COOKIE, makeRemember(app, username), req, REMEMBER_DAYS * 86400),
118
+ serializeCookie(REMEMBER_COOKIE, makeRemember(app, user), req, REMEMBER_DAYS * 86400),
100
119
  ]);
101
120
  return csrf;
102
121
  }
@@ -107,9 +126,9 @@ function endSession(req, res) {
107
126
  ]);
108
127
  }
109
128
 
110
- function makeRemember(app, username) {
129
+ function makeRemember(app, user) {
111
130
  const exp = Math.floor(Date.now() / 1000) + REMEMBER_DAYS * 86400;
112
- const payload = username + '|' + exp;
131
+ const payload = user.id + '|' + exp + '|' + (parseInt(user.pv, 10) || 1);
113
132
  return b64url(payload) + '.' + sign(payload, app.csrfSecret);
114
133
  }
115
134
  // Re-autentica desde la cookie remember si la sesion se perdio.
@@ -125,10 +144,12 @@ function rememberAutoLogin(app, req, res) {
125
144
  let plain;
126
145
  try { plain = Buffer.from(payload, 'base64url').toString('utf8'); } catch (e) { return false; }
127
146
  const parts = plain.split('|');
128
- if (parts.length !== 2) return false;
129
- const [user, exp] = parts;
130
- if (user !== app.username || !/^\d+$/.test(exp) || parseInt(exp, 10) < Math.floor(Date.now() / 1000)) return false;
147
+ if (parts.length !== 3) return false;
148
+ const [uid, exp, pv] = parts;
149
+ if (!/^\d+$/.test(exp) || parseInt(exp, 10) < Math.floor(Date.now() / 1000)) return false;
131
150
  if (!safeEqual(sign(payload, app.csrfSecret), sig)) return false;
151
+ const user = config.userById(app, uid);
152
+ if (!user || user.disabled || (parseInt(user.pv, 10) || 1) !== (parseInt(pv, 10) || 1)) return false;
132
153
  startSession(app, req, res, user);
133
154
  return true;
134
155
  }
@@ -141,10 +162,10 @@ const LOGIN_WINDOW_MS = 15 * 60 * 1000;
141
162
  const LOGIN_LOCK_MS = 15 * 60 * 1000;
142
163
  const loginAttempts = new Map();
143
164
 
144
- function clientKey(req) {
165
+ function clientKey(req, user) {
145
166
  const xff = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim();
146
167
  const ip = xff || (req.socket && req.socket.remoteAddress) || 'desconocido';
147
- return String(ip);
168
+ return String(ip) + '|' + String(user || '').trim().toLowerCase();
148
169
  }
149
170
  function pruneLoginAttempts(now) {
150
171
  for (const [key, st] of loginAttempts) {
@@ -152,25 +173,25 @@ function pruneLoginAttempts(now) {
152
173
  }
153
174
  }
154
175
  // Segundos restantes de bloqueo (0 = puede intentar).
155
- function loginLockRemaining(req) {
176
+ function loginLockRemaining(req, user) {
156
177
  const now = Date.now();
157
178
  pruneLoginAttempts(now);
158
- const st = loginAttempts.get(clientKey(req));
179
+ const st = loginAttempts.get(clientKey(req, user));
159
180
  if (!st || !st.lockUntil) return 0;
160
181
  const left = st.lockUntil - now;
161
182
  return left > 0 ? Math.ceil(left / 1000) : 0;
162
183
  }
163
- function loginRecordFailure(req) {
184
+ function loginRecordFailure(req, user) {
164
185
  const now = Date.now();
165
- const key = clientKey(req);
186
+ const key = clientKey(req, user);
166
187
  let st = loginAttempts.get(key);
167
188
  if (!st || (!st.lockUntil && now - st.first > LOGIN_WINDOW_MS)) st = { count: 0, first: now, lockUntil: 0 };
168
189
  st.count++;
169
190
  if (st.count >= LOGIN_MAX_ATTEMPTS) st.lockUntil = now + LOGIN_LOCK_MS;
170
191
  loginAttempts.set(key, st);
171
192
  }
172
- function loginClear(req) {
173
- loginAttempts.delete(clientKey(req));
193
+ function loginClear(req, user) {
194
+ loginAttempts.delete(clientKey(req, user));
174
195
  }
175
196
 
176
197
  // ---------------------------------------------------------------------------
@@ -197,7 +218,7 @@ function json(res, code, data) {
197
218
  module.exports = {
198
219
  SESSION_COOKIE, REMEMBER_COOKIE,
199
220
  parseCookies, serializeCookie, isSecure,
200
- makeSession, readSession, currentCsrf, requireLogin, requireCsrf,
221
+ makeSession, readSession, currentCsrf, requireLogin, requireRole, requireCsrf,
201
222
  startSession, endSession, rememberAutoLogin,
202
223
  loginLockRemaining, loginRecordFailure, loginClear,
203
224
  checkBridgeToken, safeEqual, json,
@@ -12,7 +12,7 @@
12
12
  */
13
13
  'use strict';
14
14
 
15
- const { spawn } = require('node:child_process');
15
+ const { spawn, spawnSync } = require('node:child_process');
16
16
  const fs = require('node:fs');
17
17
  const os = require('node:os');
18
18
  const path = require('node:path');
@@ -1549,6 +1549,77 @@ async function handleFsCommand(cmd) {
1549
1549
  }
1550
1550
  }
1551
1551
 
1552
+ // ---------------------------------------------------------------------------
1553
+ // Cambios del proyecto (git status / git diff) para revisar lo que tocó el
1554
+ // agente desde el celular. Corre git en la carpeta de la sesión (validada
1555
+ // dentro del workspace, igual que proc_start).
1556
+ // ---------------------------------------------------------------------------
1557
+ function parseGitStatus(text) {
1558
+ const files = [];
1559
+ let branch = '';
1560
+ for (const line of String(text).split('\n')) {
1561
+ if (line === '') continue;
1562
+ if (line.startsWith('## ')) { branch = line.slice(3).trim(); continue; }
1563
+ const status = line.slice(0, 2).trim() || '?';
1564
+ let p = line.slice(3);
1565
+ const arrow = p.indexOf(' -> ');
1566
+ if (arrow >= 0) p = p.slice(arrow + 4);
1567
+ files.push({ status, path: p });
1568
+ }
1569
+ return { branch, files };
1570
+ }
1571
+
1572
+ function gitIn(folder, args, timeout = 30000) {
1573
+ const r = spawnSync('git', ['-C', folder].concat(args), {
1574
+ encoding: 'utf8',
1575
+ timeout,
1576
+ windowsHide: true,
1577
+ maxBuffer: 16 * 1024 * 1024,
1578
+ });
1579
+ if (r.error) throw new Error(r.error.message || 'no se pudo ejecutar git');
1580
+ const text = String(r.stdout || '');
1581
+ return { code: r.status, text, err: String(r.stderr || '').trim() };
1582
+ }
1583
+
1584
+ async function handleGitCommand(cmd) {
1585
+ let folder;
1586
+ try { folder = procResolveFolder(cmd.args && cmd.args[0]); }
1587
+ catch (e) {
1588
+ await api('command_done', { id: cmd.id, ok: false, text: '', error: e.message }, cmd._t);
1589
+ return;
1590
+ }
1591
+ try {
1592
+ if (cmd.name === 'git_status') {
1593
+ const r = gitIn(folder, ['status', '--porcelain=v1', '-b', '--untracked-files=all']);
1594
+ if (r.code !== 0) throw new Error(r.err || 'no es un repositorio git');
1595
+ await api('command_done', { id: cmd.id, ok: true, text: JSON.stringify(parseGitStatus(r.text)), error: '' }, cmd._t);
1596
+ } else if (cmd.name === 'git_checkout') {
1597
+ // Revierte cambios de archivos RASTREADOS dentro de la carpeta de la
1598
+ // sesion. Sin 2do argumento revierte todo (`.`); con argumento,
1599
+ // solo ese archivo relativo (sin `..` ni rutas absolutas).
1600
+ let target = (cmd.args && cmd.args.length > 1) ? String(cmd.args[1]) : '.';
1601
+ if (target !== '.') {
1602
+ if (path.isAbsolute(target) || target.split(/[\\/]/).includes('..')) {
1603
+ throw new Error('ruta invalida');
1604
+ }
1605
+ }
1606
+ const r = gitIn(folder, ['checkout', '--', target]);
1607
+ if (r.code !== 0) throw new Error(r.err || 'no se pudo revertir');
1608
+ await api('command_done', { id: cmd.id, ok: true, text: JSON.stringify({ reverted: target }), error: '' }, cmd._t);
1609
+ } else {
1610
+ // diff HEAD: cambios preparados + sin preparar. `fs_result` admite
1611
+ // respuestas mas grandes que command_done (8000 car.).
1612
+ const r = gitIn(folder, ['--no-pager', 'diff', 'HEAD', '--no-color', '--no-ext-diff', '--no-renames']);
1613
+ if (r.code !== 0) throw new Error(r.err || 'no es un repositorio git');
1614
+ await api('fs_result', { id: cmd.id, ok: true, text: JSON.stringify({ diff: r.text }), error: '' }, cmd._t);
1615
+ }
1616
+ log('git ' + cmd.name + ' ok en ' + folder);
1617
+ } catch (e) {
1618
+ await api('command_done', { id: cmd.id, ok: false, text: '', error: e.message }, cmd._t);
1619
+ log('git ' + cmd.name + ' error: ' + e.message);
1620
+ }
1621
+ }
1622
+
1552
1623
  // ---------------------------------------------------------------------------
1553
1624
  // Procesa comandos read-only encolados por la web.
1554
1625
  // ---------------------------------------------------------------------------
@@ -2044,6 +2115,11 @@ async function handleCommand(cmd) {
2044
2115
  await handleFsCommand(cmd);
2045
2116
  return;
2046
2117
  }
2118
+ // Cambios git del proyecto (status/diff/revertir).
2119
+ if (cmd.name === 'git_status' || cmd.name === 'git_diff' || cmd.name === 'git_checkout') {
2120
+ await handleGitCommand(cmd);
2121
+ return;
2122
+ }
2047
2123
  // Túneles también son del puente (procesos de esta PC).
2048
2124
  if (cmd.name === 'tunnel_start') { await tunnelStart(cmd); return; }
2049
2125
  if (cmd.name === 'tunnel_stop') { await tunnelStop(cmd); return; }
package/src/cli.js CHANGED
@@ -183,6 +183,12 @@ async function cmdInit(argv) {
183
183
  console.log('Usa --force para reconfigurar (se pisan config.json/app.json).');
184
184
  return 0;
185
185
  }
186
+ if (paths.exists() && flags.force) {
187
+ const prev = config.readApp();
188
+ if ((prev.users || []).length > 1) {
189
+ console.log('aviso: --force deja un unico usuario admin; se pierden los demas usuarios.');
190
+ }
191
+ }
186
192
 
187
193
  if (stopRunningServer()) {
188
194
  console.log('Habia un server corriendo; lo detuve para reconfigurar (reinicialo con `openbridge server`).');
@@ -235,16 +241,16 @@ async function cmdInit(argv) {
235
241
  bridgeId: id,
236
242
  bridgeName: name,
237
243
  };
244
+ const adminName = flags.user || 'admin';
238
245
  const appCfg = {
239
246
  ...config.DEFAULT_APP,
240
247
  port,
241
248
  host: '127.0.0.1',
242
- username: flags.user || 'admin',
243
- password: config.hashPassword(password),
249
+ users: [config.makeUser(adminName, password, 'admin')],
244
250
  csrfSecret,
245
251
  bridgeToken,
246
252
  vapid,
247
- tunnel: { provider, domain: '' },
253
+ tunnel: { provider, domain: flags.domain ? String(flags.domain) : '' },
248
254
  };
249
255
  config.writeBridge(bridgeCfg);
250
256
  config.writeApp(appCfg);
@@ -259,7 +265,7 @@ async function cmdInit(argv) {
259
265
  console.log(' workspace : ' + workspace);
260
266
  console.log(' puente : ' + id + ' (' + name + ')');
261
267
  console.log(' URL local : http://127.0.0.1:' + port + '/chat.php');
262
- console.log(' usuario : ' + appCfg.username);
268
+ console.log(' usuario : ' + adminName);
263
269
  console.log(' contrasena: ' + password + (generated ? ' (generada)' : ''));
264
270
  console.log(' tunel : ' + provider);
265
271
  console.log(' carpetas : ' + folders.length + ' en folders.json');
@@ -488,6 +494,51 @@ async function cmdQr() {
488
494
  return 0;
489
495
  }
490
496
 
497
+ // Muestra o cambia el proveedor de tunel (y su dominio fijo) sin reconfigurar
498
+ // todo. Cambiar el proveedor requiere reiniciar el server.
499
+ async function cmdTunnel(argv) {
500
+ const { flags, _ } = parseArgs(argv);
501
+ if (!paths.exists()) {
502
+ console.error('No hay configuracion. Corre primero: openbridge init');
503
+ return 1;
504
+ }
505
+ const app = config.readApp();
506
+ const cur = app.tunnel || { provider: 'tunnelmole', domain: '' };
507
+ const arg = String(_[0] || '').toLowerCase();
508
+
509
+ if (arg === '' || arg === 'status' || arg === 'show') {
510
+ const rt = readRuntime();
511
+ const running = rt && pidAlive(rt.pid);
512
+ console.log('proveedor : ' + (cur.provider || 'tunnelmole'));
513
+ console.log('dominio : ' + (cur.domain || '(aleatorio)'));
514
+ console.log('estado : ' + (running ? 'corriendo' : 'detenido'));
515
+ if (running && rt.publicUrl) console.log('publico : ' + rt.publicUrl);
516
+ return 0;
517
+ }
518
+
519
+ let provider = arg;
520
+ if (provider === 'cloudflared') provider = 'cloudflare';
521
+ if (!['tunnelmole', 'ngrok', 'cloudflare', 'none'].includes(provider)) {
522
+ console.error('Proveedor desconocido: ' + arg + ' (usar tunnelmole|ngrok|cloudflare|none)');
523
+ return 1;
524
+ }
525
+ const domain = flags.domain !== undefined ? String(flags.domain).trim() : String(cur.domain || '');
526
+ app.tunnel = { provider, domain };
527
+ config.writeApp(app);
528
+ console.log('Tunel configurado: ' + provider + (domain ? ' (dominio ' + domain + ')' : ''));
529
+ if (provider === 'ngrok' && !domain) {
530
+ console.log('aviso: ngrok sin dominio fijo da URL aleatoria; pasá --domain <sub.ngrok.app> para una estable.');
531
+ }
532
+ if (provider === 'cloudflare' && domain) {
533
+ console.log('aviso: el quick tunnel de cloudflared ignora el dominio (URL aleatoria).');
534
+ }
535
+ const rt = readRuntime();
536
+ if (rt && pidAlive(rt.pid)) {
537
+ console.log('Hay un server corriendo: reinicialo para aplicar (openbridge stop && openbridge server).');
538
+ }
539
+ return 0;
540
+ }
541
+
491
542
  async function cmdLogs(argv) {
492
543
  const { flags } = parseArgs(argv);
493
544
  const n = parseInt(flags.n || '40', 10) || 40;
@@ -648,23 +699,114 @@ async function cmdPasswd(argv) {
648
699
  return 1;
649
700
  }
650
701
  const app = config.readApp();
702
+ const name = String(flags.user || (app.users[0] && app.users[0].name) || 'admin');
703
+ const user = config.findUser(app, name);
704
+ if (!user) {
705
+ console.error('Usuario no encontrado: ' + name);
706
+ return 1;
707
+ }
651
708
  const interactive = !flags.yes && process.stdin.isTTY;
652
709
  let password = flags.password || '';
653
- if (!password && interactive) password = await askHidden('Nueva contrasena (enter = generar): ');
710
+ if (!password && interactive) password = await askHidden('Nueva contrasena para "' + name + '" (enter = generar): ');
654
711
  let generated = false;
655
712
  if (!password) {
656
713
  password = config.randomToken(12);
657
714
  generated = true;
658
715
  }
659
716
  if (stopRunningServer()) console.log('Habia un server corriendo; lo detuve para aplicar el cambio.');
660
- app.password = config.hashPassword(password);
717
+ user.password = config.hashPassword(password);
718
+ user.pv = (parseInt(user.pv, 10) || 1) + 1; // invalida las sesiones abiertas
661
719
  config.writeApp(app);
662
- console.log('Contrasena actualizada para "' + app.username + '".');
720
+ console.log('Contrasena actualizada para "' + user.name + '".');
663
721
  console.log(' contrasena: ' + password + (generated ? ' (generada)' : ''));
664
722
  console.log(' volve a arrancar: openbridge server');
665
723
  return 0;
666
724
  }
667
725
 
726
+ // ---------------------------------------------------------------------------
727
+ // users: administracion de usuarios y roles (solo desde la CLI local)
728
+ // ---------------------------------------------------------------------------
729
+ async function cmdUsers(argv) {
730
+ const { flags, _ } = parseArgs(argv);
731
+ if (!paths.exists()) {
732
+ console.error('No hay configuracion en ' + paths.home() + '. Corre primero: openbridge init');
733
+ return 1;
734
+ }
735
+ const app = config.readApp();
736
+ const sub = String(_[0] || 'list').toLowerCase();
737
+ const name = _[1] || flags.name;
738
+
739
+ const persist = (msg) => {
740
+ if (stopRunningServer()) console.log('Habia un server corriendo; lo detuve para aplicar el cambio.');
741
+ config.writeApp(app);
742
+ console.log(msg);
743
+ console.log(' volve a arrancar: openbridge server');
744
+ };
745
+
746
+ if (sub === 'list' || sub === 'ls') {
747
+ const users = app.users || [];
748
+ if (!users.length) { console.log('(sin usuarios)'); return 0; }
749
+ for (const u of users) {
750
+ console.log((u.disabled ? 'x' : 'o') + ' ' + u.name.padEnd(16) + '[' + u.role + ']');
751
+ }
752
+ return 0;
753
+ }
754
+
755
+ if (sub === 'add') {
756
+ if (!config.userValidName(name)) { console.error('Nombre invalido (letras, numeros, . _ -; 1-32).'); return 1; }
757
+ if (config.findUser(app, name)) { console.error('Ya existe el usuario "' + name + '".'); return 1; }
758
+ let password = flags.password || '';
759
+ if (!password && process.stdin.isTTY) password = await askHidden('Contrasena para "' + name + '" (enter = generar): ');
760
+ let generated = false;
761
+ if (!password) { password = config.randomToken(12); generated = true; }
762
+ const role = config.USER_ROLES.includes(flags.role) ? flags.role : 'user';
763
+ app.users.push(config.makeUser(name, password, role));
764
+ persist('Usuario "' + name + '" agregado [' + role + '].');
765
+ console.log(' contrasena: ' + password + (generated ? ' (generada)' : ''));
766
+ return 0;
767
+ }
768
+
769
+ if (sub === 'remove' || sub === 'del' || sub === 'rm') {
770
+ const user = config.findUser(app, name);
771
+ if (!user) { console.error('Usuario no encontrado: ' + name); return 1; }
772
+ if (user.role === 'admin' && config.adminCount(app) <= 1) { console.error('No podes borrar al ultimo admin.'); return 1; }
773
+ app.users = app.users.filter((u) => u.id !== user.id);
774
+ persist('Usuario "' + user.name + '" borrado.');
775
+ return 0;
776
+ }
777
+
778
+ if (sub === 'passwd' || sub === 'password') {
779
+ if (!name) { console.error('Uso: openbridge users passwd <nombre> [--password <clave>]'); return 1; }
780
+ const extra = ['--user', name];
781
+ if (flags.password) extra.push('--password', String(flags.password));
782
+ return cmdPasswd(extra);
783
+ }
784
+
785
+ if (sub === 'role') {
786
+ const user = config.findUser(app, name);
787
+ if (!user) { console.error('Usuario no encontrado: ' + name); return 1; }
788
+ const role = String(_[2] || flags.role || '').toLowerCase();
789
+ if (!config.USER_ROLES.includes(role)) { console.error('Rol invalido (admin|user).'); return 1; }
790
+ if (user.role === 'admin' && role !== 'admin' && config.adminCount(app) <= 1) { console.error('No podes quitarle el admin al ultimo admin.'); return 1; }
791
+ user.role = role;
792
+ persist('Rol de "' + user.name + '" -> ' + role + '.');
793
+ return 0;
794
+ }
795
+
796
+ if (sub === 'disable' || sub === 'enable') {
797
+ const user = config.findUser(app, name);
798
+ if (!user) { console.error('Usuario no encontrado: ' + name); return 1; }
799
+ const disabled = sub === 'disable';
800
+ if (disabled && user.role === 'admin' && config.adminCount(app) <= 1) { console.error('No podes deshabilitar al ultimo admin.'); return 1; }
801
+ user.disabled = disabled;
802
+ persist('Usuario "' + user.name + '" ' + (disabled ? 'deshabilitado' : 'habilitado') + '.');
803
+ return 0;
804
+ }
805
+
806
+ console.error('Uso: openbridge users [list|add|remove|passwd|role|disable|enable] [nombre] [--role admin|user] [--password <clave>]');
807
+ return 1;
808
+ }
809
+
668
810
  // ---------------------------------------------------------------------------
669
811
  // autostart
670
812
  // ---------------------------------------------------------------------------
@@ -781,10 +923,12 @@ function usage() {
781
923
  console.log('');
782
924
  console.log(' init Configura la casa (workspace, contrasena, tunel)');
783
925
  console.log(' server Arranca en segundo plano (muestra el estado al levantar)');
784
- console.log(' passwd Cambia la contrasena de acceso (--password <clave>)');
926
+ console.log(' passwd Cambia la contrasena de acceso (--user <nombre> --password <clave>)');
927
+ console.log(' users Usuarios y roles (list|add|remove|passwd|role|disable|enable)');
785
928
  console.log(' stop Detiene el server en segundo plano');
786
929
  console.log(' status Estado del server, puente y chats');
787
930
  console.log(' qr Muestra la URL (publica o local) como QR para el celular');
931
+ console.log(' tunnel Muestra o cambia el proveedor de tunel (tunnelmole|ngrok|cloudflare|none) [--domain]');
788
932
  console.log(' logs Ultimas lineas de los logs (--follow --server --bridge)');
789
933
  console.log(' bridge Corre solo el puente (--api --token --id --name)');
790
934
  console.log(' import Trae data/ de OpenConex (<data-dir> [--force])');
@@ -793,7 +937,7 @@ function usage() {
793
937
  console.log(' doctor Verifica Node, opencode, configuracion y puerto');
794
938
  console.log('');
795
939
  console.log('Opciones comunes: --dir <ruta> (casa portable; default: directorio actual)');
796
- console.log('init: --workspace --name --id --port --password --tunnel --yes --force');
940
+ console.log('init: --workspace --name --id --port --password --tunnel --domain --yes --force');
797
941
  console.log('server: --port --no-tunnel --stream (sin --stream corre en segundo plano)');
798
942
  }
799
943
 
@@ -812,10 +956,12 @@ async function main(argv) {
812
956
  switch (cmd) {
813
957
  case 'init': return cmdInit(args.slice(1));
814
958
  case 'passwd': case 'password': return cmdPasswd(args.slice(1));
959
+ case 'users': case 'user': return cmdUsers(args.slice(1));
815
960
  case 'server': case 'start': return cmdServer(args.slice(1));
816
961
  case 'stop': return cmdStop();
817
962
  case 'status': return cmdStatus();
818
963
  case 'qr': return cmdQr();
964
+ case 'tunnel': return cmdTunnel(args.slice(1));
819
965
  case 'logs': return cmdLogs(args.slice(1));
820
966
  case 'bridge': return cmdBridge(args.slice(1));
821
967
  case 'import': return cmdImport(args.slice(1));
package/src/config.js CHANGED
@@ -27,14 +27,17 @@ const DEFAULT_APP = {
27
27
  port: 8799,
28
28
  host: '127.0.0.1',
29
29
  baseUrl: '',
30
- username: 'admin',
31
- password: null, // { algo, salt, hash, keylen }
30
+ username: 'admin', // legado (migra a users[0])
31
+ password: null, // legado (migra a users[0])
32
+ users: [], // [{ id, name, role, password, pv, created, disabled }]
32
33
  csrfSecret: '',
33
34
  bridgeToken: '',
34
35
  vapid: { publicKey: '', privateKey: '' },
35
36
  tunnel: { provider: 'tunnelmole', domain: '' },
36
37
  };
37
38
 
39
+ const USER_ROLES = ['admin', 'user'];
40
+
38
41
  function readJsonFile(file, fallback) {
39
42
  try {
40
43
  const raw = fs.readFileSync(file, 'utf8');
@@ -62,10 +65,17 @@ function readApp() {
62
65
  const data = { ...DEFAULT_APP, ...readJsonFile(paths.appConfigPath(), DEFAULT_APP) };
63
66
  data.vapid = { ...DEFAULT_APP.vapid, ...(data.vapid || {}) };
64
67
  data.tunnel = { ...DEFAULT_APP.tunnel, ...(data.tunnel || {}) };
68
+ normalizeApp(data);
65
69
  return data;
66
70
  }
67
71
  function writeApp(cfg) {
68
- writeJsonFile(paths.appConfigPath(), cfg);
72
+ const out = { ...cfg };
73
+ // Una vez migrado a `users`, no dejamos la contrasena legado suelta.
74
+ if (Array.isArray(out.users) && out.users.length) {
75
+ delete out.username;
76
+ delete out.password;
77
+ }
78
+ writeJsonFile(paths.appConfigPath(), out);
69
79
  }
70
80
 
71
81
  function randomToken(bytes = 32) {
@@ -93,6 +103,65 @@ function verifyPassword(password, stored) {
93
103
  return crypto.timingSafeEqual(calc, expected);
94
104
  }
95
105
 
106
+ // ---------------------------------------------------------------------------
107
+ // Usuarios (multiusuario con roles admin|user). El modelo viejo de un solo
108
+ // `username`+`password` se migra solo a `users[0]`.
109
+ // ---------------------------------------------------------------------------
110
+ function userValidName(name) {
111
+ return typeof name === 'string' && /^[A-Za-z0-9._-]{1,32}$/.test(name);
112
+ }
113
+ function makeUser(name, password, role = 'user') {
114
+ return {
115
+ id: 'u_' + crypto.randomBytes(6).toString('hex'),
116
+ name: String(name),
117
+ role: USER_ROLES.includes(role) ? role : 'user',
118
+ password: hashPassword(password),
119
+ pv: 1,
120
+ created: new Date().toISOString(),
121
+ disabled: false,
122
+ };
123
+ }
124
+ // Devuelve la lista de usuarios, sintetizando el admin legado si hace falta.
125
+ function userList(app) {
126
+ if (app && Array.isArray(app.users) && app.users.length) return app.users;
127
+ if (app && app.password && app.password.hash) {
128
+ return [{
129
+ id: 'u1',
130
+ name: (typeof app.username === 'string' && app.username) ? app.username : 'admin',
131
+ role: 'admin',
132
+ password: app.password,
133
+ pv: 1,
134
+ created: '',
135
+ disabled: false,
136
+ }];
137
+ }
138
+ return [];
139
+ }
140
+ function normalizeApp(app) {
141
+ if (!app || typeof app !== 'object') return app;
142
+ if (!Array.isArray(app.users)) app.users = [];
143
+ app.users = app.users.filter((u) => u && typeof u === 'object' && typeof u.name === 'string' && u.password);
144
+ if (!app.users.length) {
145
+ const migrated = userList(app);
146
+ if (migrated.length) app.users = migrated;
147
+ }
148
+ return app;
149
+ }
150
+ function findUser(app, name) {
151
+ const n = String(name || '').trim().toLowerCase();
152
+ if (n === '') return null;
153
+ return userList(app).find((u) => String(u.name).toLowerCase() === n) || null;
154
+ }
155
+ function userById(app, id) {
156
+ return userList(app).find((u) => u.id === id) || null;
157
+ }
158
+ function verifyUserPassword(user, password) {
159
+ return !!user && verifyPassword(password, user.password);
160
+ }
161
+ function adminCount(app) {
162
+ return userList(app).filter((u) => u.role === 'admin' && !u.disabled).length;
163
+ }
164
+
96
165
  // Claves VAPID (P-256) en el formato que espera web-push (base64url).
97
166
  function genVapid() {
98
167
  const webpush = require('web-push');
@@ -101,8 +170,9 @@ function genVapid() {
101
170
  }
102
171
 
103
172
  module.exports = {
104
- DEFAULT_BRIDGE, DEFAULT_APP,
173
+ DEFAULT_BRIDGE, DEFAULT_APP, USER_ROLES,
105
174
  readBridge, writeBridge, readApp, writeApp,
106
175
  randomToken, hashPassword, verifyPassword, genVapid,
176
+ userValidName, makeUser, userList, normalizeApp, findUser, userById, verifyUserPassword, adminCount,
107
177
  readJsonFile, writeJsonFile,
108
178
  };