UNPKG

@microsoft/windows-admin-center-sdk

Version:

Microsoft - Windows Admin Center Shell

723 lines (721 loc) 27.8 kB
import { EMPTY, Observable, throwError } from 'rxjs'; import { catchError, expand, mergeMap, tap } from 'rxjs/operators'; import { LogLevel } from '../diagnostics/log-level'; import { Logging } from '../diagnostics/logging'; import { SmeWebTelemetry } from '../diagnostics/sme-web-telemetry'; import { TelemetryEventStates } from '../diagnostics/sme-web-telemetry-models'; import { Disposer } from './disposable'; import { headerConstants } from './http-constants'; import { Net } from './net'; /** * The PowerShellSession class. */ export class PowerShellSession { powerShell; lifetime; constructor(powerShell, lifetime) { this.powerShell = powerShell; this.lifetime = lifetime; } /** * Gets the node name of session. */ get nodeName() { return this.powerShell.nodeName; } /** * Dispose the session object. */ dispose() { if (this.lifetime) { this.lifetime.dispose(); } } } /** * Class containing methods related to PowerShell runspace creation/deletion/command using PowerShell Raw API plugin. * - It's auto holding the session as long as it's used within last 3 minutes. */ export class PowerShellRaw { nodeConnection; context; // 3 minutes holding time. static maxDeltaTimeInMs = 3 * 60 * 1000; sessionId; timestampInMs = 0; markDelete = false; internalActive = false; cancelPending = false; /** * Initializes a new instance of the PowerShellRaw class. * * @param nodeConnection The node connection service. * @param context The context of PowerShell run. */ constructor(nodeConnection, context) { this.nodeConnection = nodeConnection; this.context = context; } /** * Gets active status of PowerShell execution. */ get active() { return this.internalActive; } /** * Dispose the runspace. */ dispose() { if (!this.active) { // only close sessions that have been created. // If a result was cached a component may not // execute a command and still dispose the session // when the component is destroyed. if (this.sessionId) { this.close(); } } else { this.markDelete = true; } } /** * Runs the given command * * @param command The command to execute. * @param options the powershell options. */ runCommand(command, options) { // take the timestamp only success/healthy case. // error session would be auto-deleted after expiration time. this.internalActive = true; return this.command(command, options) .pipe(catchError((error) => this.fallbackToJea(error, command, options)), expand((data) => { this.timestampInMs = Date.now(); if (this.checkCompleted(data)) { return EMPTY; } if (this.cancelPending) { // submit cancel request. // after set active state to false and complete the observable. this.cancelPending = false; return this.cancel() .pipe(catchError(() => { this.internalActive = false; return EMPTY; }), mergeMap(() => { this.internalActive = false; return EMPTY; })); } const url = Net.powerShellApiRetrieveOutput.format(this.sessionId); return this.nodeConnection.get(this.context.nodeName, url, this.context.requestOptions) .pipe(catchError((error) => { SmeWebTelemetry.tracePowershellEvent(command, TelemetryEventStates.Error, { response: error.response }); return this.fallbackToJea(error, command, options); })); })); } /** * Close/Delete the session / runspace. */ close() { if (this.context.requestOptions.automatic) { return; } if (this.sessionId) { const sessionUri = Net.powerShellApiSessions.format(this.sessionId); this.sessionId = null; this.nodeConnection.deleteQuick(this.context.nodeName, sessionUri, this.context.requestOptions); return; } Logging.log({ level: LogLevel.Verbose, source: 'PowerShell/close', message: MsftSme.getStrings().MsftSmeShell.Core.Error.PowerShellUnableSessionClose.message }); } /** * Cancel the command. */ cancelCommand() { if (this.internalActive) { this.cancelPending = true; } return EMPTY; } /** * Perform the JEA fallback, if applicable. * * @param error The error to handle * @param command The command * @param options The request options */ fallbackToJea(error, command, options) { const authError = Net.isUnauthorized(error) || error.status === 400 /* HttpStatusCode.BadRequest */; const responseEndpoint = error && error.xhr && error.xhr.getResponseHeader(headerConstants.POWERSHELL_ENDPOINT); let requestEndpoint = (options && options.powerShellEndpoint); requestEndpoint = requestEndpoint || (this.context.requestOptions && this.context.requestOptions.powerShellEndpoint); const cancel = error.handlerError && error.handlerError.code && error.handlerError.code === 'ManageAsDialogCancel'; const credSSP = (options && options.authenticationMechanism === 'Credssp'); if (!cancel && authError && responseEndpoint && requestEndpoint !== responseEndpoint && !credSSP) { this.context.requestOptions.powerShellEndpoint = responseEndpoint; return this.command(command, options) .pipe(tap(() => { // The JEA request went through - persist this context in authorization manager. this.nodeConnection.saveJeaContext(this.context.nodeName, responseEndpoint); })); } // close on error if sessionId is available. if (options && options.closeOnError) { if (!this.sessionId) { this.sessionId = error && error.xhr && error.xhr.response && error.xhr.response.sessionId; } if (this.sessionId) { this.close(); } } this.internalActive = false; return throwError(() => error); } cancel() { if (this.sessionId && this.internalActive) { const cancelUri = Net.powerShellApiCancelCommand.format(this.sessionId); return this.nodeConnection.post(this.context.nodeName, cancelUri, null, this.context.requestOptions); } Logging.log({ level: LogLevel.Warning, source: 'PowerShell', message: MsftSme.getStrings().MsftSmeShell.Core.Error.PowerShellUnableCancelCommand.message }); return EMPTY; } /** * Gets if timestamp was expired. */ get _isExpired() { const now = Date.now(); return this.timestampInMs !== 0 && (now - this.timestampInMs) > PowerShellRaw.maxDeltaTimeInMs; } /** * Initiate command execution. It auto recycles old sessions. * * @param command the PowerShell command. */ command(command, options) { const commandPacket = { ...command }; commandPacket.useInProcRunspace = !!options?.useInProcRunspace; const polling = !!this.context.requestOptions.automatic; if (polling) { commandPacket.invokeMode = 'Polling'; } if (options && options.waitTimeMs) { commandPacket.waitTimeMs = options.waitTimeMs; } const data = Net.createPropertiesJSONString(commandPacket); const newOptions = { ...this.context.requestOptions, ...{ logAudit: options && options.logAudit, logTelemetry: options && options.logTelemetry } }; const endpoint = options && options.powerShellEndpoint; if (endpoint) { newOptions.powerShellEndpoint = endpoint; } const token = options && options.authToken; if (token) { newOptions.authToken = token; } const authenticationMechanism = options && options.authenticationMechanism; if (authenticationMechanism) { newOptions.authenticationMechanism = authenticationMechanism; } if (newOptions.logTelemetry && !polling) { SmeWebTelemetry.tracePowershellEvent(command, TelemetryEventStates.Started); } let commandResponse; if (polling) { this.sessionId = null; commandResponse = this.nodeConnection.post(this.context.nodeName, Net.powerShellApiInvokeCommand, data, newOptions); } else if (this.sessionId == null || this._isExpired) { this.sessionId = null; const generatedName = this.context.key ? this.context.key + '-newSession' : 'instantSession'; const sessionUri = Net.powerShellApiSessions.format(generatedName); commandResponse = this.nodeConnection.put(this.context.nodeName, sessionUri, data, newOptions); } else { const executeUri = Net.powerShellApiExecuteCommand.format(this.sessionId); commandResponse = this.nodeConnection.post(this.context.nodeName, executeUri, data, newOptions); } return commandResponse.pipe(catchError((error) => { SmeWebTelemetry.tracePowershellEvent(command, TelemetryEventStates.Error, { response: error.response }); return throwError(() => error); })); } checkCompleted(data) { const properties = Net.getItemProperties(data); if (properties.sessionId) { // keep the PS session GUID this.sessionId = properties.sessionId; } if (properties.completed.toLowerCase() === 'true') { this.internalActive = false; if (this.markDelete) { this.close(); } return true; } return false; } } /** * The PowerShell class. * * - Single instance of PowerShell class manages single runspace. * - It queues coming requests and process one at a time sequentially. * - If a command is slow and causing with multiple responses, it aggregates response into single Q result. * - A PowerShell instance should be created through create() function, and it's statically stored/managed into _map collection. * - In QueryCache operation, it can find the PowerShell instance to run PowerShell command by using find() function. * - Once all lifetime references are gone, it deletes the runspace. * - To dispose the PowerShell instance, it can use lifetime.dispose(). */ export class PowerShell { /** * Default PowerShell endpoint. */ static defaultPowerShellEndpoint = 'http://schemas.microsoft.com/powershell/microsoft.powershell'; /** * SME PowerShell endpoint. */ static smePowerShellEndpoint = 'http://schemas.microsoft.com/powershell/microsoft.sme.powershell'; /** * SME CredSSP PowerShell endpoint to control client role of CredSSP on the gateway. */ static smeCredSSPEndpoint = 'http://schemas.microsoft.com/powershell/microsoft.sme.credssp'; /** * WAC (v2) CredSSP PowerShell endpoint to control client role of CredSSP on the gateway. */ static wacCredSSPEndpoint = 'http://schemas.microsoft.com/powershell/Microsoft.WindowsAdminCenter.Credssp'; /** * Static collection of PowerShell objects. */ static map = {}; /** * Regular expression to match all the occurrences of a single quote */ static escapeRegex = new RegExp('\'', 'g'); /** * The context of PowerShell object. */ context; /** * The queue of PowerShell command requests. */ queue = []; /** * The reference to PowerShellRaw class object. */ raw; /** * Current data to aggregate from multiple data responses. */ currentData; /** * Timestamp when last command started. */ timestamp; /** * Create script as string. * (Notes: Use createCommand() function which is based on PowerShell module, * Update gulpfile.js to generate a PowerShell module to support Show script, JEA and localization.) * * @param resource the script text from legacy ps-code converter. * @param parameters the arguments. * @param flags (optional) the switch flags. */ static createScript(script, parameters, flags) { script = 'function cvt ($o) { return ConvertFrom-Json $o }\n function SmeSubmit {\n' + script + '}\n SmeSubmit'; for (const parameter in parameters) { if (parameters.hasOwnProperty(parameter)) { const value = parameters[parameter]; if (value == null) { script += ' -{0} $null'.format(parameter); } else { script += ' -{0} (cvt \'{1}\')'.format(parameter, JSON.stringify(value).replace(PowerShell.escapeRegex, '\'\'')); } } } if (flags) { for (let i = 0; i < flags.length; i++) { script += ' -{0}'.format(flags[i]); } } return script; } /** * Create PowerShell request command. * (It creates a command object of JEA PowerShell request under restricted user role environment.) * * @param resource the script resource object with command and script data from new ps-code converter. * @param parameters the arguments. * @param flags (optional) the switch flags. * @return PowerShellCommand the PowerShell request command object. */ static createCommand(resource, parameters, flags, resourceName) { // step1: Add Jea prefix dynamically const powerShellPrefix = MsftSme.self().Init.powerShellPrefix; let command = resource.command; if (powerShellPrefix && resource && resource.command) { command = PowerShell.addPowerShellPrefix(powerShellPrefix, resource.command); } // step2: Adding parameters converter from JSON to PowerShell. // step3: Surround full content into SmeSubmit function. let script = 'function cvt($o){return ConvertFrom-Json $o}\n function SmeSubmit{\n' + resource.script + '}\nSmeSubmit'; // step4: Adding localized resources strings overriding Import-LocalizedData as function. if (resourceName) { const strings = MsftSme.getStrings()[resourceName]; if (strings && strings['PowerShell']) { const items = strings['PowerShell']; const keys = Object.keys(items); const rightSingleQuotationMark = '\u2019'; const lines = keys.map(key => (key + '=\'' + items[key] .split(rightSingleQuotationMark) .join('\'\'') .split('\'') .join('\'\'') + '\'\n')); script = 'function Import-LocalizedData{$script:strings=@{\n' + lines.join('') + '}}' + script; } } // step5: Adding each parameter with using converter. for (const parameter in parameters) { if (parameters.hasOwnProperty(parameter)) { const value = parameters[parameter]; if (value == null) { script += ' -{0} $null'.format(parameter); } else { script += ' -{0} (cvt \'{1}\')'.format(parameter, JSON.stringify(value).replace(PowerShell.escapeRegex, '\'\'')); } } } // step6: Adding switch parameters. const flagParameters = {}; if (flags) { for (let i = 0; i < flags.length; i++) { script += ' -{0}'.format(flags[i]); flagParameters[flags[i]] = true; } } return { module: resource.module, command, parameters: { ...flagParameters, ...parameters }, script, state: 'ready' }; } /** * Update the parameters in the PowerShellCommand object, and update the SmeSubmit part of the * script with these new parameters. * * @param command The PowerShellCommand instance to update. * @param parameters The new collection of parameters. * * Note: flags support can be added when it becomes necessary. */ static updateCommandParameters(command, parameters) { for (const parameter in parameters) { if (parameters.hasOwnProperty(parameter)) { command.parameters[parameter] = parameters[parameter]; const value = parameters[parameter]; // Regular expression to capture existing parameter. const regex = new RegExp('(?<=}\\s+SMESubmit .*)-{0} (\\(cvt.+?\\)|\\$null)(?= -|$)'.format(parameter), 'i'); if (command.script.match(regex)) { if (value == null) { command.script = command.script.replace(regex, ' -{0} $null'.format(parameter)); } else { command.script = command.script.replace(regex, ' -{0} (cvt \'{1}\')'.format(parameter, JSON.stringify(value).replace(PowerShell.escapeRegex, '\'\''))); } } else { command.script += ' -{0} (cvt \'{1}\')'.format(parameter, JSON.stringify(value).replace(PowerShell.escapeRegex, '\'\'')); } } } } static create(nodeName, nodeConnection, key, lifetime, requestOptions) { let ps; if (key && lifetime) { ps = PowerShell.map[PowerShell.indexName(nodeName, key)]; if (ps) { ps.addLifetime(lifetime); return ps; } } ps = new PowerShell(nodeName, nodeConnection, key, lifetime, requestOptions); if (key && lifetime) { PowerShell.map[PowerShell.indexName(nodeName, key)] = ps; } return ps; } /** * Find existing PowerShell object. Create call must be called before to create the PowerShell instance. * * @param nodeName The node name. * @param key The shared key to queue the requests to use the single runspace. */ static find(nodeName, key) { return PowerShell.map[PowerShell.indexName(nodeName, key)]; } /** * Gets the command object from string or PowerShellCommand. * * @param scriptOrCommand the script string or PowerShellCommand object. */ static getPowerShellCommand(scriptOrCommand) { return typeof scriptOrCommand === 'string' ? { script: scriptOrCommand, command: null, module: null, state: 'ready' } : { script: scriptOrCommand.script, command: scriptOrCommand.command, module: scriptOrCommand.module || MsftSme.self().Init.powerShellModuleName, parameters: scriptOrCommand.parameters, state: 'ready' }; } /** * Create new options with debugging endpoint if requested. * * @param options the PowerShell session request options. */ static newEndpointOptions(options) { // if there is no endpoint but configured with powerShellEndpoint, set debugging endpoint. const newOptions = { ...(options || {}) }; if (!newOptions.powerShellEndpoint && MsftSme.self().Init.powerShellEndpoint) { newOptions.powerShellEndpoint = MsftSme.self().Init.powerShellEndpoint; } return newOptions; } /** * Create the index name in map collection. * * @param nodeName The node name. * @param key The shared key to queue the requests to use the single runspace. */ static indexName(nodeName, key) { return nodeName + ':' + key; } /** * Adds jea prefix to the command name * * @param jeaPrefix The jea prefix originating from main.ts. * @param command The powershell command to run. */ static addPowerShellPrefix(powerShellPrefix, command) { const hyphenSeparatorIndex = command.indexOf('-'); const verb = command.substring(0, hyphenSeparatorIndex); const target = command.substring(hyphenSeparatorIndex + 1); if (target.indexOf(powerShellPrefix) === 0) { throw new Error('Command already contains prefix'); } return verb + '-' + powerShellPrefix + target; } /** * Initializes a new instance of the PowerShell class. * (private constructor which shouldn't be called directly.) * * @param nodeConnection The node connection service. * @param key The shared key to queue the requests to use the single runspace. * @param lifetime The lifetime container. */ constructor(nodeName, nodeConnection, key, lifetime, options) { this.context = { nodeName: nodeName, key: key, lifetimes: [], requestOptions: PowerShell.newEndpointOptions(options) }; this.timestamp = 0; this.raw = new PowerShellRaw(nodeConnection, this.context); if (key && lifetime) { lifetime.registerForDispose(new Disposer(() => this.lifetimeDisposer(lifetime))); this.context.lifetimes.push(lifetime); } } /** * Gets node name from current context. */ get nodeName() { return this.context.nodeName; } /** * Run PowerShell command. * * @param command The command. * @param options The options. * @return PromiseV The result of PowerShell command. */ run(scriptOrCommand, options) { const command = PowerShell.getPowerShellCommand(scriptOrCommand); if (this.context.lifetimes.length === 0) { // no disposer is assigned, force to close the session after every query. const timeoutMs = options && options.timeoutMs; if (options) { options.timeoutMs = timeoutMs; options.close = true; } else { options = { timeoutMs: timeoutMs, close: true }; } } // queue the request. const observable = this.enqueue(command, options); return observable; } /** * Cancel PowerShell command. */ cancel() { return this.raw.cancelCommand(); } /** * Enqueue a command request. * * @param command The command. * @param options The options. */ enqueue(command, options) { return new Observable((observer) => { this.queue.push({ observer, command, options }); this.dequeue(); }); } /** * Dequeue a command request. */ dequeue() { if (this.raw.active) { return false; } const item = this.queue.shift(); if (item) { this.currentData = null; this.timestamp = Date.now(); this.raw.runCommand(item.command, item.options).subscribe({ next: data => { const properties = Net.getItemProperties(data); this.collect(properties, item.options && item.options.timeoutMs, item.options && item.options.partial ? item.observer : null); }, error: error => { if (item.options && item.options.close) { this.raw.close(); } item.observer.error(error); this.timestamp = 0; this.dequeue(); }, complete: () => { if (item.options && item.options.close) { this.raw.close(); } if (!item.options || !item.options.partial) { item.observer.next(this.currentData); } item.observer.complete(); this.timestamp = 0; this.dequeue(); } }); return true; } return false; } /** * Collect response result and aggregate into single object. * * @param properties The properties of response object. * @param timeoutMs The timeout to cancel command. * @param observer The observer of powershell results. */ collect(properties, timeoutMs, observer) { if (timeoutMs && this.timestamp && (Date.now() - this.timestamp > timeoutMs)) { // force to cancel the command because of unexpected longer execution. this.raw.cancelCommand(); this.timestamp = 0; return; } if (observer) { // return partial data if observer is not null. observer.next(properties); this.currentData = properties; return; } if (this.currentData != null && this.currentData.results && properties.results) { let array; if (MsftSme.getTypeOf(this.currentData.results) === 'array') { array = this.currentData.results; } else { array = [this.currentData.results]; } if (MsftSme.getTypeOf(properties.results) === 'array') { properties.results.forEach((x) => { array.push(x); }); } else { array.push(properties.results); } this.currentData.results = array; return; } this.currentData = properties; } /** * Attach lifetime object to disposer when disposing. * * @param lifetime The lifetime object. */ addLifetime(lifetime) { const found = MsftSme.find(this.context.lifetimes, (value) => value === lifetime); if (!found) { this.context.lifetimes.push(lifetime); lifetime.registerForDispose(new Disposer(() => this.lifetimeDisposer(lifetime))); } } /** * Callback when disposing the container of view model. * If none, reference the PowerShell object. Dispose it. (Delete runspace) * * @param lifetime The lifetime object. */ lifetimeDisposer(lifetime) { const found = MsftSme.find(this.context.lifetimes, (value) => value === lifetime); if (found) { MsftSme.remove(this.context.lifetimes, lifetime); if (this.context.lifetimes.length === 0) { // cancel queue command requests. this.queue.forEach((value) => { value.observer.next(null); value.observer.complete(); }); // delete from the map collection and delete the runspace/session. delete PowerShell.map[PowerShell.indexName(this.context.nodeName, this.context.key)]; this.raw.dispose(); } } } } //# sourceMappingURL=powershell.js.map