lightman-agent 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.
Files changed (54) hide show
  1. package/agent.config.template.json +30 -0
  2. package/bin/cms-agent.js +233 -0
  3. package/nssm/nssm.exe +0 -0
  4. package/package.json +52 -0
  5. package/public/assets/index-CcBNCz6h.css +1 -0
  6. package/public/assets/index-H-8HDl46.js +1 -0
  7. package/public/index.html +19 -0
  8. package/scripts/guardian.ps1 +75 -0
  9. package/scripts/install-linux.sh +134 -0
  10. package/scripts/install-rpi.sh +117 -0
  11. package/scripts/install-windows.ps1 +529 -0
  12. package/scripts/launch-kiosk.vbs +101 -0
  13. package/scripts/lightman-agent.logrotate +12 -0
  14. package/scripts/lightman-agent.service +38 -0
  15. package/scripts/lightman-shell.bat +128 -0
  16. package/scripts/reinstall-windows.ps1 +26 -0
  17. package/scripts/restore-desktop.ps1 +32 -0
  18. package/scripts/setup.ps1 +116 -0
  19. package/scripts/setup.sh +115 -0
  20. package/scripts/uninstall-linux.sh +50 -0
  21. package/scripts/uninstall-windows.ps1 +54 -0
  22. package/src/commands/display.ts +177 -0
  23. package/src/commands/kiosk.ts +113 -0
  24. package/src/commands/maintenance.ts +106 -0
  25. package/src/commands/network.ts +129 -0
  26. package/src/commands/power.ts +163 -0
  27. package/src/commands/rpi.ts +45 -0
  28. package/src/commands/screenshot.ts +166 -0
  29. package/src/commands/serial.ts +17 -0
  30. package/src/commands/update.ts +124 -0
  31. package/src/index.ts +652 -0
  32. package/src/lib/config.ts +69 -0
  33. package/src/lib/identity.ts +40 -0
  34. package/src/lib/logger.ts +137 -0
  35. package/src/lib/platform.ts +10 -0
  36. package/src/lib/rpi.ts +180 -0
  37. package/src/lib/screens.ts +128 -0
  38. package/src/lib/types.ts +176 -0
  39. package/src/services/commands.ts +107 -0
  40. package/src/services/health.ts +161 -0
  41. package/src/services/kiosk.ts +395 -0
  42. package/src/services/localEvents.ts +60 -0
  43. package/src/services/logForwarder.ts +72 -0
  44. package/src/services/multiScreenKiosk.ts +324 -0
  45. package/src/services/oscBridge.ts +186 -0
  46. package/src/services/powerScheduler.ts +260 -0
  47. package/src/services/provisioning.ts +120 -0
  48. package/src/services/serialBridge.ts +230 -0
  49. package/src/services/serviceLauncher.ts +183 -0
  50. package/src/services/staticServer.ts +226 -0
  51. package/src/services/updater.ts +249 -0
  52. package/src/services/watchdog.ts +310 -0
  53. package/src/services/websocket.ts +152 -0
  54. package/tsconfig.json +28 -0
