@danieltmn/openbridge 0.3.0 → 0.5.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/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,12 +241,12 @@ 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,
@@ -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');
@@ -594,8 +600,8 @@ async function cmdLogs(argv) {
594
600
  // ---------------------------------------------------------------------------
595
601
  async function cmdBridge(argv) {
596
602
  const { flags } = parseArgs(argv);
597
- if (!paths.exists()) {
598
- console.error('No hay configuracion en ' + paths.home() + '. Corre primero: openbridge init');
603
+ if (!fs.existsSync(paths.configPath())) {
604
+ console.error('No hay configuracion de puente en ' + paths.home() + '. Corre primero: openbridge join <url> o openbridge init');
599
605
  return 1;
600
606
  }
601
607
  // Permite apuntar a otro hub sin editar config.json a mano.
@@ -614,6 +620,41 @@ async function cmdBridge(argv) {
614
620
  return new Promise((resolve) => child.on('exit', (code) => resolve(code || 0)));
615
621
  }
616
622
 
623
+ // ---------------------------------------------------------------------------
624
+ // join: esta PC se suma como puente de un hub (otra PC con `openbridge server`).
625
+ // Guarda la URL/token/identidad en config.json y arranca el puente.
626
+ // ---------------------------------------------------------------------------
627
+ async function cmdJoin(argv) {
628
+ const { flags, _ } = parseArgs(argv);
629
+ const url = String(_[0] || flags.api || '').trim();
630
+ if (!url || !/^https?:\/\//i.test(url)) {
631
+ console.error('Uso: openbridge join <url-del-hub> [--token <t>] [--id <pc>] [--name "<nombre>"] [--no-start]');
632
+ console.error('Ej.: openbridge join https://mi-pc.trycloudflare.com --token <t> --id pc2 --name "PC oficina"');
633
+ return 1;
634
+ }
635
+ paths.ensureDirs();
636
+ const cfg = config.readBridge();
637
+ cfg.apiUrl = url.replace(/\/+$/, '');
638
+ if (typeof flags.token === 'string') cfg.apiToken = flags.token;
639
+ if (typeof flags.id === 'string') cfg.bridgeId = sanitizeId(flags.id);
640
+ else if (!cfg.bridgeId) cfg.bridgeId = sanitizeId(os.hostname());
641
+ if (typeof flags.name === 'string' && flags.name.trim()) cfg.bridgeName = flags.name.trim().slice(0, 40);
642
+ else if (!cfg.bridgeName) cfg.bridgeName = cfg.bridgeId;
643
+ config.writeBridge(cfg);
644
+
645
+ console.log('Puente vinculado al hub: ' + cfg.apiUrl);
646
+ console.log(' id : ' + cfg.bridgeId);
647
+ console.log(' nombre : ' + cfg.bridgeName);
648
+ if (!cfg.apiToken) console.log('aviso: sin token (--token). Solo sirve si el hub no exige token del puente.');
649
+ if (flags['no-start']) {
650
+ console.log('Arrancalo cuando quieras con: openbridge bridge');
651
+ return 0;
652
+ }
653
+ console.log('');
654
+ console.log('Arrancando el puente (Ctrl+C para salir)...');
655
+ return cmdBridge([]);
656
+ }
657
+
617
658
  // ---------------------------------------------------------------------------
618
659
  // import / reset (datos)
619
660
  // ---------------------------------------------------------------------------
@@ -693,23 +734,114 @@ async function cmdPasswd(argv) {
693
734
  return 1;
694
735
  }
695
736
  const app = config.readApp();
737
+ const name = String(flags.user || (app.users[0] && app.users[0].name) || 'admin');
738
+ const user = config.findUser(app, name);
739
+ if (!user) {
740
+ console.error('Usuario no encontrado: ' + name);
741
+ return 1;
742
+ }
696
743
  const interactive = !flags.yes && process.stdin.isTTY;
697
744
  let password = flags.password || '';
698
- if (!password && interactive) password = await askHidden('Nueva contrasena (enter = generar): ');
745
+ if (!password && interactive) password = await askHidden('Nueva contrasena para "' + name + '" (enter = generar): ');
699
746
  let generated = false;
700
747
  if (!password) {
701
748
  password = config.randomToken(12);
702
749
  generated = true;
703
750
  }
704
751
  if (stopRunningServer()) console.log('Habia un server corriendo; lo detuve para aplicar el cambio.');
705
- app.password = config.hashPassword(password);
752
+ user.password = config.hashPassword(password);
753
+ user.pv = (parseInt(user.pv, 10) || 1) + 1; // invalida las sesiones abiertas
706
754
  config.writeApp(app);
707
- console.log('Contrasena actualizada para "' + app.username + '".');
755
+ console.log('Contrasena actualizada para "' + user.name + '".');
708
756
  console.log(' contrasena: ' + password + (generated ? ' (generada)' : ''));
709
757
  console.log(' volve a arrancar: openbridge server');
710
758
  return 0;
711
759
  }
712
760
 
761
+ // ---------------------------------------------------------------------------
762
+ // users: administracion de usuarios y roles (solo desde la CLI local)
763
+ // ---------------------------------------------------------------------------
764
+ async function cmdUsers(argv) {
765
+ const { flags, _ } = parseArgs(argv);
766
+ if (!paths.exists()) {
767
+ console.error('No hay configuracion en ' + paths.home() + '. Corre primero: openbridge init');
768
+ return 1;
769
+ }
770
+ const app = config.readApp();
771
+ const sub = String(_[0] || 'list').toLowerCase();
772
+ const name = _[1] || flags.name;
773
+
774
+ const persist = (msg) => {
775
+ if (stopRunningServer()) console.log('Habia un server corriendo; lo detuve para aplicar el cambio.');
776
+ config.writeApp(app);
777
+ console.log(msg);
778
+ console.log(' volve a arrancar: openbridge server');
779
+ };
780
+
781
+ if (sub === 'list' || sub === 'ls') {
782
+ const users = app.users || [];
783
+ if (!users.length) { console.log('(sin usuarios)'); return 0; }
784
+ for (const u of users) {
785
+ console.log((u.disabled ? 'x' : 'o') + ' ' + u.name.padEnd(16) + '[' + u.role + ']');
786
+ }
787
+ return 0;
788
+ }
789
+
790
+ if (sub === 'add') {
791
+ if (!config.userValidName(name)) { console.error('Nombre invalido (letras, numeros, . _ -; 1-32).'); return 1; }
792
+ if (config.findUser(app, name)) { console.error('Ya existe el usuario "' + name + '".'); return 1; }
793
+ let password = flags.password || '';
794
+ if (!password && process.stdin.isTTY) password = await askHidden('Contrasena para "' + name + '" (enter = generar): ');
795
+ let generated = false;
796
+ if (!password) { password = config.randomToken(12); generated = true; }
797
+ const role = config.USER_ROLES.includes(flags.role) ? flags.role : 'user';
798
+ app.users.push(config.makeUser(name, password, role));
799
+ persist('Usuario "' + name + '" agregado [' + role + '].');
800
+ console.log(' contrasena: ' + password + (generated ? ' (generada)' : ''));
801
+ return 0;
802
+ }
803
+
804
+ if (sub === 'remove' || sub === 'del' || sub === 'rm') {
805
+ const user = config.findUser(app, name);
806
+ if (!user) { console.error('Usuario no encontrado: ' + name); return 1; }
807
+ if (user.role === 'admin' && config.adminCount(app) <= 1) { console.error('No podes borrar al ultimo admin.'); return 1; }
808
+ app.users = app.users.filter((u) => u.id !== user.id);
809
+ persist('Usuario "' + user.name + '" borrado.');
810
+ return 0;
811
+ }
812
+
813
+ if (sub === 'passwd' || sub === 'password') {
814
+ if (!name) { console.error('Uso: openbridge users passwd <nombre> [--password <clave>]'); return 1; }
815
+ const extra = ['--user', name];
816
+ if (flags.password) extra.push('--password', String(flags.password));
817
+ return cmdPasswd(extra);
818
+ }
819
+
820
+ if (sub === 'role') {
821
+ const user = config.findUser(app, name);
822
+ if (!user) { console.error('Usuario no encontrado: ' + name); return 1; }
823
+ const role = String(_[2] || flags.role || '').toLowerCase();
824
+ if (!config.USER_ROLES.includes(role)) { console.error('Rol invalido (admin|user).'); return 1; }
825
+ if (user.role === 'admin' && role !== 'admin' && config.adminCount(app) <= 1) { console.error('No podes quitarle el admin al ultimo admin.'); return 1; }
826
+ user.role = role;
827
+ persist('Rol de "' + user.name + '" -> ' + role + '.');
828
+ return 0;
829
+ }
830
+
831
+ if (sub === 'disable' || sub === 'enable') {
832
+ const user = config.findUser(app, name);
833
+ if (!user) { console.error('Usuario no encontrado: ' + name); return 1; }
834
+ const disabled = sub === 'disable';
835
+ if (disabled && user.role === 'admin' && config.adminCount(app) <= 1) { console.error('No podes deshabilitar al ultimo admin.'); return 1; }
836
+ user.disabled = disabled;
837
+ persist('Usuario "' + user.name + '" ' + (disabled ? 'deshabilitado' : 'habilitado') + '.');
838
+ return 0;
839
+ }
840
+
841
+ console.error('Uso: openbridge users [list|add|remove|passwd|role|disable|enable] [nombre] [--role admin|user] [--password <clave>]');
842
+ return 1;
843
+ }
844
+
713
845
  // ---------------------------------------------------------------------------
714
846
  // autostart
715
847
  // ---------------------------------------------------------------------------
@@ -818,6 +950,53 @@ async function cmdDoctor() {
818
950
  return ok ? 0 : 1;
819
951
  }
820
952
 
953
+ // ---------------------------------------------------------------------------
954
+ // update: compara con npm y, con --yes, actualiza la instalacion global.
955
+ // ---------------------------------------------------------------------------
956
+ function npmRun(args) {
957
+ return spawnSync('npm', args, { encoding: 'utf8', shell: process.platform === 'win32', windowsHide: true });
958
+ }
959
+
960
+ async function cmdUpdate(argv) {
961
+ const { flags } = parseArgs(argv);
962
+ const tag = (typeof flags.tag === 'string' && flags.tag) ? flags.tag : 'latest';
963
+ const pkg = '@danieltmn/openbridge';
964
+ const spec = pkg + '@' + tag;
965
+ console.log('Version actual: ' + VERSION);
966
+
967
+ const view = npmRun(['view', spec, 'version', '--json']);
968
+ let latest = '';
969
+ try { latest = String(JSON.parse(view.stdout)).trim(); } catch (e) { latest = String(view.stdout || '').trim().replace(/"/g, ''); }
970
+ if (view.status !== 0 || !/^\d+\.\d+\.\d+/.test(latest)) {
971
+ console.error('No se pudo consultar npm (' + tag + '): ' + ((view.stderr || '').trim() || 'revisa tu conexion'));
972
+ return 1;
973
+ }
974
+ console.log('Disponible (' + tag + '): ' + latest);
975
+ if (latest === VERSION) {
976
+ console.log('Ya estas en la ultima version.');
977
+ return 0;
978
+ }
979
+ if (flags.check) {
980
+ console.log('Hay una version nueva. Corre: openbridge update --yes');
981
+ return 0;
982
+ }
983
+ if (!flags.yes && !flags.y) {
984
+ console.log('');
985
+ console.log('Para actualizar ahora:');
986
+ console.log(' openbridge update --yes (o) npm i -g ' + spec);
987
+ return 0;
988
+ }
989
+
990
+ console.log('Actualizando a ' + spec + '...');
991
+ const r = spawnSync('npm', ['i', '-g', spec], { stdio: 'inherit', shell: process.platform === 'win32', windowsHide: true });
992
+ if (r.status !== 0) {
993
+ console.error('La actualizacion fallo. Proba a mano: npm i -g ' + spec);
994
+ return 1;
995
+ }
996
+ console.log('Listo. Reinicia el server para usar la version nueva: openbridge stop && openbridge server');
997
+ return 0;
998
+ }
999
+
821
1000
  // ---------------------------------------------------------------------------
822
1001
  function usage() {
823
1002
  console.log('OpenBridge ' + VERSION + ' — tu opencode en el celular, sin hosting');
@@ -826,17 +1005,20 @@ function usage() {
826
1005
  console.log('');
827
1006
  console.log(' init Configura la casa (workspace, contrasena, tunel)');
828
1007
  console.log(' server Arranca en segundo plano (muestra el estado al levantar)');
829
- console.log(' passwd Cambia la contrasena de acceso (--password <clave>)');
1008
+ console.log(' passwd Cambia la contrasena de acceso (--user <nombre> --password <clave>)');
1009
+ console.log(' users Usuarios y roles (list|add|remove|passwd|role|disable|enable)');
830
1010
  console.log(' stop Detiene el server en segundo plano');
831
1011
  console.log(' status Estado del server, puente y chats');
832
1012
  console.log(' qr Muestra la URL (publica o local) como QR para el celular');
833
1013
  console.log(' tunnel Muestra o cambia el proveedor de tunel (tunnelmole|ngrok|cloudflare|none) [--domain]');
834
1014
  console.log(' logs Ultimas lineas de los logs (--follow --server --bridge)');
835
1015
  console.log(' bridge Corre solo el puente (--api --token --id --name)');
1016
+ console.log(' join Vincula esta PC como puente de un hub (<url> --token --id --name)');
836
1017
  console.log(' import Trae data/ de OpenConex (<data-dir> [--force])');
837
1018
  console.log(' reset Borra todos los chats/datos (--session <id> --yes)');
838
1019
  console.log(' autostart Instala/quita el arranque automatico (install|remove)');
839
1020
  console.log(' doctor Verifica Node, opencode, configuracion y puerto');
1021
+ console.log(' update Busca una version nueva en npm (--yes para actualizar)');
840
1022
  console.log('');
841
1023
  console.log('Opciones comunes: --dir <ruta> (casa portable; default: directorio actual)');
842
1024
  console.log('init: --workspace --name --id --port --password --tunnel --domain --yes --force');
@@ -858,6 +1040,7 @@ async function main(argv) {
858
1040
  switch (cmd) {
859
1041
  case 'init': return cmdInit(args.slice(1));
860
1042
  case 'passwd': case 'password': return cmdPasswd(args.slice(1));
1043
+ case 'users': case 'user': return cmdUsers(args.slice(1));
861
1044
  case 'server': case 'start': return cmdServer(args.slice(1));
862
1045
  case 'stop': return cmdStop();
863
1046
  case 'status': return cmdStatus();
@@ -865,10 +1048,12 @@ async function main(argv) {
865
1048
  case 'tunnel': return cmdTunnel(args.slice(1));
866
1049
  case 'logs': return cmdLogs(args.slice(1));
867
1050
  case 'bridge': return cmdBridge(args.slice(1));
1051
+ case 'join': return cmdJoin(args.slice(1));
868
1052
  case 'import': return cmdImport(args.slice(1));
869
1053
  case 'reset': return cmdReset(args.slice(1));
870
1054
  case 'autostart': return cmdAutostart(args.slice(1));
871
1055
  case 'doctor': return cmdDoctor();
1056
+ case 'update': return cmdUpdate(args.slice(1));
872
1057
  case 'version': case '-v': case '--version': console.log(VERSION); return 0;
873
1058
  case 'help': case '-h': case '--help': usage(); return 0;
874
1059
  default:
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
  };