diff --git a/.nycrc b/.nycrc index 18e1d882c..156e36f5b 100644 --- a/.nycrc +++ b/.nycrc @@ -2,7 +2,8 @@ "include": [ "index.js", "gpii/node_modules/*/*.js", - "gpii/node_modules/*/src/*.js" + "gpii/node_modules/*/src/*.js", + "service/src/*.js" ], "exclude": [ "!**/node_modules/" diff --git a/Gruntfile.js b/Gruntfile.js index 48f21a49e..56865f688 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -17,7 +17,7 @@ module.exports = function (grunt) { lintAll: { sources: { md: [ "./*.md","./gpii/*.md", "./settingsHelper/**/*.md"], - js: ["./gpii/**/*.js", "./tests/**/*.js", "./*.js"], + js: ["./gpii/**/*.js", "./tests/**/*.js", "./*.js", "./service/src/**/*.js", "./service/tests/**/*.js"], json: ["./gpii/**/*.json", "./tests/**/*.json", "./settingsHelper/**/*.json", "./*.json"], json5: ["./gpii/**/*.json5", "./tests/**/*.json5", "./*.json5"], other: ["./.*"] diff --git a/gpii-service/.eslintrc.json b/gpii-service/.eslintrc.json new file mode 100644 index 000000000..8498447b5 --- /dev/null +++ b/gpii-service/.eslintrc.json @@ -0,0 +1,7 @@ +{ + "extends": "eslint-config-fluid", + "env": { + "node": true, + "es6": true + } +} diff --git a/gpii-service/README.md b/gpii-service/README.md new file mode 100644 index 000000000..0383dbc28 --- /dev/null +++ b/gpii-service/README.md @@ -0,0 +1,170 @@ +# GPII Windows Service + +A Windows Service that starts the GPII process (and listeners) when the user logs on, restarts it when it stops +unexpectedly, and provides the ability to run high-privileged tasks. + +## Testing + +The service can be ran as a normal process, without installing it. + +Start the service via `npm start`, and the service will start GPII, and restart GPII if it dies. + +`npm run service-dev` will start the service in development mode. That is, without it spawning GPII and allowing new +GPII instances to connect later without any authentication. + +In both cases, the service will be started as a normal process but running as Administrator. This may only work in +vagrant boxes where UAC is at the minimal level, otherwise the commands will need to be invoked from an elevated command +prompt. + +### From gpii-windows + +`npm start` from gpii-windows ensures the service is running (in development mode) before starting gpii. GPII only +requires the service to be running if it applies solutions that depend on it. + +### Running as a Windows Service + +In order to use the service related functionality, such as starting GPII at the start of a Windows session, the gpii +service needs to be installed and ran as a Windows Service. These need to be started from an elevated command prompt. + +Install the service: `npm run service-install`. To install the service using a config that does not start GPII, and +allows later instances to connect, run `npm run service-install-dev` + +Start the service: `npm run service-start` + +Stop the service: `npm run service-stop` + +Uninstall the service: `npm run service-uninstall` + +### Logging + +When the service is being ran as a Windows service, don't expect a console window. The log will be found in +`%ProgramData%\GPII\gpii-service.log` (`C:\ProgramData\GPII\gpii-service.log`). The service doesn't put the log in the +same directory as GPII, because that's in the directory belonging to a user profile and the server doesn't run as a +normal user. + +## Configuration + +### [service.json5](config/service.json5) + +Production config, used when being ran as `gpii-service.exe`. Starts `./gpii-app.exe` and accepts a connection from only +that child process. + +### [service.dev.json5](config/service.dev.json5) + +Default development configuration, used when running the service from the source directory. This doesn't start a child +gpii process, but allows any process to connect to the pipe using a known name. + +### [service.dev.child.json5](config/service.dev.child.json5) + +Starts GPII, via `node ../gpii.js` and accepts a connection only that child process. + + +To specify the config file, use the `--config` option when running or installing the service. + +### Config options + +```json5 +{ + "processes": { + /* A process block */ + "gpii": { // key doesn't matter + /* The command to invoke. Can be undefined, to just open a pipe. */ + "command": "gpii-app.exe", // Starts gpii + + /* Provide a pipe to the process. */ + "ipc": "gpii", // The value will be used to determine internally what the pipe does (nothing special at the moment) + + /* Restart the process if it terminates. */ + "autoRestart": true, + }, + + /* Opens a pipe (\\.\pipe\gpii-gpii), without any authentication. */ + "gpii-dev": { + "ipc": "gpii", + "noAuth": true + }, + + /* More processes */ + "rfid-listener": { + "command": "../listeners/GPII_RFIDListener.exe", + "autoRestart": true + }, + "usb-listener": { + "command": "../listeners/GPII_USBListener.exe", + "autoRestart": true + } + }, + "logging": { + /* Log level: FATAL, ERROR, WARN, INFO, or DEBUG */ + "level": "DEBUG" + } +} +``` + + +## Deployment + +During the build process, gpii-app's Installer.ps1 will bundle the service into a standalone executable, and the +installer will put it in the same place as gpii-app.exe. + +The installer will install and start the service. + +## Command line options + +`index.js` recognises the following command-line arguments + +``` + --install Install the Windows Service. + --serviceArgs=ARGS + Comma separated arguments to pass to the service (use with --install). + --uninstall Uninstall the Windows Service. + --service Only used when running as a service. + --config=FILE Specify the config file to use (default: service.json). +``` + + +## Notes + +## How it works + +Services are slightly different to normal processes, the differences are handled by +[stegru/node-os-service#GPII-2338](https://github.com/stegru/node-os-service/tree/GPII-2338), which is a fork of +[node-os-service](https://github.com/stephenwvickers/node-os-service), where the service-related calls are made. + +The service is started by Windows during the start up, then waits for a user to log in. (By listening for the +[SERVICE_CONTROL_SESSIONCHANGE](https://msdn.microsoft.com/library/windows/desktop/ms683241.aspx) +service control code). + +When a user logs on, it starts the processes listed in [service-config.json](service-config.json) as that user and will +restart them if they die. + +## Debugging + +When installing the service, add the debug arguments using the `--nodeArgs`. For example: + +``` +node index.js --install --nodeArgs=--inspect-brk=0.0.0.0:1234 +sc start gpii-service +``` + +Then quickly attach to the service, before Windows thinks it didn't start. + +## IPC + +### Client authentication + +Initial research in [GPII-2399](https://issues.gpii.net/browse/GPII-2399). + +* Service creates pipe and listens +* Service starts Child, passing pipe name in `GPII_SERVICE_PIPE` environment variable. + * pipe name isn't a secret - other processes see open pipes. +* Child connects to pipe +* Service creates an event + * [CreateEvent](https://msdn.microsoft.com/library/ms682396) (unnamed, so only the handle can be used to access it) + * [DuplicateHandle](https://msdn.microsoft.com/library/ms724251) creates another handle to the event that's tied to Child's process +* Service sends the Child's handle to the event through the pipe + * The handle isn't a secret - it's a number that's meaningless to any process other than Child. +* Client calls [SetEvent](https://msdn.microsoft.com/library/ms686211) on the handle. + * Only Child can signal that event +* Service detects the event's signal, access is granted. + diff --git a/gpii-service/config/service.dev.child.json5 b/gpii-service/config/service.dev.child.json5 new file mode 100644 index 000000000..f4d3acdee --- /dev/null +++ b/gpii-service/config/service.dev.child.json5 @@ -0,0 +1,14 @@ +// Development configuration, auto starts GPII. +{ + "processes": { + "gpii": { + "command": "node ../gpii.js", + "ipc": "gpii", + "autoRestart": true + } + }, + "logging": { + "level": "DEBUG" + }, + "secretFile": "test-secret.json5" +} diff --git a/gpii-service/config/service.dev.json5 b/gpii-service/config/service.dev.json5 new file mode 100644 index 000000000..fe7cf5197 --- /dev/null +++ b/gpii-service/config/service.dev.json5 @@ -0,0 +1,14 @@ +// Development configuration, opens a pipe and waits for GPII to connect. +{ + "processes": { + "gpii": { + "ipc": "gpii", + // Allow any process to connect. + "noAuth": true + } + }, + "logging": { + "level": "DEBUG" + }, + "secretFile": "test-secret.json5" +} diff --git a/gpii-service/doc/IPC.md b/gpii-service/doc/IPC.md new file mode 100644 index 000000000..bfd150779 --- /dev/null +++ b/gpii-service/doc/IPC.md @@ -0,0 +1,215 @@ +# GPII Service + User process communications + +## Overview + +Service and user process IPC is performed through a named pipe. + +The service opens a named pipe, starts the GPII user process (providing the pipe name) which connects to the pipe at +startup. + +Once the user process has authenticated itself, a two-way asynchronous messaging session begins. + + +## Pipe initialisation + +A randomly named pipe is opened by the service. The pipe is created with node's built-in `net.Server` and `net.Socket` +classes. + +The sole purpose of a random name is to prevent name squatting. The pipe name isn't a secret, as any process can +enumerate all pipe names (eg, `powershell Get-ChildItem \\.\pipe\`). + +The name is made up of random (filename safe) characters with a prefix of `\\.\pipe\gpii-`. When starting the user +process, the pipe name is passed via the `GPII_SERVICE_PIPE` environment variable. Only the random character suffix is +placed in this variable. + +(For development, there's a special configuration that makes the pipe to always be named `\\.\pipe\gpii-gpii`, to allow +random user processes to connect without authentication or being started by the service). + +When running as a Windows service, the default ACL for the pipe forbids connections from user-level processes, so the +ACL is modified to allow connections from the user the GPII process is running as. + + +## User process start + +The service uses [CreateProcessAsUser](https://msdn.microsoft.com/library/ms682429) to start GPII in the context of the +current desktop user. + +The shell is not used to perform the invocation, so the process ID returned is that of the GPII user process. + +## Authentication + +The service needs to know that the thing at the other end of the pipe is the same process which it had started. + +When the user process connects to the pipe, the server sends a challenge (a number) on the pipe, which is responded to +out of band with the following technique: + +The challenge is a handle to an [event object](https://docs.microsoft.com/windows/desktop/Sync/event-objects) (like +binary semaphore) created by service ([CreateEvent](https://msdn.microsoft.com/library/ms682396)). The service will +wait on this event, granting access to the pipe client when the event is signalled +([SetEvent](https://msdn.microsoft.com/library/ms686211)). + +Normally, only the process which created the event can signal it (in general, all handles to things are tied to the +process that owns that handle). However, the service explicitly allows the child process to use this event via +[DuplicateHandle](https://msdn.microsoft.com/library/ms724251) which created another handle to the event that's owned by +the child process. + +The handle is not secret, as it is only usable by the process that it's intended to be used by (even the service can not +use that handle). If another process connects to the pipe and receives the handle, signalling the event will not work. +Additionally, the GPII process will not be able to signal the event because it had not received the handle - and because +GPII will only connect to the pipe its told to, there can be no man-in-the-middle. + +Pseudo-code: + +``` +pipeName = "\\.\pipe\gpii-" + random() + +childProcess = CreateProcessAsUser(command=gpii-app, env={GPII_SERVICE_PIPE:pipeName}) + +eventHandle = CreateEvent() +childEventHandle = DuplicateHandle(sourceHandle=eventHandle, targetProcess=childProcess) + +net.createServer().listen(pipeName) => { + pipe.send(childEventHandle) +} + +// client calls SetEvent(childEventHandle) + +waitForEvent(eventHandle) => { + // authenticated + pipe.send("OK") +} +``` + +## Messaging session + +After authentication, the messaging session begins. At this point, there is no concept of a 'server' and 'client'; each +endpoint can send and receive messages. + + +### Packets + +At the lowest level, the packets are blocks of data with a length header. + +``` + := + + := Payload byte count, 32-bit unsigned int + := The message +``` + +### Messages + +Messages are JSON objects, which can be of three different types: _Request_, _Response_, and _Error_. Any end can send +a _Request_, which is replied to by either a _Response_ or _Error_. Messages are asynchronous, and the order in which +messages are sent and received does not matter - while an endpoint waits for a reply to a request, it can still send and +receiving other messages. + +#### Request + +This message is sent when one endpoint wishes to instruct or interrogate the other endpoint. The implementation returns +a promise which resolves when a matching _Response_ is received, or rejects on an _Error_ message. + +``` +{ + // A random string to uniquely identify this request. + request: "", + // Type of request + requestType: "execute" + // Additional fields, specific to the type of request. +} +``` + +#### Response + +A successful reply to a _Request_. + +``` +{ + // The id of the request that's being replied to. + response: "", + // Response data + data: ... +} +``` + +#### Error + +An error reply to a _Request_. + +``` +{ + // The id of the request that's being replied to. + error: "", + // Response data + data: ... +} +``` + +## Request types + +### Handled by the gpii-service + +These are defined in [service/src/gpiiClient.js](../src/gpiiClient.js). + +#### execute + +Starts an executable, in the context of the service. + +```javascript +request = { + // The "execute" request. + requestType: "execute", + + // The executable to run. + command: "application.exe", + + // Arguments to pass. + args: [ "arg1", "arg2" ], + + // True to wait until the process to terminate before returning the response. Otherwise return when the process has + // started. + wait: false, + + // True to capture the output. Stdout and stderr will be provided in the response. This implies wait=true. + capture: false, + + // Options for the service to pass to child_process.spawn() + options: { } + +}; + +response = { + // The exit code, if request.wait==true + code: number, + // The output, if request.capture==true. + output: { + stdout: string, + stderr: string, + }, + // The pid of the process, if request.wait==false + pid: number +}; +``` + + +### Handled by gpii-app + +Defined in [gpii-service-handler/src/requestHandler.js](../../gpii/node_modules/gpii-service-handler/src/requestHandler.js) + +#### status + +Returns the status of the GPII process, to determine if GPII has became unresponsive. The service sends this request +and waits for a predefined timeout. If no response has been received in that time, then the gpii process it terminated +otherwise the request is repeated. + +```javascript +request = { + // The 'status' request. + requestType: "status" +}; + +response = { + // always true. + isRunning: true +}; +``` + diff --git a/gpii-service/index.js b/gpii-service/index.js new file mode 100644 index 000000000..6a0998f80 --- /dev/null +++ b/gpii-service/index.js @@ -0,0 +1,159 @@ +/* Bootstrap for the GPII windows service. + * + * Copyright 2017 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; +var os_service = require("@gpii/os-service"), + fs = require("fs"), + path = require("path"), + logging = require("./src/logging.js"), + parseArgs = require("minimist"); + +var args = parseArgs(process.argv.slice(2)); + +if (args.help) { + showUsage(); +} else if (args.install) { + install(); +} else if (args.uninstall) { + uninstall(); +} else if (!args.service && process.versions.pkg) { + // If running as an executable, then insist upon the --service flag. + console.error("Invalid command line."); + showUsage(); +} else { + // Start the service + startService(); +} + +/** + * Print the accepted command-line arguments. + */ +function showUsage() { + console.log("GPII Windows Service.\n"); + console.log("Command line options:"); + console.log(" --install Install the Windows Service."); + console.log(" --serviceArgs=ARGS"); + console.log(" Comma separated arguments to pass to the service (use with --install)."); + console.log(" --uninstall Uninstall the Windows Service."); + console.log(" --service Only used when running as a service."); + console.log(" --config=FILE Specify the config file to use (default: service.json5)."); +} + +/** + * Install the service. This needs to be ran as Administrator. + * + * It reads the following (optional) arguments from the command line: + * --nodeArgs=ARGS Comma separated list of arguments to pass to the node, when running the service. + * --serviceArgs=ARGS Comma separated list of arguments to pass to the service. + * --config=FILE Config file for the service to use. + * --loglevel=LEVEL Log level for the service. + * + */ +function install() { + + var serviceName = args.serviceName || "morphic-service"; + + var serviceArgs = [ "--service" ]; + + if (args.serviceArgs) { + serviceArgs.push.apply(serviceArgs, args.serviceArgs.split(/,+/)); + } + + // Forward some arguments to the service. + var forwardArgs = ["config", "loglevel"]; + forwardArgs.forEach(function (argName) { + if (args.hasOwnProperty(argName)) { + var value = args[argName]; + if (value === true) { + serviceArgs.push("--" + argName); + } else { + serviceArgs.push("--" + argName + "=" + value); + } + } + }); + + var nodeArgs = args.nodeArgs && args.nodeArgs.split(/,+/); + + console.log("Installing"); + + os_service.add(serviceName, { + programArgs: serviceArgs, + nodeArgs: nodeArgs, + displayName: "Morphic Service" + }, function (error) { + if (error) { + console.log(error.message); + } else { + console.log("Success"); + } + }); +} + +/** + * Removes the service. This needs to be ran as Administrator, and the service should be already stopped. + * + * It reads the following arguments from the command line: + * --serviceName NAME Name of the Windows Service (default: morphic-service). + */ +function uninstall() { + var serviceName = args.serviceName || "morphic-service"; + + console.log("Uninstalling"); + os_service.remove(serviceName, function (error) { + if (error) { + console.log(error.message); + } else { + console.log("Success"); + } + }); +} + +function startService() { + var dataDir = path.join(process.env.ProgramData, "Morphic"); + + try { + fs.mkdirSync(dataDir); + } catch (e) { + if (e.code !== "EEXIST") { + throw e; + } + } + + if (args.service) { + // Set up the logging early - there's no way to capture stdout for windows services. + var logFile = path.join(dataDir, "morphic-service.log"); + logging.setFile(logFile); + } + if (args.loglevel) { + logging.setLogLevel(args.loglevel); + } + + process.on("uncaughtException", function (err) { + if (!args.service) { + console.error(err); + } + logging.error(err, (err && err.stack) ? err.stack : err); + }); + + // Start the service + if (args.service) { + logging.log("Starting service"); + os_service.run(); + logging.log("Service initialising"); + } + require("./src/main.js"); +} diff --git a/gpii-service/package.json b/gpii-service/package.json new file mode 100644 index 000000000..8d2d2ec1d --- /dev/null +++ b/gpii-service/package.json @@ -0,0 +1,34 @@ +{ + "name": "gpii-service", + "version": "0.0.1", + "description": "Windows service to ensure GPII is running.", + "author": "GPII", + "license": "BSD-3-Clause", + "main": "index.js", + "bin": "index.js", + "scripts": { + "test": "node tests/index.js", + "start": "powershell \"Start-Process -Verb runas -Wait -FilePath node.exe -ArgumentList './index.js --config=service.dev.child.json5'", + "service-dev": "powershell \"if (test-path \\\\.\\pipe\\gpii-gpii) { echo 'Service is running' } else { Start-Process -Verb runas -FilePath node.exe -ArgumentList './index.js --config=service.dev.json5' }", + "service-install-dev": "node ./index.js --install --config=service.dev.json5", + "service-install": "node ./index.js --install --config=service.dev.child.json5", + "service-uninstall": "node ./index.js --uninstall", + "service-start": "sc start morphic-service", + "service-stop": "sc stop morphic-service" + }, + "dependencies": { + "ffi-napi": "2.4.4", + "json5": "2.1.0", + "minimist": "1.2.0", + "@gpii/os-service": "stegru/node-os-service#GPII-2338", + "ref-array-di": "1.2.1", + "ref-napi": "1.4.0", + "ref-struct-di": "1.1.0" + }, + "pkg": { + "targets": [ + "node10-win-x86" + ], + "assets": "config/service.json5" + } +} diff --git a/gpii-service/shared/pipe-messaging.js b/gpii-service/shared/pipe-messaging.js new file mode 100644 index 000000000..b76a285b6 --- /dev/null +++ b/gpii-service/shared/pipe-messaging.js @@ -0,0 +1,285 @@ +/* Handles the connection between the service and GPII user process. + * + * Copyright 2018 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +/** + * Message protocol: + * packet := size + payload + * size := sizeof(payload) (32-bit uint) + * payload := The message. + * + * Message is a string, JSON, or buffer. Object can look like the following: + * + * requests: + * { + * request: "", + * data: { ... } + * } + * + * responses: + * { + * response: "", + * data: { ... } + * } + * + * error responses: + * { + * error: "", + * data: { ... } + * } + * + */ +"use strict"; + +var util = require("util"), + EventEmitter = require("events"); + +var messaging = {}; + +/** + * Creates a session with the given connected pipe (or socket). + * + * This wraps a pipe, which fires a `message` event when for every JSON object received. + * + * @param {Socket} pipe The pipe. + * @param {String} sessionType [Optional] Initial text that is sent and checked by both ends to ensure both sides are + * compatible. + * @param {Function} requestCallback [Optional] Function to call when a request has been received. The function should + * return the result, or a promise resolving to the result. + * @param {Buffer} initialData [Optional] Initial data. + * @return {Session} The new session instance. + */ +messaging.createSession = function (pipe, sessionType, requestCallback, initialData) { + return new Session(pipe, sessionType, requestCallback, initialData); +}; + +/** + * Wraps a pipe with a session. + * + * @param {Socket} pipe The pipe. + * @param {String} sessionType [Optional] Initial text that is sent and checked by both ends to ensure both sides are + * compatible. + * @param {Function} requestCallback [Optional] Function to call when a request has been received. The function should + * return the result, or a promise resolving to the result. + * @param {Buffer} initialData [Optional] Initial data. + * @constructor + */ +function Session(pipe, sessionType, requestCallback, initialData) { + this.pipe = pipe; + + this.sessionType = sessionType; + this.sessionTypeChecked = !this.sessionType; + if (this.sessionType) { + this.sendMessage(this.sessionType); + } + + this.requestCallback = requestCallback; + this.promises = {}; + this.buffer = null; + this.payloadLength = null; + var session = this; + + // Read the initial data (if any) in the next tick, because it will emit events which aren't yet bound to. + process.nextTick(function () { + if (initialData) { + session.gotData(initialData); + } + + pipe.on("data", function (data) { + session.gotData(data); + }); + + pipe.on("close", function (hadError) { + session.emit("close", hadError); + }); + }); +} + +util.inherits(Session, EventEmitter); + +// Number of bytes used for the message length. +messaging.lengthByteCount = 2; + +messaging.Session = Session; + +/** + * Sends a message to the pipe. + * + * @param {String|Object|Buffer} payload The message payload. + */ +Session.prototype.sendMessage = function (payload) { + var payloadBuf; + if (Buffer.isBuffer(payload)) { + payloadBuf = payload; + } else { + var payloadString; + if (typeof(payload) === "string") { + payloadString = payload; + } else { + payloadString = JSON.stringify(payload); + } + payloadBuf = Buffer.from(payloadString); + } + + // = + + var message = Buffer.alloc(payloadBuf.length + messaging.lengthByteCount); + message.writeUIntBE(payloadBuf.length, 0, messaging.lengthByteCount); + payloadBuf.copy(message, messaging.lengthByteCount); + + this.pipe.write(message, "utf8"); +}; + +/** + * Handle the pipe's "data" event. + * + * Messages may not arrive in a single chunk, so the data is added to a buffer until there is enough data to process. + * + * packet := size + payload + * size := sizeof(payload) (32-bit uint) + * payload := The message. + * + * @param {Buffer} data The data. + */ +Session.prototype.gotData = function (data) { + if (data) { + this.buffer = this.buffer ? Buffer.concat([this.buffer, data]) : data; + } + + if (!this.payloadLength) { + // The first bytes are the length of the payload. + if (this.buffer.length >= messaging.lengthByteCount) { + this.payloadLength = this.buffer.readUIntBE(0, messaging.lengthByteCount); + this.buffer = this.buffer.slice(messaging.lengthByteCount); + } + } + + if (this.payloadLength && this.buffer.length >= this.payloadLength) { + // Got enough data for the payload. + this.gotPacket(this.buffer.toString("utf8", 0, this.payloadLength)); + this.buffer = this.buffer.slice(this.payloadLength); + this.payloadLength = 0; + + if (this.buffer.length > 0) { + // Still data after the payload, process it now + this.gotData(null); + } + } +}; + +/** + * Called when a message has been received. + * + * @param {String} packet The JSON string of the message. + */ +Session.prototype.gotPacket = function (packet) { + if (this.sessionTypeChecked) { + var message = JSON.parse(packet); + this.emit("message", message); + + if (message.request) { + this.handleRequest(message); + } else if (message.response || message.error) { + this.handleReply(message); + } + } else if (packet === this.sessionType) { + this.sessionTypeChecked = true; + this.emit("ready"); + } else { + this.pipe.end(); + this.emit("error", new Error("Unexpected client session type " + packet)); + } +}; + +/** + * Call the request callback when a request is received, and sends the response. + * + * @param {Object} message The request message. + */ +Session.prototype.handleRequest = function (message) { + var session = this; + var promise; + try { + var result = this.requestCallback && this.requestCallback(message); + promise = Promise.resolve(result); + } catch (e) { + promise = Promise.reject(e); + } + + promise.then(function (result) { + session.sendMessage({ + response: message.request, + data: result + }); + }, function (err) { + var e = null; + if (err instanceof Error) { + // Error doesn't serialise + e = {}; + Object.getOwnPropertyNames(err).forEach(function (a) { + e[a] = err[a]; + }); + } + session.sendMessage({ + error: message.request, + data: e || err + }); + }); +}; + +/** + * Resolves (or rejects) the promise for a request. + * + * @param {Object} message The response object. + */ +Session.prototype.handleReply = function (message) { + // Resolve or reject the promise that is waiting on the result. + var id = message.response || message.error; + var promise = id && this.promises[id]; + + if (promise) { + if (message.response) { + promise.resolve(message.data); + } else { + promise.reject(message.data); + } + delete this.promises[id]; + } +}; + +/** + * Send a request. + * + * @param {ServiceRequest} request The request data. + * @return {Promise} Resolves when the response has been received, rejects on error. + */ +Session.prototype.sendRequest = function (request) { + var session = this; + return new Promise(function (resolve, reject) { + + // Store the resolve/reject methods so they can be called when there's a response. + var requestId = Math.random().toString(36).substring(2); + session.promises[requestId] = { + resolve: resolve, + reject: reject + }; + + var message = Object.assign({}, request); + message.request = requestId; + session.sendMessage(message); + }); +}; + +module.exports = messaging; diff --git a/gpii-service/src/gpii-ipc.js b/gpii-service/src/gpii-ipc.js new file mode 100644 index 000000000..cd74fa118 --- /dev/null +++ b/gpii-service/src/gpii-ipc.js @@ -0,0 +1,467 @@ +/* IPC for GPII. + * Starts a process (as another user) with a communications channel. + * + * Copyright 2017 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var ref = require("ref-napi"), + net = require("net"), + crypto = require("crypto"), + service = require("./service.js"), + windows = require("./windows.js"), + logging = require("./logging.js"), + messaging = require("../shared/pipe-messaging.js"); + +var winapi = windows.winapi; + +var ipc = {}; +module.exports = ipc; + +ipc.pipePrefix = "\\\\.\\pipe\\gpii-"; + +/** + * A connection to a client. + * @typedef {Object} IpcConnection + * @property {boolean} authenticate true if authentication is required. + * @property {boolean} admin true to run the process as administrator. + * @property {number} pid The client pid. + * @property {String} name Name of the connection. + * @property {String} processKey Identifies the child process. + * @property {messaging.Session} messaging Messaging session. + * @property {function} requestHandler Function to handle requests for this connection. + */ + +/** + * @type {IpcConnection[]} + */ +ipc.ipcConnections = {}; + +/** + * Starts a process as the current desktop user, passing the name of a pipe to connect to. + * + * @param {String} command The command to execute. + * @param {String} ipcName [optional] The IPC connection name. + * @param {Object} options [optional] Options (see also {this}.execute()). + * @param {Boolean} options.authenticate Child must authenticate to pipe (default is true, if undefined). + * @param {Boolean} options.admin true to keep pipe access to admin-only. + * @param {Boolean} options.messaging true to use the messaging wrapper. + * @param {Boolean} options.processKey Identifies the child process. + * @return {Promise} Resolves with a value containing the pipe server and pid. + */ +ipc.startProcess = function (command, ipcName, options) { + if (options === undefined && typeof(ipcName) !== "string") { + options = ipcName; + ipcName = undefined; + } + options = Object.assign({}, options); + options.authenticate = options.authenticate || options.authenticate === undefined; + + var pipeName; + if (command) { + pipeName = ipc.generatePipeName(); + } else if (ipcName) { + pipeName = ipc.pipePrefix + ipcName; + } else { + return Promise.reject("startProcess must be called with command and/or ipcName."); + } + + var ipcConnection = null; + if (ipcName) { + ipcConnection = ipc.ipcConnections[ipcName]; + if (!ipcConnection) { + ipcConnection = ipc.ipcConnections[ipcName] = {}; + } + ipcConnection.name = ipcName; + ipcConnection.authenticate = options.authenticate; + ipcConnection.admin = options.admin; + ipcConnection.processKey = options.processKey; + ipcConnection.messaging = options.messaging ? undefined : false; + } + + // Create the pipe, and pass it to a new process. + return ipc.createPipe(pipeName, ipcConnection).then(function (pipeServer) { + var pid = null; + if (command) { + options.env = Object.assign({}, options.env); + options.env.GPII_SERVICE_PIPE = "pipe:" + pipeName.substr(ipc.pipePrefix.length); + pid = ipc.execute(command, options); + if (ipcConnection) { + ipcConnection.pid = pid; + } + } + + return { + pipeServer: pipeServer, + pid: pid + }; + }); +}; + +/** + * Generates a named-pipe name. + * + * @return {String} The name of the pipe. + */ +ipc.generatePipeName = function () { + var pipeName = ipc.pipePrefix + crypto.randomBytes(18).toString("base64").replace(/[\\/]/g, "."); + return pipeName; +}; + +/** + * Open a named pipe, set the permissions, and start serving. + * + * @param {String} pipeName Name of the pipe. + * @param {IpcConnection} ipcConnection The IPC connection. + * @return {Promise} A promise resolving with the pipe server when the pipe is ready to receive a connection. + */ +ipc.createPipe = function (pipeName, ipcConnection) { + return new Promise(function (resolve, reject) { + if (pipeName) { + var pipeServer = net.createServer(); + pipeServer.maxConnections = 1; + + pipeServer.on("error", function (err) { + logging.debug("ipc server error", err); + reject(err); + }); + + pipeServer.listen(pipeName, function () { + logging.debug("pipe listening", pipeName); + + var p = (ipcConnection && ipcConnection.admin) + ? Promise.resolve() + : ipc.setPipeAccess(pipeServer, pipeName); + + p.then(function () { + if (ipcConnection) { + // eslint-disable-next-line dot-notation + ipc.servePipe(ipcConnection, pipeServer).catch(reject); + } + + resolve(pipeServer); + }); + }); + } else { + reject({ + isError: true, + message: "pipeName was null" + }); + } + }); +}; + +/** + * Allows the desktop user to access the pipe. + * + * When running as a service, a normal user does not have enough permissions to open it. + * + * @param {net.Server} pipeServer The pipe server. All listeners of the "connection" event will be removed. + * @param {String} pipeName Name of the pipe. + * @return {Promise} Resolves when complete. + */ +ipc.setPipeAccess = function (pipeServer, pipeName) { + return new Promise(function (resolve) { + // setPipePermissions will connect to the pipe (and close it). This connection can be ignored, however the + // connection event needs to be swallowed before any more listeners are added. + pipeServer.removeAllListeners("connection"); + pipeServer.on("connection", function (pipe) { + pipeServer.removeAllListeners("connection"); + pipe.end(); + resolve(); + }); + + windows.setPipePermissions(pipeName); + }); +}; + +/** + * Start serving the pipe. + * + * @param {IpcConnection} ipcConnection The IPC connection. + * @param {net.Server} pipeServer The pipe server. + * @return {Promise} Resolves when the client has been validated, rejects if failed. + */ +ipc.servePipe = function (ipcConnection, pipeServer) { + return new Promise(function (resolve, reject) { + pipeServer.on("connection", function (pipe) { + logging.debug("ipc got connection"); + + if (ipcConnection.authenticate) { + pipeServer.close(); + if (!ipcConnection.pid) { + throw new Error("Got pipe connection before client was started."); + } + } + + pipe.on("error", function (err) { + logging.log("Pipe error", ipcConnection.name); + service.on("ipc.error", ipcConnection.name, ipcConnection, err); + }); + pipe.on("close", function () { + logging.log("Pipe close", ipcConnection.name); + service.emit("ipc.closed", ipcConnection.name, ipcConnection); + }); + + var promise; + if (ipcConnection.authenticate) { + promise = ipc.validateClient(pipe, ipcConnection.pid); + } else { + pipe.write("challenge:none\nOK\n"); + promise = Promise.resolve(); + } + + promise.then(function () { + logging.log("Pipe client authenticated:", ipcConnection.name); + ipcConnection.pipe = pipe; + + var handleRequest = function (request) { + return ipc.handleRequest(ipcConnection, request); + }; + + if (ipcConnection.messaging !== false) { + ipcConnection.messaging = messaging.createSession(ipcConnection.pipe, ipcConnection.name, handleRequest); + ipcConnection.messaging.on("ready", function () { + service.emit("ipc.connected", ipcConnection.name, ipcConnection); + }); + } + }).then(resolve, function (err) { + logging.error("validateClient rejected the client:", err); + reject(err); + }); + }); + }); +}; + +/** + * Validates the client connection of a pipe. + * + * @param {net.Socket} pipe The pipe to the client. + * @param {Number} pid The pid of the expected client. + * @param {Number} timeout Seconds to wait for the event (default 30). + * @return {Promise} Resolves when successful, rejects on failure. + */ +ipc.validateClient = function (pipe, pid, timeout) { + + // Create an event that's used to cancel waiting for the authentication event. + var cancelEventHandle = winapi.kernel32.CreateEventW(winapi.NULL, false, false, ref.NULL); + if (!cancelEventHandle) { + throw winapi.error("CreateEvent", cancelEventHandle); + } + + // Cancel waiting when the pipe was closed. + var onPipeClose = function () { + winapi.kernel32.SetEvent(cancelEventHandle); + }; + pipe.on("close", onPipeClose); + + var processHandle = null; + var childEventHandle = null; + try { + // Open the child process. + processHandle = winapi.kernel32.OpenProcess(winapi.constants.PROCESS_DUP_HANDLE, 0, pid); + if (!processHandle) { + throw winapi.error("OpenProcess", processHandle); + } + + // Create the event. + var eventHandle = winapi.kernel32.CreateEventW(winapi.NULL, false, false, ref.NULL); + if (!eventHandle) { + throw winapi.error("CreateEvent", eventHandle); + } + + // Duplicate the event handle for the child. + var eventHandleBuf = ref.alloc(winapi.types.HANDLE); + var ownProcess = 0xffffffff; // (uint)-1 + var success = + winapi.kernel32.DuplicateHandle(ownProcess, eventHandle, processHandle, eventHandleBuf, ref.NULL, false, 2); + if (!success) { + throw winapi.error("DuplicateHandle", success); + } + childEventHandle = eventHandleBuf.deref(); + + // Give the handle to the child. + process.nextTick(function () { + pipe.write("challenge:" + childEventHandle + "\n"); + service.logDebug("validateClient: send challenge"); + }); + + // Wait for the cancel or child process event. + return windows.waitForMultipleObjects([cancelEventHandle, eventHandle], (timeout || 30) * 1000, false).then(function (handle) { + pipe.removeListener("close", onPipeClose); + if (handle === eventHandle) { + pipe.write("OK\n"); + return Promise.resolve("success"); + } else { + pipe.end(); + return Promise.reject("failed"); + } + }); + + } finally { + if (processHandle) { + winapi.kernel32.CloseHandle(processHandle); + } + } +}; + +/** + * Executes a command in the context of the console user. + * + * https://blogs.msdn.microsoft.com/winsdk/2013/04/30/how-to-launch-a-process-interactively-from-a-windows-service/ + * + * @param {String} command The command to execute. + * @param {Object} options [optional] Options + * @param {Boolean} options.alwaysRun true to run as the current user (what this process is running as), if the console + * user token could not be received. Should only be true if not running as a service. + * @param {Object} options.env Additional environment key-value pairs. + * @param {String} options.currentDir Current directory for the new process. + * @param {Boolean} options.elevated Run with elevated privileges. + * + * @return {Number} The pid of the new process. + */ +ipc.execute = function (command, options) { + options = Object.assign({}, options); + + var userToken = windows.getDesktopUser(); + if (!userToken) { + // There is no token for this session - perhaps no one is logged on, or is in the lock-screen (screen saver). + // Continuing could cause something to be executed as the LocalSystem account, which may be undesired. + if (!options.alwaysRun) { + throw new Error("Unable to get the current desktop user (error=" + userToken.error + ")"); + } + + logging.warn("ipc.startProcess invoking as current user."); + userToken = 0; + } + + var runAsToken; + if (userToken && options.elevated) { + try { + runAsToken = windows.getElevatedToken(userToken); + } catch (err) { + runAsToken = 0; + logging.warn("getElevatedToken failed", err); + } + } else { + runAsToken = userToken; + } + + var pid = null; + + try { + + // Get a user-specific environment block. Without this, the new process will take the environment variables + // of the service, causing GPII to use the incorrect data directory. + var env = windows.getEnv(userToken); + if (options.env) { + // Add some extra values. + for (var name in options.env) { + var newValue = name + "=" + options.env[name]; + + // If there's already a variable with that name, replace it. + var re = new RegExp("^" + name + "="); + var index = env.findIndex(re.test, re); + if (index >= 0) { + env.splice(index, 1, newValue); + } else { + env.push(newValue); + } + } + } + + // Convert the environment block into a C string array. + var envString = env.join("\0") + "\0"; + var envBuf = winapi.stringToWideChar(envString); + + var commandBuf = winapi.stringToWideChar(command); + var creationFlags = winapi.constants.CREATE_UNICODE_ENVIRONMENT | winapi.constants.CREATE_NEW_CONSOLE; + + var currentDirectory = options.currentDir + ? winapi.stringToWideChar(options.currentDir) + : ref.NULL; + + var startupInfo = new winapi.STARTUPINFOEX(); + startupInfo.ref().fill(0); + startupInfo.cb = winapi.STARTUPINFOEX.size; + startupInfo.lpDesktop = winapi.stringToWideChar("winsta0\\default"); + + var processInfoBuf = new winapi.PROCESS_INFORMATION(); + processInfoBuf.ref().fill(0); + + var ret; + if (options.elevated && !runAsToken) { + // There was a problem getting the elevated token (non-admin user), run as the current user. + ret = winapi.kernel32.CreateProcessW(ref.NULL, commandBuf, ref.NULL, ref.NULL, + !!options.inheritHandles, creationFlags, envBuf, currentDirectory, + startupInfo.ref(), processInfoBuf.ref()); + } else { + ret = winapi.advapi32.CreateProcessAsUserW(runAsToken, ref.NULL, commandBuf, ref.NULL, ref.NULL, + !!options.inheritHandles, creationFlags, envBuf, currentDirectory, + startupInfo.ref(), processInfoBuf.ref()); + } + if (!ret) { + throw winapi.error("CreateProcessAsUser"); + } + + pid = processInfoBuf.dwProcessId; + + winapi.kernel32.CloseHandle(processInfoBuf.hThread); + winapi.kernel32.CloseHandle(processInfoBuf.hProcess); + + } finally { + if (userToken) { + winapi.kernel32.CloseHandle(userToken); + } + } + + return pid; +}; + +/** + * Handles a request received from a client. + * + * @param {IpcConnection} ipcConnection The IPC connection. + * @param {Object} request The request data. + * @return {Object} The result of the requestHandler callback. + */ +ipc.handleRequest = function (ipcConnection, request) { + return ipcConnection.requestHandler && ipcConnection.requestHandler(request); +}; + +/** + * Sends a request. + * + * @param {IpcConnection|String} ipcConnection The IPC connection. + * @param {ServiceRequest} request The request data. + * @return {Promise} Resolves when there's a response. + */ +ipc.sendRequest = function (ipcConnection, request) { + if (typeof(ipcConnection) === "string") { + ipcConnection = ipc.ipcConnections[ipcConnection]; + } + + return ipcConnection.messaging.sendRequest(request); +}; + +service.on("ipc.connected", function (name, connection) { + // emit another event that's bound to the IPC name + service.emit("ipc.connected:" + name, connection); +}); +service.on("ipc.closed", function (name, connection) { + // emit another event that's bound to the IPC name + service.emit("ipc.closed:" + name, connection); +}); diff --git a/gpii-service/src/gpiiClient.js b/gpii-service/src/gpiiClient.js new file mode 100644 index 000000000..ec943269f --- /dev/null +++ b/gpii-service/src/gpiiClient.js @@ -0,0 +1,297 @@ +/* Handles the requests and responses of the GPII user process. + * + * Copyright 2017 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var child_process = require("child_process"), + crypto = require("crypto"), + service = require("./service.js"), + ipc = require("./gpii-ipc.js"), + processHandling = require("./processHandling.js"); + +var gpiiClient = {}; +module.exports = gpiiClient; + +gpiiClient.options = { + // Number of seconds to wait for a response from the client before determining that the process is unresponsive. + clientTimeout: 120 +}; + +/** + * A map of functions for the requests handled. + * + * @type {Function(request)} + */ +gpiiClient.requestHandlers = {}; + +/** + * Executes something. + * + * @param {Object} request The request data. + * @param {String} request.command The command to run. + * @param {Array} request.args Arguments to pass. + * @param {Object} request.options The options argument for child_process.spawn. + * @param {Boolean} request.wait True to wait for the process to terminate before resolving. + * @param {Boolean} request.capture True capture output to stdout/stderr members of the response; implies wait=true. + * @param {Boolean} request.desktop true to run in the desktop user. + * @return {Promise} Resolves when the process has started, if wait=false, or when it's terminated. + */ +gpiiClient.requestHandlers.execute = function (request) { + var promise; + + if (request.desktop) { + var cmd = request.command; + if (request.args) { + if (Array.isArray(request.args) && request.args.length > 0) { + cmd += " " + request.args.join(); + } + } + var options = { + currentDir: request.options && request.options.cwd, + env: request.options && request.options.env, + elevated: true + }; + var pid = ipc.execute(cmd, options); + promise = processHandling.monitorProcess(pid); + } else { + promise = new Promise(function (resolve, reject) { + if (request.capture) { + request.wait = true; + } + + // spawn is used instead of exec, to avoid using the shell and worry about escaping. + var child = child_process.spawn(request.command, request.args, request.options); + + child.on("error", function (err) { + reject({ + isError: true, + error: err + }); + }); + + if (child.pid) { + var output = null; + if (request.capture) { + output = { + stdout: "", + stderr: "" + }; + child.stdout.on("data", function (data) { + // Limit the output to ~1 million characters + if (output.stdout.length < 0xfffff) { + output.stdout += data; + } + }); + child.stderr.on("data", function (data) { + if (output.stderr.length < 0xfffff) { + output.stderr += data; + } + }); + } + + if (request.wait) { + child.on("exit", function (code, signal) { + var result = { + exitCode: code, + signal: signal + }; + if (output) { + result.output = output; + } + resolve(result); + }); + } else { + resolve({pid: child.pid}); + } + } + }); + } + + return promise; +}; + +/** + * The user process is shutting down (eg, due to the user logging out of the system). The client sends this request + * to prevent the service restarting it when it terminates. + * + */ +gpiiClient.requestHandlers.closing = function () { + service.logImportant("GPII Client is closing itself"); + gpiiClient.inShutdown = true; + processHandling.dontRestartProcess(gpiiClient.ipcConnection.processKey); +}; + +/** + * Gets the client credentials from the secrets file. + * @return {Object} The client credentials. + */ +gpiiClient.requestHandlers.getClientCredentials = function () { + var secrets = service.getSecrets(); + return null;//secrets && secrets.clientCredentials; +}; + +/** + * Signs a string or Buffer (or an array of such), using the secret. + * + * @param {Object} request The signing request + * @param {String|Buffer} request.payload The thing to sign. + * @param {String} request.keyName Field name in the secrets file whose value is used as a key. + * @return {String} The HMAC digest of payload, as a hex string. + */ +gpiiClient.requestHandlers.sign = function (request) { + var result = null; + + var secrets = service.getSecrets(); + var key = secrets && secrets[request.keyName]; +key = null; + if (key) { + var hmac = crypto.createHmac("sha256", key); + + var payloads = Array.isArray(request.payload) ? request.payload : [request.payload]; + payloads.forEach(function (item) { + hmac.update(item); + }); + + result = hmac.digest("hex"); + } else { + service.logError("Attempted to sign with a key named " + + request.keyName + ", but no such value exists in the secrets file"); + } + + return result; +}; + +/** @type {Boolean} true if the client is being shutdown */ +gpiiClient.inShutdown = false; + +/** + * Adds a command handler. + * + * @param {String} requestType The request type. + * @param {Function} callback The callback function. + */ +gpiiClient.addRequestHandler = function (requestType, callback) { + gpiiClient.requestHandlers[requestType] = callback; +}; + +/** + * The IPC connection + * @type {IpcConnection} + */ +gpiiClient.ipcConnection = null; + +/** + * Called when the GPII user process has connected to the service. + * + * @param {IpcConnection} ipcConnection The IPC connection. + */ +gpiiClient.connected = function (ipcConnection) { + gpiiClient.ipcConnection = ipcConnection; + gpiiClient.inShutdown = false; + ipcConnection.requestHandler = gpiiClient.requestHandler; + + service.log("Established IPC channel with the GPII user process"); + + gpiiClient.monitorStatus(gpiiClient.options.clientTimeout); +}; + +/** + * Called when the GPII user process has disconnected from the service. + * + * @param {IpcConnection} ipcConnection The IPC connection. + */ +gpiiClient.closed = function (ipcConnection) { + service.log("Lost IPC channel with the GPII user process"); + gpiiClient.ipcConnection = null; + if (!gpiiClient.inShutdown) { + processHandling.stopChildProcess(ipcConnection.processKey, true); + } +}; + +/** + * Monitors the status of the GPII process, by continually sending a request and waiting for a reply. If there is no + * reply within a timeout, then the process is killed. + * + * @param {Number} timeout Seconds to wait before determining that the process is unresponsive. + */ +gpiiClient.monitorStatus = function (timeout) { + + var isRunning = false; + var processKey = gpiiClient.ipcConnection && gpiiClient.ipcConnection.processKey; + + gpiiClient.sendRequest("status").then(function (response) { + isRunning = response && response.isRunning; + }); + + setTimeout(function () { + if (gpiiClient.inShutdown || !gpiiClient.ipcConnection) { + // No longer needs to be monitored. + } else if (isRunning) { + gpiiClient.monitorStatus(timeout); + } else { + service.logError("GPII client is not responding."); + processHandling.stopChildProcess(processKey, true); + } + }, timeout * 1000); +}; + +/** + * Handles a request from the GPII user process. + * + * @param {ServiceRequest} request The request data. + * @return {Promise|Object} The response data. + */ +gpiiClient.requestHandler = function (request) { + var handler = request.requestType && gpiiClient.requestHandlers[request.requestType]; + service.logDebug("Got request:", request); + if (handler) { + return handler(request.requestData); + } +}; + +/** + * Sends a request to the GPII user process. + * + * @param {String} requestType The request type. + * @param {Object} requestData The request data. + * @return {Promise} Resolves with the response when it is received. + */ +gpiiClient.sendRequest = function (requestType, requestData) { + var req = { + requestType: requestType, + requestData: requestData + }; + return ipc.sendRequest(gpiiClient.ipcConnection, req); +}; + +/** + * Tell the GPII user process to shutdown. + * @return {Promise} Resolves with the response when it is received. + */ +gpiiClient.shutdown = function () { + if (gpiiClient.ipcConnection && !gpiiClient.inShutdown) { + gpiiClient.inShutdown = true; + return gpiiClient.sendRequest("shutdown"); + } +}; + +service.on("ipc.connected:gpii", gpiiClient.connected); +service.on("ipc.closed:gpii", gpiiClient.closed); + +service.on("stopping", function (promises) { + promises.push(gpiiClient.shutdown()); +}); diff --git a/gpii-service/src/logging.js b/gpii-service/src/logging.js new file mode 100644 index 000000000..5bbe06daf --- /dev/null +++ b/gpii-service/src/logging.js @@ -0,0 +1,168 @@ +/* Logging. + * + * Copyright 2017 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var fs = require("fs"); + +var logging = {}; + +logging.logFile = null; + +logging.levels = { + "FATAL": 0, + "ERROR": 10, + "IMPORTANT": 10, + "WARN": 20, + "INFO": 30, + "DEBUG": 40 +}; + +logging.setLogLevel = function (newLevel) { + var level = newLevel || logging.defaultLevel; + if (!level.isLevel) { + level = level.toString().toUpperCase(); + if (logging.levels.hasOwnProperty(level)) { + level = logging.levels[level]; + } else { + logging.error("Unknown log level: " + newLevel); + level = logging.defaultLevel; + } + } + + logging.logLevel = level; + logging.log(logging.logLevel, "Log level set: " + logging.logLevel.name); +}; + +/** + * Log something. + */ +logging.log = function () { + var args = logging.argsArray(arguments); + + var level = (args[0] && args[0].isLevel) + ? args.shift() + : logging.defaultLevel; + + logging.doLog(level, args); +}; + +logging.doLog = function (level, args) { + if (!level || !level.isLevel) { + level = logging.defaultLevel; + } + + if (level.value <= logging.logLevel.value) { + + var timestamp = new Date().toISOString(); + args.unshift(timestamp, level.name); + if (logging.logFile) { + var text = args.join(" ") + "\n"; + fs.appendFileSync(logging.logFile, text); + } else { + console.log.apply(console, args); + } + } +}; + +/** + * Sets the log file that stdout/stderr is sent to. + * + * @param {String} file The file to log to. + */ +logging.setFile = function (file) { + logging.logFile = file; + + // Capture and log writes to standard out and err. + var write = function (chunk, encoding, done) { + // This assumes a whole line is in one chunk. If not, then the parts of line will be on different log entries. + logging.log(chunk.toString().trim()); + done && done(); + }; + + process.stdout._write = process.stderr._write = write; +}; + +/** + * Converts an arguments object into an array. + * @param {Object} args An arguments object, or an array. + * @return {Array} The args object as an array. + */ +logging.argsArray = function (args) { + var togo; + if (Array.isArray(args)) { + togo = args; + } else { + togo = []; + for (var n = 0; n < args.length; n++) { + togo.push(args[n]); + } + } + + return togo; +}; + +/** + * Returns a function that logs to the given log level. + * @param {Object} level A member of logging.levels identifying the log level. + * @return {Function} A function that logs at the given level. + */ +logging.createLogFunction = function (level) { + return function () { + logging.doLog(level, logging.argsArray(arguments)); + }; +}; + +for (var level in logging.levels) { + if (logging.levels.hasOwnProperty(level)) { + // Create a level object. + var levelObj = { + isLevel: true, + value: logging.levels[level], + name: level + }; + // Add a convenience function for that level. + logging[level.toLowerCase()] = logging.createLogFunction(levelObj); + logging.levels[level] = levelObj; + } +} + +// Default level for Log entries when unspecified. +logging.defaultLevel = logging.levels.INFO; +// The current logging level +logging.logLevel = logging.defaultLevel; + +/** @name logging.fatal + * @function + */ +/** @name logging.error + * @function + */ +/** @name logging.important + * @function + */ +/** @name logging.warn + * @function + */ +/** @name logging.info + * @function + */ +/** @name logging.debug + * @function + */ + +module.exports = logging; diff --git a/gpii-service/src/main.js b/gpii-service/src/main.js new file mode 100644 index 000000000..5bfa7a57b --- /dev/null +++ b/gpii-service/src/main.js @@ -0,0 +1,28 @@ +/* Entry point for when the GPII windows service starts. + * This should be executed when the service has already started, otherwise a delay in the loading may cause the system + * to think it's not responding to the start signal. + * + * Copyright 2017 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; +var service = require("./service.js"); + +require("./windows.js"); +require("./gpii-ipc.js"); +require("./processHandling.js"); +require("./gpiiClient.js"); + +service.start(); diff --git a/gpii-service/src/processHandling.js b/gpii-service/src/processHandling.js new file mode 100644 index 000000000..80e9b83da --- /dev/null +++ b/gpii-service/src/processHandling.js @@ -0,0 +1,499 @@ +/* Manages the child processes. + * + * Copyright 2017 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var service = require("./service.js"), + ipc = require("./gpii-ipc.js"), + windows = require("./windows.js"), + winapi = require("./winapi.js"); + +var processHandling = {}; +module.exports = processHandling; + +/** + * Configuration of a child process (taken from service.json5) + * @typedef {Object} ProcessConfig + * @property {String} command The command. + * @property {String} key Identifier. + * @property {Boolean} autoRestart true to re-start the process if terminates. + * @property {String} ipc IPC channel name (optional). + * @property {Object} env Environment variables to set (optional). + * @property {String} currentDir The current dir (optional). + */ + +/** + * A running child process + * @typedef {Object} ChildProcess + * @property {ProcessConfig} procConfig The process configuration. + * @property {Number} pid Process ID. + * @property {Array} lastStart When the process was started (used for restart-throttling) + * @property {Number} failureCount How many times this process has failed to start. + * @property {Boolean} shutdown true if shutting down. + * @property {String} creationTime A number representing the time the process started. Used to ensure the process + * identified by pid is the same process. + */ + +/** + * @type {Array} + */ +processHandling.childProcesses = {}; + +/** + * The active console session has changed. + * @param {String} eventType The event type for the sessionChange event (see service.controlHandler()). + */ +processHandling.sessionChange = function (eventType) { + service.logDebug("session change", eventType); + + switch (eventType) { + case "session-logon": + // User just logged on - start the processes. + processHandling.startChildProcesses(); + break; + case "session-logoff": + // User just logged off - stop the processes (windows should have done this already). + processHandling.stopChildProcesses(); + break; + } +}; + +/** + * Starts the configured processes. + */ +processHandling.startChildProcesses = function () { + var processes = Object.keys(service.config.processes); + // Start each child process sequentially. + var startNext = function () { + var key = processes.shift(); + if (key && !service.config.processes[key].disabled) { + var proc = Object.assign({key: key}, service.config.processes[key]); + processHandling.startChildProcess(proc).then(startNext, function (err) { + service.logError("startChildProcess failed for " + key, err); + startNext(); + }); + } + }; + startNext(); +}; + +/** + * Starts a process. + * + * @param {ProcessConfig} procConfig The process configuration (from service-config.json). + * @return {Promise} Resolves (with the pid) when the process has started. + */ +processHandling.startChildProcess = function (procConfig) { + var childProcess = processHandling.childProcesses[procConfig.key]; + + service.log("Starting " + procConfig.key + ": " + (procConfig.command || "pipe only")); + service.logDebug("Process config: ", JSON.stringify(procConfig)); + + if (childProcess) { + if (processHandling.isProcessRunning(childProcess.pid, childProcess.creationTime)) { + service.logWarn("Process " + procConfig.key + " is already running"); + return Promise.reject(); + } + } else { + childProcess = { + procConfig: procConfig + }; + processHandling.childProcesses[procConfig.key] = childProcess; + } + + childProcess.pid = 0; + childProcess.pipe = null; + childProcess.lastStart = process.hrtime(); + childProcess.shutdown = false; + + var startOptions = { + env: procConfig.env, + currentDir: procConfig.currentDir, + authenticate: !procConfig.noAuth, + admin: procConfig.admin, + processKey: procConfig.key + }; + + var processPromise = null; + + if (procConfig.ipc) { + startOptions.messaging = true; + // Start the process with a pipe. + processPromise = ipc.startProcess(procConfig.command, procConfig.ipc, startOptions).then(function (p) { + if (procConfig.command) { + childProcess.pid = p.pid; + childProcess.pipe = null; + childProcess.creationTime = processHandling.getProcessCreationTime(childProcess.pid); + } + return p.pid; + }); + } else if (procConfig.command) { + // Start the process without a pipe. + childProcess.pid = ipc.execute(procConfig.command, startOptions); + childProcess.creationTime = processHandling.getProcessCreationTime(childProcess.pid); + processPromise = Promise.resolve(childProcess.pid); + } + + return processPromise.then(function (pid) { + if (procConfig.command && procConfig.autoRestart) { + processHandling.autoRestartProcess(procConfig.key); + } + return pid; + }); +}; + +/** + * Stops all child processes. This is performed when the service has been told to stop. + */ +processHandling.stopChildProcesses = function () { + service.log("Stopping processes"); + var processKeys = Object.keys(processHandling.childProcesses); + processKeys.forEach(function (processKey) { + processHandling.stopChildProcess(processKey); + }); +}; + +/** + * Stops a child process. + * @param {String} processKey Identifies the child process. + * @param {Boolean} [restart] Allow the process to be restarted, if configured. + */ +processHandling.stopChildProcess = function (processKey, restart) { + var childProcess = processHandling.childProcesses[processKey]; + if (childProcess) { + service.log("Stopping " + processKey + ": " + childProcess.procConfig.command); + + childProcess.shutdown = !restart; + + if (processHandling.isProcessRunning(childProcess.pid, childProcess.creationTime)) { + try { + process.kill(childProcess.pid); + } catch (e) { + // Ignored. + } + } else { + service.logDebug("Process '" + processKey + "' is not running"); + } + } +}; + +/** + * Set a running process to not restart. Called when the impending termination is intentional. + * @param {String} processKey Identifies the child process. + */ +processHandling.dontRestartProcess = function (processKey) { + var childProcess = processHandling.childProcesses[processKey]; + if (childProcess) { + childProcess.shutdown = true; + } +}; + +/** + * Auto-restarts a child process when it terminates. + * + * @param {String} processKey Identifies the child process. + */ +processHandling.autoRestartProcess = function (processKey) { + var childProcess = processHandling.childProcesses[processKey]; + processHandling.monitorProcess(childProcess.pid).then(function () { + service.log("Child process '" + processKey + "' died"); + service.emit("process.stop", processKey); + + if (childProcess.shutdown) { + service.log("Not restarting process (shutting down)"); + } else { + var restart = true; + // Check if it's failing to start - if it's been running for less than 20 seconds. + var timespan = process.hrtime(childProcess.lastStart); + var seconds = timespan[0]; + if (seconds > 20) { + childProcess.failureCount = 0; + } else { + service.logWarn("Process '" + processKey + "' failed at start."); + childProcess.failureCount = (childProcess.failureCount || 0) + 1; + if (childProcess.failureCount > 5) { + // Crashed at the start too many times. + service.logError("Unable to start process '" + processKey + "'"); + restart = false; + } + } + + if (restart) { + // Delay restart it. + var delay = processHandling.throttleRate(childProcess.failureCount); + service.logDebug("Restarting process '" + processKey + "' in " + Math.round(delay / 1000) + " seconds."); + setTimeout(processHandling.startChildProcess, delay, childProcess.procConfig); + } + } + }); +}; + +/** + * Gets the number of milliseconds to delay a process restart. + * + * @param {Number} failureCount The number of times the process has failed to start. + * @return {Number} Returns 10 seconds for every failure count. + */ +processHandling.throttleRate = function (failureCount) { + return failureCount * 10000; +}; + +/** + * Determine if a process is running. + * + * To deal with PID re-use, provide creationTime (from a previous call to getProcessCreationTime) to also determine if + * the running process ID still refers to the original one at the time of the getProcessCreationTime call, and hasn't + * been re-used. + * + * @param {Number} pid The process ID. + * @param {String} creationTime [Optional] Numeric string representing the time the process started. + * @return {Boolean} true if the process is running, and has the same creation time (if provided). + */ +processHandling.isProcessRunning = function (pid, creationTime) { + var running = false; + + if (pid > 0) { + try { + process.kill(pid, 0); + running = true; + } catch (e) { + // It's not running. + } + + var newCreationTime = processHandling.getProcessCreationTime(pid); + if (running) { + if (creationTime && newCreationTime) { + // The pid is running, return true if it's the same process. + running = newCreationTime === creationTime; + } else { + running = !!newCreationTime; + } + } else { + if (newCreationTime) { + // The process isn't running, but the pid is still valid. + // This could mean CloseHandle hasn't been called (by this, or any other process). + service.logDebug("Possible process handle leak on pid " + pid); + } + } + } + + return running; +}; + +/** + * Gets the time that the given process was started. This is used when determining if a pid still refers to the same + * process, due to the high level of pid re-use that Windows provides. + * + * The return value is intended to be compared to another call to this function, so the actual value (microseconds + * between 1601-01-01 and when the process started) isn't important. + * + * @param {Number} pid The process ID. + * @return {String} A numeric string, representing the time the process started - null if there's no such process. + */ +processHandling.getProcessCreationTime = function (pid) { + var creationTime = new winapi.FILETIME(), + exitTime = new winapi.FILETIME(), + kernelTime = new winapi.FILETIME(), + userTime = new winapi.FILETIME(); + + var success = false; + var processHandle = null; + + try { + + if (pid > 0) { + processHandle = winapi.kernel32.OpenProcess(winapi.constants.PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + } + + if (processHandle === winapi.NULL) { + service.logDebug(winapi.errorText("OpenProcess", "NULL")); + } else if (processHandle) { + success = winapi.kernel32.GetProcessTimes( + processHandle, creationTime.ref(), exitTime.ref(), kernelTime.ref(), userTime.ref()); + if (!success) { + service.logDebug(winapi.errorText("GetProcessTimes", success)); + } + } + } finally { + if (processHandle) { + winapi.kernel32.CloseHandle(processHandle); + } + } + + return success + ? creationTime.ref().readUInt64LE() + : null; +}; + +// handle: { handle, pid, resolve, reject } +processHandling.monitoredProcesses = {}; +// The last process to be monitored. +processHandling.lastProcess = null; + +/** + * Resolves when the given process terminates. + * + * Using WaitForSingleObject is normally enough, but because calling that (using FFI's async method) creates a thread, + * and there will be several processes to wait upon, WaitForMultipleObjects is used instead. + * + * An event (https://msdn.microsoft.com/library/ms686915) will also be added to the things to wait for, so when another + * process is added to the monitoring list WaitForMultipleObjects can be restarted. (A nicer way would be to alert the + * thread, but the thread is handled by ffi+libuv). + * + * @param {Number} pid The process ID. + * @return {Promise} Resolves when the process identified by pid terminates. + */ +processHandling.monitorProcess = function (pid) { + + return new Promise(function (resolve, reject) { + // Get the process handle. + var access = winapi.constants.SYNCHRONIZE | winapi.constants.PROCESS_QUERY_LIMITED_INFORMATION; + var processHandle = winapi.kernel32.OpenProcess(access, 0, pid); + + if (processHandle === winapi.NULL) { + // Unable to get a handle, so retry without PROCESS_QUERY_LIMITED_INFORMATION. This just means the + // will not be available. + processHandle = winapi.kernel32.OpenProcess(winapi.constants.SYNCHRONIZE, 0, pid); + if (processHandle === winapi.NULL) { + reject(windows.win32Error("OpenProcess")); + } + } + + processHandling.lastProcess = { + handle: processHandle, + pid: pid, + resolve: resolve, + reject: reject + }; + + // Add this process to the monitored list. + processHandling.monitoredProcesses[processHandle] = processHandling.lastProcess; + + // (Re)start the waiting. + var event = processHandling.monitoredProcesses.event; + if (event) { + // Cause the current call to WaitForMultipleObjects to unblock, so the new process can also be monitored. + winapi.kernel32.SetEvent(event.handle); + } else { + // Create the event. + var eventHandle = winapi.kernel32.CreateEventW(winapi.NULL, false, false, winapi.NULL); + processHandling.monitoredProcesses.event = { + handle: eventHandle, + isEvent: true + }; + + processHandling.startWait(); + } + }); +}; + +/** + * Stops a monitored process from being monitored. The promises for the process will resolve with "removed". + * + * @param {Number|Object} process The process ID, or the object in processHandling.monitoredProcesses. + * @param {Boolean} removeOnly true to only remove it from the list of monitored processes. + */ +processHandling.unmonitorProcess = function (process, removeOnly) { + var resolves = []; + var pid = parseInt(process); + if (pid) { + for (var key in processHandling.monitoredProcesses) { + var proc = !isNaN(key) && processHandling.monitoredProcesses[key]; + if (proc && proc.pid === pid) { + processHandling.unmonitorProcess(proc, removeOnly); + } + } + } else { + winapi.kernel32.CloseHandle(process.handle); + resolves.push(process.resolve); + delete processHandling.monitoredProcesses[process.handle]; + + if (!removeOnly) { + if (processHandling.monitoredProcesses.event) { + // Cause the current call to WaitForMultipleObjects to unblock to update the list. + winapi.kernel32.SetEvent(processHandling.monitoredProcesses.event.handle); + } + + resolves.forEach(function (resolve) { + resolve("removed"); + }); + } + } +}; + +/** + * Performs the actual monitoring of the processes added by monitorProcess(). + * Explained in processHandling.monitorProcess(). + */ +processHandling.startWait = function () { + var handles = Object.keys(processHandling.monitoredProcesses).map(function (key) { + return processHandling.monitoredProcesses[key].handle; + }); + + if (handles.length <= 1) { + // Other than the event, there's nothing to wait for. Release the event and don't start the wait. + winapi.kernel32.CloseHandle(processHandling.monitoredProcesses.event.handle); + delete processHandling.monitoredProcesses.event; + } else { + // Wait for one or more of the handles (processes or the event) to do something. + windows.waitForMultipleObjects(handles).then(function (handle) { + var proc = processHandling.monitoredProcesses[handle] || processHandling.monitoredProcesses.event; + if (proc.isEvent) { + // The event was triggered to re-start waiting. + } else { + // Remove it from the list, and resolve. + processHandling.unmonitorProcess(proc, true); + var exitCode = windows.getProcessExitCode(handle); + proc.resolve({ + pid: proc.pid, + exitCode: exitCode + }); + } + // Start waiting again. + processHandling.startWait(); + + }, function (reason) { + // The wait failed - it could be due to the most recent one so reject+remove that one and try again. + var last = processHandling.lastProcess && + processHandling.monitoredProcesses[processHandling.lastProcess.handle]; + if (last) { + if (processHandling.lastProcess.reject) { + processHandling.unmonitorProcess(processHandling.monitoredProcesses[last.handle], true); + processHandling.lastProcess.reject(reason); + } + } else { + // Reject + remove all of them + Object.keys(processHandling.monitoredProcesses).forEach(function (proc) { + if (!proc.isEvent) { + processHandling.unmonitorProcess(proc, true); + if (proc.reject) { + proc.reject(reason); + } + } + }); + } + processHandling.lastProcess = null; + // Try the wait again (or release the event). + processHandling.startWait(); + }); + } +}; + +// Listen for session change. +service.on("service.sessionchange", processHandling.sessionChange); +// Listen for service stop. +service.on("stop", processHandling.stopChildProcesses); diff --git a/gpii-service/src/service.js b/gpii-service/src/service.js new file mode 100644 index 000000000..dea3f15d2 --- /dev/null +++ b/gpii-service/src/service.js @@ -0,0 +1,231 @@ +/* The GPII windows service. + * + * Copyright 2017 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var os_service = require("@gpii/os-service"), + path = require("path"), + fs = require("fs"), + events = require("events"), + JSON5 = require("json5"), + logging = require("./logging.js"), + windows = require("./windows.js"), + parseArgs = require("minimist"); + +/** + * The service object is a central event source, to reduce coupling between the different modules. Events can be emitted + * for other modules to act upon. + * + * The events are: + * start - The service has started. + * + * stopping(promises) - The service is about to stop. Add a promise to the first argument to delay the shutdown. + * stop - The service is about to stop (second pass). + * + * service. - The service has received a control code (see service.controlHandler()) + * + * ipc.connected(, {IpcConnection}) - Something has connected (and authenticated) to an IPC channel. + * + * process.stop() - A child process has stopped. + */ +var service = new events.EventEmitter(); + +service.args = parseArgs(process.argv.slice(2)); + +// true if the process running as a Windows Service, otherwise a normal user process. +service.isService = !!service.args.service; +// true if the service is an exe file (rather than node) +service.isExe = !!process.versions.pkg; + +/** Log something */ +service.log = logging.log; +service.logFatal = logging.fatal; +service.logError = logging.error; +service.logImportant = logging.important; +service.logWarn = logging.warn; +service.logDebug = logging.debug; + +/** + * Loads the config file, which may be found in the first of the following locations: + * - The file parameter. + * - "--config" command line option. + * - "service.json5" next to the service executable. + * - "service.json5" in the config directory. + * + * @param {String} dir The directory form which relative paths are used. + * @param {String} file [optional] The config file. + */ +service.loadConfig = function (dir, file) { + // Load the config file. + var configFile = file || service.args.config; + if (!configFile) { + if (service.isService) { + // Check if there's a config file next to the service executable. + var tryFile = path.join(dir, "service.json5"); + if (fs.existsSync(tryFile)) { + configFile = tryFile; + } + } + if (!configFile) { + if (service.isService) { + // Use the built-in config file. + configFile = path.join(__dirname, "../config/service.json5"); + } else { + configFile = "config/service.dev.json5"; + } + } + } + if ((configFile.indexOf("/") === -1) && (configFile.indexOf("\\") === -1)) { + configFile = path.join(dir, "config", configFile); + } + + service.log("Loading config file", configFile); + service.config = JSON5.parse(fs.readFileSync(configFile)); + + // Change to the configured log level (if it's not passed via command line) + if (!service.args.loglevel && service.config.logging && service.config.logging.level) { + logging.setLogLevel(service.config.logging.level); + } +}; + +/** + * Gets the the secrets, which is the data stored in the secrets file. + * + * The secret is installed in a separate installer, which could occur after Morphic was installed. Also, the secret + * may be later updated. Because of this, the secret is read each time it is used. + * + * @return {Object} The secret, or null if the secret could not be read. This shouldn't be logged. + */ +service.getSecrets = function () { + var secret = null; + + try { + var file = service.config.secretFile + && path.resolve(windows.expandEnvironmentStrings(service.config.secretFile)); + if (file) { + service.log("Reading secrets from " + file); + secret = JSON5.parse(fs.readFileSync(file)); + } else { + service.logError("The secrets file is not configured"); + } + } catch (e) { + service.logWarn("Unable to read the secrets file " + service.config.secretFile, e); + } + + return secret ? secret : null; +}; + +/** + * Called when the service has just started. + */ +service.start = function () { + service.isService = os_service.getState() !== "stopped"; + // Control codes are how Windows tells services about certain system events. These are caught in os_service. + // Register the control codes that the service would be interested in. + os_service.acceptControl(["start", "stop", "shutdown", "sessionchange"], true); + // Handle all registered control codes. + os_service.on("*", service.controlHandler); + os_service.on("stop", service.stop); + + service.emit("start"); + service.log("service start"); + + if (windows.isUserLoggedOn()) { + // The service was started while a user is already active; fake a session-change event to get things started. + service.controlHandler("sessionchange", "session-logon"); + } +}; + +/** + * Stop the service, after shutting down the child processes. + */ +service.stop = function () { + + // Timeout waiting for things to shutdown. + var timer = setTimeout(service.stopNow, 10000); + + var promises = []; + service.logImportant("Shutting down"); + service.emit("stopping", promises); + + Promise.all(promises).then(function () { + clearTimeout(timer); + service.stopNow(); + }); +}; + +/** + * Stop the service immediately. This function should not return. + */ +service.stopNow = function () { + service.logFatal("Stopping now"); + + // Ensure the process always terminates. + process.nextTick(process.exit); + + try { + service.emit("stop"); + } finally { + // This will end the process, and not return. + os_service.stop(); + } +}; + +/** + * Called when the service receives a control code. This is what's used to detect a shutdown, service stop, or Windows + * user log-in/out. + * + * This emits a "service." event. + * + * Possible control codes: start, stop, pause, continue, interrogate, shutdown, paramchange, netbindadd, netbindremove, + * netbindenable, netbinddisable, deviceevent, hardwareprofilechange, powerevent, sessionchange, preshutdown, + * timechange, triggerevent. + * + * For this function to receive a control code, it needs to be added via os_service.acceptControl() + * + * For the "sessionchange" control code, the eventType parameter will be one of: + * console-connect, console-disconnect, remote-connect, remote-disconnect, session-logon, session-logoff, session-lock, + * session-unlock, session-remote, session-create, session-terminate. + * + * See also: https://msdn.microsoft.com/library/ms683241 + * + * @param {String} controlName Name of the control code. + * @param {String} [eventType] For the "sessionchange" control code, this specifies the type of event. + */ +service.controlHandler = function (controlName, eventType) { + service.logDebug("Service control: ", controlName, eventType); + service.emit("service." + controlName, eventType); +}; + + +// Change directory to a sane location, allowing relative paths in the config file. +var dir = null; +if (service.isExe) { + // The directory containing this executable (morphic-service.exe) + dir = path.dirname(process.execPath); +} else { + // Path of the index.js. + dir = path.join(__dirname, ".."); +} + +process.chdir(dir); + +// Load the configuration +service.loadConfig(dir); + + +module.exports = service; diff --git a/gpii-service/src/winapi.js b/gpii-service/src/winapi.js new file mode 100644 index 000000000..a6757b513 --- /dev/null +++ b/gpii-service/src/winapi.js @@ -0,0 +1,486 @@ +/* Windows API interface. + * + * Copyright 2017 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var ffi = require("ffi-napi"), + ref = require("ref-napi"), + Struct = require("ref-struct-di")(ref), + arrayType = require("ref-array-di")(ref); + +var winapi = {}; + +winapi.NULL = ref.NULL; + +winapi.constants = { + MAX_PATH: 260, + // dwCreationFlags, https://msdn.microsoft.com/library/ms684863 + CREATE_UNICODE_ENVIRONMENT: 0x00000400, + CREATE_NEW_CONSOLE: 0x00000010, + DETACHED_PROCESS: 0x00000008, + + MIB_TCP_STATE_ESTAB: 5, + + // https://msdn.microsoft.com/library/aa374905 + TOKEN_ASSIGN_PRIMARY: 0x0001, + TOKEN_DUPLICATE: 0x0002, + TOKEN_QUERY: 0x0008, + + // CreateToolhelp32Snapshot; https://msdn.microsoft.com/library/ms682489 + TH32CS_SNAPPROCESS: 0x00000002, + + INVALID_HANDLE_VALUE: 0xFFFFFFFF, // Really (uint)-1 + + HANDLE_FLAG_INHERIT: 1, + + // https://msdn.microsoft.com/library/aa446632 + GENERIC_READ: 0x80000000, + GENERIC_READWRITE: 0xC0000000, // GENERIC_READ | GENERIC_WRITE + // https://msdn.microsoft.com/library/aa379607 + WRITE_DAC: 0x00040000, + // https://msdn.microsoft.com/library/aa363858 + OPEN_EXISTING: 3, + FILE_FLAG_OVERLAPPED: 0x40000000, + // https://msdn.microsoft.com/library/ms684880 + SYNCHRONIZE: 0x00100000, + PROCESS_QUERY_LIMITED_INFORMATION: 0x1000, + PROCESS_DUP_HANDLE: 0x0040, + + // https://msdn.microsoft.com/library/ms687025 + INFINITE: 0xFFFFFFFF, + WAIT_OBJECT_0: 0, + WAIT_ABANDONED_0: 0x00000080, + WAIT_TIMEOUT: 0x102, + WAIT_FAILED: 0xFFFFFFFF +}; + +winapi.errorCodes = { + ERROR_SUCCESS: 0, + ERROR_ACCESS_DENIED: 5, + ERROR_BAD_LENGTH: 24, + ERROR_INSUFFICIENT_BUFFER: 122, + ERROR_NO_TOKEN: 1008, + ERROR_PRIVILEGE_NOT_HELD: 1314 +}; + +winapi.types = { + BYTE: "uint8", + BOOL: "int", + UINT: "uint", + HANDLE: "uint", + PHANDLE: "void*", + LP: "void*", + SIZE_T: "ulong", + WORD: "uint16", + DWORD: "ulong", + LPDWORD: "ulong*", + LONG: "long", + ULONG: "ulong", + PULONG: "ulong*", + LPTSTR: "char*", + Enum: "uint" +}; +var t = winapi.types; + +// https://msdn.microsoft.com/library/bb485761 +winapi.MIB_TCPROW2 = new Struct([ + [t.DWORD, "dwState"], + [t.DWORD, "dwLocalAddr"], + [t.DWORD, "dwLocalPort"], + [t.DWORD, "dwRemoteAddr"], + [t.DWORD, "dwRemotePort"], + [t.DWORD, "dwOwningPid"], + [t.Enum, "dwOffloadState"] +]); + +// https://msdn.microsoft.com/library/ms686329 +winapi.STARTUPINFOEX = new Struct([ + [t.DWORD, "cb"], + [t.LPTSTR, "lpReserved"], + [t.LPTSTR, "lpDesktop"], + [t.LPTSTR, "lpTitle"], + [t.DWORD, "dwX"], + [t.DWORD, "dwY"], + [t.DWORD, "dwXSize"], + [t.DWORD, "dwYSize"], + [t.DWORD, "dwXCountChars"], + [t.DWORD, "dwYCountChars"], + [t.DWORD, "dwFillAttribute"], + [t.DWORD, "dwFlags"], + [t.WORD, "wShowWindow"], + [t.WORD, "cbReserved2"], + [t.LP, "lpReserved2"], + [t.HANDLE, "hStdInput"], + [t.HANDLE, "hStdOutput"], + [t.HANDLE, "hStdError"], + [t.LP, "lpAttributeList"] +]); + +// https://msdn.microsoft.com/library/ms684873 +winapi.PROCESS_INFORMATION = new Struct([ + [t.HANDLE, "hProcess"], + [t.HANDLE, "hThread"], + [t.DWORD, "dwProcessId"], + [t.DWORD, "dwThreadId"] +]); + +// https://msdn.microsoft.com/library/bb773378 +winapi.PROFILEINFO = new Struct([ + [t.DWORD, "dwSize"], + [t.DWORD, "dwFlags"], + [t.LPTSTR, "lpUserName"], + [t.LPTSTR, "lpProfilePath"], + [t.LPTSTR, "lpDefaultPath"], + [t.LPTSTR, "lpServerName"], + [t.LPTSTR, "lpPolicyPath"], + [t.HANDLE, "hProfile"] +]); + +// https://msdn.microsoft.com/library/ms684839 +winapi.PROCESSENTRY32 = new Struct([ + [t.DWORD, "dwSize"], + [t.DWORD, "cntUsage"], + [t.DWORD, "th32ProcessID"], + [t.LP, "th32DefaultHeapID"], + [t.DWORD, "th32ModuleID"], + [t.DWORD, "cntThreads"], + [t.DWORD, "th32ParentProcessID"], + [t.LONG, "pcPriClassBase"], + [t.DWORD, "dwFlags"], + [arrayType("char", winapi.constants.MAX_PATH), "szExeFile"] +]); + +// https://msdn.microsoft.com/library/ms724284 +winapi.FILETIME = new Struct([ + [t.DWORD, "dwLowDateTime"], + [t.DWORD, "dwHighDateTime"] +]); + +// https://msdn.microsoft.com/library/aa374931 +winapi.ACL = new Struct([ + [t.BYTE, "AclRevision"], + [t.BYTE, "Sbz1"], + [t.WORD, "AclSize"], + [t.WORD, "AceCount"], + [t.WORD, "Sbz2"] +]); +winapi.PACL = ref.refType(winapi.ACL); + +// https://msdn.microsoft.com/library/aa379636 +winapi.TRUSTEE = new Struct([ + [t.LP, "pMultipleTrustee"], + [t.UINT, "MultipleTrusteeOperation"], + [t.UINT, "TrusteeForm"], + [t.UINT, "TrusteeType"], + [ref.refType(t.LP), "ptstrName"] +]); + +// https://msdn.microsoft.com/library/aa446627 +winapi.EXPLICIT_ACCESS = new Struct([ + [ t.DWORD, "grfAccessPermissions"], + [ t.UINT, "grfAccessMode"], + [ t.DWORD, "grfInheritance"], + [ winapi.TRUSTEE, "Trustee"] +]); + +winapi.kernel32 = ffi.Library("kernel32", { + // https://msdn.microsoft.com/library/aa383835 + "WTSGetActiveConsoleSessionId": [ + t.DWORD, [] + ], + // https://msdn.microsoft.com/library/aa383835 + "ProcessIdToSessionId": [ + t.BOOL, [ t.DWORD, t.LPDWORD ] + ], + "CloseHandle": [ + t.BOOL, [t.HANDLE] + ], + "GetLastError": [ + "int32", [] + ], + // https://msdn.microsoft.com/library/ms684320 + "OpenProcess": [ + t.HANDLE, [ t.DWORD, t.BOOL, "int" ] + ], + // https://msdn.microsoft.com/library/ms683179 + "GetCurrentProcess": [ + t.HANDLE, [] + ], + // https://msdn.microsoft.com/library/ms682489 + "CreateToolhelp32Snapshot": [ + t.HANDLE, [t.DWORD, t.DWORD] + ], + // https://msdn.microsoft.com/library/ms684834 + "Process32First": [ + "bool", [t.DWORD, "pointer"] + ], + // https://msdn.microsoft.com/library/ms684836 + "Process32Next": [ + t.BOOL, [t.HANDLE, "pointer"] + ], + // https://msdn.microsoft.com/library/ms686714 + "TerminateProcess": [ + t.BOOL, [t.HANDLE, t.UINT] + ], + // https://msdn.microsoft.com/library/ms683231 + "GetStdHandle": [ + t.HANDLE, [ t.DWORD ] + ], + // https://msdn.microsoft.com/library/ms724935 + "SetHandleInformation": [ + t.BOOL, [ t.HANDLE, t.DWORD, t.DWORD ] + ], + // https://msdn.microsoft.com/library/aa363858 + "CreateFileW": [ + t.HANDLE, [ t.LPTSTR, t.DWORD, t.DWORD, t.LP, t.DWORD, t.DWORD, t.HANDLE ] + ], + // https://msdn.microsoft.com/library/aa365747 + "WriteFile": [ + t.BOOL, [ t.HANDLE, t.LP, t.DWORD, t.LP, t.LP ] + ], + // https://msdn.microsoft.com/library/ms687032 + "WaitForSingleObject": [ + t.DWORD, [ t.HANDLE, t.DWORD ] + ], + // https://msdn.microsoft.com/library/ms687025 + "WaitForMultipleObjects": [ + t.DWORD, [ t.DWORD, arrayType(t.HANDLE), t.BOOL, t.DWORD ] + ], + // https://msdn.microsoft.com/library/ms682396 + "CreateEventW": [ + t.DWORD, [ t.LP, t.BOOL, t.BOOL, t.LP ] + ], + // https://msdn.microsoft.com/library/ms686211 + "SetEvent": [ + t.BOOL, [ t.HANDLE ] + ], + // https://msdn.microsoft.com/library/ms682411 + "CreateMutexW": [ + t.HANDLE, [ t.LP, t.BOOL, t.LPTSTR ] + ], + // https://msdn.microsoft.com/library/ms684315 + "OpenMutexW": [ + t.HANDLE, [ t.DWORD, t.BOOL, t.LPTSTR ] + ], + // https://msdn.microsoft.com/library/ms684315 + "ReleaseMutex": [ + t.BOOL, [ t.HANDLE ] + ], + // https://msdn.microsoft.com/library/ms683223 + "GetProcessTimes": [ + t.BOOL, [ t.HANDLE, t.LP, t.LP, t.LP, t.LP ] + ], + // https://msdn.microsoft.com/library/ms724251 + "DuplicateHandle": [ + t.BOOL, [ t.HANDLE, t.HANDLE, t.HANDLE, t.PHANDLE, t.DWORD, t.BOOL, t.DWORD ] + ], + // https://msdn.microsoft.com/library/ms724265 + "ExpandEnvironmentStringsW": [ + t.BOOL, [ t.LPTSTR, t.LPTSTR, t.UINT ] + ], + // https://docs.microsoft.com/windows/win32/api/processthreadsapi/nf-processthreadsapi-getexitcodeprocess + "GetExitCodeProcess": [ + t.BOOL, [ t.HANDLE, t.LPDWORD ] + ], + // https://docs.microsoft.com/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw + "CreateProcessW": [ + t.BOOL, [ + t.LPTSTR, // LPCTSTR lpApplicationName, + t.LPTSTR, // LPTSTR lpCommandLine, + t.LP, // LPSECURITY_ATTRIBUTES lpProcessAttributes, + t.LP, // LPSECURITY_ATTRIBUTES lpThreadAttributes, + t.BOOL, // BOOL bInheritHandles, + t.DWORD, // DWORD dwCreationFlags, + t.LP, // LPVOID lpEnvironment, + t.LP, // LPCTSTR lpCurrentDirectory, + t.LP, // LPSTARTUPINFO lpStartupInfo, + t.LP // LPPROCESS_INFORMATION lpProcessInformation + ] + ] +}); + +winapi.advapi32 = ffi.Library("advapi32", { + // https://msdn.microsoft.com/library/ms682429 + "CreateProcessAsUserW": [ + t.BOOL, [ + t.HANDLE, // HANDLE hToken, + t.LPTSTR, // LPCTSTR lpApplicationName, + t.LPTSTR, // LPTSTR lpCommandLine, + t.LP, // LPSECURITY_ATTRIBUTES lpProcessAttributes, + t.LP, // LPSECURITY_ATTRIBUTES lpThreadAttributes, + t.BOOL, // BOOL bInheritHandles, + t.DWORD, // DWORD dwCreationFlags, + t.LP, // LPVOID lpEnvironment, + t.LP, // LPCTSTR lpCurrentDirectory, + t.LP, // LPSTARTUPINFO lpStartupInfo, + t.LP // LPPROCESS_INFORMATION lpProcessInformation + ] + ], + // https://msdn.microsoft.com/library/aa379295 + "OpenProcessToken": [ + t.BOOL, [t.HANDLE, t.DWORD, t.PHANDLE] + ], + // https://msdn.microsoft.com/library/aa446654 + "GetSecurityInfo": [ + t.DWORD, [ + t.HANDLE, // HANDLE handle, + t.UINT, // SE_OBJECT_TYPE ObjectType, + t.UINT, // SECURITY_INFORMATION SecurityInfo, + t.LP, // PSID *ppsidOwner, + t.LP, // PSID *ppsidGroup, + //winapi.PACL, // PACL *ppDacl, + t.LP, // PACL *ppDacl, + t.LP, // PACL *ppSacl, + t.LP // PSECURITY_DESCRIPTOR *ppSecurityDescriptor + ] + ], + // https://msdn.microsoft.com/library/aa379588 + "SetSecurityInfo": [ + t.DWORD, [ + t.HANDLE, // HANDLE handle, + t.UINT, // SE_OBJECT_TYPE ObjectType, + t.UINT, // SECURITY_INFORMATION SecurityInfo, + t.LP, // PSID psidOwner, + t.LP, // PSID psidGroup, + t.LP, // PACL pDacl, + t.LP // PACL pSacl, + ] + ], + // https://msdn.microsoft.com/library/aa446671 + "GetTokenInformation": [ + t.BOOL, [ t.HANDLE, t.UINT, t.LP, t.DWORD, t.LPDWORD] + ], + // https://msdn.microsoft.com/library/aa379576 + "SetEntriesInAclW": [ + //t.DWORD, [ t.ULONG, t.LP, winapi.ACL, winapi.PACL ] + t.DWORD, [ t.ULONG, t.LP, t.LP, t.LP ] + ], + // https://msdn.microsoft.com/library/aa376404 + "CopySid": [ + t.BOOL, [ t.DWORD, t.LP, t.LP ] + ] +}); + +winapi.userenv = ffi.Library("userenv", { + // https://msdn.microsoft.com/library/bb762281 + "LoadUserProfileW": [ + t.BOOL, [ t.HANDLE, t.LP ] + ], + "CreateEnvironmentBlock": [ + t.BOOL, [ t.LP, t.HANDLE, t.BOOL ] + ] +}); + +// IP helper API +winapi.iphlpapi = ffi.Library("iphlpapi", { + // https://msdn.microsoft.com/library/bb408406 + "GetTcpTable2": [ + t.ULONG, [ t.LP, t.PULONG, t.BOOL ] + ] +}); + +// Windows Terminal Services API +winapi.wtsapi32 = ffi.Library("wtsapi32", { + // https://msdn.microsoft.com/library/aa383840 + "WTSQueryUserToken": [ + t.BOOL, [ t.ULONG, t.LP ] + ] +}); + +// https://docs.microsoft.com/en-gb/windows/win32/api/winnt/ne-winnt-token_information_class +winapi.TOKEN_INFORMATION_CLASS = { + TokenUser: 1, + TokenLinkedToken: 19 +}; + + +/** + * Returns an Error containing the arguments. + * + * @param {String} message The message. + * @param {String|Number} returnCode [optional] The return code. + * @param {String|Number} errorCode [optional] The last win32 error (from GetLastError), if already known. + * @return {Error} The error. + */ +winapi.error = function (message, returnCode, errorCode) { + var err = new Error(winapi.errorText(message, returnCode, errorCode)); + err.returnCode = returnCode; + err.errorCode = errorCode; + err.isError = true; + return err; +}; + +/** + * Creates an error message for a win32 error. + * + * @param {String} message The message. + * @param {String|Number} returnCode [optional] The return code. + * @param {String|Number} errorCode [optional] The last win32 error (from GetLastError), if already known. + * @return {Error} The error message. + */ +winapi.errorText = function (message, returnCode, errorCode) { + var text = "win32 error: " + message; + text += (returnCode === undefined) ? "" : (" return:" + returnCode); + text += " win32:" + (errorCode || winapi.kernel32.GetLastError()); + return text; +}; + +/** + * Convert a string to a wide-char string. + * + * @param {String} string The string to convert. + * @return {Buffer} A buffer containing the wide-char string. + */ +winapi.stringToWideChar = function (string) { + return Buffer.from(string + "\u0000", "ucs2"); // add null at the end +}; + +/** + * Convert a buffer containing a wide-char string to a string. + * + * @param {Buffer} buffer A buffer containing the wide-char string. + * @return {String} A string. + */ +winapi.stringFromWideChar = function (buffer) { + return ref.reinterpretUntilZeros(buffer, 2, 0).toString("ucs2"); +}; + +/** + * Convert a buffer containing an array of wide-char strings, to an array of strings. + * + * The input array is a C style string array, where the values are separated by null characters. The array is terminated + * by an additional 2 null characters. + * + * @param {Buffer} buffer The buffer to convert. + * @return {Array} An array of string. + */ +winapi.stringFromWideCharArray = function (buffer) { + var togo = []; + var offset = 0; + var current; + do { + current = ref.reinterpretUntilZeros(buffer, 2, offset); + if (current.length) { + togo.push(current.toString("ucs2")); + offset += current.length + 2; // Extra 2 bytes is to skip the (wide) null separator + } + } while (current.length > 0); + + return togo; +}; + +module.exports = winapi; diff --git a/gpii-service/src/windows.js b/gpii-service/src/windows.js new file mode 100644 index 000000000..eb229b0ec --- /dev/null +++ b/gpii-service/src/windows.js @@ -0,0 +1,492 @@ +/* Things related to the operating system. + * + * Copyright 2017 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var ref = require("ref-napi"), + logging = require("./logging.js"), + winapi = require("./winapi.js"), + path = require("path"); + +var windows = { + winapi: winapi +}; + +/** + * Determine if this process is running as a service. + * + * @return {Boolean} true if running as a service. + */ +windows.isService = function () { + // Services run in session 0 + var sessionId = ref.alloc(winapi.types.DWORD); + var success = winapi.kernel32.ProcessIdToSessionId(process.pid, sessionId); + + if (!success) { + throw windows.win32Error("ProcessIdToSessionId", success); + } + + return sessionId.deref() === 0; +}; + +/** + * Returns an Error containing the arguments. + * + * @param {String} message The message. + * @param {String|Number} returnCode [optional] The return code. + * @param {String|Number} errorCode [optional] The last win32 error (from GetLastError), if already known. + * @return {Error} The error. + */ +windows.win32Error = function (message, returnCode, errorCode) { + return winapi.error(message, returnCode, errorCode); +}; + +/** + * Get the user token for the current process. + * + * This token must be closed with closeToken when no longer needed. + * + * @return {Number} The token handle. + */ +windows.getOwnUserToken = function () { + // It's possible to just call GetCurrentProcessToken, but that returns a pseudo handle that doesn't have the + // required permission to start a process as that user. + + // A pseudo handle - doesn't need to be closed; + var processHandle = winapi.kernel32.GetCurrentProcess(); + // Enough for CreateProcessAsUser + var access = winapi.constants.TOKEN_ASSIGN_PRIMARY | winapi.constants.TOKEN_DUPLICATE + | winapi.constants.TOKEN_QUERY; + var tokenBuf = ref.alloc(winapi.types.HANDLE); + var success = winapi.advapi32.OpenProcessToken(processHandle, access, tokenBuf); + + if (!success) { + throw winapi.error("OpenProcessToken failed"); + } + + return tokenBuf.deref(); +}; + +/** + * Closes a user token. + * @param {Number} userToken The user token. + */ +windows.closeToken = function (userToken) { + if (userToken) { + winapi.kernel32.CloseHandle(userToken); + } +}; + +/** + * Gets the user token for the active desktop session. + * + * This token must be closed with closeToken when no longer needed. + * + * @return {Number} The token, 0 if there is no active desktop session. + */ +windows.getDesktopUser = function () { + + var userToken; + + if (windows.isService()) { + // Get the session ID of the console session. + var sessionId = winapi.kernel32.WTSGetActiveConsoleSessionId(); + logging.debug("session id:", sessionId); + + + if (sessionId === 0xffffffff) { + // There isn't a session. + userToken = 0; + } else { + // Get the access token of the user logged into the session. + var tokenBuf = ref.alloc(winapi.types.HANDLE); + var success = winapi.wtsapi32.WTSQueryUserToken(sessionId, tokenBuf); + + if (success) { + userToken = tokenBuf.deref(); + } else { + var errorCode = winapi.kernel32.GetLastError(); + logging.warn("WTSQueryUserToken failed (win32=" + errorCode + ")"); + + switch (errorCode) { + case winapi.errorCodes.ERROR_NO_TOKEN: + case winapi.errorCodes.ERROR_SUCCESS: + // There is no user on this session. + userToken = 0; + break; + case winapi.errorCodes.ERROR_ACCESS_DENIED: + case winapi.errorCodes.ERROR_PRIVILEGE_NOT_HELD: + // Not running as a service? + throw winapi.error("WTSQueryUserToken (isService may be wrong)", errorCode); + break; + default: + throw winapi.error("WTSQueryUserToken", errorCode); + break; + } + } + } + } else { + // If not running as a service, then assume the current user is the desktop user. + userToken = windows.getOwnUserToken(); + } + + return userToken; +}; + +/** + * Determines if the active console session is a user logged on. + * @return {Boolean} true if the active console session is a user logged on. + */ +windows.isUserLoggedOn = function () { + var token = windows.getDesktopUser(); + var loggedOn = !!token; + if (token) { + windows.closeToken(token); + } + + return loggedOn; +}; + +/** + * Gets the environment variables for the specified user. + * + * @param {Number} token Token handle for the user. + * @return {Array} An array of strings for each variable, in the format of "name=value" + */ +windows.getEnv = function (token) { + var envPtr = ref.alloc(winapi.types.LP); + var success = winapi.userenv.CreateEnvironmentBlock(envPtr, token, false); + if (!success) { + throw winapi.error("CreateEnvironmentBlock"); + } + return winapi.stringFromWideCharArray(envPtr.deref(), true); +}; + +/** + * Gets the GPII data directory for the specified user (identified by token). + * + * When running as a service, this process's "APPDATA" value will not point to the current user's. + * + * @param {Number} userToken Token handle for the user. + * @return {String} The GPII data directory for the given user. + */ +windows.getUserDataDir = function (userToken) { + // Search the environment block for the APPDATA value. (A better way would be to use SHGetKnownFolderPath) + var env = windows.getEnv(userToken); + var appData = null; + for (var n = 0, len = env.length; n < len; n++) { + var match = env[n].match(/^APPDATA=(.*)/i); + if (match) { + appData = match[1]; + break; + } + } + + return appData && path.join(appData, "GPII"); +}; + +/** + * Returns a promise that resolves when a process has terminated, or after the given timeout. + * + * @param {Number} pid The process ID. + * @param {Number} timeout Milliseconds to wait before timing out. (default: infinate) + * @return {Promise} Resolves when the process has terminated, or when timed out (with a value of "timeout"). Rejects + * upon failure. + */ +windows.waitForProcessTermination = function (pid, timeout) { + + return new Promise(function (resolve, reject) { + var hProcess = winapi.kernel32.OpenProcess(winapi.constants.SYNCHRONIZE, 0, pid); + if (!hProcess) { + reject(windows.win32Error("OpenProcess")); + } else { + if (!timeout && timeout !== 0) { + timeout = winapi.constants.INFINITE; + } + winapi.kernel32.WaitForSingleObject.async(hProcess, timeout, function (err, ret) { + winapi.kernel32.CloseHandle(hProcess); + + switch (ret) { + case winapi.constants.WAIT_OBJECT_0: + resolve(); + break; + case winapi.constants.WAIT_TIMEOUT: + resolve("timeout"); + break; + case winapi.constants.WAIT_FAILED: + default: + reject(windows.win32Error("WaitForSingleObject", ret)); + break; + } + }); + } + }); +}; + +/** + * Waits until one of the Win32 objects in an array are in the signalled state, resolving with that handle (or + * "timeout"). + * + * Wrapper for WaitForMultipleObjects (https://msdn.microsoft.com/library/ms687025) + * + * @param {Array} handles The win32 handles to wait on. + * @param {Number} timeout [Optional] The timeout, in milliseconds. (default: infinite) + * @param {Boolean} waitAll [Optional] Wait for all handles to be signalled, instead of just one. + * @return {Promise} Resolves with the handle that triggered, "timeout", or "all" if waitAll is true. + */ +windows.waitForMultipleObjects = function (handles, timeout, waitAll) { + + return new Promise(function (resolve, reject) { + if (!Array.isArray(handles)) { + reject({ + message: "handles must be an array", + isError: true + }); + return; + } + // Use a copy the handle array, so it can't be modified after the function returns. + handles = handles.slice(); + + if (!timeout && timeout !== 0) { + timeout = winapi.constants.INFINITE; + } + + winapi.kernel32.WaitForMultipleObjects.async(handles.length, handles, waitAll, timeout, + function (err, ret) { + if (err) { + reject(err); + } else { + switch (ret) { + case winapi.constants.WAIT_TIMEOUT: + resolve("timeout"); + break; + case winapi.constants.WAIT_FAILED: + // GetLastError will not work, because WaitForMultipleObjects is called in a different thread. + // Call WaitForMultipleObjects again, but in this thread (with a short timeout in case it works) + var newRet = winapi.kernel32.WaitForMultipleObjects( + handles.length, handles, waitAll, 1); + var errorCode = winapi.kernel32.GetLastError(); + var message = "WAIT_FAILED"; + if (newRet !== ret) { + message += " 2nd return:" + newRet; + } + reject(winapi.error("WaitForMultipleObjects:" + message, ret, errorCode)); + break; + default: + // The return is the handle index that triggered the return, offset by WAIT_OBJECT_0 or WAIT_ABANDONED_0 + var index = (ret < winapi.constants.WAIT_ABANDONED_0) + ? ret - winapi.constants.WAIT_OBJECT_0 + : ret - winapi.constants.WAIT_ABANDONED_0; + + if (index < 0 || index >= handles.length) { + // Unknown return + reject(winapi.win32Error("WaitForMultipleObjects", ret)); + } else { + resolve(handles[index]); + } + + break; + } + } + }); + }); +}; + +windows.getElevatedToken = function (token) { + // The full token (with the extra privileges) is a linked token. + var tokenInfo = windows.getTokenInformation(token, winapi.TOKEN_INFORMATION_CLASS.TokenLinkedToken); + // Take the linked token from the buffer. + var linkedToken = tokenInfo.readInt32LE(0); + return linkedToken; +}; + +/** + * Gets the security identifier (SID) from a user token. + * + * @param {Integer} token The user token. + * @return {*} The SID of the user. + */ +windows.getSidFromToken = function (token) { + var tokenInfo = windows.getTokenInformation(token, winapi.TOKEN_INFORMATION_CLASS.TokenUser); + // Take the SID from the buffer. + var TokenUserHeader = 2 * ref.types["int"].size; + var sid = tokenInfo.slice(TokenUserHeader); + return sid; +}; + +/** + * Gets some information about an access token. A wrapper for GetTokenInformation. + * + * @param {Number} token The access token. + * @param {Number} tokenInformationClass The type of information. A member of winapi.TOKEN_INFORMATION_CLASS. + * @return {Buffer} The token information structure. + */ +windows.getTokenInformation = function (token, tokenInformationClass) { + var lengthBuffer = ref.alloc(winapi.types.DWORD); + // // Get the length + var success = winapi.advapi32.GetTokenInformation(token, tokenInformationClass, ref.NULL, 0, lengthBuffer); + if (!success) { + var err = winapi.kernel32.GetLastError(); + // ERROR_INSUFFICIENT_BUFFER is expected. + if (err && err !== winapi.errorCodes.ERROR_INSUFFICIENT_BUFFER && err !== winapi.errorCodes.ERROR_BAD_LENGTH) { + throw winapi.error("GetTokenInformation", success, err); + } + } + + // GetTokenInformation fills a TOKEN_USER structure, which contains another struct containing a pointer to the SID + // and a dword. The sid pointer points to a chunk of data, which is located after the struct. + var length = lengthBuffer.deref(); + var tokenInfoBuffer = Buffer.alloc(length); + // Get the sid data. + success = winapi.advapi32.GetTokenInformation(token, tokenInformationClass, tokenInfoBuffer, + length, lengthBuffer); + if (!success) { + throw winapi.error("GetTokenInformation", success); + } + + return tokenInfoBuffer; +}; + +/** + * Set the permissions of the pipe so the logged in user can access it. + * + * This connects to the pipe, modifies the ACL to include the desktop user's security descriptor, then closes the pipe. + * + * @param {String} pipeName Name of the pipe. + */ +windows.setPipePermissions = function (pipeName) { + // winnt.h + var FILE_GENERIC_READ = 0x120089; + var FILE_GENERIC_WRITE = 0x120116; + var DACL_SECURITY_INFORMATION = 0x4; + // AccCtl.h + var SE_KERNEL_OBJECT = 0x6; + var GRANT_ACCESS = 1; + var TRUSTEE_IS_SID = 0; + var TRUSTEE_IS_USER = 1; + + var token = windows.getDesktopUser(); + var sid; + try { + sid = windows.getSidFromToken(token); + } finally { + windows.closeToken(token); + } + + var pipeHandle = null; + + try { + // Open the pipe. + var pipeNameBuf = winapi.stringToWideChar(pipeName); + pipeHandle = winapi.kernel32.CreateFileW( + pipeNameBuf, (winapi.constants.GENERIC_READ | winapi.constants.WRITE_DAC) >>> 0, 0, + ref.NULL, winapi.constants.OPEN_EXISTING, ref.NULL, ref.NULL); + + if (pipeHandle === winapi.constants.INVALID_HANDLE_VALUE) { + throw winapi.error("CreateFile", pipeHandle); + } + + // Get the ACL. + var daclP = ref.alloc(winapi.PACL); + daclP.ref().fill(0); + var result = winapi.advapi32.GetSecurityInfo(pipeHandle, SE_KERNEL_OBJECT, DACL_SECURITY_INFORMATION, + ref.NULL, ref.NULL, daclP, ref.NULL, ref.NULL); + + var dacl = daclP.deref(); + if (result) { + throw winapi.error("GetSecurityInfo", result); + } + + // Add the user to the ACL. + var access = new winapi.EXPLICIT_ACCESS(); + access.ref().fill(0); + access.grfAccessMode = GRANT_ACCESS; + access.grfAccessPermissions = (FILE_GENERIC_READ | FILE_GENERIC_WRITE); + access.grfInheritance = 0; + + access.Trustee.pMultipleTrustee = ref.NULL; + access.Trustee.MultipleTrusteeOperation = 0; + access.Trustee.TrusteeForm = TRUSTEE_IS_SID; + access.Trustee.TrusteeType = TRUSTEE_IS_USER; + access.Trustee.ptstrName = sid; + + var newDacl = ref.alloc(winapi.PACL); + newDacl.ref().fill(0); + result = winapi.advapi32.SetEntriesInAclW(1, access.ref(), dacl, newDacl); + if (result) { + throw winapi.error("SetEntriesInAclW", result); + } + + // Set the ACL. + result = winapi.advapi32.SetSecurityInfo(pipeHandle, SE_KERNEL_OBJECT, DACL_SECURITY_INFORMATION, + ref.NULL, ref.NULL, newDacl.deref(), ref.NULL); + + if (result) { + throw winapi.error("SetSecurityInfo", result); + } + } finally { + if (pipeHandle) { + winapi.kernel32.CloseHandle(pipeHandle); + } + } +}; + +/** + * Expands the environment variables in a string, which are surrounded by '%'. + * For example, the input string of "%SystemRoot%\System32" returns "C:\Windows\System32". + * + * @param {String} input The input string. + * @return {String} The input string with the environment variables expanded. + */ +windows.expandEnvironmentStrings = function (input) { + var result; + if (input && input.length > 0) { + var inputBuffer = winapi.stringToWideChar(input); + // Initial buffer of MAX_PATH should be big enough for most cases (assuming this function is called for paths). + var len = Math.max(winapi.constants.MAX_PATH + 1, input.length + 20); + var outputBuffer = Buffer.alloc(len); + // Expand the variables + var requiredSize = winapi.kernel32.ExpandEnvironmentStringsW(inputBuffer, outputBuffer, len); + if (requiredSize > len) { + // Initial buffer is too small - call again with the correct size. + len = requiredSize; + requiredSize = winapi.kernel32.ExpandEnvironmentStringsW(inputBuffer, outputBuffer, len); + } + if (requiredSize === 0) { + throw winapi.error("ExpandEnvironmentStringsW", requiredSize); + } + + result = winapi.stringFromWideChar(outputBuffer); + } else { + result = ""; + } + + return result; +}; + +/** + * Gets the exit code of a terminated process. + * @param {Number} handle The handle. + * @return {Number} The exit code. + */ +windows.getProcessExitCode = function (handle) { + var exitCodeBuf = ref.alloc(winapi.types.DWORD); + winapi.kernel32.GetExitCodeProcess(handle, exitCodeBuf); + return exitCodeBuf.deref(); +}; + +module.exports = windows; diff --git a/gpii-service/test-secret.json5 b/gpii-service/test-secret.json5 new file mode 100644 index 000000000..e89f0b6b9 --- /dev/null +++ b/gpii-service/test-secret.json5 @@ -0,0 +1,10 @@ +// Secret for testing +{ + "site": "testing.gpii.net", + "clientCredentials": { + "client_id": "pilot-computer", + "client_secret": "pilot-computer-secret" + }, + "signKey": "TEST-KEY-VGhpcyBpcyBub3QgcmFuZG9tIGRhdGEgLSBpdCdzIG9ubHkgZm9yIHRlc3RpbmcK", + "test": true +} diff --git a/gpii-service/tests/all-tests.js b/gpii-service/tests/all-tests.js new file mode 100644 index 000000000..d1ee04c52 --- /dev/null +++ b/gpii-service/tests/all-tests.js @@ -0,0 +1,50 @@ +/* Tests for the GPII Windows Service. + * + * Copyright 2017 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ +"use strict"; + +// The service tests are ran in a separate process to the rest of GPII. This not only ensures it's isolated from GPII, +// but also prevents having to re-build to be ran under electron for the gpii-app tests. + +if (!global.fluid) { + // In child process. + require("./service-tests.js"); + require("./windows-tests.js"); + require("./gpii-ipc-tests.js"); + require("./processHandling-tests.js"); + require("./pipe-messaging-tests.js"); + require("./gpii-client-tests.js"); + return; +} + +var jqUnit = require("node-jqunit"), + child_process = require("child_process"); + +jqUnit.module("GPII service tests"); + +jqUnit.asyncTest("Test window service", function () { + console.log("Starting service tests"); + + var child = child_process.spawn("node", [__filename]); + child.stdout.pipe(process.stdout); + child.stderr.pipe(process.stderr); + + child.on("close", function (code) { + console.log("Service tests ended:", code); + jqUnit.assertEquals("Service tests should pass", 0, code); + jqUnit.start(); + }); +}); diff --git a/gpii-service/tests/gpii-client-tests.js b/gpii-service/tests/gpii-client-tests.js new file mode 100644 index 000000000..a0b41a1de --- /dev/null +++ b/gpii-service/tests/gpii-client-tests.js @@ -0,0 +1,234 @@ +/* Tests for gpii-client.js + * + * Copyright 2017 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var jqUnit = require("node-jqunit"), + gpiiClient = require("../src/gpiiClient.js"), + service = require("../src/service.js"); + +var teardowns = []; + +jqUnit.module("GPII client tests", { + teardown: function () { + while (teardowns.length) { + teardowns.pop()(); + } + } +}); + +var gpiiClientTests = {}; + +gpiiClientTests.requestTests = [ + { + id: "execute: simple command", + action: "execute", + data: { + command: "whoami" + }, + expect: { + promise: { + pid: /^[0-9]+$/ + } + } + }, + { + id: "execute: bad command", + action: "execute", + data: { + command: "gpii-test-bad-command" + }, + expect: { + promise: "reject" + } + }, + { + id: "execute: wait", + action: "execute", + data: { + command: "whoami", + wait: true + }, + expect: { + promise: { + code: 0 + } + } + }, + { + id: "execute: wait + exit code 1", + action: "execute", + data: { + command: "whoami", + args: ["/bad-option"], + wait: true + }, + expect: { + promise: { + code: 1 + } + } + }, + { + id: "execute: capture output", + action: "execute", + data: { + command: "cmd.exe", + // Send something to stdout and stderr + args: ["/c", "echo hello stdout & echo hello stderr 1>&2"], + options: { + // Set the working directory to prevent cmd.exe complaining about being on a UNC path. + cwd: process.env.SystemRoot || "C:\\" + }, + wait: true, + capture: true + }, + expect: { + promise: { + code: 0, + signal: null, + output: { + stdout: "hello stdout \r\n", + stderr: "hello stderr \r\n" + } + } + } + }, + { + id: "sign", + action: "sign", + data: { + payload: "hello", + keyName: "site" + }, + // sha256-hmac(hello, testing.gpii.net) + expect: "81ba311bd1c768eaeabccccc0c208bef1a3e3be1b1476b6e35a7c4464fba5bd5" + }, + { + id: "client credentials", + action: "getClientCredentials", + data: undefined, + expect: { + "client_id": "pilot-computer", + "client_secret": "pilot-computer-secret" + } + } +]; + +/** + * Check if all properties of expected are also in subject and are equal or match a regular expression, ignoring any + * extra ones in subject. + * + * @param {Object} subject The object to check against + * @param {Object} expected The object containing the values to check for. + * @param {Number} maxDepth [Optional] How deep to check. + * @return {Boolean} true if there's a match. + */ +gpiiClientTests.deepMatch = function (subject, expected, maxDepth) { + var match = false; + if (maxDepth < 0) { + return false; + } else if (!maxDepth && maxDepth !== 0) { + maxDepth = 10; + } + + if (!subject) { + return subject === expected; + } + + for (var prop in expected) { + if (expected.hasOwnProperty(prop)) { + var exp = expected[prop]; + if (["string", "number", "boolean"].indexOf(typeof(exp)) >= 0) { + match = subject[prop] === exp; + } else if (exp instanceof RegExp) { + match = exp.test(subject[prop]); + } else { + match = gpiiClientTests.deepMatch(subject[prop], exp, maxDepth - 1); + } + if (!match) { + break; + } + } + } + + return match; +}; + +gpiiClientTests.assertDeepMatch = function (msg, expect, actual) { + var match = gpiiClientTests.deepMatch(actual, expect); + jqUnit.assertTrue(msg, match); + if (!match) { + console.log("expected:", expect); + console.log("actual:", actual); + } +}; + + +jqUnit.asyncTest("Test request handlers", function () { + + var tests = gpiiClientTests.requestTests; + jqUnit.expect(tests.length * 3); + + service.loadConfig(); + + var testIndex = -1; + var nextTest = function () { + if (++testIndex >= tests.length) { + jqUnit.start(); + return; + } + var test = tests[testIndex]; + + var suffix = " - testIndex=" + testIndex + " (" + (test.id || test.action) + ")"; + + var handler = gpiiClient.requestHandlers[test.action]; + + jqUnit.assertEquals("request handler should be a function" + suffix, "function", typeof handler); + + var result = handler(test.data); + + if (test.expect.promise) { + jqUnit.assertTrue("request handler should return a promise" + suffix, + result && typeof(result.then) === "function"); + + result.then(function (value) { + gpiiClientTests.assertDeepMatch("request handler should resolve with the expected value" + suffix, + test.expect.promise, value); + + nextTest(); + }, function (err) { + jqUnit.assertEquals("request handler should only reject if expected" + suffix, + test.expect.promise, "reject"); + + if (test.expect.promise !== "reject") { + console.log("Rejection:", err); + } + + nextTest(); + }); + } else { + gpiiClientTests.assertDeepMatch("request handler should return the expected value" + suffix, + test.expect, result); + jqUnit.assert("balancing assert count"); + nextTest(); + } + }; + + nextTest(); + +}); diff --git a/gpii-service/tests/gpii-ipc-tests-child.js b/gpii-service/tests/gpii-ipc-tests-child.js new file mode 100644 index 000000000..401d4ad0c --- /dev/null +++ b/gpii-service/tests/gpii-ipc-tests-child.js @@ -0,0 +1,184 @@ +/* A child process used by some tests. + * + * Command line options: + * + * inherited-pipe File descriptor 3 will be read from and written to. This tests the pipe inheritance. + * + * named-pipe PIPE Open the named pipe "PIPE" and send some information about the process is sent on it. This tests + * starting a client. + * + * mutex MUTEX Creates a mutex, and releases it after 10 seconds. Used to detect if this process has been run. + * + * Copyright 2017 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var logFile = null; +var fs = require("fs"); + +function log() { + var items = Array.from(arguments); + if (logFile) { + fs.appendFileSync(logFile, items.join(" ") + "\n"); + } else { + console.log.apply(console, items); + } +} + +function fail() { + var args = Array.from(arguments); + args.unshift("FAIL:"); + log.apply(log, args); + process.exit(1); +} + +process.on("uncaughtException", function (e) { + fail(e); + process.exit(1); +}); + +log("child started"); + +function setEvent(eventHandle) { + var ffi = require("ffi-napi"); + var kernel32 = ffi.Library("kernel32", { + "SetEvent": [ + "int", [ "uint" ] + ] + }); + log("Calling SetEvent:", eventHandle); + var ret = kernel32.SetEvent(eventHandle); + log("SetEvent returned ", ret); + return ret; +} + +var actions = { + /** + * For the gpii-ipc.startProcess test: Read to and from a pipe. + */ + "inherited-pipe": function () { + // Standard output isn't available to this process, so write output to a file. + logFile = process.argv[3]; + log("child started"); + + var net = require("net"); + + // Get the pipe name. + var pipeId = process.env.GPII_SERVICE_PIPE; + if (!pipeId) { + fail("GPII_SERVICE_PIPE not set."); + process.exit(1); + } else { + log("GPII_SERVICE_PIPE:", pipeId); + } + + // expecting pipe:id + var prefix = "pipe:"; + if (!pipeId.startsWith(prefix)) { + fail("GPII_SERVICE_PIPE is badly formed"); + } + + var pipeName = "\\\\.\\pipe\\gpii-" + pipeId.substr(prefix.length); + + var pipe = net.connect(pipeName, function () { + log("client: connected to server"); + + var authenticated = false; + + var allData = ""; + pipe.on("data", function (data) { + allData += data; + if (allData.indexOf("\n") >= 0) { + log("Got data:", allData); + if (authenticated) { + // Echo what was received. + pipe.write("received: " + allData); + } else { + var match = allData.match(/^challenge:([0-9]+)\n$/); + if (!match || !match[1]) { + fail("Invalid authentication challenge: '" + allData + "'"); + } + var eventHandle = parseInt(match[1]); + setEvent(eventHandle); + authenticated = true; + } + allData = ""; + } + }); + }); + pipe.on("error", function (err) { + if (err.code === "EOF") { + process.nextTick(process.exit); + } else { + log("input error", err); + throw err; + } + }); + }, + + "validate-client": function () { + var eventHandle = parseInt(process.argv[3]); + setEvent(eventHandle); + }, + /** + * For the gpii-ipc.execute test: send some information to the pipe named on the command line. + */ + "named-pipe": function () { + var net = require("net"); + + var info = { + env: process.env, + currentDir: process.cwd() + }; + + var pipeName = process.argv[3]; + var connection = net.createConnection(pipeName, function () { + log("connected"); + connection.write(JSON.stringify(info)); + connection.end(); + }); + }, + + /** + * For the process-monitor.startProcess test: Create a mutex. The parent will check if this mutex exists in order + * to determine this process has been started. + */ + "mutex": function () { + var winapi = require("../src/winapi.js"); + var mutexName = winapi.stringToWideChar(process.argv[3]); + var mutex = null; + + // Release the mutex and die after 30 seconds. + setTimeout(function () { + if (mutex) { + winapi.kernel32.ReleaseMutex(mutex); + winapi.kernel32.CloseHandle(mutex); + } + }, 30000); + + mutex = winapi.kernel32.CreateMutexW(winapi.NULL, true, mutexName); + log("mutex", winapi.stringFromWideChar(mutexName), mutex); + + } +}; + +var option = process.argv[2]; +if (actions[option]) { + actions[option](); +} else { + log("Unrecognised command line"); + process.exit(1); +} diff --git a/gpii-service/tests/gpii-ipc-tests.js b/gpii-service/tests/gpii-ipc-tests.js new file mode 100644 index 000000000..2a7bda472 --- /dev/null +++ b/gpii-service/tests/gpii-ipc-tests.js @@ -0,0 +1,484 @@ +/* Tests for gpii-ipc.js + * + * Copyright 2017 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var jqUnit = require("node-jqunit"), + net = require("net"), + path = require("path"), + fs = require("fs"), + os = require("os"), + EventEmitter = require("events"), + child_process = require("child_process"), + ipc = require("../src/gpii-ipc.js"), + windows = require("../src/windows.js"), + winapi = require("../src/winapi.js"); + +var teardowns = []; + +jqUnit.module("GPII service ipc tests", { + teardown: function () { + while (teardowns.length) { + teardowns.pop()(); + } + } +}); + +var createLogFile = function () { + // The child process will write to this file (stdout isn't captured) + var logFile = os.tmpdir() + "/gpii-test-output" + Date.now(); + console.log("logfile:", logFile); + fs.writeFileSync(logFile, ""); + teardowns.push(function () { + try { + fs.unlinkSync(logFile); + } catch (e) { + // Ignored + } + }); + return logFile; +}; + +// Tests generatePipeName +jqUnit.test("Test generatePipeName", function () { + var pipePrefix = "\\\\.\\pipe\\"; + + // Because the names are random, check against a sample of them to avoid lucky results. + var sampleSize = 300; + var pipeNames = []; + for (var n = 0; n < sampleSize; n++) { + pipeNames.push(ipc.generatePipeName()); + } + + for (var pipeIndex = 0; pipeIndex < sampleSize; pipeIndex++) { + var fullName = pipeNames[pipeIndex]; + + // Pipe Names: https://msdn.microsoft.com/library/aa365783 + jqUnit.assertTrue("Pipe path must begin with " + pipePrefix, fullName.startsWith(pipePrefix)); + jqUnit.assertTrue("Entire pipe name must <= 256 characters", fullName.length <= 256); + + var pipeName = fullName.substr(pipePrefix.length); + jqUnit.assertTrue("Pipe name must at least 1 character", pipeName.length > 0); + // "any character other than a backslash, including numbers and special characters" + // This also includes '/' because node swaps it with '\'. + jqUnit.assertFalse("Pipe name must not contain a slash or blackslash", pipeName.match(/[\\\/]/)); + + // There shouldn't be any repeated names in a sample size of this size. + var dup = pipeNames.indexOf(fullName) !== pipeIndex; + jqUnit.assertFalse("There shouldn't be any repeated pipe names", dup); + } +}); + +jqUnit.asyncTest("Test createPipe", function () { + jqUnit.expect(4); + + var pipeName = ipc.generatePipeName(); + + var promise = ipc.createPipe(pipeName); + jqUnit.assertNotNull("createPipe must return non-null", promise); + jqUnit.assertEquals("createPipe must return a promise", "function", typeof(promise.then)); + + promise.then(function (pipeServer) { + jqUnit.assertTrue("createPipe should have resolved with a value", !!pipeServer); + + jqUnit.assertTrue("createPipe resolved value should be a net.Server instance", + pipeServer instanceof net.Server); + + jqUnit.start(); + }, function (err) { + console.error(err); + jqUnit.fail("createPipe should have resolved"); + }); +}); + +jqUnit.asyncTest("Test createPipe failures", function () { + + var existingPipe = ipc.generatePipeName(); + + var pipeNames = [ + // A pipe that exists. + existingPipe, + // Badly formed name + "invalid", + null + ]; + + jqUnit.expect(pipeNames.length * 3); + + var testPipes = function (pipeNames) { + var pipeName = pipeNames.shift(); + fluid.log("Checking bad pipe name:", pipeName); + + fluid.log("Error is expected:"); + var promise = ipc.createPipe(pipeName); + jqUnit.assertNotNull("createPipe must return non-null", promise); + jqUnit.assertEquals("createPipe must return a promise", "function", typeof(promise.then)); + + promise.then(function () { + jqUnit.fail("createPipe should not have resolved"); + }, function () { + jqUnit.assert("createPipe should reject"); + + if (pipeNames.length > 0) { + testPipes(pipeNames); + } else { + jqUnit.start(); + } + }); + }; + + // Create a pipe to see what happens if another pipe is created with the same name. + ipc.createPipe(existingPipe).then(function () { + // run the tests. + testPipes(Array.from(pipeNames)); + }, function (err) { + console.error(err); + jqUnit.fail("initial createPipe failed"); + }); + +}); + +/** + * Read from a pipe, calling callback with all the data when it ends. + * + * @param {String} pipeName Pipe name. + * @param {Function} callback What to call. + */ +function readPipe(pipeName, callback) { + var buffer = ""; + var server = net.createServer(function (con) { + con.setEncoding("utf8"); + buffer = ""; + + con.on("error", function (err) { + console.error(err); + callback(err); + }); + con.on("data", function (data) { + buffer += data; + }); + con.on("end", function () { + callback(null, buffer); + }); + }); + server.listen(pipeName); + server.on("error", function (err) { + console.error(err); + jqUnit.fail("Error with the pipe server"); + }); +} + +// Tests the execution of a child process with ipc.execute +jqUnit.asyncTest("Test execute", function () { + + jqUnit.expect(4); + + var testData = { + execOptions: { + env: { + "GPII_TEST_VALUE1": "value1", + "GPII_TEST_VALUE2": "value2" + }, + currentDir: process.env.APPDATA + } + }; + + // Create a pipe so the child process can talk back (ipc.execute doesn't capture the child's stdout). + var pipeName = ipc.generatePipeName(); + readPipe(pipeName, checkReturn); + + var options = Object.assign({}, testData.execOptions); + + // Two asserts for each environment value. + jqUnit.expect(Object.keys(options.env).length * 2); + + // Start the child process, passing the pipe name. + var script = path.join(__dirname, "gpii-ipc-tests-child.js"); + var command = ["node", script, "named-pipe", pipeName].join(" "); + console.log("Executing", command); + var pid = ipc.execute(command, options); + + jqUnit.assertEquals("execute should return a number", "number", typeof(pid)); + + function checkReturn(err, data) { + if (err) { + console.error(err); + jqUnit.fail("The was something wrong with the pipe"); + } else { + var obj; + + try { + obj = JSON.parse(data); + } catch (e) { + console.log("child returned: ", data); + throw e; + } + + jqUnit.assertTrue("child process should return something", !!obj); + if (obj.error) { + console.log("Error from child:", obj.error); + jqUnit.fail("child process returned an error"); + return; + } + jqUnit.assertEquals("'currentDir' should return from child", options.currentDir, obj.currentDir); + jqUnit.assertTrue("'env' should return from child", !!obj.env); + + for (var key in options.env) { + var value = options.env[key]; + jqUnit.assertTrue("Environment should contain " + key, key, obj.hasOwnProperty(key)); + jqUnit.assertEquals("Environment '" + key + "' should be the expected value", value, obj.env[key]); + } + + jqUnit.start(); + } + } + + teardowns.push(function () { + try { + process.kill(pid); + } catch (e) { + // Ignored. + } + }); + + // Set a timeout. + windows.waitForProcessTermination(pid, 5000).then(function (value) { + if (value === "timeout") { + jqUnit.fail("Child process took too long."); + } + }, function (err) { + console.error(err); + jqUnit.fail("Unable to wait for child process."); + }); + +}); + +// Tests validateClient +jqUnit.asyncTest("Test validateClient", function () { + + var tests = [ + { + // SetEvent is called by this process. + id: "successful", + startChildProcess: false, + setEvent: true, + timeout: 20, + expect: { + end: false, + resolve: true + } + }, + { + // SetEvent is not called. + id: "timeout", + startChildProcess: false, + setEvent: false, + timeout: 2, + expect: { + end: true, + resolve: false + } + }, + { + // SetEvent is called by another process (that hasn't been granted the handle). + id: "other-process", + startChildProcess: true, + setEvent: false, + timeout: 2, + expect: { + end: true, + resolve: false + } + } + ]; + + var runTest = function (testIndex) { + if (testIndex >= tests.length) { + jqUnit.start(); + return; + } + + var test = tests[testIndex]; + var endCalled = false; + + // A mock pipe. + var pipe = new EventEmitter(); + pipe.write = function (data) { + var challenge = data.substr("challenge:".length).trim(); + jqUnit.assertFalse("challenge must be numeric", isNaN(challenge)); + + if (test.startChildProcess) { + var script = path.join(__dirname, "gpii-ipc-tests-child.js"); + var command = ["node", script, "validate-client", challenge].join(" "); + console.log("starting", command); + child_process.exec(command, { shell: false}, function (err, stdout, stderr) { + console.log("child stdout:", stdout); + console.log("child stderr:", stderr); + if (err) { + jqUnit.fail(err); + } else { + jqUnit.assertTrue("child should have called SetEvent", + stdout.indexOf("SetEvent returned") > -1); + } + }); + + } + + if (test.setEvent) { + var eventHandle = parseInt(challenge); + winapi.kernel32.SetEvent(eventHandle); + } + }; + pipe.end = function () { + endCalled = true; + }; + + var pid = process.pid; + ipc.validateClient(pipe, pid, test.timeout).then(function () { + jqUnit.assertTrue("validateClient resolved", test.expect.resolve); + }, function (e) { + console.log(e); + jqUnit.assertFalse("validateClient rejected", test.expect.resolve); + }).then(function () { + jqUnit.assertEquals("end should be called (if expected)", test.expect.end, endCalled); + runTest(testIndex + 1); + }); + }; + + runTest(0); +}); + +// Tests startProcess - creates a child process using startProcess. +jqUnit.asyncTest("Test startProcess", function () { + var logFile = createLogFile(); + var getLog = function () { + try { + return fs.readFileSync(logFile, {encoding: "utf8"}); + } catch (e) { + // ignore + return ""; + } + }; + + var script = path.join(__dirname, "gpii-ipc-tests-child.js"); + var command = ["node", script, "inherited-pipe", logFile].join(" "); + console.log("Starting", command); + + // 0: server sends challenge, client performs it, server sends "OK" + // 1: child sends expected[0] + // 2: parent sends sendData + // 3: child sends expected[1] + var sendData = "FROM PARENT" + Math.random(); + var expected = [ + // Child responds with what was sent (from the authentication). + "received: OK\n", + // Child responds with what was sent (after authentication). + "received: " + sendData + "\n" + ]; + + var expectedIndex = 0; + var complete = false; + var allData = ""; + var pid = null; + + var promise = ipc.startProcess(command, "test-startProcess"); + + jqUnit.assertNotNull("startProcess must return non-null", promise); + jqUnit.assertEquals("startProcess must return a promise", "function", typeof(promise.then)); + + promise.then(function (p) { + jqUnit.assertNotNull("startProcess must return resolve with a value", p); + jqUnit.assertFalse("startProcess resolved value pid field must be numeric", isNaN(p.pid)); + jqUnit.assertTrue("startProcess resolved value pipeServer field must be a net.Server instance", + p.pipeServer instanceof net.Server); + + pid = p.pid; + + // Set a timeout. + windows.waitForProcessTermination(pid, 5000).then(function (value) { + var logContent = getLog(); + if (value === "timeout") { + console.log(logContent); + jqUnit.fail("Child process took too long."); + } else { + if (!complete) { + console.log(logContent); + jqUnit.fail("child should not terminate until completed"); + } else if (logContent.indexOf("FAIL") >= 0) { + console.log(logContent); + jqUnit.fail("Child process failed"); + } + } + }, function (err) { + console.error(err); + jqUnit.fail("Unable to wait for child process."); + }); + + p.pipeServer.on("error", function (err) { + console.error("Pipe server error:", err); + jqUnit.fail("Pipe server failed"); + }); + + // Wait for the child to connect to the pipe + p.pipeServer.on("connection", function (pipe) { + console.log("connection from child"); + + pipe.on("data", function (data) { + allData += data; + if (allData.indexOf("\n") >= 0) { + console.log("from pipe:", allData); + jqUnit.assertEquals("Expected input from pipe", expected[expectedIndex], allData); + allData = ""; + expectedIndex++; + if (expectedIndex >= expected.length) { + complete = true; + pipe.end(); + } else { + console.log("send: " + sendData); + pipe.write(sendData + "\n"); + } + } + }); + + pipe.on("end", function () { + console.log("pipe end"); + if (complete) { + jqUnit.start(); + } else { + console.log(getLog()); + jqUnit.fail("child should not terminate until completed"); + } + }); + + pipe.on("error", function (err) { + console.error("Pipe error:", err); + jqUnit.fail("Pipe failed."); + }); + }); + + + + }, jqUnit.fail); + + teardowns.push(function () { + try { + process.kill(pid); + } catch (e) { + // Ignored. + } + }); +}); diff --git a/gpii-service/tests/pipe-messaging-tests.js b/gpii-service/tests/pipe-messaging-tests.js new file mode 100644 index 000000000..73f60b173 --- /dev/null +++ b/gpii-service/tests/pipe-messaging-tests.js @@ -0,0 +1,286 @@ +/* Tests for pipe-messaging.js + * + * Copyright 2018 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var jqUnit = require("node-jqunit"), + net = require("net"), + EventEmitter = require("events"); + + +var messaging = require("../shared/pipe-messaging.js"); + +var teardowns = []; + +jqUnit.module("GPII pipe tests", { + teardown: function () { + while (teardowns.length) { + teardowns.pop()(); + } + } +}); + +// Test gotData by sending packets using difference chunk sizes. +jqUnit.test("Test gotData", function () { + + var fakeSocket = new EventEmitter(); + var session = messaging.createSession(fakeSocket); + + // Create a packet buffer. + var createPacket = function (message) { + var messageBuf = Buffer.from(JSON.stringify(message)); + var packet = Buffer.alloc(messageBuf.length + messaging.lengthByteCount); + packet.writeUIntBE(messageBuf.length, 0, messaging.lengthByteCount); + messageBuf.copy(packet, messaging.lengthByteCount); + return packet; + }; + + var maxChunkSize = createPacket(100).length; + jqUnit.expect((maxChunkSize - 1) * 3); + + var count = 0; + session.on("message", function (msg) { + jqUnit.assertDeepEq("message expected", {id: count++}, msg); + }); + + var packetNumber = 0; + for (var chunkLen = 1; chunkLen < maxChunkSize; chunkLen++) { + var data = Buffer.concat([ + createPacket({id: packetNumber++}), + createPacket({id: packetNumber++}), + createPacket({id: packetNumber++}) + ]); + + while (data.length > 0) { + var chunk = data.slice(0, chunkLen); + data = data.slice(chunkLen); + session.gotData(chunk); + } + } +}); + +/** + * Create a pipe. + * + * @param {String|Number} pipeName Name of the pipe, or port number. + * @return {Promise} resolves with an object containing both ends of the pipe {server, client}. + */ +var createPipe = function (pipeName) { + if (!pipeName) { + pipeName = 0; + } + + return new Promise(function (resolve) { + var serverEnd = null, clientEnd = null; + var connected = function () { + if (serverEnd && clientEnd) { + resolve({ + server: serverEnd, client: clientEnd + }); + } + }; + + var server = net.createServer(function (socket) { + console.log("connected server"); + serverEnd = socket; + connected(); + }); + + server.listen(pipeName, function () { + console.log("listening"); + + net.connect(server.address(), function () { + console.log("connected client"); + clientEnd = this; + connected(); + }); + + }); + + }); +}; + +// Tests sending and receiving a message to each end of a pipe. +jqUnit.asyncTest("Test session", function () { + + jqUnit.expect(2); + var test = { + clientMessage: { message1: "a" }, + serverMessage: { message2: "b" } + }; + + createPipe().then(function (pipe) { + var server = messaging.createSession(pipe.server, "test1"); + var client = messaging.createSession(pipe.client, "test1"); + + server.on("message", function (msg) { + jqUnit.assertDeepEq("message from client", test.clientMessage, msg); + server.sendMessage(test.serverMessage); + }); + + client.on("message", function (msg) { + jqUnit.assertDeepEq("message from server", test.serverMessage, msg); + jqUnit.start(); + }); + + client.on("ready", function () { + client.sendMessage(test.clientMessage); + }); + + client.on("error", jqUnit.fail); + server.on("error", jqUnit.fail); + }, jqUnit.fail); +}); + +// Tests connecting with the wrong session type. +jqUnit.asyncTest("Test session type mismatch", function () { + + var result = { + server: false, + client: false + }; + + var gotError = function (side, err) { + var match = err && err.message && err.message.startsWith("Unexpected client session type"); + jqUnit.assertTrue(match, "Error must be the expected error for " + side + ": " + err); + + if (result[side]) { + jqUnit.fail("Got more than one error for " + side); + } + + result[side] = true; + + if (result.client && result.server) { + jqUnit.start(); + } + }; + + createPipe().then(function (pipe) { + var server = messaging.createSession(pipe.server, "session type"); + var client = messaging.createSession(pipe.client, "wrong session type"); + + server.on("message", function (msg) { + jqUnit.fail("Unexpected message to server: " + msg); + }); + client.on("message", function (msg) { + jqUnit.fail("Unexpected message to client: " + msg); + }); + + server.on("ready", function () { + jqUnit.fail("Unexpected server.ready event"); + }); + client.on("ready", function () { + jqUnit.fail("Unexpected client.ready event"); + }); + + server.on("error", function (err) { + gotError("server", err); + }); + client.on("error", function (err) { + gotError("client", err); + }); + }); +}); + +// Tests sending a request and resolving a promise when responded. +jqUnit.asyncTest("Test requests", function () { + + var tests = [ + { + action: "resolve", + reply: {value: "the response"}, + expectResolve: true + }, + { + action: "return", + reply: {value: "the other response"}, + expectResolve: true + }, + { + action: "reject", + reply: {value: "the rejection"}, + expectResolve: false + }, + { + action: "throw", + reply: {value: "the error"}, + expectResolve: false + } + ]; + + jqUnit.expect(tests.length * 5); + var currentTest; + var testIndex = 0; + var suffix; + + createPipe().then(function (pipe) { + + // Reply to the request. + var serverRequest = function (req) { + req = Object.assign({}, req); + jqUnit.assertTrue("received request should contain the 'request' field" + suffix, !!req.request); + delete req.request; + jqUnit.assertDeepEq("received request should match the sent request" + suffix, currentTest, req); + switch (req.action) { + case "resolve": + return Promise.resolve(req.reply); + case "return": + return req.reply; + case "reject": + return Promise.reject(req.reply); + case "throw": + throw req.reply; + } + }; + + var clientRequest = function () { + jqUnit.fail("Request to the client is unexpected" + suffix); + }; + + var server = messaging.createSession(pipe.server, "test1", serverRequest); + var client = messaging.createSession(pipe.client, "test1", clientRequest); + + client.on("error", jqUnit.fail); + server.on("error", jqUnit.fail); + + var sendRequest = function () { + if (testIndex >= tests.length) { + jqUnit.start(); + return; + } + + suffix = " - testIndex=" + testIndex; + currentTest = tests[testIndex++]; + + var p = client.sendRequest(currentTest); + jqUnit.assertTrue("sendRequest must return promise" + suffix, p && typeof(p.then) === "function"); + + p.then(function (reply) { + jqUnit.assertTrue("promise should resolve for this test" + suffix, currentTest.expectResolve); + jqUnit.assertDeepEq("resolved value must be the expected" + suffix, currentTest.reply, reply); + sendRequest(); + }, function (err) { + jqUnit.assertFalse("promise should reject for this test" + suffix, currentTest.expectResolve); + jqUnit.assertDeepEq("rejection value must be the expected" + suffix, currentTest.reply, err); + sendRequest(); + }); + }; + + client.on("ready", sendRequest); + + }, jqUnit.fail); +}); diff --git a/gpii-service/tests/processHandling-tests.js b/gpii-service/tests/processHandling-tests.js new file mode 100644 index 000000000..d1a90fd21 --- /dev/null +++ b/gpii-service/tests/processHandling-tests.js @@ -0,0 +1,583 @@ +/* Tests for gpii-process.js + * + * Copyright 2017 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var jqUnit = require("node-jqunit"), + path = require("path"), + child_process = require("child_process"), + processHandling = require("../src/processHandling.js"), + windows = require("../src/windows.js"), + winapi = require("../src/winapi.js"); + +var processHandlingTests = { + testData: {} +}; +var teardowns = []; + +jqUnit.module("GPII Service processHandling tests", { + teardown: function () { + while (teardowns.length) { + teardowns.pop()(); + } + } +}); + +processHandlingTests.testData.startChildProcess = [ + // Shouldn't restart if stopChildProcess is used. + { + input: { + childProcess: { + key: "norestart-stop-test", + command: "test-command", + ipc: null, + autoRestart: false + }, + stopChildProcess: false + }, + expect: { + restart: false + } + }, + { + input: { + childProcess: { + key: "restart-stop-test", + command: "test-command", + ipc: null, + autoRestart: true + }, + stopChildProcess: true + }, + expect: { + restart: false + } + }, + { + input: { + childProcess: { + key: "norestart-ipc-stop-test", + command: "test-command", + ipc: "norestart", + autoRestart: false + }, + stopChildProcess: true + }, + expect: { + restart: false + } + }, + { + input: { + childProcess: { + key: "restart-ipc-stop-test", + command: "test-command", + ipc: "restart", + autoRestart: true + }, + stopChildProcess: true + }, + expect: { + restart: false + } + }, + // Should restart if killed, and autoRestart is set + { + input: { + childProcess: { + key: "norestart-kill-test", + command: "test-command", + ipc: null, + autoRestart: false + }, + stopChildProcess: false + }, + expect: { + restart: false + } + }, + { + input: { + childProcess: { + key: "restart-kill-test", + command: "test-command", + ipc: null, + autoRestart: true + }, + stopChildProcess: false + }, + expect: { + restart: true + } + }, + { + input: { + childProcess: { + key: "norestart-ipc-kill-test", + command: "test-command", + ipc: "norestart", + autoRestart: false + }, + stopChildProcess: false + }, + expect: { + restart: false + } + }, + { + input: { + childProcess: { + key: "restart-ipc-kill-test", + command: "test-command", + ipc: "restart", + autoRestart: true + }, + stopChildProcess: false + }, + expect: { + restart: true + } + } +]; + +processHandlingTests.testData.monitorProcessFailures = [ + { input: null }, + { input: 0 }, + { input: -1 } +]; + +/** + * Start a process that self terminates after 10 seconds. + * @return {ChildProcess} The child process. + */ +processHandlingTests.startProcess = function () { + var id = "processHandlingTest" + Math.random().toString(32).substr(2); + var exe = path.join(process.env.SystemRoot, "/System32/waitfor.exe"); + var command = exe + " " + id + " /T 10 "; + return child_process.exec(command); +}; + +/** + * Waits for a mutex to be create, by polling until OpenMutex succeeds. + * + * @param {String} mutexName The name of the mutex. + * @param {Number} timeout [Optional] How long to wait (ms), default 1000. + * @return {Promise} Resolves when a mutex with the given name has been created, or with value of "timeout". + */ +processHandlingTests.waitForMutex = function (mutexName, timeout) { + return new Promise(function (resolve, reject) { + var nameBuffer = winapi.stringToWideChar(mutexName); + + var timedout = false; + setTimeout(function () { + timedout = true; + resolve("timeout"); + }, timeout || 1000); + + // Poll until OpenMutex succeeds. + var checkMutex = function () { + var mutexHandle = winapi.kernel32.OpenMutexW(winapi.constants.SYNCHRONIZE, false, nameBuffer); + + if (mutexHandle) { + winapi.kernel32.ReleaseMutex(mutexHandle); + winapi.kernel32.CloseHandle(mutexHandle); + resolve(); + } else if (!timedout) { + var err = winapi.kernel32.GetLastError(); + var ERROR_FILE_NOT_FOUND = 2; + if (err === ERROR_FILE_NOT_FOUND) { + setTimeout(checkMutex, 200); + } else { + reject(winapi.error("OpenMutex", 0, err)); + } + } + }; + + checkMutex(); + }); +}; + +// Tests getProcessCreationTime +jqUnit.test("Test getProcessCreationTime", function () { + var value; + + // This process + var thisProcess = processHandling.getProcessCreationTime(process.pid); + jqUnit.assertNotNull("creation time must not be null", thisProcess); + jqUnit.assertFalse("creation time must be a number", isNaN(thisProcess)); + + value = processHandling.getProcessCreationTime(process.pid); + jqUnit.assertEquals("two calls must return the same value", thisProcess, value); + + // A different process. + var child = processHandlingTests.startProcess(); + value = processHandling.getProcessCreationTime(child.pid); + jqUnit.assertNotNull("creation time of child process must not be null", value); + jqUnit.assertNotNull("creation time of child process must be different to this process", thisProcess); + + // A process that's not running. + value = processHandling.getProcessCreationTime(1); + jqUnit.assertNull("creation time for non-running process must be null", value); + + // A pid that's not a pid + value = processHandling.getProcessCreationTime("not a pid"); + jqUnit.assertNull("creation time for non-pid must be null", value); + +}); + +// Tests isProcessRunning +jqUnit.asyncTest("Test isProcessRunning", function () { + jqUnit.expect(10); + var running; + + // This process + running = processHandling.isProcessRunning(process.pid); + jqUnit.assertTrue("This process should be running", running); + + // A process that's not running. + running = processHandling.isProcessRunning(3); + jqUnit.assertFalse("This process should not be running", running); + + // A pid that's not a pid + running = processHandling.isProcessRunning("not a pid"); + jqUnit.assertFalse("This invalid process should not be running", running); + + // A null pid + running = processHandling.isProcessRunning(null); + jqUnit.assertFalse("This invalid process should not be running", running); + + + var creationTime, pid; + + // This process, with creation time + pid = process.pid; + creationTime = processHandling.getProcessCreationTime(pid); + running = processHandling.isProcessRunning(pid); + jqUnit.assertTrue("This process (with creation time) should be running", running); + + // This process, with wrong creation time + pid = process.pid; + creationTime = "12345"; + running = processHandling.isProcessRunning(pid, creationTime); + jqUnit.assertFalse("This process (incorrect creation time) should not be running", running); + + // Test a child process, with a handle still open - the pid will still be valid, but it's not a running process. + // (first tested without the handle opening, for a sanity check) + var testChild = function (openHandle) { + var assertSuffix = openHandle ? " (open handle)" : ""; + var child = processHandlingTests.startProcess(); + running = processHandling.isProcessRunning(child.pid); + jqUnit.assertTrue("Child process should be running" + assertSuffix, running); + + var handle; + if (openHandle) { + handle = winapi.kernel32.OpenProcess(winapi.constants.PROCESS_QUERY_LIMITED_INFORMATION, 0, child.pid); + if (!handle || handle === winapi.NULL) { + jqUnit.fail(winapi.errorText("OpenProcess failed")); + } + } + + child.on("close", function () { + setTimeout(function () { + running = processHandling.isProcessRunning(child.pid); + jqUnit.assertFalse("Child process should not be running after kill" + assertSuffix, running); + + if (openHandle) { + winapi.kernel32.CloseHandle(handle); + jqUnit.start(); + } else { + testChild(true); + } + }, 100); + }); + child.kill(); + }; + + testChild(false); +}); + +// Tests startChildProcess, stopChildProcess, and autoRestartProcess (indirectly) +jqUnit.asyncTest("Test startChildProcess", function () { + + var testData = processHandlingTests.testData.startChildProcess; + // Don't delay restarting the process. + var throttleRate_orig = processHandling.throttleRate; + processHandling.throttleRate = function () { + return 1; + }; + teardowns.push(function () { + processHandling.throttleRate = throttleRate_orig; + }); + + // For each test a child process is started (using the input data). It's then stopped via either stopChildProcess or + // kill. A check is made if the process has ended, and if the process has been restarted or not. + // The pid of the new child process is unknown, so a mutex is created by the child then checked here if it exists or + // not to determine if the new process is running. + var nextTest = function (testIndex) { + if (testIndex >= testData.length) { + jqUnit.start(); + return; + } + var test = testData[testIndex]; + var messageSuffix = " - " + test.input.childProcess.key; + + var procConfig = Object.assign({}, test.input.childProcess); + var mutexName = procConfig.key + Math.random().toString(32); + procConfig.command = "node.exe " + path.join(__dirname, "gpii-ipc-tests-child.js") + " mutex " + mutexName; + + var promise = processHandling.startChildProcess(procConfig); + + jqUnit.assertTrue("startProcess must return a promise" + messageSuffix, + promise && typeof(promise.then) === "function"); + + promise.then(function (pid) { + jqUnit.assertFalse("startProcess must resolve with a numeric pid" + messageSuffix, isNaN(pid)); + var pidRunning = processHandling.isProcessRunning(pid); + jqUnit.assertTrue("startProcess must resolve with a running pid" + messageSuffix, pidRunning); + + // See if the process gets restarted (or not). + windows.waitForProcessTermination(pid, 1000).then(function (value) { + if (value === "timeout") { + jqUnit.fail("child process didn't terminate" + messageSuffix); + } else { + // See if the new process was restarted, by waiting for the mutex it creates. + processHandlingTests.waitForMutex(mutexName, 2000).then(function (value) { + if (test.expect.restart) { + jqUnit.assertNotEquals("process should restart" + messageSuffix, "timeout", value); + } else { + jqUnit.assertEquals("process should not restart" + messageSuffix, "timeout", value); + } + + processHandling.stopChildProcess(procConfig.key, false); + + nextTest(testIndex + 1); + }, jqUnit.fail); + } + }, jqUnit.fail); + + // Kill the first process. + if (test.input.stopChildProcess) { + processHandling.stopChildProcess(procConfig.key, false); + } else { + process.kill(pid); + } + + }, jqUnit.fail); + }; + + nextTest(0); + +}); + +jqUnit.asyncTest("Test monitorProcess - single process", function () { + jqUnit.expect(3); + + var child = processHandlingTests.startProcess(); + var promise = processHandling.monitorProcess(child.pid); + + jqUnit.assertTrue("monitorProcess must return a promise", promise && typeof(promise.then) === "function"); + + var killed = false; + promise.then(function (value) { + jqUnit.assertTrue("monitorProcess should not resolve before the process is killed", killed); + jqUnit.assertEquals("monitorProcess should resolve with the process id", child.pid, value); + jqUnit.start(); + }, jqUnit.fail); + + setTimeout(function () { + killed = true; + child.kill(); + }, 100); + +}); + +jqUnit.asyncTest("Test monitorProcess - multiple processes", function () { + + var killOrder = [ 4, 0, 2, 1, 3 ]; + var procs = []; + for (var n = 0; n < killOrder.length; n++) { + procs.push(processHandlingTests.startProcess()); + } + + jqUnit.expect(procs.length * 2); + + var killed = []; + var killProcess = function () { + if (killOrder.length > 0) { + var proc = procs[killOrder.shift()]; + killed.push(proc.pid); + proc.kill(); + } else { + jqUnit.start(); + } + }; + + procs.forEach(function (proc) { + processHandling.monitorProcess(proc.pid).then(function (pid) { + jqUnit.assertEquals("monitorProcess must resolve with the same pid", proc.pid, pid); + jqUnit.assertNotEquals("monitorProcess must resolve after the process is killed", -1, killed.indexOf(pid)); + killProcess(); + }, jqUnit.fail); + }); + + killProcess(); + +}); + +jqUnit.asyncTest("Test monitorProcess failures", function () { + var testData = processHandlingTests.testData.monitorProcessFailures; + jqUnit.expect(testData.length * 4 * 2 + 1); + + var child = processHandlingTests.startProcess(); + + setTimeout(jqUnit.fail, 10000, "timeout"); + + // Tests are ran twice - the 2nd time, another process is also being monitored. This is to check an innocent process + // doesn't get caught up in the failure. + var pass = 0; + var runTest = function (testIndex) { + if (testIndex >= testData.length) { + pass++; + if (pass > 1) { + child.kill(); + return; + } else { + // Monitor a process to check it doesn't also get rejected. + processHandling.monitorProcess(child.pid).then(function () { + jqUnit.assertTrue("Child shouldn't resolve until the end" + messageSuffix, pass > 1); + jqUnit.start(); + }, function () { + jqUnit.fail("Child shouldn't fail"); + }); + testIndex = 0; + } + } + + var test = testData[testIndex]; + var messageSuffix = " - testIndex=" + testIndex + ", pass=" + pass; + console.log("Running test" + messageSuffix); + + var promise = processHandling.monitorProcess(test.input, 100, false); + + jqUnit.assertTrue("monitorProcess must return a promise", promise && typeof(promise.then) === "function"); + + promise.then(function () { + jqUnit.fail("monitorProcess should not have resolved" + messageSuffix); + }, function (e) { + jqUnit.assert("monitorProcess should have rejected" + messageSuffix); + jqUnit.assertTrue("monitorProcess should have rejected with a value" + messageSuffix, !!e); + jqUnit.assertTrue("monitorProcess should have rejected with an error" + messageSuffix, + e instanceof Error || e.isError); + + runTest(testIndex + 1); + }); + + }; + + runTest(0); +}); + +jqUnit.asyncTest("Test unmonitorProcess", function () { + jqUnit.expect(4); + + var child1 = processHandlingTests.startProcess(); + var child2 = processHandlingTests.startProcess(); + var promise1 = processHandling.monitorProcess(child1.pid); + var promise2 = processHandling.monitorProcess(child2.pid); + + var killed = false; + var removed = false; + + promise1.then(function (value) { + jqUnit.assertTrue("promise1 should not resolve before it's removed", removed); + jqUnit.assertEquals("promise1 should resolve with 'removed'", "removed", value); + killed = true; + child2.kill(); + }, jqUnit.fail); + + promise2.then(function (value) { + jqUnit.assertTrue("promise2 should not resolve before the process is killed", killed); + jqUnit.assertEquals("promise2 should resolve with the process id", child2.pid, value); + jqUnit.start(); + }, jqUnit.fail); + + setTimeout(function () { + removed = true; + processHandling.unmonitorProcess(child1.pid); + }, 100); + +}); + +// Test starting and stopping the service +jqUnit.asyncTest("Service start+stop", function () { + jqUnit.expect(1); + + var service = require("../src/service.js"); + + var mutexName = "gpii-test-" + Math.random().toString(32); + + // Configure a child process + service.config = { + processes: { + testProcess: { + command: "node.exe " + path.join(__dirname, "gpii-ipc-tests-child.js") + " mutex " + mutexName, + autoRestart: false + } + } + }; + + // Mock process.exit - the call to this is expected, but not desired. + var oldExit = process.exit; + var exitAsserted = false; + process.exit = function () { + console.log("process.exit()"); + if (!exitAsserted) { + // In order to stop the service from lingering, process.exit() could be called more than once (because + // during the test it doesn't really exit). + jqUnit.assert("process.exit"); + } + exitAsserted = true; + }; + teardowns.push(function () { + process.exit = oldExit; + }); + + + // Start the service + service.start(); + + // Wait for the child process to start. + processHandlingTests.waitForMutex(mutexName, 5000).then(function (value) { + if (value === "timeout") { + jqUnit.fail("Timed out waiting for child process"); + } else { + var pid = processHandling.childProcesses.testProcess.pid; + + // stop the service, see if the child terminates. + service.stop(); + + windows.waitForProcessTermination(pid, 15000).then(function (value) { + if (value === "timeout") { + jqUnit.fail("Timed out waiting for child process to terminate"); + } else { + console.log("Process died"); + jqUnit.start(); + } + }); + } + }, jqUnit.fail); +}); diff --git a/gpii-service/tests/service-tests.js b/gpii-service/tests/service-tests.js new file mode 100644 index 000000000..c4081da28 --- /dev/null +++ b/gpii-service/tests/service-tests.js @@ -0,0 +1,83 @@ +/* Tests for service.js + * + * Copyright 2017 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var jqUnit = require("node-jqunit"), + os = require("os"), + path = require("path"), + fs = require("fs"), + service = require("../src/service.js"); + +var teardowns = []; + +jqUnit.module("GPII service tests", { + teardown: function () { + while (teardowns.length) { + teardowns.pop()(); + } + } +}); + +jqUnit.test("Test config loader", function () { + // service.js should already have called service.config. + jqUnit.assertNotNull("service.config is called on startup", service.config); + + // Create a temporary config file. + var testDir = path.join(os.tmpdir(), "gpii-service-test" + Math.random()); + var testFile = path.join(testDir, "service.json5"); + teardowns.push(function () { + try { + fs.unlinkSync(testFile); + fs.rmdirSync(testDir); + } catch (e) { + // ignore + } + }); + + fs.mkdirSync(testDir); + fs.writeFileSync(testFile, "{testLoaded: true}"); + + + var origConfig = service.config; + // Check a config file will be loaded if the process is running as a service + try { + service.config = null; + service.isService = true; + service.loadConfig(testDir); + jqUnit.assertTrue("config should be loaded when running as a service", + service.config && service.config.testLoaded); + } finally { + service.isService = false; + service.config = origConfig; + } +}); + +jqUnit.test("Test secret loading", function () { + var secret = service.getSecrets(); + jqUnit.assertTrue("Secret should have been loaded", !!secret); + + var origFile = service.config.secretFile; + try { + // Try a secret file that does not exist + service.config.secretFile = "does/not/exist"; + var secret2 = service.getSecrets(); + jqUnit.assertNull("No secret should have been loaded", secret2); + } finally { + service.config.secretFile = origFile; + } +}); diff --git a/gpii-service/tests/windows-tests.js b/gpii-service/tests/windows-tests.js new file mode 100644 index 000000000..e4cb4899b --- /dev/null +++ b/gpii-service/tests/windows-tests.js @@ -0,0 +1,573 @@ +/* Tests for windows.js + * + * Copyright 2017 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var jqUnit = require("node-jqunit"), + child_process = require("child_process"), + path = require("path"), + windows = require("../src/windows.js"); + +var winapi = windows.winapi; + +var windowsTests = { + testData: {} +}; +var teardowns = []; + +jqUnit.module("GPII Windows tests", { + teardown: function () { + while (teardowns.length) { + teardowns.pop()(); + } + } +}); + +windowsTests.testData.waitForProcessTerminationFailures = [ + { input: -1 }, // Non-running pid + { input: 0 }, // Invalid (System Idle Process) + { input: null }, + { input: "not a pid" } +]; + +windowsTests.testData.waitForMultipleObjects = [ + { + // single + input: { + count: 1, // number of handles + all: false, + signal: [ 0 ] // which ones to signal + }, + expect: 0 // the resolve value + }, + { + // single - not signalled + input: { + count: 1, + all: false, + signal: [ ] + }, + expect: "timeout" + }, + { + // multiple + input: { + count: 3, + all: false, + signal: [ 0 ] + }, + expect: 0 + }, + { + // not the first. + input: { + count: 3, + all: false, + signal: [ 1 ] + }, + expect: 1 + }, + // MSDN: "If multiple objects become signaled, the function returns the index of the first handle in the array + // whose object was signaled" + { + // more than one signalled + input: { + count: 3, + all: false, + signal: [ 0, 2 ] + }, + expect: 0 + }, + { + // more than one signalled - reverse order + input: { + count: 3, + all: false, + signal: [ 2, 1 ] + }, + // The wait thread may execute in between the SetEvent calls. + expect: [ 2, 1 ] + }, + { + // none signalled + input: { + count: 3, + all: false, + signal: [ ] + }, + expect: "timeout" + }, + { + // wait for all (single) + input: { + count: 1, + all: true, + signal: [ 0 ] + }, + expect: 0 + }, + { + // wait for all (multiple) + input: { + count: 3, + all: true, + signal: [ 0, 1, 2 ] + }, + expect: 0 + }, + { + // some signalled + input: { + count: 3, + all: true, + signal: [ 1, 2 ] + }, + expect: "timeout" + }, + { + // none signalled + input: { + count: 3, + all: true, + signal: [ ] + }, + expect: "timeout" + } +]; + +windowsTests.testData.waitForMultipleObjectsFailures = [ + // Non-arrays + { input: null }, + { input: [] }, + { input: "not an array" }, + { input: 1 }, + // Invalid handles + { input: [ 0 ] }, + { input: [ 0, 0 ] }, + { input: [ -1 ] }, + { input: [ -1, -1 ] }, + { input: [ winapi.constants.INVALID_HANDLE_VALUE ] }, + { input: [ winapi.constants.INVALID_HANDLE_VALUE, winapi.constants.INVALID_HANDLE_VALUE ] } +]; + +/** + * Returns true if value looks like a promise. + * + * @param {Object} value The thing to test. + * @return {Boolean} true if value is a promise. + */ +windowsTests.isPromise = function (value) { + return value && typeof(value.then) === "function"; +}; + +jqUnit.test("Test isService", function () { + // Only half tested here. + var isService = windows.isService(); + jqUnit.assertFalse("This process isn't a service", isService); +}); + +jqUnit.test("Test getOwnUserToken", function () { + var userToken = windows.getOwnUserToken(); + teardowns.push(function () { + windows.closeToken(userToken); + }); + + jqUnit.assertTrue("userToken should be something", !!userToken); + jqUnit.assertFalse("userToken should be numeric", isNaN(userToken)); + // The validity of the token will be tested via execute/startProcess in gpii-ipc-tests.js +}); + +jqUnit.test("Test getDesktopUser", function () { + var userToken = windows.getDesktopUser(); + teardowns.push(function () { + windows.closeToken(userToken); + }); + + jqUnit.assertTrue("desktop userToken should be something", !!userToken); + jqUnit.assertFalse("desktop userToken should be numeric", isNaN(userToken)); + // The validity of the token will be tested via execute/startProcess in gpii-ipc-tests.js +}); + +jqUnit.test("Test isUserLoggedOn", function () { + var loggedOn = windows.isUserLoggedOn(); + + jqUnit.assertTrue("User should be detected as being logged on", loggedOn); +}); + +jqUnit.test("Test isUserLoggedOn", function () { + var loggedOn = windows.isUserLoggedOn(); + + jqUnit.assertTrue("User should be detected as being logged on", loggedOn); +}); + +jqUnit.test("Test getEnv", function () { + var userToken = windows.getOwnUserToken(); + + var env = windows.getEnv(userToken); + + jqUnit.assertTrue("returned env should be something", !!env); + jqUnit.assertTrue("env should be an array", Array.isArray(env)); + + for (var envIndex = 0; envIndex < env.length; envIndex++) { + var item = env[envIndex]; + jqUnit.assertEquals("env elements must be strings", "string", typeof(item)); + jqUnit.assertTrue("env elements must be like 'name=value'", !!item.match(/^[^=]+=/)); + } + + // The environment block returned is the initial environment, so comparing it against this process's isn't possible. + // Make sure it looks valid by just checking a few items which will probably be static. + var expected = [ + "Username", + "SystemRoot", + "UserProfile" + ]; + + for (var expectedIndex = 0, len = expected.length; expectedIndex < len; expectedIndex++) { + var name = expected[expectedIndex]; + var find = (name + "=" + process.env[name]).toLowerCase(); + + // it's only a small loop in a test. + // eslint-disable-next-line no-loop-func + var found = env.some(function (value) { + return value.toLowerCase() === find; + }); + + jqUnit.assertTrue(name + " should have been in the environment", found); + } +}); + +jqUnit.asyncTest("Test waitForProcessTermination", function () { + jqUnit.expect(7); + + // Test it with or without timing out. + var runTest = function (testTimeout) { + // Create a short-running process. + var exe = path.join(process.env.SystemRoot, "/System32/waitfor.exe"); + var command = exe + " waitForProcessTerminationTest /T 5 > nul"; + var child = child_process.exec(command); + + var timeout = testTimeout ? 100 : 2000; + var promise = windows.waitForProcessTermination(child.pid, timeout); + + jqUnit.assertTrue("waitForProcessTermination must return a promise", windowsTests.isPromise(promise)); + + var killed = false; + promise.then(function (value) { + jqUnit.assert("promise resolved"); + + if (testTimeout) { + jqUnit.assertEquals("waitForProcessTermination should have timed out", "timeout", value); + process.kill(child.pid); + jqUnit.start(); + } else { + jqUnit.assertNotEquals("waitForProcessTermination should not have timed out", "timeout", value); + jqUnit.assertTrue("waitForProcessTermination should not resolve before the process is killed", killed); + + // Test again, but expect a timeout + runTest(true); + } + }, jqUnit.fail); + + if (!testTimeout) { + process.nextTick(function () { + killed = true; + process.kill(child.pid); + }); + } + }; + + runTest(false); +}); + +jqUnit.asyncTest("Test waitForProcessTermination failure", function () { + + var testData = windowsTests.testData.waitForProcessTerminationFailures; + + jqUnit.expect(testData.length * 4); + + var runTest = function (testIndex) { + if (testIndex >= testData.length) { + jqUnit.start(); + return; + } + var suffix = ": testIndex=" + testIndex; + var promise = windows.waitForProcessTermination(testData[testIndex].input, 200); + + jqUnit.assertTrue("waitForProcessTermination must return a promise" + suffix, windowsTests.isPromise(promise)); + + promise.then(function () { + jqUnit.fail("waitForProcessTermination should not have resolved" + suffix); + }, function (e) { + jqUnit.assert("waitForProcessTermination should have rejected" + suffix); + jqUnit.assertTrue("waitForProcessTermination should have rejected with a value" + suffix, !!e); + jqUnit.assertTrue("waitForProcessTermination should have rejected with an error" + suffix, + e instanceof Error || e.isError); + runTest(testIndex + 1); + }); + + }; + + runTest(0); +}); + +jqUnit.asyncTest("Test waitForMultipleObjects", function () { + + var testData = windowsTests.testData.waitForMultipleObjects; + jqUnit.expect(testData.length * 2); + + var allHandles = []; + teardowns.push(function () { + allHandles.forEach(function (handle) { + winapi.kernel32.CloseHandle(handle); + }); + }); + + var runTest = function (testIndex) { + if (testIndex >= testData.length) { + jqUnit.start(); + return; + } + + var test = testData[testIndex]; + + // Create the events. + var handles = []; + for (var n = 0; n < test.input.count; n++) { + handles.push(winapi.kernel32.CreateEventW(winapi.NULL, false, false, winapi.NULL)); + } + allHandles.push.apply(allHandles, handles); + + var promise = windows.waitForMultipleObjects(handles, 100, test.input.all); + + jqUnit.assertTrue("waitForMultipleObjects must return a promise", windowsTests.isPromise(promise)); + + var messageSuffix = " - testIndex=" + testIndex; + var expected = isNaN(test.expect) ? test.expect : handles[test.expect]; + promise.then(function (value) { + if (Array.isArray(test.expect)) { + var found = test.expect.indexOf(value); + jqUnit.assertTrue("waitForMultipleObjects must resolve with an expected value" + messageSuffix, found); + } else { + jqUnit.assertEquals("waitForMultipleObjects must resolve with the expected value" + messageSuffix, + expected, value); + } + // Run the next test. + runTest(testIndex + 1); + }, jqUnit.fail); + + // Signal some events. + test.input.signal.forEach(function (index) { + winapi.kernel32.SetEvent(handles[index]); + }); + }; + + runTest(0); +}); + +jqUnit.asyncTest("Test waitForMultipleObjects failures", function () { + var testData = windowsTests.testData.waitForMultipleObjectsFailures; + jqUnit.expect(testData.length * 4); + + var runTest = function (testIndex) { + if (testIndex >= testData.length) { + jqUnit.start(); + return; + } + + var test = testData[testIndex]; + + var promise = windows.waitForMultipleObjects(test.input, 100, false); + + var messageSuffix = " - testIndex=" + testIndex; + jqUnit.assertTrue("waitForMultipleObjects must return a promise" + messageSuffix, + windowsTests.isPromise(promise)); + + promise.then(function () { + jqUnit.fail("waitForMultipleObjects should not have resolved" + messageSuffix); + }, function (e) { + jqUnit.assert("waitForMultipleObjects should have rejected" + messageSuffix); + jqUnit.assertTrue("waitForMultipleObjects should have rejected with a value" + messageSuffix, !!e); + jqUnit.assertTrue("waitForMultipleObjects should have rejected with an error" + messageSuffix, + e instanceof Error || e.isError); + runTest(testIndex + 1); + }); + }; + + runTest(0); +}); + +// Tests waitForMultipleObjects with a process, since that's mainly what waitForMultipleObjects will be used for. +jqUnit.asyncTest("Test waitForMultipleObjects with a process", function () { + jqUnit.expect(6); + + var runTest = function (testTimeout) { + + var exe = path.join(process.env.SystemRoot, "/System32/waitfor.exe"); + var command = exe + " waitForMultipleObjectsTest /T 5 > nul"; + var child = child_process.exec(command); + + var hProcess = winapi.kernel32.OpenProcess(winapi.constants.SYNCHRONIZE, 0, child.pid); + if (hProcess === winapi.NULL) { + jqUnit.fail(windows.win32Error("OpenProcess")); + return; + } + + teardowns.push(function () { + winapi.kernel32.CloseHandle(hProcess); + }); + + var timeout = testTimeout ? 100 : 2000; + var promise = windows.waitForMultipleObjects([hProcess], timeout, false); + + jqUnit.assertTrue("waitForMultipleObjects must return a promise", windowsTests.isPromise(promise)); + + var killed = false; + promise.then(function (value) { + if (testTimeout) { + jqUnit.assertEquals("waitForMultipleObjects should have timed out", "timeout", value); + process.kill(child.pid); + jqUnit.start(); + } else { + jqUnit.assertNotEquals("waitForMultipleObjects should not have timed out", "timeout", value); + jqUnit.assertTrue("waitForMultipleObjects should not resolve before the process is killed", killed); + jqUnit.assertEquals("waitForMultipleObjects should resolve with the process handle", hProcess, value); + + // Test again, but expect a timeout + runTest(true); + } + }, jqUnit.fail); + + if (!testTimeout) { + killed = true; + child.kill(); + } + }; + + runTest(false); +}); + +jqUnit.test("test getDesktopUser", function () { + + + // Pretend to be running as a service + var realIsService = windows.isService; + windows.isService = function () { + return true; + }; + + var sessionId = winapi.kernel32.WTSGetActiveConsoleSessionId(); + var realWTSGetActiveConsoleSessionId = winapi.kernel32.WTSGetActiveConsoleSessionId; + winapi.kernel32.WTSGetActiveConsoleSessionId = function () { + return sessionId; + }; + + var realWTSQueryUserToken = winapi.wtsapi32.WTSQueryUserToken; + var userToken = undefined; + winapi.wtsapi32.WTSQueryUserToken = function (sessionId, tokenBuf) { + if (userToken) { + tokenBuf.writeInt32LE(userToken); + } + return userToken === undefined ? realWTSQueryUserToken(sessionId, tokenBuf) : !!userToken; + }; + + try { + + // There is no session + sessionId = 0xffffffff; + var token2 = windows.getDesktopUser(); + jqUnit.assertEquals("getDesktopUser unsuccessful there's no current session", 0, token2); + + // Successful token + sessionId = 1; + userToken = 1234; + var token3 = windows.getDesktopUser(); + jqUnit.assertEquals("getDesktopUser should return correct token", userToken, token3); + + // Fail token + sessionId = 1; + userToken = 0; + var token4 = windows.getDesktopUser(); + jqUnit.assertEquals("getDesktopUser should return 0 for no token", userToken, token4); + } finally { + windows.isService = realIsService; + winapi.kernel32.WTSGetActiveConsoleSessionId = realWTSGetActiveConsoleSessionId; + realWTSQueryUserToken = winapi.wtsapi32.WTSQueryUserToken; + } +}); + +jqUnit.test("user environment tests", function () { + + // Should always be able to get the users own token + var token = windows.getOwnUserToken(); + try { + jqUnit.assertTrue("getOwnUserToken should return something", !!token); + + var env = windows.getEnv(token); + jqUnit.assertNotNull("getEnv should return something", token); + jqUnit.assertTrue("getEnv should return an array", Array.isArray(env)); + jqUnit.assertTrue("getEnv should return a filled array", env.length > 0); + + env.forEach(function (value) { + var pair = value.split("=", 2); + var result = pair.length === 2 && pair[0].length > 0; + if (!result) { + console.log("full block:", env); + console.log("current value:", value); + } + jqUnit.assertTrue("environment value should be in the format of 'key=value'", result); + }); + + var userDatadir = windows.getUserDataDir(token); + var expectedDataDir = path.join(process.env.APPDATA, "GPII"); + + jqUnit.assertEquals("User data dir for this user should be correct", expectedDataDir, userDatadir); + } finally { + windows.closeToken(token); + } +}); + +jqUnit.test("expandEnvironmentStrings tests", function () { + + // Test a normal value + process.env._env_test1 = "VALUE"; + var input = "start%_env_test1%end"; + var result = windows.expandEnvironmentStrings(input); + // the value should be expanded + jqUnit.assertEquals("expandEnvironmentStrings should return expected result", "startVALUEend", result); + delete process.env._env_test1; + + // Test an unset value + var input2 = "start%_env_unset%end"; + var result2 = windows.expandEnvironmentStrings(input2); + // The value should be unexpanded + jqUnit.assertEquals("expandEnvironmentStrings (unset value) should return the input", input2, result2); + + // Test a very long value - this should cause the initial call to ExpandEnvironmentStrings to fail, and get recalled + // with a larger buffer. + process.env._env_test2 = "very long value" + "X".repeat(winapi.constants.MAX_PATH * 2); + var input3 = "start%_env_test2%end"; + var result3 = windows.expandEnvironmentStrings(input3); + + jqUnit.assertEquals("expandEnvironmentStrings (long value) should return the expected result", + "start" + process.env._env_test2 + "end", result3); + + // Call with empty string or null should return an empty string. + ["", null].forEach(function (input) { + var result = windows.expandEnvironmentStrings(input); + jqUnit.assertEquals("expandEnvironmentStrings (empty/null) should return empty string", "", result); + }); +}); diff --git a/gpii/node_modules/WindowsUtilities/WindowsUtilities.js b/gpii/node_modules/WindowsUtilities/WindowsUtilities.js index 40a689979..507ad0f57 100644 --- a/gpii/node_modules/WindowsUtilities/WindowsUtilities.js +++ b/gpii/node_modules/WindowsUtilities/WindowsUtilities.js @@ -197,6 +197,10 @@ windows.kernel32 = ffi.Library("kernel32", { t.LP, // LPSTARTUPINFO lpStartupInfo, t.LP // LPPROCESS_INFORMATION lpProcessInformation ] + ], + // https://msdn.microsoft.com/library/ms686211 + "SetEvent": [ + t.BOOL, [ t.HANDLE ] ] }); diff --git a/gpii/node_modules/gpii-iod/README.md b/gpii/node_modules/gpii-iod/README.md new file mode 100644 index 000000000..b8bdf1234 --- /dev/null +++ b/gpii/node_modules/gpii-iod/README.md @@ -0,0 +1,7 @@ +# Install on Demand - Windows + +The IoD routines that are specific to Windows. + +## Package installers + +* [Chocolatey](./src/chocolateyInstaller.js) diff --git a/gpii/node_modules/gpii-iod/index.js b/gpii/node_modules/gpii-iod/index.js new file mode 100644 index 000000000..74ed39858 --- /dev/null +++ b/gpii/node_modules/gpii-iod/index.js @@ -0,0 +1,43 @@ +/* + * Install on Demand (Windows Specific). + * + * Copyright 2019 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var fluid = require("gpii-universal"); + +require("./src/installOnDemand.js"); +require("./src/chocolateyInstaller.js"); +require("./src/msiInstaller.js"); +require("./src/appxInstaller.js"); +require("./src/exeInstaller.js"); + +// Add the installers to the list of available installers on the gpii.iod grade. +fluid.defaults("gpii.windows.iod.installersConfig", { + gradeNames: "fluid.component", + installerGrades: { + "chocolatey": "gpii.windows.iod.chocolateyInstaller", + "msi": "gpii.windows.iod.msiInstaller", + "appx": "gpii.windows.iod.appxInstaller", + "exe": "gpii.windows.iod.exeInstaller" + } +}); + +fluid.makeGradeLinkage("gpii.windows.iod.installersConfigLink", + ["gpii.iod"], + "gpii.windows.iod.installersConfig" +); diff --git a/gpii/node_modules/gpii-iod/package.json b/gpii/node_modules/gpii-iod/package.json new file mode 100644 index 000000000..18c6f62b4 --- /dev/null +++ b/gpii/node_modules/gpii-iod/package.json @@ -0,0 +1,13 @@ +{ + "name": "iod-windows", + "description": "Install on Demand", + "version": "0.3.0", + "author": "GPII", + "bugs": "http://issues.gpii.net/browse/GPII", + "homepage": "http://gpii.net/", + "dependencies": {}, + "license" : "BSD-3-Clause", + "repository": "git://github.com/GPII/windows.git", + "main": "./index.js", + "engines": { "node" : ">=4.2.1" } +} diff --git a/gpii/node_modules/gpii-iod/scripts/setup.ps1 b/gpii/node_modules/gpii-iod/scripts/setup.ps1 new file mode 100644 index 000000000..badaf0500 --- /dev/null +++ b/gpii/node_modules/gpii-iod/scripts/setup.ps1 @@ -0,0 +1,3 @@ + +Set-ExecutionPolicy Bypass -Scope Process -Force +iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1')) diff --git a/gpii/node_modules/gpii-iod/src/appxInstaller.js b/gpii/node_modules/gpii-iod/src/appxInstaller.js new file mode 100644 index 000000000..37b8d905c --- /dev/null +++ b/gpii/node_modules/gpii-iod/src/appxInstaller.js @@ -0,0 +1,79 @@ +/* + * Installer for appx (windows store) files. + * + * Copyright 2019 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var fluid = require("infusion"); + +var gpii = fluid.registerNamespace("gpii"); +fluid.registerNamespace("gpii.windows.iod.appx"); + +// Installs appx packages +fluid.defaults("gpii.windows.iod.appxInstaller", { + gradeNames: ["fluid.component", "gpii.windows.iod.packageInstaller"], + + invokers: { + installPackage: { + funcName: "gpii.windows.iod.appx.install", + args: ["{that}", "{that}.installation", true] + }, + uninstallPackage: { + funcName: "gpii.windows.iod.appx.install", + args: ["{that}", "{that}.installation", false] + } + } +}); + +/** + * Installs (or uninstalls) a Windows store (.appx) package. + * + * @param {Component} that The packageInstaller instance. + * @param {Installation} installation The installation state. + * @param {Boolean} install `true` to install the package, `false` to uninstall. + * @return {Promise} Resolves when the action is complete. + */ +gpii.windows.iod.appx.install = function (that, installation, install) { + + var command; + if (install) { + command = "Add-AppxPackage -Path " + installation.installerFile; + } else { + command = "Get-AppxPackage -Name " + installation.packageData.appxPackageName + " | Remove-AppxPackage"; + } + + var args = [ + "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "ByPass", "-Command", command + ]; + + var execOptions = install + ? installation.packageData.installerArgs + : installation.packageData.uninstallerArgs; + + var promise = that.executeCommand(execOptions, "powershell", args); + + promise.then(function () { + if (installation.packageData.name.startsWith("lang")) { + // Make the language list in the quick strip update. + gpii.windows.messages.sendMessage("gpii-message-window", + gpii.windows.API_constants.WM_INPUTLANGCHANGE, 0, 0); + } + }); + + return promise; +}; + diff --git a/gpii/node_modules/gpii-iod/src/chocolateyInstaller.js b/gpii/node_modules/gpii-iod/src/chocolateyInstaller.js new file mode 100644 index 000000000..edb781a03 --- /dev/null +++ b/gpii/node_modules/gpii-iod/src/chocolateyInstaller.js @@ -0,0 +1,67 @@ +/* + * Chocolatey installer for IoD. + * + * Copyright 2018 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var fluid = require("infusion"); +var gpii = fluid.registerNamespace("gpii"); +fluid.registerNamespace("gpii.windows.iod.chocolatey"); + +// Installs chocolatey packages +fluid.defaults("gpii.windows.iod.chocolateyInstaller", { + gradeNames: ["fluid.component", "gpii.windows.iod.packageInstaller"], + + invokers: { + installPackage: { + funcName: "gpii.windows.iod.chocolatey.installPackage", + args: ["{that}", "{serviceHandler}", "{arguments}.0"] + }, + uninstallPackage: { + funcName: "gpii.windows.iod.chocolatey.uninstallPackage", + args: ["{that}", "{serviceHandler}", "{arguments}.0"] + } + } +}); + +/** + * Install the package. + * + * @param {Component} that - The packageInstaller instance. + * @param {Component} service - The service handler instance. + * @return {Promise} Resolves when the action is complete. + */ +gpii.windows.iod.chocolatey.installPackage = function (that) { + fluid.log("IoD.choco: Installing package " + that.installation.installerFile); + + var args = [ "install", "-y", that.installation.installerFile]; + return that.startElevatedProcess("choco", args); +}; + +/** + * Uninstall the package. + * + * @param {Component} that The packageInstaller instance. + * @param {Component} service The service handler instance. + * @return {Promise} Resolves when the action is complete. + */ +gpii.windows.iod.chocolatey.uninstallPackage = function (that) { + fluid.log("IoD.choco: Uninstalling package " + that.installation.installerFile); + + var args = [ "uninstall", "-y", that.installation.packageName]; + return that.startElevatedProcess("choco", args); +}; diff --git a/gpii/node_modules/gpii-iod/src/exeInstaller.js b/gpii/node_modules/gpii-iod/src/exeInstaller.js new file mode 100644 index 000000000..ce60ac29c --- /dev/null +++ b/gpii/node_modules/gpii-iod/src/exeInstaller.js @@ -0,0 +1,58 @@ +/* + * Installer for Executables - an installer which is an executable binary. + * + * Copyright 2019 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var fluid = require("infusion"); + +var gpii = fluid.registerNamespace("gpii"); +fluid.registerNamespace("gpii.windows.iod.exe"); + +// Installs exe packages +fluid.defaults("gpii.windows.iod.exeInstaller", { + gradeNames: ["fluid.component", "gpii.windows.iod.packageInstaller"], + + invokers: { + installPackage: { + funcName: "gpii.windows.iod.exe.install", + args: ["{that}", "{that}.installation", true] + }, + uninstallPackage: { + funcName: "gpii.windows.iod.exe.install", + args: ["{that}", "{that}.installation", false] + } + } +}); + +/** + * Installs (or uninstalls) an EXE package. + * + * @param {Component} that The packageInstaller instance. + * @param {Installation} installation The installation state. + * @param {Boolean} install `true` to install the package, `false` to uninstall. + * @return {Promise} Resolves when the action is complete. + */ +gpii.windows.iod.exe.install = function (that, installation, install) { + var execOptions = install + ? installation.packageData.installerArgs + : installation.packageData.uninstallerArgs; + + var command = execOptions.command || installation.installerFile; + + return that.executeCommand(execOptions, command); +}; diff --git a/gpii/node_modules/gpii-iod/src/installOnDemand.js b/gpii/node_modules/gpii-iod/src/installOnDemand.js new file mode 100644 index 000000000..dc63bda99 --- /dev/null +++ b/gpii/node_modules/gpii-iod/src/installOnDemand.js @@ -0,0 +1,139 @@ +/* + * Install on Demand. + * + * Copyright 2018 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var fluid = require("gpii-universal"); + +var gpii = fluid.registerNamespace("gpii"); +fluid.registerNamespace("gpii.windows.iod"); + +// Mix-in grade for gpii.iod, providing Windows-related functionality. +fluid.defaults("gpii.iod.windows", { + invokers: { + getMountedVolumes: { + funcName: "gpii.windows.getAllDrives" + } + } +}); + +// Base package installer for Windows. +fluid.defaults("gpii.windows.iod.packageInstaller", { + gradeNames: ["fluid.component", "gpii.iod.packageInstaller"], + + invokers: { + stopApplication: { + funcName: "gpii.windows.iod.stopApplication", + args: ["{that}", "{serviceHandler}", "{iod}"] + }, + startElevatedProcess: { + funcName: "gpii.windows.iod.startElevatedProcess", + // command, args, options + args: ["{serviceHandler}", "{arguments}.0", "{arguments}.1", "{arguments}.2" ] + } + } +}); + +/** + * Invokes a command as administrator, via the Windows service. + * + * @param {Component} service The service handler instance. + * @param {String} command The command to execute. + * @param {Array} args The arguments to pass. + * @param {Object} options [optional] Options object. + * @param {Boolean} options.desktop true to run on the desktop. + * @return {Promise} Resolves when the process terminates. + */ +gpii.windows.iod.startElevatedProcess = function (service, command, args, options) { + options = Object.assign({}, options); + + var promise = fluid.promise(); + + fluid.log("IoD: Executing elevated: " + command + " " + args.join(" ")); + + // Wait for service connectivity - uninstalls can occur quite early in the lifetime. + // TODO: move this into the service handler + gpii.windows.waitForCondition(function () { + return service.connected; + }, { + pollDelay: 1000, + timeout: 10000 + }).then(function () { + service.requestSender.execute(command, args, { + wait: true, + capture: true, + desktop: options.desktop + }).then(function (result) { + fluid.log(result); + promise.resolve(result); + }, function (err) { + promise.reject({ + isError: true, + message: "elevated command failed to run", + execOptions: options, + error: err + }); + }); + }, function () { + promise.reject({ + isError: true, + message: "elevated command failed to run - service not running" + }); + }); + + return promise; +}; + +/** + * Stops the application (for uninstallation). + * + * @param {Component} that The gpii.iod.installer instance. + * @param {Component} service - The service handler instance. + * @return {Promise} Resolves when the command has completed. + */ +gpii.windows.iod.stopApplication = function (that, service) { + var promise = fluid.promise(); + fluid.log("IoD: Stopping application " + that.packageData.name); + + if (that.packageData.stop) { + var cmd, args; + if (typeof(that.packageData.stop) === "string") { + cmd = that.packageData.stop; + args = []; + } else { + cmd = that.packageData.stop.cmd; + args = fluid.makeArray(that.packageData.stop.args); + } + service.requestSender.execute(cmd, args, { + wait: true, + capture: true + }).then(function (result) { + fluid.log(result); + promise.resolve(); + }, function (err) { + promise.reject({ + isError: true, + message: "IoD: Stop application failed.", + error: err + }); + }); + } else { + promise.resolve(); + } + return promise; +}; diff --git a/gpii/node_modules/gpii-iod/src/languageInstaller.js b/gpii/node_modules/gpii-iod/src/languageInstaller.js new file mode 100644 index 000000000..29517a65e --- /dev/null +++ b/gpii/node_modules/gpii-iod/src/languageInstaller.js @@ -0,0 +1,65 @@ +/* + * Chocolatey installer for IoD. + * + * Copyright 2018 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var fluid = require("infusion"); +var gpii = fluid.registerNamespace("gpii"); +fluid.registerNamespace("gpii.windows.iod.language"); + +// Installs chocolatey packages +fluid.defaults("gpii.windows.iod.language", { + gradeNames: ["fluid.component", "gpii.iod.packageInstaller"], + + invokers: { + installPackage: { + funcName: "gpii.windows.iod.language.installPackage", + args: ["{that}"] + }, + uninstallPackage: { + funcName: "gpii.windows.iod.language.uninstallPackage", + args: ["{that}"] + } + }, + + packageTypes: "language" +}); + +/** + * Install the language pack. + * + * @param {Component} that - The packageInstaller instance. + * @return {Promise} Resolves when the action is complete. + */ +gpii.windows.iod.language.installPackage = function (that) { + fluid.log("IoD.language: Installing package " + that.installerFile); + return fluid.promise.resolve(); +}; + +/** + * Uninstall the language pack. + * + * @param {Component} that The packageInstaller instance. + * @return {Promise} Resolves when the action is complete. + */ +gpii.windows.iod.language.uninstallPackage = function (that) { + fluid.log("IoD.language: Uninstalling package " + that.installerFile); + return fluid.promise.resolve(); +}; + + diff --git a/gpii/node_modules/gpii-iod/src/msiInstaller.js b/gpii/node_modules/gpii-iod/src/msiInstaller.js new file mode 100644 index 000000000..5c688586f --- /dev/null +++ b/gpii/node_modules/gpii-iod/src/msiInstaller.js @@ -0,0 +1,88 @@ +/* + * msi installer for Windows Installer files (.MSI). + * + * Copyright 2019 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var fluid = require("infusion"); + +var gpii = fluid.registerNamespace("gpii"); +fluid.registerNamespace("gpii.windows.iod.msi"); + +// Installs msi packages +fluid.defaults("gpii.windows.iod.msiInstaller", { + gradeNames: ["fluid.component", "gpii.windows.iod.packageInstaller"], + + invokers: { + installPackage: { + funcName: "gpii.windows.iod.msi.invokeMSI", + args: ["{that}", "{that}.installation", true] + }, + uninstallPackage: { + funcName: "gpii.windows.iod.msi.invokeMSI", + args: ["{that}", "{that}.installation", false] + } + } +}); + +// Mappings for uiLevel and the argument for msiexec. +gpii.windows.iod.msi.uiLevelArgs = { + "none": "/qn", + "progress": "/qb!", + "progress-cancel": "/qb", + "full": "/qf" +}; + +/** + * Installs (or uninstalls) an MSI package. + * + * Invokes msiexec (possibly via the windows service) + * + * @param {Component} that The packageInstaller instance. + * @param {Installation} installation The installation state. + * @param {Boolean} install `true` to install the package, `false` to uninstall. + * @return {Promise} Resolves when the action is complete. + */ +gpii.windows.iod.msi.invokeMSI = function (that, installation, install) { + var uiLevelArg = gpii.windows.iod.msi.uiLevelArgs[installation.packageData.uiLevel]; + if (!uiLevelArg) { + uiLevelArg = gpii.windows.iod.msi.uiLevelArgs.none; + } + + // msiexec options: https://docs.microsoft.com/en-us/windows/win32/msi/command-line-options + var args = [ + install ? "/i" : "/x", + installation.installerFile, + uiLevelArg + ]; + + // Add any arguments from the package data. + var invokeOptions = install + ? installation.packageData.installerArgs + : installation.packageData.uninstallerArgs; + + if (invokeOptions && invokeOptions.args) { + args.push(invokeOptions.args); + } else if (typeof(invokeOptions) === "string") { + args.push(invokeOptions); + } + + fluid.log("IoD.msi: invoking msiexec: " + args); + + return that.executeCommand(invokeOptions, "msiexec", args); +}; + diff --git a/gpii/node_modules/gpii-iod/test/installOnDemandTests.js b/gpii/node_modules/gpii-iod/test/installOnDemandTests.js new file mode 100644 index 000000000..942fbf9d5 --- /dev/null +++ b/gpii/node_modules/gpii-iod/test/installOnDemandTests.js @@ -0,0 +1,59 @@ +/* + * IoD Tests. + * + * Copyright 2018 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var fluid = require("gpii-universal"); +var kettle = fluid.require("kettle"); +kettle.loadTestingSupport(); +var jqUnit = fluid.require("node-jqunit"); +var gpii = fluid.registerNamespace("gpii"); + +fluid.registerNamespace("gpii.tests.windows.iod"); + +require("../index.js"); + +jqUnit.module("gpii.tests.windows.iod"); + + +jqUnit.asyncTest("install tests", function () { + + gpii.iod({ + gradeNames: ["gpii.lifecycleManager", "gpii.journal"], + components: { + packageDataFallback: { + type: "kettle.dataSource.file", + options: { + gradeNames: "kettle.dataSource.file.moduleTerms", + "path": "%gpii-universal/testData/gpii-iod/%packageName.json5", + "termMap": { + "packageName": "%packageName" + } + } + } + } + }); + + // iod.requirePackage("wget").then(function () { + // fluid.log("complete"); + // jqUnit.start(); + // }, jqUnit.fail); + + jqUnit.assert("Instantiation did not crash"); + jqUnit.start(); +}); diff --git a/gpii/node_modules/gpii-service-handler/README.md b/gpii/node_modules/gpii-service-handler/README.md new file mode 100644 index 000000000..54dd1610b --- /dev/null +++ b/gpii/node_modules/gpii-service-handler/README.md @@ -0,0 +1,19 @@ +# gpiii-service-handler + +Module to handle the communications between this GPII user process and the GPII Windows service. + +See [/service/README.md](../../../gpii-service/README.md) for general information related to the service. + +## Components + +### `gpii.windows.service.serviceHandler` - [serviceHandler.js](src/serviceHandler.js) + +Establishes a connection to the service pipe, performs authentication, and sets up a messaging session with the service. + +### `gpii.windows.service.requestHandler` - [requestHandler.js](src/requestHandler.js) + +A component of the serviceHandler grade that handles requests from the service. + +### `gpii.windows.service.requestSender` - [requestSender.js](src/requestSender.js) + +A component of the serviceHandler grade that sends requests to the service. diff --git a/gpii/node_modules/gpii-service-handler/index.js b/gpii/node_modules/gpii-service-handler/index.js new file mode 100644 index 000000000..4516693db --- /dev/null +++ b/gpii/node_modules/gpii-service-handler/index.js @@ -0,0 +1,24 @@ +/* + * Windows Service interface. + * + * Copyright 2018 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; +fluid.module.register("gpii-service-handler", __dirname, require); + +require("./src/requestHandler.js"); +require("./src/requestSender.js"); +require("./src/serviceHandler.js"); diff --git a/gpii/node_modules/gpii-service-handler/package.json b/gpii/node_modules/gpii-service-handler/package.json new file mode 100644 index 000000000..356e2974b --- /dev/null +++ b/gpii/node_modules/gpii-service-handler/package.json @@ -0,0 +1,13 @@ +{ + "name": "gpii-service-handler", + "description": "Windows Service Handler.", + "version": "0.3.0", + "author": "GPII", + "bugs": "http://issues.gpii.net/browse/GPII", + "homepage": "http://gpii.net/", + "dependencies": {}, + "license" : "BSD-3-Clause", + "repository": "git://github.com/GPII/windows.git", + "main": "./index.js", + "engines": { "node" : ">=4.2.1" } +} diff --git a/gpii/node_modules/gpii-service-handler/src/requestHandler.js b/gpii/node_modules/gpii-service-handler/src/requestHandler.js new file mode 100644 index 000000000..75c5578b3 --- /dev/null +++ b/gpii/node_modules/gpii-service-handler/src/requestHandler.js @@ -0,0 +1,87 @@ +/* + * Handles requests from Windows Service. + * + * Copyright 2018 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var fluid = require("gpii-universal"); + +var gpii = fluid.registerNamespace("gpii"); +fluid.registerNamespace("gpii.windows.service.serviceHandler"); + +/** + * A request from the service. + * @typedef {Object} ServiceRequest + * @property {String} requestType The type of request to perform. + * @property {Mixed} [various] Zero or more additional fields specific to the request. + */ + +// Handles requests from the service. +// handleRequest() is invoked when a ServiceRequest is received from the service. The requestType field of the +// ServiceRequest is used to determine which invoker to call, and the return value is returned. +fluid.defaults("gpii.windows.service.requestHandler", { + gradeNames: ["fluid.component" ], + invokers: { + // Called from the handleMessage callback of the message session when a request has been received. + handleRequest: { + funcName: "gpii.windows.service.handleRequest", + args: [ "{that}", "{arguments}.0" ] // ServiceRequest + }, + // Dynamically called via handleRequest. + status: { + funcName: "gpii.windows.service.status", + args: [ "{that}" ] + }, + shutdown: { + funcName: "gpii.windows.service.shutdown", + args: [ "{that}" ] + } + } +}); + +/** + * Handle a request from the service, by calling the relevant invoker of gpii.windows.service.requestHandler. + * + * @param {Component} that The gpii.serviceHandler instance. + * @param {ServiceRequest} request The request from the service. + * @return {Object|Promise} The return of the handler function. + */ +gpii.windows.service.handleRequest = function (that, request) { + fluid.log("Service: request", request); + var handler = that[request.requestType]; + return typeof(handler) === "function" && handler(request); +}; + +/** + * Determines the status of GPII. Currently, this only determines if GPII is running and hasn't frozen, where if a + * response from this request hasn't been received in a timely manner then it can be assumed GPII has froze. + * + * @return {Object} an object containing the status. + */ +gpii.windows.service.status = function () { + return { + isRunning: true + }; +}; + +/** + * Called by the service to shutdown GPII. + */ +gpii.windows.service.shutdown = function () { + fluid.log("Service requested shutdown"); + gpii.windows.messages.sendMessage("gpii-message-window", gpii.windows.API_constants.WM_QUERYENDSESSION, 0, 0); +}; diff --git a/gpii/node_modules/gpii-service-handler/src/requestSender.js b/gpii/node_modules/gpii-service-handler/src/requestSender.js new file mode 100644 index 000000000..01ce1b13d --- /dev/null +++ b/gpii/node_modules/gpii-service-handler/src/requestSender.js @@ -0,0 +1,146 @@ +/* + * Handles commands for the Windows Service. + * + * Copyright 2018 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var fluid = require("gpii-universal"); + +var gpii = fluid.registerNamespace("gpii"); +fluid.registerNamespace("gpii.windows.service.serviceHandler"); + +// Sends requests to the service. +fluid.defaults("gpii.windows.service.requestSender", { + gradeNames: ["fluid.component" ], + invokers: { + // Called by the functions in this grade to send the request to the server. + sendRequest: { + funcName: "gpii.windows.service.sendRequest", + args: [ "{serviceHandler}", "{arguments}.0", "{arguments}.1" ] // requestType, requestData + }, + execute: { + funcName: "gpii.windows.service.execute", + args: [ "{that}", "{arguments}.0", "{arguments}.1", "{arguments}.2" ] // command, args, options + }, + // Lets the service know this process is intentionally closing + closing: { + func: "{that}.sendRequest", + args: [ "closing" ] + }, + getClientCredentials: { + func: "{that}.sendRequest", + args: [ "getClientCredentials" ] + }, + sign: { + func: "{that}.sendRequest", + args: [ + "sign", + { + payload: "{arguments}.0", + keyName: "{arguments}.1" + } + ] + } + }, + listeners: { + "onDestroy.close": { + // Inform the service that this process is closing intentionally. + funcName: "gpii.windows.service.sendRequest", + args: [ "{serviceHandler}", "closing" ] + } + } +}); + +/** + * Sends a request to the service. + * + * @param {Component} service The gpii.serviceHandler instance. + * @param {String} requestType Type of request for the service. + * @param {Object} requestData Request data. + * @return {Promise} Promise resolving with the response. + */ +gpii.windows.service.sendRequest = function (service, requestType, requestData) { + fluid.log("Service: sending request ", requestType, requestData); + var promiseTogo; + if (service.session) { + var serviceRequest = { + requestType: requestType, + requestData: requestData + }; + promiseTogo = service.session.sendRequest(serviceRequest); + } else { + promiseTogo = fluid.promise().reject({ + isError: true, + message: "Not attached to the Windows service." + }); + } + + promiseTogo.then(function () { + fluid.log("Service: Responded"); + }, function (result) { + fluid.log("Service: Request failed ", result); + }); + + return promiseTogo; +}; + +/** + * Sends the "execute" request to the service. + * + * @param {Component} that The gpii.windows.service.requestSender instance. + * @param {String} command The command to run. + * @param {Array} args Arguments to pass. + * @param {Object} options The request data. + * @param {Object} options.options The options argument for child_process.spawn. + * @param {Boolean} options.wait True to wait for the process to terminate before resolving. + * @param {Boolean} options.capture True capture output to stdout/stderr members of the response; implies wait=true. + * @return {Promise} Resolves when the process has started, if wait=false, or when it's terminated. + */ +gpii.windows.service.execute = function (that, command, args, options) { + var request = Object.assign({ + command: command, + args: args + }, options); + return that.sendRequest("execute", request); +}; + +fluid.registerNamespace("gpii.launch"); + +fluid.defaults("gpii.launch.admin", { + gradeNames: "fluid.function", + argumentMap: { + command: 0, + args: 1, + options: 2 + } +}); + +/** + * Runs a command as administrator, via the service. + * + * @param {String} command The command to run. + * @param {Array} args Arguments to pass. + * @param {Object} options The request data. + * @param {Object} options.options The options argument for child_process.spawn. + * @param {Boolean} options.wait True to wait for the process to terminate before resolving. + * @param {Boolean} options.capture True capture output to stdout/stderr members of the response; implies wait=true. + * @return {Promise} Resolves when the process has been started, or if options.wait is set, when finished + */ +gpii.launch.admin = function (command, args, options) { + var service = fluid.queryIoCSelector(fluid.rootComponent, "gpii.windows.service.serviceHandler")[0]; + return service.requestSender.execute(command, args, options); +}; diff --git a/gpii/node_modules/gpii-service-handler/src/serviceHandler.js b/gpii/node_modules/gpii-service-handler/src/serviceHandler.js new file mode 100644 index 000000000..6cb1ceb42 --- /dev/null +++ b/gpii/node_modules/gpii-service-handler/src/serviceHandler.js @@ -0,0 +1,360 @@ +/* + * Windows Service interface. + * This connects to and authenticates with the Windows service. The GPII_SERVICE_PIPE environment variable is used to + * specify the name of the pipe to connect to. + * + * Copyright 2018 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var fluid = require("gpii-universal"), + net = require("net"); + +var gpii = fluid.registerNamespace("gpii"); +fluid.registerNamespace("gpii.windows.service.serviceHandler"); + +require("../../WindowsUtilities/WindowsUtilities.js"); + +var messaging = fluid.require("%gpii-windows/gpii-service/shared/pipe-messaging.js"); + +fluid.defaults("gpii.windows.service", { + gradeNames: ["fluid.component"], + components: { + serviceHandler: { + type: "gpii.windows.service.serviceHandler" + } + }, + events: { + onServiceReady: "{serviceHandler}.events.onConnected" + }, + distributeOptions: { + // Make the accessRequester use the service to get the client credentials. + accessRequester: { + record: "gpii.windows.service.clientCredentialsSource", + target: "{that gpii.flowManager.settingsDataSource}.options.components.accessRequester.options.clientCredentialDataSourceGrade" + } + } +}); + +fluid.makeGradeLinkage("gpii.windows.serviceLinkage", + ["gpii.flowManager.local"], + "gpii.windows.service" +); + +// Manages the connection to the Windows service. +fluid.defaults("gpii.windows.service.serviceHandler", { + gradeNames: ["fluid.component", "fluid.resolveRootSingle"], + singleRootType: "gpii.windows.service.serviceHandler", + + components: { + requestHandler: { + type: "gpii.windows.service.requestHandler" + }, + requestSender: { + type: "gpii.windows.service.requestSender" + } + }, + + events: { + "onConnected": null, + "onPipeClose": null + }, + listeners: { + "onCreate.connectToService": "{that}.connectToService", + "onPipeClose": { + "funcName": "gpii.windows.service.servicePipeClosed", + "args": ["{that}", "{arguments}.0"] + }, + "onDestroy.closePipe": { + "funcName": "gpii.windows.service.closePipe", + "args": ["{that}"] + } + }, + invokers: { + connectToService: { + funcName: "gpii.windows.service.connectToService", + args: ["{that}"] + } + }, + members: { + /** true when connected */ + connected: false, + /** When the successful connection was made. */ + connectTime: null, + /** Number of connection failures. */ + failureCount: 0, + + /** + * The connection to the service. + * @type net.Socket + */ + pipe: null, + + /** + * The messaging session. + * @type Session + */ + session: null + }, + + pipePrefix: "\\\\.\\pipe\\gpii-", + /** true to reconnect if the pipe had disconnected */ + reconnect: true +}); + +/** + * Connect to the service, as specified by the GPII_SERVICE_PIPE environment variable, which is expected to be in the + * form of "pipe:". + * + * The full pipe name will then consist of: \\.\pipe\gpii-. + * + * @param {Component} that The gpii.serviceHandler instance. + * @return {Promise} Resolves when the connection is complete (and authenticated). + */ +gpii.windows.service.connectToService = function (that) { + var pipeId; + + if (process.env.GPII_SERVICE_PIPE) { + var match = process.env.GPII_SERVICE_PIPE.match(/^pipe:([^\\/\s]{1,200})\s*$/); + if (match && match[1]) { + pipeId = match[1]; + } else { + fluid.log(fluid.logLevel.WARN, "GPII_SERVICE_PIPE is badly formed: " + process.env.GPII_SERVICE_PIPE); + } + } else { + pipeId = "gpii"; + } + + var promise; + + if (pipeId) { + var pipeName = that.options.pipePrefix + pipeId; + + fluid.log(fluid.logLevel.IMPORTANT, "Service: Connecting to " + pipeName); + + // Connect to the pipe. + promise = gpii.windows.service.serviceAuthenticate(that, pipeName).then(function (data) { + that.connectTime = process.hrtime(); + that.connected = true; + + that.pipe.on("error", function (err) { + fluid.log("Service: Pipe error: ", err); + that.pipe.destroy(); + }); + + that.pipe.on("close", function () { + fluid.log("Service: Pipe closed"); + that.events.onPipeClose.fire(); + that.pipe.destroy(); + }); + + that.pipe.on("end", function () { + that.pipe.destroy(); + }); + + fluid.log("Service: Authenticated"); + + that.session = messaging.createSession(that.pipe, "gpii", that.requestHandler.handleRequest, data); + that.session.on("ready", function () { + fluid.log("Service: Ready"); + that.events.onConnected.fire(); + }); + }, that.events.onPipeClose.fire); + } else { + fluid.log("Service: Not connecting to the service."); + promise = fluid.promise(); + promise.reject(); + } + + return promise; +}; + +/** + * Authenticate with the service. + * + * @param {Component} that The gpii.serviceHandler instance. + * @param {String} pipeName The pipe name. + * @return {Promise} Resolves when the connection is complete (and authenticated), with any data that was after the + * challenge. + */ +gpii.windows.service.serviceAuthenticate = function (that, pipeName) { + var promise = fluid.promise(); + + var allData = null; + var authDone = false; + + var pipe = net.connect(pipeName, function () { + fluid.log("Service: Connected"); + }); + + pipe.on("data", function (data) { + allData = allData ? Buffer.concat([allData, data]) : data; + // There's a chance that extra data could be after the challenge, so only take what's needed. + var eol; + while ((eol = allData.indexOf("\n".charCodeAt(0))) >= 0) { + var line = allData.toString("utf8", 0, eol); + allData = allData.slice(eol + 1); + var authenticated = false; + if (line === "OK") { + // Service responds with "OK" when authenticated. + authenticated = true; + } else if (line.startsWith("challenge:") && !authDone) { + var match = line.match(/^challenge:([0-9]+|none)$/); + if (!match || !match[1]) { + promise.reject("Invalid authentication challenge: '" + line + "'"); + break; + } + var challenge = match[1]; + if (challenge !== "none") { + // Call set event, to prove this process is the pipe client. + gpii.windows.service.serviceChallenge(challenge); + } + authDone = true; + } else if (line.length > 0) { + promise.reject("Unexpected data from service: " + line); + break; + } + + if (authenticated) { + promise.resolve(allData); + break; + } + } + }); + + var pipeProblem = function (err) { + fluid.log("Service: Unable to authenticate: ", err || "Pipe closed"); + if (promise.disposition) { + pipe.destroy(); + } else { + promise.reject({ + isError: !!err, + message: err ? "Pipe error" : "Pipe closed", + error: err + }); + } + }; + + pipe.on("close", pipeProblem); + pipe.on("error", pipeProblem); + pipe.on("end", pipeProblem); + + return promise.then(function () { + that.pipe = pipe; + pipe.removeAllListeners(); + }, function (err) { + fluid.log("Service: Authentication failed:", err); + pipe.removeAllListeners(); + pipe.destroy(); + }); +}; + +/** + * Performs the authentication challenge presented by the service upon connecting. + * + * The challenge data is an event handle, with which SetEvent is called. Only this process is able to use this handle, + * and the service will know when it's been called (GPII-2399). + * + * @param {String} challenge The challenge data. + */ +gpii.windows.service.serviceChallenge = function (challenge) { + var eventHandle = parseInt(challenge); + gpii.windows.kernel32.SetEvent(eventHandle); +}; + +/** + * Closes the pipe. + * + * @param {Component} that The gpii.serviceHandler instance. + */ +gpii.windows.service.closePipe = function (that) { + if (that.pipe) { + that.pipe.end(); + } +}; + +/** + * Called when the pipe has been closed. + * + * @param {Component} that The gpii.serviceHandler instance. + */ +gpii.windows.service.servicePipeClosed = function (that) { + if (that.connected) { + that.connected = false; + } + + if (that.reconnect) { + // Throttle the retry attempts by a few seconds for each failure, unless the last connection was above 30 seconds. + var duration = that.connectTime && process.hrtime(that.connectTime); + if (duration && duration[0] > 30) { + that.failureCount = 0; + } else { + that.failureCount++; + } + + var delay = Math.min(30, that.failureCount * 5); + + fluid.log("Service: Retrying connection" + (delay ? " in " + delay + " seconds" : "")); + + setTimeout(that.connectToService, delay * 1000); + } +}; + +// Data source for the client credentials, which takes it from the secrets file via the service. +fluid.defaults("gpii.windows.service.clientCredentialsSource", { + gradeNames: ["fluid.component"], + components: { + fallbackDataSource: { + type: "gpii.accessRequester.clientCredentialDataSource.file" + } + }, + invokers: { + get: { + funcName: "gpii.windows.service.getClientCredentials", + args: ["{gpii.windows.service.serviceHandler}", "{that}.fallbackDataSource"] + } + } +}); + +/** + * Gets the client credentials from the service. + * + * @param {Component} service The service handler instance. + * @param {Component} fallbackDataSource The datasource for client credentials, if the service doesn't have any (perhaps + * due to the secrets file not being installed). + * @return {Promise} Resolves with the client credentials. + */ +gpii.windows.service.getClientCredentials = function (service, fallbackDataSource) { + var promiseTogo = fluid.promise(); + var credentialsPromise = fluid.promise(); + service.requestSender.getClientCredentials().then(function (clientCredentials) { + if (clientCredentials) { + credentialsPromise.resolve(clientCredentials); + } else { + credentialsPromise.reject({ + isError: true, + message: "Service returned no client credentials" + }); + } + }, credentialsPromise.reject); + + credentialsPromise.then(promiseTogo.resolve, function () { + // Use the fallback credentials source. + fluid.promise.follow(fallbackDataSource.get(), promiseTogo); + }); + + return promiseTogo; +}; diff --git a/gpii/node_modules/gpii-service-handler/test/serviceHandlerTests.js b/gpii/node_modules/gpii-service-handler/test/serviceHandlerTests.js new file mode 100644 index 000000000..89cd74fa1 --- /dev/null +++ b/gpii/node_modules/gpii-service-handler/test/serviceHandlerTests.js @@ -0,0 +1,312 @@ +/* + * Windows service handler tests + * + * Copyright 2017 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var fluid = require("gpii-universal"), + ffi = require("ffi-napi"), + net = require("net"); + +var jqUnit = fluid.require("node-jqunit"); +var gpii = fluid.registerNamespace("gpii"); + +fluid.registerNamespace("gpii.tests.serviceHandler"); + +process.env.GPII_SERVICE_PIPE_DISABLED = true; +require("../index.js"); + +jqUnit.module("gpii.tests.serviceHandler"); + +gpii.tests.serviceHandler.kernel32 = ffi.Library("kernel32", { + // https://msdn.microsoft.com/library/ms687032 + "WaitForSingleObject": [ + "ulong", ["uint", "ulong"] + ], + // https://msdn.microsoft.com/library/ms682396 + "CreateEventW": [ + "ulong", [ "int", "int", "int", "int" ] + ] +}); + +jqUnit.asyncTest("serviceChallenge tests", function () { + + var badHandles = [ + null, + "", + 0, + "0", + 123, + "123", + "text" + ]; + + // Try some invalid data. Nothing is expected to happen, it shouldn't crash. + badHandles.forEach(function (handle) { + fluid.log("serviceChallenge - trying: ", handle); + gpii.windows.service.serviceChallenge(handle); + }); + + // Try a real event handle. + var eventHandle = gpii.tests.serviceHandler.kernel32.CreateEventW(0, false, false, 0); + var timeout = 5000; + + gpii.tests.serviceHandler.kernel32.WaitForSingleObject.async(eventHandle, timeout, function (err, result) { + if (err) { + jqUnit.fail(err); + } else { + var WAIT_OBJECT_0 = 0; + jqUnit.assertEquals("WaitForSingleObject should return WAIT_OBJECT_0", WAIT_OBJECT_0, result); + jqUnit.start(); + } + }); + + gpii.windows.service.serviceChallenge(eventHandle); +}); + +jqUnit.asyncTest("connectToService failure tests", function () { + var tests = [ + "aa", + 123, + "a:", + ":a", + "pipe:", + "pipe:a/b", + "pipe:a\\b", + "pipe:aa bb", + "pipe:aa/bb", + "pipe:aa\nbb", + "pipe:201-valid-characters-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ]; + + jqUnit.expect(tests.length * 3 + 4); + + var serviceHandler = gpii.windows.service.serviceHandler({ + listeners: { + // Don't auto-connect. + "onCreate.connectToService": "fluid.identity" + } + }); + + var runTest = function (testIndex) { + if (testIndex >= tests.length) { + jqUnit.start(); + return; + } + + var test = tests[testIndex]; + + process.env.GPII_SERVICE_PIPE = test; + var promise = serviceHandler.connectToService(); + + jqUnit.assertNotNull("connectToService must return non-null", promise); + jqUnit.assertTrue("connectToService must return a promise", fluid.isPromise(promise)); + + promise.then(function () { + jqUnit.fail("connectToService should have rejected"); + }, function (e) { + jqUnit.assertUndefined("connectToService should reject with undefined", e); + runTest(testIndex + 1); + }); + + }; + + // Test a valid name, but doesn't exist. + process.env.GPII_SERVICE_PIPE = "pipe:test-not-exist" + Math.random().toString(36); + var promise = serviceHandler.connectToService(); + + jqUnit.assertNotNull("connectToService must return non-null", promise); + jqUnit.assertTrue("connectToService must return a promise", fluid.isPromise(promise)); + + promise.then(function () { + jqUnit.fail("connectToService should have rejected"); + }, function (e) { + jqUnit.assertTrue("connectToService should reject with an error", e.isError); + jqUnit.assertEquals("connectToService should reject with ENOENT", "ENOENT", e.error && e.error.code); + runTest(0); + }); + +}); + +jqUnit.asyncTest("connectToService tests", function () { + + var tests = [ + { + data: "challenge:none\nOK\n", + expect: "resolve" + }, + { + data: "challenge:1\nOK\n", + expect: "resolve" + }, + { + data: "challenge:2\nOK\n", + expect: "resolve" + }, + { + data: "challenge:A\nOK\n", + expect: "reject" + }, + { + data: "challenge:1\nchallenge:2\nOK\n", + expect: "reject" + }, + { + data: "stupid value\n", + expect: "reject" + }, + { + data: "OK\n", + expect: "resolve" + }, + { + data: null, + disconnect: true, + expect: "reject" + }, + { + data: "challenge:1\n", + disconnect: true, + expect: "reject" + }, + { + data: "challenge:none\n", + disconnect: true, + expect: "reject" + }, + { + data: "challenge:none\nOK\n", + disconnect: true, + expect: "resolve" + } + ]; + + var serviceHandler = gpii.windows.service.serviceHandler({ + listeners: { + // Don't auto-connect. + "onCreate.connectToService": "fluid.identity" + }, + reconnect: false + }); + + var pipeIdPrefix = "test-connectToService-" + Math.random().toString(36); + + var runTest = function (testIndex) { + if (testIndex >= tests.length) { + jqUnit.start(); + return; + } + + var test = tests[testIndex]; + + var pipeId = pipeIdPrefix + testIndex; + var pipeName = serviceHandler.options.pipePrefix + pipeId; + + var pipeServer = net.createServer(); + + var closed = 0; + + pipeServer.on("error", jqUnit.fail); + + pipeServer.listen(pipeName, function () { + process.env.GPII_SERVICE_PIPE = "pipe:" + pipeId; + + var promise = serviceHandler.connectToService(); + + jqUnit.assertNotNull("connectToService must return non-null", promise); + jqUnit.assertTrue("connectToService must return a promise", fluid.isPromise(promise)); + + promise.then(function () { + jqUnit.assertEquals("connectToService must only resolve if expected", test.expect, "resolve"); + runTest(testIndex + 1); + }, function () { + jqUnit.assertEquals("connectToService must only reject if expected", test.expect, "reject"); + // Wait for the pipe to close. + gpii.windows.waitForCondition(function () { + return closed; + }, { + timeout: 5000, + dontReject: true + }).then(function (value) { + jqUnit.assertNotEquals("connectToService should have closed the pipe", "timeout", value); + runTest(testIndex + 1); + }, jqUnit.fail); + }); + }); + + pipeServer.on("connection", function (pipe) { + fluid.log("pipeServer.connection"); + + pipe.on("data", function (data) { + fluid.log("pipeServer.data", data.toString()); + }); + + pipe.on("close", function () { + closed = true; + }); + + if (test.data) { + fluid.log("Sending ", test.data.replace(/\n/g, "\\n")); + pipe.write(test.data); + } + + if (test.disconnect) { + fluid.log("Disconnecting"); + pipe.destroy(); + } + }); + }; + + runTest(0); +}); + +jqUnit.test("status request tests", function () { + + var requestHandler = gpii.windows.service.requestHandler(); + + var result = requestHandler.status(); + + jqUnit.assertTrue("status should return an object", fluid.isPlainObject(result)); + jqUnit.assertTrue("status should be 'running'", result.isRunning); + + requestHandler.destroy(); +}); + +jqUnit.test("shutdown request tests", function () { + + var requestHandler = gpii.windows.service.requestHandler(); + + var sendMessageCalled = false; + + // Mock the sendMessage, instead of sending the real shutdown message. + var sendMessageOrig = gpii.windows.messages.sendMessage; + gpii.windows.messages.sendMessage = function (window, msg) { + jqUnit.assertEquals("sendMessage should be called with the correct window", "gpii-message-window", window); + jqUnit.assertEquals("sendMessage should be called with the correct message", + gpii.windows.API_constants.WM_QUERYENDSESSION, msg); + sendMessageCalled = true; + }; + + try { + + requestHandler.shutdown(); + jqUnit.assertTrue("sendMessage should have been invoked", sendMessageCalled); + } finally { + gpii.windows.messages.sendMessage = sendMessageOrig; + } +}); diff --git a/gpii/node_modules/userListeners/index.js b/gpii/node_modules/userListeners/index.js index 61b857d35..a15b441e1 100644 --- a/gpii/node_modules/userListeners/index.js +++ b/gpii/node_modules/userListeners/index.js @@ -22,3 +22,4 @@ require("./src/userListeners.js"); require("./src/pcsc.js"); require("./src/proximity.js"); require("./src/usb.js"); +require("./src/windowsLogin.js"); diff --git a/gpii/node_modules/userListeners/src/userListeners.js b/gpii/node_modules/userListeners/src/userListeners.js index 6d379de92..e9062f13b 100644 --- a/gpii/node_modules/userListeners/src/userListeners.js +++ b/gpii/node_modules/userListeners/src/userListeners.js @@ -27,6 +27,21 @@ fluid.defaults("gpii.userListeners.windows", { components: { proximity: { type: "gpii.windows.userListeners.proximity" + }, + windowsLogin: { + type: "gpii.windows.userListeners.windowsLogin", + options: { + events: { + onListenersStart: { + events: { + onListenersStart: "{userListeners}.events.onListenersStart", + onServiceReady: "{service}.events.onServiceReady", + onSessionStart: "{lifecycleManager}.events.onSessionStart" + } + }, + onListenersStop: "{userListeners}.events.onListenersStop" + } + } } } }); diff --git a/gpii/node_modules/userListeners/src/windowsLogin.cs b/gpii/node_modules/userListeners/src/windowsLogin.cs new file mode 100644 index 000000000..7695151c2 --- /dev/null +++ b/gpii/node_modules/userListeners/src/windowsLogin.cs @@ -0,0 +1,15 @@ +using System.Threading.Tasks; +using System.Security.Principal; + +public class WindowsLogin +{ + /// + /// Invoked by node, to get the current user's SID. + /// + /// Not used + /// The user's SID + public async Task GetUserSid(dynamic input) + { + return WindowsIdentity.GetCurrent().User.Value; + } +} diff --git a/gpii/node_modules/userListeners/src/windowsLogin.js b/gpii/node_modules/userListeners/src/windowsLogin.js new file mode 100644 index 000000000..be1e32b66 --- /dev/null +++ b/gpii/node_modules/userListeners/src/windowsLogin.js @@ -0,0 +1,312 @@ +/* Windows Login user listener. + * + * Copyright 2017 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var fluid = require("gpii-universal"), + path = require("path"), + fs = require("fs"), + os = require("os"), + edge = process.versions.electron ? require("electron-edge-js") : require("edge-js"); + +var gpii = fluid.registerNamespace("gpii"); + +// The windows-login user listener +fluid.defaults("gpii.windows.userListeners.windowsLogin", { + gradeNames: ["fluid.component", "gpii.userListener"], + members: { + listenerName: "windows-login", + environmental: true + }, + invokers: { + startListener: { + funcName: "gpii.windows.userListeners.startWindowsLogin", + args: ["{that}"] + }, + stopListener: "fluid.identity", + getGpiiKey: { + funcName: "gpii.windows.userListeners.getGpiiKey", + args: ["{that}", "{serviceHandler}.requestSender.sign"] + }, + getUserId: { + funcName: "gpii.windows.userListeners.getUserId", + args: ["{that}.options.config.userIdSource"] + }, + checkBlockedUser: { + funcName: "gpii.windows.userListeners.checkBlockedUser", + args: ["{that}.options.config.blockedUsers", "{arguments}.0"] + } + }, + config: { + // Windows user account names for which auto-login is disabled [GPII-4000] + blockedUsers: [], + userIdSource: undefined + } +}); + +/** + * Determines if a given user name matches one of the blocked users, for which auto-login is disabled. + * + * @param {Array} blockedUsers The array of blocked users. + * @param {String} username The user name to check. + * @return {Boolean} true if the username is blocked, and auto-login should not be performed. + */ +gpii.windows.userListeners.checkBlockedUser = function (blockedUsers, username) { + var blocked = fluid.makeArray(blockedUsers).some(function (pattern) { + return gpii.windows.userListeners.wildMatch(username, pattern); + }); + + if (blocked) { + fluid.log("Local user account '" + username + "' is blocked from using the windows login user listener"); + } + + return blocked; +}; + +/** + * Matches text against a pattern with '*' and '?' wildcards. + * @param {String} text The text to match. + * @param {String} pattern The pattern to match against. + * @return {Boolean} true if text matches the pattern. + */ +gpii.windows.userListeners.wildMatch = function (text, pattern) { + var textOffset = 0; + var patternOffset = 0; + var match = true; + + if (text === pattern) { + textOffset = text.length; + patternOffset = pattern.length; + } + + while (match && textOffset < text.length) { + switch (pattern[patternOffset]) { + case "?": + // '?' always matches the current character + break; + case "*": + // Skip multiple *'s + while (pattern[patternOffset] === "*") { + patternOffset++; + } + if (patternOffset >= pattern.length) { + // Pattern ends with a *, remainder matches. + patternOffset = pattern.length - 1; + textOffset = text.length - 1; + break; + } + + // Try to match the remaining text, by skipping to the next character until a match is made. + while (textOffset < text.length) { + match = gpii.windows.userListeners.wildMatch(text.substr(textOffset), pattern.substr(patternOffset)); + if (match) { + break; + } else { + textOffset++; + } + } + break; + default: + // Literal in the pattern must match the one in the text. + match = pattern[patternOffset] === text[textOffset]; + } + + // Move to the next character + patternOffset++; + textOffset++; + } + + // Ignore *'s at the end, they match zero characters + while (pattern[patternOffset] === "*") { + patternOffset++; + } + + return match && patternOffset === pattern.length; +}; + +/** + * Attempts a login, using the current Windows user account. + * + * @param {Component} that The gpii.windows.userListeners.windowsLogin instance. + * @return {Promise} Resolves when the listener has started. + */ +gpii.windows.userListeners.startWindowsLogin = function (that) { + var promise; + var username = os.userInfo().username; + var blocked = that.checkBlockedUser(username); + if (blocked) { + promise = fluid.promise().reject("blocked"); + } else { + promise = that.getGpiiKey().then(function (gpiiKey) { + that.events.onTokenArrive.fire(that, gpiiKey); + }); + } + return promise; +}; + +/** + * Function to sign some data. + * + * @callback windowsLogin.sign + * @param {String|Buffer} payload The data to sign. + * @param {String} keyName Key identifier (field name in the service's secrets file). + * @return {Promise} Resolves with the signed digest, as a 256bit hex string. + */ + +/** + * Generates a gpiiKey based on either the current Windows user, or a custom registry value or file. + * + * The source of the user identifier, userIdSource, is specified in the site-config. This is to cater for deployments + * where multiple users use the same Windows account (access is granted via membership card, for example). + * + * It can be one of: + * - "disable": to disable auto-login completely. + * - "username" (default): The name of the current user. + * - "userid": The current user's SID. + * - "reg:..." Path to a registry value containing the user ID. + * - "file:..." A path to a file, whose content is the user ID (only the first line is taken). + * + * The key is generated using the signed digest of the user's ID. The site's domain (stored in the service's secrets + * file) is used to ensure gpii keys are unique across different deployments. + * + * @param {Component} that The gpii.windows.userListeners.windowsLogin instance. + * @param {windowsLogin.sign} sign The function used to sign the user id. + * @return {Promise} Resolves with the gpiiKey for the current user. + */ +gpii.windows.userListeners.getGpiiKey = function (that, sign) { + var promise = fluid.promise(); + + var userId = that.getUserId(); + + fluid.log("Auto-login user id: " + userId); + + if (userId && !that.checkBlockedUser(userId)) { + sign(userId, "site").then(function (digest) { + // Truncate the digest into a guid. + var result = gpii.windows.userListeners.hexToGuid(digest); + promise.resolve(result); + }, promise.reject); + } else { + promise.reject("Unable to acquire the current user id"); + } + return promise; +}; + +/** + * Gets the user ID, via the given source. + * + * @param {String} userIdSource A string identifying the location from which to retrieve a unique user identifier. + * @return {String} The user ID - null if unknown. + */ +gpii.windows.userListeners.getUserId = function (userIdSource) { + var userId; + var source = (userIdSource || "username").toString().toLowerCase(); + + if ((source === "disable") || (source === "disabled")) { + // Disabled + fluid.log("auto-login is disabled by the site-config."); + userId = null; + } else if (source === "username") { + // The Windows account name + userId = os.userInfo().username; + } else if (source === "userid") { + // The User's SID + userId = gpii.windows.getUserSid(); + } else if (source.startsWith("reg:")) { + // A registry location + var match = /^reg:\\*(HK[A-Z_]+)\\(.*)\\([^\/]+)$/i.exec(userIdSource.replace("/", "\\")); + var baseKey = match[1]; + var keyPath = match[2]; + var valueName = match[3]; + if (baseKey.length <= 4) { + baseKey = ({ + HKLM: "HKEY_LOCAL_MACHINE", + HKCU: "HKEY_CURRENT_USER", + HKCR: "HKEY_CLASSES_ROOT", + HKU: "HKEY_USERS" + })[baseKey]; + } + + userId = gpii.windows.readRegistryKey(baseKey, keyPath, valueName, "REG_SZ").value; + if (!userId && !(keyPath.startsWith("64:") || keyPath.startsWith("32:"))) { + // try the other-bit registry + keyPath = (os.arch() === "x64" ? "32:" : "64:") + keyPath; + userId = gpii.windows.readRegistryKey(baseKey, keyPath, valueName, "REG_SZ").value; + } + } else if (source.startsWith("file:")) { + // A file + var path = source.substr(5); + // Just use the first line of the file. + try { + var content = fs.readFileSync(path, "utf8"); + userId = content && content.trim().substr(0, 0xff).split(/[\r\n]/, 1)[0]; + } catch (e) { + fluid.log("Could not read the auto-login user from " + path + ":", e); + userId = null; + } + } else { + fluid.log("auto-login has an unknown userId source of '" + userIdSource + "'"); + userId = null; + } + + return (userId !== "" && userId) || null; + +}; + +/** + * Mash a hex string (of at least 32 characters) into a GUID. + * @param {String} hexString The input hex string. + * @return {String} A guid, based on the given hex string. + */ +gpii.windows.userListeners.hexToGuid = function (hexString) { + var result; + if (hexString.length < 32) { + fluid.fail("hexToGuid wants a longer string"); + } else { + result = [ + hexString.substr(0, 8), + hexString.substr(8, 4), + hexString.substr(12, 4), + hexString.substr(16, 4), + hexString.substr(20, 12) + ].join("-"); + } + + return result; +}; + +/** + * Gets the current user's SID (security identifier) as a string. This is the unique identifier of a user on a domain. + * For example: "S-1-5-21-2284820620-4150533183-3809465755-1001" + * + * @return {String} The SID of the currently logged in user. + */ +gpii.windows.getUserSid = function () { + // Lazily initialise the .NET function, to save having to compile the assembly on start-up as it might not be used. + if (!gpii.windows.getUserSidImpl) { + gpii.windows.getUserSidImpl = edge.func({ + source: path.join(__dirname, "windowsLogin.cs"), + typeName: "WindowsLogin", + methodName: "GetUserSid", + references: [ + "System.Runtime.dll" + ] + }); + } + + return gpii.windows.getUserSidImpl(null, true); +}; diff --git a/gpii/node_modules/userListeners/test/all-tests.js b/gpii/node_modules/userListeners/test/all-tests.js index 3e42ea7d6..b93d68ed5 100644 --- a/gpii/node_modules/userListeners/test/all-tests.js +++ b/gpii/node_modules/userListeners/test/all-tests.js @@ -23,6 +23,7 @@ var fluid = require("gpii-universal"), kettle.loadTestingSupport(); +require("./windowsLoginTests.js"); require("./pcscTests.js"); require("./proximityTests.js"); require("./usbTests.js"); diff --git a/gpii/node_modules/userListeners/test/windowsLoginTests.js b/gpii/node_modules/userListeners/test/windowsLoginTests.js new file mode 100644 index 000000000..87dde9674 --- /dev/null +++ b/gpii/node_modules/userListeners/test/windowsLoginTests.js @@ -0,0 +1,321 @@ +/* + * Tests for the windows login user listener. + * + * Copyright 2017 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var fluid = require("gpii-universal"), + os = require("os"), + path = require("path"), + fs = require("fs"), + child_process = require("child_process"); + +var jqUnit = fluid.require("node-jqunit"); +var gpii = fluid.registerNamespace("gpii"); + +fluid.registerNamespace("gpii.tests.userListener"); + +require("../index.js"); + +var teardowns = []; + +jqUnit.module("gpii.tests.userListener.windowsLogin", { + teardown: function () { + while (teardowns.length) { + teardowns.pop()(); + } + } +}); + +fluid.defaults("gpii.tests.userListener.windowsLogin", { + gradeNames: ["fluid.component", "gpii.windows.userListeners.windowsLogin"], + listeners: { + "onTokenArrive.callFlowManager": "fluid.identity", + "onTokenRemove.callFlowManager": "fluid.identity" + }, + distributeOptions: { + service: { + record: "gpii.test.userListeners.windowsLoginService", + target: "{/ gpii.userListeners.windows}.options.gradeNames" + } + } +}); + +// Give the user listeners an "onServiceReady" event. +fluid.defaults("gpii.test.userListeners.windowsLoginService", { + gradeNames: ["fluid.component"], + components: { + service: { + type: "fluid.component", + options: { + events: { + "onServiceReady": null + } + } + } + } +}); + +jqUnit.asyncTest("testing getting the user's SID", function () { + + jqUnit.expect(5); + + // User SIDs begin with this. + var sidPrefix = "S-1-5-21-"; + var sid = gpii.windows.getUserSid(); + + jqUnit.assertEquals("getUserSid should return a string", "string", typeof(sid)); + jqUnit.assertTrue("return from getUserSid should look like an SID", sid.startsWith(sidPrefix)); + + var sid2 = gpii.windows.getUserSid(); + var sid3 = gpii.windows.getUserSid(); + jqUnit.assertEquals("getUserSid should always return the same value", sid, sid2); + jqUnit.assertEquals("getUserSid should always return the same value (again)", sid, sid3); + + // Compare it to the value from the "whoami" command. + child_process.exec("%SystemRoot%\\System32\\whoami.exe /user", function (err, stdout, stderr) { + if (err) { + jqUnit.fail(err); + } + fluid.log("whoami:", stdout, stderr); + jqUnit.assertTrue("SID should match the whoami command output", stdout.trim().endsWith(sid)); + jqUnit.start(); + }); +}); + +jqUnit.test("testing hexToGuid", function () { + + jqUnit.expect(2); + + var expect = "01234567-89ab-cdef-0123-456789abcdef"; + + var guid1 = gpii.windows.userListeners.hexToGuid("0123456789abcdef0123456789abcdef"); + jqUnit.assertEquals("hex string should produce the expected GUID (32 chars)", expect, guid1); + + var guid2 = + gpii.windows.userListeners.hexToGuid("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); + jqUnit.assertEquals("hex string should produce the expected GUID (over 32 chars)", expect, guid2); + + jqUnit.expectFrameworkDiagnostic("hexToGuid should fails with a short string (16 chars)", function () { + gpii.windows.userListeners.hexToGuid("0123456789abcdef"); + }, "hexToGuid wants a longer string"); +}); + +jqUnit.test("Testing getUserId", function () { + + // Windows Account SID + var userIdResult = gpii.windows.userListeners.getUserId("userid"); + jqUnit.assertEquals("getUserId(userid) should return the user's SID", gpii.windows.getUserSid(), userIdResult); + + // Windows username + var usernameResult = gpii.windows.userListeners.getUserId("username"); + jqUnit.assertEquals("getUserId(username) should return the user's username", os.userInfo().username, usernameResult); + + // File content + var testFile = path.join(os.tmpdir(), "gpii-username-test" + Math.random()); + teardowns.push(function () { + fs.unlinkSync(testFile); + }); + + var expectFileResult = "test-userid" + Math.random(); + fs.writeFileSync(testFile, expectFileResult); + + var fileResult = gpii.windows.userListeners.getUserId("file:" + testFile); + jqUnit.assertEquals("getUserId(file) should return the correct value", expectFileResult, fileResult); + + // File content (no file) + var noFileResult = gpii.windows.userListeners.getUserId("file:c:\\not\\exists"); + jqUnit.assertEquals("getUserId(no file) should return the correct value", null, noFileResult); + + // Registry value + var baseKey = "HKEY_CURRENT_USER"; + var subKey = "Software\\gpii-temp"; + var valueName = "username-test"; + var userValue = "test-userid" + Math.random(); + gpii.windows.writeRegistryKey(baseKey, subKey, valueName, userValue, "REG_SZ"); + teardowns.push(function () { + if (subKey.endsWith("gpii-temp")) { + gpii.windows.deleteRegistryKey(baseKey, subKey); + } + }); + + var regResult = gpii.windows.userListeners.getUserId("reg:" + baseKey + "\\" + subKey + "\\" + valueName); + jqUnit.assertEquals("getUserId(file) should return the correct value", userValue, regResult); + + // Abbreviated base key + var regResult2 = gpii.windows.userListeners.getUserId("reg:" + "HKCU\\" + subKey + "\\" + valueName); + jqUnit.assertEquals("getUserId(file) should return the correct value", userValue, regResult2); + + // Non existing registry key + var regResult3 = gpii.windows.userListeners.getUserId("reg:" + "HKCU\\gpii\\does\\not\\exist"); + jqUnit.assertEquals("getUserId(file) should return the correct value", null, regResult3); +}); + +jqUnit.test("testing blocked user name matching", function () { + var tests = [ + { + blockedUsers: [], + usernames: { + "empty1": false, + "empty2": false + } + }, + { + blockedUsers: [ "one", "*two", "three*", "*four*" ], + usernames: { + "one": true, + "two": true, + "three": true, + "four": true, + + "a_one": false, + "a_two": true, + "a_three": false, + "a_four": true, + + "one_b": false, + "two_b": false, + "three_b": true, + "four_b": true, + + "a_one_b": false, + "a_two_b": false, + "a_three_b": false, + "a_four_b": true, + + "xyz": false + } + }, + { + blockedUsers: [ "one*two", "*three*four", "five*six*", "*seven*eight*" ], + usernames: { + "one_x_two": true, + "three_x_four": true, + "five_x_six": true, + "seven_x_eight": true, + + "a_one_x_two": false, + "a_three_x_four": true, + "a_five_x_six": false, + "a_seven_x_eight": true, + + "one_x_two_b": false, + "three_x_four_b": false, + "five_x_six_b": true, + "seven_x_eight_b": true, + + "a_one_x_two_b": false, + "a_three_x_four_b": false, + "a_five_x_six_b": false, + "a_seven_x_eight_b": true + } + }, + { + blockedUsers: [ "on?", "t?o", "?hree", "f?ur", "?iv?", "si?*", "s?v*" ], + usernames: { + "one": true, + "two": true, + "three": true, + "four": true, + "five": true, + "six": true, + "seven": true, + "eight": false + } + } + ]; + + fluid.each(tests, function (test) { + fluid.each(test.usernames, function (expected, username) { + var result = gpii.windows.userListeners.checkBlockedUser(test.blockedUsers, username); + jqUnit.assertEquals("blockedUser " + username, expected, result); + }); + }); +}); + +jqUnit.asyncTest("testing blocked local accounts", function () { + // Create an instance with the current user being a blocked user + var windowsLogin = gpii.tests.userListener.windowsLogin({ + config: { + blockedUsers: [os.userInfo().username] + } + }); + + jqUnit.expect(2); + windowsLogin.getGpiiKey = function () { + return fluid.promise().resolve("getGpiiKey"); + }; + var blockedPromise = gpii.windows.userListeners.startWindowsLogin(windowsLogin); + + jqUnit.assertTrue("startWindowsLogin should return a promise", fluid.isPromise(blockedPromise)); + + blockedPromise.then(function () { + jqUnit.fail("startWindowsLogin should not resolve"); + }, function (reason) { + jqUnit.assertEquals("startWindowsLogin should reject with 'blocked'", "blocked", reason); + jqUnit.start(); + }); +}); + +jqUnit.asyncTest("testing getGpiiKey", function () { + var windowsLogin = gpii.tests.userListener.windowsLogin({ + config: { + userIdSource: "userid" + } + }); + + jqUnit.expect(2); + + var sid = gpii.windows.getUserSid(); + var expectedKey = "01234567-89ab-cdef-0123-456789abcdef"; + + var sign = function (payload) { + jqUnit.assertEquals("signing function should be called with the current SID", sid, payload); + return fluid.toPromise("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); + }; + + gpii.windows.userListeners.getGpiiKey(windowsLogin, sign).then(function (key) { + jqUnit.assertEquals("getGpiiKey should resolve with the expected key", expectedKey, key); + jqUnit.start(); + }); +}); + +jqUnit.asyncTest("testing getGpiiKey, with blocked user id", function () { + var testFile = path.join(os.tmpdir(), "gpii-username-test" + Math.random()); + teardowns.push(function () { + fs.unlinkSync(testFile); + }); + fs.writeFileSync(testFile, "blocked-user"); + + + var windowsLogin = gpii.tests.userListener.windowsLogin({ + config: { + userIdSource: "file:" + testFile, + blockedUsers: [ "blocked-*" ] + } + }); + + jqUnit.expect(0); + + var sign = function () { + jqUnit.fail("sign function should not be called for a blocked user id"); + }; + + gpii.windows.userListeners.getGpiiKey(windowsLogin, sign).then(function () { + jqUnit.fail("getGpiiKey should not resolve"); + }, jqUnit.start()); +}); diff --git a/index.js b/index.js index 739810906..efd5ed6d4 100644 --- a/index.js +++ b/index.js @@ -48,5 +48,7 @@ require("./gpii/node_modules/wmiSettingsHandler"); require("./gpii/node_modules/gpii-localisation"); require("./gpii/node_modules/gpii-office"); require("./gpii/node_modules/gpii-userInput"); +require("./gpii/node_modules/gpii-service-handler"); +require("./gpii/node_modules/gpii-iod"); module.exports = fluid; diff --git a/package.json b/package.json index cf31b5880..224be01f4 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "homepage": "http://gpii.net/", "scripts": { "pretest": "node node_modules/rimraf/bin.js coverage/* reports/*", + "service": "npm --prefix ./gpii-service/ run service-dev", + "start": "npm run service && node ./gpii.js", "test": "npm run test:refreshenv && npm run test:unit && npm run test:acceptance", "test:refreshenv": "refreshenv", "test:acceptance": "set GPII_TEST_COUCH_USE_EXTERNAL=TRUE && nyc node.exe ./tests/AcceptanceTests.js builtIn", @@ -17,7 +19,7 @@ "dependencies": { "edge-js": "10.3.1", "ffi-napi": "2.4.3", - "gpii-universal": "JavierJF/universal#GPII-3810", + "gpii-universal": "stegru/universal#GPII-2971", "@pokusew/pcsclite": "0.4.18", "ref": "1.3.4", "ref-struct": "1.1.0", diff --git a/provisioning/NpmInstall.ps1 b/provisioning/NpmInstall.ps1 index b739a235a..b9430eb51 100755 --- a/provisioning/NpmInstall.ps1 +++ b/provisioning/NpmInstall.ps1 @@ -23,3 +23,10 @@ Invoke-Command $msbuild "VolumeControl.sln /p:Configuration=Release /p:Platform= $testProcessHandlingDir = Join-Path $rootDir "gpii\node_modules\processHandling\test" $csc = Join-Path -Path (Split-Path -Parent $msbuild) csc.exe Invoke-Command $csc "/target:exe /out:test-window.exe test-window.cs" $testProcessHandlingDir + +# Build the Windows Service +$serviceDir = Join-Path $rootDir "gpii-service" +# Build in its own drive to avoid a long pathname, and isolate from any node_modules in a parent. +subst o: $serviceDir +Invoke-Command "npm" "install" o:\ +subst /d o: diff --git a/tests/UnitTests.js b/tests/UnitTests.js index 79735853c..89d20d4b3 100644 --- a/tests/UnitTests.js +++ b/tests/UnitTests.js @@ -35,3 +35,4 @@ require("../gpii/node_modules/windowsUtilities/test/WindowsUtilitiesTests.js"); require("../gpii/node_modules/wmiSettingsHandler/test/testWmiSettingsHandler.js"); require("../gpii/node_modules/gpii-office/test/ribbonTest.js"); require("../gpii/node_modules/gpii-userInput/test/testSendKeys.js"); +require("../gpii-service/tests/all-tests.js");