@nosserb/prmt 0.0.0-stage → 2.0.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.
Files changed (166) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +191 -2
  3. package/bin/prmt.mjs +174 -0
  4. package/dist/assets/AuditModule-BOO_iRy8.js +1 -0
  5. package/dist/assets/Audits-yUBm0EpW.js +1 -0
  6. package/dist/assets/BrainPage-FIWO_9Nc.js +3 -0
  7. package/dist/assets/BrainPage-XQCEGRc5.css +1 -0
  8. package/dist/assets/Carte-CPuiR1Ha.css +1 -0
  9. package/dist/assets/Carte-xycu1mQn.js +1 -0
  10. package/dist/assets/Dashboard-DKvCQcX8.js +2 -0
  11. package/dist/assets/Dashboard-XdhC6_hM.css +1 -0
  12. package/dist/assets/Field-C7c2mCbb.js +2 -0
  13. package/dist/assets/Journal-BLIIlh80.css +1 -0
  14. package/dist/assets/Journal-BaelFjwV.js +1 -0
  15. package/dist/assets/Landing-B2jxV3zs.css +1 -0
  16. package/dist/assets/Landing-Dr612Zdu.js +26 -0
  17. package/dist/assets/Register-DA_Aq5f8.js +1 -0
  18. package/dist/assets/Reports-8wfDNSxH.js +1 -0
  19. package/dist/assets/Reports-B8GXN95N.css +1 -0
  20. package/dist/assets/SavoirPage-BTxbH3LY.css +1 -0
  21. package/dist/assets/SavoirPage-Dp3eUCHp.js +1 -0
  22. package/dist/assets/SettingsPage-BElIP46c.css +1 -0
  23. package/dist/assets/SettingsPage-BiQbnw3o.js +1 -0
  24. package/dist/assets/Shell-BAyHVljp.css +1 -0
  25. package/dist/assets/Shell-CQRlx9-V.js +2 -0
  26. package/dist/assets/Table-DUmGel5e.js +1 -0
  27. package/dist/assets/Tabs-CfhbELZk.js +1 -0
  28. package/dist/assets/Terminal-Bv1IJfi9.js +3 -0
  29. package/dist/assets/Terminal-CEfj38NV.css +1 -0
  30. package/dist/assets/arrow-right-Cx3pAEhl.js +1 -0
  31. package/dist/assets/auditViews-BMza2Sdz.js +1 -0
  32. package/dist/assets/auditViews-BePWj26h.css +1 -0
  33. package/dist/assets/bricolage-grotesque-latin-ext-opsz-normal-IcJDqblK.woff2 +0 -0
  34. package/dist/assets/bricolage-grotesque-latin-opsz-normal-Cre6nC2_.woff2 +0 -0
  35. package/dist/assets/bricolage-grotesque-vietnamese-opsz-normal-D9N6E8K1.woff2 +0 -0
  36. package/dist/assets/charts-BcijSJ4J.js +1 -0
  37. package/dist/assets/circle-stop-C33BAcKW.js +1 -0
  38. package/dist/assets/command-ChA49Xk7.js +1 -0
  39. package/dist/assets/command-Ck4pdQCv.css +1 -0
  40. package/dist/assets/download-CeuKOenD.js +1 -0
  41. package/dist/assets/eye-CXXHBBpr.js +1 -0
  42. package/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  43. package/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  44. package/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  45. package/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  46. package/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  47. package/dist/assets/habillage-B5mMBadn.js +1 -0
  48. package/dist/assets/index-BH1etemR.css +1 -0
  49. package/dist/assets/index-COSiM-R9.js +14 -0
  50. package/dist/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
  51. package/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  52. package/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  53. package/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  54. package/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  55. package/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  56. package/dist/assets/layers-2x-Bpkbi35X.png +0 -0
  57. package/dist/assets/layers-BWBAp2CZ.png +0 -0
  58. package/dist/assets/markdown-DF87LQh1.css +1 -0
  59. package/dist/assets/markdown-o5sMhPHN.js +4 -0
  60. package/dist/assets/marker-icon-hN30_KVU.png +0 -0
  61. package/dist/assets/play-Daw4K_73.js +1 -0
  62. package/dist/assets/plus-D_QHm8ud.js +1 -0
  63. package/dist/assets/trash-DLpHM-s8.js +1 -0
  64. package/dist/assets/web-6j1-aY67.js +1 -0
  65. package/dist/favicon.svg +1 -0
  66. package/dist/index.html +18 -0
  67. package/docs/ANALYSE.md +87 -0
  68. package/docs/SECURITE.md +188 -0
  69. package/lib/scripts/lier.js +15 -0
  70. package/lib/scripts/searxng.js +91 -0
  71. package/lib/server/app.js +444 -0
  72. package/lib/server/audits/modules/local.js +429 -0
  73. package/lib/server/audits/modules/network.js +331 -0
  74. package/lib/server/audits/modules/web.js +302 -0
  75. package/lib/server/audits/net.js +141 -0
  76. package/lib/server/audits/registry.js +202 -0
  77. package/lib/server/audits/types.js +58 -0
  78. package/lib/server/brain/brain.js +624 -0
  79. package/lib/server/brain/conversations.js +82 -0
  80. package/lib/server/brain/ollama.js +197 -0
  81. package/lib/server/brain/prompt.js +188 -0
  82. package/lib/server/brain/provider.js +1 -0
  83. package/lib/server/cli/bridge.js +292 -0
  84. package/lib/server/cli/pont.js +62 -0
  85. package/lib/server/config.js +68 -0
  86. package/lib/server/engine/analyzer.js +205 -0
  87. package/lib/server/engine/catalog.js +229 -0
  88. package/lib/server/engine/decoder.js +91 -0
  89. package/lib/server/engine/engine.js +194 -0
  90. package/lib/server/engine/paths.js +129 -0
  91. package/lib/server/engine/policy.js +397 -0
  92. package/lib/server/engine/relax.js +10 -0
  93. package/lib/server/engine/runner.js +193 -0
  94. package/lib/server/errors.js +23 -0
  95. package/lib/server/http/guard.js +75 -0
  96. package/lib/server/http/router.js +154 -0
  97. package/lib/server/http/static.js +76 -0
  98. package/lib/server/index.js +40 -0
  99. package/lib/server/journal.js +139 -0
  100. package/lib/server/lien.js +33 -0
  101. package/lib/server/savoir/base.js +284 -0
  102. package/lib/server/savoir/lecture.js +406 -0
  103. package/lib/server/savoir/memoire.js +115 -0
  104. package/lib/server/search/categories.js +179 -0
  105. package/lib/server/search/fiche.js +326 -0
  106. package/lib/server/search/lieux.js +134 -0
  107. package/lib/server/search/overpass.js +216 -0
  108. package/lib/server/search/searxng.js +223 -0
  109. package/lib/server/search/tiles.js +103 -0
  110. package/lib/server/status.js +157 -0
  111. package/lib/server/stores.js +141 -0
  112. package/lib/server/telephone/acces.js +457 -0
  113. package/lib/server/telephone/certificat.js +79 -0
  114. package/lib/server/telephone/qr.js +308 -0
  115. package/lib/shared/analysis.js +40 -0
  116. package/lib/shared/schemas.js +105 -0
  117. package/lib/shared/types.js +10 -0
  118. package/package.json +81 -4
  119. package/searxng/settings.yml +35 -0
  120. package/tui/Modelfile +23 -0
  121. package/tui/README.md +103 -0
  122. package/tui/prmt_tui.py +65 -0
  123. package/tui/promethee/__init__.py +1 -0
  124. package/tui/promethee/__main__.py +4 -0
  125. package/tui/promethee/accueil.py +66 -0
  126. package/tui/promethee/agents.py +220 -0
  127. package/tui/promethee/app/__init__.py +4 -0
  128. package/tui/promethee/app/application.py +299 -0
  129. package/tui/promethee/app/clavier.py +99 -0
  130. package/tui/promethee/app/commandes_slash.py +164 -0
  131. package/tui/promethee/app/contexte.py +188 -0
  132. package/tui/promethee/app/definitions.py +518 -0
  133. package/tui/promethee/app/ecran.py +177 -0
  134. package/tui/promethee/app/envoi.py +141 -0
  135. package/tui/promethee/app/historique.py +373 -0
  136. package/tui/promethee/app/modele.py +108 -0
  137. package/tui/promethee/app/notes.py +63 -0
  138. package/tui/promethee/app/outils.py +589 -0
  139. package/tui/promethee/app/plateforme.py +409 -0
  140. package/tui/promethee/app/saisie.py +100 -0
  141. package/tui/promethee/app/taches.py +92 -0
  142. package/tui/promethee/app/tour.py +539 -0
  143. package/tui/promethee/app/validation.py +210 -0
  144. package/tui/promethee/appels.py +35 -0
  145. package/tui/promethee/commandes.py +154 -0
  146. package/tui/promethee/completion.py +140 -0
  147. package/tui/promethee/config.py +12 -0
  148. package/tui/promethee/consignes.py +278 -0
  149. package/tui/promethee/conversation.py +94 -0
  150. package/tui/promethee/etat.py +253 -0
  151. package/tui/promethee/fichiers.py +354 -0
  152. package/tui/promethee/journal.py +116 -0
  153. package/tui/promethee/logo.py +132 -0
  154. package/tui/promethee/memoire.py +50 -0
  155. package/tui/promethee/ollama.py +52 -0
  156. package/tui/promethee/plan.py +91 -0
  157. package/tui/promethee/politique.py +429 -0
  158. package/tui/promethee/preferences.py +65 -0
  159. package/tui/promethee/presse_papiers.py +44 -0
  160. package/tui/promethee/recherche.py +581 -0
  161. package/tui/promethee/securite.py +204 -0
  162. package/tui/promethee/shell.py +190 -0
  163. package/tui/promethee/textes.py +190 -0
  164. package/tui/promethee/theme.py +92 -0
  165. package/tui/promethee/widgets.py +493 -0
  166. package/tui/requirements.txt +1 -0
