master
js 391 lines 17.7 KB
Raw
1 /*
2 * @description Cross-platform macOS flat package builder for MeshAgent installers.
3 * Creates a XAR-based distribution package instead of the legacy bundle .mpkg
4 * format that macOS Sequoia/Tahoe rejects.
5 */
6
7 'use strict';
8
9 const crypto = require('crypto');
10 const fs = require('fs');
11 const fsp = fs.promises;
12 const os = require('os');
13 const path = require('path');
14 const zlib = require('zlib');
15 const childProcess = require('child_process');
16 const { promisify } = require('util');
17
18 const deflate = promisify(zlib.deflate);
19 const execFile = promisify(childProcess.execFile);
20
21 const LAUNCH_DAEMON_PLIST = `<?xml version="1.0" encoding="UTF-8"?>
22 <!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
23 <plist version="1.0">
24 <dict>
25 <key>Label</key>
26 <string>###SERVICENAME###</string>
27 <key>ProgramArguments</key>
28 <array>
29 <string>/usr/local/mesh_services/###COMPANYNAME###/###SERVICENAME###/###EXECUTABLENAME###</string>
30 </array>
31 <key>WorkingDirectory</key>
32 <string>/usr/local/mesh_services/###COMPANYNAME###/###SERVICENAME###/</string>
33 <key>RunAtLoad</key>
34 <true/>
35 <key>KeepAlive</key>
36 <true/>
37 <key>ThrottleInterval</key>
38 <integer>1</integer>
39 </dict>
40 </plist>
41 `;
42
43 const LAUNCH_AGENT_PLIST = `<?xml version="1.0" encoding="UTF-8"?>
44 <!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
45 <plist version="1.0">
46 <dict>
47 <key>Label</key>
48 <string>###SERVICENAME###-launchagent</string>
49 <key>ProgramArguments</key>
50 <array>
51 <string>/usr/local/mesh_services/###COMPANYNAME###/###SERVICENAME###/###EXECUTABLENAME###</string>
52 <string>-kvmagent</string>
53 </array>
54 <key>LimitLoadToSessionType</key>
55 <array>
56 <string>LoginWindow</string>
57 <string>Aqua</string>
58 </array>
59 <key>WorkingDirectory</key>
60 <string>/usr/local/mesh_services/###COMPANYNAME###/###SERVICENAME###/</string>
61 <key>RunAtLoad</key>
62 <true/>
63 <key>KeepAlive</key>
64 <true/>
65 </dict>
66 </plist>
67 `;
68
69 const POSTINSTALL = `#!/bin/bash
70 set -e
71
72 SERVICENAME="###SERVICENAME###"
73 COMPANYNAME="###COMPANYNAME###"
74 EXECUTABLENAME="###EXECUTABLENAME###"
75 INSTALLDIR="/usr/local/mesh_services/\${COMPANYNAME}/\${SERVICENAME}"
76
77 chown -R root:wheel "/usr/local/mesh_services/\${COMPANYNAME}" || true
78 chown root:wheel "\${INSTALLDIR}/\${EXECUTABLENAME}" "\${INSTALLDIR}/\${EXECUTABLENAME}.msh"
79 chown root:wheel "/Library/LaunchDaemons/\${SERVICENAME}.plist" "/Library/LaunchAgents/\${SERVICENAME}-launchagent.plist"
80
81 chmod 755 "\${INSTALLDIR}" "\${INSTALLDIR}/\${EXECUTABLENAME}"
82 chmod 644 "\${INSTALLDIR}/\${EXECUTABLENAME}.msh" "/Library/LaunchDaemons/\${SERVICENAME}.plist" "/Library/LaunchAgents/\${SERVICENAME}-launchagent.plist"
83
84 /bin/launchctl bootout system "/Library/LaunchDaemons/\${SERVICENAME}.plist" >/dev/null 2>&1 || true
85 /bin/launchctl bootstrap system "/Library/LaunchDaemons/\${SERVICENAME}.plist" >/dev/null 2>&1 || /bin/launchctl load "/Library/LaunchDaemons/\${SERVICENAME}.plist"
86
87 CONSOLE_USER=$(stat -f '%Su' /dev/console 2>/dev/null || true)
88 CONSOLE_UID=$(id -u "\${CONSOLE_USER}" 2>/dev/null || true)
89 if [ -n "\${CONSOLE_UID}" ] && [ "\${CONSOLE_UID}" != "0" ]; then
90 /bin/launchctl bootout "gui/\${CONSOLE_UID}" "/Library/LaunchAgents/\${SERVICENAME}-launchagent.plist" >/dev/null 2>&1 || true
91 /bin/launchctl bootstrap "gui/\${CONSOLE_UID}" "/Library/LaunchAgents/\${SERVICENAME}-launchagent.plist" >/dev/null 2>&1 || true
92 fi
93 `;
94
95 const UNINSTALL = `#!/bin/bash
96
97 echo "Stopping ###SERVICENAME###..."
98 sudo /bin/launchctl bootout system "/Library/LaunchDaemons/###SERVICENAME###.plist" &> /dev/null || sudo /bin/launchctl unload "/Library/LaunchDaemons/###SERVICENAME###.plist" &> /dev/null
99 sudo pkill -9 "###SERVICENAME###" &> /dev/null || true
100 CONSOLE_USER=$(stat -f '%Su' /dev/console 2>/dev/null || true)
101 CONSOLE_UID=$(id -u "\${CONSOLE_USER}" 2>/dev/null || true)
102 if [ -n "\${CONSOLE_UID}" ] && [ "\${CONSOLE_UID}" != "0" ]; then
103 sudo /bin/launchctl bootout "gui/\${CONSOLE_UID}" "/Library/LaunchAgents/###SERVICENAME###-launchagent.plist" &> /dev/null || true
104 fi
105 sudo /bin/launchctl unload "/Library/LaunchDaemons/meshagentDiagnostic_periodicStart.plist" &> /dev/null
106 sudo /bin/launchctl unload "/Library/LaunchDaemons/meshagentDiagnostic.plist" &> /dev/null
107 sudo rm "/Library/LaunchDaemons/meshagentDiagnostic_periodicStart.plist" &> /dev/null
108 sudo rm "/Library/LaunchDaemons/meshagentDiagnostic.plist" &> /dev/null
109
110 echo "Resetting TCC permissions for ###SERVICENAME###..."
111 BUNDLE_ID=$(mdls -name kMDItemCFBundleIdentifier -raw "/usr/local/mesh_services/###COMPANYNAME###/###SERVICENAME###/###EXECUTABLENAME###" 2>/dev/null || true)
112 if [ -n "\${BUNDLE_ID}" ] && [ "\${BUNDLE_ID}" != "(null)" ]; then
113 sudo tccutil reset All "\${BUNDLE_ID}" &> /dev/null || true
114 fi
115 sudo tccutil reset All "###SERVICENAME###" &> /dev/null || true
116
117 sudo rm "/usr/local/mesh_services/###COMPANYNAME###/###SERVICENAME###/###EXECUTABLENAME###" &> /dev/null
118 sudo rm "/usr/local/mesh_services/###COMPANYNAME###/###SERVICENAME###/###EXECUTABLENAME###.msh" &> /dev/null
119 sudo rm "/usr/local/mesh_services/###COMPANYNAME###/###SERVICENAME###/###EXECUTABLENAME###.db" &> /dev/null
120 sudo rm "/usr/local/mesh_services/meshagentDiagnostic/meshagentDiagnostic" &> /dev/null
121 sudo rm "/Library/LaunchDaemons/###SERVICENAME###.plist" &> /dev/null
122 sudo rm "/Library/LaunchAgents/###SERVICENAME###-launchagent.plist" &> /dev/null
123 echo "###SERVICENAME### was uninstalled."
124 `;
125
126 function replaceTokens(str, tokens) {
127 return str.split('###SERVICENAME###').join(tokens.serviceName)
128 .split('###COMPANYNAME###').join(tokens.companyName)
129 .split('###EXECUTABLENAME###').join(tokens.executableName);
130 }
131
132 function xmlEscape(str) {
133 return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
134 }
135
136 function pkgIdentifierSegment(str) {
137 return String(str).toLowerCase().replace(/[^a-z0-9.-]/g, '-').replace(/^-+|-+$/g, '') || 'meshagent';
138 }
139
140 async function chmodIfExists(file, mode) {
141 try { await fsp.chmod(file, mode); } catch (ex) { }
142 }
143
144 async function walk(dir) {
145 const entries = await fsp.readdir(dir, { withFileTypes: true });
146 let files = 0, bytes = 0;
147 for (const entry of entries) {
148 const p = path.join(dir, entry.name);
149 if (entry.isDirectory()) {
150 const r = await walk(p);
151 files += r.files;
152 bytes += r.bytes;
153 } else if (entry.isFile()) {
154 const s = await fsp.stat(p);
155 files++;
156 bytes += s.size;
157 }
158 }
159 return { files, bytes };
160 }
161
162 function pad4(buffer) {
163 const pad = (4 - (buffer.length % 4)) % 4;
164 return (pad === 0) ? buffer : Buffer.concat([buffer, Buffer.alloc(pad)]);
165 }
166
167 function octal(value, width) {
168 const max = Math.pow(8, width) - 1;
169 const n = Math.max(0, Math.min(Number(value) || 0, max));
170 return Math.floor(n).toString(8).padStart(width, '0').slice(-width);
171 }
172
173 async function collectPayloadEntries(root, relativePath) {
174 const fullPath = path.join(root, relativePath);
175 const stat = await fsp.stat(fullPath);
176 const entries = [];
177 if (relativePath !== '') {
178 entries.push({ name: relativePath.split(path.sep).join('/'), stat: stat, data: stat.isFile() ? await fsp.readFile(fullPath) : null });
179 }
180 if (stat.isDirectory()) {
181 const names = (await fsp.readdir(fullPath)).sort();
182 for (const name of names) { entries.push.apply(entries, await collectPayloadEntries(root, path.join(relativePath, name))); }
183 }
184 return entries;
185 }
186
187 function cpioOdcRecord(name, mode, data, ino, mtime) {
188 data = data || Buffer.alloc(0);
189 const nameBuffer = Buffer.from(name + '\0', 'utf8');
190 const header = [
191 '070707',
192 octal(0, 6), // dev
193 octal(ino, 6),
194 octal(mode, 6),
195 octal(0, 6), // uid
196 octal(0, 6), // gid
197 octal(1, 6), // nlink
198 octal(0, 6), // rdev
199 octal(mtime || Math.floor(Date.now() / 1000), 11),
200 octal(nameBuffer.length, 6),
201 octal(data.length, 11)
202 ].join('');
203 return Buffer.concat([Buffer.from(header, 'ascii'), nameBuffer, data]);
204 }
205
206 async function createPayload(payloadRoot, targetFile) {
207 const entries = await collectPayloadEntries(payloadRoot, '');
208 const records = [];
209 let ino = 1;
210 for (const entry of entries) {
211 records.push(cpioOdcRecord(entry.name, entry.stat.mode, entry.data, ino++, Math.floor(entry.stat.mtimeMs / 1000)));
212 }
213 records.push(cpioOdcRecord('TRAILER!!!', 0, Buffer.alloc(0), ino));
214 await fsp.writeFile(targetFile, zlib.gzipSync(Buffer.concat(records)));
215 }
216
217 async function createBom(payloadRoot, targetFile) {
218 try {
219 await execFile('mkbom', [payloadRoot, targetFile], { timeout: 30000 });
220 } catch (ex) {
221 // Linux/Windows hosts can still build the package archive without a
222 // third-party BOM dependency. macOS hosts use the native mkbom tool
223 // above so local validation keeps the richer bill of materials.
224 await fsp.writeFile(targetFile, Buffer.alloc(0));
225 }
226 }
227
228 async function collectXarEntry(filePath, name, id) {
229 const stat = await fsp.stat(filePath);
230 const entry = {
231 id: id,
232 name: name,
233 type: stat.isDirectory() ? 'directory' : 'file',
234 mode: stat.mode,
235 uid: stat.uid,
236 gid: stat.gid,
237 atime: stat.atime,
238 mtime: stat.mtime,
239 ctime: stat.ctime
240 };
241 if (stat.isFile()) {
242 entry.data = await fsp.readFile(filePath);
243 } else if (stat.isDirectory()) {
244 const names = (await fsp.readdir(filePath)).sort();
245 entry.children = [];
246 for (const childName of names) {
247 entry.children.push(await collectXarEntry(path.join(filePath, childName), childName, ++collectXarEntry.nextId));
248 }
249 }
250 return entry;
251 }
252
253 function xarDate(d) {
254 return d.toISOString();
255 }
256
257 function xarFileXml(entry, depth, heapParts) {
258 const indent = ' '.repeat(depth);
259 let xml = indent + '<file id="' + entry.id + '">\n'
260 + indent + ' <name>' + xmlEscape(entry.name) + '</name>\n'
261 + indent + ' <type>' + entry.type + '</type>\n'
262 + indent + ' <mode>' + entry.mode.toString(8) + '</mode>\n'
263 + indent + ' <uid>' + entry.uid + '</uid>\n'
264 + indent + ' <gid>' + entry.gid + '</gid>\n'
265 + indent + ' <atime>' + xarDate(entry.atime) + '</atime>\n'
266 + indent + ' <mtime>' + xarDate(entry.mtime) + '</mtime>\n'
267 + indent + ' <ctime>' + xarDate(entry.ctime) + '</ctime>\n';
268 if (entry.type == 'file') {
269 const offset = 20 + heapParts.reduce(function (total, part) { return total + part.length; }, 0);
270 const sum = crypto.createHash('sha1').update(entry.data).digest('hex');
271 heapParts.push(entry.data);
272 xml += indent + ' <data>\n'
273 + indent + ' <archived-checksum style="sha1">' + sum + '</archived-checksum>\n'
274 + indent + ' <extracted-checksum style="sha1">' + sum + '</extracted-checksum>\n'
275 + indent + ' <offset>' + offset + '</offset>\n'
276 + indent + ' <encoding style="application/octet-stream"/>\n'
277 + indent + ' <size>' + entry.data.length + '</size>\n'
278 + indent + ' <length>' + entry.data.length + '</length>\n'
279 + indent + ' </data>\n';
280 } else {
281 for (const child of entry.children) { xml += xarFileXml(child, depth + 1, heapParts); }
282 }
283 return xml + indent + '</file>\n';
284 }
285
286 async function createXarPackage(paths) {
287 collectXarEntry.nextId = 0;
288 const entries = [];
289 for (const p of paths) { entries.push(await collectXarEntry(p, path.basename(p), ++collectXarEntry.nextId)); }
290
291 const heapParts = [];
292 let toc = '<?xml version="1.0" encoding="UTF-8"?>\n<xar>\n <toc>\n'
293 + ' <checksum style="sha1">\n <size>20</size>\n <offset>0</offset>\n </checksum>\n'
294 + ' <creation-time>' + (new Date()).toISOString() + '</creation-time>\n';
295 for (const entry of entries) { toc += xarFileXml(entry, 2, heapParts); }
296 toc += ' </toc>\n</xar>';
297
298 const tocBuffer = Buffer.from(toc, 'utf8');
299 const compressedToc = await deflate(tocBuffer);
300 const tocChecksum = crypto.createHash('sha1').update(compressedToc).digest();
301 const header = Buffer.alloc(28);
302 header.writeUInt32BE(0x78617221, 0); // xar!
303 header.writeUInt16BE(28, 4);
304 header.writeUInt16BE(1, 6);
305 header.writeBigUInt64BE(BigInt(compressedToc.length), 8);
306 header.writeBigUInt64BE(BigInt(tocBuffer.length), 16);
307 header.writeUInt32BE(1, 24); // sha1
308 return Buffer.concat([header, compressedToc, tocChecksum].concat(heapParts));
309 }
310
311 async function createMacOSInstaller(opts) {
312 const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'meshcentral-macos-pkg-'));
313 try {
314 const payloadRoot = path.join(tmpRoot, 'payload');
315 const scriptsRoot = path.join(tmpRoot, 'scripts');
316 const basePkg = path.join(tmpRoot, 'internal.pkg');
317 const resourcesDir = path.join(tmpRoot, 'Resources');
318 const installDir = path.join(payloadRoot, 'usr', 'local', 'mesh_services', opts.companyName, opts.serviceName);
319 const launchDaemons = path.join(payloadRoot, 'Library', 'LaunchDaemons');
320 const launchAgents = path.join(payloadRoot, 'Library', 'LaunchAgents');
321 const tokens = { serviceName: opts.serviceName, companyName: opts.companyName, executableName: opts.executableName };
322
323 await fsp.mkdir(installDir, { recursive: true });
324 await fsp.mkdir(launchDaemons, { recursive: true });
325 await fsp.mkdir(launchAgents, { recursive: true });
326 await fsp.mkdir(basePkg, { recursive: true });
327 await fsp.mkdir(scriptsRoot, { recursive: true });
328 await fsp.mkdir(resourcesDir, { recursive: true });
329
330 await fsp.copyFile(opts.agentPath, path.join(installDir, opts.executableName));
331 await fsp.writeFile(path.join(installDir, opts.executableName + '.msh'), opts.meshSettings);
332 await fsp.writeFile(path.join(launchDaemons, opts.serviceName + '.plist'), replaceTokens(LAUNCH_DAEMON_PLIST, tokens));
333 await fsp.writeFile(path.join(launchAgents, opts.serviceName + '-launchagent.plist'), replaceTokens(LAUNCH_AGENT_PLIST, tokens));
334 await fsp.writeFile(path.join(scriptsRoot, 'postinstall'), replaceTokens(POSTINSTALL, tokens));
335
336 if (opts.backgroundPath) {
337 await fsp.copyFile(opts.backgroundPath, path.join(resourcesDir, 'background'));
338 } else {
339 const backgroundPath = path.join(__dirname, 'agents', 'macosinstallerbackground.png');
340 await fsp.copyFile(backgroundPath, path.join(resourcesDir, 'background'));
341 }
342
343 await chmodIfExists(path.join(installDir, opts.executableName), 0o755);
344 await chmodIfExists(path.join(scriptsRoot, 'postinstall'), 0o755);
345 await chmodIfExists(path.join(installDir, opts.executableName + '.msh'), 0o644);
346 await chmodIfExists(path.join(launchDaemons, opts.serviceName + '.plist'), 0o644);
347 await chmodIfExists(path.join(launchAgents, opts.serviceName + '-launchagent.plist'), 0o644);
348
349 const payloadStats = await walk(payloadRoot);
350 const installKBytes = Math.ceil(payloadStats.bytes / 1000);
351 await createPayload(payloadRoot, path.join(basePkg, 'Payload'));
352 await createPayload(scriptsRoot, path.join(basePkg, 'Scripts'));
353 await createBom(payloadRoot, path.join(basePkg, 'Bom'));
354
355 const packageInfo = '<pkg-info format-version="2" identifier="com.meshcentral.' + xmlEscape(pkgIdentifierSegment(opts.serviceName)) + '.pkg" version="1.0" install-location="/" relocatable="false" auth="root">\n'
356 + ' <payload installKBytes="' + installKBytes + '" numberOfFiles="' + payloadStats.files + '"/>\n'
357 + ' <scripts>\n'
358 + ' <postinstall file="./postinstall"/>\n'
359 + ' </scripts>\n'
360 + '</pkg-info>\n';
361 await fsp.writeFile(path.join(basePkg, 'PackageInfo'), packageInfo);
362
363 const welcome = 'Welcome to the MeshCentral agent for MacOS\n\nThis installer will install the mesh agent for "' + opts.meshName + '" and allow the administrator to remotely monitor and control this computer over the internet. For more information, go to https://meshcentral.com.\n\nThis software is provided under Apache 2.0 license.\n';
364 const distribution = '<?xml version="1.0" encoding="utf-8"?>\n'
365 + '<installer-script minSpecVersion="1.000000">\n'
366 + ' <title>' + xmlEscape(opts.displayName) + '</title>\n'
367 + ' <options customize="always" allow-external-scripts="no" rootVolumeOnly="true"/>\n'
368 + ' <background file="background" alignment="topleft" scaling="tofit"/>\n'
369 + ' <welcome language="en-US" mime-type="text/plain"><![CDATA[' + welcome.split(']]>').join(']]]]><![CDATA[>') + ']]></welcome>\n'
370 + ' <choices-outline>\n'
371 + ' <line choice="choice65"/>\n'
372 + ' </choices-outline>\n'
373 + ' <choice id="choice65" title="' + xmlEscape(opts.displayName) + '">\n'
374 + ' <pkg-ref id="internal.pkg"/>\n'
375 + ' </choice>\n'
376 + ' <pkg-ref id="internal.pkg" installKBytes="' + installKBytes + '" version="1.0" auth="Root">#internal.pkg</pkg-ref>\n'
377 + ' <options hostArchitectures="arm64,x86_64"/>\n'
378 + '</installer-script>\n';
379 await fsp.writeFile(path.join(tmpRoot, 'Distribution'), distribution);
380
381 const pkgBuffer = await createXarPackage([basePkg, resourcesDir, path.join(tmpRoot, 'Distribution')]);
382 return {
383 pkg: pkgBuffer,
384 uninstall: replaceTokens(UNINSTALL, tokens)
385 };
386 } finally {
387 await fsp.rm(tmpRoot, { recursive: true, force: true });
388 }
389 }
390
391 module.exports = { createMacOSInstaller };