@microsoft/windows-admin-center-sdk
Version:
Microsoft - Windows Admin Center Shell
723 lines (721 loc) • 27.8 kB
JavaScript
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