neuroalpha 0.6.13 → 0.7.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.
Files changed (46) hide show
  1. package/README.md +14 -10
  2. package/bin/cli.js +79 -69
  3. package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +2 -2
  4. package/package.json +4 -5
  5. package/public/activate.html +41 -19
  6. package/public/activate.js +81 -10
  7. package/public/app.js +471 -166
  8. package/public/index.html +9 -39
  9. package/public/styles.css +42 -3
  10. package/server/api/routes.js +595 -906
  11. package/server/auth/index.js +583 -761
  12. package/server/brokers/index.js +14 -3
  13. package/server/config.js +73 -53
  14. package/server/index.js +186 -212
  15. package/server/llm/local-engine.js +843 -0
  16. package/server/llm/providers.js +45 -34
  17. package/server/model/live.js +2 -1
  18. package/server/notify/index.js +304 -466
  19. package/server/strategist/index.js +4 -1
  20. package/deploy/docs/01-central.md +0 -96
  21. package/deploy/docs/02-cloud-instances.md +0 -130
  22. package/deploy/docs/03-desktop-build.md +0 -92
  23. package/deploy/docs/04-client-guide.md +0 -101
  24. package/deploy/docs/05-operations.md +0 -109
  25. package/deploy/docs/06-env-reference.md +0 -91
  26. package/deploy/docs/07-stress-test.md +0 -253
  27. package/deploy/docs/pdf/01-central.pdf +0 -0
  28. package/deploy/docs/pdf/02-cloud-instances.pdf +0 -0
  29. package/deploy/docs/pdf/03-desktop-build.pdf +0 -0
  30. package/deploy/docs/pdf/04-client-guide.pdf +0 -0
  31. package/deploy/docs/pdf/05-operations.pdf +0 -0
  32. package/deploy/docs/pdf/06-env-reference.pdf +0 -0
  33. package/deploy/docs/pdf/07-stress-test.pdf +0 -0
  34. package/deploy/install.sh +0 -128
  35. package/deploy/k8s/instance.yaml +0 -147
  36. package/public/admin.html +0 -604
  37. package/public/landing.html +0 -1207
  38. package/public/legal.html +0 -134
  39. package/scripts/set-admin-password.mjs +0 -30
  40. package/server/billing/yookassa.js +0 -142
  41. package/server/instance/agent.js +0 -259
  42. package/server/instance/central.js +0 -514
  43. package/server/instance/identity.js +0 -79
  44. package/server/instance/provision.js +0 -166
  45. package/server/instance/signing.js +0 -71
  46. package/server/role.js +0 -44
