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,45 @@
1
+ import type { CommandHandler } from '../lib/types.js';
2
+ import type { Logger } from '../lib/logger.js';
3
+ import { isRaspberryPi, getRpiInfo, getGpuTemp, getThrottled, isSdCardReadOnly, startWatchdog, stopWatchdog } from '../lib/rpi.js';
4
+
5
+ export function registerRpiCommands(
6
+ register: (command: string, handler: CommandHandler) => void,
7
+ logger: Logger
8
+ ): void {
9
+ // rpi:info — returns model, serial, revision, gpuTemp, throttled, sdCardReadOnly
10
+ register('rpi:info', async () => {
11
+ if (!isRaspberryPi()) {
12
+ throw new Error('Not a Raspberry Pi');
13
+ }
14
+ const info = getRpiInfo();
15
+ return {
16
+ ...info,
17
+ gpuTemp: getGpuTemp(),
18
+ throttled: getThrottled(),
19
+ sdCardReadOnly: isSdCardReadOnly(),
20
+ };
21
+ });
22
+
23
+ // rpi:watchdog-start — start hardware watchdog
24
+ register('rpi:watchdog-start', async () => {
25
+ if (!isRaspberryPi()) {
26
+ throw new Error('Not a Raspberry Pi');
27
+ }
28
+ const started = startWatchdog();
29
+ if (!started) {
30
+ throw new Error('Watchdog device not available. Ensure /dev/watchdog exists and agent has permissions.');
31
+ }
32
+ logger.info('Hardware watchdog started');
33
+ return { started: true };
34
+ });
35
+
36
+ // rpi:watchdog-stop — stop hardware watchdog gracefully
37
+ register('rpi:watchdog-stop', async () => {
38
+ if (!isRaspberryPi()) {
39
+ throw new Error('Not a Raspberry Pi');
40
+ }
41
+ stopWatchdog();
42
+ logger.info('Hardware watchdog stopped');
43
+ return { stopped: true };
44
+ });
45
+ }
@@ -0,0 +1,166 @@
1
+ import { execFileSync } from 'child_process';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { z } from 'zod';
5
+ import type { CommandHandler } from '../lib/types.js';
6
+ import type { Logger } from '../lib/logger.js';
7
+ import { getPlatform } from '../lib/platform.js';
8
+
9
+ const ALLOWED_CAPTURE_TOOLS = ['scrot', 'import', 'screencapture'] as const;
10
+
11
+ // --- Zod Schema ---
12
+ const ScreenshotArgsSchema = z.object({
13
+ serverUrl: z.string().url().optional(),
14
+ deviceId: z.string().uuid().optional(),
15
+ apiKey: z.string().min(1).optional(),
16
+ quality: z.number().int().min(1).max(100).optional(),
17
+ });
18
+
19
+ export function registerScreenshotCommands(
20
+ register: (command: string, handler: CommandHandler) => void,
21
+ logger: Logger
22
+ ): void {
23
+ register('kiosk:screenshot', async (args) => {
24
+ const parsed = ScreenshotArgsSchema.safeParse(args ?? {});
25
+ if (!parsed.success) {
26
+ throw new Error(`Invalid screenshot args: ${parsed.error.issues.map((i) => i.message).join(', ')}`);
27
+ }
28
+
29
+ const { serverUrl, deviceId, apiKey, quality: parsedQuality } = parsed.data;
30
+ const quality = parsedQuality ?? 75;
31
+
32
+ const tmpFile = `/tmp/lightman-screenshot-${Date.now()}.jpg`;
33
+
34
+ try {
35
+ // --- Capture screenshot ---
36
+ const { tool, toolArgs } = resolveCapture(quality, tmpFile);
37
+ logger.info(`Capturing screenshot: ${tool} ${toolArgs.join(' ')}`);
38
+
39
+ execFileSync(tool, toolArgs, { timeout: 10_000, stdio: 'pipe' });
40
+
41
+ if (!fs.existsSync(tmpFile)) {
42
+ throw new Error('Screenshot file was not created');
43
+ }
44
+
45
+ const buffer = await fs.promises.readFile(tmpFile);
46
+ logger.info(`Screenshot captured: ${buffer.length} bytes`);
47
+
48
+ // --- Upload if server info provided ---
49
+ if (serverUrl && deviceId && apiKey) {
50
+ try {
51
+ const uploaded = await uploadScreenshot(
52
+ buffer,
53
+ tmpFile,
54
+ serverUrl,
55
+ deviceId,
56
+ apiKey,
57
+ logger
58
+ );
59
+ return { captured: true, size: buffer.length, uploaded };
60
+ } catch (uploadErr) {
61
+ const errMsg =
62
+ uploadErr instanceof Error ? uploadErr.message : String(uploadErr);
63
+ logger.error('Screenshot upload failed:', errMsg);
64
+ return { captured: true, size: buffer.length, uploaded: false, error: errMsg };
65
+ }
66
+ }
67
+
68
+ return { captured: true, size: buffer.length, uploaded: false };
69
+ } catch (err) {
70
+ const errMsg = err instanceof Error ? err.message : String(err);
71
+ logger.error('Screenshot capture failed:', errMsg);
72
+ throw new Error(`Screenshot capture failed: ${errMsg}`);
73
+ } finally {
74
+ // Clean up temp file
75
+ try {
76
+ if (fs.existsSync(tmpFile)) {
77
+ await fs.promises.unlink(tmpFile);
78
+ }
79
+ } catch {
80
+ // Ignore cleanup errors
81
+ }
82
+ }
83
+ });
84
+ }
85
+
86
+ /**
87
+ * Determine the capture tool and arguments based on platform.
88
+ * No user-controlled command strings — only allowlisted tools with safe args.
89
+ */
90
+ function resolveCapture(
91
+ quality: number,
92
+ tmpFile: string
93
+ ): { tool: string; toolArgs: string[] } {
94
+ const platform = getPlatform();
95
+
96
+ if (platform === 'linux') {
97
+ if (commandExists('scrot')) {
98
+ return { tool: 'scrot', toolArgs: ['-q', String(quality), tmpFile] };
99
+ }
100
+ if (commandExists('import')) {
101
+ return { tool: 'import', toolArgs: ['-window', 'root', '-quality', String(quality), tmpFile] };
102
+ }
103
+ throw new Error(
104
+ 'No screenshot tool available. Install scrot or ImageMagick (import).'
105
+ );
106
+ }
107
+
108
+ if (platform === 'darwin') {
109
+ return { tool: 'screencapture', toolArgs: ['-x', '-t', 'jpg', tmpFile] };
110
+ }
111
+
112
+ throw new Error(`Screenshot capture not supported on platform: ${platform}`);
113
+ }
114
+
115
+ /**
116
+ * Check if a command exists on the system using execFileSync (no shell injection).
117
+ */
118
+ function commandExists(cmd: string): boolean {
119
+ if (!ALLOWED_CAPTURE_TOOLS.includes(cmd as typeof ALLOWED_CAPTURE_TOOLS[number])) {
120
+ return false;
121
+ }
122
+ try {
123
+ execFileSync('which', [cmd], { stdio: 'pipe' });
124
+ return true;
125
+ } catch {
126
+ return false;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Upload screenshot to server via multipart/form-data.
132
+ */
133
+ async function uploadScreenshot(
134
+ buffer: Buffer,
135
+ filePath: string,
136
+ serverUrl: string,
137
+ deviceId: string,
138
+ apiKey: string,
139
+ logger: Logger
140
+ ): Promise<boolean> {
141
+ const endpoint = `/api/devices/${deviceId}/screenshot`;
142
+ const url = `${serverUrl}${endpoint}`;
143
+ const filename = path.basename(filePath);
144
+
145
+ logger.info(`Uploading screenshot to ${url}`);
146
+
147
+ const blob = new Blob([buffer], { type: 'image/jpeg' });
148
+ const formData = new FormData();
149
+ formData.append('screenshot', blob, filename);
150
+
151
+ const response = await fetch(url, {
152
+ method: 'POST',
153
+ headers: {
154
+ 'x-api-key': apiKey,
155
+ },
156
+ body: formData,
157
+ });
158
+
159
+ if (!response.ok) {
160
+ const body = await response.text().catch(() => '');
161
+ throw new Error(`Upload failed: HTTP ${response.status} ${body}`);
162
+ }
163
+
164
+ logger.info('Screenshot uploaded successfully');
165
+ return true;
166
+ }
@@ -0,0 +1,17 @@
1
+ import type { CommandHandler } from '../lib/types.js';
2
+ import type { Logger } from '../lib/logger.js';
3
+
4
+ // --- Register Serial Commands ---
5
+
6
+ export function registerSerialCommands(
7
+ register: (command: string, handler: CommandHandler) => void,
8
+ logger: Logger
9
+ ): void {
10
+ // serial:close — placeholder
11
+ register('serial:close', async (args) => {
12
+ const port = args?.port as string;
13
+ if (!port) throw new Error('Port path is required');
14
+ logger.info(`serial:close requested for ${port}`);
15
+ return { status: 'acknowledged', port };
16
+ });
17
+ }
@@ -0,0 +1,124 @@
1
+ import { z } from 'zod';
2
+ import type { Logger } from '../lib/logger.js';
3
+ import type { Updater } from '../services/updater.js';
4
+ import type { WsClient } from '../services/websocket.js';
5
+ import type { CommandHandler } from '../lib/types.js';
6
+
7
+ type RegisterFn = (name: string, handler: CommandHandler) => void;
8
+
9
+ // --- Zod Schemas ---
10
+ const UpdateArgsSchema = z.object({
11
+ url: z.string().url().refine(
12
+ (val) => {
13
+ try {
14
+ const parsed = new URL(val);
15
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:';
16
+ } catch {
17
+ return false;
18
+ }
19
+ },
20
+ { message: 'Only http/https URLs are supported' }
21
+ ),
22
+ version: z.string().min(1),
23
+ checksum: z.string().regex(/^[a-f0-9]{64}$/i, 'Invalid checksum format (expected SHA256 hex)'),
24
+ });
25
+
26
+ export function registerUpdateCommands(
27
+ register: RegisterFn,
28
+ updater: Updater,
29
+ wsClient: WsClient,
30
+ logger: Logger
31
+ ): void {
32
+ /**
33
+ * agent:update — Download, verify, install an update, then restart.
34
+ * Args: { url: string, version: string, checksum: string }
35
+ */
36
+ register('agent:update', async (args) => {
37
+ const parsed = UpdateArgsSchema.safeParse(args ?? {});
38
+ if (!parsed.success) {
39
+ const issues = parsed.error.issues;
40
+ const urlIssue = issues.find((i) => i.path.includes('url'));
41
+ const checksumIssue = issues.find((i) => i.path.includes('checksum'));
42
+
43
+ if (urlIssue && urlIssue.code === 'invalid_string') {
44
+ throw new Error('Invalid URL');
45
+ }
46
+ if (urlIssue && urlIssue.message === 'Only http/https URLs are supported') {
47
+ throw new Error('Only http/https URLs are supported');
48
+ }
49
+ if (checksumIssue) {
50
+ throw new Error('Invalid checksum format (expected SHA256 hex)');
51
+ }
52
+ throw new Error('Missing required args: url, version, checksum');
53
+ }
54
+
55
+ const { url, version, checksum } = parsed.data;
56
+
57
+ // Send status update
58
+ const sendStatus = (phase: string, detail?: Record<string, unknown>) => {
59
+ wsClient.send({
60
+ type: 'agent:update_status',
61
+ payload: { phase, version, ...detail },
62
+ timestamp: Date.now(),
63
+ });
64
+ };
65
+
66
+ try {
67
+ sendStatus('downloading');
68
+ const filePath = await updater.download(url);
69
+
70
+ sendStatus('verifying');
71
+ const valid = await updater.verify(filePath, checksum);
72
+ if (!valid) {
73
+ sendStatus('error', { error: 'Checksum verification failed' });
74
+ throw new Error('Checksum verification failed');
75
+ }
76
+
77
+ sendStatus('installing');
78
+ await updater.install(filePath, version);
79
+
80
+ // Clean old downloads
81
+ updater.cleanDownloads();
82
+
83
+ sendStatus('restarting');
84
+ logger.info(`Update to v${version} complete. Restarting...`);
85
+
86
+ // Delay restart to allow result to be sent
87
+ setTimeout(() => process.exit(0), 2000);
88
+
89
+ return { success: true, version, restarting: true };
90
+ } catch (err) {
91
+ const message = err instanceof Error ? err.message : String(err);
92
+ sendStatus('error', { error: message });
93
+ logger.error(`Update failed: ${message}`);
94
+ throw new Error(`Update failed: ${message}`);
95
+ }
96
+ });
97
+
98
+ /**
99
+ * agent:rollback — Rollback to the previous backup version.
100
+ */
101
+ register('agent:rollback', async () => {
102
+ try {
103
+ await updater.rollback();
104
+ logger.info('Rollback complete. Restarting...');
105
+
106
+ // Delay restart to allow result to be sent
107
+ setTimeout(() => process.exit(0), 2000);
108
+
109
+ return { success: true, restarting: true };
110
+ } catch (err) {
111
+ const message = err instanceof Error ? err.message : String(err);
112
+ logger.error(`Rollback failed: ${message}`);
113
+ throw new Error(`Rollback failed: ${message}`);
114
+ }
115
+ });
116
+
117
+ /**
118
+ * agent:update-status — Get current update status.
119
+ */
120
+ register('agent:update-status', async () => {
121
+ const status = updater.getStatus();
122
+ return { ...status } as Record<string, unknown>;
123
+ });
124
+ }