Updates
This commit is contained in:
1 parent
d27811254d
commit
582d0e635d
35 files changed
+2944
-1203
No files matched your search
@@ -0,0 +1,123 @@
|
||||
// Minimal reader for the OLE2 / Compound File Binary (CFB) container format —
|
||||
// the on-disk shape of an MSI database (and legacy Office docs). Dependency-free
|
||||
// and read-only: it walks the FAT / mini-FAT and directory to expose each
|
||||
// stream as a Buffer keyed by its (decoded) directory name. Just enough of
|
||||
// [MS-CFB] to pull MSI tables; it does not write or modify compound files.
|
||||
|
||||
const SIG = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
|
||||
const ENDOFCHAIN = 0xfffffffe;
|
||||
const FREESECT = 0xffffffff;
|
||||
const MAX_CHAIN = 1 << 24; // chain-length guard against malformed/looping files
|
||||
|
||||
export function isCompoundFile(buffer) {
|
||||
return Buffer.isBuffer(buffer) && buffer.length >= 512 && buffer.subarray(0, 8).equals(SIG);
|
||||
}
|
||||
|
||||
function decodeName(buffer, nameLen) {
|
||||
// nameLen includes the trailing UTF-16 null terminator.
|
||||
if (!nameLen || nameLen < 2) return '';
|
||||
return buffer.toString('utf16le', 0, nameLen - 2);
|
||||
}
|
||||
|
||||
function followChain(start, fat, limit) {
|
||||
const chain = [];
|
||||
let sector = start;
|
||||
let guard = 0;
|
||||
while (sector !== ENDOFCHAIN && sector !== FREESECT && sector < limit) {
|
||||
chain.push(sector);
|
||||
if (++guard > MAX_CHAIN) throw new Error('CFB sector chain too long (malformed file)');
|
||||
sector = fat[sector];
|
||||
if (sector == null) break;
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
export function readCompoundFile(buffer) {
|
||||
if (!isCompoundFile(buffer)) throw new Error('Not a compound file (bad OLE2 signature)');
|
||||
|
||||
const sectorShift = buffer.readUInt16LE(30);
|
||||
const miniSectorShift = buffer.readUInt16LE(32);
|
||||
const sectorSize = 1 << sectorShift;
|
||||
const miniSectorSize = 1 << miniSectorShift;
|
||||
const numFatSectors = buffer.readUInt32LE(44);
|
||||
const firstDirSector = buffer.readUInt32LE(48);
|
||||
const miniCutoff = buffer.readUInt32LE(56);
|
||||
const firstMiniFatSector = buffer.readUInt32LE(60);
|
||||
const firstDifatSector = buffer.readUInt32LE(68);
|
||||
const numDifatSectors = buffer.readUInt32LE(72);
|
||||
|
||||
const sectorOffset = (sector) => (sector + 1) * sectorSize;
|
||||
const totalSectors = Math.floor(buffer.length / sectorSize);
|
||||
|
||||
// 1) Assemble the DIFAT (locations of every FAT sector): 109 from the header,
|
||||
// then the DIFAT sector chain.
|
||||
const fatSectorLocations = [];
|
||||
for (let i = 0; i < 109 && fatSectorLocations.length < numFatSectors; i += 1) {
|
||||
const loc = buffer.readUInt32LE(76 + i * 4);
|
||||
if (loc !== FREESECT) fatSectorLocations.push(loc);
|
||||
}
|
||||
let difatSector = firstDifatSector;
|
||||
const entriesPerDifat = sectorSize / 4 - 1;
|
||||
for (let s = 0; s < numDifatSectors && difatSector !== ENDOFCHAIN && difatSector !== FREESECT; s += 1) {
|
||||
const base = sectorOffset(difatSector);
|
||||
for (let i = 0; i < entriesPerDifat; i += 1) {
|
||||
const loc = buffer.readUInt32LE(base + i * 4);
|
||||
if (loc !== FREESECT && fatSectorLocations.length < numFatSectors) fatSectorLocations.push(loc);
|
||||
}
|
||||
difatSector = buffer.readUInt32LE(base + entriesPerDifat * 4);
|
||||
}
|
||||
|
||||
// 2) Build the FAT: a flat next-sector table.
|
||||
const entriesPerSector = sectorSize / 4;
|
||||
const fat = new Uint32Array(fatSectorLocations.length * entriesPerSector);
|
||||
let fi = 0;
|
||||
for (const loc of fatSectorLocations) {
|
||||
const base = sectorOffset(loc);
|
||||
for (let i = 0; i < entriesPerSector; i += 1) fat[fi++] = buffer.readUInt32LE(base + i * 4);
|
||||
}
|
||||
|
||||
const readChain = (start) => {
|
||||
const chain = followChain(start, fat, totalSectors);
|
||||
const out = Buffer.allocUnsafe(chain.length * sectorSize);
|
||||
chain.forEach((sector, idx) => buffer.copy(out, idx * sectorSize, sectorOffset(sector), sectorOffset(sector) + sectorSize));
|
||||
return out;
|
||||
};
|
||||
|
||||
// 3) Directory entries.
|
||||
const dirBytes = readChain(firstDirSector);
|
||||
const entries = [];
|
||||
for (let off = 0; off + 128 <= dirBytes.length; off += 128) {
|
||||
const type = dirBytes.readUInt8(off + 66);
|
||||
if (type === 0) continue; // unused slot
|
||||
entries.push({
|
||||
name: decodeName(dirBytes.subarray(off, off + 64), dirBytes.readUInt16LE(off + 64)),
|
||||
type,
|
||||
startSector: dirBytes.readUInt32LE(off + 116),
|
||||
size: Number(dirBytes.readBigUInt64LE(off + 120))
|
||||
});
|
||||
}
|
||||
|
||||
// 4) Mini stream lives in the root entry; small streams are read from it.
|
||||
const root = entries.find((e) => e.type === 5);
|
||||
const miniStream = root && root.size > 0 ? readChain(root.startSector).subarray(0, root.size) : Buffer.alloc(0);
|
||||
const miniFatBytes = firstMiniFatSector === ENDOFCHAIN ? Buffer.alloc(0) : readChain(firstMiniFatSector);
|
||||
const miniFat = new Uint32Array(miniFatBytes.length / 4);
|
||||
for (let i = 0; i < miniFat.length; i += 1) miniFat[i] = miniFatBytes.readUInt32LE(i * 4);
|
||||
|
||||
const readMiniChain = (start, size) => {
|
||||
const chain = followChain(start, miniFat, miniStream.length / miniSectorSize + 1);
|
||||
const out = Buffer.allocUnsafe(chain.length * miniSectorSize);
|
||||
chain.forEach((sector, idx) => miniStream.copy(out, idx * miniSectorSize, sector * miniSectorSize, sector * miniSectorSize + miniSectorSize));
|
||||
return out.subarray(0, size);
|
||||
};
|
||||
|
||||
const streams = new Map();
|
||||
for (const entry of entries) {
|
||||
if (entry.type !== 2) continue; // streams only
|
||||
const data = entry.size < miniCutoff
|
||||
? readMiniChain(entry.startSector, entry.size)
|
||||
: readChain(entry.startSector).subarray(0, entry.size);
|
||||
streams.set(entry.name, data);
|
||||
}
|
||||
return { streams };
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// Read identity metadata from a Windows Installer (.msi) database. An MSI is a
|
||||
// compound file (see cfbReader) whose tables are stored as streams. We pull the
|
||||
// `Property` table (ProductCode/Version/Name, UpgradeCode, Manufacturer) and the
|
||||
// SummaryInformation property set (subject/author/template) — enough to auto-fill
|
||||
// a deployment and a product-code detection rule with zero typing. Dependency-
|
||||
// free and read-only; the table-decoding helpers are pure and unit-tested.
|
||||
|
||||
import { isCompoundFile, readCompoundFile } from './cfbReader.js';
|
||||
|
||||
const SUMMARY_STREAM = 'SummaryInformation';
|
||||
|
||||
// MSI mangles table/stream names into a Unicode private range. Encode a plain
|
||||
// table name ("Property") the same way the directory stores it, so we can look
|
||||
// it up in the decoded streams map. [A-Za-z0-9._] map to 6-bit codes packed in
|
||||
// pairs (0x3800 base) with a trailing odd char at 0x4800.
|
||||
function encChar(code) {
|
||||
if (code >= 0x30 && code <= 0x39) return code - 0x30; // 0-9
|
||||
if (code >= 0x41 && code <= 0x5a) return code - 0x41 + 10; // A-Z
|
||||
if (code >= 0x61 && code <= 0x7a) return code - 0x61 + 36; // a-z
|
||||
if (code === 0x2e) return 62; // .
|
||||
if (code === 0x5f) return 63; // _
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function encodeStreamName(name) {
|
||||
let out = '';
|
||||
let i = 0;
|
||||
while (i < name.length) {
|
||||
const c1 = encChar(name.charCodeAt(i));
|
||||
if (c1 < 0) { out += name[i]; i += 1; continue; }
|
||||
const c2 = i + 1 < name.length ? encChar(name.charCodeAt(i + 1)) : -1;
|
||||
if (c2 >= 0) { out += String.fromCharCode(0x3800 + c1 + c2 * 0x40); i += 2; }
|
||||
else { out += String.fromCharCode(0x4800 + c1); i += 1; }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function decoderFor(codepage) {
|
||||
if (codepage === 65001) return (b) => b.toString('utf8');
|
||||
if (codepage === 1200) return (b) => b.toString('utf16le');
|
||||
return (b) => b.toString('latin1'); // 1252/0/ASCII — fine for property keys + GUIDs
|
||||
}
|
||||
|
||||
// _StringPool is an array of (size, refcount) uint16 pairs; slot 0 carries the
|
||||
// codepage. _StringData is the concatenated bytes. Strings are 1-indexed; a zero
|
||||
// size with a nonzero refcount marks a >64KB string whose length spans two slots.
|
||||
export function decodeStringPool(poolBuf, dataBuf) {
|
||||
const codepage = poolBuf.length >= 2 ? poolBuf.readUInt16LE(0) : 0;
|
||||
const decode = decoderFor(codepage);
|
||||
const strings = ['']; // index 0 is always the empty string
|
||||
let dataOff = 0;
|
||||
let i = 1;
|
||||
while (i * 4 + 4 <= poolBuf.length) {
|
||||
let len = poolBuf.readUInt16LE(i * 4);
|
||||
const refcount = poolBuf.readUInt16LE(i * 4 + 2);
|
||||
i += 1;
|
||||
if (len === 0 && refcount === 0) { strings.push(''); continue; }
|
||||
if (len === 0 && i * 4 + 4 <= poolBuf.length) {
|
||||
len = (refcount << 16) | poolBuf.readUInt16LE(i * 4);
|
||||
i += 1;
|
||||
}
|
||||
strings.push(decode(dataBuf.subarray(dataOff, dataOff + len)));
|
||||
dataOff += len;
|
||||
}
|
||||
return { codepage, strings };
|
||||
}
|
||||
|
||||
// The Property table has two string columns (Property, Value), stored column-
|
||||
// major. Cells are string-pool indices: 2 bytes, or 3 when the pool is huge.
|
||||
export function parsePropertyTable(tableBuf, strings) {
|
||||
const idxWidth = strings.length > 0xffff ? 3 : 2;
|
||||
const rowBytes = idxWidth * 2;
|
||||
const rows = Math.floor(tableBuf.length / rowBytes);
|
||||
const readIdx = (off) => (idxWidth === 2 ? tableBuf.readUInt16LE(off) : tableBuf.readUIntLE(off, 3));
|
||||
const props = {};
|
||||
for (let r = 0; r < rows; r += 1) {
|
||||
const key = strings[readIdx(r * idxWidth)];
|
||||
if (!key) continue;
|
||||
props[key] = strings[readIdx(rows * idxWidth + r * idxWidth)] || '';
|
||||
}
|
||||
return props;
|
||||
}
|
||||
|
||||
function readPropValue(buf, off, type, decode) {
|
||||
switch (type) {
|
||||
case 2: return buf.readInt16LE(off); // VT_I2
|
||||
case 3: return buf.readInt32LE(off); // VT_I4
|
||||
case 30: { // VT_LPSTR
|
||||
const len = buf.readUInt32LE(off);
|
||||
return decode(buf.subarray(off + 4, off + 4 + len)).replace(/\0+$/, '');
|
||||
}
|
||||
case 31: { // VT_LPWSTR
|
||||
const len = buf.readUInt32LE(off);
|
||||
return buf.toString('utf16le', off + 4, off + 4 + len * 2).replace(/\0+$/, '');
|
||||
}
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
// SummaryInformation is a standard OLE property set ([MS-OLEPS]). We read the
|
||||
// first section and pull the PIDs MSI populates (subject, author, template…).
|
||||
export function parseSummaryInformation(buf) {
|
||||
if (!buf || buf.length < 48) return {};
|
||||
const sectionOffset = buf.readUInt32LE(44); // FMTID(16) at 28, its offset at 44
|
||||
if (sectionOffset + 8 > buf.length) return {};
|
||||
const count = buf.readUInt32LE(sectionOffset + 4);
|
||||
const idOffsets = [];
|
||||
for (let i = 0; i < count && sectionOffset + 8 + i * 8 + 8 <= buf.length; i += 1) {
|
||||
idOffsets.push([
|
||||
buf.readUInt32LE(sectionOffset + 8 + i * 8),
|
||||
sectionOffset + buf.readUInt32LE(sectionOffset + 8 + i * 8 + 4)
|
||||
]);
|
||||
}
|
||||
let codepage = 0;
|
||||
for (const [pid, off] of idOffsets) {
|
||||
if (pid === 1 && off + 6 <= buf.length && buf.readUInt32LE(off) === 2) {
|
||||
const cp = buf.readInt16LE(off + 4);
|
||||
codepage = cp < 0 ? cp + 65536 : cp;
|
||||
}
|
||||
}
|
||||
const decode = decoderFor(codepage);
|
||||
const PID = { 2: 'title', 3: 'subject', 4: 'author', 6: 'comments', 7: 'template', 9: 'revNumber', 18: 'creatingApp' };
|
||||
const out = {};
|
||||
for (const [pid, off] of idOffsets) {
|
||||
if (!PID[pid] || off + 4 > buf.length) continue;
|
||||
const value = readPropValue(buf, off + 4, buf.readUInt32LE(off), decode);
|
||||
if (value != null) out[PID[pid]] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Pure: turn a decoded streams map (from cfbReader) into normalized metadata.
|
||||
export function extractMsiMetadata(streams) {
|
||||
const props = {};
|
||||
const pool = streams.get('_StringPool');
|
||||
const data = streams.get('_StringData');
|
||||
if (pool && data) {
|
||||
const { strings } = decodeStringPool(pool, data);
|
||||
const table = streams.get(encodeStreamName('Property'));
|
||||
if (table) Object.assign(props, parsePropertyTable(table, strings));
|
||||
}
|
||||
const summary = parseSummaryInformation(streams.get(SUMMARY_STREAM));
|
||||
const [platform = '', language = ''] = String(summary.template || '').split(';');
|
||||
return {
|
||||
source: 'msi',
|
||||
productName: props.ProductName || summary.subject || summary.title || '',
|
||||
productVersion: props.ProductVersion || '',
|
||||
productCode: props.ProductCode || '',
|
||||
upgradeCode: props.UpgradeCode || '',
|
||||
manufacturer: props.Manufacturer || summary.author || '',
|
||||
language: props.ProductLanguage || language.trim(),
|
||||
platform: platform.trim(),
|
||||
packageCode: summary.revNumber || '',
|
||||
properties: props
|
||||
};
|
||||
}
|
||||
|
||||
export function isMsi(buffer) {
|
||||
return isCompoundFile(buffer);
|
||||
}
|
||||
|
||||
export function readMsiMetadata(buffer) {
|
||||
if (!isCompoundFile(buffer)) throw new Error('Not an MSI (missing OLE2 compound-file signature)');
|
||||
return extractMsiMetadata(readCompoundFile(buffer).streams);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import zlib from 'node:zlib';
|
||||
import { basenameAny, normalizePackagePath } from '../utils/pathUtils.js';
|
||||
|
||||
// Assembles a PSADT v4 package tree from a rendered profile script plus its linked
|
||||
// Asset Library files, and zips it without an archiver dependency. The layout
|
||||
// planning and the ZIP writer are pure and unit-tested; disk reads stay in the
|
||||
// controller.
|
||||
|
||||
export const ENTRY_SCRIPT_NAME = 'Invoke-AppDeployToolkit.ps1';
|
||||
|
||||
// Link role -> default PSADT v4 subfolder when the link carries no explicit
|
||||
// package path. Installers/payload go under Files, supporting content under
|
||||
// SupportFiles, icons under Assets.
|
||||
const ROLE_FOLDER = {
|
||||
installer: 'Files',
|
||||
'package-file': 'Files',
|
||||
'support-file': 'SupportFiles',
|
||||
'detection-script': 'SupportFiles',
|
||||
icon: 'Assets',
|
||||
reference: 'SupportFiles'
|
||||
};
|
||||
|
||||
// Pure: decide where the rendered script and each linked asset land in the
|
||||
// package tree. An explicit link packagePath wins; otherwise the role picks a
|
||||
// standard folder and the asset's own file name. Collisions are dropped with a
|
||||
// warning so a package never silently overwrites a file.
|
||||
export function planPackageLayout(links = []) {
|
||||
const warnings = [];
|
||||
const used = new Map();
|
||||
used.set(ENTRY_SCRIPT_NAME.toLowerCase(), ENTRY_SCRIPT_NAME);
|
||||
const assets = [];
|
||||
|
||||
for (const link of links) {
|
||||
const folder = ROLE_FOLDER[link.linkRole] || 'SupportFiles';
|
||||
const name = basenameAny(link.packagePath || link.originalName || link.assetId);
|
||||
let target = normalizePackagePath(link.packagePath, `${folder}/${name}`) || `${folder}/${name}`;
|
||||
if (!target.includes('/')) target = `${folder}/${target}`;
|
||||
|
||||
const key = target.toLowerCase();
|
||||
if (used.has(key)) {
|
||||
warnings.push(`Skipped "${link.originalName || link.assetId}": another file already maps to ${target}.`);
|
||||
continue;
|
||||
}
|
||||
used.set(key, link.originalName || link.assetId);
|
||||
assets.push({ assetId: link.assetId, packagePath: target, role: link.linkRole, name: link.originalName });
|
||||
}
|
||||
|
||||
const directories = ['Files', 'SupportFiles'];
|
||||
if (assets.some((asset) => asset.packagePath.startsWith('Assets/'))) directories.push('Assets');
|
||||
return { scriptPath: ENTRY_SCRIPT_NAME, assets, directories, warnings };
|
||||
}
|
||||
|
||||
// Pure: pick the installer the .intunewin build should treat as its entry setup
|
||||
// file (relative to the package root). An explicit choice wins; otherwise prefer
|
||||
// an installer-role asset, then the first file under Files/.
|
||||
export function resolveSetupFile(layout, explicit = '') {
|
||||
const explicitClean = normalizePackagePath(explicit, '');
|
||||
if (explicitClean) return explicitClean;
|
||||
const installer = layout.assets.find((asset) => asset.role === 'installer');
|
||||
if (installer) return installer.packagePath;
|
||||
const firstFile = layout.assets.find((asset) => asset.packagePath.startsWith('Files/'));
|
||||
return firstFile ? firstFile.packagePath : '';
|
||||
}
|
||||
|
||||
let CRC_TABLE;
|
||||
function crc32(buf) {
|
||||
if (!CRC_TABLE) {
|
||||
CRC_TABLE = new Int32Array(256);
|
||||
for (let n = 0; n < 256; n += 1) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k += 1) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
CRC_TABLE[n] = c;
|
||||
}
|
||||
}
|
||||
let crc = -1;
|
||||
for (let i = 0; i < buf.length; i += 1) crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ buf[i]) & 0xff];
|
||||
return (crc ^ -1) >>> 0;
|
||||
}
|
||||
|
||||
// Pure: build a standard PKZIP buffer from entries. Files are deflate-compressed
|
||||
// (method 8), directories and empty files stored (method 0). A fixed 1980 DOS
|
||||
// timestamp keeps output reproducible. Round-trips through zlib.inflateRawSync.
|
||||
export function buildZip(entries = []) {
|
||||
const local = [];
|
||||
const central = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
const isDir = Boolean(entry.isDirectory);
|
||||
let name = String(entry.path).replace(/\\/g, '/');
|
||||
if (isDir && !name.endsWith('/')) name += '/';
|
||||
const nameBytes = Buffer.from(name, 'utf8');
|
||||
const raw = isDir ? Buffer.alloc(0) : (Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data || '', 'utf8'));
|
||||
const deflated = raw.length ? zlib.deflateRawSync(raw) : Buffer.alloc(0);
|
||||
const useDeflate = raw.length > 0 && deflated.length < raw.length;
|
||||
const method = useDeflate ? 8 : 0;
|
||||
const stored = useDeflate ? deflated : raw;
|
||||
const crc = crc32(raw);
|
||||
|
||||
const localHeader = Buffer.alloc(30);
|
||||
localHeader.writeUInt32LE(0x04034b50, 0);
|
||||
localHeader.writeUInt16LE(20, 4);
|
||||
localHeader.writeUInt16LE(0, 6);
|
||||
localHeader.writeUInt16LE(method, 8);
|
||||
localHeader.writeUInt16LE(0, 10);
|
||||
localHeader.writeUInt16LE(0x21, 12);
|
||||
localHeader.writeUInt32LE(crc, 14);
|
||||
localHeader.writeUInt32LE(stored.length, 18);
|
||||
localHeader.writeUInt32LE(raw.length, 22);
|
||||
localHeader.writeUInt16LE(nameBytes.length, 26);
|
||||
localHeader.writeUInt16LE(0, 28);
|
||||
local.push(localHeader, nameBytes, stored);
|
||||
|
||||
const centralHeader = Buffer.alloc(46);
|
||||
centralHeader.writeUInt32LE(0x02014b50, 0);
|
||||
centralHeader.writeUInt16LE(20, 4);
|
||||
centralHeader.writeUInt16LE(20, 6);
|
||||
centralHeader.writeUInt16LE(0, 8);
|
||||
centralHeader.writeUInt16LE(method, 10);
|
||||
centralHeader.writeUInt16LE(0, 12);
|
||||
centralHeader.writeUInt16LE(0x21, 14);
|
||||
centralHeader.writeUInt32LE(crc, 16);
|
||||
centralHeader.writeUInt32LE(stored.length, 20);
|
||||
centralHeader.writeUInt32LE(raw.length, 24);
|
||||
centralHeader.writeUInt16LE(nameBytes.length, 28);
|
||||
centralHeader.writeUInt16LE(0, 30);
|
||||
centralHeader.writeUInt16LE(0, 32);
|
||||
centralHeader.writeUInt16LE(0, 34);
|
||||
centralHeader.writeUInt16LE(0, 36);
|
||||
centralHeader.writeUInt32LE(isDir ? 0x10 : 0, 38);
|
||||
centralHeader.writeUInt32LE(offset, 42);
|
||||
central.push(centralHeader, nameBytes);
|
||||
|
||||
offset += localHeader.length + nameBytes.length + stored.length;
|
||||
}
|
||||
|
||||
const centralBuffer = Buffer.concat(central);
|
||||
const end = Buffer.alloc(22);
|
||||
end.writeUInt32LE(0x06054b50, 0);
|
||||
end.writeUInt16LE(0, 4);
|
||||
end.writeUInt16LE(0, 6);
|
||||
end.writeUInt16LE(entries.length, 8);
|
||||
end.writeUInt16LE(entries.length, 10);
|
||||
end.writeUInt32LE(centralBuffer.length, 12);
|
||||
end.writeUInt32LE(offset, 16);
|
||||
end.writeUInt16LE(0, 20);
|
||||
|
||||
return Buffer.concat([...local, centralBuffer, end]);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// Package documentation — turn an Intune deployment (+ optional linked PSADT
|
||||
// profile and catalog application) into an auto-generated datasheet. Pure: it
|
||||
// renders a section model to Markdown or self-contained HTML with no IO. The
|
||||
// controller loads the records and picks the format.
|
||||
|
||||
const RETURN_CODE_LABELS = {
|
||||
success: 'Success',
|
||||
softReboot: 'Soft reboot',
|
||||
hardReboot: 'Hard reboot',
|
||||
retry: 'Retry',
|
||||
failed: 'Failed'
|
||||
};
|
||||
|
||||
const INTENT_LABELS = {
|
||||
available: 'Available',
|
||||
required: 'Required',
|
||||
uninstall: 'Uninstall',
|
||||
exclude: 'Excluded'
|
||||
};
|
||||
|
||||
function dash(value) {
|
||||
const str = value == null ? '' : String(value).trim();
|
||||
return str === '' ? '—' : str;
|
||||
}
|
||||
|
||||
// Build a format-neutral section model from the loaded records. Each section is
|
||||
// one of: { rows } definition list, { table } grid, { code } block, or { text }.
|
||||
export function buildDatasheetModel({ deployment, profile = null, application = null } = {}) {
|
||||
if (!deployment) throw new Error('deployment is required');
|
||||
|
||||
const title = deployment.name || profile?.name || 'PSADT package';
|
||||
const vendor = profile?.appVendor || application?.vendor || '';
|
||||
const appName = profile?.appName || deployment.name || '';
|
||||
const version = profile?.appVersion || application?.version || '';
|
||||
const arch = profile?.appArch || deployment.requirements?.architecture || '';
|
||||
const subtitle = [vendor, appName, version].map((part) => String(part || '').trim()).filter(Boolean).join(' ');
|
||||
|
||||
const sections = [];
|
||||
|
||||
sections.push({
|
||||
heading: 'Overview',
|
||||
rows: [
|
||||
['Package', title],
|
||||
['Vendor', vendor],
|
||||
['Application', appName],
|
||||
['Version', version],
|
||||
['Architecture', arch],
|
||||
['App type', deployment.appType],
|
||||
['Command style', deployment.commandStyle],
|
||||
['Install behavior', deployment.installBehavior],
|
||||
['Restart behavior', deployment.restartBehavior],
|
||||
['Status', deployment.status]
|
||||
]
|
||||
});
|
||||
|
||||
sections.push({
|
||||
heading: 'Install & uninstall',
|
||||
rows: [
|
||||
['Install command', { code: deployment.installCommand }],
|
||||
['Uninstall command', { code: deployment.uninstallCommand }]
|
||||
]
|
||||
});
|
||||
|
||||
sections.push({
|
||||
heading: 'Detection',
|
||||
rows: [['Detection type', deployment.detectionType || 'manual']],
|
||||
code: deployment.detectionRule ? deployment.detectionRule : null
|
||||
});
|
||||
|
||||
const returnCodes = Array.isArray(deployment.returnCodes) ? deployment.returnCodes : [];
|
||||
sections.push({
|
||||
heading: 'Return codes',
|
||||
table: {
|
||||
columns: ['Code', 'Type', 'Meaning'],
|
||||
rows: returnCodes.length
|
||||
? returnCodes.map((rc) => [String(rc.code), RETURN_CODE_LABELS[rc.type] || rc.type || '', rc.meaning || ''])
|
||||
: []
|
||||
}
|
||||
});
|
||||
|
||||
const assignments = Array.isArray(deployment.assignments) ? deployment.assignments : [];
|
||||
sections.push({
|
||||
heading: 'Assignments',
|
||||
table: {
|
||||
columns: ['Ring', 'Intent', 'Group', 'Notes'],
|
||||
rows: assignments.length
|
||||
? assignments.map((a) => [a.ring || '', INTENT_LABELS[a.intent] || a.intent || '', a.groupName || '', a.notes || ''])
|
||||
: []
|
||||
}
|
||||
});
|
||||
|
||||
const req = deployment.requirements || {};
|
||||
sections.push({
|
||||
heading: 'Requirements',
|
||||
rows: [
|
||||
['Architecture', req.architecture],
|
||||
['Minimum OS', req.minOs],
|
||||
['Disk space (MB)', req.diskSpaceMb ? String(req.diskSpaceMb) : ''],
|
||||
['Run as 32-bit', req.runAs32Bit ? 'Yes' : 'No']
|
||||
]
|
||||
});
|
||||
|
||||
sections.push({
|
||||
heading: 'Source & version',
|
||||
rows: [
|
||||
['PSADT profile', profile?.name || deployment.profileName || ''],
|
||||
['Source folder', deployment.sourceFolder],
|
||||
['Package file', deployment.intunewinFile],
|
||||
['Catalog application', application?.name || deployment.applicationName || ''],
|
||||
['Version source', application ? `${application.versionSource || ''} ${application.versionSourceRef || ''}`.trim() : '']
|
||||
]
|
||||
});
|
||||
|
||||
if (deployment.notes) {
|
||||
sections.push({ heading: 'Notes', text: deployment.notes });
|
||||
}
|
||||
|
||||
return { title, subtitle, generatedAt: new Date().toISOString(), sections };
|
||||
}
|
||||
|
||||
function rowValueToMd(value) {
|
||||
if (value && typeof value === 'object' && 'code' in value) return `\`${dash(value.code).replace(/`/g, '`')}\``;
|
||||
return dash(value);
|
||||
}
|
||||
|
||||
function renderMarkdown(model) {
|
||||
const lines = [`# ${model.title}`, ''];
|
||||
if (model.subtitle) lines.push(`*${model.subtitle}*`, '');
|
||||
lines.push(`Generated ${model.generatedAt}`, '');
|
||||
|
||||
for (const section of model.sections) {
|
||||
lines.push(`## ${section.heading}`, '');
|
||||
if (section.rows) {
|
||||
lines.push('| Field | Value |', '| --- | --- |');
|
||||
for (const [label, value] of section.rows) {
|
||||
lines.push(`| ${label} | ${rowValueToMd(value).replace(/\n/g, ' ')} |`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
if (section.table) {
|
||||
const { columns, rows } = section.table;
|
||||
lines.push(`| ${columns.join(' | ')} |`, `| ${columns.map(() => '---').join(' | ')} |`);
|
||||
if (rows.length) {
|
||||
for (const row of rows) lines.push(`| ${row.map((cell) => dash(cell).replace(/\n/g, ' ').replace(/\|/g, '\\|')).join(' | ')} |`);
|
||||
} else {
|
||||
lines.push(`| ${columns.map((_, i) => (i === 0 ? '_None_' : '')).join(' | ')} |`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
if (section.code) {
|
||||
lines.push('```', section.code, '```', '');
|
||||
}
|
||||
if (section.text) {
|
||||
lines.push(section.text, '');
|
||||
}
|
||||
}
|
||||
return lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
|
||||
}
|
||||
|
||||
function esc(value) {
|
||||
return dash(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function rowValueToHtml(value) {
|
||||
if (value && typeof value === 'object' && 'code' in value) return `<code>${esc(value.code)}</code>`;
|
||||
return esc(value);
|
||||
}
|
||||
|
||||
function renderHtml(model) {
|
||||
const parts = [];
|
||||
for (const section of model.sections) {
|
||||
parts.push(`<section><h2>${esc(section.heading)}</h2>`);
|
||||
if (section.rows) {
|
||||
parts.push('<table class="kv"><tbody>');
|
||||
for (const [label, value] of section.rows) {
|
||||
parts.push(`<tr><th>${esc(label)}</th><td>${rowValueToHtml(value)}</td></tr>`);
|
||||
}
|
||||
parts.push('</tbody></table>');
|
||||
}
|
||||
if (section.table) {
|
||||
const { columns, rows } = section.table;
|
||||
parts.push('<table class="grid"><thead><tr>');
|
||||
parts.push(columns.map((col) => `<th>${esc(col)}</th>`).join(''));
|
||||
parts.push('</tr></thead><tbody>');
|
||||
if (rows.length) {
|
||||
for (const row of rows) {
|
||||
parts.push('<tr>' + row.map((cell) => `<td>${esc(cell)}</td>`).join('') + '</tr>');
|
||||
}
|
||||
} else {
|
||||
parts.push(`<tr><td class="muted" colspan="${columns.length}">None configured</td></tr>`);
|
||||
}
|
||||
parts.push('</tbody></table>');
|
||||
}
|
||||
if (section.code) {
|
||||
parts.push(`<pre><code>${esc(section.code)}</code></pre>`);
|
||||
}
|
||||
if (section.text) {
|
||||
parts.push(`<p>${esc(section.text)}</p>`);
|
||||
}
|
||||
parts.push('</section>');
|
||||
}
|
||||
|
||||
return [
|
||||
'<!doctype html>',
|
||||
'<html lang="en"><head><meta charset="utf-8">',
|
||||
`<title>${esc(model.title)} — package datasheet</title>`,
|
||||
'<style>',
|
||||
':root{color-scheme:light dark}',
|
||||
'body{font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;max-width:60rem;margin:2rem auto;padding:0 1rem;line-height:1.5}',
|
||||
'h1{margin-bottom:.25rem}.subtitle{color:#6b7280;margin-top:0}.meta{color:#6b7280;font-size:.85rem}',
|
||||
'section{margin-top:1.75rem}h2{border-bottom:1px solid #d1d5db;padding-bottom:.25rem}',
|
||||
'table{border-collapse:collapse;width:100%;margin-top:.5rem}',
|
||||
'th,td{text-align:left;padding:.4rem .6rem;border:1px solid #d1d5db;vertical-align:top}',
|
||||
'table.kv th{width:14rem;white-space:nowrap}',
|
||||
'code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.9em}',
|
||||
'pre{background:#f3f4f6;padding:.75rem;border-radius:.4rem;overflow:auto}pre code{font-size:.85em}',
|
||||
'.muted{color:#6b7280}',
|
||||
'</style></head><body>',
|
||||
`<h1>${esc(model.title)}</h1>`,
|
||||
model.subtitle ? `<p class="subtitle">${esc(model.subtitle)}</p>` : '',
|
||||
`<p class="meta">Generated ${esc(model.generatedAt)}</p>`,
|
||||
parts.join('\n'),
|
||||
'</body></html>',
|
||||
''
|
||||
].filter((line) => line !== '').join('\n');
|
||||
}
|
||||
|
||||
// Render a datasheet for a deployment. format: 'md' (default) | 'html'.
|
||||
export function renderDatasheet(records, { format = 'md' } = {}) {
|
||||
const model = buildDatasheetModel(records);
|
||||
const normalized = format === 'html' ? 'html' : 'md';
|
||||
const content = normalized === 'html' ? renderHtml(model) : renderMarkdown(model);
|
||||
return {
|
||||
format: normalized,
|
||||
contentType: normalized === 'html' ? 'text/html; charset=utf-8' : 'text/markdown; charset=utf-8',
|
||||
extension: normalized === 'html' ? 'html' : 'md',
|
||||
title: model.title,
|
||||
content
|
||||
};
|
||||
}
|
||||
@@ -1,34 +1,10 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { path } from '../utils/pathUtils.js';
|
||||
import express from 'express';
|
||||
import { db, visibleClause } from '../db.js';
|
||||
import { config } from '../config.js';
|
||||
import { decryptSecret } from './cryptoStore.js';
|
||||
import { logger } from './logger.js';
|
||||
import { getBooleanSetting, getStringSetting } from '../models/settingsModel.js';
|
||||
import { normalizeOsFamily } from '../utils/osFamily.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const rdp = require('node-rdpjs');
|
||||
const socketIo = require('socket.io');
|
||||
const mstscPackagePath = require.resolve('mstsc.js/package.json');
|
||||
const mstscClientDir = path.join(path.dirname(mstscPackagePath), 'client');
|
||||
const SESSION_TTL_MS = 5 * 60 * 1000;
|
||||
const sessions = new Map();
|
||||
let socketServer = null;
|
||||
|
||||
function token() {
|
||||
return randomBytes(24).toString('base64url');
|
||||
}
|
||||
|
||||
function escapeHtml(value = '') {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function splitDomainUsername(username = '') {
|
||||
const value = String(username || '');
|
||||
const match = value.match(/^([^\\]+)\\(.+)$/);
|
||||
@@ -36,11 +12,47 @@ function splitDomainUsername(username = '') {
|
||||
return { domain: match[1], username: match[2] };
|
||||
}
|
||||
|
||||
function cleanupExpiredSessions() {
|
||||
const cutoff = Date.now() - SESSION_TTL_MS;
|
||||
for (const [sessionToken, session] of sessions.entries()) {
|
||||
if (session.createdAt < cutoff) sessions.delete(sessionToken);
|
||||
function numberSetting(key, fallback, min, max) {
|
||||
const value = Number(getStringSetting(key, String(fallback)));
|
||||
if (!Number.isFinite(value)) return fallback;
|
||||
return Math.max(min, Math.min(value, max));
|
||||
}
|
||||
|
||||
function makeHttpError(message, statusCode = 400, details = undefined) {
|
||||
const error = new Error(message);
|
||||
error.statusCode = statusCode;
|
||||
if (details) error.details = details;
|
||||
return error;
|
||||
}
|
||||
|
||||
export function normalizeMyrtilleGatewayUrl(value) {
|
||||
const trimmed = String(value || '').trim();
|
||||
if (!trimmed) return '';
|
||||
const normalized = trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
|
||||
const url = new URL(normalized);
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
throw makeHttpError('Myrtille gateway URL must use http or https.', 400);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function normalizeHashEndpoint(value) {
|
||||
const trimmed = String(value || '').trim();
|
||||
return trimmed || 'GetHash.aspx';
|
||||
}
|
||||
|
||||
function myrtilleSettings() {
|
||||
const gatewayUrl = normalizeMyrtilleGatewayUrl(getStringSetting('myrtille_gateway_url', config.myrtille.gatewayUrl));
|
||||
return {
|
||||
enabled: getBooleanSetting('myrtille_enabled', config.myrtille.enabled),
|
||||
gatewayUrl,
|
||||
usePasswordHash: getBooleanSetting('myrtille_use_password_hash', config.myrtille.usePasswordHash),
|
||||
allowPlainPassword: getBooleanSetting('myrtille_allow_plain_password', config.myrtille.allowPlainPassword),
|
||||
hashEndpoint: normalizeHashEndpoint(getStringSetting('myrtille_hash_endpoint', config.myrtille.hashEndpoint)),
|
||||
width: numberSetting('myrtille_default_width', config.myrtille.defaultWidth, 640, 7680),
|
||||
height: numberSetting('myrtille_default_height', config.myrtille.defaultHeight, 480, 4320),
|
||||
requestTimeoutMs: numberSetting('myrtille_request_timeout_ms', config.myrtille.requestTimeoutMs, 1000, 120000)
|
||||
};
|
||||
}
|
||||
|
||||
function loadRdpTarget(hostId, userId) {
|
||||
@@ -55,246 +67,158 @@ function loadRdpTarget(hostId, userId) {
|
||||
`).get(hostId, userId, userId, userId, userId);
|
||||
}
|
||||
|
||||
export function createRdpLaunchSession(hostId, userId) {
|
||||
cleanupExpiredSessions();
|
||||
async function fetchMyrtillePasswordHash(settings, password) {
|
||||
const url = new URL(settings.hashEndpoint, settings.gatewayUrl);
|
||||
url.searchParams.set('password', password);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), settings.requestTimeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'text/plain, text/html, */*' },
|
||||
signal: controller.signal
|
||||
});
|
||||
const body = (await response.text()).trim();
|
||||
if (!response.ok) {
|
||||
throw makeHttpError(
|
||||
`Myrtille password hash endpoint returned HTTP ${response.status}.`,
|
||||
502,
|
||||
{ status: response.status, body: body.slice(0, 500) }
|
||||
);
|
||||
}
|
||||
const hash = extractHashFromMyrtilleResponse(body);
|
||||
if (!hash) {
|
||||
throw makeHttpError('Myrtille password hash endpoint did not return a usable hash.', 502, { body: body.slice(0, 500) });
|
||||
}
|
||||
return hash;
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw makeHttpError(`Myrtille password hash request timed out after ${settings.requestTimeoutMs}ms.`, 502);
|
||||
}
|
||||
if (error?.statusCode) throw error;
|
||||
throw makeHttpError(`Unable to request a Myrtille password hash: ${error.message}`, 502);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export function extractHashFromMyrtilleResponse(body = '') {
|
||||
const text = String(body || '').trim();
|
||||
if (!text) return '';
|
||||
const inputMatch = text.match(/value=["']([^"']+)["']/i);
|
||||
if (inputMatch?.[1]) return inputMatch[1].trim();
|
||||
const markerMatch = text.match(/passwordHash["'\s:=>-]+([A-Za-z0-9+/=_:-]+)/i);
|
||||
if (markerMatch?.[1]) return markerMatch[1].trim();
|
||||
return text.replace(/<[^>]+>/g, ' ').trim().split(/\s+/).find((part) => part.length >= 8) || '';
|
||||
}
|
||||
|
||||
export function buildMyrtilleLaunchUrl({
|
||||
gatewayUrl,
|
||||
target,
|
||||
domain = '',
|
||||
username,
|
||||
passwordHash = '',
|
||||
password = '',
|
||||
width = 1280,
|
||||
height = 800
|
||||
}) {
|
||||
const url = new URL(gatewayUrl);
|
||||
url.searchParams.set('__EVENTTARGET', '');
|
||||
url.searchParams.set('__EVENTARGUMENT', '');
|
||||
url.searchParams.set('server', target);
|
||||
if (domain) url.searchParams.set('domain', domain);
|
||||
url.searchParams.set('user', username);
|
||||
if (passwordHash) url.searchParams.set('passwordHash', passwordHash);
|
||||
else if (password) url.searchParams.set('password', password);
|
||||
url.searchParams.set('width', String(width));
|
||||
url.searchParams.set('height', String(height));
|
||||
url.searchParams.set('connect', 'Connect!');
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export async function createRdpLaunchSession(hostId, userId) {
|
||||
const target = loadRdpTarget(hostId, userId);
|
||||
if (!target) {
|
||||
const error = new Error('Windows host with an assigned visible credential is required for RDP launch.');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
throw makeHttpError('Windows host with an assigned visible credential is required for RDP launch.', 404);
|
||||
}
|
||||
if (normalizeOsFamily(target.os_family, 'other') !== 'windows') {
|
||||
const error = new Error('RDP launch is only available for Windows hosts.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
throw makeHttpError('RDP launch is only available for Windows hosts.', 400);
|
||||
}
|
||||
if (target.credential_kind !== 'username_password') {
|
||||
const error = new Error('RDP launch requires a username/password credential assigned to the host.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
throw makeHttpError('RDP launch requires a username/password credential assigned to the host.', 400);
|
||||
}
|
||||
|
||||
const settings = myrtilleSettings();
|
||||
if (!settings.enabled || !settings.gatewayUrl) {
|
||||
throw makeHttpError('Myrtille RDP gateway is not configured. Enable myrtille_enabled and set myrtille_gateway_url in Configuration or environment variables.', 400);
|
||||
}
|
||||
|
||||
const password = decryptSecret(target);
|
||||
if (!password) {
|
||||
const error = new Error('Assigned host credential does not have a decryptable password.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
throw makeHttpError('Assigned host credential does not have a decryptable password.', 400);
|
||||
}
|
||||
|
||||
const sessionToken = token();
|
||||
const address = target.fqdn || target.address;
|
||||
const parsedUsername = splitDomainUsername(target.credential_username);
|
||||
const session = {
|
||||
token: sessionToken,
|
||||
userId,
|
||||
hostId: target.id,
|
||||
hostName: target.name,
|
||||
address: target.fqdn || target.address,
|
||||
port: 3389,
|
||||
let passwordHash = '';
|
||||
let plainPassword = '';
|
||||
|
||||
if (settings.usePasswordHash) {
|
||||
try {
|
||||
passwordHash = await fetchMyrtillePasswordHash(settings, password);
|
||||
} catch (error) {
|
||||
if (!settings.allowPlainPassword) {
|
||||
throw makeHttpError(
|
||||
`${error.message} Myrtille password-hash mode is enabled and plaintext URL fallback is disabled.`,
|
||||
error.statusCode || 502,
|
||||
error.details
|
||||
);
|
||||
}
|
||||
logger.warn('falling back to plaintext Myrtille password launch because hash generation failed and fallback is enabled', {
|
||||
hostId: target.id,
|
||||
hostName: target.name,
|
||||
userId,
|
||||
error: error.message
|
||||
});
|
||||
plainPassword = password;
|
||||
}
|
||||
} else {
|
||||
if (!settings.allowPlainPassword) {
|
||||
throw makeHttpError('Myrtille plaintext password launch is disabled. Enable password-hash mode or explicitly allow plaintext fallback.', 400);
|
||||
}
|
||||
plainPassword = password;
|
||||
}
|
||||
|
||||
const url = buildMyrtilleLaunchUrl({
|
||||
gatewayUrl: settings.gatewayUrl,
|
||||
target: address,
|
||||
domain: parsedUsername.domain,
|
||||
username: parsedUsername.username,
|
||||
password,
|
||||
credentialName: target.credential_name,
|
||||
createdAt: Date.now()
|
||||
};
|
||||
sessions.set(sessionToken, session);
|
||||
logger.info('rdp session created', {
|
||||
passwordHash,
|
||||
password: plainPassword,
|
||||
width: settings.width,
|
||||
height: settings.height
|
||||
});
|
||||
|
||||
logger.info('myrtille rdp launch created', {
|
||||
hostId: target.id,
|
||||
hostName: target.name,
|
||||
userId,
|
||||
credentialId: target.credential_id
|
||||
credentialId: target.credential_id,
|
||||
gatewayUrl: settings.gatewayUrl,
|
||||
credentialMode: passwordHash ? 'passwordHash' : 'password'
|
||||
});
|
||||
|
||||
return {
|
||||
token: sessionToken,
|
||||
url: `/rdp/${sessionToken}`,
|
||||
expiresInSeconds: Math.floor(SESSION_TTL_MS / 1000),
|
||||
provider: 'myrtille',
|
||||
url,
|
||||
gatewayUrl: settings.gatewayUrl,
|
||||
expiresInSeconds: 0,
|
||||
credentialMode: passwordHash ? 'passwordHash' : 'password',
|
||||
host: {
|
||||
id: target.id,
|
||||
name: target.name,
|
||||
address: session.address
|
||||
address
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function renderSessionPage(session) {
|
||||
const safeToken = escapeHtml(session.token);
|
||||
const safeHost = escapeHtml(session.hostName);
|
||||
const safeAddress = escapeHtml(session.address);
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>RDP - ${safeHost}</title>
|
||||
<link rel="icon" href="/rdp/assets/img/favicon.ico" />
|
||||
<script src="/rdp/socket.io/socket.io.js"></script>
|
||||
<script src="/rdp/assets/js/mstsc.js"></script>
|
||||
<script src="/rdp/assets/js/keyboard.js"></script>
|
||||
<script src="/rdp/assets/js/rle.js"></script>
|
||||
<script src="/rdp/assets/js/client.js"></script>
|
||||
<script src="/rdp/assets/js/canvas.js"></script>
|
||||
<style>
|
||||
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
color: #eff7ff;
|
||||
background:
|
||||
radial-gradient(circle at 18% 12%, rgba(109, 102, 255, .25), transparent 34%),
|
||||
radial-gradient(circle at 78% 18%, rgba(88, 221, 255, .22), transparent 32%),
|
||||
linear-gradient(135deg, #090d1e, #101a32 48%, #050814);
|
||||
}
|
||||
.launch {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.panel {
|
||||
width: min(560px, 100%);
|
||||
border: 1px solid rgba(156, 199, 232, .24);
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,.11), rgba(255,255,255,.055)), rgba(8, 14, 31, .72);
|
||||
box-shadow: 0 28px 100px rgba(0, 0, 0, .42), inset 0 1px 0 rgba(255,255,255,.14);
|
||||
padding: 28px;
|
||||
backdrop-filter: blur(24px) saturate(140%);
|
||||
}
|
||||
.eyebrow { color: #58ddff; font-size: 11px; font-weight: 800; letter-spacing: .18em; text-transform: uppercase; }
|
||||
h1 { margin: 8px 0 8px; font-size: clamp(28px, 4vw, 44px); line-height: 1; }
|
||||
p { margin: 0; color: rgba(225, 236, 251, .72); line-height: 1.6; }
|
||||
.status { margin-top: 20px; display: flex; align-items: center; gap: 10px; font-weight: 800; }
|
||||
.pulse { width: 10px; height: 10px; border-radius: 50%; background: #64e7bd; box-shadow: 0 0 22px #64e7bd; }
|
||||
#rdpCanvas { display: none; width: 100vw; height: 100vh; background: #050814; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main id="main" class="launch">
|
||||
<section class="panel">
|
||||
<span class="eyebrow">POSHManager RDP</span>
|
||||
<h1>${safeHost}</h1>
|
||||
<p>Opening a browser RDP session to ${safeAddress} using the assigned host credential. Close this tab to end the session.</p>
|
||||
<div class="status"><span class="pulse"></span><span id="status">Preparing secure session...</span></div>
|
||||
</section>
|
||||
</main>
|
||||
<canvas id="rdpCanvas"></canvas>
|
||||
<script>
|
||||
(function () {
|
||||
try { window.opener = null; } catch (err) {}
|
||||
var token = "${safeToken}";
|
||||
var status = document.getElementById('status');
|
||||
var canvas = document.getElementById('rdpCanvas');
|
||||
var main = document.getElementById('main');
|
||||
var client = Mstsc.client.create(canvas);
|
||||
function resize() {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
}
|
||||
window.addEventListener('resize', resize);
|
||||
resize();
|
||||
status.textContent = 'Connecting...';
|
||||
canvas.style.display = 'block';
|
||||
main.style.display = 'none';
|
||||
client.connect('poshmanager-session', '', '', token, function (err) {
|
||||
canvas.style.display = 'none';
|
||||
main.style.display = 'grid';
|
||||
status.textContent = err ? 'RDP session failed or closed.' : 'RDP session closed.';
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function mountRdpGateway(app) {
|
||||
app.use('/rdp/assets', express.static(mstscClientDir, {
|
||||
fallthrough: false,
|
||||
immutable: true,
|
||||
maxAge: '1h'
|
||||
}));
|
||||
|
||||
app.get('/rdp/sessions/:token', (req, res) => {
|
||||
res.redirect(302, `/rdp/${encodeURIComponent(req.params.token)}`);
|
||||
});
|
||||
|
||||
app.get('/rdp/:token', (req, res) => {
|
||||
cleanupExpiredSessions();
|
||||
const session = sessions.get(req.params.token);
|
||||
if (!session) return res.status(404).send('RDP session expired or not found.');
|
||||
res.type('html').send(renderSessionPage(session));
|
||||
});
|
||||
}
|
||||
|
||||
export function startRdpGateway(httpServer) {
|
||||
if (socketServer) return socketServer;
|
||||
socketServer = socketIo(httpServer, { path: '/rdp/socket.io' });
|
||||
socketServer.on('connection', (client) => {
|
||||
let rdpClient = null;
|
||||
|
||||
client.on('infos', (infos = {}) => {
|
||||
cleanupExpiredSessions();
|
||||
const sessionToken = infos.token || infos.sessionToken || infos.password;
|
||||
const session = sessions.get(sessionToken);
|
||||
if (!session) {
|
||||
client.emit('rdp-error', { code: 'POSHM_RDP_SESSION', message: 'RDP session expired or invalid.' });
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
if (rdpClient) rdpClient.close();
|
||||
const screen = {
|
||||
width: Math.max(640, Math.min(Number(infos.screen?.width || 1280), 3840)),
|
||||
height: Math.max(480, Math.min(Number(infos.screen?.height || 800), 2160))
|
||||
};
|
||||
|
||||
logger.info('rdp connection starting', {
|
||||
hostId: session.hostId,
|
||||
hostName: session.hostName,
|
||||
address: session.address,
|
||||
userId: session.userId
|
||||
});
|
||||
|
||||
rdpClient = rdp.createClient({
|
||||
domain: session.domain,
|
||||
userName: session.username,
|
||||
password: session.password,
|
||||
enablePerf: true,
|
||||
autoLogin: true,
|
||||
screen,
|
||||
locale: infos.locale,
|
||||
logLevel: 'ERROR'
|
||||
}).on('connect', () => {
|
||||
client.emit('rdp-connect');
|
||||
logger.info('rdp connection established', { hostId: session.hostId, hostName: session.hostName, userId: session.userId });
|
||||
}).on('bitmap', (bitmap) => {
|
||||
client.emit('rdp-bitmap', bitmap);
|
||||
}).on('close', () => {
|
||||
client.emit('rdp-close');
|
||||
sessions.delete(sessionToken);
|
||||
logger.info('rdp connection closed', { hostId: session.hostId, hostName: session.hostName, userId: session.userId });
|
||||
}).on('error', (err) => {
|
||||
client.emit('rdp-error', { code: err.code || 'RDP_ERROR', message: err.message || String(err) });
|
||||
logger.error('rdp connection failed', { hostId: session.hostId, hostName: session.hostName, userId: session.userId, error: err.message || String(err) });
|
||||
}).connect(session.address, session.port);
|
||||
});
|
||||
|
||||
client.on('mouse', (x, y, button, isPressed) => {
|
||||
if (rdpClient) rdpClient.sendPointerEvent(x, y, button, isPressed);
|
||||
});
|
||||
client.on('wheel', (x, y, step, isNegative, isHorizontal) => {
|
||||
if (rdpClient) rdpClient.sendWheelEvent(x, y, step, isNegative, isHorizontal);
|
||||
});
|
||||
client.on('scancode', (code, isPressed) => {
|
||||
if (rdpClient) rdpClient.sendKeyEventScancode(code, isPressed);
|
||||
});
|
||||
client.on('unicode', (code, isPressed) => {
|
||||
if (rdpClient) rdpClient.sendKeyEventUnicode(code, isPressed);
|
||||
});
|
||||
client.on('disconnect', () => {
|
||||
if (rdpClient) rdpClient.close();
|
||||
});
|
||||
});
|
||||
logger.info('POSHManager RDP gateway mounted at /rdp');
|
||||
return socketServer;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { RECIPES } from '../data/recipeCatalog.js';
|
||||
import { analyzeInstaller } from './installerIntelService.js';
|
||||
import { buildDetectionRule } from './detectionRuleService.js';
|
||||
|
||||
// Lists/searches the curated catalog and turns a recipe into the request bodies
|
||||
// the models accept (PSADT profile + Intune deployment + winget-tracked app).
|
||||
|
||||
// installTask type enum is exe|msi|msp|script; everything EXE-based maps to exe.
|
||||
function taskTypeFor(installerType = '') {
|
||||
if (installerType === 'msi') return 'msi';
|
||||
if (installerType === 'msp') return 'msp';
|
||||
return 'exe';
|
||||
}
|
||||
|
||||
function summarize(recipe) {
|
||||
return {
|
||||
id: recipe.id,
|
||||
vendor: recipe.vendor,
|
||||
name: recipe.name,
|
||||
category: recipe.category || 'Other',
|
||||
installerType: recipe.installerType,
|
||||
fileName: recipe.fileName,
|
||||
arch: recipe.arch || 'x64',
|
||||
installArgs: recipe.installArgs || '',
|
||||
detectionKind: recipe.detection?.type || '',
|
||||
wingetId: recipe.wingetId || '',
|
||||
homepage: recipe.homepage || ''
|
||||
};
|
||||
}
|
||||
|
||||
export function listRecipes() {
|
||||
return RECIPES.map(summarize);
|
||||
}
|
||||
|
||||
export function searchRecipes(query = '') {
|
||||
const q = String(query).trim().toLowerCase();
|
||||
if (!q) return listRecipes();
|
||||
return RECIPES.filter((r) =>
|
||||
[r.vendor, r.name, r.category, r.wingetId, r.id]
|
||||
.some((field) => String(field || '').toLowerCase().includes(q))
|
||||
).map(summarize);
|
||||
}
|
||||
|
||||
export function getRecipe(id) {
|
||||
return RECIPES.find((r) => r.id === id) || null;
|
||||
}
|
||||
|
||||
// recipe → { profile, deployment, application }; application is null with no winget id.
|
||||
export function buildRecipeArtifacts(recipe, { visibility = 'personal', groupId = null } = {}) {
|
||||
if (!recipe) throw new Error('recipe is required');
|
||||
const scope = { visibility, groupId: visibility === 'group' ? groupId || null : null };
|
||||
const displayName = `${recipe.vendor} ${recipe.name}`.trim();
|
||||
const analysis = analyzeInstaller({ fileName: recipe.fileName, productCode: recipe.productCode || '' });
|
||||
const detection = buildDetectionRule(recipe.detection || {});
|
||||
|
||||
const profile = {
|
||||
name: displayName,
|
||||
appVendor: recipe.vendor,
|
||||
appName: recipe.name,
|
||||
appVersion: recipe.version || '',
|
||||
appArch: recipe.arch === 'x86' ? 'x86' : 'x64',
|
||||
closeProcesses: recipe.closeProcesses || [],
|
||||
installTasks: [{
|
||||
type: taskTypeFor(recipe.installerType),
|
||||
phase: 'Install',
|
||||
filePath: recipe.fileName,
|
||||
arguments: recipe.installArgs || ''
|
||||
}],
|
||||
...scope
|
||||
};
|
||||
|
||||
const deployment = {
|
||||
name: displayName,
|
||||
appType: 'Windows app (Win32)',
|
||||
commandStyle: 'v4',
|
||||
requirements: { architecture: recipe.arch === 'x86' ? 'x86' : 'x64' },
|
||||
detectionType: detection.detectionType,
|
||||
detectionRule: detection.detectionRule,
|
||||
status: 'draft',
|
||||
notes: `Created from the "${recipe.name}" recipe. Source: ${recipe.homepage || 'n/a'}`,
|
||||
...scope
|
||||
};
|
||||
|
||||
const application = recipe.wingetId ? {
|
||||
name: displayName,
|
||||
vendor: recipe.vendor,
|
||||
versionSource: 'winget',
|
||||
versionSourceRef: recipe.wingetId,
|
||||
autoCheck: true,
|
||||
notes: `Auto-tracking ${recipe.wingetId} via winget (from the "${recipe.name}" recipe).`,
|
||||
...scope
|
||||
} : null;
|
||||
|
||||
return { recipeId: recipe.id, displayName, profile, deployment, application, analysis, detection };
|
||||
}
|
||||
Reference in new issue
Block a user