Add support for Windows

This commit is contained in:
Joran Dirk Greef 2016-07-15 17:13:03 +02:00
parent fd775d735c
commit a57487d444
7 changed files with 239 additions and 23 deletions

View file

@ -1,14 +1,18 @@
# sudo-prompt # sudo-prompt
Run a non-graphical terminal command using `sudo`, prompting the user with a graphical OS dialog if necessary. Useful for background Node.js applications or native Electron apps that need sudo. Run a non-graphical terminal command using `sudo`, prompting the user with a graphical OS dialog if necessary. Useful for background Node.js applications or native Electron apps that need `sudo`.
![A sudo prompt on OS X for an app called "Ronomon"](./osx.png) ## Cross-Platform
`sudo-prompt` provides a native OS dialog prompt on **OS X**, **Linux** and **Windows**.
`sudo-prompt` provides a native OS dialog prompt on **OS X** and **Linux** with custom name and optional icon. ![OS X](./osx.png)
`sudo-prompt` has no external dependencies and does not require any native bindings. ![Linux](./linux.png)
![Windows](./windows.png)
## Installation ## Installation
`sudo-prompt` has no external dependencies and does not require any native bindings.
``` ```
npm install sudo-prompt npm install sudo-prompt
``` ```
@ -18,8 +22,8 @@ Note: Your command should not start with the `sudo` prefix.
``` ```
var sudo = require('sudo-prompt'); var sudo = require('sudo-prompt');
var options = { var options = {
name: 'Ronomon', name: 'Electron',
icns: '/path/to/icns/file', // (optional) icns: '/Applications/Electron.app/Contents/Resources/Electron.icns', // (optional)
}; };
sudo.exec('echo hello', options, function(error, stdout, stderr) {}); sudo.exec('echo hello', options, function(error, stdout, stderr) {});
``` ```
@ -33,6 +37,8 @@ On OS X, `sudo-prompt` should behave just like the `sudo` command in the shell.
On Linux, `sudo-prompt` will use either `pkexec` or `kdesudo` to show the password prompt and run your command. Where possible, `sudo-prompt` will try and get these to mimic `sudo`. Depending on which binary is used, and due to the limitations of some binaries, the name of your program or the command itself may be displayed to your user. Passing `options.icns` is currently not supported by `sudo-prompt` on Linux. Patches are welcome to add support for icons based on `polkit`. On Linux, `sudo-prompt` will use either `pkexec` or `kdesudo` to show the password prompt and run your command. Where possible, `sudo-prompt` will try and get these to mimic `sudo`. Depending on which binary is used, and due to the limitations of some binaries, the name of your program or the command itself may be displayed to your user. Passing `options.icns` is currently not supported by `sudo-prompt` on Linux. Patches are welcome to add support for icons based on `polkit`.
On Windows, `sudo-prompt` will elevate your command using User Account Control (UAC). Passing `options.name` or `options.icns` is currently not supported by `sudo-prompt` on Windows.
## Non-graphical terminal commands only ## Non-graphical terminal commands only
Just as you should never use `sudo` to launch any graphical applications, you should never use `sudo-prompt` to launch any graphical applications. Doing so could cause files in your home directory to become owned by root. `sudo-prompt` is explicitly designed to launch non-graphical terminal commands. For more information, [read this post](http://www.psychocats.net/ubuntu/graphicalsudo). Just as you should never use `sudo` to launch any graphical applications, you should never use `sudo-prompt` to launch any graphical applications. Doing so could cause files in your home directory to become owned by root. `sudo-prompt` is explicitly designed to launch non-graphical terminal commands. For more information, [read this post](http://www.psychocats.net/ubuntu/graphicalsudo).
@ -42,7 +48,7 @@ On systems where the user has opted to have `tty-tickets` enabled, each call to
You should never rely on `sudo-prompt` to execute your calls in order. If you need to enforce ordering of calls, then you should explicitly order your calls in your application. Where your commands are short-lived, you should queue your calls to `exec()` to make sure your user is not overloaded with password prompts. You should never rely on `sudo-prompt` to execute your calls in order. If you need to enforce ordering of calls, then you should explicitly order your calls in your application. Where your commands are short-lived, you should queue your calls to `exec()` to make sure your user is not overloaded with password prompts.
## Invalidating the timestamp ## Invalidating the timestamp
You can invalidate the user's `sudo` timestamp file to force the prompt to appear by running the following command in your terminal: On OS X and Linux, you can invalidate the user's `sudo` timestamp file to force the prompt to appear by running the following command in your terminal:
```sh ```sh
$ sudo -k $ sudo -k

212
index.js
View file

@ -9,6 +9,8 @@ var Node = {
}; };
function Attempt(instance, end) { function Attempt(instance, end) {
var platform = Node.process.platform;
if (platform === 'win32') return Windows(instance, end);
// The -n (non-interactive) option prevents sudo from prompting the user for // The -n (non-interactive) option prevents sudo from prompting the user for
// a password. If a password is required, sudo will return an error and exit. // a password. If a password is required, sudo will return an error and exit.
var command = []; var command = [];
@ -23,7 +25,6 @@ function Attempt(instance, end) {
Node.child.exec(command, Node.child.exec(command,
function(error, stdout, stderr) { function(error, stdout, stderr) {
if (/sudo: /i.test(stderr)) { if (/sudo: /i.test(stderr)) {
var platform = Node.process.platform;
if (platform === 'linux') { if (platform === 'linux') {
return Linux(instance, end); return Linux(instance, end);
} else if (platform === 'darwin') { } else if (platform === 'darwin') {
@ -97,7 +98,7 @@ function Exec() {
} }
} }
var platform = Node.process.platform; var platform = Node.process.platform;
if (platform !== 'darwin' && platform !== 'linux') { if (platform !== 'darwin' && platform !== 'linux' && platform !== 'win32') {
return end(new Error('Platform not yet supported.')); return end(new Error('Platform not yet supported.'));
} }
var instance = { var instance = {
@ -169,10 +170,6 @@ function Mac(instance, callback) {
UUID(instance, UUID(instance,
function(error, uuid) { function(error, uuid) {
if (error) return callback(error); if (error) return callback(error);
if (!uuid || typeof uuid !== 'string' || uuid.length !== 32) {
// This is critical to ensure we don't remove the wrong temp directory.
return callback(new Error('Expected a valid UUID.'));
}
instance.uuid = uuid; instance.uuid = uuid;
instance.path = Node.path.join( instance.path = Node.path.join(
temp, temp,
@ -184,7 +181,7 @@ function Mac(instance, callback) {
function(errorRemove) { function(errorRemove) {
if (error) return callback(error); if (error) return callback(error);
if (errorRemove) return callback(errorRemove); if (errorRemove) return callback(errorRemove);
callback(error, stdout, stderr); callback(undefined, stdout, stderr);
} }
); );
} }
@ -338,11 +335,20 @@ function MacResult(instance, end) {
} }
function Remove(path, end) { function Remove(path, end) {
if (!path) return end(new Error('Remove: Path not defined.')); if (typeof path !== 'string' || !path.trim()) {
return end(new Error('Argument path not defined.'));
}
var command = []; var command = [];
if (Node.process.platform === 'win32') {
if (/"/.test(path)) {
return end(new Error('Argument path cannot contain double-quotes.'));
}
command.push('rmdir /s /q "' + path + '"');
} else {
command.push('/bin/rm'); command.push('/bin/rm');
command.push('-rf'); command.push('-rf');
command.push('"' + EscapeDoubleQuotes(Node.path.normalize(path)) + '"'); command.push('"' + EscapeDoubleQuotes(Node.path.normalize(path)) + '"');
}
command = command.join(' '); command = command.join(' ');
Node.child.exec(command, end); Node.child.exec(command, end);
} }
@ -356,7 +362,12 @@ function UUID(instance, end) {
hash.update(instance.options.name); hash.update(instance.options.name);
hash.update(instance.command); hash.update(instance.command);
hash.update(random); hash.update(random);
end(undefined, hash.digest('hex').slice(-32)); var uuid = hash.digest('hex').slice(-32);
if (!uuid || typeof uuid !== 'string' || uuid.length !== 32) {
// This is critical to ensure we don't remove the wrong temp directory.
return end(new Error('Expected a valid UUID.'));
}
end(undefined, uuid);
} }
); );
} }
@ -370,6 +381,189 @@ function ValidName(string) {
return true; return true;
} }
function Windows(instance, callback) {
var temp = Node.os.tmpdir();
if (!temp) return callback(new Error('os.tmpdir() not defined.'));
UUID(instance,
function(error, uuid) {
if (error) return callback(error);
instance.uuid = uuid;
instance.path = Node.path.join(temp, instance.uuid);
if (/"/.test(instance.path)) {
// We expect double quotes to be reserved on Windows.
// Even so, we test for this and abort if they are present.
return callback(
new Error('instance.path cannot contain double-quotes.')
);
}
instance.pathElevate = Node.path.join(instance.path, 'elevate.vbs');
instance.pathExecute = Node.path.join(instance.path, 'execute.bat');
instance.pathCommand = Node.path.join(instance.path, 'command.bat');
instance.pathStdout = Node.path.join(instance.path, 'stdout');
instance.pathStderr = Node.path.join(instance.path, 'stderr');
instance.pathStatus = Node.path.join(instance.path, 'status');
Node.fs.mkdir(instance.path,
function(error) {
if (error) return callback(error);
function end(error, stdout, stderr) {
Remove(instance.path,
function(errorRemove) {
if (error) return callback(error);
if (errorRemove) return callback(errorRemove);
callback(undefined, stdout, stderr);
}
);
}
WindowsWriteElevateScript(instance,
function(error) {
if (error) return end(error);
WindowsWriteExecuteScript(instance,
function(error) {
if (error) return end(error);
WindowsWriteCommandScript(instance,
function(error) {
if (error) return end(error);
WindowsElevate(instance,
function(error) {
if (error) return end(error);
WindowsWaitForStatus(instance,
function(error) {
if (error) return end(error);
WindowsResult(instance, end);
}
);
}
);
}
);
}
);
}
);
}
);
}
);
}
function WindowsElevate(instance, end) {
// We used to use this for executing elevate.vbs:
// var command = 'cscript.exe //NoLogo "' + instance.pathElevate + '"';
var command = [];
command.push('powershell.exe');
command.push('Start-Process "' + instance.pathExecute + '"');
command.push('-WindowStyle hidden');
command.push('-Verb runAs');
command = command.join(' ');
var child = Node.child.exec(command,
function(error, stdout, stderr) {
if (error) {
if (/canceled by the user/i.test(error)) {
end(PERMISSION_DENIED);
} else {
end(error);
}
} else {
end();
}
}
);
child.stdin.end(); // Otherwise PowerShell waits indefinitely on Windows 7.
}
function WindowsResult(instance, end) {
Node.fs.readFile(instance.pathStatus, 'utf-8',
function(error, code) {
if (error) return end(error);
Node.fs.readFile(instance.pathStdout, 'utf-8',
function(error, stdout) {
if (error) return end(error);
Node.fs.readFile(instance.pathStderr, 'utf-8',
function(error, stderr) {
if (error) return end(error);
code = parseInt(code.trim(), 10);
if (code === 0) {
end(undefined, stdout, stderr);
} else {
error = new Error('Command failed: ' + instance.command);
end(error, stdout, stderr);
}
}
);
}
);
}
);
}
function WindowsWaitForStatus(instance, end) {
// VBScript cannot wait for the elevated process to finish so we have to poll.
// VBScript cannot return error code if user does not grant permission.
// PowerShell can be used to elevate and wait on Windows 10.
// PowerShell can be used to elevate on Windows 7 but it cannot wait.
// powershell.exe Start-Process cmd.exe -Verb runAs -Wait
Node.fs.stat(instance.pathStatus,
function(error, stats) {
if ((error && error.code === 'ENOENT') || stats.size < 2) {
// Retry if file does not exist or is not finished writing.
// We expect a file size of 2. That should cover at least "0\r".
// We use a 1 second timeout to keep a light footprint for long-lived
// sudo-prompt processes.
setTimeout(
function() {
WindowsWaitForStatus(instance, end);
},
1000
);
} else if (error) {
end(error);
} else {
end();
}
}
);
}
function WindowsWriteCommandScript(instance, end) {
var cwd = Node.process.cwd();
if (/"/.test(cwd)) {
// We expect double quotes to be reserved on Windows.
// Even so, we test for this and abort if they are present.
return end(new Error('process.cwd() cannot contain double-quotes.'));
}
var script = [];
script.push('@echo off');
script.push('cd "' + cwd + '"');
script.push(instance.command);
script = script.join('\r\n');
Node.fs.writeFile(instance.pathCommand, script, 'utf-8', end);
}
function WindowsWriteElevateScript(instance, end) {
end();
// We do not use VBScript to elevate since it does not return an error if
// the user does not grant permission. This is here for reference.
// var script = [];
// script.push('Set objShell = CreateObject("Shell.Application")');
// script.push(
// 'objShell.ShellExecute "' + instance.pathExecute + '", "", "", "runas", 0'
// );
// script = script.join('\r\n');
// Node.fs.writeFile(instance.pathElevate, script, 'utf-8', end);
}
function WindowsWriteExecuteScript(instance, end) {
var script = [];
script.push('@echo off');
script.push(
'call "' + instance.pathCommand + '"' +
' > "' + instance.pathStdout + '" 2> "' + instance.pathStderr + '"'
);
script.push('(echo %ERRORLEVEL%) > "' + instance.pathStatus + '"');
script = script.join('\r\n');
Node.fs.writeFile(instance.pathExecute, script, 'utf-8', end);
}
module.exports.exec = Exec; module.exports.exec = Exec;
// We used to expect that applet.app would be included with this module. // We used to expect that applet.app would be included with this module.

BIN
linux.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

BIN
osx.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

After

Width:  |  Height:  |  Size: 85 KiB

View file

@ -2,6 +2,7 @@ var sudo = require('./');
var exec = require('child_process').exec; var exec = require('child_process').exec;
function kill(end) { function kill(end) {
if (process.platform === 'win32') return end();
exec('sudo -k', end); exec('sudo -k', end);
} }
kill( kill(
@ -9,7 +10,12 @@ kill(
var options = { var options = {
name: 'Sudo Prompt' name: 'Sudo Prompt'
}; };
sudo.exec('sleep 10 && echo world', options, if (process.platform === 'win32') {
var sleep = 'timeout /t 10\r\necho world';
} else {
var sleep = 'sleep 10 && echo world';
}
sudo.exec(sleep, options,
function(error, stdout, stderr) { function(error, stdout, stderr) {
console.log(error, stdout, stderr); console.log(error, stdout, stderr);
} }

16
test.js
View file

@ -3,24 +3,32 @@ var sudo = require('./');
var exec = require('child_process').exec; var exec = require('child_process').exec;
function kill(end) { function kill(end) {
if (process.platform === 'win32') return end();
exec('sudo -k', end); exec('sudo -k', end);
} }
function icns() { function icns() {
if (process.platform !== 'darwin') return undefined; if (process.platform !== 'darwin') return undefined;
var path = '/Applications/Chess.app/Contents/Resources/Chess.icns'; var path = '/Applications/Electron.app/Contents/Resources/Electron.icns';
try { try {
fs.statSync(path); fs.statSync(path);
return path; return path;
} catch (error) {} } catch (error) {}
return undefined; return undefined;
} }
kill( kill(
function() { function() {
var options = { var options = {
icns: icns(), icns: icns(),
name: 'Sudo Prompt' name: 'Electron'
}; };
var command = 'echo hello'; var command = 'echo hello';
if (process.platform === 'win32') {
var expected = 'hello\r\n';
} else {
var expected = 'hello\n';
}
console.log('sudo.exec(' + JSON.stringify(command) + ', ' + JSON.stringify(options) + ')'); console.log('sudo.exec(' + JSON.stringify(command) + ', ' + JSON.stringify(options) + ')');
sudo.exec(command, options, sudo.exec(command, options,
function(error, stdout, stderr) { function(error, stdout, stderr) {
@ -30,7 +38,9 @@ kill(
kill( kill(
function() { function() {
if (error) throw error; if (error) throw error;
if (stdout !== 'hello\n') throw new Error('stdout != "hello\n"'); if (stdout !== expected) {
throw new Error('stdout != ' + JSON.stringify(expected));
}
if (stderr !== "") throw new Error('stderr != ""'); if (stderr !== "") throw new Error('stderr != ""');
console.log('OK'); console.log('OK');
} }

BIN
windows.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB