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,183 @@
|
|
|
1
|
+
import { spawn, execSync } from 'child_process';
|
|
2
|
+
import type { ChildProcess } from 'child_process';
|
|
3
|
+
import { resolve } from 'path';
|
|
4
|
+
import type { Logger } from '../lib/logger.js';
|
|
5
|
+
|
|
6
|
+
interface ManagedService {
|
|
7
|
+
name: string;
|
|
8
|
+
cwd: string;
|
|
9
|
+
command: string;
|
|
10
|
+
args: string[];
|
|
11
|
+
port: number;
|
|
12
|
+
process: ChildProcess | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class ServiceLauncher {
|
|
16
|
+
private services: ManagedService[] = [];
|
|
17
|
+
private logger: Logger;
|
|
18
|
+
private projectRoot: string;
|
|
19
|
+
|
|
20
|
+
constructor(logger: Logger, projectRoot: string) {
|
|
21
|
+
this.logger = logger;
|
|
22
|
+
this.projectRoot = projectRoot;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Kill any existing processes on managed ports, then start fresh.
|
|
27
|
+
*/
|
|
28
|
+
async startAll(): Promise<void> {
|
|
29
|
+
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
30
|
+
|
|
31
|
+
this.services = [
|
|
32
|
+
{
|
|
33
|
+
name: 'server',
|
|
34
|
+
cwd: resolve(this.projectRoot, 'server'),
|
|
35
|
+
command: npmCmd,
|
|
36
|
+
args: ['run', 'dev'],
|
|
37
|
+
port: 3401,
|
|
38
|
+
process: null,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'display',
|
|
42
|
+
cwd: resolve(this.projectRoot, 'display'),
|
|
43
|
+
command: npmCmd,
|
|
44
|
+
args: ['run', 'dev'],
|
|
45
|
+
port: 3403,
|
|
46
|
+
process: null,
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
// Kill anything already on these ports for a clean start
|
|
51
|
+
for (const svc of this.services) {
|
|
52
|
+
await this.killProcessOnPort(svc.port);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Small delay to let ports free up
|
|
56
|
+
await new Promise((r) => setTimeout(r, 1_000));
|
|
57
|
+
|
|
58
|
+
for (const svc of this.services) {
|
|
59
|
+
this.logger.info(`Starting ${svc.name} (port ${svc.port})...`);
|
|
60
|
+
svc.process = spawn(svc.command, svc.args, {
|
|
61
|
+
cwd: svc.cwd,
|
|
62
|
+
stdio: 'pipe',
|
|
63
|
+
detached: false,
|
|
64
|
+
shell: true,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
svc.process.stdout?.on('data', (data: Buffer) => {
|
|
68
|
+
const lines = data.toString().trim();
|
|
69
|
+
if (lines) this.logger.debug(`[${svc.name}] ${lines}`);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
svc.process.stderr?.on('data', (data: Buffer) => {
|
|
73
|
+
const lines = data.toString().trim();
|
|
74
|
+
if (lines) this.logger.debug(`[${svc.name}:err] ${lines}`);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
svc.process.on('exit', (code) => {
|
|
78
|
+
this.logger.warn(`${svc.name} exited with code ${code}`);
|
|
79
|
+
svc.process = null;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
svc.process.on('error', (err) => {
|
|
83
|
+
this.logger.error(`${svc.name} spawn error: ${err.message}`);
|
|
84
|
+
svc.process = null;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Wait for each service to become reachable
|
|
89
|
+
for (const svc of this.services) {
|
|
90
|
+
await this.waitForPort(svc.name, svc.port, 60_000);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
this.logger.info('All services started successfully');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Stop all managed services.
|
|
98
|
+
*/
|
|
99
|
+
stopAll(): void {
|
|
100
|
+
for (const svc of this.services) {
|
|
101
|
+
if (svc.process) {
|
|
102
|
+
this.logger.info(`Stopping ${svc.name} (pid ${svc.process.pid})...`);
|
|
103
|
+
try {
|
|
104
|
+
if (process.platform === 'win32') {
|
|
105
|
+
spawn('taskkill', ['/pid', String(svc.process.pid), '/T', '/F'], {
|
|
106
|
+
stdio: 'ignore',
|
|
107
|
+
shell: true,
|
|
108
|
+
});
|
|
109
|
+
} else {
|
|
110
|
+
svc.process.kill('SIGTERM');
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
// already dead
|
|
114
|
+
}
|
|
115
|
+
svc.process = null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
isRunning(name: string): boolean {
|
|
121
|
+
const svc = this.services.find((s) => s.name === name);
|
|
122
|
+
return svc?.process !== null && svc?.process?.exitCode === null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private async killProcessOnPort(port: number): Promise<void> {
|
|
126
|
+
try {
|
|
127
|
+
if (process.platform === 'win32') {
|
|
128
|
+
// Find PID using the port on Windows
|
|
129
|
+
const result = execSync(
|
|
130
|
+
`netstat -ano | findstr :${port} | findstr LISTENING`,
|
|
131
|
+
{ encoding: 'utf-8', timeout: 5_000 }
|
|
132
|
+
).trim();
|
|
133
|
+
const lines = result.split('\n');
|
|
134
|
+
const pids = new Set<string>();
|
|
135
|
+
for (const line of lines) {
|
|
136
|
+
const parts = line.trim().split(/\s+/);
|
|
137
|
+
const pid = parts[parts.length - 1];
|
|
138
|
+
if (pid && pid !== '0') pids.add(pid);
|
|
139
|
+
}
|
|
140
|
+
for (const pid of pids) {
|
|
141
|
+
this.logger.info(`Killing existing process on port ${port} (pid ${pid})`);
|
|
142
|
+
try {
|
|
143
|
+
execSync(`taskkill /pid ${pid} /T /F`, { timeout: 5_000 });
|
|
144
|
+
} catch {
|
|
145
|
+
// process may already be gone
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
// Unix: use fuser or lsof
|
|
150
|
+
try {
|
|
151
|
+
execSync(`fuser -k ${port}/tcp`, { timeout: 5_000 });
|
|
152
|
+
this.logger.info(`Killed existing process on port ${port}`);
|
|
153
|
+
} catch {
|
|
154
|
+
// no process on port, that's fine
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
// No process found on port — that's fine
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private async waitForPort(name: string, port: number, timeoutMs: number): Promise<void> {
|
|
163
|
+
const start = Date.now();
|
|
164
|
+
const interval = 1_500;
|
|
165
|
+
|
|
166
|
+
while (Date.now() - start < timeoutMs) {
|
|
167
|
+
try {
|
|
168
|
+
const res = await fetch(`http://localhost:${port}/`, {
|
|
169
|
+
signal: AbortSignal.timeout(2_000),
|
|
170
|
+
});
|
|
171
|
+
if (res.status > 0) {
|
|
172
|
+
this.logger.info(`${name} is ready on port ${port}`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
// not ready yet
|
|
177
|
+
}
|
|
178
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
this.logger.warn(`${name} did not become ready on port ${port} within ${timeoutMs}ms, continuing anyway`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { createServer, request as httpRequest, type IncomingMessage, type ServerResponse } from 'http';
|
|
2
|
+
import { createReadStream, statSync, existsSync } from 'fs';
|
|
3
|
+
import { resolve, extname, join } from 'path';
|
|
4
|
+
import { WebSocket, WebSocketServer } from 'ws';
|
|
5
|
+
import type { Logger } from '../lib/logger.js';
|
|
6
|
+
|
|
7
|
+
const MIME: Record<string, string> = {
|
|
8
|
+
'.html': 'text/html; charset=utf-8',
|
|
9
|
+
'.js': 'application/javascript',
|
|
10
|
+
'.css': 'text/css',
|
|
11
|
+
'.json': 'application/json',
|
|
12
|
+
'.png': 'image/png',
|
|
13
|
+
'.jpg': 'image/jpeg',
|
|
14
|
+
'.svg': 'image/svg+xml',
|
|
15
|
+
'.ico': 'image/x-icon',
|
|
16
|
+
'.woff': 'font/woff',
|
|
17
|
+
'.woff2': 'font/woff2',
|
|
18
|
+
'.ttf': 'font/ttf',
|
|
19
|
+
'.mp4': 'video/mp4',
|
|
20
|
+
'.webm': 'video/webm',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export class StaticServer {
|
|
24
|
+
private port: number;
|
|
25
|
+
private distPath: string;
|
|
26
|
+
private serverUrl: string;
|
|
27
|
+
private logger: Logger;
|
|
28
|
+
private server: ReturnType<typeof createServer> | null = null;
|
|
29
|
+
|
|
30
|
+
constructor(port: number, distPath: string, serverUrl: string, logger: Logger) {
|
|
31
|
+
this.port = port;
|
|
32
|
+
this.distPath = distPath;
|
|
33
|
+
this.serverUrl = serverUrl;
|
|
34
|
+
this.logger = logger;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
start(): void {
|
|
38
|
+
if (!existsSync(this.distPath)) {
|
|
39
|
+
this.logger.warn(`StaticServer: dist path not found: ${this.distPath}`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
this.server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
44
|
+
this.handle(req, res);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Proxy WebSocket upgrades (/ws/*) to the real server
|
|
48
|
+
this.server.on('upgrade', (req, socket, head) => {
|
|
49
|
+
const upstream = new URL(req.url || '/', this.serverUrl);
|
|
50
|
+
const wsUrl = upstream.toString().replace(/^http/, 'ws');
|
|
51
|
+
this.logger.debug(`WS proxy: ${req.url} → ${wsUrl}`);
|
|
52
|
+
|
|
53
|
+
const upstreamWs = new WebSocket(wsUrl, {
|
|
54
|
+
headers: { ...req.headers, host: new URL(this.serverUrl).host },
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
upstreamWs.on('open', () => {
|
|
58
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
59
|
+
wss.handleUpgrade(req, socket, head, (clientWs) => {
|
|
60
|
+
// Pipe client ↔ upstream
|
|
61
|
+
clientWs.on('message', (data, isBinary) => {
|
|
62
|
+
if (upstreamWs.readyState === WebSocket.OPEN) {
|
|
63
|
+
upstreamWs.send(data, { binary: isBinary });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
upstreamWs.on('message', (data, isBinary) => {
|
|
67
|
+
if (clientWs.readyState === WebSocket.OPEN) {
|
|
68
|
+
clientWs.send(data, { binary: isBinary });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
clientWs.on('close', () => upstreamWs.close());
|
|
72
|
+
upstreamWs.on('close', () => clientWs.close());
|
|
73
|
+
clientWs.on('error', () => upstreamWs.close());
|
|
74
|
+
upstreamWs.on('error', () => clientWs.close());
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
upstreamWs.on('error', (err) => {
|
|
79
|
+
this.logger.warn(`WS proxy error: ${err.message}`);
|
|
80
|
+
socket.destroy();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
this.server.listen(this.port, '0.0.0.0', () => {
|
|
85
|
+
this.logger.info(`Display static server listening on port ${this.port} (proxy → ${this.serverUrl})`);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
this.server.on('error', (err) => {
|
|
89
|
+
this.logger.error(`StaticServer error: ${err.message}`);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
stop(): void {
|
|
94
|
+
if (this.server) {
|
|
95
|
+
this.server.close();
|
|
96
|
+
this.server = null;
|
|
97
|
+
this.logger.info('Display static server stopped');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private handle(req: IncomingMessage, res: ServerResponse): void {
|
|
102
|
+
const rawUrl = req.url || '/';
|
|
103
|
+
const path = rawUrl.split('?')[0];
|
|
104
|
+
|
|
105
|
+
// Proxy /api/* and /storage/* to the real server
|
|
106
|
+
if (path.startsWith('/api/') || path.startsWith('/storage/')) {
|
|
107
|
+
this.proxyHttp(req, res);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Proxy /display/* to the server first (always get latest build).
|
|
112
|
+
// Only fall back to local files if server is unreachable.
|
|
113
|
+
if (path.startsWith('/display')) {
|
|
114
|
+
this.proxyWithFallback(req, res);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Serve static files for non-display paths
|
|
119
|
+
this.serveStatic(req, res);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private proxyHttp(req: IncomingMessage, res: ServerResponse): void {
|
|
123
|
+
const target = new URL(this.serverUrl);
|
|
124
|
+
const options = {
|
|
125
|
+
hostname: target.hostname,
|
|
126
|
+
port: target.port || 80,
|
|
127
|
+
path: req.url,
|
|
128
|
+
method: req.method,
|
|
129
|
+
headers: { ...req.headers, host: target.host },
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const proxy = httpRequest(options, (upstreamRes) => {
|
|
133
|
+
res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers);
|
|
134
|
+
upstreamRes.pipe(res);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
proxy.on('error', (err) => {
|
|
138
|
+
this.logger.warn(`HTTP proxy error: ${err.message}`);
|
|
139
|
+
if (!res.headersSent) {
|
|
140
|
+
res.writeHead(502);
|
|
141
|
+
res.end('Bad Gateway');
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
req.pipe(proxy);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Try to proxy the request to the real server (latest display build).
|
|
150
|
+
* If the server is unreachable, fall back to local static files.
|
|
151
|
+
*/
|
|
152
|
+
private proxyWithFallback(req: IncomingMessage, res: ServerResponse): void {
|
|
153
|
+
const target = new URL(this.serverUrl);
|
|
154
|
+
let fell = false;
|
|
155
|
+
const fallback = () => {
|
|
156
|
+
if (fell || res.headersSent) return;
|
|
157
|
+
fell = true;
|
|
158
|
+
this.logger.debug(`Proxy failed for ${req.url}, serving from local files`);
|
|
159
|
+
this.serveStatic(req, res);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const options = {
|
|
163
|
+
hostname: target.hostname,
|
|
164
|
+
port: target.port || 80,
|
|
165
|
+
path: req.url,
|
|
166
|
+
method: req.method,
|
|
167
|
+
headers: { ...req.headers, host: target.host },
|
|
168
|
+
timeout: 3000,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const proxy = httpRequest(options, (upstreamRes) => {
|
|
172
|
+
const statusCode = upstreamRes.statusCode || 502;
|
|
173
|
+
|
|
174
|
+
// If upstream display route is unavailable/misconfigured,
|
|
175
|
+
// serve the local bundled display instead of surfacing server errors.
|
|
176
|
+
if (statusCode >= 400) {
|
|
177
|
+
upstreamRes.resume();
|
|
178
|
+
fallback();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
res.writeHead(statusCode, upstreamRes.headers);
|
|
183
|
+
upstreamRes.pipe(res);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
proxy.on('error', fallback);
|
|
187
|
+
proxy.on('timeout', () => { proxy.destroy(); fallback(); });
|
|
188
|
+
|
|
189
|
+
req.pipe(proxy);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private serveStatic(req: IncomingMessage, res: ServerResponse): void {
|
|
193
|
+
const rawUrl = (req.url || '/').split('?')[0];
|
|
194
|
+
|
|
195
|
+
// Strip /display prefix to map to dist files
|
|
196
|
+
let filePath = rawUrl.startsWith('/display')
|
|
197
|
+
? rawUrl.slice('/display'.length) || '/'
|
|
198
|
+
: rawUrl;
|
|
199
|
+
|
|
200
|
+
let absPath = resolve(this.distPath, filePath.replace(/^\//, ''));
|
|
201
|
+
|
|
202
|
+
// Security: stay inside distPath
|
|
203
|
+
if (!absPath.startsWith(this.distPath)) {
|
|
204
|
+
res.writeHead(403);
|
|
205
|
+
res.end('Forbidden');
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// SPA fallback
|
|
210
|
+
if (!existsSync(absPath) || statSync(absPath).isDirectory()) {
|
|
211
|
+
absPath = join(this.distPath, 'index.html');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (!existsSync(absPath)) {
|
|
215
|
+
res.writeHead(404);
|
|
216
|
+
res.end('Not Found');
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const ext = extname(absPath).toLowerCase();
|
|
221
|
+
const mime = MIME[ext] || 'application/octet-stream';
|
|
222
|
+
|
|
223
|
+
res.writeHead(200, { 'Content-Type': mime });
|
|
224
|
+
createReadStream(absPath).pipe(res);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { createWriteStream, createReadStream, existsSync, mkdirSync, renameSync, rmSync, readdirSync, statSync } from 'fs';
|
|
2
|
+
import { resolve, join } from 'path';
|
|
3
|
+
import { createHash } from 'crypto';
|
|
4
|
+
import { pipeline } from 'stream/promises';
|
|
5
|
+
import http from 'http';
|
|
6
|
+
import https from 'https';
|
|
7
|
+
import { execFileSync } from 'child_process';
|
|
8
|
+
import type { Logger } from '../lib/logger.js';
|
|
9
|
+
|
|
10
|
+
interface UpdatePaths {
|
|
11
|
+
current: string; // /opt/lightman/agent/
|
|
12
|
+
staging: string; // /opt/lightman/agent-staging/
|
|
13
|
+
backup: string; // /opt/lightman/agent-backup/
|
|
14
|
+
downloads: string; // /opt/lightman/agent-downloads/
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface UpdateStatus {
|
|
18
|
+
phase: 'idle' | 'downloading' | 'verifying' | 'installing' | 'restarting' | 'error';
|
|
19
|
+
version?: string;
|
|
20
|
+
progress?: number;
|
|
21
|
+
error?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class Updater {
|
|
25
|
+
private readonly logger: Logger;
|
|
26
|
+
private readonly paths: UpdatePaths;
|
|
27
|
+
private status: UpdateStatus = { phase: 'idle' };
|
|
28
|
+
|
|
29
|
+
constructor(logger: Logger, basePath?: string) {
|
|
30
|
+
this.logger = logger;
|
|
31
|
+
const base = basePath || '/opt/lightman';
|
|
32
|
+
this.paths = {
|
|
33
|
+
current: resolve(base, 'agent'),
|
|
34
|
+
staging: resolve(base, 'agent-staging'),
|
|
35
|
+
backup: resolve(base, 'agent-backup'),
|
|
36
|
+
downloads: resolve(base, 'agent-downloads'),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getStatus(): UpdateStatus {
|
|
41
|
+
return { ...this.status };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Returns true if the updater is in the middle of an install or download.
|
|
46
|
+
*/
|
|
47
|
+
isBusy(): boolean {
|
|
48
|
+
return this.status.phase === 'downloading' ||
|
|
49
|
+
this.status.phase === 'verifying' ||
|
|
50
|
+
this.status.phase === 'installing';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Download a file from URL to a local temp path.
|
|
55
|
+
* Returns the local file path.
|
|
56
|
+
*/
|
|
57
|
+
async download(url: string): Promise<string> {
|
|
58
|
+
// Validate URL protocol
|
|
59
|
+
const parsed = new URL(url);
|
|
60
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
61
|
+
throw new Error('Only http and https URLs are supported');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
this.status = { ...this.status, phase: 'downloading' };
|
|
65
|
+
this.logger.info(`Downloading update from: ${url}`);
|
|
66
|
+
|
|
67
|
+
// Ensure downloads dir exists
|
|
68
|
+
if (!existsSync(this.paths.downloads)) {
|
|
69
|
+
mkdirSync(this.paths.downloads, { recursive: true });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const filename = `update-${Date.now()}.tar.gz`;
|
|
73
|
+
const filePath = join(this.paths.downloads, filename);
|
|
74
|
+
|
|
75
|
+
return new Promise<string>((resolvePromise, reject) => {
|
|
76
|
+
const client = parsed.protocol === 'https:' ? https : http;
|
|
77
|
+
const req = client.get(url, (res) => {
|
|
78
|
+
if (res.statusCode !== 200) {
|
|
79
|
+
reject(new Error(`Download failed with status ${res.statusCode}`));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const ws = createWriteStream(filePath);
|
|
84
|
+
pipeline(res, ws)
|
|
85
|
+
.then(() => {
|
|
86
|
+
this.logger.info(`Download complete: ${filePath}`);
|
|
87
|
+
resolvePromise(filePath);
|
|
88
|
+
})
|
|
89
|
+
.catch(reject);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
req.on('error', reject);
|
|
93
|
+
req.setTimeout(5 * 60 * 1000, () => {
|
|
94
|
+
req.destroy(new Error('Download timeout (5 minutes)'));
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Verify SHA256 checksum of a file.
|
|
101
|
+
*/
|
|
102
|
+
async verify(filePath: string, expectedChecksum: string): Promise<boolean> {
|
|
103
|
+
this.status = { ...this.status, phase: 'verifying' };
|
|
104
|
+
this.logger.info(`Verifying checksum for: ${filePath}`);
|
|
105
|
+
|
|
106
|
+
const hash = createHash('sha256');
|
|
107
|
+
const stream = createReadStream(filePath);
|
|
108
|
+
|
|
109
|
+
await pipeline(stream, hash);
|
|
110
|
+
const actual = hash.digest('hex');
|
|
111
|
+
|
|
112
|
+
const match = actual === expectedChecksum.toLowerCase();
|
|
113
|
+
if (!match) {
|
|
114
|
+
this.logger.error(`Checksum mismatch: expected ${expectedChecksum}, got ${actual}`);
|
|
115
|
+
} else {
|
|
116
|
+
this.logger.info('Checksum verified OK');
|
|
117
|
+
}
|
|
118
|
+
return match;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Install update: extract tarball to staging, swap current -> backup, staging -> current, restart.
|
|
123
|
+
*/
|
|
124
|
+
async install(tarballPath: string, version: string): Promise<void> {
|
|
125
|
+
this.status = { phase: 'installing', version };
|
|
126
|
+
this.logger.info(`Installing update v${version}...`);
|
|
127
|
+
|
|
128
|
+
// Clean staging dir
|
|
129
|
+
if (existsSync(this.paths.staging)) {
|
|
130
|
+
rmSync(this.paths.staging, { recursive: true, force: true });
|
|
131
|
+
}
|
|
132
|
+
mkdirSync(this.paths.staging, { recursive: true });
|
|
133
|
+
|
|
134
|
+
// Extract tarball to staging
|
|
135
|
+
try {
|
|
136
|
+
execFileSync('tar', ['-xzf', tarballPath, '-C', this.paths.staging], {
|
|
137
|
+
timeout: 60_000,
|
|
138
|
+
});
|
|
139
|
+
} catch (err) {
|
|
140
|
+
this.status = { phase: 'error', version, error: 'Extraction failed' };
|
|
141
|
+
// Clean up staging directory on extraction failure
|
|
142
|
+
try {
|
|
143
|
+
if (existsSync(this.paths.staging)) {
|
|
144
|
+
rmSync(this.paths.staging, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
// Non-critical cleanup error
|
|
148
|
+
}
|
|
149
|
+
throw new Error(`Failed to extract tarball: ${err instanceof Error ? err.message : String(err)}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Atomic swap: current -> backup, staging -> current
|
|
153
|
+
try {
|
|
154
|
+
// Remove old backup if it exists
|
|
155
|
+
if (existsSync(this.paths.backup)) {
|
|
156
|
+
rmSync(this.paths.backup, { recursive: true, force: true });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Move current -> backup (preserve for rollback)
|
|
160
|
+
if (existsSync(this.paths.current)) {
|
|
161
|
+
renameSync(this.paths.current, this.paths.backup);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Move staging -> current
|
|
165
|
+
renameSync(this.paths.staging, this.paths.current);
|
|
166
|
+
|
|
167
|
+
this.logger.info(`Update v${version} installed successfully`);
|
|
168
|
+
} catch (err) {
|
|
169
|
+
this.status = { phase: 'error', version, error: 'Swap failed' };
|
|
170
|
+
// Attempt recovery: if backup exists and current doesn't, restore backup
|
|
171
|
+
if (!existsSync(this.paths.current) && existsSync(this.paths.backup)) {
|
|
172
|
+
try {
|
|
173
|
+
renameSync(this.paths.backup, this.paths.current);
|
|
174
|
+
this.logger.warn('Recovered from failed swap using backup');
|
|
175
|
+
} catch {
|
|
176
|
+
this.logger.error('CRITICAL: Failed to recover from swap failure');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
throw new Error(`Install swap failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Clean up downloaded tarball
|
|
183
|
+
try {
|
|
184
|
+
rmSync(tarballPath, { force: true });
|
|
185
|
+
} catch {
|
|
186
|
+
// Non-critical
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
this.status = { phase: 'restarting', version };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Rollback to backup version.
|
|
194
|
+
*/
|
|
195
|
+
async rollback(): Promise<void> {
|
|
196
|
+
this.logger.info('Rolling back to backup version...');
|
|
197
|
+
|
|
198
|
+
if (!existsSync(this.paths.backup)) {
|
|
199
|
+
throw new Error('No backup version available for rollback');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Move current -> staging (temporary)
|
|
203
|
+
if (existsSync(this.paths.staging)) {
|
|
204
|
+
rmSync(this.paths.staging, { recursive: true, force: true });
|
|
205
|
+
}
|
|
206
|
+
if (existsSync(this.paths.current)) {
|
|
207
|
+
renameSync(this.paths.current, this.paths.staging);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Move backup -> current
|
|
211
|
+
renameSync(this.paths.backup, this.paths.current);
|
|
212
|
+
|
|
213
|
+
// Clean up old current (now in staging)
|
|
214
|
+
if (existsSync(this.paths.staging)) {
|
|
215
|
+
rmSync(this.paths.staging, { recursive: true, force: true });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
this.logger.info('Rollback complete');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Clean old downloads, keeping only the most recent 3.
|
|
223
|
+
*/
|
|
224
|
+
cleanDownloads(): void {
|
|
225
|
+
try {
|
|
226
|
+
if (!existsSync(this.paths.downloads)) return;
|
|
227
|
+
|
|
228
|
+
const files = readdirSync(this.paths.downloads)
|
|
229
|
+
.filter(f => f.endsWith('.tar.gz'))
|
|
230
|
+
.map(f => ({
|
|
231
|
+
name: f,
|
|
232
|
+
path: join(this.paths.downloads, f),
|
|
233
|
+
mtime: statSync(join(this.paths.downloads, f)).mtimeMs,
|
|
234
|
+
}))
|
|
235
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
236
|
+
|
|
237
|
+
// Keep only 3 most recent
|
|
238
|
+
for (const file of files.slice(3)) {
|
|
239
|
+
try {
|
|
240
|
+
rmSync(file.path, { force: true });
|
|
241
|
+
} catch {
|
|
242
|
+
// Ignore cleanup errors
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
// Non-critical
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|