dotrino-content 0.2.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/README.md +173 -0
- package/bin/cli.js +203 -0
- package/package.json +57 -0
- package/src/agent.js +104 -0
- package/src/announce.js +132 -0
- package/src/blobstore-s3.js +177 -0
- package/src/blobstore.js +139 -0
- package/src/db.js +237 -0
- package/src/index.js +3 -0
- package/src/node.js +243 -0
- package/src/ops.js +207 -0
- package/src/public.js +370 -0
- package/src/s3.js +257 -0
- package/src/server.js +142 -0
- package/src/storage.js +175 -0
- package/src/vaultEnv.js +144 -0
package/README.md
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# dotrino-content
|
|
2
|
+
|
|
3
|
+
Nodo de contenido del ecosistema **Dotrino**: guarda y sirve **media pesada**
|
|
4
|
+
(video/imagen/audio/archivos) direccionada por **hash de contenido** (`cid`),
|
|
5
|
+
autohospedado por el usuario. Diseño completo en
|
|
6
|
+
[`docs/DISENO.md`](./docs/DISENO.md); estado/continuación en
|
|
7
|
+
[`docs/HANDOFF.md`](./docs/HANDOFF.md).
|
|
8
|
+
|
|
9
|
+
> **Estado: Fase 2 (aparato del vault) + vistas previas públicas.** Al core local se
|
|
10
|
+
> le suma la identidad: el node se enrola a tu bóveda y se puede administrar desde tus
|
|
11
|
+
> apps por el proxy, sin abrir puertos. El HTTP de administración sigue escuchando
|
|
12
|
+
> **solo en `127.0.0.1`**; con `--public` se abre, aparte, un puerto que sirve
|
|
13
|
+
> **únicamente vistas previas** para que un enlace compartido tenga tarjeta en las
|
|
14
|
+
> redes. El transporte P2P entre aparatos es lo que queda de la Fase 3.
|
|
15
|
+
|
|
16
|
+
## Uso
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
# una vez: enlazar este node a tu bóveda (saca el código de `dotrino-vault pair`)
|
|
20
|
+
npx dotrino-content enroll <código>
|
|
21
|
+
|
|
22
|
+
npx dotrino-content start [--port 3777] [--dir ~/.dotrino-content] \
|
|
23
|
+
[--max-gb 50] [--max-blob-mb 512] [--gc-min 60] [--no-agent]
|
|
24
|
+
|
|
25
|
+
# con vistas previas públicas (ver más abajo antes de encenderlo)
|
|
26
|
+
npx dotrino-content start --public --public-port 3778 --public-egress-gb 5 \
|
|
27
|
+
--public-url https://content.tudominio.com
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Al enrolar, el node genera **su propia llave** y muestra un código que tienes que
|
|
31
|
+
tipear en la bóveda para aprobarlo: ese código no viaja por la red, así que aprobar
|
|
32
|
+
exige tener delante esta máquina. La **clave maestra nunca llega aquí** — solo un
|
|
33
|
+
certificado con caducidad, que el node renueva solo y que puedes revocar
|
|
34
|
+
(`dotrino-vault revoke <deviceId>`) sin tocar el resto de tus aparatos.
|
|
35
|
+
|
|
36
|
+
Env: `DOTRINO_CONTENT_DIR` (datos), `DOTRINO_CONTENT_LINK_DIR` (enlace), `PORT`.
|
|
37
|
+
Requiere **Node ≥ 22.5**. El core usa solo `node:crypto` y `node:sqlite`; la
|
|
38
|
+
identidad viene de los pilares del ecosistema (`@dotrino/remote-agent`,
|
|
39
|
+
`@dotrino/identity`).
|
|
40
|
+
|
|
41
|
+
## Plano de control (Fase 2, por el proxy, cifrado)
|
|
42
|
+
|
|
43
|
+
Con el node enlazado, tus propias apps —cualquier aparato con un certificado de **la
|
|
44
|
+
misma** bóveda— pueden administrarlo a distancia dentro de una sesión cifrada:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
hello quién es el node (owner, versión, uso de disco, tope)
|
|
48
|
+
put {data,mime,…} guardar algo pequeño desde otro aparato tuyo (≤ 256 KB)
|
|
49
|
+
get <cid> leerlo de vuelta (≤ 256 KB)
|
|
50
|
+
list · stat <cid> · stats qué guarda
|
|
51
|
+
pin <cid> · unpin <cid> retener / soltar
|
|
52
|
+
remove <cid> borrar
|
|
53
|
+
acl <cid> public|private abrir o cerrar un blob (público es opt-in explícito)
|
|
54
|
+
meta <cid> {…} nombre/título/descripción para la tarjeta de la vista previa
|
|
55
|
+
thumb <cid> <thumbCid> enlazar la miniatura (otro blob, público por su cuenta)
|
|
56
|
+
gc recolectar vencidos ahora
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**`put` y `get` tienen un tope duro de 256 KB, y eso NO es un límite a subir: es la
|
|
60
|
+
frontera.** El plano de control es el proxy del ecosistema —trama de 1 MB, cola de
|
|
61
|
+
mensajes, no un almacén—, así que por aquí pasa lo que **es** un mensaje: un post (un
|
|
62
|
+
eco pesa cientos de bytes) y una miniatura (decenas de KB). Por eso **no hay subida
|
|
63
|
+
por partes**: trocear sería disimular la frontera y acabar usando la infraestructura
|
|
64
|
+
del ecosistema como transporte. Los originales suben en local por HTTP y, entre
|
|
65
|
+
aparatos, por P2P.
|
|
66
|
+
|
|
67
|
+
## Vistas previas públicas (`--public`, apagado por defecto)
|
|
68
|
+
|
|
69
|
+
**Para qué es: para que un enlace que compartes tenga TARJETA** en X, LinkedIn,
|
|
70
|
+
WhatsApp o Telegram. No es para servir tu contenido — eso se sigue abriendo en la app,
|
|
71
|
+
con la referencia en el `#fragment`, que nunca llega a ningún servidor. Lo que sale por
|
|
72
|
+
este puerto es la **miniatura** que tú marcaste pública, no el archivo.
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
GET|HEAD /c/<cid> los bytes, si pasan TODOS los cerrojos de abajo
|
|
76
|
+
GET /p/<cid> permalink: tarjeta (og:*) + botón "Abrir" hacia la app
|
|
77
|
+
GET /robots.txt · /health
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Cinco cerrojos, y ninguno se puede saltar desde fuera:
|
|
81
|
+
|
|
82
|
+
| | |
|
|
83
|
+
|---|---|
|
|
84
|
+
| **Solo lo público y en claro** | lo cifrado no sale ni marcado público a mano en el índice |
|
|
85
|
+
| **Solo imágenes de mapa de bits** | JPEG, PNG, GIF, WebP, AVIF. **SVG no**: es un documento que ejecuta scripts |
|
|
86
|
+
| **El tipo se comprueba en los bytes** | el `Content-Type` lo declara quien sube, así que no se cree: un HTML subido como `image/png` responde 404 |
|
|
87
|
+
| **Tope de tamaño** (`--public-max-kb`, 512) | es lo que hace que esto sea un servidor de miniaturas y no un CDN. `0` lo quita y entonces sirve originales: el ancho de banda lo pagas tú |
|
|
88
|
+
| **Límite por IP + techo diario** | `--public-rate` (60/min) y `--public-egress-gb`, que se **persiste** y corta antes de mandar una respuesta que no quepa |
|
|
89
|
+
|
|
90
|
+
Lo privado responde **404, nunca 403**: un 403 confirmaría que ese `cid` está aquí. Y
|
|
91
|
+
`robots.txt` prohíbe todo (las tarjetas funcionan igual: los rastreadores de las redes
|
|
92
|
+
piden la página cuando alguien pega el enlace, no indexan). `--public-index` lo levanta.
|
|
93
|
+
|
|
94
|
+
**La miniatura la genera tu app al subir** (con un canvas) y se sube como **otro
|
|
95
|
+
blob**, que se enlaza con la op `thumb`. El node no decodifica imágenes: así no
|
|
96
|
+
arrastra dependencias nativas. Enlazar una miniatura **no** la publica — se marca
|
|
97
|
+
pública por su cuenta.
|
|
98
|
+
|
|
99
|
+
## API HTTP (localhost)
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
POST /c?ttl=<ms>&enc=1 subir (streaming; Content-Type = mime) → { cid, size, mime, existed }
|
|
103
|
+
GET /c/<cid> descargar/streamear (Range → 206; ETag = cid, immutable)
|
|
104
|
+
HEAD /c/<cid> size/mime/etag sin cuerpo
|
|
105
|
+
DELETE /c/<cid> borrar
|
|
106
|
+
GET /list índice de blobs
|
|
107
|
+
POST /pin/<cid> retención (excluye del GC) POST /unpin/<cid>
|
|
108
|
+
GET /stats nº de blobs, bytes usados, cuota
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
- `cid = sha256-<hex>` (prefijo de algoritmo → extensible a BLAKE3 después; se
|
|
112
|
+
usa SHA-256 de `node:crypto` porque no exige dependencias nativas y el
|
|
113
|
+
`.npmrc` del ecosistema bloquea build scripts de npm).
|
|
114
|
+
- Disco: `blobs/<aa>/<bb>/<cid>` (sharding); índice en SQLite (`index.db`).
|
|
115
|
+
- Dedup por contenido: re-subir el mismo archivo devuelve `existed: true`.
|
|
116
|
+
- **Cuota** (`--max-gb`): al no caber, el GC desaloja no-pineados más viejos;
|
|
117
|
+
los **pineados jamás se borran** (si solo quedan pineados → `507`).
|
|
118
|
+
- **TTL** opcional por blob (`?ttl=<ms>`): vencido = candidato a GC.
|
|
119
|
+
- El cifrado E2E es **del lado del cliente** (el node solo ve ciphertext si
|
|
120
|
+
subes cifrado y marcas `enc=1`); la llave viaja en el `#fragment` del enlace.
|
|
121
|
+
- **`owner` y `acl`:** con el node enlazado, todo lo que se sube queda estampado con
|
|
122
|
+
el `ownerId` de tu bóveda (la mitad izquierda de la referencia compartible
|
|
123
|
+
`ownerId + cid`). El `acl` nace privado: lo que no se marca `public` a mano no sale
|
|
124
|
+
del node cuando llegue el modo público, y un blob cifrado no puede marcarse
|
|
125
|
+
público (nadie sin la llave podría leerlo).
|
|
126
|
+
|
|
127
|
+
## `@dotrino/content-client` (lo que usa una app)
|
|
128
|
+
|
|
129
|
+
El cliente de navegador vive en [`lib/`](./lib) y se publica desde este mismo repo,
|
|
130
|
+
igual que `dotrino-vault` publica su lib. Está aquí a propósito: el protocolo —los
|
|
131
|
+
nombres de las ops, el tope de 256 KB, el formato de la referencia— es de las dos
|
|
132
|
+
puntas, y separarlas en dos repos es la forma de que acaben diciendo cosas distintas.
|
|
133
|
+
|
|
134
|
+
```js
|
|
135
|
+
import { ContentClient, buildUrl } from '@dotrino/content-client'
|
|
136
|
+
|
|
137
|
+
const cc = await ContentClient.connect({ link }) // link del vault: { id, cert, iss }
|
|
138
|
+
const ref = await cc.put(bytes, { mime: 'image/png' }) // cifra por defecto
|
|
139
|
+
const url = buildUrl(ref) // https://eco.dotrino.com/#<owner>/<cid>/<llave>
|
|
140
|
+
const back = await cc.get(ref) // comprueba el hash antes de devolver nada
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
- **Cifra por defecto** (§4): el node guarda ciphertext y la llave sale en la
|
|
144
|
+
referencia, para el `#fragment`. Con `encrypt: false` queda en claro, que es lo que
|
|
145
|
+
hace falta para poder marcarlo `public` y que tenga tarjeta.
|
|
146
|
+
- **`connect()` falla con `code: 'no-node'`** si no tienes ninguno encendido, en vez
|
|
147
|
+
de esperar. Es lo esperable y **la app tiene que saber seguir sin node**: al
|
|
148
|
+
`@dotrino/store` va lo que debe estar siempre disponible; aquí van los bytes.
|
|
149
|
+
- **`get()` comprueba el hash** de lo que llega: el `cid` **es** el hash, así que unos
|
|
150
|
+
bytes que no cuadren se rechazan vengan de donde vengan.
|
|
151
|
+
- **Miniaturas** (`@dotrino/content-client/thumb`): `makeThumbnail()` en canvas y
|
|
152
|
+
`putImageWithThumbnail()`, que sube el original **cifrado y privado** y la miniatura
|
|
153
|
+
**en claro y pública** — que es el reparto que hace que haya tarjeta sin publicar el
|
|
154
|
+
archivo.
|
|
155
|
+
- **Todavía NO lee el contenido de otro usuario**: hoy solo se abre sesión con un
|
|
156
|
+
aparato de tu misma acta. Un tercero con tu enlace ve la vista previa (si la
|
|
157
|
+
encendiste) y podrá pedir los bytes cuando exista el transporte P2P.
|
|
158
|
+
|
|
159
|
+
## Tests
|
|
160
|
+
|
|
161
|
+
```sh
|
|
162
|
+
npm test
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Fases
|
|
166
|
+
|
|
167
|
+
1. ✅ **Core local**: blobs por `cid`, Range/206, índice, cuota+GC.
|
|
168
|
+
2. ✅ **Aparato del vault** (este estado): enrolamiento, plano de control cifrado,
|
|
169
|
+
`owner` + `acl`.
|
|
170
|
+
3. Exposición: P2P/swarm por WebRTC + modo público HTTP opt-in + sembrador 24/7.
|
|
171
|
+
4. Integración con **eco** (la app que resuelve el `#fragment`) + catálogo.
|
|
172
|
+
|
|
173
|
+
Licencia MIT.
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI de dotrino-content.
|
|
4
|
+
*
|
|
5
|
+
* dotrino-content enroll <código> enlaza este node a tu vault (una vez)
|
|
6
|
+
* dotrino-content start [--port 3777] [--dir <ruta>] [--max-gb <n>] [--gc-min <min>]
|
|
7
|
+
* [--no-agent] arranca sin el plano de control
|
|
8
|
+
* [--public] abre el puerto de VISTAS PREVIAS (§7.2)
|
|
9
|
+
*
|
|
10
|
+
* El HTTP de administración sigue escuchando SOLO en loopback: es la vía local
|
|
11
|
+
* para subir y leer. Lo que añade el enlace es el plano de CONTROL (administrar el
|
|
12
|
+
* node desde tus apps, por el proxy, sin abrir puertos) — ver DISENO.md §7.
|
|
13
|
+
*
|
|
14
|
+
* `--public` levanta un SEGUNDO servidor, aparte y con sus propias reglas
|
|
15
|
+
* (src/public.js): sirve las **vistas previas** de lo que marcaste público —solo
|
|
16
|
+
* imágenes comprobadas, con tope de tamaño, límite por IP y techo de salida— para
|
|
17
|
+
* que un enlace compartido tenga tarjeta en las redes. No es un CDN y no lo va a
|
|
18
|
+
* ser: el contenido se sigue abriendo en la app.
|
|
19
|
+
*
|
|
20
|
+
* Env: DOTRINO_CONTENT_DIR (datos), DOTRINO_CONTENT_LINK_DIR (enlace), PORT.
|
|
21
|
+
*/
|
|
22
|
+
import os from 'node:os'
|
|
23
|
+
import path from 'node:path'
|
|
24
|
+
import { parseArgs } from 'node:util'
|
|
25
|
+
import { ContentNode } from '../src/node.js'
|
|
26
|
+
import { createServer } from '../src/server.js'
|
|
27
|
+
import { createPublicServer, DEFAULT_PUBLIC_PORT, DEFAULT_MAX_BYTES, DEFAULT_RATE_PER_MIN } from '../src/public.js'
|
|
28
|
+
import { isLinked, linkDir, startContentAgent } from '../src/agent.js'
|
|
29
|
+
|
|
30
|
+
const USAGE = `uso:
|
|
31
|
+
dotrino-content enroll <código>
|
|
32
|
+
dotrino-content start [--port 3777] [--dir <ruta>] [--max-gb <n>] [--max-blob-mb <n>] [--gc-min <min>] [--no-agent]
|
|
33
|
+
[--public] [--public-port 3778] [--public-host 0.0.0.0] [--public-max-kb 512]
|
|
34
|
+
[--public-rate 60] [--public-egress-gb <n>] [--public-url https://…] [--app-url https://…]
|
|
35
|
+
[--public-index]`
|
|
36
|
+
|
|
37
|
+
const { values, positionals } = parseArgs({
|
|
38
|
+
allowPositionals: true,
|
|
39
|
+
options: {
|
|
40
|
+
port: { type: 'string' },
|
|
41
|
+
dir: { type: 'string' },
|
|
42
|
+
'max-gb': { type: 'string' },
|
|
43
|
+
'max-blob-mb': { type: 'string' },
|
|
44
|
+
'gc-min': { type: 'string' },
|
|
45
|
+
'no-agent': { type: 'boolean' },
|
|
46
|
+
// --- modo público (§7.2): apagado por defecto, y cada límite es un flag ---
|
|
47
|
+
public: { type: 'boolean' },
|
|
48
|
+
'public-port': { type: 'string' },
|
|
49
|
+
'public-host': { type: 'string' },
|
|
50
|
+
'public-max-kb': { type: 'string' },
|
|
51
|
+
'public-rate': { type: 'string' },
|
|
52
|
+
'public-egress-gb': { type: 'string' },
|
|
53
|
+
'public-url': { type: 'string' },
|
|
54
|
+
'app-url': { type: 'string' },
|
|
55
|
+
'public-index': { type: 'boolean' }
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const cmd = positionals[0] || 'start'
|
|
60
|
+
if (cmd !== 'start' && cmd !== 'enroll') {
|
|
61
|
+
console.error(`comando desconocido: ${cmd}\n${USAGE}`)
|
|
62
|
+
process.exit(1)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Enrolamiento: el flujo endurecido del ecosistema, tal cual lo hace la terminal.
|
|
67
|
+
* Este node genera su llave, MUESTRA un código y espera a que lo apruebes tipeando
|
|
68
|
+
* ese código en tu bóveda — así aprobar exige tener delante esta máquina. La clave
|
|
69
|
+
* maestra nunca llega aquí: solo un certificado con fecha de caducidad.
|
|
70
|
+
*/
|
|
71
|
+
if (cmd === 'enroll') {
|
|
72
|
+
const pairing = positionals[1]
|
|
73
|
+
if (!pairing) {
|
|
74
|
+
console.error('falta el código de emparejamiento.\n' +
|
|
75
|
+
'Sácalo de tu bóveda (dotrino-vault pair, o profile.dotrino.com/#myvault) y pásalo aquí:\n' +
|
|
76
|
+
' dotrino-content enroll <código>')
|
|
77
|
+
process.exit(1)
|
|
78
|
+
}
|
|
79
|
+
// Se enrola como SERVICIO (`ns:content`), que es lo que le da además la llave de
|
|
80
|
+
// CIFRADO a la que el vault le sella sus variables. El enlace del plano de control
|
|
81
|
+
// queda escrito con la MISMA llave: un aparato, una identidad (ver `vaultEnv.js`).
|
|
82
|
+
const { enrollToVault, serviceDir, NS } = await import('../src/vaultEnv.js')
|
|
83
|
+
try {
|
|
84
|
+
const res = await enrollToVault(pairing, {
|
|
85
|
+
onReplace: (prev) => {
|
|
86
|
+
console.log(`\n⚠ este node YA estaba enrolado (aparato ${prev.deviceId}).`)
|
|
87
|
+
console.log(' Se descarta esa identidad; con ella se va su cajón de variables,')
|
|
88
|
+
console.log(' que va indexado por su llave. Si solo querías recargar la')
|
|
89
|
+
console.log(' configuración, NO enroles: reinicia el node.\n')
|
|
90
|
+
},
|
|
91
|
+
onCode: ({ deviceId, code }) => {
|
|
92
|
+
console.log(`\n este node es el aparato ${deviceId}`)
|
|
93
|
+
console.log(` apruébalo en tu bóveda: dotrino-vault approve ${code}\n`)
|
|
94
|
+
console.log(' (el código NO viaja por la red: lo tipeas tú)')
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
const days = Math.round((res.cert.exp - Date.now()) / 86400000)
|
|
98
|
+
console.log(`\nlisto: node enlazado. Certificado válido ${days} días (se renueva solo).`)
|
|
99
|
+
console.log(`identidad en ${serviceDir()} · enlace en ${linkDir()}`)
|
|
100
|
+
console.log(`\nahora carga su configuración en la bóveda (namespace «${NS}»):`)
|
|
101
|
+
console.log(' dotrino-vault secret set content CONTENT_STORAGE=local --public')
|
|
102
|
+
} catch (e) {
|
|
103
|
+
console.error(`\nno se pudo enlazar: ${e.message}`)
|
|
104
|
+
process.exit(1)
|
|
105
|
+
}
|
|
106
|
+
process.exit(0)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const dir = values.dir || process.env.DOTRINO_CONTENT_DIR ||
|
|
110
|
+
path.join(os.homedir(), '.dotrino-content')
|
|
111
|
+
const port = Number(values.port || process.env.PORT || 3777)
|
|
112
|
+
const maxBytes = values['max-gb'] ? Number(values['max-gb']) * 1024 ** 3 : 0
|
|
113
|
+
const maxBlobBytes = values['max-blob-mb'] ? Number(values['max-blob-mb']) * 1024 ** 2 : 0
|
|
114
|
+
const gcMin = Number(values['gc-min'] || 60)
|
|
115
|
+
|
|
116
|
+
// La configuración la sirve el vault (§15.14). Se pide ANTES de levantar nada: de
|
|
117
|
+
// ahí sale `CONTENT_STORAGE` y, con él, qué almacén usa este node. Sin vault esto no
|
|
118
|
+
// hace nada y el node corre en local, que es el modo normal de un autohospedado.
|
|
119
|
+
const { startVaultConfig, isEnrolled } = await import('../src/vaultEnv.js')
|
|
120
|
+
const log = (m) => console.log(m)
|
|
121
|
+
const vaultConfig = startVaultConfig({ log })
|
|
122
|
+
if (!isEnrolled()) console.log('sin vault: configuración local (enrola con: dotrino-content enroll <código>)')
|
|
123
|
+
// Se ESPERA a la configuración antes de montar el almacén, porque de ella sale cuál es
|
|
124
|
+
// (§15.14). Con plazo: si la bóveda no contesta, se arranca con el disco y ya se
|
|
125
|
+
// reiniciará cuando llegue — un node no puede quedarse sin servir por eso.
|
|
126
|
+
await vaultConfig.ready
|
|
127
|
+
|
|
128
|
+
const { openStore } = await import('../src/storage.js')
|
|
129
|
+
const { store } = await openStore({ dir, log })
|
|
130
|
+
|
|
131
|
+
const node = await new ContentNode({ dir, maxBytes, maxBlobBytes, store, log }).init()
|
|
132
|
+
// Lo que quedó sin subir en un arranque anterior. Se lanza y no se espera.
|
|
133
|
+
node.backupPending().then(({ pending }) => { if (pending) log(`[almacén] ${pending} pendiente(s) de subir al bucket`) })
|
|
134
|
+
const server = createServer(node)
|
|
135
|
+
|
|
136
|
+
// GC periódico de vencidos (ttl); el GC por cuota corre inline en cada put.
|
|
137
|
+
const gcTimer = setInterval(() => node.gc(), gcMin * 60_000)
|
|
138
|
+
gcTimer.unref()
|
|
139
|
+
node.gc()
|
|
140
|
+
|
|
141
|
+
// Escuchar SOLO en loopback. La exposición al mundo es la Fase 3 (§7.2) y va con su
|
|
142
|
+
// propia ACL, su límite por IP y su techo de salida: no se activa por descuido.
|
|
143
|
+
server.listen(port, '127.0.0.1', () => {
|
|
144
|
+
const s = node.stats()
|
|
145
|
+
console.log(`dotrino-content en http://127.0.0.1:${port} · datos: ${dir}`)
|
|
146
|
+
console.log(`blobs: ${s.blobs} · bytes: ${s.bytes}${maxBytes ? ` / ${maxBytes}` : ''}`)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// Plano de control: solo si este node ya está enlazado a un vault. Sin enlace sigue
|
|
150
|
+
// siendo lo de antes (un node local), y se dice en voz alta para que nadie crea que
|
|
151
|
+
// tiene administración remota cuando no la tiene.
|
|
152
|
+
let agent = null
|
|
153
|
+
if (values['no-agent']) {
|
|
154
|
+
console.log('plano de control: apagado (--no-agent)')
|
|
155
|
+
} else if (isLinked()) {
|
|
156
|
+
try {
|
|
157
|
+
agent = await startContentAgent({ node })
|
|
158
|
+
} catch (e) {
|
|
159
|
+
console.error(`plano de control: no arrancó (${e.message})`)
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
console.log('plano de control: sin enlace (corre `dotrino-content enroll <código>` para administrarlo desde tus apps)')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Modo público (§7.2). Va DESPUÉS del agente a propósito: el `owner` del node lo
|
|
166
|
+
// resuelve el enlace con el vault, y es la mitad izquierda de la referencia
|
|
167
|
+
// `ownerId + cid` que la tarjeta necesita para armar el enlace "Abrir".
|
|
168
|
+
let publicServer = null
|
|
169
|
+
if (values.public) {
|
|
170
|
+
const maxKb = values['public-max-kb'] !== undefined ? Number(values['public-max-kb']) : DEFAULT_MAX_BYTES / 1024
|
|
171
|
+
const publicPort = Number(values['public-port'] || DEFAULT_PUBLIC_PORT)
|
|
172
|
+
const publicHost = values['public-host'] || '0.0.0.0'
|
|
173
|
+
const egressGb = Number(values['public-egress-gb'] || 0)
|
|
174
|
+
publicServer = createPublicServer(node, {
|
|
175
|
+
maxBytes: maxKb * 1024,
|
|
176
|
+
ratePerMin: Number(values['public-rate'] || DEFAULT_RATE_PER_MIN),
|
|
177
|
+
maxEgressBytes: egressGb ? egressGb * 1024 ** 3 : 0,
|
|
178
|
+
publicUrl: values['public-url'] || null,
|
|
179
|
+
appUrl: values['app-url'] || undefined,
|
|
180
|
+
index: !!values['public-index'],
|
|
181
|
+
owner: node.owner
|
|
182
|
+
})
|
|
183
|
+
publicServer.listen(publicPort, publicHost, () => {
|
|
184
|
+
console.log(`vistas previas públicas en http://${publicHost}:${publicPort} · solo imágenes` +
|
|
185
|
+
`${maxKb ? ` de hasta ${maxKb} KB` : ' (SIN tope de tamaño)'}` +
|
|
186
|
+
`${egressGb ? ` · techo de salida ${egressGb} GB/día` : ''}`)
|
|
187
|
+
if (!node.owner) {
|
|
188
|
+
console.log(' ojo: este node no está enlazado a un vault, así que las tarjetas salen sin enlace "Abrir"')
|
|
189
|
+
}
|
|
190
|
+
if (!maxKb) {
|
|
191
|
+
console.log(' ojo: sin tope de tamaño esto sirve originales, no vistas previas — y el ancho de banda lo pagas tú')
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const shutdown = () => {
|
|
197
|
+
clearInterval(gcTimer)
|
|
198
|
+
try { agent?.close() } catch (_) {}
|
|
199
|
+
try { publicServer?.close() } catch (_) {}
|
|
200
|
+
server.close(() => { node.close(); process.exit(0) })
|
|
201
|
+
}
|
|
202
|
+
process.on('SIGINT', shutdown)
|
|
203
|
+
process.on('SIGTERM', shutdown)
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dotrino-content",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Nodo de contenido del ecosistema Dotrino: guarda los bytes del usuario direccionados por su hash (cid), en su propia maquina y, si el dueno quiere, respaldados en su propio bucket (R2, Backblaze, Hetzner, Storj o S3). Lo publico sale por URL directa; lo privado, cifrado y solo por la red Dotrino.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"dotrino-content": "bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.js"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"start": "node bin/cli.js start",
|
|
14
|
+
"test": "node --test 'test/**/*.test.js'",
|
|
15
|
+
"type-check": "tsc --noEmit && tsc --noEmit -p lib"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22.5"
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "22.20.1",
|
|
23
|
+
"typescript": "5.9.3"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@dotrino/identity": "0.55.0",
|
|
27
|
+
"@dotrino/remote-agent": "0.4.1",
|
|
28
|
+
"@dotrino/vault": "0.27.0"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"dotrino",
|
|
32
|
+
"content-addressed",
|
|
33
|
+
"cid",
|
|
34
|
+
"self-hosted",
|
|
35
|
+
"privacy",
|
|
36
|
+
"s3",
|
|
37
|
+
"r2",
|
|
38
|
+
"blob-storage"
|
|
39
|
+
],
|
|
40
|
+
"homepage": "https://github.com/imdotrino/dotrino-content#readme",
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "git+https://github.com/imdotrino/dotrino-content.git"
|
|
44
|
+
},
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/imdotrino/dotrino-content/issues"
|
|
47
|
+
},
|
|
48
|
+
"author": "Dotrino",
|
|
49
|
+
"files": [
|
|
50
|
+
"bin",
|
|
51
|
+
"src",
|
|
52
|
+
"README.md"
|
|
53
|
+
],
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public"
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/agent.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent.js — Fase 2 (DISENO.md §5, §11): este node deja de ser una pieza local y
|
|
3
|
+
* pasa a ser un APARATO DEL VAULT, administrable a distancia desde tus propias apps
|
|
4
|
+
* sin abrir un solo puerto.
|
|
5
|
+
*
|
|
6
|
+
* NO IMPLEMENTA NADA DE ESO: lo hace `@dotrino/remote-agent`, el middleware que ya
|
|
7
|
+
* usan `dotrino-terminal` y `dotrino-ia`. De ahí salen, y por eso no se reescriben
|
|
8
|
+
* aquí: el enrolamiento contra el vault (llave `D` propia + cert `D ← maestra`, la
|
|
9
|
+
* maestra nunca vive aquí), el `identify` firmado en el proxy, el canal cifrado por
|
|
10
|
+
* sesión, el refresco de la lista de revocados, la RENOVACIÓN del cert antes de que
|
|
11
|
+
* venza y el auto-borrado cuando llega un `vault.revoked` firmado por la maestra.
|
|
12
|
+
*
|
|
13
|
+
* Lo único de este archivo es el pegamento: cada payload que llega por una sesión
|
|
14
|
+
* ya autorizada se despacha en `ops.js`, y la respuesta vuelve por el mismo canal
|
|
15
|
+
* cifrado. Autorizar = tener un cert de LA MISMA maestra que este node (eso lo
|
|
16
|
+
* comprueba `verifyChain` dentro del middleware); no hay lista de invitados propia.
|
|
17
|
+
*
|
|
18
|
+
* El `owner` del node se deriva de la maestra a la que está enlazado: es el
|
|
19
|
+
* `ownerId` de la referencia compartible `ownerId + cid` (§3), y se estampa en todo
|
|
20
|
+
* lo que se suba mientras el node esté enlazado.
|
|
21
|
+
*/
|
|
22
|
+
import { pubkeyId } from '@dotrino/identity/capabilities'
|
|
23
|
+
import { startRemoteAgent } from '@dotrino/remote-agent/agent'
|
|
24
|
+
import { dataDir, loadLink } from '@dotrino/remote-agent/link'
|
|
25
|
+
import { startAnnounce } from './announce.js'
|
|
26
|
+
import { createOps } from './ops.js'
|
|
27
|
+
|
|
28
|
+
/** Carpeta de datos del enlace (NO es la de los blobs: el enlace es del aparato). */
|
|
29
|
+
export const linkDir = () => process.env.DOTRINO_CONTENT_LINK_DIR || dataDir('dotrino-content')
|
|
30
|
+
|
|
31
|
+
/** ¿Está este node enlazado a un vault? (sin enlace, Fase 1: solo loopback) */
|
|
32
|
+
export const isLinked = (dir = linkDir()) => {
|
|
33
|
+
const link = loadLink(dir)
|
|
34
|
+
return !!(link?.device?.privateJwk && link?.cert && link?.iss)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Arranca el plano de control del node.
|
|
39
|
+
*
|
|
40
|
+
* @param {{
|
|
41
|
+
* node?: import('./node.js').ContentNode,
|
|
42
|
+
* dir?: string, proxyUrl?: string, version?: string|null,
|
|
43
|
+
* client?: any, quiet?: boolean, onRevoked?: () => void, announce?: boolean
|
|
44
|
+
* }} opts
|
|
45
|
+
* client: transporte inyectado — SOLO para pruebas (bus en memoria). En
|
|
46
|
+
* producción lo levanta el middleware con el proxy del ecosistema.
|
|
47
|
+
* announce: publicarse en el canal del dueño para que un TERCERO con el enlace
|
|
48
|
+
* pueda encontrar este node (§3.1). Encendido por defecto; se apaga con `false`
|
|
49
|
+
* en un node que solo quieras usar desde tus propios aparatos.
|
|
50
|
+
* @returns {Promise<{ machine: string, machineId: string, owner: string, close: () => void }>}
|
|
51
|
+
*/
|
|
52
|
+
export async function startContentAgent ({
|
|
53
|
+
node, dir = linkDir(), proxyUrl, version = null, client, quiet = false, onRevoked, announce = true
|
|
54
|
+
} = {}) {
|
|
55
|
+
if (!node) throw new Error('startContentAgent: falta node')
|
|
56
|
+
const link = loadLink(dir)
|
|
57
|
+
if (!link?.iss) {
|
|
58
|
+
throw new Error('este node no está enlazado a un vault. Ejecuta primero: dotrino-content enroll <código>')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// El node representa a la maestra que lo certificó: su huella es el `ownerId`.
|
|
62
|
+
// Se resuelve ANTES de abrir el transporte, para que la primera sesión que entre
|
|
63
|
+
// ya encuentre el despachador armado.
|
|
64
|
+
const owner = await pubkeyId(link.iss)
|
|
65
|
+
node.owner = owner
|
|
66
|
+
const dispatch = createOps(node, { owner, version })
|
|
67
|
+
|
|
68
|
+
const agent = await startRemoteAgent({
|
|
69
|
+
dir,
|
|
70
|
+
proxyUrl,
|
|
71
|
+
client,
|
|
72
|
+
quiet,
|
|
73
|
+
onRevoked,
|
|
74
|
+
onSession: (session) => {
|
|
75
|
+
session.on('message', (msg) => {
|
|
76
|
+
dispatch(msg)
|
|
77
|
+
.then((reply) => session.send(reply))
|
|
78
|
+
.catch((e) => session.send({ rid: msg?.rid, ok: false, code: 'failed', error: e.message }))
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
if (!quiet) console.log(`[content] control plane ready · owner ${owner.slice(0, 16)}`)
|
|
84
|
+
|
|
85
|
+
// ANUNCIO (§3.1): sin esto el node es administrable por sus dueños pero
|
|
86
|
+
// INVISIBLE para quien recibe un enlace — la referencia nombra al dueño, y hay
|
|
87
|
+
// que poder pasar de ahí a «qué nodes suyos están vivos». Reusa la conexión del
|
|
88
|
+
// middleware: nunca se abre una segunda.
|
|
89
|
+
const beacon = announce && agent.client
|
|
90
|
+
? startAnnounce({ client: agent.client, owner, quiet })
|
|
91
|
+
: null
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
...agent,
|
|
95
|
+
owner,
|
|
96
|
+
channels: () => beacon?.channels() || [],
|
|
97
|
+
close () {
|
|
98
|
+
try { beacon?.close() } catch (_) {}
|
|
99
|
+
agent.close()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export default { startContentAgent, isLinked, linkDir }
|
package/src/announce.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* announce.js — ANUNCIO del node (DISENO.md §3.1). Es lo que hace que se sepa
|
|
3
|
+
* **dónde** está el contenido de un dueño.
|
|
4
|
+
*
|
|
5
|
+
* La referencia compartible nombra al DUEÑO, no a la máquina (`ownerId + cid`), así
|
|
6
|
+
* que resolverla es contestar «¿qué nodes de este dueño están vivos ahora mismo?».
|
|
7
|
+
* Hay dos caminos, según quién pregunte, y este archivo es el segundo:
|
|
8
|
+
*
|
|
9
|
+
* - **El dueño** pregunta a su bóveda (`listAgentsByLabel(id, 'content')`): sus
|
|
10
|
+
* aparatos y sus direcciones. No necesita ningún anuncio.
|
|
11
|
+
* - **Un tercero con el enlace** no puede consultar la bóveda de nadie — ni debe.
|
|
12
|
+
* Para él, el node se publica en un **canal del proxy** y cualquiera lista quién
|
|
13
|
+
* está dentro. Eso es esto.
|
|
14
|
+
*
|
|
15
|
+
* **El nombre del canal lleva el `ownerId`, no el `cid`.** Un canal por contenido
|
|
16
|
+
* filtraría qué guarda cada quien con solo mirar la lista de canales; y además
|
|
17
|
+
* serían miles. Con uno por dueño, lo que se sabe al listar es «este dueño tiene
|
|
18
|
+
* nodes en línea», que es justo lo que hace falta para pedirle algo.
|
|
19
|
+
*
|
|
20
|
+
* **Por qué el canal va con id de proxio delante.** Hay dos proxios federados y un
|
|
21
|
+
* canal SIN prefijo es local a cada uno: el node anunciado en uno sería invisible
|
|
22
|
+
* para quien esté conectado al otro. Se publica en el canal de **cada nodo
|
|
23
|
+
* conocido** y se lista en todos, en vez de elegir uno "dueño" por una fórmula: la
|
|
24
|
+
* lista de nodos de la malla cambia cuando entra o sale un proxio, y una fórmula
|
|
25
|
+
* que dependa de ella reasigna todos los canales el día que eso pase. Son dos
|
|
26
|
+
* canales hoy; publicar en los dos cuesta dos mensajes y no se rompe nunca.
|
|
27
|
+
*
|
|
28
|
+
* **Anunciarse NO da acceso a nada.** Es una guía de teléfonos: dice a quién
|
|
29
|
+
* llamar. Quién puede pedir qué lo siguen decidiendo la ACL y el certificado, en
|
|
30
|
+
* el sitio de siempre.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** Nombre del canal de un dueño dentro de un proxio concreto. */
|
|
34
|
+
export const channelFor = (nodeId, ownerId) => `${nodeId}/content_${ownerId}`
|
|
35
|
+
|
|
36
|
+
/** Cada cuánto se re-publica (el proxio caduca las entradas de canal). */
|
|
37
|
+
export const REPUBLISH_MS = 4 * 60 * 1000
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Publica este node en el canal de su dueño, en todos los proxios conocidos, y lo
|
|
41
|
+
* mantiene publicado. Best-effort a propósito: si el proxy está caído, el node
|
|
42
|
+
* sigue funcionando —sirve en local y atiende a los aparatos del acta— y el
|
|
43
|
+
* siguiente tic lo vuelve a intentar. Un anuncio perdido no rompe nada; lo único
|
|
44
|
+
* que pasa es que un tercero no lo encuentra hasta que vuelva.
|
|
45
|
+
*
|
|
46
|
+
* @param {{ client: any, owner: string, quiet?: boolean, intervalMs?: number }} opts
|
|
47
|
+
* client: el `WebSocketProxyClient` YA conectado del agente (`ra.client`) — no se
|
|
48
|
+
* abre otro: dos conexiones del mismo aparato son dos identidades de transporte.
|
|
49
|
+
* @returns {{ channels: () => string[], close: () => void }}
|
|
50
|
+
*/
|
|
51
|
+
export function startAnnounce ({ client, owner, quiet = false, intervalMs = REPUBLISH_MS }) {
|
|
52
|
+
if (!client) throw new Error('startAnnounce: falta client')
|
|
53
|
+
if (!owner) throw new Error('startAnnounce: falta owner')
|
|
54
|
+
|
|
55
|
+
let stopped = false
|
|
56
|
+
let current = []
|
|
57
|
+
|
|
58
|
+
/** Los proxios donde hay que anunciarse: el que nos atiende y los que conoce. */
|
|
59
|
+
const targets = () => {
|
|
60
|
+
const known = Array.isArray(client.knownNodes) ? client.knownNodes : []
|
|
61
|
+
return known.length ? known : (client.node ? [client.node] : [])
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function publishAll () {
|
|
65
|
+
if (stopped) return
|
|
66
|
+
const names = targets().map((n) => channelFor(n, owner))
|
|
67
|
+
if (!names.length) return // aún sin `connected`: el próximo tic
|
|
68
|
+
const done = []
|
|
69
|
+
for (const name of names) {
|
|
70
|
+
try {
|
|
71
|
+
await client.publish(name, { app: 'content', owner })
|
|
72
|
+
done.push(name)
|
|
73
|
+
} catch (e) {
|
|
74
|
+
if (!quiet) console.error(`[content] no me pude anunciar en ${name}: ${e.message}`)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const first = !current.length && done.length
|
|
78
|
+
current = done
|
|
79
|
+
if (first && !quiet) console.log(`[content] anunciado como node de ${owner.slice(0, 16)} en ${done.length} proxio(s)`)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
publishAll()
|
|
83
|
+
// Re-publicar al reconectar: el token cambia y el anuncio viejo apunta a una
|
|
84
|
+
// conexión que ya no existe. Sin esto, un corte de red deja al node listado y
|
|
85
|
+
// mudo, que es peor que no estar listado.
|
|
86
|
+
const offToken = client.on('token', () => { current = []; publishAll() })
|
|
87
|
+
const timer = setInterval(publishAll, intervalMs)
|
|
88
|
+
timer.unref?.()
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
channels: () => [...current],
|
|
92
|
+
close () {
|
|
93
|
+
stopped = true
|
|
94
|
+
clearInterval(timer)
|
|
95
|
+
try { offToken?.() } catch (_) {}
|
|
96
|
+
for (const name of current) { try { client.unpublish(name) } catch (_) {} }
|
|
97
|
+
current = []
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Lado del que BUSCA: los tokens de los nodes de un dueño que están en línea, en
|
|
104
|
+
* todos los proxios conocidos, sin repetidos.
|
|
105
|
+
*
|
|
106
|
+
* Lo que devuelve son **candidatos, no autoridades**: cualquiera puede publicarse
|
|
107
|
+
* en el canal de cualquier dueño (el nombre del canal no es un secreto). Quien
|
|
108
|
+
* pregunte tiene que comprobar las dos cosas de siempre — que el node presenta un
|
|
109
|
+
* certificado que encadena a ese `ownerId`, y que los bytes que entrega hashean al
|
|
110
|
+
* `cid` pedido. Con esas dos, un impostor en la lista no consigue nada más que
|
|
111
|
+
* gastar un intento.
|
|
112
|
+
*
|
|
113
|
+
* @param {{ client: any, owner: string }} opts
|
|
114
|
+
* @returns {Promise<string[]>} tokens (direcciones en el proxy)
|
|
115
|
+
*/
|
|
116
|
+
export async function findNodes ({ client, owner }) {
|
|
117
|
+
if (!client || !owner) throw new Error('findNodes: faltan client u owner')
|
|
118
|
+
const known = Array.isArray(client.knownNodes) && client.knownNodes.length
|
|
119
|
+
? client.knownNodes
|
|
120
|
+
: (client.node ? [client.node] : [])
|
|
121
|
+
const out = new Set()
|
|
122
|
+
for (const nodeId of known) {
|
|
123
|
+
try {
|
|
124
|
+
for (const token of await client.list(channelFor(nodeId, owner))) out.add(token)
|
|
125
|
+
} catch (_) { /* un proxio que no contesta no invalida al otro */ }
|
|
126
|
+
}
|
|
127
|
+
// El propio que pregunta puede estar en la lista (una PWA que también es node).
|
|
128
|
+
if (client.token) out.delete(client.token)
|
|
129
|
+
return [...out]
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export default { startAnnounce, findNodes, channelFor, REPUBLISH_MS }
|