@@ -0,0 +1,310 @@
1
+ import { readdirSync, statSync, unlinkSync } from 'fs';
2
+ import { join } from 'path';
3
+ import type { KioskManager } from './kiosk.js';
4
+ import type { WsClient } from './websocket.js';
5
+ import type { HealthMonitor } from './health.js';
6
+ import type { Logger } from '../lib/logger.js';
7
+ import type { WatchdogConfig, CrashReport } from '../lib/types.js';
8
+
9
+ const DEFAULT_CONFIG: WatchdogConfig = {
10
+ checkIntervalMs: 60_000,
11
+ kioskCrashCooldownMs: 300_000, // 5 min
12
+ highMemoryThresholdMb: 500,
13
+ highMemoryCooldownMs: 3_600_000, // 1 hour
14
+ highDiskThresholdPercent: 90,
15
+ highDiskCooldownMs: 3_600_000, // 1 hour
16
+ wsDisconnectedThresholdMs: 600_000, // 10 min
17
+ wsDisconnectedCooldownMs: 900_000, // 15 min
18
+ };
19
+
20
+ const CHROME_CACHE_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000; // Once per day
21
+ const CHROME_CACHE_DIR = process.platform === 'win32'
22
+ ? 'C:\\ProgramData\\Lightman\\chrome-kiosk\\Default\\Cache'
23
+ : '/tmp/lightman-chrome-cache';
24
+
25
+ interface RecoveryStats {
26
+ kioskRestarts: number;
27
+ memoryRestarts: number;
28
+ diskCleanups: number;
29
+ wsRestarts: number;
30
+ }
31
+
32
+ export class Watchdog {
33
+ private kioskManager: KioskManager;
34
+ private wsClient: WsClient;
35
+ private healthMonitor: HealthMonitor;
36
+ private logger: Logger;
37
+ private config: WatchdogConfig;
38
+ private shellMode: boolean;
39
+ private timer: NodeJS.Timeout | null = null;
40
+ private cooldowns: Map<string, number> = new Map();
41
+ private stats: RecoveryStats = {
42
+ kioskRestarts: 0,
43
+ memoryRestarts: 0,
44
+ diskCleanups: 0,
45
+ wsRestarts: 0,
46
+ };
47
+ private wsDisconnectedSince: number | null = null;
48
+ private shuttingDown = false;
49
+ private serverUrl: string;
50
+ private identity: { deviceId: string; apiKey: string };
51
+ private lastChromeCacheCleanup = 0;
52
+ private _multiScreenActive = false;
53
+
54
+ constructor(
55
+ kioskManager: KioskManager,
56
+ wsClient: WsClient,
57
+ healthMonitor: HealthMonitor,
58
+ logger: Logger,
59
+ serverUrl: string,
60
+ identity: { deviceId: string; apiKey: string },
61
+ config?: Partial<WatchdogConfig>,
62
+ shellMode?: boolean
63
+ ) {
64
+ this.kioskManager = kioskManager;
65
+ this.wsClient = wsClient;
66
+ this.healthMonitor = healthMonitor;
67
+ this.logger = logger;
68
+ this.serverUrl = serverUrl;
69
+ this.identity = identity;
70
+ this.config = { ...DEFAULT_CONFIG, ...config };
71
+ this.shellMode = shellMode ?? false;
72
+ }
73
+
74
+ /** When multi-screen kiosk is active, watchdog should NOT restart the single kiosk */
75
+ setMultiScreenActive(active: boolean): void {
76
+ this._multiScreenActive = active;
77
+ }
78
+
79
+ start(): void {
80
+ this.timer = setInterval(() => {
81
+ this.check().catch((err) => {
82
+ this.logger.error('Watchdog check failed:', err instanceof Error ? err.message : String(err));
83
+ });
84
+ }, this.config.checkIntervalMs);
85
+ this.logger.info(`Watchdog started (interval: ${this.config.checkIntervalMs}ms)`);
86
+ }
87
+
88
+ stop(): void {
89
+ if (this.timer) {
90
+ clearInterval(this.timer);
91
+ this.timer = null;
92
+ }
93
+ this.logger.info('Watchdog stopped');
94
+ }
95
+
96
+ getStats(): RecoveryStats {
97
+ return { ...this.stats };
98
+ }
99
+
100
+ getCooldowns(): Record<string, number> {
101
+ const now = Date.now();
102
+ const result: Record<string, number> = {};
103
+ for (const [key, expiry] of this.cooldowns) {
104
+ const remaining = expiry - now;
105
+ if (remaining > 0) {
106
+ result[key] = remaining;
107
+ }
108
+ }
109
+ return result;
110
+ }
111
+
112
+ async runDiskCleanup(): Promise<{ freedBytes: number; deletedFiles: number }> {
113
+ let freedBytes = 0;
114
+ let deletedFiles = 0;
115
+
116
+ const cleanDirs = ['/tmp'];
117
+ const now = Date.now();
118
+ const maxAgeMs = 7 * 24 * 60 * 60 * 1000; // 7 days
119
+
120
+ for (const dir of cleanDirs) {
121
+ try {
122
+ const files = readdirSync(dir);
123
+ for (const file of files) {
124
+ if (!file.startsWith('lightman-')) continue;
125
+ try {
126
+ const filePath = join(dir, file);
127
+ const stat = statSync(filePath);
128
+ if (now - stat.mtimeMs > maxAgeMs) {
129
+ freedBytes += stat.size;
130
+ deletedFiles += 1;
131
+ unlinkSync(filePath);
132
+ }
133
+ } catch {
134
+ // Skip files we can't access
135
+ }
136
+ }
137
+ } catch {
138
+ // Skip dirs we can't read
139
+ }
140
+ }
141
+
142
+ this.logger.info(`Disk cleanup: freed ${freedBytes} bytes, deleted ${deletedFiles} files`);
143
+ return { freedBytes, deletedFiles };
144
+ }
145
+
146
+ async sendCrashReport(processName: string, exitCode: number | null, signal: string | null): Promise<void> {
147
+ try {
148
+ const health = await this.healthMonitor.collect();
149
+ const report: CrashReport = {
150
+ process: processName,
151
+ exitCode,
152
+ signal,
153
+ timestamp: new Date().toISOString(),
154
+ system: {
155
+ memPercent: health.memPercent,
156
+ diskPercent: health.diskPercent,
157
+ cpuUsage: health.cpuUsage,
158
+ uptime: health.uptime,
159
+ },
160
+ };
161
+
162
+ const url = `${this.serverUrl}/api/devices/${this.identity.deviceId}/crash-report`;
163
+ await fetch(url, {
164
+ method: 'POST',
165
+ headers: {
166
+ 'Content-Type': 'application/json',
167
+ 'Authorization': `Bearer ${this.identity.apiKey}`,
168
+ },
169
+ body: JSON.stringify(report),
170
+ });
171
+
172
+ this.logger.info(`Crash report sent for ${processName}`);
173
+ } catch (err) {
174
+ this.logger.error('Failed to send crash report:', err instanceof Error ? err.message : String(err));
175
+ }
176
+ }
177
+
178
+ // --- Private ---
179
+
180
+ private async check(): Promise<void> {
181
+ const health = await this.healthMonitor.collect();
182
+ const now = Date.now();
183
+
184
+ // Track WS disconnection duration
185
+ if (!this.wsClient.isConnected()) {
186
+ if (this.wsDisconnectedSince === null) {
187
+ this.wsDisconnectedSince = now;
188
+ }
189
+ } else {
190
+ this.wsDisconnectedSince = null;
191
+ }
192
+
193
+ // Rule: Kiosk crash recovery
194
+ // Skip if multi-screen kiosk is active (it manages its own Chrome instances)
195
+ // In shell mode, the shell BAT handles Chrome restarts — we only monitor and report
196
+ if (this._multiScreenActive) {
197
+ // Multi-screen mode — watchdog does not touch Chrome
198
+ } else {
199
+ const kioskStatus = this.kioskManager.getStatus();
200
+ if (!kioskStatus.running && !kioskStatus.crashLoopDetected) {
201
+ if (this.shellMode) {
202
+ // Shell mode: just report, don't try to launch (shell handles it)
203
+ if (this.canAct('kiosk_crash', this.config.kioskCrashCooldownMs)) {
204
+ this.logger.warn('Watchdog: Chrome not running (shell mode — shell should relaunch)');
205
+ this.setCooldown('kiosk_crash', this.config.kioskCrashCooldownMs);
206
+ this.sendCrashReport('kiosk', null, null).catch(() => {});
207
+ }
208
+ } else if (this.canAct('kiosk_crash', this.config.kioskCrashCooldownMs)) {
209
+ this.logger.warn('Watchdog: kiosk not running, attempting restart');
210
+ this.stats = { ...this.stats, kioskRestarts: this.stats.kioskRestarts + 1 };
211
+ this.setCooldown('kiosk_crash', this.config.kioskCrashCooldownMs);
212
+
213
+ setTimeout(() => {
214
+ this.kioskManager.launch().catch((err) => {
215
+ this.logger.error('Watchdog kiosk restart failed:', err instanceof Error ? err.message : String(err));
216
+ });
217
+ }, 3000);
218
+
219
+ this.sendCrashReport('kiosk', null, null).catch(() => {});
220
+ }
221
+ }
222
+ } // end else (not multi-screen)
223
+
224
+ // Rule: High memory — restart agent (systemd will restart)
225
+ const heapMb = process.memoryUsage().heapUsed / (1024 * 1024);
226
+ if (!this.shuttingDown && health.memPercent > 80 && heapMb > this.config.highMemoryThresholdMb) {
227
+ if (this.canAct('high_memory', this.config.highMemoryCooldownMs)) {
228
+ this.logger.warn(`Watchdog: high memory (heap: ${Math.round(heapMb)}MB, system: ${health.memPercent}%), restarting agent`);
229
+ this.stats = { ...this.stats, memoryRestarts: this.stats.memoryRestarts + 1 };
230
+ this.setCooldown('high_memory', this.config.highMemoryCooldownMs);
231
+ this.shuttingDown = true;
232
+ setTimeout(() => process.exit(0), 1000);
233
+ return;
234
+ }
235
+ }
236
+
237
+ // Rule: High disk usage — cleanup
238
+ if (health.diskPercent > this.config.highDiskThresholdPercent) {
239
+ if (this.canAct('high_disk', this.config.highDiskCooldownMs)) {
240
+ this.logger.warn(`Watchdog: high disk usage (${health.diskPercent}%), running cleanup`);
241
+ this.stats = { ...this.stats, diskCleanups: this.stats.diskCleanups + 1 };
242
+ this.setCooldown('high_disk', this.config.highDiskCooldownMs);
243
+ await this.runDiskCleanup();
244
+ }
245
+ }
246
+
247
+ // Rule: WS disconnected too long — restart agent
248
+ if (
249
+ !this.shuttingDown &&
250
+ this.wsDisconnectedSince !== null &&
251
+ now - this.wsDisconnectedSince > this.config.wsDisconnectedThresholdMs
252
+ ) {
253
+ if (this.canAct('ws_disconnected', this.config.wsDisconnectedCooldownMs)) {
254
+ this.logger.warn(`Watchdog: WS disconnected for ${Math.round((now - this.wsDisconnectedSince) / 1000)}s, restarting agent`);
255
+ this.stats = { ...this.stats, wsRestarts: this.stats.wsRestarts + 1 };
256
+ this.setCooldown('ws_disconnected', this.config.wsDisconnectedCooldownMs);
257
+ this.shuttingDown = true;
258
+ setTimeout(() => process.exit(0), 1000);
259
+ return;
260
+ }
261
+ }
262
+
263
+ // Rule: Daily Chrome cache cleanup (prevents disk fill over months)
264
+ if (now - this.lastChromeCacheCleanup > CHROME_CACHE_CLEANUP_INTERVAL_MS) {
265
+ this.lastChromeCacheCleanup = now;
266
+ this.cleanChromeCacheDir().catch(() => {});
267
+ }
268
+ }
269
+
270
+ private async cleanChromeCacheDir(): Promise<void> {
271
+ try {
272
+ const cacheDir = CHROME_CACHE_DIR;
273
+ const files = readdirSync(cacheDir);
274
+ const now = Date.now();
275
+ const maxAgeMs = 3 * 24 * 60 * 60 * 1000; // 3 days
276
+ let cleaned = 0;
277
+
278
+ for (const file of files) {
279
+ try {
280
+ const filePath = join(cacheDir, file);
281
+ const stat = statSync(filePath);
282
+ if (stat.isFile() && now - stat.mtimeMs > maxAgeMs) {
283
+ unlinkSync(filePath);
284
+ cleaned++;
285
+ }
286
+ } catch {
287
+ // Skip files we can't access
288
+ }
289
+ }
290
+
291
+ if (cleaned > 0) {
292
+ this.logger.info(`Chrome cache cleanup: removed ${cleaned} stale files`);
293
+ }
294
+ } catch {
295
+ // Cache dir may not exist yet, that's fine
296
+ }
297
+ }
298
+
299
+ private canAct(action: string, _cooldownMs: number): boolean {
300
+ const expiry = this.cooldowns.get(action);
301
+ if (expiry && Date.now() < expiry) {
302
+ return false;
303
+ }
304
+ return true;
305
+ }
306
+
307
+ private setCooldown(action: string, cooldownMs: number): void {
308
+ this.cooldowns = new Map([...this.cooldowns, [action, Date.now() + cooldownMs]]);
309
+ }
310
+ }
@@ -0,0 +1,152 @@
1
+ import WebSocket from 'ws';
2
+ import type { WsMessage, Identity } from '../lib/types.js';
3
+ import type { Logger } from '../lib/logger.js';
4
+
5
+ interface WsClientOptions {
6
+ serverUrl: string;
7
+ identity: Identity;
8
+ logger: Logger;
9
+ onMessage: (msg: WsMessage) => void;
10
+ }
11
+
12
+ export class WsClient {
13
+ private ws: WebSocket | null = null;
14
+ private serverUrl: string;
15
+ private identity: Identity;
16
+ private logger: Logger;
17
+ private onMessage: (msg: WsMessage) => void;
18
+
19
+ private reconnectAttempts = 0;
20
+ private maxReconnectDelay = 60_000;
21
+ private baseDelay = 1_000;
22
+ private reconnectTimer: NodeJS.Timeout | null = null;
23
+ private closed = false;
24
+ private messageQueue: WsMessage[] = [];
25
+
26
+ constructor(options: WsClientOptions) {
27
+ this.serverUrl = options.serverUrl;
28
+ this.identity = options.identity;
29
+ this.logger = options.logger;
30
+ this.onMessage = options.onMessage;
31
+ }
32
+
33
+ connect(): void {
34
+ if (this.closed) return;
35
+
36
+ const wsUrl = this.serverUrl.replace(/^http/, 'ws') +
37
+ '/ws/agent?apiKey=' + encodeURIComponent(this.identity.apiKey);
38
+
39
+ this.logger.debug('Connecting to', wsUrl.replace(/apiKey=.*/, 'apiKey=***'));
40
+
41
+ try {
42
+ this.ws = new WebSocket(wsUrl);
43
+ } catch (err) {
44
+ this.logger.error('Failed to create WebSocket:', err);
45
+ this.scheduleReconnect();
46
+ return;
47
+ }
48
+
49
+ this.ws.on('open', () => {
50
+ this.logger.info('WebSocket connected');
51
+ this.reconnectAttempts = 0;
52
+ this.flushQueue();
53
+ });
54
+
55
+ this.ws.on('message', (data) => {
56
+ try {
57
+ const msg: WsMessage = JSON.parse(data.toString());
58
+ this.onMessage(msg);
59
+ } catch (err) {
60
+ this.logger.error('Failed to parse WS message:', err);
61
+ }
62
+ });
63
+
64
+ this.ws.on('close', (code, reason) => {
65
+ this.logger.warn(`WebSocket closed: ${code} ${reason.toString()}`);
66
+ this.ws = null;
67
+ if (!this.closed) {
68
+ this.scheduleReconnect();
69
+ }
70
+ });
71
+
72
+ this.ws.on('error', (err) => {
73
+ this.logger.error('WebSocket error:', err);
74
+ // 'close' event will fire after this, triggering reconnect
75
+ });
76
+
77
+ this.ws.on('ping', () => {
78
+ // ws library auto-responds with pong
79
+ });
80
+ }
81
+
82
+ send(msg: WsMessage): void {
83
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
84
+ this.ws.send(JSON.stringify(msg));
85
+ } else {
86
+ // Queue message for when connection is restored
87
+ this.messageQueue.push(msg);
88
+ // Keep queue bounded
89
+ if (this.messageQueue.length > 100) {
90
+ this.messageQueue.shift();
91
+ }
92
+ }
93
+ }
94
+
95
+ close(): void {
96
+ this.closed = true;
97
+ if (this.reconnectTimer) {
98
+ clearTimeout(this.reconnectTimer);
99
+ this.reconnectTimer = null;
100
+ }
101
+ if (this.ws) {
102
+ this.ws.close(1000, 'Agent shutting down');
103
+ this.ws = null;
104
+ }
105
+ this.messageQueue = [];
106
+ }
107
+
108
+ isConnected(): boolean {
109
+ return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
110
+ }
111
+
112
+ /** Calculate delay with exponential backoff + jitter */
113
+ getReconnectDelay(): number {
114
+ const exponential = this.baseDelay * Math.pow(2, this.reconnectAttempts);
115
+ const capped = Math.min(exponential, this.maxReconnectDelay);
116
+ // Add jitter: 0.5x to 1.5x
117
+ const jitter = capped * (0.5 + Math.random());
118
+ return Math.round(jitter);
119
+ }
120
+
121
+ private scheduleReconnect(): void {
122
+ if (this.closed) return;
123
+
124
+ const delay = this.getReconnectDelay();
125
+ this.reconnectAttempts++;
126
+
127
+ this.logger.info(
128
+ `Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`
129
+ );
130
+
131
+ this.reconnectTimer = setTimeout(() => {
132
+ this.reconnectTimer = null;
133
+ this.connect();
134
+ }, delay);
135
+ }
136
+
137
+ private flushQueue(): void {
138
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
139
+
140
+ const now = Date.now();
141
+ const MAX_AGE = 300_000; // 5 minutes
142
+
143
+ while (this.messageQueue.length > 0) {
144
+ const msg = this.messageQueue.shift()!;
145
+ if (now - msg.timestamp > MAX_AGE) {
146
+ this.logger.warn(`Discarding stale queued message: ${msg.type}`);
147
+ continue;
148
+ }
149
+ this.ws.send(JSON.stringify(msg));
150
+ }
151
+ }
152
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2022"],
7
+ "outDir": "dist",
8
+ "rootDir": "src",
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "resolveJsonModule": true,
14
+ "declaration": true,
15
+ "sourceMap": true,
16
+ "noUnusedLocals": false,
17
+ "noUnusedParameters": false,
18
+ "noFallthroughCasesInSwitch": true,
19
+ "isolatedModules": true,
20
+ "paths": {
21
+ "@/*": ["./src/*"]
22
+ },
23
+ "baseUrl": ".",
24
+ "types": ["node"]
25
+ },
26
+ "include": ["src/**/*"],
27
+ "exclude": ["node_modules", "dist", "src/__tests__"]
28
+ }