@danieltmn/openbridge 0.1.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 +60 -0
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/bin/openbridge.js +18 -0
- package/docs/ARCHITECTURE.md +77 -0
- package/package.json +54 -0
- package/src/auth.js +204 -0
- package/src/bridge/bridge.js +2280 -0
- package/src/cli.js +791 -0
- package/src/config.js +108 -0
- package/src/log.js +22 -0
- package/src/paths.js +152 -0
- package/src/push.js +85 -0
- package/src/store/index.js +930 -0
- package/src/store/jsonfile.js +54 -0
- package/src/tunnel/index.js +141 -0
- package/src/web/assets/app.js +3939 -0
- package/src/web/assets/icons/icon-128x128.png +0 -0
- package/src/web/assets/icons/icon-144x144.png +0 -0
- package/src/web/assets/icons/icon-152x152.png +0 -0
- package/src/web/assets/icons/icon-192x192.png +0 -0
- package/src/web/assets/icons/icon-384x384.png +0 -0
- package/src/web/assets/icons/icon-512x512.png +0 -0
- package/src/web/assets/icons/icon-72x72.png +0 -0
- package/src/web/assets/icons/icon-96x96.png +0 -0
- package/src/web/assets/manifest.webmanifest +24 -0
- package/src/web/assets/sw.js +85 -0
- package/src/web/assets/themes/dark-plus.css +19 -0
- package/src/web/assets/themes/dark-red.css +18 -0
- package/src/web/assets/themes/default.css +18 -0
- package/src/web/assets/themes/github-light.css +19 -0
- package/src/web/assets/themes/high-contrast.css +18 -0
- package/src/web/assets/themes/index.json +15 -0
- package/src/web/assets/themes/light-plus.css +19 -0
- package/src/web/assets/themes/light.css +18 -0
- package/src/web/assets/themes/monokai.css +19 -0
- package/src/web/assets/themes/one-dark.css +19 -0
- package/src/web/assets/themes/solarized.css +18 -0
- package/src/web/assets/themes/terminal.css +28 -0
- package/src/web/assets/themes/themes.css +2 -0
- package/src/web/routes.js +871 -0
- package/src/web/server.js +130 -0
- package/src/web/templates/chat.html +1296 -0
- package/src/web/templates/login.html +144 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Todos los cambios relevantes de OpenBridge. Formato basado en
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/) y
|
|
5
|
+
[Versionado Semantico](https://semver.org/lang/es/).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-09-11
|
|
8
|
+
|
|
9
|
+
Primera version publicada. Paquete npm **`@danieltmn/openbridge`** y repo
|
|
10
|
+
**`tamnora/openbridge`** en GitHub (el nombre `openbridge` a secas esta bloqueado
|
|
11
|
+
por npm por similitud con `open-bridge`).
|
|
12
|
+
|
|
13
|
+
### Agregado
|
|
14
|
+
|
|
15
|
+
- CLI: `init`, `passwd`, `server`, `stop`, `status`, `logs`, `bridge`, `import`,
|
|
16
|
+
`reset`, `autostart`, `doctor`, `version`, `help`.
|
|
17
|
+
- Casa portable en `<base>/.openbridge/` (`config.json`, `app.json`,
|
|
18
|
+
`folders.json`, `data/`, `logs/`), con **migracion automatica** del layout
|
|
19
|
+
viejo.
|
|
20
|
+
- Store JSON (`src/store/`) con mutex por archivo y escritura atomica.
|
|
21
|
+
- Auth: cookie de sesion firmada, CSRF, remember-me, token del puente (scrypt) y
|
|
22
|
+
**rate limit / lockout** de login (5 intentos, 15 min).
|
|
23
|
+
- Server web (`src/web/`): router, templates, estaticos, SSE y API completa
|
|
24
|
+
compatible con `app.js`/`bridge.js`; logs reales a `logs/server.log`.
|
|
25
|
+
- Web Push (`src/push.js`): VAPID + `web-push`.
|
|
26
|
+
- Tuneles enchufables: `tunnelmole`, `ngrok` y `cloudflare`.
|
|
27
|
+
- `openbridge server` en segundo plano (muestra el estado al levantar);
|
|
28
|
+
`--stream` para primer plano. `--detach` se mantiene como alias.
|
|
29
|
+
- `bridge --api --token --id --name` para apuntar a otro hub sin editar
|
|
30
|
+
`config.json`.
|
|
31
|
+
- `import <data-dir>` (trae `data/` de OpenConex), `reset`
|
|
32
|
+
(`--session <id>` / `--yes`) y `autostart install|remove` (Windows/Linux/macOS).
|
|
33
|
+
- `logs --follow`, `logs --server` y `logs --bridge`.
|
|
34
|
+
- Consumo de **tokens y capacidad de contexto** por sesion (sidebar, encabezado,
|
|
35
|
+
statusbar y franja de trabajo); el puente sincroniza `models_ctx` desde
|
|
36
|
+
`opencode models --verbose`.
|
|
37
|
+
- Indicador KITT con bordes y estela luminosa.
|
|
38
|
+
- Guard de Node < 18 con mensaje amigable.
|
|
39
|
+
- Tests con `node --test` (store, auth, API, `safeJoinWorkspace`, migracion) y
|
|
40
|
+
CI en Windows/Linux/macOS.
|
|
41
|
+
- `docs/ARCHITECTURE.md`, `CHANGELOG.md`, `AGENTS.md`, README y LICENSE.
|
|
42
|
+
|
|
43
|
+
### Cambiado
|
|
44
|
+
|
|
45
|
+
- **Login solo con contrasena**: se quito el campo de usuario (es un unico
|
|
46
|
+
`admin`).
|
|
47
|
+
- `init --force` y `passwd` detienen un server en ejecucion antes de reescribir
|
|
48
|
+
la config, para no dejar la contrasena vieja en memoria.
|
|
49
|
+
- `init`: contrasena oculta al escribir, resumen con credenciales/URL, validacion
|
|
50
|
+
del proveedor de tunel y exclusion de `.openbridge/` en el escaneo.
|
|
51
|
+
- `status`: muestra puente en linea y cantidad de chats.
|
|
52
|
+
- `stop`: mata el arbol completo de procesos (Windows `taskkill /T`; POSIX grupo
|
|
53
|
+
detached).
|
|
54
|
+
- Mensaje claro cuando el puerto esta ocupado.
|
|
55
|
+
|
|
56
|
+
### Corregido
|
|
57
|
+
|
|
58
|
+
- El servidor ya no falla en silencio: los errores y los 4xx/5xx van a
|
|
59
|
+
`logs/server.log`.
|
|
60
|
+
- Evita anidar `.openbridge/.openbridge` y valida la instancia ya corriendo.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OpenBridge
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# OpenBridge
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@danieltmn/openbridge)
|
|
4
|
+
[](https://github.com/tamnora/openbridge/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
Tu **opencode** en el celular, **sin hosting**: corre la app, el puente y un
|
|
8
|
+
túnel público desde tu PC. El celular entra por la URL del túnel y vos manejás
|
|
9
|
+
opencode (tus carpetas, modelos y chats) desde donde estés.
|
|
10
|
+
|
|
11
|
+
> Proyecto nuevo e independiente, portado a **Node** desde OpenConex (PHP).
|
|
12
|
+
> Ya no hace falta subir nada a un hosting ni depender de uno.
|
|
13
|
+
|
|
14
|
+
## Cómo funciona
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
[Celular] ──► URL del túnel ──► App Node en tu PC ──► .openbridge/data/ (JSON)
|
|
18
|
+
│
|
|
19
|
+
└──► bridge (Node) ──► opencode run
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
- **Un solo runtime: Node 18+.** La app web, la API, los datos y el puente viven
|
|
23
|
+
en el mismo proceso/hogar.
|
|
24
|
+
- **opencode** es el único requisito externo (el motor que responde).
|
|
25
|
+
- **Túnel** con TunnelMole (gratis, sin cuenta), ngrok o Cloudflare para exponer
|
|
26
|
+
la app a internet.
|
|
27
|
+
|
|
28
|
+
## Requisitos
|
|
29
|
+
|
|
30
|
+
- [Node.js](https://nodejs.org) 18 o superior.
|
|
31
|
+
- `opencode` instalado y autenticado: `npm i -g opencode-ai`.
|
|
32
|
+
|
|
33
|
+
## Instalación
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# opción 1: sin instalar nada (npx)
|
|
37
|
+
npx @danieltmn/openbridge init # asistente: workspace, contraseña, túnel, puerto
|
|
38
|
+
npx @danieltmn/openbridge server # arranca en segundo plano y muestra el estado/URL
|
|
39
|
+
|
|
40
|
+
# opción 2: instalar global
|
|
41
|
+
npm i -g @danieltmn/openbridge
|
|
42
|
+
openbridge init
|
|
43
|
+
openbridge server
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Al arrancar (queda en segundo plano) imprime el **estado con la URL pública**
|
|
47
|
+
(`https://….tunnelmole.net/chat.php`): abríla desde el celular y logueate con el
|
|
48
|
+
usuario (`admin`) y la contraseña que elegiste en `init`. Para correrlo en
|
|
49
|
+
primer plano usá `openbridge server --stream`; para detenerlo, `openbridge stop`.
|
|
50
|
+
|
|
51
|
+
### Opciones de `init`
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
--workspace <ruta> carpeta de trabajo (donde están tus proyectos)
|
|
55
|
+
--name "PC 1" nombre visible de esta computadora
|
|
56
|
+
--id pc1 identificador corto (default: derivado del nombre)
|
|
57
|
+
--port 8799 puerto local
|
|
58
|
+
--password <clave> contraseña de acceso
|
|
59
|
+
--tunnel <prov> tunnelmole | ngrok | cloudflare | none
|
|
60
|
+
--yes sin preguntas (usa defaults)
|
|
61
|
+
--force reconfigura aunque ya exista
|
|
62
|
+
--dir <ruta> casa portable (default: directorio actual)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Comandos
|
|
66
|
+
|
|
67
|
+
| Comando | Qué hace |
|
|
68
|
+
|---|---|
|
|
69
|
+
| `openbridge init` | Configura la casa (workspace, contraseña, túnel, puerto) |
|
|
70
|
+
| `openbridge passwd` | Cambia la contraseña de acceso (detiene el server si corre) |
|
|
71
|
+
| `openbridge server` | Arranca app + puente + túnel en **segundo plano** y muestra el estado (`--stream` = primer plano) |
|
|
72
|
+
| `openbridge stop` | Detiene el server y su árbol de procesos |
|
|
73
|
+
| `openbridge status` | Estado, URL, puente en línea y chats |
|
|
74
|
+
| `openbridge logs` | Logs (`--follow`, `--server`, `--bridge`) |
|
|
75
|
+
| `openbridge bridge` | Corre **solo** el puente (`--api --token --id --name`) |
|
|
76
|
+
| `openbridge import` | Trae `data/` de OpenConex (`<data-dir> [--force]`) |
|
|
77
|
+
| `openbridge reset` | Borra chats/datos (`--session <id>`, `--yes`) |
|
|
78
|
+
| `openbridge autostart` | Arranque automático (`install`/`remove`) |
|
|
79
|
+
| `openbridge doctor` | Verifica Node, opencode, configuración y puerto |
|
|
80
|
+
|
|
81
|
+
## Casa portable
|
|
82
|
+
|
|
83
|
+
Todo vive dentro de **`.openbridge/`** en el directorio donde corrés `init`
|
|
84
|
+
(o `--dir` / `OPENBRIDGE_HOME`):
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
.openbridge/
|
|
88
|
+
config.json config del puente (apiUrl, token, workspace, bridgeId…)
|
|
89
|
+
app.json config de la app (usuario, contraseña, VAPID, puerto, túnel)
|
|
90
|
+
folders.json lista blanca de carpetas que se ven desde el celular
|
|
91
|
+
data/ sesiones, mensajes, catálogo, registro de puentes, push
|
|
92
|
+
logs/ server.log y bridge.log
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Movés la carpeta a donde quieras y sigue funcionando (es portable). Si venías
|
|
96
|
+
de una versión anterior con los archivos sueltos, se **migran solos** a
|
|
97
|
+
`.openbridge/` la primera vez que corras un comando.
|
|
98
|
+
|
|
99
|
+
## Varias computadoras
|
|
100
|
+
|
|
101
|
+
El hub (la PC con la app) puede atender **varias PCs** a la vez:
|
|
102
|
+
|
|
103
|
+
- En la PC "hub": `openbridge init` + `openbridge server` (queda la app + túnel).
|
|
104
|
+
- En otra PC: `openbridge init` y luego `openbridge bridge --api <url-del-hub>
|
|
105
|
+
--token <token> --id pc2 --name "PC 2"` (sin editar `config.json` a mano).
|
|
106
|
+
|
|
107
|
+
En el sidebar de la web elegís qué PC usar y ves sus proyectos y chats.
|
|
108
|
+
|
|
109
|
+
## Túnel y URL estable
|
|
110
|
+
|
|
111
|
+
Proveedores soportados (`--tunnel`): `tunnelmole` (gratis, sin cuenta),
|
|
112
|
+
`ngrok` (cuenta + authtoken; admite dominio fijo con `--domain` en
|
|
113
|
+
`app.json`) y `cloudflare` (quick tunnel de `cloudflared`). Con URL
|
|
114
|
+
**aleatoria**, la suscripción push no persiste entre reinicios (el push está
|
|
115
|
+
atado al origen). Para PWA/push estables conviene ngrok con dominio fijo o
|
|
116
|
+
Cloudflare con dominio propio.
|
|
117
|
+
|
|
118
|
+
## Avisos push
|
|
119
|
+
|
|
120
|
+
Las claves VAPID se generan solas en `init`. Con URL de túnel **aleatoria**, la
|
|
121
|
+
suscripción push no persiste entre reinicios (el push está atado al origen). Para
|
|
122
|
+
una URL fija hace falta un proveedor con dominio estable (ngrok/Cloudflare).
|
|
123
|
+
|
|
124
|
+
## Seguridad
|
|
125
|
+
|
|
126
|
+
- La app escucha solo en `127.0.0.1`; el túnel la expone.
|
|
127
|
+
- Login con contraseña (scrypt) + CSRF y rate limit (5 intentos); token del
|
|
128
|
+
puente autogenerado.
|
|
129
|
+
- El túnel es **público mientras corre**: detenelo (`openbridge stop`) cuando no
|
|
130
|
+
lo uses.
|
|
131
|
+
|
|
132
|
+
## Estado
|
|
133
|
+
|
|
134
|
+
Proyecto en desarrollo. Ya funciona: `init`, `server` (segundo plano; `--stream`
|
|
135
|
+
en primer plano), `stop`, `status`, `logs` (`--follow`), `bridge` (con flags),
|
|
136
|
+
`passwd`, `import`, `reset`, `autostart`, `doctor`, login con rate limit, API
|
|
137
|
+
completa, SSE, catálogo por PC, Web Push y túnel (TunnelMole/ngrok/cloudflare).
|
|
138
|
+
Tests en `npm test`. Pendiente: re-suscripción push al cambiar la URL y más
|
|
139
|
+
cobertura end-to-end.
|
|
140
|
+
|
|
141
|
+
## Licencia
|
|
142
|
+
|
|
143
|
+
MIT.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const major = parseInt(process.versions.node.split('.')[0], 10);
|
|
5
|
+
if (major < 18) {
|
|
6
|
+
console.error('OpenBridge requiere Node 18 o superior (tenes ' + process.versions.node + ').');
|
|
7
|
+
console.error('Actualizalo en https://nodejs.org');
|
|
8
|
+
process.exit(1);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const cli = require('../src/cli');
|
|
12
|
+
|
|
13
|
+
cli.main(process.argv.slice(2))
|
|
14
|
+
.then((code) => { if (typeof code === 'number') process.exitCode = code; })
|
|
15
|
+
.catch((e) => {
|
|
16
|
+
console.error('error: ' + ((e && e.message) || e));
|
|
17
|
+
process.exitCode = 1;
|
|
18
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Arquitectura de OpenBridge
|
|
2
|
+
|
|
3
|
+
## Vision general
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
[Celular] -- HTTPS --> [Tunel] --> [App Node: src/web] -- JSON --> [.openbridge/data/]
|
|
7
|
+
|
|
|
8
|
+
+--> [Puente: src/bridge] -- CLI --> [opencode]
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Un unico runtime (**Node 18+**). La app web, la API y los datos viven en el
|
|
12
|
+
mismo proceso; el puente es un proceso hijo que ejecuta `opencode run` en las
|
|
13
|
+
carpetas del workspace. El tunel (TunnelMole/ngrok/cloudflared) expone la app
|
|
14
|
+
local a internet. `openbridge server` arranca todo en segundo plano (y muestra
|
|
15
|
+
el estado); `--stream` lo deja en primer plano.
|
|
16
|
+
|
|
17
|
+
## Modulos
|
|
18
|
+
|
|
19
|
+
| Ruta | Responsabilidad |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `bin/openbridge.js` | Entrada CLI + guard de Node. |
|
|
22
|
+
| `src/cli.js` | Comandos (`init`, `passwd`, `server`, `stop`, `status`, `logs`, `bridge`, `import`, `reset`, `autostart`, `doctor`). |
|
|
23
|
+
| `src/paths.js` | Casa portable: ubicacion de `config.json`, `app.json`, `data/`, `logs/`. |
|
|
24
|
+
| `src/config.js` | Lectura/escritura de config, hash scrypt, VAPID. |
|
|
25
|
+
| `src/auth.js` | Cookie de sesion firmada, CSRF, remember-me, rate limit, token del puente. |
|
|
26
|
+
| `src/log.js` | Log a consola + `logs/server.log`. |
|
|
27
|
+
| `src/push.js` | Web Push (VAPID + `web-push`). |
|
|
28
|
+
| `src/tunnel/` | Proveedores de tunel enchufables. |
|
|
29
|
+
| `src/web/server.js` | Servidor HTTP, estaticos, seguridad. |
|
|
30
|
+
| `src/web/routes.js` | Router de paginas y API (`?action=...`), compatible con `app.js`/`bridge.js`. |
|
|
31
|
+
| `src/web/assets/` | Frontend (`app.js`, temas, iconos, service worker, manifest). |
|
|
32
|
+
| `src/store/` | Store JSON con mutex por archivo y escritura atomica. |
|
|
33
|
+
| `src/bridge/bridge.js` | Puente: sincroniza catalogo, hace poll y ejecuta opencode. |
|
|
34
|
+
|
|
35
|
+
## Casa portable
|
|
36
|
+
|
|
37
|
+
La "base" es el directorio de `init` (o `--dir` / `OPENBRIDGE_HOME`). Todos los
|
|
38
|
+
archivos de OpenBridge viven dentro de `<base>/.openbridge/`:
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
config.json config del puente
|
|
42
|
+
app.json config de la app (password, token, VAPID, puerto, tunel)
|
|
43
|
+
folders.json lista blanca de carpetas
|
|
44
|
+
data/ sesiones, mensajes, catalogos por PC, registro de puentes, push
|
|
45
|
+
logs/ server.log, bridge.log
|
|
46
|
+
runtime.json estado del server en ejecucion (pid, URL, hijos)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`paths.migrate()` mueve automaticamente el layout viejo (archivos sueltos en la
|
|
50
|
+
base) a `.openbridge/`; `data/` y `logs/` solo se mueven si tienen marcas de
|
|
51
|
+
OpenBridge. La CLI pasa la **base** (`--dir`/cwd) al puente via
|
|
52
|
+
`OPENBRIDGE_HOME`, y el puente le agrega `.openbridge`.
|
|
53
|
+
|
|
54
|
+
## Flujo de un mensaje
|
|
55
|
+
|
|
56
|
+
1. El celular envia `?action=send` (con CSRF) -> `src/web/routes.js`.
|
|
57
|
+
2. El mensaje queda `pending` en `data/messages-<id>.json`.
|
|
58
|
+
3. El puente hace `poll`, reclama el mensaje y corre
|
|
59
|
+
`opencode run --model <m> [--session <id>]`.
|
|
60
|
+
4. La salida se publica con `respond_partial` (streaming) y `respond`.
|
|
61
|
+
5. El server empuja un Web Push y el celular refresca por SSE/poll.
|
|
62
|
+
|
|
63
|
+
## Multi-PC
|
|
64
|
+
|
|
65
|
+
El hub corre la app; cada PC corre `openbridge bridge` apuntando al hub
|
|
66
|
+
(`apiUrl` + `apiToken` + `bridgeId`). El catalogo se guarda por PC
|
|
67
|
+
(`data/catalog-<id>.json`) y el sidebar permite elegir que PC usar.
|
|
68
|
+
|
|
69
|
+
## Decisiones
|
|
70
|
+
|
|
71
|
+
- **Stateless sessions**: la cookie se firma con HMAC; no hay store de sesiones.
|
|
72
|
+
- **Escritura atomica + mutex**: `tmp` + `rename` y una cola por archivo evitan
|
|
73
|
+
corrupciones con polls concurrentes.
|
|
74
|
+
- **Sin dependencias de framework**: HTTP nativo de Node; la unica dependencia
|
|
75
|
+
es `web-push`.
|
|
76
|
+
- **Proveedores de tunel detras de una interfaz**: `startTunnel(port, provider)`
|
|
77
|
+
devuelve `{ url, provider, pid, stop }`.
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@danieltmn/openbridge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Tu opencode en el celular, sin hosting: corre la app, el puente y un tunel publico desde tu PC.",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"author": "tamnora",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"homepage": "https://github.com/tamnora/openbridge#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/tamnora/openbridge.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/tamnora/openbridge/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"opencode",
|
|
18
|
+
"openbridge",
|
|
19
|
+
"tunnel",
|
|
20
|
+
"remote",
|
|
21
|
+
"mobile",
|
|
22
|
+
"pwa",
|
|
23
|
+
"cli",
|
|
24
|
+
"tunnelmole",
|
|
25
|
+
"ngrok",
|
|
26
|
+
"cloudflare"
|
|
27
|
+
],
|
|
28
|
+
"bin": {
|
|
29
|
+
"openbridge": "bin/openbridge.js",
|
|
30
|
+
"openconex": "bin/openbridge.js"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"bin",
|
|
37
|
+
"src",
|
|
38
|
+
"docs/ARCHITECTURE.md",
|
|
39
|
+
"README.md",
|
|
40
|
+
"CHANGELOG.md",
|
|
41
|
+
"LICENSE"
|
|
42
|
+
],
|
|
43
|
+
"scripts": {
|
|
44
|
+
"start": "node bin/openbridge.js server",
|
|
45
|
+
"test": "node --test",
|
|
46
|
+
"prepublishOnly": "npm test"
|
|
47
|
+
},
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"web-push": "^3.6.7"
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Autenticacion web: cookie de sesion firmada (stateless), CSRF, "mantener
|
|
5
|
+
* sesion" y token del puente. Sin dependencias externas.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const crypto = require('node:crypto');
|
|
9
|
+
const config = require('./config');
|
|
10
|
+
|
|
11
|
+
const SESSION_COOKIE = 'ob_session';
|
|
12
|
+
const REMEMBER_COOKIE = 'ob_remember';
|
|
13
|
+
const REMEMBER_DAYS = 30;
|
|
14
|
+
|
|
15
|
+
function b64url(buf) {
|
|
16
|
+
return Buffer.from(buf).toString('base64url');
|
|
17
|
+
}
|
|
18
|
+
function sign(data, secret) {
|
|
19
|
+
return crypto.createHmac('sha256', secret).update(data).digest('base64url');
|
|
20
|
+
}
|
|
21
|
+
function safeEqual(a, b) {
|
|
22
|
+
const ba = Buffer.from(String(a));
|
|
23
|
+
const bb = Buffer.from(String(b));
|
|
24
|
+
if (ba.length !== bb.length) return false;
|
|
25
|
+
return crypto.timingSafeEqual(ba, bb);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseCookies(header) {
|
|
29
|
+
const out = {};
|
|
30
|
+
const raw = String(header || '');
|
|
31
|
+
for (const part of raw.split(';')) {
|
|
32
|
+
const i = part.indexOf('=');
|
|
33
|
+
if (i < 0) continue;
|
|
34
|
+
const k = part.slice(0, i).trim();
|
|
35
|
+
const v = part.slice(i + 1).trim();
|
|
36
|
+
if (k) out[k] = decodeURIComponent(v);
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isSecure(req) {
|
|
42
|
+
const proto = req.headers['x-forwarded-proto'];
|
|
43
|
+
return proto === 'https';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function serializeCookie(name, value, req, maxAge) {
|
|
47
|
+
const parts = [name + '=' + encodeURIComponent(value), 'Path=/', 'HttpOnly', 'SameSite=Lax'];
|
|
48
|
+
if (isSecure(req)) parts.push('Secure');
|
|
49
|
+
if (typeof maxAge === 'number') parts.push('Max-Age=' + maxAge);
|
|
50
|
+
return parts.join('; ');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Sesion
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
function makeSession(app, username, csrf, exp) {
|
|
57
|
+
const payload = b64url(JSON.stringify({ u: username, c: csrf, e: exp }));
|
|
58
|
+
return payload + '.' + sign(payload, app.csrfSecret);
|
|
59
|
+
}
|
|
60
|
+
function readSession(app, req) {
|
|
61
|
+
const cookies = parseCookies(req.headers.cookie);
|
|
62
|
+
const raw = cookies[SESSION_COOKIE];
|
|
63
|
+
if (!raw) return null;
|
|
64
|
+
const dot = raw.lastIndexOf('.');
|
|
65
|
+
if (dot < 0) return null;
|
|
66
|
+
const payload = raw.slice(0, dot);
|
|
67
|
+
const sig = raw.slice(dot + 1);
|
|
68
|
+
if (!safeEqual(sign(payload, app.csrfSecret), sig)) return null;
|
|
69
|
+
let data;
|
|
70
|
+
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;
|
|
73
|
+
}
|
|
74
|
+
function currentCsrf(app, req) {
|
|
75
|
+
const s = readSession(app, req);
|
|
76
|
+
return s ? s.c : '';
|
|
77
|
+
}
|
|
78
|
+
function requireLogin(app, req, res) {
|
|
79
|
+
const s = readSession(app, req);
|
|
80
|
+
if (!s) { json(res, 401, { ok: false, error: 'No autorizado' }); return null; }
|
|
81
|
+
return s;
|
|
82
|
+
}
|
|
83
|
+
function requireCsrf(app, req, res) {
|
|
84
|
+
const s = readSession(app, req);
|
|
85
|
+
const given = String(req.headers['x-csrf'] || '').trim();
|
|
86
|
+
if (!s || given === '' || !safeEqual(s.c, given)) {
|
|
87
|
+
json(res, 403, { ok: false, error: 'Sesion expirada, recarga la pagina.' });
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
return s;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function startSession(app, req, res, username) {
|
|
94
|
+
const csrf = crypto.randomBytes(16).toString('hex');
|
|
95
|
+
const exp = Math.floor(Date.now() / 1000) + REMEMBER_DAYS * 86400;
|
|
96
|
+
const token = makeSession(app, username, csrf, exp);
|
|
97
|
+
res.setHeader('Set-Cookie', [
|
|
98
|
+
serializeCookie(SESSION_COOKIE, token, req, REMEMBER_DAYS * 86400),
|
|
99
|
+
serializeCookie(REMEMBER_COOKIE, makeRemember(app, username), req, REMEMBER_DAYS * 86400),
|
|
100
|
+
]);
|
|
101
|
+
return csrf;
|
|
102
|
+
}
|
|
103
|
+
function endSession(req, res) {
|
|
104
|
+
res.setHeader('Set-Cookie', [
|
|
105
|
+
serializeCookie(SESSION_COOKIE, '', req, 0),
|
|
106
|
+
serializeCookie(REMEMBER_COOKIE, '', req, 0),
|
|
107
|
+
]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function makeRemember(app, username) {
|
|
111
|
+
const exp = Math.floor(Date.now() / 1000) + REMEMBER_DAYS * 86400;
|
|
112
|
+
const payload = username + '|' + exp;
|
|
113
|
+
return b64url(payload) + '.' + sign(payload, app.csrfSecret);
|
|
114
|
+
}
|
|
115
|
+
// Re-autentica desde la cookie remember si la sesion se perdio.
|
|
116
|
+
function rememberAutoLogin(app, req, res) {
|
|
117
|
+
if (readSession(app, req)) return false;
|
|
118
|
+
const cookies = parseCookies(req.headers.cookie);
|
|
119
|
+
const raw = cookies[REMEMBER_COOKIE];
|
|
120
|
+
if (!raw) return false;
|
|
121
|
+
const dot = raw.lastIndexOf('.');
|
|
122
|
+
if (dot < 0) return false;
|
|
123
|
+
const payload = raw.slice(0, dot);
|
|
124
|
+
const sig = raw.slice(dot + 1);
|
|
125
|
+
let plain;
|
|
126
|
+
try { plain = Buffer.from(payload, 'base64url').toString('utf8'); } catch (e) { return false; }
|
|
127
|
+
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;
|
|
131
|
+
if (!safeEqual(sign(payload, app.csrfSecret), sig)) return false;
|
|
132
|
+
startSession(app, req, res, user);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Rate limit / lockout de login (en memoria: el server es un solo proceso)
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
const LOGIN_MAX_ATTEMPTS = 5;
|
|
140
|
+
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
|
141
|
+
const LOGIN_LOCK_MS = 15 * 60 * 1000;
|
|
142
|
+
const loginAttempts = new Map();
|
|
143
|
+
|
|
144
|
+
function clientKey(req) {
|
|
145
|
+
const xff = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim();
|
|
146
|
+
const ip = xff || (req.socket && req.socket.remoteAddress) || 'desconocido';
|
|
147
|
+
return String(ip);
|
|
148
|
+
}
|
|
149
|
+
function pruneLoginAttempts(now) {
|
|
150
|
+
for (const [key, st] of loginAttempts) {
|
|
151
|
+
if (!st.lockUntil && now - st.first > LOGIN_WINDOW_MS) loginAttempts.delete(key);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// Segundos restantes de bloqueo (0 = puede intentar).
|
|
155
|
+
function loginLockRemaining(req) {
|
|
156
|
+
const now = Date.now();
|
|
157
|
+
pruneLoginAttempts(now);
|
|
158
|
+
const st = loginAttempts.get(clientKey(req));
|
|
159
|
+
if (!st || !st.lockUntil) return 0;
|
|
160
|
+
const left = st.lockUntil - now;
|
|
161
|
+
return left > 0 ? Math.ceil(left / 1000) : 0;
|
|
162
|
+
}
|
|
163
|
+
function loginRecordFailure(req) {
|
|
164
|
+
const now = Date.now();
|
|
165
|
+
const key = clientKey(req);
|
|
166
|
+
let st = loginAttempts.get(key);
|
|
167
|
+
if (!st || (!st.lockUntil && now - st.first > LOGIN_WINDOW_MS)) st = { count: 0, first: now, lockUntil: 0 };
|
|
168
|
+
st.count++;
|
|
169
|
+
if (st.count >= LOGIN_MAX_ATTEMPTS) st.lockUntil = now + LOGIN_LOCK_MS;
|
|
170
|
+
loginAttempts.set(key, st);
|
|
171
|
+
}
|
|
172
|
+
function loginClear(req) {
|
|
173
|
+
loginAttempts.delete(clientKey(req));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Token del puente
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
function checkBridgeToken(app, req) {
|
|
180
|
+
let given = String(req.headers['x-bridge-token'] || '').trim();
|
|
181
|
+
if (given === '') {
|
|
182
|
+
try {
|
|
183
|
+
const u = new URL(req.url, 'http://localhost');
|
|
184
|
+
given = String(u.searchParams.get('token') || '').trim();
|
|
185
|
+
} catch (e) { /* nada */ }
|
|
186
|
+
}
|
|
187
|
+
if (given === '' || !app.bridgeToken) return false;
|
|
188
|
+
return safeEqual(app.bridgeToken, given);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function json(res, code, data) {
|
|
192
|
+
res.statusCode = code;
|
|
193
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
194
|
+
res.end(JSON.stringify(data));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = {
|
|
198
|
+
SESSION_COOKIE, REMEMBER_COOKIE,
|
|
199
|
+
parseCookies, serializeCookie, isSecure,
|
|
200
|
+
makeSession, readSession, currentCsrf, requireLogin, requireCsrf,
|
|
201
|
+
startSession, endSession, rememberAutoLogin,
|
|
202
|
+
loginLockRemaining, loginRecordFailure, loginClear,
|
|
203
|
+
checkBridgeToken, safeEqual, json,
|
|
204
|
+
};
|