@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/CHANGELOG.md +30 -0
- package/README.md +221 -144
- package/docs/ARCHITECTURE.md +49 -8
- package/package.json +1 -1
- package/src/auth.js +44 -23
- package/src/bridge/bridge.js +77 -1
- package/src/cli.js +155 -9
- package/src/config.js +74 -4
- package/src/web/assets/app.js +286 -43
- package/src/web/assets/manifest.webmanifest +2 -0
- package/src/web/assets/sw.js +1 -1
- package/src/web/routes.js +49 -13
- package/src/web/server.js +7 -0
- package/src/web/templates/chat.html +54 -2
- package/src/web/templates/login.html +5 -1
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
|
-
|
|
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,
|
|
57
|
-
const payload = b64url(JSON.stringify({ u:
|
|
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 ||
|
|
72
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
129
|
+
function makeRemember(app, user) {
|
|
111
130
|
const exp = Math.floor(Date.now() / 1000) + REMEMBER_DAYS * 86400;
|
|
112
|
-
const payload =
|
|
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 !==
|
|
129
|
-
const [
|
|
130
|
-
if (
|
|
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,
|
package/src/bridge/bridge.js
CHANGED
|
@@ -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
|
-
|
|
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 : ' +
|
|
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
|
-
|
|
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 "' +
|
|
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, //
|
|
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
|
-
|
|
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
|
};
|