@@ -1,514 +0,0 @@
1
- // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
- // Центральный сервер: реестр машин клиентов, выпуск подписанных лицензий, вход на машину через центр (SSO),
3
- // пересылка уведомлений с машин в Telegram / MAX клиента, активация установок на компьютер
4
- // и автоматическое развёртывание облачных машин.
5
- // cloud — арендованная машина в Kubernetes на поддомене naXXXXXX.odant.org (создаётся автоматически после оплаты)
6
- // desktop — установка на компьютер клиента (VIP по договору), активируется одноразовым кодом из кабинета
7
- import crypto from 'node:crypto';
8
- import { db } from '../db.js';
9
- import { getUserById, effectivePlan, ensureApiKey } from '../auth/index.js';
10
- import { notifyUser, registerInstanceActionHandler, registerInstanceChatRelay } from '../notify/index.js';
11
- import { signToken, centralPublicKeyPem } from './signing.js';
12
- import { PAAS_DRIVER, provisioningEnabled, provisionApply, provisionDelete } from './provision.js';
13
-
14
- export const LICENSE_TTL_SEC = Math.max(3600, Number(process.env.LICENSE_TTL_HOURS || 72) * 3600);
15
- const SSO_TTL_SEC = 60;
16
- const CODE_TTL_MS = 10 * 60000;
17
- const INSTANCE_DOMAIN = String(process.env.INSTANCE_DOMAIN || 'odant.org').trim().replace(/^\.+/, '');
18
- const INSTANCE_SCHEME = String(process.env.INSTANCE_SCHEME || 'https').trim();
19
- const TRADING_STATES = ['active', 'paused', 'halted'];
20
- const KINDS = ['cloud', 'desktop'];
21
- const MAX_DESKTOP_PER_USER = Math.max(1, Number(process.env.MAX_DESKTOP_INSTALLS || 1));
22
-
23
- db.exec(`
24
- CREATE TABLE IF NOT EXISTS instances (
25
- id TEXT PRIMARY KEY,
26
- user_id INTEGER NOT NULL,
27
- host TEXT NOT NULL,
28
- key_hash TEXT NOT NULL,
29
- created_at INTEGER NOT NULL,
30
- last_seen INTEGER DEFAULT 0,
31
- version TEXT DEFAULT '',
32
- status TEXT DEFAULT NULL,
33
- trading TEXT DEFAULT 'active',
34
- revoked INTEGER DEFAULT 0,
35
- note TEXT DEFAULT ''
36
- );
37
- CREATE INDEX IF NOT EXISTS instances_user ON instances(user_id);
38
- CREATE TABLE IF NOT EXISTS instance_actions (
39
- id TEXT PRIMARY KEY,
40
- instance_id TEXT NOT NULL,
41
- user_id INTEGER NOT NULL,
42
- action TEXT NOT NULL,
43
- account_id TEXT NOT NULL,
44
- batch_id TEXT DEFAULT '',
45
- created_at INTEGER NOT NULL,
46
- expires_at INTEGER NOT NULL,
47
- used_at INTEGER DEFAULT 0
48
- );
49
- CREATE TABLE IF NOT EXISTS instance_commands (
50
- id TEXT PRIMARY KEY,
51
- instance_id TEXT NOT NULL,
52
- payload TEXT NOT NULL,
53
- created_at INTEGER NOT NULL,
54
- acked_at INTEGER DEFAULT 0
55
- );
56
- CREATE INDEX IF NOT EXISTS instance_commands_inst ON instance_commands(instance_id, acked_at);
57
- CREATE TABLE IF NOT EXISTS instance_codes (
58
- code_hash TEXT PRIMARY KEY,
59
- instance_id TEXT NOT NULL,
60
- created_at INTEGER NOT NULL,
61
- expires_at INTEGER NOT NULL,
62
- used_at INTEGER DEFAULT 0
63
- );
64
- `);
65
- for (const [col, def] of [
66
- ['kind', "TEXT DEFAULT 'cloud'"],
67
- ['install_id', "TEXT DEFAULT ''"],
68
- ['local_url', "TEXT DEFAULT ''"],
69
- ['hostname', "TEXT DEFAULT ''"],
70
- ['provision_state', "TEXT DEFAULT ''"],
71
- ['provision_error', "TEXT DEFAULT ''"],
72
- ['offline_alerted_at', 'INTEGER DEFAULT 0'],
73
- ]) {
74
- if (!db.prepare('PRAGMA table_info(instances)').all().some((c) => c.name === col)) db.exec(`ALTER TABLE instances ADD COLUMN ${col} ${def}`);
75
- }
76
-
77
- const hashKey = (key) => crypto.createHash('sha256').update(String(key || '')).digest('hex');
78
- const newInstanceKey = () => `na_inst_${crypto.randomBytes(24).toString('hex')}`;
79
- // Адрес терминала на компьютере клиента — только localhost (иначе одноразовая ссылка входа могла бы уйти на чужой сайт)
80
- const cleanLocalUrl = (u) => {
81
- try {
82
- const x = new URL(String(u || ''));
83
- if (x.protocol !== 'http:' || !['localhost', '127.0.0.1'].includes(x.hostname)) return '';
84
- return `${x.protocol}//${x.host}`;
85
- } catch { return ''; }
86
- };
87
-
88
- function parseInst(r) {
89
- if (!r) return null;
90
- let status = null;
91
- try { status = r.status ? JSON.parse(r.status) : null; } catch { /* ignore */ }
92
- return { ...r, kind: KINDS.includes(r.kind) ? r.kind : 'cloud', status, revoked: Boolean(r.revoked) };
93
- }
94
-
95
- export function publicInstance(r) {
96
- const i = parseInst(r);
97
- if (!i) return null;
98
- const { key_hash: _omit, install_id: installIdRaw, ...rest } = i;
99
- const u = getUserById(i.user_id);
100
- return {
101
- ...rest,
102
- installBound: Boolean(installIdRaw),
103
- url: i.kind === 'cloud' ? `${INSTANCE_SCHEME}://${i.host}` : (i.local_url || ''),
104
- userEmail: u?.email || '',
105
- userName: u?.name || '',
106
- online: Boolean(i.last_seen && Date.now() - i.last_seen < 3 * 60000),
107
- };
108
- }
109
-
110
- // Для кабинета клиента: без служебных полей
111
- export function cabinetInstances(userId) {
112
- return instancesOfUser(userId).map((i) => ({
113
- id: i.id,
114
- kind: i.kind,
115
- host: i.kind === 'cloud' ? i.host : '',
116
- hostname: i.hostname || '',
117
- installBound: Boolean(i.install_id),
118
- online: Boolean(i.last_seen && Date.now() - i.last_seen < 3 * 60000),
119
- lastSeen: i.last_seen || 0,
120
- version: i.version || '',
121
- provisionState: i.provision_state || '',
122
- trading: i.trading,
123
- }));
124
- }
125
-
126
- export function listInstances() {
127
- return db.prepare('SELECT * FROM instances ORDER BY created_at DESC').all().map(publicInstance);
128
- }
129
-
130
- export function instanceByKey(key) {
131
- const k = String(key || '').trim();
132
- if (!k.startsWith('na_inst_')) return null;
133
- return parseInst(db.prepare('SELECT * FROM instances WHERE key_hash = ?').get(hashKey(k)));
134
- }
135
-
136
- function instanceById(id) {
137
- return parseInst(db.prepare('SELECT * FROM instances WHERE id = ?').get(String(id || '').trim().toLowerCase()));
138
- }
139
-
140
- export function instancesOfUser(userId) {
141
- return db.prepare('SELECT * FROM instances WHERE user_id = ? AND revoked = 0 ORDER BY created_at').all(Number(userId)).map(parseInst);
142
- }
143
-
144
- // Переменные машины (Secret Kubernetes или ручная установка)
145
- function envObject(inst, instanceKey, centralUrl) {
146
- return {
147
- NEUROALPHA_ROLE: 'instance',
148
- INSTANCE_KIND: inst.kind,
149
- INSTANCE_ID: inst.id,
150
- INSTANCE_KEY: instanceKey,
151
- CENTRAL_URL: centralUrl,
152
- CLOUD_URL: centralUrl,
153
- LICENSE_PUBLIC_KEY: centralPublicKeyPem().trim(),
154
- };
155
- }
156
- const envText = (obj) => Object.entries(obj).map(([k, v]) => `${k}=${String(v).replace(/\n/g, '\\n')}`).join('\n');
157
-
158
- function setProvision(id, state, error = '') {
159
- db.prepare('UPDATE instances SET provision_state = ?, provision_error = ? WHERE id = ?').run(state, String(error || '').slice(0, 400), id);
160
- }
161
-
162
- // Развернуть (или обновить) облачную машину: ключ передаётся прямо в Secret и нигде не показывается
163
- async function deployCloud(inst, instanceKey, centralUrl) {
164
- if (inst.kind !== 'cloud' || !provisioningEnabled()) return;
165
- setProvision(inst.id, 'deploying');
166
- try {
167
- await provisionApply(inst, envObject(inst, instanceKey, centralUrl));
168
- setProvision(inst.id, 'deployed');
169
- } catch (e) {
170
- setProvision(inst.id, 'error', e.message);
171
- console.warn(`⚠ Развёртывание ${inst.id}: ${e.message}`);
172
- }
173
- }
174
-
175
- export async function createInstance({ userId, id, note = '', kind = 'cloud' } = {}, { centralUrl }) {
176
- const u = getUserById(Number(userId));
177
- if (!u) throw new Error('Пользователь не найден');
178
- const k = KINDS.includes(kind) ? kind : 'cloud';
179
- let iid = String(id || '').trim().toLowerCase();
180
- if (iid && !/^[a-z0-9][a-z0-9-]{2,30}$/.test(iid)) throw new Error('ID машины: латиница, цифры и дефис, 3–31 символ');
181
- if (!iid) {
182
- do { iid = `${k === 'desktop' ? 'pc' : 'na'}${crypto.randomInt(100000, 999999)}`; } while (instanceById(iid));
183
- } else if (instanceById(iid)) {
184
- throw new Error(`Машина ${iid} уже существует`);
185
- }
186
- const key = newInstanceKey();
187
- const host = k === 'cloud' ? `${iid}.${INSTANCE_DOMAIN}` : '';
188
- db.prepare('INSERT INTO instances (id, user_id, host, key_hash, created_at, note, kind, provision_state) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
189
- .run(iid, u.id, host, hashKey(key), Date.now(), String(note || '').slice(0, 200), k, k === 'cloud' ? 'pending' : '');
190
- const inst = instanceById(iid);
191
- const env = envObject(inst, key, centralUrl);
192
- if (k === 'cloud' && provisioningEnabled()) {
193
- await deployCloud(inst, key, centralUrl);
194
- // при автоматическом развёртывании ключ не показывается: он уже в Secret кластера
195
- return { instance: publicInstance(instanceById(iid)), provisioned: true };
196
- }
197
- return { instance: publicInstance(inst), instanceKey: key, env: envText(env) };
198
- }
199
-
200
- export async function rotateInstanceKey(id, { centralUrl }) {
201
- const inst = instanceById(id);
202
- if (!inst) throw new Error('Машина не найдена');
203
- const key = newInstanceKey();
204
- db.prepare('UPDATE instances SET key_hash = ? WHERE id = ?').run(hashKey(key), inst.id);
205
- if (inst.kind === 'cloud' && provisioningEnabled()) {
206
- await deployCloud(inst, key, centralUrl);
207
- return { instance: publicInstance(instanceById(inst.id)), provisioned: true };
208
- }
209
- return { instance: publicInstance(instanceById(inst.id)), instanceKey: key, env: envText(envObject(inst, key, centralUrl)) };
210
- }
211
-
212
- export function setInstanceTrading(id, trading) {
213
- if (!TRADING_STATES.includes(trading)) throw new Error('Состояние торговли: active, paused или halted');
214
- const r = db.prepare('UPDATE instances SET trading = ? WHERE id = ?').run(trading, String(id).toLowerCase());
215
- if (!r.changes) throw new Error('Машина не найдена');
216
- return publicInstance(instanceById(id));
217
- }
218
-
219
- export function setInstanceRevoked(id, revoked) {
220
- const r = db.prepare('UPDATE instances SET revoked = ? WHERE id = ?').run(revoked ? 1 : 0, String(id).toLowerCase());
221
- if (!r.changes) throw new Error('Машина не найдена');
222
- return publicInstance(instanceById(id));
223
- }
224
-
225
- // Отвязать установку от компьютера: старая установка сразу теряет ключ, новую активируют новым кодом
226
- export function unbindInstall(id, { userId = null } = {}) {
227
- const inst = instanceById(id);
228
- if (!inst || (userId !== null && inst.user_id !== Number(userId))) throw new Error('Машина не найдена');
229
- db.prepare("UPDATE instances SET install_id = '', key_hash = ?, local_url = '', hostname = '' WHERE id = ?").run(hashKey(newInstanceKey()), inst.id);
230
- return publicInstance(instanceById(inst.id));
231
- }
232
-
233
- export async function deleteInstance(id) {
234
- const inst = instanceById(id);
235
- if (inst?.kind === 'cloud' && provisioningEnabled()) await provisionDelete(inst);
236
- db.prepare('DELETE FROM instances WHERE id = ?').run(String(id).toLowerCase());
237
- db.prepare('DELETE FROM instance_codes WHERE instance_id = ?').run(String(id).toLowerCase());
238
- return listInstances();
239
- }
240
-
241
- // ---------- лицензия: выдаётся на каждый heartbeat машины ----------
242
- const STATUS_KEYS = ['version', 'node', 'uptimeSec', 'rssMb', 'dbMb', 'accounts', 'autoAccounts', 'pausedAccounts', 'haltedAccounts',
243
- 'brokers', 'lastSyncAgoSec', 'forecastAgoSec', 'tradingAllowed', 'tradingReason', 'licenseLeftSec', 'errors', 'host'];
244
-
245
- function sanitizeStatus(s = {}) {
246
- const out = {};
247
- for (const k of STATUS_KEYS) {
248
- const v = s[k];
249
- if (v === undefined || v === null) continue;
250
- if (typeof v === 'number' || typeof v === 'boolean') out[k] = v;
251
- else if (typeof v === 'string') out[k] = v.slice(0, 300);
252
- else if (k === 'brokers' && typeof v === 'object') {
253
- out.brokers = Object.fromEntries(Object.entries(v).slice(0, 10).map(([b, n]) => [String(b).slice(0, 20), Number(n) || 0]));
254
- } else if (k === 'errors' && Array.isArray(v)) {
255
- out.errors = v.slice(0, 5).map((e) => String(e).slice(0, 200));
256
- }
257
- }
258
- return out;
259
- }
260
-
261
- export function instanceHeartbeat(inst, body = {}) {
262
- // Ключ установки на компьютер работает только на той установке, где его активировали
263
- const gotInstall = String(body.installId || '').trim().slice(0, 64);
264
- if (inst.kind === 'desktop') {
265
- if (!inst.install_id) throw Object.assign(new Error('Установка не активирована — введите код из личного кабинета'), { status: 409 });
266
- if (gotInstall !== inst.install_id) throw Object.assign(new Error('Ключ активирован на другом компьютере — отвяжите его в личном кабинете'), { status: 409 });
267
- }
268
- const status = sanitizeStatus(body.status || {});
269
- const localUrl = inst.kind === 'desktop' ? cleanLocalUrl(body.localUrl) : '';
270
- if (inst.offline_alerted_at) {
271
- db.prepare('UPDATE instances SET offline_alerted_at = 0 WHERE id = ?').run(inst.id);
272
- alertAdmins(`✓ НейроАльфа: машина ${inst.id} снова на связи (не было ${Math.round((Date.now() - (inst.last_seen || Date.now())) / 60000)} мин).`);
273
- }
274
- db.prepare("UPDATE instances SET last_seen = ?, version = ?, status = ?, local_url = CASE WHEN ? <> '' THEN ? ELSE local_url END, provision_state = CASE WHEN kind = 'cloud' AND provision_state IN ('pending', 'deploying', 'deployed') THEN 'running' ELSE provision_state END WHERE id = ?")
275
- .run(Date.now(), String(status.version || body.version || '').slice(0, 40), JSON.stringify(status), localUrl, localUrl, inst.id);
276
- const u = getUserById(inst.user_id);
277
- if (!u) throw Object.assign(new Error('Владелец машины не найден'), { status: 403 });
278
- let plan = inst.revoked ? { id: 'none', name: 'Без подписки' } : effectivePlan(u);
279
- // установка на компьютер — только по тарифу с правом установки (VIP по договору или Хозяин)
280
- if (inst.kind === 'desktop' && !plan.allowDesktop) plan = { id: 'none', name: 'Без права установки на компьютер' };
281
- const payload = {
282
- typ: 'license',
283
- aud: inst.id,
284
- sub: u.id,
285
- kind: inst.kind,
286
- email: u.email,
287
- name: u.name || '',
288
- plan: plan.id,
289
- planName: plan.name,
290
- planUntil: u.plan_until || 0,
291
- offerAcceptedAt: u.offer_accepted_at || 0,
292
- apiKey: ensureApiKey(u.id),
293
- trading: inst.revoked ? 'paused' : (TRADING_STATES.includes(inst.trading) ? inst.trading : 'active'),
294
- revoked: inst.revoked,
295
- // команды из мессенджера (подписаны вместе с лицензией — подделать их на пути нельзя)
296
- cmds: inst.revoked ? [] : pendingCommands(inst.id, Array.isArray(body.ack) ? body.ack : []),
297
- };
298
- return {
299
- license: signToken(payload, LICENSE_TTL_SEC),
300
- serverTime: Date.now(),
301
- ...(inst.kind === 'desktop' && process.env.DESKTOP_LATEST_VERSION ? { latestVersion: process.env.DESKTOP_LATEST_VERSION, downloadUrl: process.env.DESKTOP_DOWNLOAD_URL || '' } : {}),
302
- };
303
- }
304
-
305
- // ---------- установка на компьютер: одноразовый код активации ----------
306
- export function createDesktopActivationCode(user) {
307
- const plan = effectivePlan(user);
308
- if (!plan.allowDesktop) throw Object.assign(new Error('Установка на свой компьютер доступна по договору на тарифе VIP — оставьте заявку в разделе тарифов'), { status: 403 });
309
- let inst = instancesOfUser(user.id).find((i) => i.kind === 'desktop' && !i.install_id);
310
- if (!inst) {
311
- const desktops = instancesOfUser(user.id).filter((i) => i.kind === 'desktop');
312
- if (desktops.length >= MAX_DESKTOP_PER_USER) {
313
- throw Object.assign(new Error(`По договору доступно установок на компьютер: ${MAX_DESKTOP_PER_USER}. Чтобы перенести программу на другой компьютер, сначала отвяжите текущий`), { status: 409 });
314
- }
315
- const iid = (() => { let x; do { x = `pc${crypto.randomInt(100000, 999999)}`; } while (instanceById(x)); return x; })();
316
- db.prepare("INSERT INTO instances (id, user_id, host, key_hash, created_at, kind) VALUES (?, ?, '', ?, ?, 'desktop')").run(iid, user.id, hashKey(newInstanceKey()), Date.now());
317
- inst = instanceById(iid);
318
- }
319
- const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
320
- const part = () => Array.from({ length: 4 }, () => alphabet[crypto.randomInt(alphabet.length)]).join('');
321
- const code = `NA-${part()}-${part()}`;
322
- const now = Date.now();
323
- db.prepare('DELETE FROM instance_codes WHERE expires_at < ? OR instance_id = ?').run(now - 86400000, inst.id);
324
- db.prepare('INSERT INTO instance_codes (code_hash, instance_id, created_at, expires_at) VALUES (?, ?, ?, ?)').run(hashKey(code), inst.id, now, now + CODE_TTL_MS);
325
- return { code, expiresAt: now + CODE_TTL_MS, instanceId: inst.id };
326
- }
327
-
328
- const activateFails = new Map(); // ip -> { count, start }
329
- export function activateDesktopInstall({ code, installId, localUrl, hostname, version }, { ip = '' } = {}) {
330
- const now = Date.now();
331
- const f = activateFails.get(ip);
332
- if (f && now - f.start < 15 * 60000 && f.count >= 10) throw Object.assign(new Error('Слишком много неверных кодов — повторите через 15 минут'), { status: 429 });
333
- const row = db.prepare('SELECT * FROM instance_codes WHERE code_hash = ?').get(hashKey(String(code || '').trim().toUpperCase()));
334
- const fail = (msg, status = 400) => {
335
- if (!f || now - f.start > 15 * 60000) activateFails.set(ip, { count: 1, start: now }); else f.count += 1;
336
- return Object.assign(new Error(msg), { status });
337
- };
338
- if (!row || row.used_at || row.expires_at < now) throw fail('Код активации неверный или устарел — получите новый в личном кабинете');
339
- const inst = instanceById(row.instance_id);
340
- if (!inst || inst.revoked || inst.kind !== 'desktop') throw fail('Установка для этого кода недоступна');
341
- const iid = String(installId || '').trim();
342
- if (!/^[a-f0-9]{16,64}$/.test(iid)) throw fail('Некорректный идентификатор установки');
343
- if (inst.install_id && inst.install_id !== iid) throw fail('Этот ключ уже активирован на другом компьютере — отвяжите его в личном кабинете', 409);
344
- const plan = effectivePlan(getUserById(inst.user_id));
345
- if (!plan.allowDesktop) throw fail('Тариф не позволяет установку на компьютер', 403);
346
- const key = newInstanceKey();
347
- db.prepare('UPDATE instances SET key_hash = ?, install_id = ?, local_url = ?, hostname = ?, version = ? WHERE id = ?')
348
- .run(hashKey(key), iid, cleanLocalUrl(localUrl), String(hostname || '').slice(0, 60), String(version || '').slice(0, 40), inst.id);
349
- db.prepare('UPDATE instance_codes SET used_at = ? WHERE code_hash = ?').run(now, row.code_hash);
350
- activateFails.delete(ip);
351
- return { instanceId: inst.id, instanceKey: key, publicKey: centralPublicKeyPem() };
352
- }
353
-
354
- // ---------- вход на машину через центр ----------
355
- export function ssoRedirectUrl(user, wantedId = '') {
356
- const list = instancesOfUser(user.id);
357
- const inst = wantedId ? list.find((i) => i.id === String(wantedId).toLowerCase()) : (list.find((i) => i.kind === 'cloud') || list[0]);
358
- if (!inst) return null;
359
- const base = inst.kind === 'cloud' ? `${INSTANCE_SCHEME}://${inst.host}` : cleanLocalUrl(inst.local_url);
360
- if (!base) return null;
361
- const t = signToken({ typ: 'sso', aud: inst.id, sub: user.id, jti: crypto.randomBytes(12).toString('hex') }, SSO_TTL_SEC);
362
- return `${base}/api/auth/sso?t=${encodeURIComponent(t)}`;
363
- }
364
-
365
- // ---------- уведомления с машины → Telegram / MAX клиента ----------
366
- const notifyWindows = new Map(); // instanceId -> { start, count }
367
- const NOTIFY_PER_HOUR = 120;
368
-
369
- const ACTION_TTL_MS = 2 * 3600000;
370
- const ACTIONS = { exec: 'исполнить', rej: 'отклонить' };
371
-
372
- export async function relayInstanceNotify(inst, text, buttons = []) {
373
- const now = Date.now();
374
- const w = notifyWindows.get(inst.id);
375
- if (!w || now - w.start > 3600000) notifyWindows.set(inst.id, { start: now, count: 1 });
376
- else if (++w.count > NOTIFY_PER_HOUR) throw Object.assign(new Error('Превышен лимит уведомлений машины'), { status: 429 });
377
- const clean = String(text || '').slice(0, 3500).trim();
378
- if (!clean) throw new Error('Пустое уведомление');
379
- // Кнопки «Исполнить / Отклонить»: в мессенджер уходит только одноразовый короткий id (лимит Telegram — 64 байта)
380
- const ins = db.prepare('INSERT INTO instance_actions (id, instance_id, user_id, action, account_id, batch_id, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
381
- const mapped = (Array.isArray(buttons) ? buttons : []).slice(0, 4)
382
- .filter((b) => ACTIONS[b?.action] && /^[\w-]{1,64}$/.test(String(b.accountId || '')))
383
- .map((b) => {
384
- const id = crypto.randomBytes(9).toString('base64url');
385
- ins.run(id, inst.id, inst.user_id, b.action, String(b.accountId), String(b.batchId || '').slice(0, 64), now, now + ACTION_TTL_MS);
386
- return { label: String(b.label || ACTIONS[b.action]).slice(0, 40), data: `ix:${id}` };
387
- });
388
- db.prepare('DELETE FROM instance_actions WHERE expires_at < ?').run(now - 86400000);
389
- return notifyUser(inst.user_id, clean, mapped.length ? mapped : null);
390
- }
391
-
392
- // Нажатие кнопки в Telegram / MAX → команда в очередь машины (доставляется в подписанной лицензии)
393
- registerInstanceActionHandler(async (actionId, { channel, chatId }) => {
394
- const a = db.prepare('SELECT * FROM instance_actions WHERE id = ?').get(String(actionId));
395
- if (!a || a.used_at || a.expires_at < Date.now()) return 'Кнопка устарела или уже использована — подтвердите заявки в терминале.';
396
- const u = getUserById(a.user_id);
397
- const bound = channel === 'max' ? u?.max_chat_id : u?.tg_chat_id;
398
- if (!bound || !chatId || String(bound) !== String(chatId)) return '⛔ Это действие доступно только из чата, привязанного к владельцу счёта.';
399
- const inst = instanceById(a.instance_id);
400
- if (!inst || inst.revoked) return 'Машина недоступна — подтвердите заявки в терминале.';
401
- // обе кнопки одного уведомления становятся недействительными
402
- db.prepare('UPDATE instance_actions SET used_at = ? WHERE instance_id = ? AND account_id = ? AND batch_id = ? AND used_at = 0').run(Date.now(), a.instance_id, a.account_id, a.batch_id);
403
- const cmdId = crypto.randomBytes(9).toString('base64url');
404
- db.prepare('INSERT INTO instance_commands (id, instance_id, payload, created_at) VALUES (?, ?, ?, ?)')
405
- .run(cmdId, inst.id, JSON.stringify({ id: cmdId, type: 'batch', action: a.action, accountId: a.account_id, batchId: a.batch_id }), Date.now());
406
- const online = inst.last_seen && Date.now() - inst.last_seen < 3 * 60000;
407
- return `${a.action === 'exec' ? '✓ Команда «Исполнить»' : '✕ Команда «Отклонить»'} отправлена на машину${online ? ' — выполнится в течение нескольких секунд' : ' (сейчас она не на связи — команда выполнится при подключении, в течение 2 часов)'}.`;
408
- });
409
-
410
- // Доставка текстового вопроса из Telegram / MAX на выделенную машину клиента (если она есть и на связи)
411
- export function queueInstanceChatCommand(userId, text, channel = 'tg') {
412
- const list = instancesOfUser(userId).filter((i) => !i.revoked);
413
- if (!list.length) return null;
414
- // предпочитаем машину, которая недавно выходила на связь
415
- const inst = list.find((i) => i.last_seen && Date.now() - i.last_seen < 3 * 60000) || list[0];
416
- const cmdId = crypto.randomBytes(9).toString('base64url');
417
- db.prepare('INSERT INTO instance_commands (id, instance_id, payload, created_at) VALUES (?, ?, ?, ?)')
418
- .run(cmdId, inst.id, JSON.stringify({ id: cmdId, type: 'chat', text: String(text || '').slice(0, 1500), channel }), Date.now());
419
- const online = Boolean(inst.last_seen && Date.now() - inst.last_seen < 3 * 60000);
420
- return { instanceId: inst.id, online };
421
- }
422
- registerInstanceChatRelay(queueInstanceChatCommand);
423
-
424
- // Команды для машины: отдаются в каждой лицензии, пока машина не подтвердит выполнение (ack)
425
- function pendingCommands(instId, acked = []) {
426
- if (acked.length) {
427
- const up = db.prepare('UPDATE instance_commands SET acked_at = ? WHERE instance_id = ? AND id = ?');
428
- for (const id of acked.slice(0, 50)) up.run(Date.now(), instId, String(id));
429
- }
430
- db.prepare('DELETE FROM instance_commands WHERE created_at < ?').run(Date.now() - 7 * 86400000);
431
- return db.prepare('SELECT payload FROM instance_commands WHERE instance_id = ? AND acked_at = 0 AND created_at > ? ORDER BY created_at LIMIT 20')
432
- .all(instId, Date.now() - ACTION_TTL_MS).map((r) => JSON.parse(r.payload));
433
- }
434
-
435
- // ---------- автоматическое создание облачных машин после оплаты ----------
436
- export async function ensureUserCloudInstance(userId, { centralUrl = String(process.env.CENTRAL_PUBLIC_URL || 'https://neuroalpha.odant.org').replace(/\/+$/, '') } = {}) {
437
- const u = getUserById(Number(userId));
438
- if (!u || u.role === 'admin' || u.id === 1) return null;
439
- const plan = effectivePlan(u);
440
- if (!plan || plan.id === 'none' || plan.allowDesktop) return null;
441
- const own = db.prepare('SELECT * FROM instances WHERE user_id = ?').all(u.id).map(parseInst);
442
- if (own.length) return publicInstance(own[0]);
443
- try {
444
- const r = await createInstance({ userId: u.id, note: `авто · ${plan.name}` }, { centralUrl });
445
- console.log(`✓ Создана облачная машина ${r.instance.id} для ${u.email}`);
446
- return r.instance;
447
- } catch (e) {
448
- console.warn(`⚠ Не удалось создать машину для ${u.email}: ${e.message}`);
449
- return null;
450
- }
451
- }
452
-
453
- // Раз в 2 минуты: у каждого клиента с активной подпиской должна быть облачная машина
454
- // (кроме администраторов и клиентов с установкой на компьютер). Неудачные развёртывания повторяются.
455
- let reconcileBusy = false;
456
- export async function reconcileInstances({ centralUrl }) {
457
- if (reconcileBusy) return;
458
- reconcileBusy = true;
459
- try {
460
- const users = db.prepare("SELECT * FROM users WHERE role <> 'admin'").all();
461
- for (const u of users) {
462
- await ensureUserCloudInstance(u.id, { centralUrl });
463
- }
464
- if (provisioningEnabled()) {
465
- // повтор неудачных развёртываний (с новым ключом — прежний хранится только в виде хэша)
466
- const failed = db.prepare("SELECT * FROM instances WHERE kind = 'cloud' AND revoked = 0 AND provision_state IN ('error', 'pending')").all().map(parseInst);
467
- for (const inst of failed) await rotateInstanceKey(inst.id, { centralUrl }).catch(() => {});
468
- }
469
- } finally {
470
- reconcileBusy = false;
471
- }
472
- }
473
-
474
- // ---------- контроль связи: оповещение администраторов, если машина клиента пропала ----------
475
- const OFFLINE_ALERT_MIN = Math.max(3, Number(process.env.INSTANCE_OFFLINE_ALERT_MIN || 10));
476
-
477
- function alertAdmins(text) {
478
- for (const a of db.prepare("SELECT id FROM users WHERE role = 'admin'").all()) {
479
- notifyUser(a.id, text).catch(() => {});
480
- }
481
- }
482
-
483
- export function checkInstancesOnline() {
484
- const cutoff = Date.now() - OFFLINE_ALERT_MIN * 60000;
485
- const kinds = process.env.INSTANCE_ALERT_DESKTOP === '1' ? ['cloud', 'desktop'] : ['cloud'];
486
- const lost = db.prepare(`SELECT * FROM instances WHERE revoked = 0 AND last_seen > 0 AND last_seen < ? AND offline_alerted_at = 0 AND kind IN (${kinds.map(() => '?').join(',')})`)
487
- .all(cutoff, ...kinds).map(parseInst);
488
- for (const inst of lost) {
489
- db.prepare('UPDATE instances SET offline_alerted_at = ? WHERE id = ?').run(Date.now(), inst.id);
490
- const u = getUserById(inst.user_id);
491
- alertAdmins(`⚠ НейроАльфа: машина ${inst.id}${inst.kind === 'cloud' ? ` (${inst.host})` : ` (${inst.hostname || 'компьютер'})`} клиента ${u?.email || inst.user_id} не на связи ${Math.round((Date.now() - inst.last_seen) / 60000)} мин.${inst.status?.autoAccounts ? ` Счетов в «Автомате»: ${inst.status.autoAccounts}.` : ''} Без связи лицензия действует ещё около ${Math.max(0, Math.round((inst.last_seen + LICENSE_TTL_SEC * 1000 - Date.now()) / 3600000))} ч.`);
492
- }
493
- return lost.length;
494
- }
495
-
496
- export function startInstanceMonitor() {
497
- const t = setInterval(() => { try { checkInstancesOnline(); } catch (e) { console.warn(`⚠ Контроль связи машин: ${e.message}`); } }, 120000);
498
- t.unref?.();
499
- }
500
-
501
- export function startCentralReconciler({ centralUrl }) {
502
- startInstanceMonitor();
503
- if (!provisioningEnabled()) {
504
- console.log('Развёртывание машин клиентов: вручную (PAAS_DRIVER=none)');
505
- return;
506
- }
507
- console.log(`Развёртывание машин клиентов: автоматически (PAAS_DRIVER=${PAAS_DRIVER})`);
508
- const tick = () => reconcileInstances({ centralUrl }).catch((e) => console.warn(`⚠ Развёртывание машин: ${e.message}`));
509
- setTimeout(tick, 15000);
510
- const t = setInterval(tick, 120000);
511
- t.unref?.();
512
- }
513
-
514
- export { centralPublicKeyPem, PAAS_DRIVER };
@@ -1,79 +0,0 @@
1
- // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
- // Личность машины клиента: ID, ключ машины, открытый ключ центра и идентификатор установки.
3
- // cloud — из переменных окружения (Secret Kubernetes, создаётся центром при развёртывании)
4
- // desktop — из файла instance.json в каталоге данных, который записывается при активации кодом из кабинета
5
- import crypto from 'node:crypto';
6
- import fs from 'node:fs';
7
- import path from 'node:path';
8
- import { ROOT, DATA_DIR } from '../config.js';
9
- import { IS_DESKTOP, INSTANCE_DOMAIN, CENTRAL_URL } from '../role.js';
10
- import { BUILD } from '../build-info.js';
11
-
12
- const FILE = path.join(DATA_DIR, 'instance.json');
13
- const INSTALL_FILE = path.join(DATA_DIR, 'install-id');
14
-
15
- function readFileJson() {
16
- try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch { return null; }
17
- }
18
-
19
- let cached = null;
20
-
21
- export function getIdentity() {
22
- if (cached) return cached;
23
- const saved = IS_DESKTOP ? readFileJson() : null;
24
- const id = String((BUILD ? '' : process.env.INSTANCE_ID) || saved?.id || '').trim().toLowerCase();
25
- const key = String((BUILD ? '' : process.env.INSTANCE_KEY) || saved?.key || '').trim();
26
- cached = {
27
- id,
28
- key,
29
- centralUrl: String(BUILD ? CENTRAL_URL : (saved?.centralUrl || CENTRAL_URL)).replace(/\/+$/, ''),
30
- publicKeyPem: String(saved?.publicKeyPem || ''),
31
- url: String(process.env.INSTANCE_URL || (IS_DESKTOP ? '' : (id ? `https://${id}.${INSTANCE_DOMAIN}` : ''))).replace(/\/+$/, ''),
32
- };
33
- return cached;
34
- }
35
-
36
- export function isActivated() {
37
- const i = getIdentity();
38
- return Boolean(i.id && i.key);
39
- }
40
-
41
- export function saveDesktopIdentity({ id, key, centralUrl, publicKeyPem }) {
42
- fs.mkdirSync(DATA_DIR, { recursive: true });
43
- fs.writeFileSync(FILE, JSON.stringify({ id, key, centralUrl, publicKeyPem, activatedAt: Date.now() }, null, 2), { mode: 0o600 });
44
- cached = null;
45
- return getIdentity();
46
- }
47
-
48
- export function clearDesktopIdentity() {
49
- try { fs.unlinkSync(FILE); } catch { /* ignore */ }
50
- cached = null;
51
- }
52
-
53
- // Случайный идентификатор этой установки: центр привязывает к нему ключ, чтобы ключ нельзя было перенести на другой компьютер
54
- export function installId() {
55
- try {
56
- const v = fs.readFileSync(INSTALL_FILE, 'utf8').trim();
57
- if (v.length >= 16) return v;
58
- } catch { /* ignore */ }
59
- const v = crypto.randomBytes(16).toString('hex');
60
- fs.mkdirSync(DATA_DIR, { recursive: true });
61
- fs.writeFileSync(INSTALL_FILE, v, { mode: 0o600 });
62
- return v;
63
- }
64
-
65
- // Открытый ключ центра: в защищённой сборке — только вшитый при сборке;
66
- // иначе переменная окружения → файл config/license-public.pem → полученный при активации
67
- export function centralPublicKeyCandidates() {
68
- if (BUILD) return BUILD.publicKeyPem ? [BUILD.publicKeyPem] : [];
69
- const out = [];
70
- const env = String(process.env.LICENSE_PUBLIC_KEY || '').trim().replace(/\\n/g, '\n');
71
- if (env.includes('BEGIN')) out.push(env);
72
- const file = String(process.env.LICENSE_PUBLIC_KEY_FILE || '').trim();
73
- for (const f of [file, path.join(ROOT, 'config', 'license-public.pem')].filter(Boolean)) {
74
- try { const pem = fs.readFileSync(f, 'utf8'); if (pem.includes('BEGIN')) out.push(pem); } catch { /* ignore */ }
75
- }
76
- const saved = getIdentity().publicKeyPem;
77
- if (saved.includes('BEGIN')) out.push(saved);
78
- return out;
79
- }