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.
- package/agent.config.template.json +30 -0
- package/bin/cms-agent.js +233 -0
- package/nssm/nssm.exe +0 -0
- package/package.json +52 -0
- package/public/assets/index-CcBNCz6h.css +1 -0
- package/public/assets/index-H-8HDl46.js +1 -0
- package/public/index.html +19 -0
- package/scripts/guardian.ps1 +75 -0
- package/scripts/install-linux.sh +134 -0
- package/scripts/install-rpi.sh +117 -0
- package/scripts/install-windows.ps1 +529 -0
- package/scripts/launch-kiosk.vbs +101 -0
- package/scripts/lightman-agent.logrotate +12 -0
- package/scripts/lightman-agent.service +38 -0
- package/scripts/lightman-shell.bat +128 -0
- package/scripts/reinstall-windows.ps1 +26 -0
- package/scripts/restore-desktop.ps1 +32 -0
- package/scripts/setup.ps1 +116 -0
- package/scripts/setup.sh +115 -0
- package/scripts/uninstall-linux.sh +50 -0
- package/scripts/uninstall-windows.ps1 +54 -0
- package/src/commands/display.ts +177 -0
- package/src/commands/kiosk.ts +113 -0
- package/src/commands/maintenance.ts +106 -0
- package/src/commands/network.ts +129 -0
- package/src/commands/power.ts +163 -0
- package/src/commands/rpi.ts +45 -0
- package/src/commands/screenshot.ts +166 -0
- package/src/commands/serial.ts +17 -0
- package/src/commands/update.ts +124 -0
- package/src/index.ts +652 -0
- package/src/lib/config.ts +69 -0
- package/src/lib/identity.ts +40 -0
- package/src/lib/logger.ts +137 -0
- package/src/lib/platform.ts +10 -0
- package/src/lib/rpi.ts +180 -0
- package/src/lib/screens.ts +128 -0
- package/src/lib/types.ts +176 -0
- package/src/services/commands.ts +107 -0
- package/src/services/health.ts +161 -0
- package/src/services/kiosk.ts +395 -0
- package/src/services/localEvents.ts +60 -0
- package/src/services/logForwarder.ts +72 -0
- package/src/services/multiScreenKiosk.ts +324 -0
- package/src/services/oscBridge.ts +186 -0
- package/src/services/powerScheduler.ts +260 -0
- package/src/services/provisioning.ts +120 -0
- package/src/services/serialBridge.ts +230 -0
- package/src/services/serviceLauncher.ts +183 -0
- package/src/services/staticServer.ts +226 -0
- package/src/services/updater.ts +249 -0
- package/src/services/watchdog.ts +310 -0
- package/src/services/websocket.ts +152 -0
- package/tsconfig.json +28 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { execFile, spawn } from 'child_process';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import type { CommandHandler } from '../lib/types.js';
|
|
4
|
+
import { getPlatform } from '../lib/platform.js';
|
|
5
|
+
import type { Logger } from '../lib/logger.js';
|
|
6
|
+
|
|
7
|
+
// --- Zod Schemas ---
|
|
8
|
+
const BrightnessArgsSchema = z.object({
|
|
9
|
+
level: z.number().int().min(0).max(100),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const PowerArgsSchema = z.object({
|
|
13
|
+
state: z.enum(['on', 'off', 'standby']),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const RotateArgsSchema = z.object({
|
|
17
|
+
rotation: z.enum(['normal', 'left', 'right', 'inverted']),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const VolumeArgsSchema = z.object({
|
|
21
|
+
level: z.number().int().min(0).max(100),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function execFilePromise(cmd: string, args: string[]): Promise<string> {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
execFile(cmd, args, (err, stdout, stderr) => {
|
|
27
|
+
if (err) {
|
|
28
|
+
reject(new Error(stderr || err.message));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
resolve(stdout.trim());
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Pipe data to a command via stdin using spawn (no shell).
|
|
38
|
+
*/
|
|
39
|
+
function spawnWithStdin(cmd: string, args: string[], stdinData: string): Promise<string> {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
42
|
+
let stdout = '';
|
|
43
|
+
let stderr = '';
|
|
44
|
+
|
|
45
|
+
child.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
|
|
46
|
+
child.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
|
|
47
|
+
|
|
48
|
+
child.on('error', (err) => reject(new Error(err.message)));
|
|
49
|
+
child.on('close', (code) => {
|
|
50
|
+
if (code !== 0) {
|
|
51
|
+
reject(new Error(stderr || `Process exited with code ${code}`));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
resolve(stdout.trim());
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
child.stdin.write(stdinData);
|
|
58
|
+
child.stdin.end();
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Detect the primary connected display output name from xrandr.
|
|
64
|
+
*/
|
|
65
|
+
async function getConnectedDisplay(): Promise<string> {
|
|
66
|
+
const output = await execFilePromise('xrandr', []);
|
|
67
|
+
const match = output.match(/^(\S+)\s+connected/m);
|
|
68
|
+
if (!match) {
|
|
69
|
+
throw new Error('No connected display found');
|
|
70
|
+
}
|
|
71
|
+
return match[1];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function registerDisplayCommands(
|
|
75
|
+
register: (command: string, handler: CommandHandler) => void,
|
|
76
|
+
logger: Logger
|
|
77
|
+
): void {
|
|
78
|
+
// display:brightness — set screen brightness (Linux only)
|
|
79
|
+
register('display:brightness', async (args) => {
|
|
80
|
+
if (getPlatform() !== 'linux') {
|
|
81
|
+
throw new Error('Not supported on this platform');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const parsed = BrightnessArgsSchema.safeParse(args ?? {});
|
|
85
|
+
if (!parsed.success) {
|
|
86
|
+
throw new Error('Invalid brightness level, must be 0-100');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const { level } = parsed.data;
|
|
90
|
+
const brightness = (level / 100).toFixed(2);
|
|
91
|
+
|
|
92
|
+
logger.info(`Setting brightness to ${level}%`);
|
|
93
|
+
|
|
94
|
+
const display = await getConnectedDisplay();
|
|
95
|
+
await execFilePromise('xrandr', ['--output', display, '--brightness', brightness]);
|
|
96
|
+
return { level };
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// display:power — control display power via CEC (Linux only)
|
|
100
|
+
register('display:power', async (args) => {
|
|
101
|
+
if (getPlatform() !== 'linux') {
|
|
102
|
+
throw new Error('Not supported on this platform');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const parsed = PowerArgsSchema.safeParse(args ?? {});
|
|
106
|
+
if (!parsed.success) {
|
|
107
|
+
throw new Error('Invalid state, must be on | off | standby');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const { state } = parsed.data;
|
|
111
|
+
|
|
112
|
+
const cecCommands: Readonly<Record<string, string>> = {
|
|
113
|
+
on: 'on 0',
|
|
114
|
+
off: 'standby 0',
|
|
115
|
+
standby: 'standby 0',
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const cecCmd = cecCommands[state];
|
|
119
|
+
|
|
120
|
+
logger.info(`Setting display power to ${state}`);
|
|
121
|
+
|
|
122
|
+
// Pipe CEC command via spawn stdin (no shell)
|
|
123
|
+
await spawnWithStdin('cec-client', ['-s', '-d', '1'], cecCmd + '\n');
|
|
124
|
+
return { state };
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// display:rotate — rotate display output (Linux only)
|
|
128
|
+
register('display:rotate', async (args) => {
|
|
129
|
+
if (getPlatform() !== 'linux') {
|
|
130
|
+
throw new Error('Not supported on this platform');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const parsed = RotateArgsSchema.safeParse(args ?? {});
|
|
134
|
+
if (!parsed.success) {
|
|
135
|
+
throw new Error('Invalid rotation, must be normal | left | right | inverted');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const { rotation } = parsed.data;
|
|
139
|
+
|
|
140
|
+
logger.info(`Setting display rotation to ${rotation}`);
|
|
141
|
+
|
|
142
|
+
const display = await getConnectedDisplay();
|
|
143
|
+
await execFilePromise('xrandr', ['--output', display, '--rotate', rotation]);
|
|
144
|
+
return { rotation };
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// display:volume — set audio volume (Linux only)
|
|
148
|
+
register('display:volume', async (args) => {
|
|
149
|
+
if (getPlatform() !== 'linux') {
|
|
150
|
+
throw new Error('Not supported on this platform');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const parsed = VolumeArgsSchema.safeParse(args ?? {});
|
|
154
|
+
if (!parsed.success) {
|
|
155
|
+
throw new Error('Invalid volume level, must be 0-100');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const { level } = parsed.data;
|
|
159
|
+
|
|
160
|
+
logger.info(`Setting volume to ${level}%`);
|
|
161
|
+
|
|
162
|
+
await execFilePromise('amixer', ['set', 'Master', `${level}%`]);
|
|
163
|
+
return { level };
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// display:info — query display information (Linux with fallback)
|
|
167
|
+
register('display:info', async () => {
|
|
168
|
+
if (getPlatform() !== 'linux') {
|
|
169
|
+
throw new Error('Not supported on this platform');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
logger.info('Querying display info');
|
|
173
|
+
|
|
174
|
+
const stdout = await execFilePromise('xrandr', ['--query']);
|
|
175
|
+
return { raw: stdout };
|
|
176
|
+
});
|
|
177
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { CommandHandler, KioskStatus } from '../lib/types.js';
|
|
3
|
+
import type { KioskManager } from '../services/kiosk.js';
|
|
4
|
+
import type { MultiScreenKioskManager } from '../services/multiScreenKiosk.js';
|
|
5
|
+
import type { Logger } from '../lib/logger.js';
|
|
6
|
+
|
|
7
|
+
// --- Zod Schemas ---
|
|
8
|
+
const HttpUrlSchema = z.string().url().refine(
|
|
9
|
+
(val) => {
|
|
10
|
+
try {
|
|
11
|
+
const parsed = new URL(val);
|
|
12
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
{ message: 'Must be a valid http/https URL' }
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const LaunchArgsSchema = z.object({
|
|
21
|
+
url: HttpUrlSchema.optional(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const NavigateArgsSchema = z.object({
|
|
25
|
+
url: HttpUrlSchema,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export function registerKioskCommands(
|
|
29
|
+
register: (command: string, handler: CommandHandler) => void,
|
|
30
|
+
kioskManager: KioskManager,
|
|
31
|
+
logger: Logger
|
|
32
|
+
): void {
|
|
33
|
+
register('kiosk:launch', async (args) => {
|
|
34
|
+
const parsed = LaunchArgsSchema.safeParse(args ?? {});
|
|
35
|
+
if (!parsed.success) {
|
|
36
|
+
throw new Error('kiosk:launch requires a valid http/https URL');
|
|
37
|
+
}
|
|
38
|
+
const { url } = parsed.data;
|
|
39
|
+
logger.info(`kiosk:launch${url ? ` → ${url}` : ' (default URL)'}`);
|
|
40
|
+
const status: KioskStatus = await kioskManager.launch(url);
|
|
41
|
+
return status as unknown as Record<string, unknown>;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
register('kiosk:kill', async () => {
|
|
45
|
+
logger.info('kiosk:kill');
|
|
46
|
+
await kioskManager.kill();
|
|
47
|
+
return { killed: true };
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
register('kiosk:navigate', async (args) => {
|
|
51
|
+
const parsed = NavigateArgsSchema.safeParse(args ?? {});
|
|
52
|
+
if (!parsed.success) {
|
|
53
|
+
const hasUrl = typeof args?.url === 'string' && args.url.length > 0;
|
|
54
|
+
if (!hasUrl) {
|
|
55
|
+
throw new Error('kiosk:navigate requires args.url');
|
|
56
|
+
}
|
|
57
|
+
throw new Error('kiosk:navigate requires a valid http/https URL');
|
|
58
|
+
}
|
|
59
|
+
const { url } = parsed.data;
|
|
60
|
+
logger.info(`kiosk:navigate → ${url}`);
|
|
61
|
+
await kioskManager.navigate(url);
|
|
62
|
+
return { navigated: true, url };
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
register('kiosk:restart', async () => {
|
|
66
|
+
logger.info('kiosk:restart');
|
|
67
|
+
const status: KioskStatus = await kioskManager.restart();
|
|
68
|
+
return status as unknown as Record<string, unknown>;
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
register('kiosk:status', async () => {
|
|
72
|
+
const status: KioskStatus = kioskManager.getStatus();
|
|
73
|
+
return status as unknown as Record<string, unknown>;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Register multi-screen kiosk commands.
|
|
79
|
+
* These allow the server to manage individual screens on multi-display devices.
|
|
80
|
+
*/
|
|
81
|
+
export function registerMultiScreenKioskCommands(
|
|
82
|
+
register: (command: string, handler: CommandHandler) => void,
|
|
83
|
+
multiKiosk: MultiScreenKioskManager,
|
|
84
|
+
getIdentity: () => { deviceId: string; apiKey: string },
|
|
85
|
+
logger: Logger
|
|
86
|
+
): void {
|
|
87
|
+
register('kiosk:multi:status', async () => {
|
|
88
|
+
const status = multiKiosk.getStatus();
|
|
89
|
+
return status as unknown as Record<string, unknown>;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
register('kiosk:multi:navigate', async (args) => {
|
|
93
|
+
const hardwareId = args?.hardwareId as string;
|
|
94
|
+
const url = args?.url as string;
|
|
95
|
+
if (!hardwareId) throw new Error('kiosk:multi:navigate requires args.hardwareId');
|
|
96
|
+
if (!url) throw new Error('kiosk:multi:navigate requires args.url');
|
|
97
|
+
logger.info(`kiosk:multi:navigate ${hardwareId} → ${url}`);
|
|
98
|
+
await multiKiosk.navigateScreen(hardwareId, url, getIdentity());
|
|
99
|
+
return { navigated: true, hardwareId, url };
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
register('kiosk:multi:restart', async () => {
|
|
103
|
+
logger.info('kiosk:multi:restart');
|
|
104
|
+
const status = await multiKiosk.restartAll(getIdentity());
|
|
105
|
+
return status as unknown as Record<string, unknown>;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
register('kiosk:multi:kill', async () => {
|
|
109
|
+
logger.info('kiosk:multi:kill');
|
|
110
|
+
await multiKiosk.killAll();
|
|
111
|
+
return { killed: true };
|
|
112
|
+
});
|
|
113
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import { readFileSync, writeFileSync } from 'fs';
|
|
3
|
+
import { resolve } from 'path';
|
|
4
|
+
import type { CommandHandler } from '../lib/types.js';
|
|
5
|
+
import type { Logger } from '../lib/logger.js';
|
|
6
|
+
import type { Watchdog } from '../services/watchdog.js';
|
|
7
|
+
|
|
8
|
+
export function registerMaintenanceCommands(
|
|
9
|
+
register: (command: string, handler: CommandHandler) => void,
|
|
10
|
+
watchdog: Watchdog,
|
|
11
|
+
logger: Logger
|
|
12
|
+
): void {
|
|
13
|
+
// maintenance:cleanup — Run disk cleanup immediately
|
|
14
|
+
register('maintenance:cleanup', async () => {
|
|
15
|
+
logger.info('Manual disk cleanup requested');
|
|
16
|
+
const result = await watchdog.runDiskCleanup();
|
|
17
|
+
return { ...result };
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// maintenance:status — Get watchdog recovery stats
|
|
21
|
+
register('maintenance:status', async () => {
|
|
22
|
+
const stats = watchdog.getStats();
|
|
23
|
+
const cooldowns = watchdog.getCooldowns();
|
|
24
|
+
return { stats, cooldowns };
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// maintenance:restore-desktop — Switch from shell replacement back to normal desktop
|
|
28
|
+
// After this command, machine needs a reboot to take effect.
|
|
29
|
+
register('maintenance:restore-desktop', async () => {
|
|
30
|
+
if (process.platform !== 'win32') {
|
|
31
|
+
return { restored: false, message: 'Only available on Windows' };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
logger.warn('RESTORE DESKTOP: Switching Windows shell back to explorer.exe');
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
// Restore HKLM shell to explorer.exe
|
|
38
|
+
execSync(
|
|
39
|
+
'reg add "HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon" /v Shell /d explorer.exe /f',
|
|
40
|
+
{ stdio: 'ignore', timeout: 10_000 }
|
|
41
|
+
);
|
|
42
|
+
// Remove HKCU shell override
|
|
43
|
+
execSync(
|
|
44
|
+
'reg delete "HKCU\\Software\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon" /v Shell /f',
|
|
45
|
+
{ stdio: 'ignore', timeout: 10_000 }
|
|
46
|
+
);
|
|
47
|
+
} catch {
|
|
48
|
+
// HKCU key may not exist, that's fine
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Also set shellMode: false in the config so agent resumes normal kiosk management
|
|
52
|
+
try {
|
|
53
|
+
const configPath = resolve(process.cwd(), 'agent.config.json');
|
|
54
|
+
const raw = readFileSync(configPath, 'utf-8');
|
|
55
|
+
const config = JSON.parse(raw);
|
|
56
|
+
if (config.kiosk) {
|
|
57
|
+
config.kiosk.shellMode = false;
|
|
58
|
+
}
|
|
59
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
60
|
+
logger.info('Config updated: shellMode set to false');
|
|
61
|
+
} catch (err) {
|
|
62
|
+
logger.error('Failed to update config:', err instanceof Error ? err.message : String(err));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
logger.warn('Desktop restored. Machine needs a REBOOT to take effect.');
|
|
66
|
+
return { restored: true, message: 'Desktop restored. Reboot required.' };
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// maintenance:enable-shell — Switch from normal desktop to shell replacement mode
|
|
70
|
+
// After this command, machine needs a reboot to take effect.
|
|
71
|
+
register('maintenance:enable-shell', async () => {
|
|
72
|
+
if (process.platform !== 'win32') {
|
|
73
|
+
return { enabled: false, message: 'Only available on Windows' };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const shellBat = resolve(process.cwd(), 'lightman-shell.bat');
|
|
77
|
+
logger.warn(`ENABLE SHELL: Switching Windows shell to ${shellBat}`);
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
// Set shell to lightman-shell.bat
|
|
81
|
+
execSync(
|
|
82
|
+
`reg add "HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon" /v Shell /d "\\"${shellBat}\\"" /f`,
|
|
83
|
+
{ stdio: 'ignore', timeout: 10_000 }
|
|
84
|
+
);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
return { enabled: false, message: `Registry update failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Update config to shellMode: true
|
|
90
|
+
try {
|
|
91
|
+
const configPath = resolve(process.cwd(), 'agent.config.json');
|
|
92
|
+
const raw = readFileSync(configPath, 'utf-8');
|
|
93
|
+
const config = JSON.parse(raw);
|
|
94
|
+
if (config.kiosk) {
|
|
95
|
+
config.kiosk.shellMode = true;
|
|
96
|
+
}
|
|
97
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
98
|
+
logger.info('Config updated: shellMode set to true');
|
|
99
|
+
} catch (err) {
|
|
100
|
+
logger.error('Failed to update config:', err instanceof Error ? err.message : String(err));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
logger.warn('Shell mode enabled. Machine needs a REBOOT to take effect.');
|
|
104
|
+
return { enabled: true, message: 'Shell mode enabled. Reboot required.' };
|
|
105
|
+
});
|
|
106
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import net from 'net';
|
|
2
|
+
import dns from 'dns';
|
|
3
|
+
import { URL } from 'url';
|
|
4
|
+
import si from 'systeminformation';
|
|
5
|
+
import type { CommandHandler } from '../lib/types.js';
|
|
6
|
+
import type { Logger } from '../lib/logger.js';
|
|
7
|
+
|
|
8
|
+
export function registerNetworkCommands(
|
|
9
|
+
register: (command: string, handler: CommandHandler) => void,
|
|
10
|
+
logger: Logger,
|
|
11
|
+
serverUrl: string
|
|
12
|
+
): void {
|
|
13
|
+
// network:ping — TCP connect to CMS server, measure round-trip
|
|
14
|
+
register('network:ping', async () => {
|
|
15
|
+
const url = new URL(serverUrl);
|
|
16
|
+
const host = url.hostname;
|
|
17
|
+
const port = parseInt(url.port, 10) || 3001;
|
|
18
|
+
|
|
19
|
+
const start = Date.now();
|
|
20
|
+
const reachable = await tcpPing(host, port, 5000);
|
|
21
|
+
const latencyMs = Date.now() - start;
|
|
22
|
+
|
|
23
|
+
logger.info(`Network ping: ${host}:${port} → ${reachable ? 'OK' : 'FAIL'} (${latencyMs}ms)`);
|
|
24
|
+
return { latency_ms: latencyMs, reachable, host, port };
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// network:bandwidth — Download 1MB test file, measure speed
|
|
28
|
+
register('network:bandwidth', async () => {
|
|
29
|
+
const url = `${serverUrl}/api/agent/bandwidth-test`;
|
|
30
|
+
logger.info(`Bandwidth test: downloading from ${url}`);
|
|
31
|
+
|
|
32
|
+
const start = Date.now();
|
|
33
|
+
try {
|
|
34
|
+
const response = await fetch(url);
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
throw new Error(`HTTP ${response.status}`);
|
|
37
|
+
}
|
|
38
|
+
const buffer = await response.arrayBuffer();
|
|
39
|
+
const durationMs = Date.now() - start;
|
|
40
|
+
const sizeMb = buffer.byteLength / (1024 * 1024);
|
|
41
|
+
const speedMbps = (sizeMb * 8) / (durationMs / 1000);
|
|
42
|
+
|
|
43
|
+
logger.info(`Bandwidth: ${speedMbps.toFixed(2)} Mbps (${buffer.byteLength} bytes in ${durationMs}ms)`);
|
|
44
|
+
return {
|
|
45
|
+
speed_mbps: Math.round(speedMbps * 100) / 100,
|
|
46
|
+
duration_ms: durationMs,
|
|
47
|
+
bytes: buffer.byteLength,
|
|
48
|
+
};
|
|
49
|
+
} catch (err) {
|
|
50
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
51
|
+
logger.error('Bandwidth test failed:', message);
|
|
52
|
+
throw new Error(`Bandwidth test failed: ${message}`);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// network:dns — Resolve CMS hostname
|
|
57
|
+
register('network:dns', async () => {
|
|
58
|
+
const url = new URL(serverUrl);
|
|
59
|
+
const hostname = url.hostname;
|
|
60
|
+
|
|
61
|
+
logger.info(`DNS resolve: ${hostname}`);
|
|
62
|
+
const start = Date.now();
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const addresses = await new Promise<string[]>((resolve, reject) => {
|
|
66
|
+
dns.resolve(hostname, (err, addrs) => {
|
|
67
|
+
if (err) reject(err);
|
|
68
|
+
else resolve(addrs);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
const timeMs = Date.now() - start;
|
|
72
|
+
|
|
73
|
+
logger.info(`DNS resolved: ${hostname} → ${addresses.join(', ')} (${timeMs}ms)`);
|
|
74
|
+
return { resolved: true, addresses, time_ms: timeMs, hostname };
|
|
75
|
+
} catch (err) {
|
|
76
|
+
const timeMs = Date.now() - start;
|
|
77
|
+
// If it's an IP address, DNS resolve will fail — that's OK
|
|
78
|
+
if (net.isIP(hostname)) {
|
|
79
|
+
return { resolved: true, addresses: [hostname], time_ms: timeMs, hostname, note: 'IP address (no DNS needed)' };
|
|
80
|
+
}
|
|
81
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
82
|
+
logger.error('DNS resolve failed:', message);
|
|
83
|
+
return { resolved: false, addresses: [], time_ms: timeMs, hostname, error: message };
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// network:interfaces — List network interfaces
|
|
88
|
+
register('network:interfaces', async () => {
|
|
89
|
+
const ifaces = await si.networkInterfaces();
|
|
90
|
+
const ifaceList = Array.isArray(ifaces) ? ifaces : [ifaces];
|
|
91
|
+
|
|
92
|
+
const interfaces = ifaceList
|
|
93
|
+
.filter((iface) => !iface.internal && iface.ip4)
|
|
94
|
+
.map((iface) => ({
|
|
95
|
+
name: iface.iface,
|
|
96
|
+
ip4: iface.ip4,
|
|
97
|
+
mac: iface.mac,
|
|
98
|
+
speed: iface.speed,
|
|
99
|
+
type: iface.type,
|
|
100
|
+
}));
|
|
101
|
+
|
|
102
|
+
logger.info(`Network interfaces: ${interfaces.length} found`);
|
|
103
|
+
return { interfaces };
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* TCP ping: attempt a TCP connection and measure latency.
|
|
109
|
+
* Returns true if connection succeeds within timeout.
|
|
110
|
+
*/
|
|
111
|
+
function tcpPing(host: string, port: number, timeoutMs: number): Promise<boolean> {
|
|
112
|
+
return new Promise((resolve) => {
|
|
113
|
+
const socket = new net.Socket();
|
|
114
|
+
let resolved = false;
|
|
115
|
+
|
|
116
|
+
const done = (result: boolean) => {
|
|
117
|
+
if (resolved) return;
|
|
118
|
+
resolved = true;
|
|
119
|
+
socket.destroy();
|
|
120
|
+
resolve(result);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
socket.setTimeout(timeoutMs);
|
|
124
|
+
socket.on('connect', () => done(true));
|
|
125
|
+
socket.on('timeout', () => done(false));
|
|
126
|
+
socket.on('error', () => done(false));
|
|
127
|
+
socket.connect(port, host);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { execFile } from 'child_process';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import type { CommandHandler } from '../lib/types.js';
|
|
4
|
+
import { getPlatform } from '../lib/platform.js';
|
|
5
|
+
import type { Logger } from '../lib/logger.js';
|
|
6
|
+
|
|
7
|
+
// --- Zod Schemas ---
|
|
8
|
+
const ShutdownDelayedArgsSchema = z.object({
|
|
9
|
+
delayMs: z.number().finite().optional(),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
let pendingShutdown: ReturnType<typeof setTimeout> | null = null;
|
|
13
|
+
|
|
14
|
+
interface ExecCommand {
|
|
15
|
+
readonly bin: string;
|
|
16
|
+
readonly args: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function execCommand(cmd: ExecCommand, logger: Logger): void {
|
|
20
|
+
execFile(cmd.bin, [...cmd.args], (err, stdout, stderr) => {
|
|
21
|
+
if (err) {
|
|
22
|
+
logger.error(`Exec failed: ${cmd.bin}`, err.message);
|
|
23
|
+
}
|
|
24
|
+
if (stderr) {
|
|
25
|
+
logger.warn(`Exec stderr: ${cmd.bin}`, stderr);
|
|
26
|
+
}
|
|
27
|
+
if (stdout) {
|
|
28
|
+
logger.debug(`Exec stdout: ${cmd.bin}`, stdout);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getShutdownCommand(): ExecCommand {
|
|
34
|
+
const platform = getPlatform();
|
|
35
|
+
switch (platform) {
|
|
36
|
+
case 'windows':
|
|
37
|
+
return { bin: 'shutdown', args: ['/s', '/t', '0'] };
|
|
38
|
+
case 'darwin':
|
|
39
|
+
case 'linux':
|
|
40
|
+
default:
|
|
41
|
+
return { bin: 'shutdown', args: ['-h', 'now'] };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getRebootCommand(): ExecCommand {
|
|
46
|
+
const platform = getPlatform();
|
|
47
|
+
switch (platform) {
|
|
48
|
+
case 'windows':
|
|
49
|
+
return { bin: 'shutdown', args: ['/r', '/t', '0'] };
|
|
50
|
+
case 'darwin':
|
|
51
|
+
case 'linux':
|
|
52
|
+
default:
|
|
53
|
+
return { bin: 'shutdown', args: ['-r', 'now'] };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getSuspendCommand(): ExecCommand {
|
|
58
|
+
const platform = getPlatform();
|
|
59
|
+
switch (platform) {
|
|
60
|
+
case 'linux':
|
|
61
|
+
return { bin: 'systemctl', args: ['suspend'] };
|
|
62
|
+
case 'windows':
|
|
63
|
+
return { bin: 'rundll32.exe', args: ['powrprof.dll,SetSuspendState', '0,1,0'] };
|
|
64
|
+
case 'darwin':
|
|
65
|
+
return { bin: 'pmset', args: ['sleepnow'] };
|
|
66
|
+
default:
|
|
67
|
+
return { bin: 'systemctl', args: ['suspend'] };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function registerPowerCommands(
|
|
72
|
+
register: (command: string, handler: CommandHandler) => void,
|
|
73
|
+
logger: Logger
|
|
74
|
+
): void {
|
|
75
|
+
// system:shutdown — graceful shutdown with 5s delay
|
|
76
|
+
register('system:shutdown', async () => {
|
|
77
|
+
const cmd = getShutdownCommand();
|
|
78
|
+
logger.info(`Scheduling shutdown in 5000ms: ${cmd}`);
|
|
79
|
+
|
|
80
|
+
setTimeout(() => {
|
|
81
|
+
try {
|
|
82
|
+
execCommand(cmd, logger);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
logger.error('Shutdown exec error', err instanceof Error ? err.message : String(err));
|
|
85
|
+
}
|
|
86
|
+
}, 5000);
|
|
87
|
+
|
|
88
|
+
return { delayed: true, delayMs: 5000 };
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// system:reboot — graceful reboot with 5s delay
|
|
92
|
+
register('system:reboot', async () => {
|
|
93
|
+
const cmd = getRebootCommand();
|
|
94
|
+
logger.info(`Scheduling reboot in 5000ms: ${cmd}`);
|
|
95
|
+
|
|
96
|
+
setTimeout(() => {
|
|
97
|
+
try {
|
|
98
|
+
execCommand(cmd, logger);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
logger.error('Reboot exec error', err instanceof Error ? err.message : String(err));
|
|
101
|
+
}
|
|
102
|
+
}, 5000);
|
|
103
|
+
|
|
104
|
+
return { delayed: true, delayMs: 5000 };
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// system:suspend — immediate suspend
|
|
108
|
+
register('system:suspend', async () => {
|
|
109
|
+
const cmd = getSuspendCommand();
|
|
110
|
+
logger.info(`Executing suspend: ${cmd}`);
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
execCommand(cmd, logger);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
logger.error('Suspend exec error', err instanceof Error ? err.message : String(err));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { suspended: true };
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// system:shutdown-delayed — shutdown after configurable delay (5s to 24h)
|
|
122
|
+
register('system:shutdown-delayed', async (args) => {
|
|
123
|
+
const parsed = ShutdownDelayedArgsSchema.safeParse(args ?? {});
|
|
124
|
+
if (!parsed.success) {
|
|
125
|
+
throw new Error(`Invalid args: ${parsed.error.issues.map((i) => i.message).join(', ')}`);
|
|
126
|
+
}
|
|
127
|
+
const rawDelay = parsed.data.delayMs ?? 60000;
|
|
128
|
+
const delayMs = Math.max(5000, Math.min(rawDelay, 86_400_000));
|
|
129
|
+
const cmd = getShutdownCommand();
|
|
130
|
+
|
|
131
|
+
logger.info(`Scheduling delayed shutdown in ${delayMs}ms: ${cmd}`);
|
|
132
|
+
|
|
133
|
+
// Clear any existing pending shutdown
|
|
134
|
+
if (pendingShutdown !== null) {
|
|
135
|
+
clearTimeout(pendingShutdown);
|
|
136
|
+
logger.info('Cleared previous pending shutdown');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
pendingShutdown = setTimeout(() => {
|
|
140
|
+
try {
|
|
141
|
+
execCommand(cmd, logger);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
logger.error('Delayed shutdown exec error', err instanceof Error ? err.message : String(err));
|
|
144
|
+
}
|
|
145
|
+
pendingShutdown = null;
|
|
146
|
+
}, delayMs);
|
|
147
|
+
|
|
148
|
+
return { delayed: true, delayMs };
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// system:cancel-shutdown — cancel a pending delayed shutdown
|
|
152
|
+
register('system:cancel-shutdown', async () => {
|
|
153
|
+
if (pendingShutdown !== null) {
|
|
154
|
+
clearTimeout(pendingShutdown);
|
|
155
|
+
pendingShutdown = null;
|
|
156
|
+
logger.info('Pending shutdown cancelled');
|
|
157
|
+
return { cancelled: true };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
logger.info('No pending shutdown to cancel');
|
|
161
|
+
return { cancelled: false, reason: 'no pending shutdown' };
|
|
162
|
+
});
|
|
163
|
+
}
|