nio-cat 0.5.0 → 0.6.1
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/bin/nio.js +150 -13
- package/package.json +2 -1
- package/src/agents/claude.js +3 -1
- package/src/audit.js +55 -0
- package/src/auth.js +175 -0
- package/src/commands.js +12 -1
- package/src/directory.js +56 -0
- package/src/hub.js +60 -12
- package/src/jobs.js +287 -0
- package/src/mcp.js +142 -0
- package/src/memory.js +131 -0
- package/src/proc.js +1 -1
- package/src/push.js +52 -18
- package/src/server.js +93 -31
- package/src/service.js +123 -0
- package/src/tunnel.js +150 -34
- package/web/app/app.js +558 -44
- package/web/app/style.css +73 -11
- package/web/board.css +3 -2
- package/web/board.js +4 -3
- package/web/sw.js +13 -9
package/src/memory.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Memòria compartida: fitxers markdown a ~/.nio/memory, només en aquest ordinador.
|
|
3
|
+
// Claude Code i Codex hi accedeixen pel servidor MCP de nio (`nio mcp`), i tu des de l'app.
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { HOME } = require('./config');
|
|
7
|
+
|
|
8
|
+
const DIR = process.env.NIO_MEMORY || path.join(HOME, 'memory');
|
|
9
|
+
const MAX = 512 * 1024; // mida màxima d'un fitxer
|
|
10
|
+
|
|
11
|
+
const SEED = {
|
|
12
|
+
'LLEGEIX-ME.md': `# Memòria de nio
|
|
13
|
+
|
|
14
|
+
Aquesta carpeta és la memòria que comparteixen els teus agents (Claude Code i Codex) en aquest ordinador.
|
|
15
|
+
Són fitxers markdown normals: els pots editar aquí, des de l'app (Memòria) o amb qualsevol editor.
|
|
16
|
+
|
|
17
|
+
- \`preferencies.md\`: com t'agrada treballar (idioma, estil, eines…).
|
|
18
|
+
- \`projectes/\`: un fitxer per projecte, amb context que val la pena recordar.
|
|
19
|
+
- \`persones/\`: gent amb qui treballes i el que cal saber-ne.
|
|
20
|
+
|
|
21
|
+
nio no envia mai aquests fitxers enlloc.
|
|
22
|
+
`,
|
|
23
|
+
'preferencies.md': '# Preferències\n\n- Respon en català.\n',
|
|
24
|
+
'projectes/.keep': '',
|
|
25
|
+
'persones/.keep': '',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function httpErr(status, message) {
|
|
29
|
+
return Object.assign(new Error(message), { status });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function ensure() {
|
|
33
|
+
if (fs.existsSync(DIR)) return;
|
|
34
|
+
fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
|
35
|
+
for (const [rel, txt] of Object.entries(SEED)) {
|
|
36
|
+
const f = path.join(DIR, rel);
|
|
37
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
38
|
+
fs.writeFileSync(f, txt);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Ruta relativa segura: només .md dins de la carpeta de memòria, sense «..» ni fitxers ocults.
|
|
43
|
+
function safe(rel) {
|
|
44
|
+
const r = String(rel || '').replace(/\\/g, '/').trim();
|
|
45
|
+
if (!r || r.length > 200) throw httpErr(400, 'Falta el nom del fitxer');
|
|
46
|
+
if (r.startsWith('/')) throw httpErr(400, 'Fes servir una ruta relativa a la memòria');
|
|
47
|
+
const parts = r.split('/');
|
|
48
|
+
if (parts.some((p) => !p || p === '.' || p === '..' || p.startsWith('.'))) throw httpErr(400, 'Aquest nom de fitxer no és vàlid');
|
|
49
|
+
if (!/\.md$/i.test(r)) throw httpErr(400, 'Els fitxers de memòria han d’acabar en .md');
|
|
50
|
+
if (!/^[\p{L}\p{N} _.,()'’-]+$/u.test(parts.join(''))) throw httpErr(400, 'Fes servir lletres, xifres, espais, guions i punts');
|
|
51
|
+
const abs = path.resolve(DIR, r);
|
|
52
|
+
if (!abs.startsWith(path.resolve(DIR) + path.sep)) throw httpErr(400, 'Aquest nom de fitxer no és vàlid');
|
|
53
|
+
return { rel: parts.join('/'), abs };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function list() {
|
|
57
|
+
ensure();
|
|
58
|
+
const out = [];
|
|
59
|
+
const walk = (dir, pre) => {
|
|
60
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
61
|
+
if (e.name.startsWith('.')) continue;
|
|
62
|
+
const rel = pre ? `${pre}/${e.name}` : e.name;
|
|
63
|
+
const abs = path.join(dir, e.name);
|
|
64
|
+
if (e.isDirectory()) walk(abs, rel);
|
|
65
|
+
else if (e.isFile() && /\.md$/i.test(e.name)) {
|
|
66
|
+
const st = fs.statSync(abs);
|
|
67
|
+
out.push({ path: rel, size: st.size, updated: st.mtimeMs });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
walk(DIR, '');
|
|
72
|
+
return out.sort((a, b) => a.path.localeCompare(b.path, 'ca'));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function read(rel) {
|
|
76
|
+
ensure();
|
|
77
|
+
const { rel: r, abs } = safe(rel);
|
|
78
|
+
if (!fs.existsSync(abs)) throw httpErr(404, `No hi ha cap fitxer «${r}» a la memòria`);
|
|
79
|
+
return { path: r, content: fs.readFileSync(abs, 'utf8'), updated: fs.statSync(abs).mtimeMs };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function write(rel, content, { append = false } = {}) {
|
|
83
|
+
ensure();
|
|
84
|
+
const { rel: r, abs } = safe(rel);
|
|
85
|
+
const text = String(content ?? '');
|
|
86
|
+
const prev = append && fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : '';
|
|
87
|
+
const next = append ? `${prev}${prev && !prev.endsWith('\n') ? '\n' : ''}${text}` : text;
|
|
88
|
+
if (Buffer.byteLength(next) > MAX) throw httpErr(413, 'El fitxer seria massa gran (màxim 512 kB)');
|
|
89
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true, mode: 0o700 });
|
|
90
|
+
fs.writeFileSync(abs, next.endsWith('\n') ? next : `${next}\n`, { mode: 0o600 });
|
|
91
|
+
return { path: r, size: Buffer.byteLength(next) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function remove(rel) {
|
|
95
|
+
ensure();
|
|
96
|
+
const { rel: r, abs } = safe(rel);
|
|
97
|
+
if (!fs.existsSync(abs)) throw httpErr(404, `No hi ha cap fitxer «${r}» a la memòria`);
|
|
98
|
+
fs.rmSync(abs);
|
|
99
|
+
return { path: r };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Cerca de text (sense majúscules ni accents) amb la línia on surt.
|
|
103
|
+
function search(q, limit = 40) {
|
|
104
|
+
const norm = (s) => String(s).toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
|
|
105
|
+
const terms = norm(q).split(/\s+/).filter(Boolean);
|
|
106
|
+
if (!terms.length) throw httpErr(400, 'Escriu què vols buscar');
|
|
107
|
+
const hits = [];
|
|
108
|
+
for (const f of list()) {
|
|
109
|
+
const lines = fs.readFileSync(path.join(DIR, f.path), 'utf8').split('\n');
|
|
110
|
+
const nameHit = terms.every((t) => norm(f.path).includes(t));
|
|
111
|
+
lines.forEach((line, i) => {
|
|
112
|
+
if (hits.length >= limit) return;
|
|
113
|
+
const n = norm(line);
|
|
114
|
+
if (terms.every((t) => n.includes(t)) || (nameHit && i === 0)) hits.push({ path: f.path, line: i + 1, text: line.slice(0, 300) });
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return hits;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function routes() {
|
|
121
|
+
const wrap = (fn) => fn;
|
|
122
|
+
return [
|
|
123
|
+
{ method: 'GET', path: '/api/memory', handler: wrap(() => ({ dir: DIR, files: list() })) },
|
|
124
|
+
{ method: 'GET', path: '/api/memory/search', handler: wrap(({ url }) => search(url.searchParams.get('q') || '')) },
|
|
125
|
+
{ method: 'GET', path: /^\/api\/memory\/f\/(.+)$/, handler: wrap(({ params }) => read(params[0])) },
|
|
126
|
+
{ method: 'PUT', path: /^\/api\/memory\/f\/(.+)$/, limit: MAX + 4096, handler: wrap(({ params, body }) => write(params[0], body.content)) },
|
|
127
|
+
{ method: 'DELETE', path: /^\/api\/memory\/f\/(.+)$/, handler: wrap(({ params }) => remove(params[0])) },
|
|
128
|
+
];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = { DIR, ensure, safe, list, read, write, remove, search, routes };
|
package/src/proc.js
CHANGED
package/src/push.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
// Web Push sense dependències ni servidors de tercers:
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// Web Push sense dependències ni servidors de tercers: VAPID (RFC 8292) i, si el navegador
|
|
3
|
+
// ens ha donat les claus de la subscripció, contingut xifrat de punta a punta (RFC 8291,
|
|
4
|
+
// aes128gcm). El servei de push d'Apple o Google només veu bytes xifrats.
|
|
5
|
+
// Sense claus (subscripcions antigues), s'envia un avís buit i el service worker
|
|
6
|
+
// demana el detall directament a aquest ordinador.
|
|
5
7
|
const crypto = require('crypto');
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
const b64 = (buf) => Buffer.from(buf).toString('base64url');
|
|
10
|
+
const unb64 = (s) => Buffer.from(String(s || ''), 'base64url');
|
|
11
|
+
const hmac = (key, data) => crypto.createHmac('sha256', key).update(data).digest();
|
|
10
12
|
|
|
11
13
|
function vapidHeader(vapid, endpoint) {
|
|
12
14
|
const aud = new URL(endpoint).origin;
|
|
@@ -20,21 +22,53 @@ function vapidHeader(vapid, endpoint) {
|
|
|
20
22
|
return `vapid t=${data}.${b64(sig)}, k=${vapid.publicKey}`;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
+
// RFC 8291. `test` permet fixar la clau efímera i la sal per comprovar-ho amb els vectors de l'RFC.
|
|
26
|
+
function encrypt(payload, { p256dh, auth }, test = {}) {
|
|
27
|
+
const uaPublic = unb64(p256dh);
|
|
28
|
+
const authSecret = unb64(auth);
|
|
29
|
+
if (uaPublic.length !== 65 || authSecret.length !== 16) throw new Error('Claus de subscripció no vàlides');
|
|
30
|
+
const ecdh = crypto.createECDH('prime256v1');
|
|
31
|
+
if (test.privateKey) ecdh.setPrivateKey(unb64(test.privateKey));
|
|
32
|
+
else ecdh.generateKeys();
|
|
33
|
+
const asPublic = ecdh.getPublicKey();
|
|
34
|
+
const shared = ecdh.computeSecret(uaPublic);
|
|
35
|
+
const salt = test.salt ? unb64(test.salt) : crypto.randomBytes(16);
|
|
36
|
+
|
|
37
|
+
const prkKey = hmac(authSecret, shared);
|
|
38
|
+
const keyInfo = Buffer.concat([Buffer.from('WebPush: info\0'), uaPublic, asPublic, Buffer.from([1])]);
|
|
39
|
+
const ikm = hmac(prkKey, keyInfo);
|
|
40
|
+
const prk = hmac(salt, ikm);
|
|
41
|
+
const cek = hmac(prk, Buffer.from('Content-Encoding: aes128gcm\0\x01')).subarray(0, 16);
|
|
42
|
+
const nonce = hmac(prk, Buffer.from('Content-Encoding: nonce\0\x01')).subarray(0, 12);
|
|
43
|
+
|
|
44
|
+
const plain = Buffer.concat([Buffer.from(payload), Buffer.from([2])]); // un sol registre, sense farciment
|
|
45
|
+
const cipher = crypto.createCipheriv('aes-128-gcm', cek, nonce);
|
|
46
|
+
const body = Buffer.concat([cipher.update(plain), cipher.final(), cipher.getAuthTag()]);
|
|
47
|
+
const rs = Buffer.alloc(4);
|
|
48
|
+
rs.writeUInt32BE(4096);
|
|
49
|
+
return Buffer.concat([salt, rs, Buffer.from([asPublic.length]), asPublic, body]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Envia l'avís a totes les subscripcions. Retorna els endpoints que ja no són vàlids.
|
|
53
|
+
async function notifyAll(cfg, message) {
|
|
25
54
|
const dead = [];
|
|
55
|
+
const json = message ? JSON.stringify(message).slice(0, 3000) : null;
|
|
26
56
|
await Promise.all(
|
|
27
57
|
(cfg.pushSubs || []).map(async (sub) => {
|
|
28
58
|
try {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
59
|
+
const headers = {
|
|
60
|
+
Authorization: vapidHeader(cfg.vapid, sub.endpoint),
|
|
61
|
+
TTL: '3600',
|
|
62
|
+
Urgency: 'high',
|
|
63
|
+
};
|
|
64
|
+
let body = null;
|
|
65
|
+
if (json && sub.keys?.p256dh && sub.keys?.auth) {
|
|
66
|
+
body = encrypt(json, sub.keys);
|
|
67
|
+
headers['Content-Encoding'] = 'aes128gcm';
|
|
68
|
+
headers['Content-Type'] = 'application/octet-stream';
|
|
69
|
+
}
|
|
70
|
+
headers['Content-Length'] = String(body ? body.length : 0);
|
|
71
|
+
const res = await fetch(sub.endpoint, { method: 'POST', headers, body });
|
|
38
72
|
if (res.status === 404 || res.status === 410) dead.push(sub.endpoint);
|
|
39
73
|
} catch {
|
|
40
74
|
/* sense xarxa: ho tornarem a provar al següent avís */
|
|
@@ -44,4 +78,4 @@ async function notifyAll(cfg) {
|
|
|
44
78
|
return dead;
|
|
45
79
|
}
|
|
46
80
|
|
|
47
|
-
module.exports = { notifyAll, vapidHeader };
|
|
81
|
+
module.exports = { notifyAll, vapidHeader, encrypt };
|
package/src/server.js
CHANGED
|
@@ -13,6 +13,7 @@ const github = require('./github');
|
|
|
13
13
|
const { addServer, knownHosts } = require('./ssh');
|
|
14
14
|
const { agentCommands } = require('./commands');
|
|
15
15
|
const { pairLink, qrSvg, makeCode, codeLink } = require('./pair');
|
|
16
|
+
const directory = require('./directory');
|
|
16
17
|
|
|
17
18
|
// L'app web: copiada dins del paquet en publicar-lo, o l'arrel del repositori en desenvolupament.
|
|
18
19
|
const WEB_DIRS = [path.join(__dirname, '..', 'web'), path.join(__dirname, '..', '..')];
|
|
@@ -42,14 +43,6 @@ function originAllowed(cfg, origin) {
|
|
|
42
43
|
});
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
function tokenOk(cfg, req) {
|
|
46
|
-
const h = req.headers.authorization || '';
|
|
47
|
-
const t = h.startsWith('Bearer ') ? h.slice(7) : '';
|
|
48
|
-
const a = Buffer.from(t);
|
|
49
|
-
const b = Buffer.from(cfg.token);
|
|
50
|
-
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
46
|
function isLoopbackDirect(req) {
|
|
54
47
|
const ip = req.socket.remoteAddress || '';
|
|
55
48
|
const local = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
|
|
@@ -141,26 +134,53 @@ function listDir(cfg, raw) {
|
|
|
141
134
|
// Noms de model d'Ollama: «qwen3:4b», «hf.co/usuari/model:Q4_K_M»…
|
|
142
135
|
const MODEL_NAME = /^[\w][\w.\-/]{0,100}(:[\w.\-]{1,60})?$/;
|
|
143
136
|
|
|
144
|
-
|
|
145
|
-
|
|
137
|
+
// Rutes que afegeixen altres mòduls (memòria, feines, Telegram, veu, navegador…).
|
|
138
|
+
// Cada ruta: { method, path: RegExp|string, handler: async ({ req, res, url, params, body, device }), raw?, limit? }.
|
|
139
|
+
function matchRoute(routes, method, p) {
|
|
140
|
+
for (const r of routes) {
|
|
141
|
+
if (r.method !== method) continue;
|
|
142
|
+
if (typeof r.path === 'string') {
|
|
143
|
+
if (r.path === p) return { r, params: [] };
|
|
144
|
+
} else {
|
|
145
|
+
const m = p.match(r.path);
|
|
146
|
+
if (m) return { r, params: m.slice(1).map((x) => decodeURIComponent(x)) };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function createServer({ cfg, hub, auth, audit, getPublicUrl, getApp, routes = [], info = () => ({}) }) {
|
|
153
|
+
const streams = new Map(); // res -> id del dispositiu
|
|
154
|
+
auth.onRevoke((d) => {
|
|
155
|
+
for (const [res, dev] of streams) if (dev === d.id) { res.end(); streams.delete(res); }
|
|
156
|
+
const before = (cfg.pushSubs || []).length;
|
|
157
|
+
cfg.pushSubs = (cfg.pushSubs || []).filter((x) => x.device !== d.id);
|
|
158
|
+
if (cfg.pushSubs.length !== before) save(cfg);
|
|
159
|
+
});
|
|
146
160
|
const seenDevices = new Map(); // mòbils que s'han connectat (per avisar l'ordinador que mostra el QR)
|
|
147
161
|
const pulls = new Map(); // descàrregues de models d'Ollama en curs
|
|
148
162
|
|
|
149
163
|
hub.on('out', (msg) => {
|
|
150
164
|
const data = `data: ${JSON.stringify(msg)}\n\n`;
|
|
151
|
-
for (const res of streams) res.write(data);
|
|
165
|
+
for (const res of streams.keys()) res.write(data);
|
|
152
166
|
});
|
|
153
167
|
setInterval(() => {
|
|
154
|
-
for (const res of streams) res.write(': ping\n\n');
|
|
168
|
+
for (const res of streams.keys()) res.write(': ping\n\n');
|
|
155
169
|
}, 15000).unref();
|
|
156
170
|
|
|
157
171
|
const hookUrl = () => `http://127.0.0.1:${cfg.port}/internal/approval`;
|
|
158
172
|
|
|
159
|
-
async function api(req, res, url) {
|
|
173
|
+
async function api(req, res, url, device) {
|
|
160
174
|
const p = url.pathname;
|
|
161
175
|
const m = (re) => p.match(re);
|
|
162
176
|
const method = req.method;
|
|
163
177
|
|
|
178
|
+
const hit = matchRoute(routes, method, p);
|
|
179
|
+
if (hit) {
|
|
180
|
+
const body = method === 'GET' || method === 'DELETE' || hit.r.raw ? undefined : await readBody(req, hit.r.limit);
|
|
181
|
+
return hit.r.handler({ req, res, url, params: hit.params, body, device });
|
|
182
|
+
}
|
|
183
|
+
|
|
164
184
|
if (p === '/api/info' && method === 'GET') {
|
|
165
185
|
return {
|
|
166
186
|
name: 'nio',
|
|
@@ -173,8 +193,32 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
173
193
|
roots: cfg.roots,
|
|
174
194
|
vapidKey: cfg.vapid.publicKey,
|
|
175
195
|
id: cfg.id,
|
|
196
|
+
device: { id: device.id, name: device.name, primary: !!device.primary },
|
|
197
|
+
identity: directory.identity(cfg),
|
|
198
|
+
...(await info()),
|
|
176
199
|
};
|
|
177
200
|
}
|
|
201
|
+
if (p === '/api/devices' && method === 'GET') return auth.list(device.id);
|
|
202
|
+
let dv;
|
|
203
|
+
if ((dv = m(/^\/api\/devices\/([\w-]+)$/))) {
|
|
204
|
+
if (method === 'PATCH') {
|
|
205
|
+
const b = await readBody(req);
|
|
206
|
+
const r = auth.rename(dv[1], b.name);
|
|
207
|
+
if (!r) throw httpErr(404, 'Aquest dispositiu no existeix');
|
|
208
|
+
return r;
|
|
209
|
+
}
|
|
210
|
+
if (method === 'DELETE') {
|
|
211
|
+
const r = auth.revoke(dv[1]);
|
|
212
|
+
if (!r) throw httpErr(404, 'Aquest dispositiu no existeix');
|
|
213
|
+
audit.add({ type: 'device', what: `Accés revocat: ${r.name}`, by: device.name });
|
|
214
|
+
return r;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (p === '/api/audit' && method === 'GET') {
|
|
218
|
+
const limit = Math.min(1000, Number(url.searchParams.get('limit')) || 200);
|
|
219
|
+
const type = url.searchParams.get('type');
|
|
220
|
+
return audit.recent(limit, type ? (e) => e.type === type : null);
|
|
221
|
+
}
|
|
178
222
|
if (p === '/api/ssh/hosts' && method === 'GET') return knownHosts();
|
|
179
223
|
if (p === '/api/projects' && method === 'GET') return listProjects(cfg);
|
|
180
224
|
if (p === '/api/fs' && method === 'GET') return listDir(cfg, url.searchParams.get('path'));
|
|
@@ -236,9 +280,11 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
236
280
|
// Enllaç i QR per connectar un altre dispositiu (el mòbil) amb aquest ordinador.
|
|
237
281
|
const pub = getPublicUrl();
|
|
238
282
|
if (!pub || /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:|\/|$)/i.test(pub)) return { link: null };
|
|
239
|
-
|
|
240
|
-
const code =
|
|
241
|
-
|
|
283
|
+
// Un secret d'un sol ús (no la clau principal): el mòbil el canvia per una clau pròpia i revocable.
|
|
284
|
+
const { code: secret, expires } = auth.createCode({ secret: true });
|
|
285
|
+
const link = pairLink(getApp ? getApp() : cfg.app, pub, secret);
|
|
286
|
+
const code = makeCode(pub, secret);
|
|
287
|
+
return { link, code, svg: qrSvg(codeLink(getApp ? getApp() : cfg.app, code)), url: pub, lan: /^http:\/\//.test(pub), expires };
|
|
242
288
|
}
|
|
243
289
|
if (p === '/api/github' && method === 'GET') return github.status();
|
|
244
290
|
if (p === '/api/github/repos' && method === 'GET') return github.repos(url.searchParams.get('owner') || '');
|
|
@@ -293,7 +339,7 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
293
339
|
if (p === '/api/approvals' && method === 'GET') return hub.pendingApprovals();
|
|
294
340
|
if ((r = m(/^\/api\/approvals\/([\w-]+)$/)) && method === 'POST') {
|
|
295
341
|
const b = await readBody(req);
|
|
296
|
-
const a = hub.decide(r[1], b.decision === 'allow' ? 'allow' : 'deny', b.reason);
|
|
342
|
+
const a = hub.decide(r[1], b.decision === 'allow' ? 'allow' : 'deny', b.reason, device.name);
|
|
297
343
|
if (!a) throw httpErr(404, 'Aquesta petició ja s’ha resolt');
|
|
298
344
|
return a;
|
|
299
345
|
}
|
|
@@ -301,9 +347,10 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
301
347
|
if (p === '/api/push' && method === 'POST') {
|
|
302
348
|
const b = await readBody(req);
|
|
303
349
|
if (!b.endpoint || !/^https:\/\//.test(b.endpoint)) throw httpErr(400, 'La subscripció als avisos no és vàlida');
|
|
350
|
+
const keys = b.keys && typeof b.keys.p256dh === 'string' && typeof b.keys.auth === 'string' ? { p256dh: b.keys.p256dh.slice(0, 200), auth: b.keys.auth.slice(0, 100) } : null;
|
|
304
351
|
cfg.pushSubs = (cfg.pushSubs || []).filter((x) => x.endpoint !== b.endpoint);
|
|
305
|
-
cfg.pushSubs.push({ endpoint: b.endpoint, added: Date.now() });
|
|
306
|
-
cfg.pushSubs = cfg.pushSubs.slice(-
|
|
352
|
+
cfg.pushSubs.push({ endpoint: String(b.endpoint).slice(0, 1000), keys, device: device.id, added: Date.now() });
|
|
353
|
+
cfg.pushSubs = cfg.pushSubs.slice(-20);
|
|
307
354
|
save(cfg);
|
|
308
355
|
return { ok: true };
|
|
309
356
|
}
|
|
@@ -376,11 +423,26 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
376
423
|
return res.end(JSON.stringify({ nio: true, id: cfg.id, name: os.hostname() }));
|
|
377
424
|
}
|
|
378
425
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
426
|
+
const json = (status, obj) => {
|
|
427
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
428
|
+
res.end(JSON.stringify(obj));
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
// Aparellament: canvia un codi d'un sol ús per una clau pròpia d'aquest dispositiu. No demana clau.
|
|
432
|
+
if (url.pathname === '/api/pair' && req.method === 'POST') {
|
|
433
|
+
try {
|
|
434
|
+
const b = await readBody(req, 4096);
|
|
435
|
+
const r = auth.redeem(b.code, b.name);
|
|
436
|
+
audit.add({ type: 'device', what: `Dispositiu aparellat: ${r.device.name}`, by: r.device.name });
|
|
437
|
+
return json(200, { ...r, id: cfg.id, host: os.hostname(), identity: directory.identity(cfg) });
|
|
438
|
+
} catch (e) {
|
|
439
|
+
return json(e.status || 500, { error: e.message });
|
|
383
440
|
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (url.pathname.startsWith('/api/')) {
|
|
444
|
+
const device = auth.fromRequest(req);
|
|
445
|
+
if (!device) return json(401, { error: 'Aquest dispositiu no té accés (o l’has revocat). Torna a aparellar-lo amb un codi nou.' });
|
|
384
446
|
if (url.pathname === '/api/stream') {
|
|
385
447
|
res.writeHead(200, {
|
|
386
448
|
'content-type': 'text/event-stream; charset=utf-8',
|
|
@@ -392,10 +454,10 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
392
454
|
const dev = String(url.searchParams.get('d') || '').slice(0, 40), kind = url.searchParams.get('k') === 'phone' ? 'phone' : 'desktop';
|
|
393
455
|
if (dev && kind === 'phone' && Date.now() - (seenDevices.get(dev) || 0) > 10 * 60 * 1000) {
|
|
394
456
|
const data = `data: ${JSON.stringify({ type: 'device', device: dev, kind })}\n\n`;
|
|
395
|
-
for (const o of streams) o.write(data);
|
|
457
|
+
for (const o of streams.keys()) o.write(data);
|
|
396
458
|
}
|
|
397
459
|
if (dev) seenDevices.set(dev, Date.now());
|
|
398
|
-
streams.
|
|
460
|
+
streams.set(res, device.id);
|
|
399
461
|
req.on('close', () => streams.delete(res));
|
|
400
462
|
return;
|
|
401
463
|
}
|
|
@@ -414,12 +476,12 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
414
476
|
return res.end();
|
|
415
477
|
}
|
|
416
478
|
try {
|
|
417
|
-
const out = await api(req, res, url);
|
|
418
|
-
res.
|
|
419
|
-
|
|
479
|
+
const out = await api(req, res, url, device);
|
|
480
|
+
if (res.headersSent) return; // la ruta ja ha respost (p. ex. àudio o imatges)
|
|
481
|
+
json(200, out);
|
|
420
482
|
} catch (e) {
|
|
421
|
-
res.
|
|
422
|
-
|
|
483
|
+
if (res.headersSent) return res.end();
|
|
484
|
+
json(e.status || 500, { error: e.message });
|
|
423
485
|
}
|
|
424
486
|
return;
|
|
425
487
|
}
|
|
@@ -433,4 +495,4 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
433
495
|
return server;
|
|
434
496
|
}
|
|
435
497
|
|
|
436
|
-
module.exports = { createServer, originAllowed };
|
|
498
|
+
module.exports = { createServer, originAllowed, readBody };
|
package/src/service.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Servei del sistema perquè nio arrenqui sol amb l'ordinador (systemd a Linux, launchd a macOS).
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { spawnSync } = require('child_process');
|
|
7
|
+
const { HOME } = require('./config');
|
|
8
|
+
const { searchPath } = require('./proc');
|
|
9
|
+
|
|
10
|
+
const LABEL = 'cat.nio.daemon';
|
|
11
|
+
|
|
12
|
+
function entry() {
|
|
13
|
+
return fs.realpathSync(path.join(__dirname, '..', 'bin', 'nio.js'));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function q(s) {
|
|
17
|
+
// Cometes per a ExecStart de systemd.
|
|
18
|
+
return /[\s"\\]/.test(s) ? `"${String(s).replace(/(["\\])/g, '\\$1')}"` : s;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function systemdUnit({ roots, system }) {
|
|
22
|
+
const env = [`PATH=${searchPath().join(':')}`, process.env.NIO_HOME ? `NIO_HOME=${process.env.NIO_HOME}` : null].filter(Boolean);
|
|
23
|
+
return `[Unit]
|
|
24
|
+
Description=nio · els teus agents, des del mòbil
|
|
25
|
+
After=network-online.target
|
|
26
|
+
Wants=network-online.target
|
|
27
|
+
|
|
28
|
+
[Service]
|
|
29
|
+
Type=simple
|
|
30
|
+
ExecStart=${[process.execPath, entry(), '--no-open', ...roots].map(q).join(' ')}
|
|
31
|
+
WorkingDirectory=${roots[0] || os.homedir()}
|
|
32
|
+
${env.map((e) => `Environment=${q(e)}`).join('\n')}
|
|
33
|
+
Environment=NO_COLOR=1
|
|
34
|
+
Restart=always
|
|
35
|
+
RestartSec=3
|
|
36
|
+
${system ? `User=${os.userInfo().username}\n` : ''}
|
|
37
|
+
[Install]
|
|
38
|
+
WantedBy=${system ? 'multi-user.target' : 'default.target'}
|
|
39
|
+
`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function launchdPlist({ roots }) {
|
|
43
|
+
const x = (s) => String(s).replace(/&/g, '&').replace(/</g, '<');
|
|
44
|
+
const args = [process.execPath, entry(), '--no-open', ...roots].map((a) => ` <string>${x(a)}</string>`).join('\n');
|
|
45
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
46
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
47
|
+
<plist version="1.0">
|
|
48
|
+
<dict>
|
|
49
|
+
<key>Label</key><string>${LABEL}</string>
|
|
50
|
+
<key>ProgramArguments</key>
|
|
51
|
+
<array>
|
|
52
|
+
${args}
|
|
53
|
+
</array>
|
|
54
|
+
<key>WorkingDirectory</key><string>${x(roots[0] || os.homedir())}</string>
|
|
55
|
+
<key>EnvironmentVariables</key>
|
|
56
|
+
<dict>
|
|
57
|
+
<key>PATH</key><string>${x(searchPath().join(':'))}</string>
|
|
58
|
+
<key>NO_COLOR</key><string>1</string>${process.env.NIO_HOME ? `\n <key>NIO_HOME</key><string>${x(process.env.NIO_HOME)}</string>` : ''}
|
|
59
|
+
</dict>
|
|
60
|
+
<key>RunAtLoad</key><true/>
|
|
61
|
+
<key>KeepAlive</key><true/>
|
|
62
|
+
<key>StandardOutPath</key><string>${x(path.join(HOME, 'nio.log'))}</string>
|
|
63
|
+
<key>StandardErrorPath</key><string>${x(path.join(HOME, 'nio.log'))}</string>
|
|
64
|
+
</dict>
|
|
65
|
+
</plist>
|
|
66
|
+
`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sh(cmd, args) {
|
|
70
|
+
const r = spawnSync(cmd, args, { encoding: 'utf8' });
|
|
71
|
+
return { ok: r.status === 0, out: `${r.stdout || ''}${r.stderr || ''}`.trim() };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function install({ roots, log = console.log }) {
|
|
75
|
+
if (entry().includes(`${path.sep}_npx${path.sep}`)) {
|
|
76
|
+
log(' Avís: nio s’executa des de la memòria cau de npx, que es pot esborrar.');
|
|
77
|
+
log(' Per a un servei estable, instal·la’l primer: npm install -g https://www.nio.cat/nio.tgz\n');
|
|
78
|
+
}
|
|
79
|
+
if (process.platform === 'darwin') {
|
|
80
|
+
const file = path.join(os.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
|
|
81
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
82
|
+
sh('launchctl', ['unload', file]);
|
|
83
|
+
fs.writeFileSync(file, launchdPlist({ roots }));
|
|
84
|
+
const r = sh('launchctl', ['load', '-w', file]);
|
|
85
|
+
if (!r.ok) throw new Error(`launchctl no l’ha pogut carregar: ${r.out}`);
|
|
86
|
+
return { file, logs: `tail -f ${path.join(HOME, 'nio.log')}` };
|
|
87
|
+
}
|
|
88
|
+
if (process.platform !== 'linux') throw new Error('El servei automàtic només funciona a Linux (systemd) i macOS (launchd).');
|
|
89
|
+
if (!sh('systemctl', ['--version']).ok) throw new Error('Aquest sistema no té systemd.');
|
|
90
|
+
const system = process.getuid && process.getuid() === 0;
|
|
91
|
+
const file = system ? '/etc/systemd/system/nio.service' : path.join(os.homedir(), '.config', 'systemd', 'user', 'nio.service');
|
|
92
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
93
|
+
fs.writeFileSync(file, systemdUnit({ roots, system }));
|
|
94
|
+
const ctl = system ? ['systemctl'] : ['systemctl', '--user'];
|
|
95
|
+
sh(ctl[0], [...ctl.slice(1), 'daemon-reload']);
|
|
96
|
+
const r = sh(ctl[0], [...ctl.slice(1), 'enable', '--now', 'nio.service']);
|
|
97
|
+
if (!r.ok) throw new Error(`systemctl no l’ha pogut engegar: ${r.out}`);
|
|
98
|
+
sh(ctl[0], [...ctl.slice(1), 'restart', 'nio.service']);
|
|
99
|
+
let linger = null;
|
|
100
|
+
if (!system) {
|
|
101
|
+
// Sense «linger», el servei d'usuari s'atura quan tanques la sessió (i no arrenca en reiniciar).
|
|
102
|
+
linger = sh('loginctl', ['enable-linger', os.userInfo().username]).ok;
|
|
103
|
+
}
|
|
104
|
+
return { file, linger, logs: system ? 'journalctl -u nio -f' : 'journalctl --user -u nio -f' };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function uninstall() {
|
|
108
|
+
if (process.platform === 'darwin') {
|
|
109
|
+
const file = path.join(os.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
|
|
110
|
+
sh('launchctl', ['unload', '-w', file]);
|
|
111
|
+
fs.rmSync(file, { force: true });
|
|
112
|
+
return file;
|
|
113
|
+
}
|
|
114
|
+
const system = process.getuid && process.getuid() === 0;
|
|
115
|
+
const ctl = system ? ['systemctl'] : ['systemctl', '--user'];
|
|
116
|
+
sh(ctl[0], [...ctl.slice(1), 'disable', '--now', 'nio.service']);
|
|
117
|
+
const file = system ? '/etc/systemd/system/nio.service' : path.join(os.homedir(), '.config', 'systemd', 'user', 'nio.service');
|
|
118
|
+
fs.rmSync(file, { force: true });
|
|
119
|
+
sh(ctl[0], [...ctl.slice(1), 'daemon-reload']);
|
|
120
|
+
return file;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { install, uninstall, systemdUnit, launchdPlist };
|