@alizadefaig/pos-server 1.2.26 → 1.2.28
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/app/core/Context.js +170 -0
- package/app/core/auth/index.js +133 -0
- package/app/core/auth/license.js +45 -0
- package/app/core/auth/posMode.js +260 -0
- package/app/core/auth/sanctum.js +209 -0
- package/app/core/auth/scope.js +231 -0
- package/app/core/emitter.js +67 -0
- package/app/core/errors.js +18 -0
- package/app/core/guard.js +54 -0
- package/app/core/idempotency.js +196 -0
- package/app/core/index.js +374 -0
- package/app/core/models/Order.js +176 -0
- package/app/core/models/OrderProduct.js +249 -0
- package/app/core/repositories/EventRepository.js +51 -0
- package/app/core/repositories/OrderProductRepository.js +225 -0
- package/app/core/repositories/OrderRepository.js +274 -0
- package/app/core/repositories/OutboxRepository.js +52 -0
- package/app/core/repositories/PaymentRepository.js +43 -0
- package/app/core/repositories/PrinterRepository.js +179 -0
- package/app/core/repositories/ProductRelations.js +148 -0
- package/app/core/repositories/SnapshotRepository.js +197 -0
- package/app/core/resources/HallResource.js +54 -0
- package/app/core/resources/OrderResource.js +177 -0
- package/app/core/resources/ProductResource.js +186 -0
- package/app/core/resources/ShiftResource.js +42 -0
- package/app/core/resources/TeamResource.js +117 -0
- package/app/core/resources/casts.js +55 -0
- package/app/core/services/AuthService.js +468 -0
- package/app/core/services/InitService.js +279 -0
- package/app/core/services/KdsService.js +639 -0
- package/app/core/services/OrderProductService.js +309 -0
- package/app/core/services/OrderService.js +911 -0
- package/app/core/services/PrintService.js +715 -0
- package/app/core/services/PrinterAdminService.js +324 -0
- package/app/core/services/ReportService.js +581 -0
- package/app/core/services/ShiftService.js +199 -0
- package/app/core/services/StorageService.js +425 -0
- package/app/core/services/TelegramService.js +162 -0
- package/app/core/support/db.js +48 -0
- package/app/core/support/ids.js +9 -0
- package/app/core/support/imageData.js +135 -0
- package/app/core/support/images.js +92 -0
- package/app/core/support/lanHandoff.js +105 -0
- package/app/core/support/lang.js +98 -0
- package/app/core/support/lock.js +63 -0
- package/app/core/support/num.js +51 -0
- package/app/core/support/php.js +125 -0
- package/app/core/support/pin.js +34 -0
- package/app/core/support/receipts.js +278 -0
- package/app/core/support/rows.js +38 -0
- package/app/core/support/str.js +31 -0
- package/app/core/support/teamSettings.js +77 -0
- package/app/core/support/telegram.js +253 -0
- package/app/core/support/telegramReport.js +143 -0
- package/app/core/support/time.js +58 -0
- package/app/core/support/tz.js +80 -0
- package/app/core/support/unlock.js +113 -0
- package/app/core/timers.js +151 -0
- package/app/db/Database.js +197 -1
- package/app/db/Migrator.js +45 -0
- package/app/db/driver.js +248 -0
- package/app/db/migrations/001_snapshot.sql +214 -0
- package/app/db/migrations/002_orders.sql +190 -0
- package/app/db/migrations/003_idempotency.sql +18 -0
- package/app/db/migrations/004_shifts.sql +21 -0
- package/app/db/migrations/005_printers.sql +43 -0
- package/app/db/migrations/006_sync.sql +136 -0
- package/app/db/migrations/007_printer_mac.sql +4 -0
- package/app/db/migrations/008_kds.sql +94 -0
- package/app/db/migrations/009_product_categories.sql +20 -0
- package/app/db/migrations/010_printers_sync.sql +43 -0
- package/app/db/migrations/011_team_logos.sql +13 -0
- package/app/db/migrations/012_category_workshop.sql +4 -0
- package/app/monitor.js +185 -0
- package/app/printers/address.js +348 -0
- package/app/proxy.js +11 -3
- package/app/relay/masters.js +173 -0
- package/app/security.js +218 -0
- package/app/socket/auth.js +160 -0
- package/app/socket/request.js +87 -0
- package/app/socket/server.js +121 -19
- package/app/sync/bridge.js +162 -0
- package/app/sync/cloud.js +120 -0
- package/app/sync/config.js +185 -0
- package/app/sync/discovery.js +546 -0
- package/app/sync/index.js +1644 -0
- package/app/sync/kv.js +26 -0
- package/app/sync/lan.js +139 -0
- package/app/sync/lanDevices.js +177 -0
- package/app/sync/lanFront.js +431 -0
- package/app/sync/mirror.js +236 -0
- package/app/sync/push.js +324 -0
- package/app/sync/reconcile.js +161 -0
- package/app/sync/signal.js +202 -0
- package/app/sync/snapshot.js +189 -0
- package/app/sync/store.js +234 -0
- package/app/tcp.js +11 -2
- package/package.json +6 -3
- package/routes/common/api/auth.js +38 -0
- package/routes/common/api/kds.js +37 -0
- package/routes/common/api/orderProducts.js +81 -0
- package/routes/common/api/orders.js +176 -0
- package/routes/common/api/printersCore.js +35 -0
- package/routes/common/api/reports.js +29 -0
- package/routes/common/socket.js +15 -35
- package/routes/common/tax.js +29 -7
- package/routes/desktop/api/offline.js +146 -0
- package/routes/desktop/api/printers.js +288 -5
- package/routes/desktop/local.js +130 -0
- package/routes/server/api/login.js +37 -0
- package/routes/server/api/platform.js +31 -0
- package/routes/server/api/shifts.js +25 -0
- package/routes/server/master.js +24 -0
- package/server.js +72 -3
- package/utils.js +6 -0
- package/tests/usbDiagnose.js +0 -59
- package/tests/usbPrinterTest.js +0 -20
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
const dgram = require('node:dgram');
|
|
2
|
+
const os = require('node:os');
|
|
3
|
+
const {createHmac, randomBytes, timingSafeEqual} = require('node:crypto');
|
|
4
|
+
const config = require('./config');
|
|
5
|
+
const {subnetHosts} = require('../printers/address');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Автопоиск мастера в зале (OFFLINE-FIRST-PLAN §12.8, P2): OFFLINE_MASTER_URL=auto вместо
|
|
9
|
+
* жёсткого http://192.168.1.10:3005 — ведомому не нужно знать адрес мастера, а смена адреса
|
|
10
|
+
* мастера по DHCP не оставляет зал без кассы.
|
|
11
|
+
*
|
|
12
|
+
* Протокол — запрос/ответ, а не маяк мастера:
|
|
13
|
+
* ведомый → широковещательно {t:'cafepos-discover', v, nonce, company_id?}
|
|
14
|
+
* мастер → адресно ведомому {t:'cafepos-master', v, nonce, port, company_id, terminal_id,
|
|
15
|
+
* hosts, proof}
|
|
16
|
+
* proof = HMAC-SHA256(lan_key, 'cafepos-discover|v|nonce|port|company_id|terminal_id|hosts')
|
|
17
|
+
*
|
|
18
|
+
* Почему так:
|
|
19
|
+
* - ведомый шлёт ключ зала только на адрес, который доказал знание этого ключа. Ключ в сети не
|
|
20
|
+
* летает, а ответ без ключа не подделать;
|
|
21
|
+
* - nonce свежий на каждый запрос — записанный ответ повторить нельзя, часы касс не участвуют;
|
|
22
|
+
* - hosts (свои IPv4 мастера) входят в подпись, и ведомый принимает ответ только с адреса из
|
|
23
|
+
* этого списка: чужое устройство, переславшее настоящий ответ от своего имени, не подойдёт;
|
|
24
|
+
* - ответ на широковещательный запрос Windows Firewall пропускает по умолчанию, так что у
|
|
25
|
+
* ведомого не нужен входящий UDP-порт — правило брандмауэра нужно только мастеру (тот же
|
|
26
|
+
* exe, что уже слушает 0.0.0.0:3005).
|
|
27
|
+
*
|
|
28
|
+
* Ведомый ищет мастера по ступеням (как evopos), от дешёвой к дорогой:
|
|
29
|
+
* 1. известный адрес — текущий или сохранённый с прошлого запуска (kv): один HTTP-запрос;
|
|
30
|
+
* 2. широковещательный UDP-запрос;
|
|
31
|
+
* 3. скан своей подсети (не шире /24) по HTTP — когда широковещание не доходит: точка доступа
|
|
32
|
+
* фильтрует broadcast или брандмауэр мастера пропускает TCP 3005, но не UDP. Изоляцию
|
|
33
|
+
* клиентов Wi-Fi скан не обходит — там режется любой трафик между устройствами.
|
|
34
|
+
* Не чаще раза в OFFLINE_DISCOVERY_SCAN_MS.
|
|
35
|
+
* По HTTP мастер отвечает на GET /local/whoami?nonce= тем же подписанным ответом, что и по UDP,
|
|
36
|
+
* и ведомый проверяет его той же функцией (accept). Ключ зала в запросе не передаётся.
|
|
37
|
+
*
|
|
38
|
+
* Работает только при включённом синке и заданном ключе зала: мастер без ключа слушает только
|
|
39
|
+
* localhost (см. lan.js) — искать его в сети бессмысленно.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const VERSION = 1;
|
|
43
|
+
const QUERY = 'cafepos-discover';
|
|
44
|
+
const ANSWER = 'cafepos-master';
|
|
45
|
+
const MAX_PACKET = 2048;
|
|
46
|
+
// мастер отвечает не чаще — поддельные запросы с чужим адресом не превратят его в усилитель
|
|
47
|
+
const MAX_ANSWERS_PER_SECOND = 20;
|
|
48
|
+
// скан подсети по HTTP: адресов за раз и сколько ждать каждый (в LAN мастер отвечает за мс)
|
|
49
|
+
const SCAN_BATCH = 48;
|
|
50
|
+
const SCAN_TIMEOUT_MS = 400;
|
|
51
|
+
// проверка известного адреса (ступень 1)
|
|
52
|
+
const VERIFY_TIMEOUT_MS = 1000;
|
|
53
|
+
const IPV4 = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
|
|
54
|
+
|
|
55
|
+
const log = (...args) => console.log('[discovery]', ...args);
|
|
56
|
+
|
|
57
|
+
const num = (name, fallback) => {
|
|
58
|
+
const value = Number(process.env[name]);
|
|
59
|
+
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const settings = () => ({
|
|
63
|
+
port: num('OFFLINE_DISCOVERY_PORT', 3005),
|
|
64
|
+
// пока мастер не найден или не отвечает — спрашиваем часто, нашли — только проверяем
|
|
65
|
+
interval: num('OFFLINE_DISCOVERY_MS', 3000),
|
|
66
|
+
// сколько ждать ответы на один запрос
|
|
67
|
+
window: num('OFFLINE_DISCOVERY_WINDOW_MS', 1500),
|
|
68
|
+
// скан подсети (ступень 3): HTTP-порт мастера — по соглашению тот же 3005
|
|
69
|
+
scan: process.env.OFFLINE_DISCOVERY_SCAN !== 'false',
|
|
70
|
+
scanInterval: num('OFFLINE_DISCOVERY_SCAN_MS', 30000),
|
|
71
|
+
httpPort: num('OFFLINE_DISCOVERY_HTTP_PORT', 3005),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const sign = (key, fields) => createHmac('sha256', key)
|
|
75
|
+
.update([QUERY, VERSION, fields.nonce, fields.port, fields.company_id || '', fields.terminal_id || '',
|
|
76
|
+
[...fields.hosts].sort().join(',')].join('|'))
|
|
77
|
+
.digest('hex');
|
|
78
|
+
|
|
79
|
+
const proofMatches = (key, fields, proof) => {
|
|
80
|
+
if (typeof proof !== 'string' || !/^[0-9a-f]{64}$/.test(proof)) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
const expected = Buffer.from(sign(key, fields), 'hex');
|
|
84
|
+
return timingSafeEqual(expected, Buffer.from(proof, 'hex'));
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Свои IPv4 и широковещательные адреса подсетей (0 = нет интерфейсов). */
|
|
88
|
+
const interfaces = () => {
|
|
89
|
+
const result = [];
|
|
90
|
+
for (const list of Object.values(os.networkInterfaces())) {
|
|
91
|
+
for (const item of list || []) {
|
|
92
|
+
if (item.family !== 'IPv4' && item.family !== 4) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (item.internal) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const address = item.address.split('.').map(Number);
|
|
99
|
+
const mask = String(item.netmask || '255.255.255.0').split('.').map(Number);
|
|
100
|
+
const broadcast = address.map((part, i) => (part | (~mask[i] & 255))).join('.');
|
|
101
|
+
result.push({address: item.address, broadcast});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return result;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const parse = (buffer) => {
|
|
108
|
+
if (!buffer || buffer.length > MAX_PACKET) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const message = JSON.parse(buffer.toString('utf8'));
|
|
113
|
+
return message && typeof message === 'object' && message.v === VERSION ? message : null;
|
|
114
|
+
} catch (e) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const send = (socket, message, port, address) => new Promise((resolve) => {
|
|
120
|
+
const buffer = Buffer.from(JSON.stringify(message));
|
|
121
|
+
socket.send(buffer, port, address, (error) => resolve(!error));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
/** Не больше max ответов в секунду. */
|
|
125
|
+
class Limiter {
|
|
126
|
+
constructor(max) {
|
|
127
|
+
this.max = max;
|
|
128
|
+
this.second = 0;
|
|
129
|
+
this.count = 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
take() {
|
|
133
|
+
const second = Math.floor(Date.now() / 1000);
|
|
134
|
+
if (second !== this.second) {
|
|
135
|
+
this.second = second;
|
|
136
|
+
this.count = 0;
|
|
137
|
+
}
|
|
138
|
+
this.count += 1;
|
|
139
|
+
return this.count <= this.max;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Подписанный ответ мастера на nonce — одинаковый для UDP и /local/whoami. null — отвечать
|
|
145
|
+
* нечего: нет ключа зала, нет сессии кассы или nonce не похож на наш.
|
|
146
|
+
*/
|
|
147
|
+
const buildAnswer = ({nonce, httpPort, identity}) => {
|
|
148
|
+
const key = config.lanKey();
|
|
149
|
+
if (!key || !identity || !identity.company_id) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
const value = String(nonce || '');
|
|
153
|
+
if (!/^[0-9a-f]{32,64}$/.test(value)) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
const fields = {
|
|
157
|
+
nonce: value,
|
|
158
|
+
port: Number(httpPort) || 3005,
|
|
159
|
+
company_id: identity.company_id,
|
|
160
|
+
terminal_id: identity.terminal_id || '',
|
|
161
|
+
hosts: interfaces().map((item) => item.address),
|
|
162
|
+
};
|
|
163
|
+
if (!fields.hosts.length) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
return {t: ANSWER, v: VERSION, ...fields, proof: sign(key, fields)};
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Проверка ответа мастера (UDP и HTTP). address — откуда ответ пришёл на самом деле: он обязан
|
|
171
|
+
* быть в подписанных hosts. port — если известен (HTTP): мастер обязан назвать тот же.
|
|
172
|
+
*/
|
|
173
|
+
const accept = (message, {key, nonce, address, companyId = null, port = null}) => {
|
|
174
|
+
if (!message || message.t !== ANSWER || message.nonce !== nonce) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
const hosts = Array.isArray(message.hosts) ? message.hosts.map(String) : [];
|
|
178
|
+
const fields = {
|
|
179
|
+
nonce,
|
|
180
|
+
port: Number(message.port),
|
|
181
|
+
company_id: String(message.company_id || ''),
|
|
182
|
+
terminal_id: String(message.terminal_id || ''),
|
|
183
|
+
hosts,
|
|
184
|
+
};
|
|
185
|
+
if (!Number.isInteger(fields.port) || fields.port <= 0 || fields.port > 65535) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
if (port !== null && fields.port !== port) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
if (!hosts.includes(address) || !proofMatches(key, fields, message.proof)) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
if (companyId && fields.company_id !== companyId) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
url: `http://${address}:${fields.port}`,
|
|
199
|
+
address,
|
|
200
|
+
port: fields.port,
|
|
201
|
+
company_id: fields.company_id,
|
|
202
|
+
terminal_id: fields.terminal_id,
|
|
203
|
+
};
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------- мастер: отвечает
|
|
207
|
+
|
|
208
|
+
class Responder {
|
|
209
|
+
/**
|
|
210
|
+
* @param {{httpPort: () => number, identity: () => ({company_id, terminal_id}|null)}} options
|
|
211
|
+
* identity — кто мы сейчас (сессия кассы); нет сессии — не отвечаем: ведомому нечего
|
|
212
|
+
* сверять, а зал без залогиненного мастера всё равно не обслуживается.
|
|
213
|
+
*/
|
|
214
|
+
constructor({httpPort, identity}) {
|
|
215
|
+
this.httpPort = httpPort;
|
|
216
|
+
this.identity = identity;
|
|
217
|
+
this.socket = null;
|
|
218
|
+
this.answered = 0;
|
|
219
|
+
this.limiter = new Limiter(MAX_ANSWERS_PER_SECOND);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
start() {
|
|
223
|
+
if (this.socket) {
|
|
224
|
+
return this;
|
|
225
|
+
}
|
|
226
|
+
const {port} = settings();
|
|
227
|
+
// Без reuseAddr: на Windows он позволил бы другой программе занять тот же порт.
|
|
228
|
+
const socket = dgram.createSocket('udp4');
|
|
229
|
+
socket.on('error', (e) => {
|
|
230
|
+
console.warn('[discovery] responder error:', e.message);
|
|
231
|
+
this.stop();
|
|
232
|
+
});
|
|
233
|
+
socket.on('message', (buffer, rinfo) => {
|
|
234
|
+
this.answer(socket, parse(buffer), rinfo).catch(() => {});
|
|
235
|
+
});
|
|
236
|
+
socket.bind(port, () => log(`answering on udp/${port}`));
|
|
237
|
+
socket.unref();
|
|
238
|
+
this.socket = socket;
|
|
239
|
+
return this;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async answer(socket, message, rinfo) {
|
|
243
|
+
if (!message || message.t !== QUERY) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const identity = this.identity();
|
|
247
|
+
// Ведомый другого заведения в той же сети — не наш.
|
|
248
|
+
if (identity && message.company_id && message.company_id !== identity.company_id) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const answer = buildAnswer({nonce: message.nonce, httpPort: this.httpPort(), identity});
|
|
252
|
+
if (!answer || !this.limiter.take()) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
this.answered += 1;
|
|
256
|
+
await send(socket, answer, rinfo.port, rinfo.address);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
stop() {
|
|
260
|
+
if (this.socket) {
|
|
261
|
+
const socket = this.socket;
|
|
262
|
+
this.socket = null;
|
|
263
|
+
try {
|
|
264
|
+
socket.close();
|
|
265
|
+
} catch (e) {
|
|
266
|
+
// уже закрыт
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const httpLimiter = new Limiter(MAX_ANSWERS_PER_SECOND);
|
|
273
|
+
let httpAnswered = 0;
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* GET /local/whoami?nonce= — тот же подписанный ответ по HTTP (ступени 1 и 3 поиска). Путь
|
|
277
|
+
* открыт без ключа зала (lan.js): ответ сам доказывает знание ключа и ничего не раскрывает.
|
|
278
|
+
* null — не отвечаем (не мастер, нет ключа или сессии, превышен лимит).
|
|
279
|
+
*/
|
|
280
|
+
const whoami = ({nonce, httpPort, identity}) => {
|
|
281
|
+
const answer = buildAnswer({nonce, httpPort, identity});
|
|
282
|
+
if (!answer || !httpLimiter.take()) {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
httpAnswered += 1;
|
|
286
|
+
return answer;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------- ведомый: ищет
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Один раунд поиска: широковещательный запрос во все подсети, проверка ответов в течение
|
|
293
|
+
* window. Отдаёт проверенных кандидатов (без дублей по адресу).
|
|
294
|
+
*
|
|
295
|
+
* @param {{companyId?: string, targets?: string[]}} options
|
|
296
|
+
* targets — куда слать вместо широковещательных адресов (тесты, сети без broadcast).
|
|
297
|
+
*/
|
|
298
|
+
const probe = ({companyId = null, targets = null} = {}) => new Promise((resolve) => {
|
|
299
|
+
const key = config.lanKey();
|
|
300
|
+
if (!key) {
|
|
301
|
+
resolve([]);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const {port, window} = settings();
|
|
305
|
+
const nonce = randomBytes(16).toString('hex');
|
|
306
|
+
const found = new Map();
|
|
307
|
+
const socket = dgram.createSocket('udp4');
|
|
308
|
+
let finished = false;
|
|
309
|
+
const finish = () => {
|
|
310
|
+
if (finished) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
finished = true;
|
|
314
|
+
clearTimeout(timer);
|
|
315
|
+
try {
|
|
316
|
+
socket.close();
|
|
317
|
+
} catch (e) {
|
|
318
|
+
// уже закрыт
|
|
319
|
+
}
|
|
320
|
+
resolve([...found.values()]);
|
|
321
|
+
};
|
|
322
|
+
const timer = setTimeout(finish, window);
|
|
323
|
+
|
|
324
|
+
socket.on('error', finish);
|
|
325
|
+
socket.on('message', (buffer, rinfo) => {
|
|
326
|
+
const candidate = accept(parse(buffer), {key, nonce, address: rinfo.address, companyId});
|
|
327
|
+
if (candidate) {
|
|
328
|
+
found.set(rinfo.address, candidate);
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
socket.bind(0, async () => {
|
|
332
|
+
socket.setBroadcast(true);
|
|
333
|
+
const message = {t: QUERY, v: VERSION, nonce, ...(companyId ? {company_id: companyId} : {})};
|
|
334
|
+
const addresses = targets && targets.length
|
|
335
|
+
? targets
|
|
336
|
+
: [...new Set([...interfaces().map((item) => item.broadcast), '255.255.255.255'])];
|
|
337
|
+
await Promise.all(addresses.map((address) => send(socket, message, port, address)));
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Проверка адреса по HTTP: GET <url>/local/whoami со свежим nonce. Отдаёт кандидата или null.
|
|
343
|
+
* Только http и IPv4 — подпись мастера перечисляет его IPv4, по имени хоста её не сверить.
|
|
344
|
+
*/
|
|
345
|
+
const verify = async (url, {companyId = null, timeout = VERIFY_TIMEOUT_MS} = {}) => {
|
|
346
|
+
const key = config.lanKey();
|
|
347
|
+
let target;
|
|
348
|
+
try {
|
|
349
|
+
target = new URL(url);
|
|
350
|
+
} catch (e) {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
if (!key || target.protocol !== 'http:' || !IPV4.test(target.hostname)) {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
const port = Number(target.port || 80);
|
|
357
|
+
const nonce = randomBytes(16).toString('hex');
|
|
358
|
+
const controller = new AbortController();
|
|
359
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
360
|
+
try {
|
|
361
|
+
const response = await fetch(`http://${target.hostname}:${port}/local/whoami?nonce=${nonce}`, {
|
|
362
|
+
signal: controller.signal,
|
|
363
|
+
redirect: 'error',
|
|
364
|
+
});
|
|
365
|
+
if (!response.ok) {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
const body = Buffer.from(await response.arrayBuffer());
|
|
369
|
+
return accept(parse(body), {key, nonce, address: target.hostname, companyId, port});
|
|
370
|
+
} catch (e) {
|
|
371
|
+
return null;
|
|
372
|
+
} finally {
|
|
373
|
+
clearTimeout(timer);
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Скан подсети по HTTP пачками по SCAN_BATCH. Останавливается на первой пачке, где кто-то
|
|
379
|
+
* ответил. hosts — вместо своей подсети (тесты).
|
|
380
|
+
*/
|
|
381
|
+
const scan = async ({companyId = null, hosts = null, port = settings().httpPort} = {}) => {
|
|
382
|
+
const list = hosts || subnetHosts();
|
|
383
|
+
for (let i = 0; i < list.length; i += SCAN_BATCH) {
|
|
384
|
+
const answers = await Promise.all(list.slice(i, i + SCAN_BATCH)
|
|
385
|
+
.map((host) => verify(`http://${host}:${port}`, {companyId, timeout: SCAN_TIMEOUT_MS})));
|
|
386
|
+
const found = answers.filter(Boolean);
|
|
387
|
+
if (found.length) {
|
|
388
|
+
return found;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return [];
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
class Finder {
|
|
395
|
+
/**
|
|
396
|
+
* @param {{companyId: () => (string|null), isReachable: () => (boolean|null),
|
|
397
|
+
* onChange: (url: string|null) => void, targets?: string[]}} options
|
|
398
|
+
* isReachable — жив ли текущий мастер по последней проверке /local/status: пока жив,
|
|
399
|
+
* сеть не опрашиваем;
|
|
400
|
+
* onChange — найден другой адрес (перезапуск моста к мастеру);
|
|
401
|
+
* remembered / remember — адрес с прошлого запуска и его сохранение (ступень 1);
|
|
402
|
+
* scanHosts — кого сканировать вместо своей подсети (тесты).
|
|
403
|
+
*/
|
|
404
|
+
constructor({companyId, isReachable, onChange, targets = null, remembered = () => null,
|
|
405
|
+
remember = () => {}, scanHosts = null}) {
|
|
406
|
+
this.companyId = companyId;
|
|
407
|
+
this.isReachable = isReachable;
|
|
408
|
+
this.onChange = onChange;
|
|
409
|
+
this.targets = targets;
|
|
410
|
+
this.remembered = remembered;
|
|
411
|
+
this.remember = remember;
|
|
412
|
+
this.scanHosts = scanHosts;
|
|
413
|
+
this.lastScanAt = null;
|
|
414
|
+
this.via = null;
|
|
415
|
+
this.timer = null;
|
|
416
|
+
this.running = false;
|
|
417
|
+
this.stopped = true;
|
|
418
|
+
this.current = null;
|
|
419
|
+
this.lastProbeAt = null;
|
|
420
|
+
this.lastFoundAt = null;
|
|
421
|
+
this.candidates = [];
|
|
422
|
+
this.conflict = false;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
start() {
|
|
426
|
+
if (!this.stopped) {
|
|
427
|
+
return this;
|
|
428
|
+
}
|
|
429
|
+
this.stopped = false;
|
|
430
|
+
this.schedule(0);
|
|
431
|
+
return this;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
schedule(delay) {
|
|
435
|
+
clearTimeout(this.timer);
|
|
436
|
+
if (this.stopped) {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
this.timer = setTimeout(() => this.run(), delay);
|
|
440
|
+
if (this.timer.unref) {
|
|
441
|
+
this.timer.unref();
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Внеочередной поиск (мастер перестал отвечать). */
|
|
446
|
+
kick() {
|
|
447
|
+
if (!this.running) {
|
|
448
|
+
this.schedule(0);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async run() {
|
|
453
|
+
if (this.stopped || this.running) {
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
this.running = true;
|
|
457
|
+
try {
|
|
458
|
+
if (!this.current || this.isReachable() === false) {
|
|
459
|
+
await this.search();
|
|
460
|
+
}
|
|
461
|
+
} catch (e) {
|
|
462
|
+
console.warn('[discovery] probe failed:', e.message);
|
|
463
|
+
} finally {
|
|
464
|
+
this.running = false;
|
|
465
|
+
}
|
|
466
|
+
this.schedule(settings().interval);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async search() {
|
|
470
|
+
this.lastProbeAt = new Date().toISOString();
|
|
471
|
+
const companyId = this.companyId();
|
|
472
|
+
let candidates = [];
|
|
473
|
+
let via = null;
|
|
474
|
+
// 1. известный адрес: текущий или сохранённый с прошлого запуска
|
|
475
|
+
const known = [...new Set([this.current, this.remembered()].filter(Boolean))];
|
|
476
|
+
for (const url of known) {
|
|
477
|
+
const candidate = await verify(url, {companyId});
|
|
478
|
+
if (candidate) {
|
|
479
|
+
candidates = [candidate];
|
|
480
|
+
via = 'saved';
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
// 2. широковещательный запрос
|
|
485
|
+
if (!candidates.length && !this.stopped) {
|
|
486
|
+
candidates = await probe({companyId, targets: this.targets});
|
|
487
|
+
via = 'udp';
|
|
488
|
+
}
|
|
489
|
+
// 3. скан подсети — широковещание могло не дойти (фильтр точки доступа, брандмауэр)
|
|
490
|
+
if (!candidates.length && !this.stopped && this.scanDue()) {
|
|
491
|
+
this.lastScanAt = Date.now();
|
|
492
|
+
candidates = await scan({companyId, hosts: this.scanHosts});
|
|
493
|
+
via = 'scan';
|
|
494
|
+
}
|
|
495
|
+
if (this.stopped) {
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
this.candidates = candidates;
|
|
499
|
+
if (!candidates.length) {
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
this.via = via;
|
|
503
|
+
// Два мастера одного заведения в сети — ошибка настройки. Держимся текущего, если он среди
|
|
504
|
+
// них, иначе берём первого по адресу (детерминированно для всех ведомых зала).
|
|
505
|
+
this.conflict = candidates.length > 1;
|
|
506
|
+
if (this.conflict) {
|
|
507
|
+
console.warn(`[discovery] several masters answer: ${candidates.map((c) => c.url).join(', ')}`);
|
|
508
|
+
}
|
|
509
|
+
const keep = this.current && candidates.find((c) => c.url === this.current);
|
|
510
|
+
const chosen = keep || [...candidates].sort((a, b) => a.address.localeCompare(b.address))[0];
|
|
511
|
+
this.lastFoundAt = new Date().toISOString();
|
|
512
|
+
if (chosen.url !== this.current) {
|
|
513
|
+
log(`master ${this.current ? `moved ${this.current} → ` : 'found at '}${chosen.url}`);
|
|
514
|
+
this.current = chosen.url;
|
|
515
|
+
config.setDiscoveredMasterUrl(chosen.url);
|
|
516
|
+
this.remember(chosen.url);
|
|
517
|
+
this.onChange(chosen.url);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
scanDue() {
|
|
522
|
+
const {scan: enabled, scanInterval} = settings();
|
|
523
|
+
return enabled && (this.lastScanAt === null || Date.now() - this.lastScanAt >= scanInterval);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
status() {
|
|
527
|
+
return {
|
|
528
|
+
url: this.current,
|
|
529
|
+
last_probe_at: this.lastProbeAt,
|
|
530
|
+
last_found_at: this.lastFoundAt,
|
|
531
|
+
candidates: this.candidates.map((c) => c.url),
|
|
532
|
+
conflict: this.conflict,
|
|
533
|
+
via: this.via,
|
|
534
|
+
last_scan_at: this.lastScanAt ? new Date(this.lastScanAt).toISOString() : null,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
stop() {
|
|
539
|
+
this.stopped = true;
|
|
540
|
+
clearTimeout(this.timer);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const httpAnswers = () => httpAnswered;
|
|
545
|
+
|
|
546
|
+
module.exports = {Responder, Finder, probe, verify, scan, whoami, httpAnswers, sign, interfaces, settings};
|