@@ -0,0 +1,457 @@
1
+ /**
2
+ * Accès depuis le téléphone : la console, joignable par le réseau local en HTTPS, pour les seuls appareils appairés.
3
+ *
4
+ * Fermé par défaut, ouvert depuis les Réglages du PC. Une fois ouvert, il le reste aux démarrages suivants, jusqu'à ce qu'on
5
+ * le ferme. Le serveur de la boucle locale (127.0.0.1:4317) ne change pas. Ce module tient l'écoute, le certificat, les
6
+ * codes d'appairage et les appareils. La garde et les routes restent dans server/http/guard.ts et server/app.ts.
7
+ *
8
+ * Écoute : un seul port (4318) par adresse du PC sur un réseau privé. Le premier octet de chaque connexion dit s'il s'agit
9
+ * de TLS : sinon, une redirection vers https, pour qu'une adresse tapée sans « https:// » mène quand même à la console.
10
+ */
11
+ import { createHash, randomBytes, randomInt, timingSafeEqual } from 'node:crypto';
12
+ import fs from 'node:fs';
13
+ import http from 'node:http';
14
+ import https from 'node:https';
15
+ import net from 'node:net';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ import { AppError, notFound } from '../errors.js';
19
+ import { loadCertificate, makeCertificate } from './certificat.js';
20
+ import { qrRows } from './qr.js';
21
+ export const PHONE_PORT = 4318;
22
+ export const COOKIE = '__Host-promethee-appareil';
23
+ /** Un appareil qui ne s'est pas montré depuis 30 jours doit être appairé de nouveau. */
24
+ export const DEVICE_DAYS = 30;
25
+ export const CODE_MINUTES = 10;
26
+ export const CODE_TRIES = 5;
27
+ /** Essais d'appairage par minute, tous codes confondus. */
28
+ const TRIES_PER_MINUTE = 20;
29
+ const DAY = 86_400_000;
30
+ /** Sans ambiguïté à la lecture : ni O, ni 0, ni I, ni 1, ni L. */
31
+ const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
32
+ const isPrivate = (ip) => /^10\./.test(ip) || /^192\.168\./.test(ip) || /^172\.(1[6-9]|2\d|3[01])\./.test(ip);
33
+ /** 100.64.0.0/10 : les adresses que Tailscale donne aux appareils de ton réseau privé. */
34
+ const isTailscale = (ip) => /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(ip);
35
+ /** Cartes virtuelles : WSL, Hyper-V, machines virtuelles, Docker, Bluetooth. Un téléphone n'y est jamais. */
36
+ const VIRTUAL = /vEthernet|WSL|Hyper-V|VirtualBox|vboxnet|VMware|Docker|Loopback|Bluetooth/i;
37
+ /**
38
+ * Adresses IPv4 du PC où un téléphone peut le joindre : le réseau local (adresses privées, cartes virtuelles écartées), et
39
+ * l'adresse Tailscale si Tailscale est installé, pour joindre la console de l'extérieur sans rien ouvrir sur internet.
40
+ */
41
+ export function lanAddresses(interfaces = os.networkInterfaces()) {
42
+ const lan = [];
43
+ const tailnet = [];
44
+ for (const [name, list] of Object.entries(interfaces)) {
45
+ const tailscale = /tailscale/i.test(name);
46
+ if (!tailscale && VIRTUAL.test(name))
47
+ continue;
48
+ for (const a of list ?? []) {
49
+ if (a.family !== 'IPv4' || a.internal)
50
+ continue;
51
+ if (tailscale && isTailscale(a.address) && !tailnet.includes(a.address))
52
+ tailnet.push(a.address);
53
+ if (!tailscale && isPrivate(a.address) && !lan.includes(a.address))
54
+ lan.push(a.address);
55
+ }
56
+ }
57
+ // Tailscale d'abord : le QR code mène à la première adresse, et celle-là marche chez toi comme dehors. Pour le navigateur
58
+ // du téléphone, chaque adresse est un site à part, appairé à part.
59
+ return [...tailnet, ...lan];
60
+ }
61
+ /** Toutes les 30 s, l'accès ouvert relit les adresses du PC : un autre Wi-Fi, ou Tailscale qui démarre, sont pris en compte. */
62
+ export const WATCH_MS = 30_000;
63
+ /** « Chrome sur Android », « Safari sur iPhone »… Rien d'autre n'est tiré de l'en-tête. */
64
+ export function deviceName(ua) {
65
+ const browser = /EdgA?\/|EdgiOS\//.test(ua)
66
+ ? 'Edge'
67
+ : /SamsungBrowser\//.test(ua)
68
+ ? 'Samsung Internet'
69
+ : /Firefox\/|FxiOS\//.test(ua)
70
+ ? 'Firefox'
71
+ : /CriOS\/|Chrome\//.test(ua)
72
+ ? 'Chrome'
73
+ : /Safari\//.test(ua)
74
+ ? 'Safari'
75
+ : 'Navigateur';
76
+ const system = /iPhone/.test(ua) ? 'iPhone' : /iPad/.test(ua) ? 'iPad' : /Android/.test(ua) ? 'Android' : /Windows/.test(ua) ? 'Windows' : /Mac OS X|Macintosh/.test(ua) ? 'Mac' : /Linux/.test(ua) ? 'Linux' : 'appareil inconnu';
77
+ return `${browser} sur ${system}`;
78
+ }
79
+ export const normalizeCode = (code) => code.toUpperCase().replace(/[^A-Z0-9]/g, '');
80
+ const sha256 = (s) => createHash('sha256').update(s).digest('hex');
81
+ const sameHex = (a, b) => a.length === b.length && timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
82
+ /** La valeur du cookie d'appareil dans un en-tête Cookie, ou null. */
83
+ export function cookieValue(header) {
84
+ for (const part of (header ?? '').split(';')) {
85
+ const at = part.indexOf('=');
86
+ if (at > 0 && part.slice(0, at).trim() === COOKIE)
87
+ return part.slice(at + 1).trim();
88
+ }
89
+ return null;
90
+ }
91
+ export const setCookie = (value) => `${COOKIE}=${value}; Path=/; Max-Age=${DEVICE_DAYS * 86_400}; HttpOnly; Secure; SameSite=Strict`;
92
+ export class PhoneAccess {
93
+ file;
94
+ port;
95
+ now;
96
+ stored;
97
+ listening = null;
98
+ cert = null;
99
+ error = null;
100
+ pairing = null;
101
+ tries = [];
102
+ /** Connexions ouvertes par chaque appareil : retirer un appareil les coupe. */
103
+ connections = new Map();
104
+ options;
105
+ watcher = null;
106
+ rechecking = false;
107
+ constructor(options) {
108
+ this.options = options;
109
+ this.file = options.dir ? path.join(options.dir, 'appareils.json') : null;
110
+ this.port = options.port ?? PHONE_PORT;
111
+ this.now = options.now ?? Date.now;
112
+ this.stored = this.load();
113
+ if (options.dir) {
114
+ const key = this.read('cle.pem');
115
+ const cert = this.read('certificat.pem');
116
+ if (key && cert)
117
+ this.cert = loadCertificate(key, cert, new Date(this.now()));
118
+ }
119
+ }
120
+ // ------------------------------------------------------------ Disque
121
+ read(name) {
122
+ if (!this.options.dir)
123
+ return null;
124
+ try {
125
+ return fs.readFileSync(path.join(this.options.dir, name), 'utf8');
126
+ }
127
+ catch {
128
+ return null;
129
+ }
130
+ }
131
+ write(name, text) {
132
+ if (!this.options.dir)
133
+ return;
134
+ fs.mkdirSync(this.options.dir, { recursive: true });
135
+ const file = path.join(this.options.dir, name);
136
+ fs.writeFileSync(`${file}.tmp`, text, 'utf8');
137
+ fs.renameSync(`${file}.tmp`, file);
138
+ }
139
+ load() {
140
+ const text = this.read('appareils.json');
141
+ if (!text)
142
+ return { active: false, devices: [] };
143
+ try {
144
+ const s = JSON.parse(text);
145
+ const devices = (Array.isArray(s.devices) ? s.devices : []).filter((d) => typeof d?.id === 'string' && typeof d.name === 'string' && typeof d.hash === 'string' && /^[a-f0-9]{64}$/.test(d.hash) && typeof d.pairedAt === 'string');
146
+ return { active: s.active === true, devices };
147
+ }
148
+ catch {
149
+ return { active: false, devices: [] };
150
+ }
151
+ }
152
+ save() {
153
+ if (this.file)
154
+ this.write('appareils.json', `${JSON.stringify(this.stored, null, 2)}\n`);
155
+ }
156
+ // ------------------------------------------------------------ État
157
+ urls() {
158
+ return (this.listening?.ips ?? []).map((ip) => `https://${ip}:${this.listening?.port ?? this.port}`);
159
+ }
160
+ /** Hôtes acceptés par la garde : « IP:port » de chaque adresse écoutée. */
161
+ hosts() {
162
+ return (this.listening?.ips ?? []).map((ip) => `${ip}:${this.listening?.port ?? this.port}`);
163
+ }
164
+ expired(d) {
165
+ return this.now() - Date.parse(d.lastSeenAt ?? d.pairedAt) > DEVICE_DAYS * DAY;
166
+ }
167
+ devices() {
168
+ const live = this.stored.devices.filter((d) => !this.expired(d));
169
+ if (live.length !== this.stored.devices.length) {
170
+ this.stored.devices = live;
171
+ this.save();
172
+ }
173
+ return live;
174
+ }
175
+ state() {
176
+ const pairing = this.pairing && this.pairing.expiresAt > this.now() ? this.pairing : null;
177
+ const url = this.urls()[0];
178
+ return {
179
+ active: this.stored.active,
180
+ urls: this.urls(),
181
+ port: this.listening?.port ?? this.port,
182
+ fingerprint: this.cert?.fingerprint ?? null,
183
+ error: this.error,
184
+ pairing: pairing && url
185
+ ? { code: pairing.code, expiresAt: new Date(pairing.expiresAt).toISOString(), url: `${url}/#appairer=${pairing.code}`, qr: qrRows(`${url}/#appairer=${pairing.code}`) }
186
+ : null,
187
+ devices: this.devices().map(({ id, name, pairedAt, lastSeenAt }) => ({ id, name, pairedAt, lastSeenAt })),
188
+ };
189
+ }
190
+ summary() {
191
+ return { active: this.stored.active, urls: this.urls(), devices: this.devices().length };
192
+ }
193
+ // ------------------------------------------------------------ Ouverture et fermeture
194
+ /** Au démarrage du serveur : rouvre l'accès s'il était ouvert. */
195
+ async resume() {
196
+ if (!this.stored.active)
197
+ return;
198
+ await this.listen();
199
+ this.watch();
200
+ this.options.journal.add({
201
+ kind: 'security',
202
+ outcome: this.listening ? 'info' : 'error',
203
+ title: this.listening ? 'Accès téléphone rouvert au démarrage' : 'Accès téléphone : écoute impossible au démarrage',
204
+ detail: this.listening ? `${this.urls().join(', ')}, appareils appairés seulement` : (this.error ?? ''),
205
+ });
206
+ }
207
+ async open() {
208
+ const was = this.stored.active;
209
+ this.stored.active = true;
210
+ this.save();
211
+ await this.listen();
212
+ this.watch();
213
+ if (this.listening)
214
+ this.newCode();
215
+ if (!was)
216
+ this.options.journal.add({
217
+ kind: 'security',
218
+ outcome: this.listening ? 'info' : 'error',
219
+ title: this.listening ? 'Accès téléphone ouvert' : 'Accès téléphone : écoute impossible',
220
+ detail: this.listening ? `${this.urls().join(', ')}, appareils appairés seulement` : (this.error ?? ''),
221
+ });
222
+ return this.state();
223
+ }
224
+ async close() {
225
+ const was = this.stored.active;
226
+ this.stored.active = false;
227
+ this.pairing = null;
228
+ this.save();
229
+ this.unwatch();
230
+ await this.stop();
231
+ this.error = null;
232
+ if (was)
233
+ this.options.journal.add({ kind: 'security', outcome: 'info', title: 'Accès téléphone fermé', detail: 'La console ne répond plus que sur cette machine.' });
234
+ return this.state();
235
+ }
236
+ certificate(ips) {
237
+ if (this.cert && ips.every((ip) => this.cert?.ips.includes(ip)))
238
+ return this.cert;
239
+ // Nouvelle adresse (autre Wi-Fi) : nouveau certificat, nouvelle empreinte. Les appareils restent appairés.
240
+ this.cert = makeCertificate(ips, new Date(this.now()));
241
+ this.write('cle.pem', this.cert.key);
242
+ this.write('certificat.pem', this.cert.cert);
243
+ return this.cert;
244
+ }
245
+ addresses() {
246
+ return this.options.addresses ? this.options.addresses() : lanAddresses();
247
+ }
248
+ watch() {
249
+ if (this.watcher)
250
+ return;
251
+ this.watcher = setInterval(() => void this.recheck(), this.options.watchMs ?? WATCH_MS);
252
+ this.watcher.unref();
253
+ }
254
+ unwatch() {
255
+ if (this.watcher)
256
+ clearInterval(this.watcher);
257
+ this.watcher = null;
258
+ }
259
+ /** Les adresses ont changé (autre Wi-Fi, Tailscale démarré ou arrêté), ou l'écoute avait échoué : on rouvre. */
260
+ async recheck() {
261
+ if (!this.stored.active || this.rechecking)
262
+ return;
263
+ const ips = this.addresses();
264
+ const before = this.listening?.ips ?? [];
265
+ const same = ips.length === before.length && ips.every((ip) => before.includes(ip));
266
+ if (same && (this.listening || ips.length === 0))
267
+ return;
268
+ this.rechecking = true;
269
+ try {
270
+ const was = this.listening !== null;
271
+ await this.listen();
272
+ if (!same || (!was && this.listening))
273
+ this.options.journal.add({
274
+ kind: 'security',
275
+ outcome: this.listening ? 'info' : 'error',
276
+ title: 'Accès téléphone : adresses du PC changées',
277
+ detail: this.listening ? `${this.urls().join(', ')}, appareils appairés seulement` : (this.error ?? ''),
278
+ });
279
+ }
280
+ finally {
281
+ this.rechecking = false;
282
+ }
283
+ }
284
+ async listen() {
285
+ await this.stop();
286
+ this.error = null;
287
+ const ips = this.addresses();
288
+ if (ips.length === 0) {
289
+ this.error = 'Le PC n’a pas d’adresse sur un réseau local. Connecte-le au Wi-Fi, puis ferme et rouvre l’accès.';
290
+ return;
291
+ }
292
+ let cert;
293
+ try {
294
+ cert = this.certificate(ips);
295
+ }
296
+ catch (err) {
297
+ this.error = `Certificat impossible à créer : ${err instanceof Error ? err.message : String(err)}`;
298
+ return;
299
+ }
300
+ const sockets = new Set();
301
+ const tls = https.createServer({ key: cert.key, cert: cert.cert, minVersion: 'TLSv1.2' }, this.options.serve);
302
+ const plain = http.createServer((req, res) => {
303
+ const host = (req.headers.host ?? '').toLowerCase();
304
+ if (!this.hosts().includes(host)) {
305
+ res.writeHead(421, { 'Content-Type': 'text/plain; charset=utf-8' });
306
+ res.end('Hôte non autorisé.');
307
+ return;
308
+ }
309
+ res.writeHead(307, { Location: `https://${host}${(req.url ?? '/').startsWith('/') ? req.url : '/'}`, 'Cache-Control': 'no-store' });
310
+ res.end();
311
+ });
312
+ const fronts = [];
313
+ // Port 0 (tests) : le système en choisit un à la première adresse, les suivantes prennent le même.
314
+ let port = this.port;
315
+ try {
316
+ for (const ip of ips) {
317
+ const front = net.createServer((socket) => {
318
+ sockets.add(socket);
319
+ socket.on('close', () => sockets.delete(socket));
320
+ socket.on('error', () => socket.destroy());
321
+ // Une connexion muette ne garde pas une place : 15 s pour envoyer son premier octet.
322
+ socket.setTimeout(15_000, () => socket.destroy());
323
+ socket.once('data', (chunk) => {
324
+ socket.setTimeout(0);
325
+ socket.pause();
326
+ socket.unshift(chunk);
327
+ (chunk[0] === 0x16 ? tls : plain).emit('connection', socket);
328
+ process.nextTick(() => socket.resume());
329
+ });
330
+ });
331
+ await new Promise((resolve, reject) => {
332
+ front.once('error', reject);
333
+ front.listen(port, ip, () => {
334
+ front.off('error', reject);
335
+ resolve();
336
+ });
337
+ });
338
+ fronts.push(front);
339
+ if (port === 0)
340
+ port = front.address().port;
341
+ }
342
+ }
343
+ catch (err) {
344
+ for (const f of fronts)
345
+ f.close();
346
+ const code = err.code;
347
+ this.error = code === 'EADDRINUSE' ? `Le port ${this.port} est déjà pris. Ferme l’autre programme, ou change de port avec la variable PROMETHEE_PORT_TELEPHONE.` : `Écoute impossible : ${err instanceof Error ? err.message : String(err)}`;
348
+ return;
349
+ }
350
+ this.listening = { ips, port, fronts, tls, plain, sockets };
351
+ }
352
+ async stop() {
353
+ const l = this.listening;
354
+ if (!l)
355
+ return;
356
+ this.listening = null;
357
+ // Dans cet ordre, sans attendre entre les deux : plus de nouvelle connexion, puis les connexions ouvertes coupées tout de
358
+ // suite. Attendre la fermeture avant de couper laisserait un téléphone, sur une connexion gardée ouverte, envoyer encore
359
+ // des requêtes à un accès déjà fermé.
360
+ const closed = l.fronts.map((f) => new Promise((resolve) => f.close(() => resolve())));
361
+ for (const s of l.sockets)
362
+ s.destroy();
363
+ l.tls.closeAllConnections();
364
+ l.plain.closeAllConnections();
365
+ this.connections.clear();
366
+ await Promise.all(closed);
367
+ }
368
+ // ------------------------------------------------------------ Appairage
369
+ newCode() {
370
+ if (!this.stored.active || !this.listening)
371
+ throw new AppError('phone_closed', 409, 'L’accès téléphone est fermé : ouvre-le d’abord.');
372
+ let code = '';
373
+ for (let i = 0; i < 8; i++)
374
+ code += ALPHABET[randomInt(ALPHABET.length)];
375
+ this.pairing = { code, expiresAt: this.now() + CODE_MINUTES * 60_000, tries: 0 };
376
+ return this.state();
377
+ }
378
+ /** Échange un code contre un appareil. Rend la valeur du cookie à poser. */
379
+ pair(given, userAgent) {
380
+ const now = this.now();
381
+ this.tries = this.tries.filter((t) => now - t < 60_000);
382
+ if (this.tries.length >= TRIES_PER_MINUTE)
383
+ throw new AppError('pairing_throttled', 429, 'Trop d’essais d’appairage. Attends une minute.');
384
+ this.tries.push(now);
385
+ const pairing = this.pairing;
386
+ if (!pairing || pairing.expiresAt <= now)
387
+ throw new AppError('pairing_expired', 400, 'Aucun code d’appairage en cours, ou il a expiré. Sur le PC, ouvre Réglages, Téléphone, et demande un nouveau code.');
388
+ const code = normalizeCode(given);
389
+ const right = code.length === pairing.code.length && timingSafeEqual(Buffer.from(code), Buffer.from(pairing.code));
390
+ if (!right) {
391
+ pairing.tries++;
392
+ const left = CODE_TRIES - pairing.tries;
393
+ if (left <= 0) {
394
+ this.pairing = null;
395
+ this.options.journal.add({ kind: 'security', outcome: 'refused', title: 'Code d’appairage grillé', detail: `${CODE_TRIES} essais faux depuis le réseau local.` });
396
+ throw new AppError('pairing_refused', 403, 'Code incorrect. Ce code est maintenant grillé : demande un nouveau code sur le PC.');
397
+ }
398
+ throw new AppError('pairing_refused', 403, `Code incorrect. ${left > 1 ? `Encore ${left} essais` : 'Encore un essai'} avec ce code.`);
399
+ }
400
+ this.pairing = null;
401
+ const base = deviceName(userAgent);
402
+ const taken = new Set(this.devices().map((d) => d.name));
403
+ let name = base;
404
+ for (let i = 2; taken.has(name); i++)
405
+ name = `${base} (${i})`;
406
+ const id = randomBytes(8).toString('hex');
407
+ const secret = randomBytes(32).toString('base64url');
408
+ const at = new Date(now).toISOString();
409
+ this.stored.devices.push({ id, name, hash: sha256(secret), pairedAt: at, lastSeenAt: at });
410
+ this.save();
411
+ this.options.journal.add({ kind: 'security', outcome: 'info', title: `Téléphone appairé : ${name}`, detail: 'Il peut utiliser la console depuis le réseau local. Retire-le dans Réglages, Téléphone.' });
412
+ return { device: { id, name, pairedAt: at, lastSeenAt: at }, cookie: `${id}.${secret}` };
413
+ }
414
+ /** L'appareil d'une requête, d'après son cookie. null s'il est inconnu, retiré ou expiré. */
415
+ device(cookieHeader, socket) {
416
+ const value = cookieValue(cookieHeader);
417
+ const m = value ? /^([a-f0-9]{16})\.([A-Za-z0-9_-]{43})$/.exec(value) : null;
418
+ if (!m)
419
+ return null;
420
+ const found = this.devices().find((d) => d.id === m[1]);
421
+ if (!found || !sameHex(found.hash, sha256(m[2] ?? '')))
422
+ return null;
423
+ const now = this.now();
424
+ // L'heure de dernière visite part sur le disque au plus toutes les 10 minutes.
425
+ if (!found.lastSeenAt || now - Date.parse(found.lastSeenAt) > 600_000) {
426
+ found.lastSeenAt = new Date(now).toISOString();
427
+ this.save();
428
+ }
429
+ if (socket) {
430
+ let set = this.connections.get(found.id);
431
+ if (!set)
432
+ this.connections.set(found.id, (set = new Set()));
433
+ if (!set.has(socket)) {
434
+ set.add(socket);
435
+ socket.once('close', () => set.delete(socket));
436
+ }
437
+ }
438
+ const { id, name, pairedAt, lastSeenAt } = found;
439
+ return { id, name, pairedAt, lastSeenAt };
440
+ }
441
+ removeDevice(id) {
442
+ const found = this.stored.devices.find((d) => d.id === id);
443
+ if (!found)
444
+ throw notFound('Cet appareil n’est pas, ou plus, appairé.');
445
+ this.stored.devices = this.stored.devices.filter((d) => d.id !== id);
446
+ this.save();
447
+ for (const s of this.connections.get(id) ?? [])
448
+ s.destroy();
449
+ this.connections.delete(id);
450
+ this.options.journal.add({ kind: 'security', outcome: 'info', title: `Téléphone retiré : ${found.name}`, detail: 'Son cookie ne vaut plus rien, ses connexions sont coupées.' });
451
+ return this.state();
452
+ }
453
+ async dispose() {
454
+ this.unwatch();
455
+ await this.stop();
456
+ }
457
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Certificat du téléphone : ECDSA P-256, signé par lui-même, valable un an, pour les adresses du PC sur le réseau local.
3
+ * Écrit en DER à la main (quelques dizaines de lignes) plutôt qu'avec une dépendance ou OpenSSL : Node sait signer et relire
4
+ * un certificat, pas en fabriquer un.
5
+ *
6
+ * Aucune autorité ne le signe : le navigateur du téléphone avertit à la première visite. Le chiffrement protège de l'écoute
7
+ * sur le Wi-Fi. Contre un attaquant actif sur le même réseau au moment de l'appairage, c'est l'empreinte, comparée à l'œil,
8
+ * qui protège (docs/SECURITE.md).
9
+ */
10
+ import { X509Certificate, createPrivateKey, generateKeyPairSync, randomBytes, sign } from 'node:crypto';
11
+ import { isIPv4 } from 'node:net';
12
+ const NAME = 'Prométhée, accès téléphone';
13
+ const DAY = 86_400_000;
14
+ export const CERT_DAYS = 365;
15
+ const length = (n) => (n < 0x80 ? [n] : n < 0x100 ? [0x81, n] : [0x82, n >> 8, n & 0xff]);
16
+ const tlv = (tag, ...parts) => {
17
+ const body = Buffer.concat(parts);
18
+ return Buffer.concat([Buffer.from([tag, ...length(body.length)]), body]);
19
+ };
20
+ const seq = (...parts) => tlv(0x30, ...parts);
21
+ const set = (...parts) => tlv(0x31, ...parts);
22
+ const octets = (b) => tlv(0x04, b);
23
+ const bits = (b, unused = 0) => tlv(0x03, Buffer.from([unused]), b);
24
+ const explicit = (n, inner) => tlv(0xa0 + n, inner);
25
+ const bool = (v) => tlv(0x01, Buffer.from([v ? 0xff : 0]));
26
+ const utf8 = (s) => tlv(0x0c, Buffer.from(s, 'utf8'));
27
+ /** Entier positif : un zéro devant si le bit de poids fort est à 1. */
28
+ const integer = (b) => tlv(0x02, (b[0] ?? 0) & 0x80 ? Buffer.concat([Buffer.from([0]), b]) : b);
29
+ export function oid(dotted) {
30
+ const [a = 0, b = 0, ...rest] = dotted.split('.').map(Number);
31
+ const out = [40 * a + b];
32
+ for (const n of rest) {
33
+ const chunk = [n & 0x7f];
34
+ for (let v = Math.floor(n / 128); v > 0; v = Math.floor(v / 128))
35
+ chunk.unshift((v & 0x7f) | 0x80);
36
+ out.push(...chunk);
37
+ }
38
+ return tlv(0x06, Buffer.from(out));
39
+ }
40
+ /** UTCTime (AAMMJJhhmmssZ), valable jusqu'en 2049. */
41
+ const time = (d) => tlv(0x17, Buffer.from(d.toISOString().replace(/[-:T]/g, '').slice(2, 14) + 'Z', 'ascii'));
42
+ const extension = (id, critical, value) => seq(oid(id), ...(critical ? [bool(true)] : []), octets(value));
43
+ const ECDSA_SHA256 = seq(oid('1.2.840.10045.4.3.2'));
44
+ export function makeCertificate(ips, now = new Date()) {
45
+ const addresses = ips.filter((ip) => isIPv4(ip));
46
+ if (addresses.length === 0)
47
+ throw new Error('Aucune adresse IPv4 pour le certificat.');
48
+ const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
49
+ const name = seq(set(seq(oid('2.5.4.3'), utf8(NAME))));
50
+ const serial = randomBytes(16);
51
+ serial[0] = (serial[0] ?? 0) & 0x7f || 0x01;
52
+ const notBefore = new Date(now.getTime() - DAY);
53
+ const notAfter = new Date(now.getTime() + CERT_DAYS * DAY);
54
+ const san = seq(...addresses.map((ip) => tlv(0x87, Buffer.from(ip.split('.').map(Number)))));
55
+ const tbs = seq(explicit(0, integer(Buffer.from([2]))), integer(serial), ECDSA_SHA256, name, seq(time(notBefore), time(notAfter)), name, publicKey.export({ type: 'spki', format: 'der' }), explicit(3, seq(extension('2.5.29.19', true, seq()), extension('2.5.29.15', true, bits(Buffer.from([0x80]), 7)), extension('2.5.29.37', false, seq(oid('1.3.6.1.5.5.7.3.1'))), extension('2.5.29.17', false, san))));
56
+ const der = seq(tbs, ECDSA_SHA256, bits(sign('sha256', tbs, privateKey)));
57
+ const cert = `-----BEGIN CERTIFICATE-----\n${der.toString('base64').replace(/(.{64})/g, '$1\n').replace(/\n$/, '')}\n-----END CERTIFICATE-----\n`;
58
+ const x509 = new X509Certificate(cert);
59
+ return { key: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), cert, fingerprint: x509.fingerprint256, ips: addresses, notAfter: notAfter.toISOString() };
60
+ }
61
+ /** Relit un certificat enregistré. null s'il est abîmé, si la clé ne va pas avec, ou s'il expire dans moins de 30 jours. */
62
+ export function loadCertificate(key, cert, now = new Date()) {
63
+ try {
64
+ const x509 = new X509Certificate(cert);
65
+ if (!x509.checkPrivateKey(createPrivateKey(key)))
66
+ return null;
67
+ if (Date.parse(x509.validTo) - now.getTime() < 30 * DAY)
68
+ return null;
69
+ const ips = (x509.subjectAltName ?? '')
70
+ .split(',')
71
+ .map((s) => s.trim())
72
+ .filter((s) => s.startsWith('IP Address:'))
73
+ .map((s) => s.slice('IP Address:'.length));
74
+ return { key, cert, fingerprint: x509.fingerprint256, ips, notAfter: new Date(x509.validTo).toISOString() };
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }