hikvision-server 1.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.
package/src/manager.ts ADDED
@@ -0,0 +1,492 @@
1
+ /**
2
+ * Hikvision Manager - 独立管理服务
3
+ *
4
+ * 只负责 API Server 的启动/停止/状态查询
5
+ * 独立端口运行,即使 API Server 停止也能被访问
6
+ */
7
+
8
+ import express from 'express';
9
+ import cors from 'cors';
10
+ import * as fs from 'fs';
11
+ import * as path from 'path';
12
+ import { fileURLToPath } from 'url';
13
+ import { fork } from 'child_process';
14
+
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+ // dist/manager.js 在 packages/server/dist/,向上三级到项目根目录
17
+ const PROJECT_ROOT = path.resolve(__dirname, '../../../');
18
+ const API_SERVER_SCRIPT = path.resolve(__dirname, '../dist/index.js');
19
+ const PID_PATH = path.resolve(PROJECT_ROOT, '.server.pid');
20
+ const CONFIG_PATH = path.resolve(PROJECT_ROOT, 'packages/server/config.json');
21
+
22
+ const app = express();
23
+ const PORT = parseInt(process.env.MANAGER_PORT || '3001', 10);
24
+
25
+ // 加载配置
26
+ let configData: any = {};
27
+ try {
28
+ if (fs.existsSync(CONFIG_PATH)) {
29
+ configData = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
30
+ }
31
+ } catch {}
32
+
33
+ // CORS 白名单配置
34
+ // 优先级:环境变量 CORS_ORIGIN > config.json manager.corsOrigins > 内置默认
35
+ // 端口从 config.json web.port 读取,未配置则默认 3030
36
+ const getCorsOrigins = (): string[] => {
37
+ const webPort = configData?.web?.port || 3030;
38
+
39
+ // 1. 环境变量(逗号分隔,支持带端口或不带端口)
40
+ if (process.env.CORS_ORIGIN) {
41
+ return process.env.CORS_ORIGIN.split(',').map(s => {
42
+ const origin = s.trim();
43
+ // 不带端口的自动拼接 webPort
44
+ if (origin && !origin.includes(':')) {
45
+ return `${origin}:${webPort}`;
46
+ }
47
+ return origin;
48
+ }).filter(Boolean);
49
+ }
50
+
51
+ // 2. config.json 配置(只配 IP,协议和端口自动拼接)
52
+ if (configData?.manager?.corsOrigins && Array.isArray(configData.manager.corsOrigins)) {
53
+ return configData.manager.corsOrigins.map((s: string) => {
54
+ const ip = s.trim();
55
+ if (!ip) return '';
56
+ // 自动拼接 http:// 和 :webPort
57
+ return `http://${ip}:${webPort}`;
58
+ }).filter(Boolean);
59
+ }
60
+
61
+ // 3. 内置默认本机(不需要配置,端口从 web.port 读取)
62
+ return [
63
+ `http://localhost:${webPort}`,
64
+ `http://127.0.0.1:${webPort}`,
65
+ ];
66
+ };
67
+
68
+ app.use(cors({
69
+ origin: getCorsOrigins(),
70
+ credentials: true,
71
+ }));
72
+
73
+ // API Key 认证
74
+ const API_KEY = process.env.MANAGER_API_KEY || configData?.auth?.apiKey || '';
75
+
76
+ // 白名单路由(不需要认证)
77
+ const PUBLIC_ROUTES = ['/api/health', '/api/manager/status'];
78
+
79
+ // 时序安全的密钥比较
80
+ function safeCompare(a: string, b: string): boolean {
81
+ if (a.length !== b.length) return false;
82
+ // 使用 Node.js 的 timingSafeEqual 进行恒定时间比较
83
+ try {
84
+ const crypto = require('crypto');
85
+ return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
86
+ } catch {
87
+ // Fallback: constant-time comparison to prevent timing attacks
88
+ let result = 0;
89
+ for (let i = 0; i < a.length; i++) {
90
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
91
+ }
92
+ return result === 0;
93
+ }
94
+ }
95
+
96
+ // 认证中间件
97
+ function authMiddleware(req: any, res: any, next: any) {
98
+ // 公开路由放行
99
+ if (PUBLIC_ROUTES.some(r => req.path.startsWith(r))) {
100
+ return next();
101
+ }
102
+
103
+ // 检查 API Key
104
+ if (!API_KEY) {
105
+ // 未配置 API Key,生产模式必须拒绝
106
+ if (process.env.NODE_ENV === 'production') {
107
+ return res.status(503).json({
108
+ success: false,
109
+ message: '服务配置不完整:MANAGER_API_KEY 未设置'
110
+ });
111
+ }
112
+ // 开发模式允许未授权访问(但输出警告)
113
+ console.warn('⚠️ 警告:Manager 服务运行于无认证模式(开发模式)');
114
+ return next();
115
+ }
116
+
117
+ const apiKey = req.headers['x-api-key'];
118
+ if (!apiKey) {
119
+ return res.status(401).json({ success: false, message: '未授权:缺少 API Key' });
120
+ }
121
+ if (!safeCompare(apiKey, API_KEY)) {
122
+ return res.status(401).json({ success: false, message: '未授权:无效的 API Key' });
123
+ }
124
+
125
+ next();
126
+ }
127
+
128
+ app.use(authMiddleware);
129
+
130
+ // 脱敏显示:前2位 + **** + 后2位
131
+ function maskSecret(value: string): string {
132
+ if (!value) return '';
133
+ if (value.length <= 4) return '****';
134
+ return value.slice(0, 2) + '****' + value.slice(-2);
135
+ }
136
+
137
+ // 检查 API Server 是否运行
138
+ function isServerRunning(): { running: boolean; pid?: number } {
139
+ if (!fs.existsSync(PID_PATH)) {
140
+ // PID 文件不存在,检查端口是否被占用(可能是外部启动的)
141
+ const apiPort = parseInt(process.env.API_SERVER_PORT || '3000', 10);
142
+ const hexPort = apiPort.toString(16).toUpperCase().padStart(4, '0');
143
+
144
+ try {
145
+ // 检查 /proc/net/tcp 和 /proc/net/tcp6
146
+ for (const netFile of ['/proc/net/tcp', '/proc/net/tcp6']) {
147
+ if (!fs.existsSync(netFile)) continue;
148
+ const lines = fs.readFileSync(netFile, 'utf-8').split('\n');
149
+ for (let i = 1; i < lines.length; i++) { // 跳过表头
150
+ const line = lines[i].trim();
151
+ if (!line) continue;
152
+ const parts = line.split(/\s+/);
153
+ if (parts.length < 10) continue;
154
+ const localAddr = parts[1];
155
+ if (localAddr.endsWith(`:${hexPort}`)) {
156
+ const state = parts[3];
157
+ // 0A = LISTEN
158
+ if (state === '0A') {
159
+ // 尝试通过 inode 找到 PID
160
+ const inode = parts[9];
161
+ try {
162
+ const procDirs = fs.readdirSync('/proc');
163
+ for (const dir of procDirs) {
164
+ if (/^\d+$/.test(dir)) {
165
+ try {
166
+ const fdDirs = fs.readdirSync(`/proc/${dir}/fd`);
167
+ for (const fd of fdDirs) {
168
+ try {
169
+ const link = fs.readlinkSync(`/proc/${dir}/fd/${fd}`);
170
+ if (link.includes(`socket:[${inode}]`)) {
171
+ return { running: true, pid: parseInt(dir) };
172
+ }
173
+ } catch {}
174
+ }
175
+ } catch {}
176
+ }
177
+ }
178
+ } catch {}
179
+ return { running: true };
180
+ }
181
+ }
182
+ }
183
+ }
184
+ } catch {
185
+ // /proc 不可用,继续
186
+ }
187
+ return { running: false };
188
+ }
189
+
190
+ try {
191
+ const pid = parseInt(fs.readFileSync(PID_PATH, 'utf-8').trim());
192
+ if (isNaN(pid)) {
193
+ return { running: false };
194
+ }
195
+
196
+ // 检查进程是否存在
197
+ try {
198
+ process.kill(pid, 0);
199
+ return { running: true, pid };
200
+ } catch {
201
+ // 进程不存在,清理 PID 文件
202
+ fs.unlinkSync(PID_PATH);
203
+ return { running: false };
204
+ }
205
+ } catch {
206
+ return { running: false };
207
+ }
208
+ }
209
+
210
+ /**
211
+ * 启动 API Server
212
+ */
213
+ app.post('/api/manager/start', async (_req, res) => {
214
+ const status = isServerRunning();
215
+
216
+ if (status.running) {
217
+ return res.json({
218
+ success: true,
219
+ message: `API Server 已在运行 (PID: ${status.pid})`
220
+ });
221
+ }
222
+
223
+ // 检查端口是否被占用
224
+ const apiPort = parseInt(process.env.API_SERVER_PORT || '3000', 10);
225
+ const hexPort = apiPort.toString(16).toUpperCase().padStart(4, '0');
226
+
227
+ try {
228
+ for (const netFile of ['/proc/net/tcp', '/proc/net/tcp6']) {
229
+ if (!fs.existsSync(netFile)) continue;
230
+ const lines = fs.readFileSync(netFile, 'utf-8').split('\n');
231
+ for (let i = 1; i < lines.length; i++) {
232
+ const line = lines[i].trim();
233
+ if (!line) continue;
234
+ const parts = line.split(/\s+/);
235
+ if (parts.length < 10) continue;
236
+ const localAddr = parts[1];
237
+ if (localAddr.endsWith(`:${hexPort}`)) {
238
+ const state = parts[3];
239
+ if (state === '0A') {
240
+ return res.json({
241
+ success: true,
242
+ message: `API Server 已在运行 (端口 ${apiPort} 被占用)`
243
+ });
244
+ }
245
+ }
246
+ }
247
+ }
248
+ } catch {
249
+ // 无法检查端口,继续尝试启动
250
+ }
251
+
252
+ try {
253
+ // 检查脚本是否存在
254
+ if (!fs.existsSync(API_SERVER_SCRIPT)) {
255
+ return res.status(500).json({
256
+ success: false,
257
+ message: 'API Server 未编译,请先运行 npm run build'
258
+ });
259
+ }
260
+
261
+ const child = fork(API_SERVER_SCRIPT, [], {
262
+ cwd: PROJECT_ROOT,
263
+ env: { ...process.env },
264
+ silent: true,
265
+ });
266
+
267
+ // 监听子进程错误事件
268
+ child.on('error', (err) => {
269
+ console.error('❌ API Server 子进程错误:', err.message);
270
+ if (fs.existsSync(PID_PATH)) {
271
+ try {
272
+ fs.unlinkSync(PID_PATH);
273
+ } catch {}
274
+ }
275
+ });
276
+
277
+ // 监听子进程退出事件
278
+ child.on('exit', (code, signal) => {
279
+ console.log(`API Server 子进程退出: code=${code}, signal=${signal}`);
280
+ if (fs.existsSync(PID_PATH)) {
281
+ try {
282
+ fs.unlinkSync(PID_PATH);
283
+ } catch {}
284
+ }
285
+ });
286
+
287
+ if (child.pid) {
288
+ fs.writeFileSync(PID_PATH, String(child.pid), 'utf-8');
289
+ }
290
+
291
+ res.json({
292
+ success: true,
293
+ message: 'API Server 已启动',
294
+ pid: child.pid
295
+ });
296
+ } catch (error: any) {
297
+ res.status(500).json({
298
+ success: false,
299
+ message: error.message || '启动失败'
300
+ });
301
+ }
302
+ });
303
+
304
+ /**
305
+ * 停止 API Server
306
+ */
307
+ app.post('/api/manager/stop', async (_req, res) => {
308
+ const status = isServerRunning();
309
+
310
+ if (!status.running) {
311
+ return res.json({ success: true, message: '服务未运行' });
312
+ }
313
+
314
+ try {
315
+ const pid = status.pid!;
316
+
317
+ // 优雅关闭
318
+ process.kill(pid, 'SIGTERM');
319
+
320
+ // 等待进程退出
321
+ await new Promise<void>((resolve) => {
322
+ const timeout = setTimeout(() => {
323
+ try {
324
+ process.kill(pid, 'SIGKILL');
325
+ } catch {}
326
+ resolve();
327
+ }, 3000);
328
+
329
+ const checkInterval = setInterval(() => {
330
+ try {
331
+ process.kill(pid, 0);
332
+ } catch {
333
+ clearInterval(checkInterval);
334
+ clearTimeout(timeout);
335
+ resolve();
336
+ }
337
+ }, 100);
338
+ });
339
+
340
+ // 清理 PID 文件
341
+ if (fs.existsSync(PID_PATH)) {
342
+ fs.unlinkSync(PID_PATH);
343
+ }
344
+
345
+ res.json({ success: true, message: `服务已停止 (PID: ${pid})` });
346
+ } catch (error: any) {
347
+ if (error.code === 'ESRCH') {
348
+ if (fs.existsSync(PID_PATH)) {
349
+ fs.unlinkSync(PID_PATH);
350
+ }
351
+ res.json({ success: true, message: '服务已停止(进程不存在)' });
352
+ } else {
353
+ res.status(500).json({ success: false, message: error.message });
354
+ }
355
+ }
356
+ });
357
+
358
+ /**
359
+ * 重启 API Server
360
+ */
361
+ app.post('/api/manager/restart', async (_req, res) => {
362
+ const status = isServerRunning();
363
+
364
+ try {
365
+ // 先停止
366
+ if (status.running) {
367
+ const pid = status.pid!;
368
+ process.kill(pid, 'SIGTERM');
369
+
370
+ await new Promise<void>((resolve) => {
371
+ const timeout = setTimeout(() => {
372
+ try { process.kill(pid, 'SIGKILL'); } catch {}
373
+ resolve();
374
+ }, 3000);
375
+
376
+ const checkInterval = setInterval(() => {
377
+ try {
378
+ process.kill(pid, 0);
379
+ } catch {
380
+ clearInterval(checkInterval);
381
+ clearTimeout(timeout);
382
+ resolve();
383
+ }
384
+ }, 100);
385
+ });
386
+
387
+ if (fs.existsSync(PID_PATH)) {
388
+ fs.unlinkSync(PID_PATH);
389
+ }
390
+ }
391
+
392
+ // 再启动
393
+ if (!fs.existsSync(API_SERVER_SCRIPT)) {
394
+ return res.status(500).json({
395
+ success: false,
396
+ message: 'API Server 未编译,请先运行 npm run build'
397
+ });
398
+ }
399
+
400
+ const child = fork(API_SERVER_SCRIPT, [], {
401
+ cwd: PROJECT_ROOT,
402
+ env: { ...process.env },
403
+ silent: true,
404
+ });
405
+
406
+ // 监听子进程错误事件
407
+ child.on('error', (err) => {
408
+ console.error('❌ API Server 子进程错误:', err.message);
409
+ if (fs.existsSync(PID_PATH)) {
410
+ try {
411
+ fs.unlinkSync(PID_PATH);
412
+ } catch {}
413
+ }
414
+ });
415
+
416
+ // 监听子进程退出事件
417
+ child.on('exit', (code, signal) => {
418
+ console.log(`API Server 子进程退出: code=${code}, signal=${signal}`);
419
+ if (fs.existsSync(PID_PATH)) {
420
+ try {
421
+ fs.unlinkSync(PID_PATH);
422
+ } catch {}
423
+ }
424
+ });
425
+
426
+ if (child.pid) {
427
+ fs.writeFileSync(PID_PATH, String(child.pid), 'utf-8');
428
+ }
429
+
430
+ res.json({
431
+ success: true,
432
+ message: '服务已重启',
433
+ pid: child.pid
434
+ });
435
+ } catch (error: any) {
436
+ res.status(500).json({ success: false, message: error.message });
437
+ }
438
+ });
439
+
440
+ /**
441
+ * 查询服务状态
442
+ */
443
+ app.get('/api/manager/status', (_req, res) => {
444
+ const status = isServerRunning();
445
+
446
+ res.json({
447
+ success: true,
448
+ data: {
449
+ isRunning: status.running,
450
+ pid: status.pid || null,
451
+ port: parseInt(process.env.API_SERVER_PORT || '3000', 10),
452
+ }
453
+ });
454
+ });
455
+
456
+ /**
457
+ * 读取配置文件(Manager 服务提供,API Server 不可用时使用)
458
+ */
459
+ app.get('/api/config', (_req, res) => {
460
+ try {
461
+ const configPath = path.resolve(PROJECT_ROOT, 'packages/server/config.json');
462
+ let configData: any = {};
463
+ if (fs.existsSync(configPath)) {
464
+ configData = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
465
+ }
466
+
467
+ // 脱敏显示
468
+ const maskedConfig = {
469
+ ...configData,
470
+ hikvision: configData?.hikvision ? {
471
+ ...configData.hikvision,
472
+ appKey: configData.hikvision.appKey ? maskSecret(configData.hikvision.appKey) : '',
473
+ appSecret: configData.hikvision.appSecret ? maskSecret(configData.hikvision.appSecret) : '',
474
+ } : undefined,
475
+ };
476
+
477
+ res.json({
478
+ success: true,
479
+ data: {
480
+ configPath,
481
+ fileConfig: maskedConfig,
482
+ }
483
+ });
484
+ } catch (error: any) {
485
+ res.status(500).json({ success: false, message: error.message });
486
+ }
487
+ });
488
+
489
+ app.listen(PORT, () => {
490
+ console.log(`🔧 Manager Service running on http://localhost:${PORT}`);
491
+ console.log(` API Server: http://localhost:${process.env.API_SERVER_PORT || '3000'}`);
492
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src"
6
+ },
7
+ "include": ["src/**/*"]
8
+ }