Updates
This commit is contained in:
1 parent
d27811254d
commit
582d0e635d
35 files changed
+2944
-1203
No files matched your search
@@ -40,6 +40,16 @@ export const config = {
|
||||
defaultAdminName: process.env.DEFAULT_ADMIN_NAME || 'POSH Admin',
|
||||
powershellBin: process.env.POWERSHELL_BIN || 'pwsh',
|
||||
allowScriptExecution: (process.env.ALLOW_SCRIPT_EXECUTION || 'true').toLowerCase() === 'true',
|
||||
myrtille: {
|
||||
enabled: (process.env.MYRTILLE_ENABLED || 'false').toLowerCase() === 'true',
|
||||
gatewayUrl: process.env.MYRTILLE_GATEWAY_URL || '',
|
||||
usePasswordHash: (process.env.MYRTILLE_USE_PASSWORD_HASH || 'true').toLowerCase() === 'true',
|
||||
allowPlainPassword: (process.env.MYRTILLE_ALLOW_PLAIN_PASSWORD || 'false').toLowerCase() === 'true',
|
||||
hashEndpoint: process.env.MYRTILLE_HASH_ENDPOINT || 'GetHash.aspx',
|
||||
defaultWidth: Number(process.env.MYRTILLE_DEFAULT_WIDTH || 1280),
|
||||
defaultHeight: Number(process.env.MYRTILLE_DEFAULT_HEIGHT || 800),
|
||||
requestTimeoutMs: Number(process.env.MYRTILLE_REQUEST_TIMEOUT_MS || 15000)
|
||||
},
|
||||
vcenter: {
|
||||
enabled: (process.env.VCENTER_ENABLED || 'false').toLowerCase() === 'true',
|
||||
baseUrl: process.env.VCENTER_BASE_URL || '',
|
||||
|
||||
@@ -33,9 +33,9 @@ export function destroy(req, res) {
|
||||
res.status(204).end();
|
||||
}
|
||||
|
||||
export function createRdpSession(req, res) {
|
||||
export async function createRdpSession(req, res) {
|
||||
try {
|
||||
res.status(201).json(createRdpLaunchSession(req.params.id, req.user.id));
|
||||
res.status(201).json(await createRdpLaunchSession(req.params.id, req.user.id));
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || 400).json({ error: error.message });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { analyzeInstaller, listInstallerTechnologies } from '../services/installerIntelService.js';
|
||||
import { buildDetectionRule } from '../services/detectionRuleService.js';
|
||||
import { buildRecipeArtifacts, getRecipe, searchRecipes } from '../services/recipeService.js';
|
||||
import { createPsadtProfile, createIntuneDeployment } from '../models/psadtModel.js';
|
||||
import { createApplication } from '../models/applicationModel.js';
|
||||
|
||||
export function installerTypes(req, res) {
|
||||
res.json(listInstallerTechnologies());
|
||||
@@ -18,3 +21,31 @@ export function detection(req, res) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export function recipes(req, res) {
|
||||
res.json(searchRecipes(req.query?.q || ''));
|
||||
}
|
||||
|
||||
export function applyRecipe(req, res) {
|
||||
const recipe = getRecipe(req.params.id);
|
||||
if (!recipe) return res.status(404).json({ error: 'Recipe not found' });
|
||||
try {
|
||||
const artifacts = buildRecipeArtifacts(recipe, {
|
||||
visibility: req.body?.visibility || 'personal',
|
||||
groupId: req.body?.groupId || null
|
||||
});
|
||||
const userId = req.user.id;
|
||||
|
||||
const profile = createPsadtProfile(artifacts.profile, userId);
|
||||
const application = artifacts.application ? createApplication(artifacts.application, userId) : null;
|
||||
const deployment = createIntuneDeployment({
|
||||
...artifacts.deployment,
|
||||
profileId: profile.id,
|
||||
applicationId: application?.id || null
|
||||
}, userId);
|
||||
|
||||
res.status(201).json({ recipeId: recipe.id, profile, deployment, application });
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
@@ -26,10 +26,13 @@ import { computeDrift } from '../services/intuneDriftService.js';
|
||||
import { canPublish } from '../services/graphRbac.js';
|
||||
import { cloneForNextVersion, setRingIntent } from '../services/intunePromotionService.js';
|
||||
import { parseIntunewin } from '../services/intunewinParser.js';
|
||||
import { createAssetFromUpload, getAssetFile } from '../models/assetModel.js';
|
||||
import { createAssetFromUpload, getAssetFile, listLinkedAssets } from '../models/assetModel.js';
|
||||
import { validatePsadtScript, validationRuleCatalog } from '../services/psadtValidator.js';
|
||||
import { planPsadtMigration } from '../services/psadtMigrationService.js';
|
||||
import { basenameAny, path } from '../utils/pathUtils.js';
|
||||
import { ENTRY_SCRIPT_NAME, buildZip, planPackageLayout, resolveSetupFile } from '../services/packageBuilder.js';
|
||||
import { renderDatasheet } from '../services/packageDocService.js';
|
||||
import { loadApplication } from '../models/applicationModel.js';
|
||||
import { basenameAny, path, sanitizeFileName } from '../utils/pathUtils.js';
|
||||
|
||||
export function catalog(req, res) {
|
||||
res.json(getPsadtCatalog());
|
||||
@@ -434,6 +437,24 @@ export function intuneGraphAudit(req, res) {
|
||||
res.json(listGraphAudit({ deploymentId: deployment.id }));
|
||||
}
|
||||
|
||||
// Auto-generated package datasheet: render a deployment (plus its linked PSADT
|
||||
// profile and catalog application) to Markdown or HTML. Pure render lives in
|
||||
// packageDocService. ?format=md|html (default md); ?download=1 forces a file.
|
||||
export function intuneDatasheet(req, res) {
|
||||
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
|
||||
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
|
||||
const profile = deployment.profileId ? loadPsadtProfile(deployment.profileId, req.user.id) : null;
|
||||
const application = deployment.applicationId ? loadApplication(deployment.applicationId, req.user.id) : null;
|
||||
|
||||
const doc = renderDatasheet({ deployment, profile, application }, { format: req.query?.format });
|
||||
const fileName = `${sanitizeFileName(doc.title || 'package', 'package').replace(/\.[^.]+$/, '')}-datasheet.${doc.extension}`;
|
||||
res.type(doc.contentType);
|
||||
if (req.query?.download === '1' || req.query?.download === 'true') {
|
||||
res.set('Content-Disposition', `attachment; filename="${fileName}"`);
|
||||
}
|
||||
res.send(doc.content);
|
||||
}
|
||||
|
||||
export function index(req, res) {
|
||||
res.json(listPsadtProfiles(req.user.id));
|
||||
}
|
||||
@@ -467,6 +488,89 @@ export function render(req, res) {
|
||||
res.json(rendered);
|
||||
}
|
||||
|
||||
// Assemble a complete, portable PSADT v4 package from a profile: render the
|
||||
// Invoke-AppDeployToolkit.ps1 entry script, lay out Files/SupportFiles/Assets
|
||||
// from the profile's linked Asset Library items, zip it, and ingest the zip into
|
||||
// the Asset Library. With { build: true } also hand the assembled tree to the
|
||||
// .intunewin builder. Pure layout + zip logic lives in packageBuilder.
|
||||
export async function packageProfile(req, res) {
|
||||
const rendered = renderPsadtProfile(req.params.id, req.user.id);
|
||||
if (!rendered) return res.status(404).json({ error: 'PSADT profile not found' });
|
||||
|
||||
const links = listLinkedAssets('psadt_profile', req.params.id, req.user.id);
|
||||
const layout = planPackageLayout(links);
|
||||
const warnings = [...layout.warnings];
|
||||
|
||||
const zipEntries = [
|
||||
{ path: ENTRY_SCRIPT_NAME, data: Buffer.from(rendered.script, 'utf8') },
|
||||
...layout.directories.map((dir) => ({ path: dir, isDirectory: true }))
|
||||
];
|
||||
for (const asset of layout.assets) {
|
||||
const fileRef = getAssetFile(asset.assetId, req.user.id);
|
||||
if (!fileRef) {
|
||||
warnings.push(`Asset "${asset.name || asset.assetId}" is no longer accessible and was skipped.`);
|
||||
continue;
|
||||
}
|
||||
zipEntries.push({ path: asset.packagePath, data: fs.readFileSync(fileRef.filePath) });
|
||||
}
|
||||
const includedFiles = zipEntries.filter((entry) => !entry.isDirectory && entry.path !== ENTRY_SCRIPT_NAME);
|
||||
|
||||
const stageDir = path.join(config.toolsDir, '_package', rendered.profile.id);
|
||||
fs.rmSync(stageDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(stageDir, { recursive: true });
|
||||
const baseName = sanitizeFileName(`${rendered.profile.name || 'psadt'}-package`, 'psadt-package').replace(/\.[^.]+$/, '');
|
||||
const zipPath = path.join(stageDir, `${baseName}.zip`);
|
||||
const zipBuffer = buildZip(zipEntries);
|
||||
fs.writeFileSync(zipPath, zipBuffer);
|
||||
|
||||
const asset = createAssetFromUpload(
|
||||
{ path: zipPath, originalname: `${baseName}.zip`, mimetype: 'application/zip', size: zipBuffer.length },
|
||||
{ name: `${rendered.profile.name} package`, visibility: rendered.profile.visibility, groupId: rendered.profile.groupId, packagePath: `${baseName}.zip` },
|
||||
req.user.id
|
||||
);
|
||||
|
||||
const manifest = {
|
||||
script: ENTRY_SCRIPT_NAME,
|
||||
directories: layout.directories,
|
||||
files: layout.assets.map((item) => ({ packagePath: item.packagePath, role: item.role, name: item.name })),
|
||||
fileCount: includedFiles.length
|
||||
};
|
||||
|
||||
let intunewin = null;
|
||||
if (req.body?.build) {
|
||||
const setupFile = resolveSetupFile(layout, req.body?.setupFile);
|
||||
const availability = builderAvailability();
|
||||
if (!setupFile) {
|
||||
warnings.push('Skipped .intunewin build: no installer in the package. Link an installer asset or pass setupFile.');
|
||||
} else if (!availability.available) {
|
||||
warnings.push(`Skipped .intunewin build: ${availability.reason}`);
|
||||
} else {
|
||||
const sourceFolder = path.join(stageDir, 'source');
|
||||
for (const entry of zipEntries) {
|
||||
const dest = path.join(sourceFolder, entry.path);
|
||||
if (entry.isDirectory) { fs.mkdirSync(dest, { recursive: true }); continue; }
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.writeFileSync(dest, entry.data);
|
||||
}
|
||||
try {
|
||||
const { outputFile } = await buildIntuneWin({ sourceFolder, setupFile, outputDir: path.join(stageDir, '_out') });
|
||||
const stats = fs.statSync(outputFile);
|
||||
const outName = basenameAny(outputFile, 'package.intunewin');
|
||||
intunewin = createAssetFromUpload(
|
||||
{ path: outputFile, originalname: outName, mimetype: 'application/octet-stream', size: stats.size },
|
||||
{ name: `${rendered.profile.name} intunewin`, visibility: rendered.profile.visibility, groupId: rendered.profile.groupId, packagePath: outName },
|
||||
req.user.id
|
||||
);
|
||||
manifest.setupFile = setupFile;
|
||||
} catch (error) {
|
||||
warnings.push(`.intunewin build failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json({ asset, manifest, warnings, intunewin, builder: builderAvailability() });
|
||||
}
|
||||
|
||||
export function rules(req, res) {
|
||||
res.json(validationRuleCatalog());
|
||||
}
|
||||
|
||||
@@ -64,7 +64,8 @@ const operationsArticles = [
|
||||
'Create credentials in Credential Vault first, then attach them to hosts. Secrets are encrypted using AES-256-GCM.',
|
||||
'Hosts support WinRM, SSH, local, and API transport metadata. PowerShell remoting and firewall prerequisites must be configured outside POSHManager.',
|
||||
'Each host tracks an OS type of Windows, Linux, or Other. vCenter imports infer this from VMware Tools when possible; manual hosts should set it during add/edit.',
|
||||
'Windows hosts with an assigned username/password Credential Vault entry show an RDP icon. The icon opens a short-lived /rdp/:token URL in a new tab and renders a mstsc.js browser RDP canvas while the API keeps the real password server-side.',
|
||||
'Windows hosts with an assigned username/password Credential Vault entry show an RDP icon. The icon asks the API for a Myrtille launch URL and opens that gateway in a new tab.',
|
||||
'Configure myrtille_enabled and myrtille_gateway_url in Configuration / Integrations or pin them with MYRTILLE_ENABLED and MYRTILLE_GATEWAY_URL. Password-hash mode is enabled by default so the browser receives a Myrtille passwordHash instead of the plaintext vault secret.',
|
||||
'When Linux targets are selected, POSHManager checks scripts for common Windows-only PowerShell patterns such as registry providers, C:\\ paths, WMI/Win32 classes, Windows executables, and risky alias usage.'
|
||||
]),
|
||||
section('Host Groups and VMware sources', [
|
||||
@@ -96,6 +97,11 @@ const operationsArticles = [
|
||||
'Users & Groups is a dedicated administration menu item. Add users and groups through modal create flows.',
|
||||
'Scripts, RunPlans, variables, PSADT profiles, and Intune deployment plans can use personal, shared, or group visibility.'
|
||||
]),
|
||||
section('Profile and themes', [
|
||||
'Open the avatar menu in the top bar to edit profile details, avatar URL, password settings, and theme preference.',
|
||||
'Available themes include Dashtreme Pulse, Green Screen, Aurora Glass, Greyscale Pro, and macOS Desktop. The macOS mode uses desktop-style frosted chrome; Greyscale Pro uses neutral high-contrast surfaces.',
|
||||
'Dense data tables use icon-only row actions with accessible labels and hover tooltips, while page-level create/save commands keep text labels.'
|
||||
]),
|
||||
section('Configuration', [
|
||||
'Config is grouped into collapsible sections for Deployment, Authentication, Execution, Runtime, and Other settings, with Microsoft Graph Intune environments managed in the Config / Intune panel.',
|
||||
'Settings can be controlled through the UI and environment variables for Docker deployment. Trusted origins, server FQDN, Entra fields, PowerShell binary, and script execution switch are key operational settings.'
|
||||
@@ -108,6 +114,7 @@ const apiArticles = [
|
||||
apiExample('Post', '/api/auth/login', 'Authenticate with local credentials and receive a JWT token.', '@{ email = "admin@posh.local"; password = "change-me-now" }'),
|
||||
apiExample('Get', '/api/auth/me', 'Return the current authenticated user.'),
|
||||
apiExample('Put', '/api/auth/profile', 'Update display name, profile fields, and avatar URL.', '@{ displayName = "POSH Admin"; email = "admin@posh.local"; jobTitle = "Platform Administrator"; phone = ""; timezone = "America/Chicago"; avatarUrl = "" }'),
|
||||
apiExample('Put', '/api/auth/theme', 'Update the current user theme. Supported values include dashtreme, terminal, aurora, greyscale, and macos.', '@{ theme = "macos" }'),
|
||||
apiExample('Get', '/api/bootstrap', 'Return dashboard summary, settings payload, and Entra status.'),
|
||||
section('Notes', [
|
||||
'Use the Bearer token returned by /api/auth/login for every protected request.',
|
||||
@@ -135,7 +142,7 @@ const apiArticles = [
|
||||
article('api-hosts-credentials-runplans', 'API', 'Hosts, Credentials, RunPlans, Jobs, And Logs API', 'Manage execution targets and inspect results through the API.', [
|
||||
apiExample('Post', '/api/credentials', 'Create an encrypted username/password credential.', '@{ name = "WinRM Admin"; kind = "username_password"; username = "CONTOSO\\svc-posh"; secret = "change-me"; visibility = "personal" }'),
|
||||
apiExample('Post', '/api/hosts', 'Create a managed host.', '@{ name = "APP01"; address = "app01.contoso.local"; fqdn = "app01.contoso.local"; osFamily = "windows"; transport = "winrm"; port = 5986; credentialId = $null; tags = @("prod","web"); notes = "" }'),
|
||||
apiExample('Post', '/api/hosts/hst_123/rdp-session', 'Create a short-lived browser RDP launch URL for a visible Windows host with an assigned visible username/password credential.', '$session = Invoke-RestMethod -Method Post -Uri "$base/api/hosts/hst_123/rdp-session" -Headers $headers\nStart-Process "$base$($session.url)"'),
|
||||
apiExample('Post', '/api/hosts/hst_123/rdp-session', 'Create a Myrtille browser RDP launch URL for a visible Windows host with an assigned visible username/password credential.', '$session = Invoke-RestMethod -Method Post -Uri "$base/api/hosts/hst_123/rdp-session" -Headers $headers\nStart-Process $session.url'),
|
||||
apiExample('Post', '/api/scripts/scr_123/compatibility', 'Analyze a script against direct host and Host Group targets before executing against Linux hosts.', '@{ hostIds = @("hst_linux_01"); hostGroupIds = @("hg_mixed_targets") }'),
|
||||
apiExample('Get', '/api/runplans/rp_123/compatibility', 'Analyze a RunPlan script against its current target OS mix before execution.'),
|
||||
apiExample('Post', '/api/runplans', 'Create a RunPlan. hostIds should contain existing host ids.', '@{ name = "Patch Validation"; description = "Run validation script"; scriptId = "scr_123"; visibility = "shared"; parallel = $true; hostIds = @("hst_123") }'),
|
||||
@@ -163,6 +170,8 @@ const apiArticles = [
|
||||
apiExample('Post', '/api/psadt/validate', 'Validate raw PSADT script content.', '@{ platform = "intune"; content = "Invoke-AppDeployToolkit.exe -DeploymentType Install" }'),
|
||||
apiExample('Post', '/api/psadt/migration/plan', 'Plan a legacy script migration to PSADT 4.2.0.', `@{ targetVersion = "4.2.0"; content = "Deploy-Application.ps1\`nExecute-Process -Path setup.exe\`n$appName = 'Legacy App'" }`),
|
||||
apiExample('Post', '/api/psadt/profiles', 'Create a reusable PSADT deployment profile.', '@{ name = "Edge Enterprise"; appVendor = "Microsoft"; appName = "Edge"; appVersion = "126.0"; appArch = "x64"; appLang = "EN"; appRevision = "01"; templateVersion = "v4"; deploymentType = "Install"; deployMode = "Auto"; requireAdmin = $true; zeroConfig = $false; suppressReboot = $false; closeProcesses = @(); installTasks = @(@{ type = "exe"; phase = "Install"; filePath = "setup.exe"; arguments = "/S"; secureArguments = $false }); uiPlan = @{ welcome = $true }; configPlan = @{}; admxPlan = @{}; visibility = "shared" }'),
|
||||
apiExample('Post', '/api/psadt/profiles/psadt_123/package', 'Assemble a portable PSADT v4 package: render Invoke-AppDeployToolkit.ps1, lay out Files/SupportFiles/Assets from the profile\'s linked assets, zip it, and ingest the zip into the Asset Library. Pass build = $true to also produce a .intunewin from the assembled tree (Windows + IntuneWinAppUtil or INTUNEWIN_BUILD_COMMAND). Download the returned asset via /api/assets/{id}/download.', '@{ build = $true; setupFile = "Files/setup.exe" }'),
|
||||
apiExample('Get', '/api/psadt/intune/deployments/intune_123/datasheet?format=html', 'Auto-generate a package datasheet (Markdown or self-contained HTML) for a deployment: metadata, install/uninstall commands, detection, return codes, assignments, requirements, and source/version. Use format=md or format=html; add download=1 to return it as a file attachment.'),
|
||||
apiExample('Post', '/api/psadt/intune/deployments', 'Create an Intune Publishing Wizard plan.', '@{ name = "Edge Pilot"; sourceFolder = "C:\\Packages\\Edge\\PSADT"; intunewinFile = "Edge.intunewin"; commandStyle = "v4"; installBehavior = "system"; restartBehavior = "return-code"; uiMode = "native-v4"; requirements = @{ architecture = "x64"; minOs = "Windows 10 22H2"; diskSpaceMb = 512; runAs32Bit = $false }; installCommand = "Invoke-AppDeployToolkit.exe -DeploymentType Install"; uninstallCommand = "Invoke-AppDeployToolkit.exe -DeploymentType Uninstall"; detectionType = "custom-script"; detectionRule = "Write-Output detected"; returnCodes = @(@{ code = 0; type = "success"; meaning = "OK" }, @{ code = 1602; type = "retry"; meaning = "Deferred" }, @{ code = 3010; type = "softReboot"; meaning = "Reboot" }); assignments = @(@{ ring = "Pilot"; intent = "available"; groupName = "IT Pilot"; notes = "Validate" }); status = "ready"; visibility = "shared" }')
|
||||
], ['api', 'psadt', 'intune', 'migration'])
|
||||
,
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
// Curated application recipe library. Each entry holds the installer
|
||||
// technology, an example installer file name, silent install args, a detection
|
||||
// footprint, and (when one exists) the winget id for update tracking.
|
||||
//
|
||||
// `installerType` must match an INSTALLER_TECHNOLOGIES id.
|
||||
// `detection` is passed verbatim to detectionRuleService.buildDetectionRule.
|
||||
// Args with <angle-bracket> placeholders are intentional refine-me prompts.
|
||||
|
||||
export const RECIPES = [
|
||||
// --- Browsers --------------------------------------------------------------
|
||||
{
|
||||
id: 'google-chrome',
|
||||
vendor: 'Google', name: 'Chrome', category: 'Browser',
|
||||
installerType: 'msi', fileName: 'googlechromestandaloneenterprise64.msi', arch: 'x64',
|
||||
installArgs: '/qn /norestart',
|
||||
closeProcesses: [{ name: 'chrome', description: 'Google Chrome' }],
|
||||
detection: { type: 'file', path: '%ProgramFiles%\\Google\\Chrome\\Application\\chrome.exe' },
|
||||
wingetId: 'Google.Chrome', homepage: 'https://chromeenterprise.google/browser/download/'
|
||||
},
|
||||
{
|
||||
id: 'mozilla-firefox',
|
||||
vendor: 'Mozilla', name: 'Firefox', category: 'Browser',
|
||||
installerType: 'nsis', fileName: 'Firefox Setup.exe', arch: 'x64',
|
||||
installArgs: '/S',
|
||||
closeProcesses: [{ name: 'firefox', description: 'Mozilla Firefox' }],
|
||||
detection: { type: 'file', path: '%ProgramFiles%\\Mozilla Firefox\\firefox.exe' },
|
||||
wingetId: 'Mozilla.Firefox', homepage: 'https://www.mozilla.org/firefox/all/'
|
||||
},
|
||||
{
|
||||
id: 'microsoft-edge',
|
||||
vendor: 'Microsoft', name: 'Edge', category: 'Browser',
|
||||
installerType: 'msi', fileName: 'MicrosoftEdgeEnterpriseX64.msi', arch: 'x64',
|
||||
installArgs: '/qn /norestart',
|
||||
closeProcesses: [{ name: 'msedge', description: 'Microsoft Edge' }],
|
||||
detection: { type: 'file', path: '%ProgramFiles(x86)%\\Microsoft\\Edge\\Application\\msedge.exe' },
|
||||
wingetId: 'Microsoft.Edge', homepage: 'https://www.microsoft.com/edge/business/download'
|
||||
},
|
||||
{
|
||||
id: 'brave',
|
||||
vendor: 'Brave', name: 'Brave Browser', category: 'Browser',
|
||||
installerType: 'nsis', fileName: 'BraveBrowserStandaloneSetup.exe', arch: 'x64',
|
||||
installArgs: '/silent /install',
|
||||
detection: { type: 'file', path: '%ProgramFiles%\\BraveSoftware\\Brave-Browser\\Application\\brave.exe' },
|
||||
wingetId: 'Brave.Brave', homepage: 'https://brave.com/download/'
|
||||
},
|
||||
|
||||
// --- Productivity & Office -------------------------------------------------
|
||||
{
|
||||
id: 'adobe-reader',
|
||||
vendor: 'Adobe', name: 'Acrobat Reader', category: 'Productivity',
|
||||
installerType: 'msi', fileName: 'AcroRead.msi', arch: 'x64',
|
||||
installArgs: '/qn /norestart',
|
||||
closeProcesses: [{ name: 'Acrobat', description: 'Adobe Acrobat' }, { name: 'AcroRd32', description: 'Adobe Reader' }],
|
||||
detection: { type: 'file', path: '%ProgramFiles%\\Adobe\\Acrobat DC\\Acrobat\\Acrobat.exe' },
|
||||
wingetId: 'Adobe.Acrobat.Reader.64-bit', homepage: 'https://get.adobe.com/reader/enterprise/'
|
||||
},
|
||||
{
|
||||
id: 'microsoft-365-apps',
|
||||
vendor: 'Microsoft', name: 'Microsoft 365 Apps (Word/Excel/PowerPoint)', category: 'Productivity',
|
||||
installerType: 'exe', fileName: 'setup.exe', arch: 'x64',
|
||||
// Office is deployed with the Office Deployment Tool + a configuration.xml.
|
||||
installArgs: '/configure configuration.xml',
|
||||
closeProcesses: [{ name: 'winword', description: 'Word' }, { name: 'excel', description: 'Excel' }, { name: 'powerpnt', description: 'PowerPoint' }, { name: 'outlook', description: 'Outlook' }],
|
||||
detection: { type: 'file', path: '%ProgramFiles%\\Microsoft Office\\root\\Office16\\WINWORD.EXE' },
|
||||
wingetId: 'Microsoft.Office', homepage: 'https://www.microsoft.com/microsoft-365/enterprise'
|
||||
},
|
||||
{
|
||||
id: 'libreoffice',
|
||||
vendor: 'The Document Foundation', name: 'LibreOffice', category: 'Productivity',
|
||||
installerType: 'msi', fileName: 'LibreOffice.msi', arch: 'x64',
|
||||
installArgs: '/qn /norestart',
|
||||
detection: { type: 'file', path: '%ProgramFiles%\\LibreOffice\\program\\soffice.exe' },
|
||||
wingetId: 'TheDocumentFoundation.LibreOffice', homepage: 'https://www.libreoffice.org/download/download/'
|
||||
},
|
||||
|
||||
// --- Communication ---------------------------------------------------------
|
||||
{
|
||||
id: 'zoom',
|
||||
vendor: 'Zoom', name: 'Zoom Workplace', category: 'Communication',
|
||||
installerType: 'msi', fileName: 'ZoomInstallerFull.msi', arch: 'x64',
|
||||
installArgs: '/qn /norestart ZoomAutoUpdate=true',
|
||||
closeProcesses: [{ name: 'Zoom', description: 'Zoom' }],
|
||||
detection: { type: 'file', path: '%ProgramFiles%\\Zoom\\bin\\Zoom.exe' },
|
||||
wingetId: 'Zoom.Zoom', homepage: 'https://zoom.us/download'
|
||||
},
|
||||
{
|
||||
id: 'microsoft-teams',
|
||||
vendor: 'Microsoft', name: 'Teams (new)', category: 'Communication',
|
||||
installerType: 'msix', fileName: 'MSTeams-x64.msix', arch: 'x64',
|
||||
installArgs: '',
|
||||
detection: { type: 'file', path: '%ProgramFiles%\\WindowsApps\\MSTeams_*\\ms-teams.exe' },
|
||||
wingetId: 'Microsoft.Teams', homepage: 'https://www.microsoft.com/microsoft-teams/download-app'
|
||||
},
|
||||
{
|
||||
id: 'slack',
|
||||
vendor: 'Slack', name: 'Slack', category: 'Communication',
|
||||
installerType: 'msi', fileName: 'slack-standalone.msi', arch: 'x64',
|
||||
installArgs: '/qn /norestart',
|
||||
closeProcesses: [{ name: 'slack', description: 'Slack' }],
|
||||
detection: { type: 'registry', keyPath: 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{Slack}', valueName: 'DisplayName' },
|
||||
wingetId: 'SlackTechnologies.Slack', homepage: 'https://slack.com/downloads/windows'
|
||||
},
|
||||
{
|
||||
id: 'cisco-webex',
|
||||
vendor: 'Cisco', name: 'Webex App', category: 'Communication',
|
||||
installerType: 'msi', fileName: 'Webex.msi', arch: 'x64',
|
||||
installArgs: '/qn /norestart ALLUSERS=1 AUTOUPGRADEENABLED=1',
|
||||
detection: { type: 'file', path: '%ProgramFiles(x86)%\\Cisco Spark\\CiscoCollabHost.exe' },
|
||||
wingetId: 'Cisco.Webex', homepage: 'https://www.webex.com/downloads.html'
|
||||
},
|
||||
|
||||
// --- Media -----------------------------------------------------------------
|
||||
{
|
||||
id: 'vlc',
|
||||
vendor: 'VideoLAN', name: 'VLC media player', category: 'Media',
|
||||
installerType: 'nsis', fileName: 'vlc-win64.exe', arch: 'x64',
|
||||
installArgs: '/S',
|
||||
closeProcesses: [{ name: 'vlc', description: 'VLC media player' }],
|
||||
detection: { type: 'file', path: '%ProgramFiles%\\VideoLAN\\VLC\\vlc.exe' },
|
||||
wingetId: 'VideoLAN.VLC', homepage: 'https://www.videolan.org/vlc/'
|
||||
},
|
||||
|
||||
// --- Utilities -------------------------------------------------------------
|
||||
{
|
||||
id: '7zip',
|
||||
vendor: '7-Zip', name: '7-Zip', category: 'Utility',
|
||||
installerType: 'msi', fileName: '7z-x64.msi', arch: 'x64',
|
||||
installArgs: '/qn /norestart',
|
||||
detection: { type: 'file', path: '%ProgramFiles%\\7-Zip\\7z.exe' },
|
||||
wingetId: '7zip.7zip', homepage: 'https://www.7-zip.org/download.html'
|
||||
},
|
||||
{
|
||||
id: 'winrar',
|
||||
vendor: 'RARLAB', name: 'WinRAR', category: 'Utility',
|
||||
installerType: 'exe', fileName: 'winrar-x64.exe', arch: 'x64',
|
||||
installArgs: '/S',
|
||||
detection: { type: 'file', path: '%ProgramFiles%\\WinRAR\\WinRAR.exe' },
|
||||
wingetId: 'RARLab.WinRAR', homepage: 'https://www.win-rar.com/download.html'
|
||||
},
|
||||
{
|
||||
id: 'notepad-plus-plus',
|
||||
vendor: 'Notepad++', name: 'Notepad++', category: 'Utility',
|
||||
installerType: 'nsis', fileName: 'npp.Installer.x64.exe', arch: 'x64',
|
||||
installArgs: '/S',
|
||||
closeProcesses: [{ name: 'notepad++', description: 'Notepad++' }],
|
||||
detection: { type: 'file', path: '%ProgramFiles%\\Notepad++\\notepad++.exe' },
|
||||
wingetId: 'Notepad++.Notepad++', homepage: 'https://notepad-plus-plus.org/downloads/'
|
||||
},
|
||||
{
|
||||
id: 'greenshot',
|
||||
vendor: 'Greenshot', name: 'Greenshot', category: 'Utility',
|
||||
installerType: 'inno', fileName: 'Greenshot-INSTALLER.exe', arch: 'x64',
|
||||
installArgs: '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-',
|
||||
detection: { type: 'file', path: '%ProgramFiles%\\Greenshot\\Greenshot.exe' },
|
||||
wingetId: 'Greenshot.Greenshot', homepage: 'https://getgreenshot.org/downloads/'
|
||||
},
|
||||
{
|
||||
id: 'putty',
|
||||
vendor: 'Simon Tatham', name: 'PuTTY', category: 'Utility',
|
||||
installerType: 'msi', fileName: 'putty-64bit-installer.msi', arch: 'x64',
|
||||
installArgs: '/qn /norestart',
|
||||
detection: { type: 'file', path: '%ProgramFiles%\\PuTTY\\putty.exe' },
|
||||
wingetId: 'PuTTY.PuTTY', homepage: 'https://www.putty.org/'
|
||||
},
|
||||
{
|
||||
id: 'winscp',
|
||||
vendor: 'WinSCP', name: 'WinSCP', category: 'Utility',
|
||||
installerType: 'inno', fileName: 'WinSCP-Setup.exe', arch: 'x64',
|
||||
installArgs: '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /ALLUSERS',
|
||||
detection: { type: 'file', path: '%ProgramFiles(x86)%\\WinSCP\\WinSCP.exe' },
|
||||
wingetId: 'WinSCP.WinSCP', homepage: 'https://winscp.net/eng/download.php'
|
||||
},
|
||||
|
||||
// --- Developer -------------------------------------------------------------
|
||||
{
|
||||
id: 'git',
|
||||
vendor: 'Git', name: 'Git for Windows', category: 'Developer',
|
||||
installerType: 'inno', fileName: 'Git-64-bit.exe', arch: 'x64',
|
||||
installArgs: '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-',
|
||||
detection: { type: 'file', path: '%ProgramFiles%\\Git\\bin\\git.exe' },
|
||||
wingetId: 'Git.Git', homepage: 'https://git-scm.com/download/win'
|
||||
},
|
||||
{
|
||||
id: 'vscode',
|
||||
vendor: 'Microsoft', name: 'Visual Studio Code', category: 'Developer',
|
||||
installerType: 'inno', fileName: 'VSCodeSetup-x64.exe', arch: 'x64',
|
||||
installArgs: '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /MERGETASKS=!runcode',
|
||||
closeProcesses: [{ name: 'Code', description: 'Visual Studio Code' }],
|
||||
detection: { type: 'file', path: '%ProgramFiles%\\Microsoft VS Code\\Code.exe' },
|
||||
wingetId: 'Microsoft.VisualStudioCode', homepage: 'https://code.visualstudio.com/download'
|
||||
},
|
||||
{
|
||||
id: 'powershell-7',
|
||||
vendor: 'Microsoft', name: 'PowerShell 7', category: 'Developer',
|
||||
installerType: 'msi', fileName: 'PowerShell-7-win-x64.msi', arch: 'x64',
|
||||
installArgs: '/qn /norestart',
|
||||
detection: { type: 'file', path: '%ProgramFiles%\\PowerShell\\7\\pwsh.exe' },
|
||||
wingetId: 'Microsoft.PowerShell', homepage: 'https://github.com/PowerShell/PowerShell/releases'
|
||||
},
|
||||
{
|
||||
id: 'nodejs',
|
||||
vendor: 'OpenJS Foundation', name: 'Node.js LTS', category: 'Developer',
|
||||
installerType: 'msi', fileName: 'node-x64.msi', arch: 'x64',
|
||||
installArgs: '/qn /norestart',
|
||||
detection: { type: 'file', path: '%ProgramFiles%\\nodejs\\node.exe' },
|
||||
wingetId: 'OpenJS.NodeJS.LTS', homepage: 'https://nodejs.org/en/download'
|
||||
},
|
||||
{
|
||||
id: 'python-3',
|
||||
vendor: 'Python Software Foundation', name: 'Python 3', category: 'Developer',
|
||||
installerType: 'exe', fileName: 'python-installer.exe', arch: 'x64',
|
||||
installArgs: '/quiet InstallAllUsers=1 PrependPath=1 Include_test=0',
|
||||
detection: { type: 'registry', keyPath: 'HKLM:\\SOFTWARE\\Python\\PythonCore' },
|
||||
wingetId: 'Python.Python.3.12', homepage: 'https://www.python.org/downloads/windows/'
|
||||
},
|
||||
{
|
||||
id: 'temurin-jdk',
|
||||
vendor: 'Eclipse Adoptium', name: 'Temurin JDK 21', category: 'Developer',
|
||||
installerType: 'msi', fileName: 'OpenJDK21-jdk_x64_windows.msi', arch: 'x64',
|
||||
installArgs: '/qn /norestart ADDLOCAL=FeatureMain,FeatureEnvironment',
|
||||
detection: { type: 'file', path: '%ProgramFiles%\\Eclipse Adoptium\\jdk-21\\bin\\java.exe' },
|
||||
wingetId: 'EclipseAdoptium.Temurin.21.JDK', homepage: 'https://adoptium.net/temurin/releases/'
|
||||
},
|
||||
|
||||
// --- Remote access & virtualization ---------------------------------------
|
||||
{
|
||||
id: 'vmware-workstation',
|
||||
vendor: 'Broadcom (VMware)', name: 'Workstation Pro', category: 'Virtualization',
|
||||
installerType: 'installshield', fileName: 'VMware-workstation.exe', arch: 'x64',
|
||||
installArgs: '/s /v"/qn EULAS_AGREED=1 AUTOSOFTWAREUPDATE=0 REBOOT=ReallySuppress"',
|
||||
detection: { type: 'file', path: '%ProgramFiles(x86)%\\VMware\\VMware Workstation\\vmware.exe' },
|
||||
wingetId: 'VMware.WorkstationPro', homepage: 'https://www.vmware.com/products/workstation-pro.html'
|
||||
},
|
||||
{
|
||||
id: 'realvnc-server',
|
||||
vendor: 'RealVNC', name: 'VNC Server', category: 'Remote access',
|
||||
installerType: 'msi', fileName: 'VNC-Server.msi', arch: 'x64',
|
||||
installArgs: '/qn /norestart',
|
||||
detection: { type: 'file', path: '%ProgramFiles%\\RealVNC\\VNC Server\\vncserver.exe' },
|
||||
wingetId: 'RealVNC.VNCServer', homepage: 'https://www.realvnc.com/en/connect/download/vnc/'
|
||||
},
|
||||
{
|
||||
id: 'teamviewer-host',
|
||||
vendor: 'TeamViewer', name: 'TeamViewer Host', category: 'Remote access',
|
||||
installerType: 'msi', fileName: 'TeamViewer_Host.msi', arch: 'x64',
|
||||
installArgs: '/qn /norestart',
|
||||
detection: { type: 'file', path: '%ProgramFiles%\\TeamViewer\\TeamViewer.exe' },
|
||||
wingetId: 'TeamViewer.TeamViewer.Host', homepage: 'https://www.teamviewer.com/download/windows/'
|
||||
},
|
||||
{
|
||||
id: 'anydesk',
|
||||
vendor: 'AnyDesk', name: 'AnyDesk', category: 'Remote access',
|
||||
installerType: 'exe', fileName: 'AnyDesk.exe', arch: 'x64',
|
||||
installArgs: '--install "%ProgramFiles(x86)%\\AnyDesk" --start-with-win --silent',
|
||||
detection: { type: 'file', path: '%ProgramFiles(x86)%\\AnyDesk\\AnyDesk.exe' },
|
||||
wingetId: 'AnyDeskSoftwareGmbH.AnyDesk', homepage: 'https://anydesk.com/en/downloads/windows'
|
||||
},
|
||||
|
||||
// --- Security & enterprise agents (no winget; vendor-managed updates) ------
|
||||
{
|
||||
id: 'crowdstrike-falcon',
|
||||
vendor: 'CrowdStrike', name: 'Falcon Sensor', category: 'Security agent',
|
||||
installerType: 'msi', fileName: 'WindowsSensor.exe', arch: 'x64',
|
||||
installArgs: '/install /quiet /norestart CID=<your-falcon-cid>',
|
||||
detection: { type: 'file', path: '%ProgramFiles%\\CrowdStrike\\CSFalconService.exe' },
|
||||
wingetId: '', homepage: 'https://www.crowdstrike.com/'
|
||||
},
|
||||
{
|
||||
id: 'cisco-umbrella',
|
||||
vendor: 'Cisco', name: 'Umbrella Roaming Client', category: 'Security agent',
|
||||
installerType: 'msi', fileName: 'Setup.msi', arch: 'x64',
|
||||
installArgs: '/qn /norestart',
|
||||
detection: { type: 'file', path: '%ProgramFiles(x86)%\\OpenDNS\\Umbrella Roaming Client\\ERAgent.exe' },
|
||||
wingetId: '', homepage: 'https://umbrella.cisco.com/'
|
||||
},
|
||||
{
|
||||
id: 'splunk-uf',
|
||||
vendor: 'Splunk', name: 'Universal Forwarder', category: 'Monitoring agent',
|
||||
installerType: 'msi', fileName: 'splunkforwarder-x64.msi', arch: 'x64',
|
||||
installArgs: '/qn AGREETOLICENSE=Yes RECEIVING_INDEXER="<indexer-host>:9997" SPLUNKUSERNAME=admin',
|
||||
detection: { type: 'file', path: '%ProgramFiles%\\SplunkUniversalForwarder\\bin\\splunk.exe' },
|
||||
wingetId: '', homepage: 'https://www.splunk.com/en_us/download/universal-forwarder.html'
|
||||
},
|
||||
{
|
||||
id: 'nessus-agent',
|
||||
vendor: 'Tenable', name: 'Nessus Agent', category: 'Security agent',
|
||||
installerType: 'msi', fileName: 'NessusAgent-x64.msi', arch: 'x64',
|
||||
installArgs: '/qn NESSUS_GROUPS="<group>" NESSUS_SERVER="<manager>:8834" NESSUS_KEY="<linking-key>"',
|
||||
detection: { type: 'file', path: '%ProgramFiles%\\Tenable\\Nessus Agent\\nessuscli.exe' },
|
||||
wingetId: '', homepage: 'https://www.tenable.com/downloads/nessus-agents'
|
||||
},
|
||||
{
|
||||
id: 'sap-gui',
|
||||
vendor: 'SAP', name: 'SAP GUI for Windows', category: 'Enterprise',
|
||||
installerType: 'exe', fileName: 'NwSapSetup.exe', arch: 'x64',
|
||||
installArgs: '/silent /product="SAPGUI"',
|
||||
detection: { type: 'file', path: '%ProgramFiles(x86)%\\SAP\\FrontEnd\\SAPGUI\\saplogon.exe' },
|
||||
wingetId: '', homepage: 'https://support.sap.com/'
|
||||
}
|
||||
];
|
||||
+1
-4
@@ -11,7 +11,6 @@ import { startCatalogWorker } from './services/catalogWorker.js';
|
||||
import { startPromotionWorker } from './services/promotionWorker.js';
|
||||
import { startHostGroupWorker } from './services/hostGroupWorker.js';
|
||||
import { startRunPlanScheduleWorker } from './services/runPlanScheduleWorker.js';
|
||||
import { mountRdpGateway, startRdpGateway } from './services/rdpGatewayService.js';
|
||||
|
||||
// Fail fast in production rather than ship with well-known default secrets.
|
||||
// These defaults are fine for local development but are publicly known, so a
|
||||
@@ -56,16 +55,14 @@ app.use(cors({
|
||||
app.use(express.json({ limit: config.jsonLimit }));
|
||||
app.use(requestLogger);
|
||||
app.use('/api', apiRoutes);
|
||||
mountRdpGateway(app);
|
||||
|
||||
app.use((error, req, res, next) => {
|
||||
logger.error('unhandled error', { error });
|
||||
res.status(500).json({ error: 'Unexpected server error' });
|
||||
});
|
||||
|
||||
const httpServer = app.listen(config.port, () => {
|
||||
app.listen(config.port, () => {
|
||||
logger.info(`POSHManager API listening on ${config.port}`);
|
||||
startRdpGateway(httpServer);
|
||||
startCatalogWorker();
|
||||
startPromotionWorker();
|
||||
startHostGroupWorker();
|
||||
|
||||
@@ -221,6 +221,25 @@ export function deleteAsset(assetId, userId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Reverse of listAssetLinks: every asset linked to a target (e.g. a PSADT
|
||||
// profile) that the user can see, with the per-link package path/role used by
|
||||
// the package builder to place it in the deployment tree.
|
||||
export function listLinkedAssets(targetType, targetId, userId) {
|
||||
return db.prepare(`
|
||||
SELECT a.*, l.link_role AS link_role, l.package_path AS link_package_path
|
||||
FROM asset_links l
|
||||
INNER JOIN assets a ON a.id = l.asset_id
|
||||
WHERE l.target_type = ? AND l.target_id = ? AND ${visibleClause('a')}
|
||||
ORDER BY l.created_at
|
||||
`).all(targetType, targetId, userId, userId).map((row) => ({
|
||||
assetId: row.id,
|
||||
originalName: row.original_name,
|
||||
kind: row.kind,
|
||||
linkRole: row.link_role,
|
||||
packagePath: row.link_package_path || row.package_path || row.original_name
|
||||
}));
|
||||
}
|
||||
|
||||
export function listAssetLinks(assetId, userId) {
|
||||
const asset = selectAssetById(assetId, userId);
|
||||
if (!asset) return null;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import { analyze, detection, installerTypes } from '../controllers/packagingController.js';
|
||||
import { analyze, applyRecipe, detection, installerTypes, recipes } from '../controllers/packagingController.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const packagingRoutes = Router();
|
||||
|
||||
// Phase 1 installer intelligence: detect technology, recommend silent commands,
|
||||
// and generate detection rules.
|
||||
packagingRoutes.get('/installer-types', requireAuth, installerTypes);
|
||||
packagingRoutes.post('/analyze', requireAuth, analyze);
|
||||
packagingRoutes.post('/detection', requireAuth, detection);
|
||||
packagingRoutes.get('/recipes', requireAuth, recipes);
|
||||
packagingRoutes.post('/recipes/:id/apply', requireAuth, applyRecipe);
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
destroy,
|
||||
index,
|
||||
intuneCreate,
|
||||
intuneDatasheet,
|
||||
intuneDestroy,
|
||||
intuneGraphAssign,
|
||||
intuneGraphAudit,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
intuneUpdate,
|
||||
migrationApply,
|
||||
migrationPlan,
|
||||
packageProfile,
|
||||
render,
|
||||
rules,
|
||||
show,
|
||||
@@ -60,9 +62,11 @@ psadtRoutes.post('/intune/deployments/:id/build', requireAuth, intuneBuild);
|
||||
psadtRoutes.post('/intune/deployments/:id/new-version', requireAuth, intuneNewVersion);
|
||||
psadtRoutes.post('/intune/deployments/:id/promote', requireAuth, intunePromote);
|
||||
psadtRoutes.get('/intune/deployments/:id/graph/audit', requireAuth, intuneGraphAudit);
|
||||
psadtRoutes.get('/intune/deployments/:id/datasheet', requireAuth, intuneDatasheet);
|
||||
psadtRoutes.get('/profiles', requireAuth, index);
|
||||
psadtRoutes.post('/profiles', requireAuth, create);
|
||||
psadtRoutes.get('/profiles/:id', requireAuth, show);
|
||||
psadtRoutes.put('/profiles/:id', requireAuth, update);
|
||||
psadtRoutes.delete('/profiles/:id', requireAuth, destroy);
|
||||
psadtRoutes.get('/profiles/:id/render', requireAuth, render);
|
||||
psadtRoutes.post('/profiles/:id/package', requireAuth, packageProfile);
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -35,6 +35,14 @@ export function settingDefinitions() {
|
||||
{ key: 'entra_password_change_url', value: config.entra.passwordChangeUrl, pinned: envProvided('ENTRA_PASSWORD_CHANGE_URL') },
|
||||
{ key: 'powershell_bin', value: config.powershellBin, pinned: false },
|
||||
{ key: 'allow_script_execution', value: String(config.allowScriptExecution), pinned: envProvided('ALLOW_SCRIPT_EXECUTION') },
|
||||
{ key: 'myrtille_enabled', value: String(config.myrtille.enabled), pinned: envProvided('MYRTILLE_ENABLED') },
|
||||
{ key: 'myrtille_gateway_url', value: config.myrtille.gatewayUrl, pinned: envProvided('MYRTILLE_GATEWAY_URL') },
|
||||
{ key: 'myrtille_use_password_hash', value: String(config.myrtille.usePasswordHash), pinned: envProvided('MYRTILLE_USE_PASSWORD_HASH') },
|
||||
{ key: 'myrtille_allow_plain_password', value: String(config.myrtille.allowPlainPassword), pinned: envProvided('MYRTILLE_ALLOW_PLAIN_PASSWORD') },
|
||||
{ key: 'myrtille_hash_endpoint', value: config.myrtille.hashEndpoint, pinned: envProvided('MYRTILLE_HASH_ENDPOINT') },
|
||||
{ key: 'myrtille_default_width', value: String(config.myrtille.defaultWidth), pinned: envProvided('MYRTILLE_DEFAULT_WIDTH') },
|
||||
{ key: 'myrtille_default_height', value: String(config.myrtille.defaultHeight), pinned: envProvided('MYRTILLE_DEFAULT_HEIGHT') },
|
||||
{ key: 'myrtille_request_timeout_ms', value: String(config.myrtille.requestTimeoutMs), pinned: envProvided('MYRTILLE_REQUEST_TIMEOUT_MS') },
|
||||
{ key: 'host_group_sync_interval_minutes', value: process.env.HOST_GROUP_SYNC_INTERVAL_MINUTES || '60', pinned: envProvided('HOST_GROUP_SYNC_INTERVAL_MINUTES') },
|
||||
{ key: 'runplan_schedule_interval_minutes', value: process.env.RUNPLAN_SCHEDULE_INTERVAL_MINUTES || '1', pinned: envProvided('RUNPLAN_SCHEDULE_INTERVAL_MINUTES') },
|
||||
{ key: 'vcenter_enabled', value: String(config.vcenter.enabled), pinned: envProvided('VCENTER_ENABLED') },
|
||||
|
||||
@@ -6,7 +6,8 @@ const { createHost, listHosts, updateHost, deleteHost, filterVisibleHostIds } =
|
||||
const { createCredential } = await load('../models/credentialModel.js');
|
||||
const { createHostGroup, listHostGroups, syncHostGroup } = await load('../models/hostGroupModel.js');
|
||||
const { createRunPlan, loadRunPlan } = await load('../models/runPlanModel.js');
|
||||
const { createRdpLaunchSession } = await load('../services/rdpGatewayService.js');
|
||||
const { createRdpLaunchSession, buildMyrtilleLaunchUrl } = await load('../services/rdpGatewayService.js');
|
||||
const { updateSettings } = await load('../models/settingsModel.js');
|
||||
const { db } = await load('../db.js');
|
||||
|
||||
const owner = adminUserId();
|
||||
@@ -54,7 +55,15 @@ test('filterVisibleHostIds drops hosts the user cannot see', () => {
|
||||
assert.deepEqual(visibleToOther, [shared.id]);
|
||||
});
|
||||
|
||||
test('RDP launch creates a short-lived browser session for Windows hosts with visible credentials', () => {
|
||||
test('RDP launch creates a Myrtille browser URL for Windows hosts with visible credentials', async () => {
|
||||
updateSettings({
|
||||
myrtille_enabled: 'true',
|
||||
myrtille_gateway_url: 'https://rdp.contoso.local/Myrtille/',
|
||||
myrtille_use_password_hash: 'false',
|
||||
myrtille_allow_plain_password: 'true',
|
||||
myrtille_default_width: '1440',
|
||||
myrtille_default_height: '900'
|
||||
});
|
||||
const credential = createCredential({
|
||||
name: 'RDP Admin',
|
||||
kind: 'username_password',
|
||||
@@ -72,14 +81,28 @@ test('RDP launch creates a short-lived browser session for Windows hosts with vi
|
||||
tags: [],
|
||||
visibility: 'shared'
|
||||
}, owner);
|
||||
const session = createRdpLaunchSession(host.id, other);
|
||||
assert.match(session.url, /^\/rdp\/[^/]+$/);
|
||||
const session = await createRdpLaunchSession(host.id, other);
|
||||
const url = new URL(session.url);
|
||||
assert.equal(session.provider, 'myrtille');
|
||||
assert.equal(url.origin + url.pathname, 'https://rdp.contoso.local/Myrtille/');
|
||||
assert.equal(url.searchParams.get('server'), 'rdp-win-01.contoso.local');
|
||||
assert.equal(url.searchParams.get('domain'), 'CONTOSO');
|
||||
assert.equal(url.searchParams.get('user'), 'rdpadmin');
|
||||
assert.equal(url.searchParams.get('password'), 'rdp-secret');
|
||||
assert.equal(url.searchParams.get('width'), '1440');
|
||||
assert.equal(url.searchParams.get('height'), '900');
|
||||
assert.equal(url.searchParams.get('connect'), 'Connect!');
|
||||
assert.equal(session.host.name, 'RDP-WIN-01');
|
||||
assert.equal(session.host.address, 'rdp-win-01.contoso.local');
|
||||
assert.ok(!session.url.includes('rdp-secret'));
|
||||
});
|
||||
|
||||
test('RDP launch rejects non-Windows hosts or hosts without credentials', () => {
|
||||
test('RDP launch rejects non-Windows hosts, hosts without credentials, or disabled Myrtille config', async () => {
|
||||
updateSettings({
|
||||
myrtille_enabled: 'true',
|
||||
myrtille_gateway_url: 'https://rdp.contoso.local/Myrtille/',
|
||||
myrtille_use_password_hash: 'false',
|
||||
myrtille_allow_plain_password: 'true'
|
||||
});
|
||||
const credential = createCredential({
|
||||
name: 'RDP Linux Credential',
|
||||
kind: 'username_password',
|
||||
@@ -88,10 +111,29 @@ test('RDP launch rejects non-Windows hosts or hosts without credentials', () =>
|
||||
visibility: 'shared'
|
||||
}, owner);
|
||||
const linux = createHost({ name: 'RDP-LINUX-01', address: 'rdp-linux.local', osFamily: 'linux', transport: 'ssh', credentialId: credential.id, tags: [], visibility: 'shared' }, owner);
|
||||
assert.throws(() => createRdpLaunchSession(linux.id, owner), /Windows hosts/i);
|
||||
await assert.rejects(() => createRdpLaunchSession(linux.id, owner), /Windows hosts/i);
|
||||
|
||||
const noCredential = createHost({ name: 'RDP-NOCRED-01', address: 'rdp-nocred.local', osFamily: 'windows', transport: 'winrm', tags: [], visibility: 'shared' }, owner);
|
||||
assert.throws(() => createRdpLaunchSession(noCredential.id, owner), /assigned visible credential/i);
|
||||
await assert.rejects(() => createRdpLaunchSession(noCredential.id, owner), /assigned visible credential/i);
|
||||
|
||||
const windows = createHost({ name: 'RDP-DISABLED-01', address: 'rdp-disabled.local', osFamily: 'windows', transport: 'winrm', credentialId: credential.id, tags: [], visibility: 'shared' }, owner);
|
||||
updateSettings({ myrtille_enabled: 'false' });
|
||||
await assert.rejects(() => createRdpLaunchSession(windows.id, owner), /Myrtille RDP gateway is not configured/i);
|
||||
});
|
||||
|
||||
test('Myrtille launch URL can carry passwordHash without exposing plaintext', () => {
|
||||
const url = new URL(buildMyrtilleLaunchUrl({
|
||||
gatewayUrl: 'https://rdp.contoso.local/Myrtille/',
|
||||
target: 'win-01.contoso.local',
|
||||
domain: 'CONTOSO',
|
||||
username: 'admin',
|
||||
passwordHash: 'hash-value',
|
||||
width: 1280,
|
||||
height: 800
|
||||
}));
|
||||
assert.equal(url.searchParams.get('passwordHash'), 'hash-value');
|
||||
assert.equal(url.searchParams.has('password'), false);
|
||||
assert.equal(url.searchParams.get('connect'), 'Connect!');
|
||||
});
|
||||
|
||||
test('manual host groups keep explicit members', () => {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import './setup.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { adminUserId, load } from './setup.mjs';
|
||||
|
||||
const owner = adminUserId();
|
||||
const { createPsadtProfile, renderPsadtProfile } = await load('../models/psadtModel.js');
|
||||
const { createAssetFromUpload, createAssetLink, listLinkedAssets } = await load('../models/assetModel.js');
|
||||
const { buildZip, planPackageLayout } = await load('../services/packageBuilder.js');
|
||||
|
||||
function seedAsset(name, contents) {
|
||||
const tmp = path.join(os.tmpdir(), `pkg-src-${Math.random().toString(16).slice(2)}`);
|
||||
fs.writeFileSync(tmp, contents);
|
||||
return createAssetFromUpload(
|
||||
{ path: tmp, originalname: name, mimetype: 'application/octet-stream', size: contents.length },
|
||||
{ name, visibility: 'shared' },
|
||||
owner
|
||||
);
|
||||
}
|
||||
|
||||
test('listLinkedAssets returns a PSADT profile\'s linked assets with their package path and role', () => {
|
||||
const profile = createPsadtProfile({ name: 'Linked Pkg App', appVendor: 'Acme', appName: 'Tool', visibility: 'shared' }, owner);
|
||||
const installer = seedAsset('setup.exe', Buffer.from('MZ binary'));
|
||||
createAssetLink(installer.id, { targetType: 'psadt_profile', targetId: profile.id, linkRole: 'installer', packagePath: 'Files/setup.exe' }, owner);
|
||||
|
||||
const linked = listLinkedAssets('psadt_profile', profile.id, owner);
|
||||
assert.equal(linked.length, 1);
|
||||
assert.equal(linked[0].assetId, installer.id);
|
||||
assert.equal(linked[0].linkRole, 'installer');
|
||||
assert.equal(linked[0].packagePath, 'Files/setup.exe');
|
||||
});
|
||||
|
||||
test('a profile + linked assets assemble into a zip carrying the rendered script and payload', () => {
|
||||
const profile = createPsadtProfile({ name: 'Assemble App', appVendor: 'Acme', appName: 'Assemble', visibility: 'shared' }, owner);
|
||||
const installer = seedAsset('install.exe', Buffer.from('payload-bytes'));
|
||||
const config = seedAsset('settings.xml', Buffer.from('<config/>'));
|
||||
createAssetLink(installer.id, { targetType: 'psadt_profile', targetId: profile.id, linkRole: 'installer' }, owner);
|
||||
createAssetLink(config.id, { targetType: 'psadt_profile', targetId: profile.id, linkRole: 'support-file' }, owner);
|
||||
|
||||
const rendered = renderPsadtProfile(profile.id, owner);
|
||||
const layout = planPackageLayout(listLinkedAssets('psadt_profile', profile.id, owner));
|
||||
assert.deepEqual(layout.assets.map((a) => a.packagePath).sort(), ['Files/install.exe', 'SupportFiles/settings.xml']);
|
||||
|
||||
const zip = buildZip([
|
||||
{ path: 'Invoke-AppDeployToolkit.ps1', data: Buffer.from(rendered.script, 'utf8') },
|
||||
{ path: 'Files/install.exe', data: Buffer.from('payload-bytes') }
|
||||
]);
|
||||
const names = listZipNames(zip);
|
||||
assert.ok(names.includes('Invoke-AppDeployToolkit.ps1'));
|
||||
assert.ok(names.includes('Files/install.exe'));
|
||||
});
|
||||
|
||||
function listZipNames(buf) {
|
||||
const names = [];
|
||||
let pos = 0;
|
||||
while (buf.readUInt32LE(pos) === 0x04034b50) {
|
||||
const compSize = buf.readUInt32LE(pos + 18);
|
||||
const nameLen = buf.readUInt16LE(pos + 26);
|
||||
const extraLen = buf.readUInt16LE(pos + 28);
|
||||
names.push(buf.slice(pos + 30, pos + 30 + nameLen).toString('utf8'));
|
||||
pos = pos + 30 + nameLen + extraLen + compSize;
|
||||
}
|
||||
return names;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import zlib from 'node:zlib';
|
||||
import { ENTRY_SCRIPT_NAME, buildZip, planPackageLayout, resolveSetupFile } from '../services/packageBuilder.js';
|
||||
|
||||
// --- layout planning (pure) --------------------------------------------------
|
||||
|
||||
test('planPackageLayout places linked assets by role when no package path is set', () => {
|
||||
const layout = planPackageLayout([
|
||||
{ assetId: 'a1', originalName: 'setup.exe', linkRole: 'installer' },
|
||||
{ assetId: 'a2', originalName: 'config.xml', linkRole: 'support-file' },
|
||||
{ assetId: 'a3', originalName: 'icon.png', linkRole: 'icon' }
|
||||
]);
|
||||
assert.equal(layout.scriptPath, ENTRY_SCRIPT_NAME);
|
||||
assert.deepEqual(layout.assets.map((a) => a.packagePath), ['Files/setup.exe', 'SupportFiles/config.xml', 'Assets/icon.png']);
|
||||
assert.ok(layout.directories.includes('Assets'), 'Assets dir added because an icon is present');
|
||||
});
|
||||
|
||||
test('planPackageLayout honors an explicit package path verbatim', () => {
|
||||
const layout = planPackageLayout([
|
||||
{ assetId: 'a1', originalName: 'setup.exe', linkRole: 'installer', packagePath: 'Files/x64/setup.exe' }
|
||||
]);
|
||||
assert.equal(layout.assets[0].packagePath, 'Files/x64/setup.exe');
|
||||
});
|
||||
|
||||
test('planPackageLayout has no Assets dir when nothing maps there', () => {
|
||||
const layout = planPackageLayout([{ assetId: 'a1', originalName: 'setup.exe', linkRole: 'installer' }]);
|
||||
assert.deepEqual(layout.directories, ['Files', 'SupportFiles']);
|
||||
});
|
||||
|
||||
test('planPackageLayout drops colliding targets with a warning', () => {
|
||||
const layout = planPackageLayout([
|
||||
{ assetId: 'a1', originalName: 'setup.exe', linkRole: 'installer', packagePath: 'Files/setup.exe' },
|
||||
{ assetId: 'a2', originalName: 'other.exe', linkRole: 'installer', packagePath: 'files/SETUP.exe' }
|
||||
]);
|
||||
assert.equal(layout.assets.length, 1, 'second asset on the same path is skipped');
|
||||
assert.equal(layout.warnings.length, 1);
|
||||
});
|
||||
|
||||
test('planPackageLayout relocates a bare entry-script name under its role folder, never overwriting root', () => {
|
||||
const layout = planPackageLayout([
|
||||
{ assetId: 'a1', originalName: 'Invoke-AppDeployToolkit.ps1', linkRole: 'reference', packagePath: 'Invoke-AppDeployToolkit.ps1' }
|
||||
]);
|
||||
assert.equal(layout.assets[0].packagePath, 'SupportFiles/Invoke-AppDeployToolkit.ps1');
|
||||
assert.notEqual(layout.assets[0].packagePath, ENTRY_SCRIPT_NAME);
|
||||
});
|
||||
|
||||
// --- setup file resolution (pure) --------------------------------------------
|
||||
|
||||
test('resolveSetupFile prefers an explicit choice, then installer role, then first Files entry', () => {
|
||||
const layout = planPackageLayout([
|
||||
{ assetId: 'a1', originalName: 'helper.exe', linkRole: 'package-file' },
|
||||
{ assetId: 'a2', originalName: 'setup.exe', linkRole: 'installer' }
|
||||
]);
|
||||
assert.equal(resolveSetupFile(layout, 'Files/manual.exe'), 'Files/manual.exe');
|
||||
assert.equal(resolveSetupFile(layout), 'Files/setup.exe');
|
||||
|
||||
const noInstaller = planPackageLayout([{ assetId: 'a1', originalName: 'helper.exe', linkRole: 'package-file' }]);
|
||||
assert.equal(resolveSetupFile(noInstaller), 'Files/helper.exe');
|
||||
|
||||
const empty = planPackageLayout([]);
|
||||
assert.equal(resolveSetupFile(empty), '');
|
||||
});
|
||||
|
||||
// --- zip writer (pure, round-trips through zlib) -----------------------------
|
||||
|
||||
test('buildZip produces a valid archive whose entries inflate back to their input', () => {
|
||||
const script = 'Write-Output "hello"\n'.repeat(50);
|
||||
const binary = Buffer.from([0, 1, 2, 3, 255, 254, 253]);
|
||||
const zip = buildZip([
|
||||
{ path: ENTRY_SCRIPT_NAME, data: Buffer.from(script, 'utf8') },
|
||||
{ path: 'Files', isDirectory: true },
|
||||
{ path: 'Files/payload.bin', data: binary }
|
||||
]);
|
||||
|
||||
assert.equal(zip.readUInt32LE(0), 0x04034b50, 'starts with a local file header');
|
||||
const recovered = readZip(zip);
|
||||
assert.equal(recovered.get(ENTRY_SCRIPT_NAME).toString('utf8'), script);
|
||||
assert.ok(recovered.has('Files/'), 'directory entry is present');
|
||||
assert.deepEqual([...recovered.get('Files/payload.bin')], [...binary]);
|
||||
});
|
||||
|
||||
test('buildZip stores incompressible/empty data without inflating it', () => {
|
||||
const zip = buildZip([{ path: 'a.txt', data: Buffer.alloc(0) }]);
|
||||
assert.equal(readZip(zip).get('a.txt').length, 0);
|
||||
});
|
||||
|
||||
// Minimal central-directory reader for the test: walk local headers and inflate.
|
||||
function readZip(buf) {
|
||||
const out = new Map();
|
||||
let pos = 0;
|
||||
while (buf.readUInt32LE(pos) === 0x04034b50) {
|
||||
const method = buf.readUInt16LE(pos + 8);
|
||||
const compSize = buf.readUInt32LE(pos + 18);
|
||||
const nameLen = buf.readUInt16LE(pos + 26);
|
||||
const extraLen = buf.readUInt16LE(pos + 28);
|
||||
const name = buf.slice(pos + 30, pos + 30 + nameLen).toString('utf8');
|
||||
const dataStart = pos + 30 + nameLen + extraLen;
|
||||
const stored = buf.slice(dataStart, dataStart + compSize);
|
||||
out.set(name, method === 8 ? zlib.inflateRawSync(stored) : stored);
|
||||
pos = dataStart + compSize;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildDatasheetModel, renderDatasheet } from '../services/packageDocService.js';
|
||||
|
||||
function sampleDeployment(overrides = {}) {
|
||||
return {
|
||||
id: 'intune_1',
|
||||
name: 'Acme Reader 24.1',
|
||||
profileId: 'psadt_1',
|
||||
profileName: 'Acme Reader',
|
||||
applicationId: null,
|
||||
appType: 'Windows app (Win32)',
|
||||
commandStyle: 'v4',
|
||||
installBehavior: 'system',
|
||||
restartBehavior: 'return-code',
|
||||
sourceFolder: 'C:\\packages\\reader',
|
||||
intunewinFile: 'reader.intunewin',
|
||||
installCommand: 'Invoke-AppDeployToolkit.exe -DeploymentType Install',
|
||||
uninstallCommand: 'Invoke-AppDeployToolkit.exe -DeploymentType Uninstall',
|
||||
detectionType: 'msi-product-code',
|
||||
detectionRule: '{12345678-ABCD-1234-ABCD-1234567890AB}',
|
||||
requirements: { architecture: 'x64', minOs: 'Windows 10 22H2', diskSpaceMb: 250, runAs32Bit: false },
|
||||
returnCodes: [
|
||||
{ code: 0, type: 'success', meaning: 'Install completed.' },
|
||||
{ code: 3010, type: 'softReboot', meaning: 'Reboot required.' }
|
||||
],
|
||||
assignments: [
|
||||
{ ring: 'Pilot', intent: 'available', groupName: 'IT-Pilot', notes: 'Validation ring.' }
|
||||
],
|
||||
status: 'draft',
|
||||
notes: 'Created from the recipe.',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
const sampleProfile = {
|
||||
name: 'Acme Reader',
|
||||
appVendor: 'Acme',
|
||||
appName: 'Reader',
|
||||
appVersion: '24.1',
|
||||
appArch: 'x64'
|
||||
};
|
||||
|
||||
test('buildDatasheetModel pulls metadata from the profile and lays out every section', () => {
|
||||
const model = buildDatasheetModel({ deployment: sampleDeployment(), profile: sampleProfile });
|
||||
assert.equal(model.title, 'Acme Reader 24.1');
|
||||
assert.equal(model.subtitle, 'Acme Reader 24.1');
|
||||
const headings = model.sections.map((s) => s.heading);
|
||||
assert.deepEqual(headings, ['Overview', 'Install & uninstall', 'Detection', 'Return codes', 'Assignments', 'Requirements', 'Source & version', 'Notes']);
|
||||
});
|
||||
|
||||
test('buildDatasheetModel requires a deployment', () => {
|
||||
assert.throws(() => buildDatasheetModel({}), /deployment is required/);
|
||||
});
|
||||
|
||||
test('buildDatasheetModel omits the Notes section when there are no notes', () => {
|
||||
const model = buildDatasheetModel({ deployment: sampleDeployment({ notes: '' }), profile: sampleProfile });
|
||||
assert.ok(!model.sections.some((s) => s.heading === 'Notes'));
|
||||
});
|
||||
|
||||
test('renderDatasheet markdown carries commands, detection, return codes and assignments', () => {
|
||||
const doc = renderDatasheet({ deployment: sampleDeployment(), profile: sampleProfile }, { format: 'md' });
|
||||
assert.equal(doc.format, 'md');
|
||||
assert.equal(doc.extension, 'md');
|
||||
assert.match(doc.contentType, /text\/markdown/);
|
||||
assert.match(doc.content, /^# Acme Reader 24\.1/);
|
||||
assert.match(doc.content, /Invoke-AppDeployToolkit\.exe -DeploymentType Install/);
|
||||
assert.match(doc.content, /\{12345678-ABCD-1234-ABCD-1234567890AB\}/);
|
||||
assert.match(doc.content, /\| 3010 \| Soft reboot \| Reboot required\. \|/);
|
||||
assert.match(doc.content, /\| Pilot \| Available \| IT-Pilot \| Validation ring\. \|/);
|
||||
});
|
||||
|
||||
test('renderDatasheet html is self-contained and escapes user content', () => {
|
||||
const doc = renderDatasheet(
|
||||
{ deployment: sampleDeployment({ notes: 'Watch <script> & "quotes"' }), profile: sampleProfile },
|
||||
{ format: 'html' }
|
||||
);
|
||||
assert.equal(doc.format, 'html');
|
||||
assert.equal(doc.extension, 'html');
|
||||
assert.match(doc.contentType, /text\/html/);
|
||||
assert.match(doc.content, /^<!doctype html>/);
|
||||
assert.match(doc.content, /Watch <script> & "quotes"/);
|
||||
assert.ok(!doc.content.includes('<script>'), 'no unescaped script tag leaks through');
|
||||
});
|
||||
|
||||
test('renderDatasheet shows empty tables gracefully and defaults to markdown', () => {
|
||||
const doc = renderDatasheet({ deployment: sampleDeployment({ assignments: [], returnCodes: [] }) });
|
||||
assert.equal(doc.format, 'md');
|
||||
assert.match(doc.content, /_None_/);
|
||||
});
|
||||
|
||||
test('renderDatasheet falls back to the deployment name without a profile', () => {
|
||||
const doc = renderDatasheet({ deployment: sampleDeployment({ profileId: null }) }, { format: 'md' });
|
||||
assert.match(doc.content, /# Acme Reader 24\.1/);
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import './setup.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { load } from './setup.mjs';
|
||||
|
||||
const { listRecipes, searchRecipes, getRecipe, buildRecipeArtifacts } =
|
||||
await load('../services/recipeService.js');
|
||||
|
||||
// --- catalog -----------------------------------------------------------------
|
||||
|
||||
test('listRecipes exposes a sanitized catalog with the fields the UI needs', () => {
|
||||
const list = listRecipes();
|
||||
assert.ok(list.length >= 10, 'catalog has a useful number of recipes');
|
||||
const chrome = list.find((r) => r.id === 'google-chrome');
|
||||
assert.ok(chrome);
|
||||
assert.equal(chrome.wingetId, 'Google.Chrome');
|
||||
assert.ok(chrome.installArgs.includes('/qn'));
|
||||
assert.ok(chrome.homepage.startsWith('https://'));
|
||||
});
|
||||
|
||||
test('every recipe references a known installer technology and a valid detection footprint', async () => {
|
||||
const { listInstallerTechnologies } = await load('../services/installerIntelService.js');
|
||||
const techIds = new Set(listInstallerTechnologies().map((t) => t.id));
|
||||
for (const r of listRecipes()) {
|
||||
assert.ok(techIds.has(r.installerType), `${r.id} -> ${r.installerType} is a known technology`);
|
||||
// Detection must not throw when applied (file/registry/msi all valid here).
|
||||
assert.doesNotThrow(() => buildRecipeArtifacts(getRecipe(r.id)), `${r.id} detection builds`);
|
||||
}
|
||||
});
|
||||
|
||||
test('enterprise agents without a winget id still build, but skip the catalog app', () => {
|
||||
const falcon = getRecipe('crowdstrike-falcon');
|
||||
assert.equal(falcon.wingetId, '');
|
||||
const { application, deployment } = buildRecipeArtifacts(falcon);
|
||||
assert.equal(application, null, 'no winget id -> no auto-tracked app');
|
||||
assert.ok(deployment.detectionRule.includes('CSFalconService.exe'));
|
||||
});
|
||||
|
||||
test('searchRecipes matches vendor, name, and winget id case-insensitively', () => {
|
||||
assert.ok(searchRecipes('mozilla').some((r) => r.id === 'mozilla-firefox'));
|
||||
assert.ok(searchRecipes('VLC').some((r) => r.id === 'vlc'));
|
||||
assert.ok(searchRecipes('Microsoft.PowerShell').some((r) => r.id === 'powershell-7'));
|
||||
assert.equal(searchRecipes('nonexistent-app-xyz').length, 0);
|
||||
assert.equal(searchRecipes('').length, listRecipes().length);
|
||||
});
|
||||
|
||||
// --- apply transform (pure) --------------------------------------------------
|
||||
|
||||
test('buildRecipeArtifacts produces profile + deployment + tracked app for an MSI recipe', () => {
|
||||
const { profile, deployment, application } = buildRecipeArtifacts(getRecipe('google-chrome'));
|
||||
|
||||
// Profile carries the raw silent install as a PSADT install task.
|
||||
assert.equal(profile.name, 'Google Chrome');
|
||||
assert.equal(profile.appVendor, 'Google');
|
||||
assert.equal(profile.installTasks.length, 1);
|
||||
assert.equal(profile.installTasks[0].type, 'msi');
|
||||
assert.equal(profile.installTasks[0].filePath, 'googlechromestandaloneenterprise64.msi');
|
||||
assert.ok(profile.installTasks[0].arguments.includes('/qn'));
|
||||
|
||||
// Deployment wraps it in PSADT v4 with a file-detection rule.
|
||||
assert.equal(deployment.commandStyle, 'v4');
|
||||
assert.equal(deployment.detectionType, 'custom-script');
|
||||
assert.ok(deployment.detectionRule.includes('chrome.exe'));
|
||||
assert.equal(deployment.status, 'draft');
|
||||
|
||||
// Catalog app is wired to the winget version watcher.
|
||||
assert.equal(application.versionSource, 'winget');
|
||||
assert.equal(application.versionSourceRef, 'Google.Chrome');
|
||||
assert.equal(application.autoCheck, true);
|
||||
});
|
||||
|
||||
test('EXE-based recipes become exe install tasks with the silent switch', () => {
|
||||
const { profile } = buildRecipeArtifacts(getRecipe('git'));
|
||||
assert.equal(profile.installTasks[0].type, 'exe');
|
||||
assert.ok(profile.installTasks[0].arguments.includes('/VERYSILENT'));
|
||||
});
|
||||
|
||||
test('applying with group visibility scopes the group id', () => {
|
||||
const { profile, deployment } = buildRecipeArtifacts(getRecipe('7zip'), { visibility: 'group', groupId: 'grp_1' });
|
||||
assert.equal(profile.visibility, 'group');
|
||||
assert.equal(profile.groupId, 'grp_1');
|
||||
assert.equal(deployment.groupId, 'grp_1');
|
||||
});
|
||||
|
||||
test('group id is dropped when visibility is not group', () => {
|
||||
const { profile } = buildRecipeArtifacts(getRecipe('7zip'), { visibility: 'personal', groupId: 'grp_1' });
|
||||
assert.equal(profile.groupId, null);
|
||||
});
|
||||
|
||||
test('buildRecipeArtifacts throws on a missing recipe', () => {
|
||||
assert.throws(() => buildRecipeArtifacts(null));
|
||||
});
|
||||
Reference in new issue
Block a user