Add exec options (support options.icns on OS X), add beta Linux support, improve concurrency

This commit is contained in:
Joran Dirk Greef 2015-11-17 15:40:53 +02:00
parent 233b58927b
commit 57a83efd64
11 changed files with 397 additions and 122 deletions

View file

@ -1,12 +1,12 @@
# sudo-prompt # sudo-prompt
Run a command using sudo, prompting the user with an OS dialog if necessary. Useful for background applications or native Electron apps that need sudo. Run a command using `sudo`, prompting the user with an OS dialog if necessary. Useful for background applications or native Electron apps that need sudo.
![Sudo on Mac OS X for an app called "Ronomon"](./osx.png) ![A sudo prompt on OS X for an app called "Ronomon"](./osx.png)
Currently supports native OS dialog prompt on Mac OS X (patches welcome for Linux) and uses process.title as the name of the app requesting permission. `sudo-prompt` provides a native OS dialog prompt on **OS X** and **Linux (beta)** with custom name and optional icon.
sudo-prompt has no external dependencies and does not require any native bindings. `sudo-prompt` has no external dependencies and does not require any native bindings.
## Installation ## Installation
``` ```
@ -14,24 +14,32 @@ npm install sudo-prompt
``` ```
## Usage ## Usage
Note: Your command should not start with the "sudo" prefix. Note: Your command should not start with the `sudo` prefix.
``` ```
// To run a command using sudo-prompt:
var sudo = require('sudo-prompt'); var sudo = require('sudo-prompt');
sudo.exec('echo hello', function(error) {}); var options = {
name: 'Ronomon',
// To update the sudo timestamp for the current user: icns: '/path/to/icns/file' // (optional)
// This will extend any existing sudo session for a few more minutes. };
// It will prompt to create a new session if there is no existing sudo session. sudo.exec('echo hello', options, function(error) {});
sudo.touch(function(error) {});
// To use something other than process.title as the app name:
// Must be alphanumeric (may contain spaces).
sudo.setName('Your app name')
``` ```
`sudo-prompt` will use `process.title` as `options.name` if `options.name` is not provided. `options.name` must be alphanumeric only (spaces are supported) and at most 70 characters.
*Please note that `sudo.setName()` and `sudo.touch()` have been deprecated to provide a completely functional interface to `exec()`. These calls will be removed in the next release of `sudo-prompt`.*
## Behavior ## Behavior
sudo-prompt should behave just like the `sudo` command in the shell. If your command does not work with the `sudo` command in the shell (perhaps because it uses `>` redirection to a restricted file), then it will not work with sudo-prompt. However, it is still possible to use sudo-prompt to get a privileged shell, [see this issue for more information](https://github.com/jorangreef/sudo-prompt/issues/1). On OS X, `sudo-prompt` should behave just like the `sudo` command in the shell. If your command does not work with the `sudo` command in the shell (perhaps because it uses `>` redirection to a restricted file), then it will not work with `sudo-prompt`. However, it is still possible to use sudo-prompt to get a privileged shell, [see this closed issue for more information](https://github.com/jorangreef/sudo-prompt/issues/1).
*Please note that Linux support is currently in beta and requires more testing across Linux distributions.*
On Linux, `sudo-prompt` will use either `gksudo`, `pkexec`, or `kdesudo` to show the password prompt and run your command. Where possible, `sudo-prompt` will try and get these to mimic `sudo` as much as possible (for example by preserving environment), but your command should not rely on any environment variables or relative paths, in order to work correctly. 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`.
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).
## Concurrency ## Concurrency
You can call `sudo.exec` and `sudo.touch` concurrently, and sudo-prompt will batch up permission requests into a single password prompt. On OS X, you can issue multiple calls to `sudo.exec` concurrently, and `sudo-prompt` will batch up multiple permission requests into a single password prompt. These calls will be batched to the extent that they share the same `options.name` and `options.icns` arguments (including the actual content of `options.icns` if provided).
On Linux, `sudo` usually has `tty-tickets` enabled. This prevents `sudo-prompt` from batching up multiple permission requests, and will result in a password prompt for each call.
While `sudo-prompt` may batch up calls, you should never rely on `sudo-prompt` to execute your calls in order. For example, several calls may be waiting on a password prompt, and the next call after the password prompt may execute before any of these calls. If you need to enforce ordering of calls, then you should explicitly order your calls in your application.

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleAllowMixedLocalizations</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>English</string>
<key>CFBundleExecutable</key>
<string>applet</string>
<key>CFBundleIconFile</key>
<string>applet.icns</string>
<key>CFBundleIdentifier</key>
<string>com.sudo-prompt</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Password Prompt</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>aplt</string>
<key>LSMinimumSystemVersionByArchitecture</key>
<dict>
<key>x86_64</key>
<string>10.6</string>
</dict>
<key>LSRequiresCarbon</key>
<true/>
<key>LSUIElement</key>
<true/>
</dict>
</plist>

BIN
applet.app/Contents/MacOS/applet Executable file

Binary file not shown.

View file

@ -0,0 +1 @@
APPLaplt

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 B

View file

@ -0,0 +1,4 @@
{\rtf1\ansi\ansicpg1252\cocoartf1404\cocoasubrtf130
{\fonttbl}
{\colortbl;\red255\green255\blue255;}
}

385
index.js
View file

@ -1,39 +1,35 @@
var Node = { var Node = {
child: require('child_process'), child: require('child_process'),
crypto: require('crypto'),
fs: require('fs'), fs: require('fs'),
os: require('os'), os: require('os'),
path: require('path'), path: require('path'),
process: process process: process,
util: require('util')
}; };
function escapeDoubleQuotes(string) { function attempt(attempts, command, options, end) {
return string.replace(/"/g, '\\"'); if (typeof attempts !== 'number' || Math.floor(attempts) !== attempts || attempts < 0) {
} return end(new Error('Attempts argument should be a positive integer.'));
}
function validName(string) { // The -n (non-interactive) option prevents sudo from prompting the user for
return /^[a-z0-9 ]+$/i.test(string) && string.trim().length > 0; // a password. If a password is required for the command to run, sudo will
} // display an error message and exit.
var Name = undefined;
var Sudo = function(command, end) {
if (Node.process.platform === 'darwin') return Sudo.Mac(command, end);
end(new Error('Platform not yet supported.'));
// TO DO: Add support for linux.
};
Sudo.Mac = function(command, end, count) {
if (count === undefined) count = 0;
if (count >= 2) return end(new Error('Permission denied after several password prompts.'));
if (/^sudo/i.test(command)) return end(new Error('Command should not contain "sudo".'));
// Run sudo in non-interactive mode (-n).
Node.child.exec('/usr/bin/sudo -n ' + command, Node.child.exec('/usr/bin/sudo -n ' + command,
function(error, stdout, stderr) { function(error, stdout, stderr) {
if (/sudo: a password is required/i.test(stderr)) { if (/sudo: a password is required/i.test(stderr)) {
Sudo.Mac.prompt( if (attempts > 0) return end(new Error('User did not grant permission.'));
if (Node.process.platform === 'linux') {
// Linux will probably use TTY tickets for sudo timestamps.
// If so, we cannot easily extend the sudo timestamp for the user.
// We prefer this since a single prompt can be used for multiple calls.
// Instead, we have to use a single prompt for each call.
return linux(command, options, end);
}
prompt(options,
function(error) { function(error) {
if (error) return end(error); if (error) return end(error);
Sudo.Mac(command, end, ++count); // Cannot use ++ suffix here. attempt(++attempts, command, options, end); // Cannot use ++ suffix here.
} }
); );
} else if (!error && /^sudo:/i.test(stderr)) { } else if (!error && /^sudo:/i.test(stderr)) {
@ -43,84 +39,297 @@ Sudo.Mac = function(command, end, count) {
} }
} }
); );
}; }
Sudo.Mac.prompt = function(end) { function copy(source, target, end) {
var self = this; source = escapeDoubleQuotes(Node.path.normalize(source));
var title = Name || Node.process.title; target = escapeDoubleQuotes(Node.path.normalize(target));
if (!validName(title)) return end(new Error('Please use sudo.setName(string) to set your app name (process.title contains invalid characters).')); var command = '/bin/cp -R -p "' + source + '" "' + target + '"';
var temp = Node.os.tmpdir(); Node.child.exec(command, end);
if (!temp) return end(new Error('Requires os.tmpdir() to be defined.')); }
if (!Node.process.env.USER) return end(new Error('Requires env[\'USER\'] to be defined.'));
if (self.prompting) { function escapeDoubleQuotes(string) {
// Already waiting for user to enter password... return string.replace(/"/g, '\\"');
// We expect that Sudo.exec may be called multiple times. }
// If a prompt is already pending, then we wait for the result of the prompt
// and do not trigger another permission request dialog. function exec() {
self.prompting.push(end); if (arguments.length < 1 || arguments.length > 3) {
} else { throw new Error('Wrong number of arguments.');
// Prompting user for password... }
self.prompting = [end]; var command = arguments[0];
var finish = function(error) { var options = {};
var callbacks = self.prompting; var end = function() {};
self.prompting = false; if (typeof command !== 'string') {
for (var index = 0, length = callbacks.length; index < length; index++) { throw new Error('Command should be a string.');
callbacks[index](error); }
if (arguments.length === 2) {
if (Node.util.isObject(arguments[1])) {
options = arguments[1];
} else if (Node.util.isFunction(arguments[1])) {
end = arguments[1];
} else {
throw new Error('Expected options or callback.');
}
} else if (arguments.length === 3) {
if (Node.util.isObject(arguments[1])) {
options = arguments[1];
} else {
throw new Error('Expected options to be an object.');
}
if (Node.util.isFunction(arguments[2])) {
end = arguments[2];
} else {
throw new Error('Expected callback to be a function.');
}
}
if (/^sudo/i.test(command)) {
return end(new Error('Command should not contain "sudo".'));
}
if (typeof options.name === 'undefined') {
if (typeof name === 'string') {
// If name is a string, it has been set and verified by setName.
options.name = name;
} else {
var title = Node.process.title;
if (validName(title)) {
options.name = title;
} else {
return end(new Error('options.name must be provided (process.title is not valid).'));
} }
}; }
// We copy osascript to a new tmp location using the title of this process as the basename. } else if (!validName(options.name)) {
// We can then use this new binary to change the sudo timestamp, and OS X will use return end(new Error('options.name must be alphanumeric only (spaces are allowed).'));
// the title of this process when asking the user for permission. }
Node.child.exec('which osascript', if (typeof options.icns !== 'undefined') {
function(error, stdout, stderr) { if (typeof options.icns !== 'string') {
if (error) return finish(error); return end(new Error('options.icns must be a string if provided.'));
var source = stdout.trim(); } else if (options.icns.trim().length === 0) {
var target = Node.path.join(temp, title); return end(new Error('options.icns must be a non-empty string if provided.'));
Node.fs.readFile(source, }
function(error, buffer) { }
if (error) return finish(error); if (Node.process.platform !== 'darwin' && Node.process.platform !== 'linux') {
Node.fs.writeFile(target, buffer, return end(new Error('Platform not yet supported.'));
function(error) { }
if (error) return finish(error); attempt(0, command, options, end);
Node.fs.chmod(target, 0755, }
function(error) {
if (error) return finish(error); function linux(command, options, end) {
// Set the sudo timestamp for our user: linuxBinary(
var command = '"' + escapeDoubleQuotes(target) + '" -e \'do shell script "mkdir -p /var/db/sudo/$USER; touch /var/db/sudo/$USER" with administrator privileges\''; function(error, binary) {
Node.child.exec(command, if (error) return end(error);
function(error, stdout, stderr) { linuxExecute(binary, command, options, end);
if (/user canceled/i.test(error)) error = new Error('User did not grant permission.'); }
if (error) return finish(error); );
Node.fs.unlink(target, finish); }
}
); function linuxBinary(end) {
} var index = 0;
); // We prefer gksudo over pkexec since it gives a nicer prompt:
} var paths = ['/usr/bin/gksudo', '/usr/bin/pkexec', '/usr/bin/kdesudo'];
); function test() {
if (index === paths.length) {
return end(new Error('Unable to find gksudo, pkexec or kdesudo.'));
}
var path = paths[index++];
Node.fs.stat(path,
function(error) {
if (error) {
if (error.code === 'ENOTDIR' || error.code === 'ENOENT') {
return test();
} else {
return end(error);
} }
); } else {
end(undefined, path);
}
} }
); );
} }
}; test();
}
Sudo.Mac.prompting = false; function linuxExecute(binary, command, options, end) {
var string = '';
string += '"' + escapeDoubleQuotes(binary) + '" ';
if (/gksudo/i.test(binary)) {
string += '--preserve-env ';
string += '--sudo-mode ';
string += '--description="' + escapeDoubleQuotes(options.name) + '" ';
} else if (/pkexec/i.test(binary)) {
string += '--disable-internal-agent ';
}
string += command;
Node.child.exec(string,
function(error, stdout, stderr) {
if (error && /Request dismissed|Command failed/i.test(error)) {
error = new Error('User did not grant permission.');
}
end(error, stdout, stderr);
}
);
}
exports.exec = Sudo; function macIcon(target, options, end) {
if (!options.icns) return end();
copy(options.icns, Node.path.join(target, 'Contents', 'Resources', 'applet.icns'), end);
}
exports.touch = function(end) { function macOpen(target, options, end) {
target = escapeDoubleQuotes(Node.path.normalize(target));
var command = 'open -n -W "' + target + '"';
Node.child.exec(command, end);
}
function macPrompt(hash, options, callback) {
var temp = Node.os.tmpdir();
if (!temp) return callback(new Error('Requires os.tmpdir() to be defined.'));
if (!Node.process.env.USER) return callback(new Error('Requires env[\'USER\'] to be defined.'));
var source = Node.path.join(Node.path.dirname(module.filename), 'applet.app');
var target = Node.path.join(temp, hash, options.name + '.app');
function end(error) {
remove(target,
function(errorRemove) {
if (error) return callback(error);
if (errorRemove) return callback(errorRemove);
callback();
}
);
}
Node.fs.mkdir(Node.path.dirname(target),
function(error) {
if (error && error.code === 'EEXIST') error = undefined;
if (error) return end(error);
copy(source, target,
function(error) {
if (error) return end(error);
macIcon(target, options,
function(error) {
if (error) return end(error);
macPropertyList(target, options,
function(error) {
if (error) return end(error);
macOpen(target, options, end);
}
);
}
);
}
);
}
);
}
function macPropertyList(target, options, end) {
// Value must be in single quotes (not double quotes) according to man entry.
// e.g. defaults write com.companyname.appname "Default Color" '(255, 0, 0)'
// The defaults command will be changed in an upcoming major release to only
// operate on preferences domains. General plist manipulation utilities will
// be folded into a different command-line program.
var path = escapeDoubleQuotes(Node.path.join(target, 'Contents', 'Info.plist'));
var key = escapeDoubleQuotes('CFBundleName');
var value = options.name + ' Password Prompt';
if (/'/.test(value)) {
return end(new Error('Value should not contain single quotes.'));
}
var command = 'defaults write "' + path + '" "' + key + '" \'' + value + '\'';
Node.child.exec(command, end);
}
var name = null;
function prompt(options, end) {
version(options,
function(error, hash) {
if (error) return end(error);
if (!prompting.hasOwnProperty(hash)) prompting[hash] = [];
prompting[hash].push(end);
// Already waiting for user to enter password...
// We expect that exec() may be called multiple times.
// If a prompt is already pending, then we wait for the result of the prompt
// and do not trigger another permission request dialog.
if (prompting[hash].length > 1) return;
function done(error) {
// We must clear prompting queue before looping, otherwise sudo calls which
// are synchronously issued by these callbacks may fail to be executed.
var callbacks = prompting[hash];
delete prompting[hash];
for (var index = 0, length = callbacks.length; index < length; index++) {
var callback = callbacks[index];
callback(error);
}
}
if (Node.process.platform === 'darwin') return macPrompt(hash, options, done);
if (Node.process.platform === 'linux') return linuxPrompt(hash, options, done);
end(new Error('Platform not supported (unexpected, should have been checked already).'));
}
);
}
var prompting = {};
function remove(target, end) {
if (!target) return end(new Error('Target not defined.'));
target = escapeDoubleQuotes(Node.path.normalize(target));
var command = 'rm -rf "' + target + '"';
Node.child.exec(command, end);
}
function setName(string) {
// DEPRECATED to move away from a global variable towards a functional
// interface. Otherwise using setName could have rare race conditions when
// multiple calls need to use different names.
if (!validName(string)) {
throw new Error('Name must be alphanumeric only (spaces are allowed).');
}
name = string;
}
function touch(end) {
// DEPRECATED to reduce the surface area of the interface.
// Better to call exec() directly as this supports the options argument.
// touch() may fail if process.title is not valid.
// Depends on setName() which has also been deprecated.
// This is a convenience method to extend the sudo session. // This is a convenience method to extend the sudo session.
// This uses existing sudo-prompt machinery. // This uses existing sudo-prompt machinery.
Sudo('echo touchingsudotimestamp', exec('echo touchingsudotimestamp', {},
function(error, stdout, stderr) { function(error, stdout, stderr) {
if (error) return end(error); if (error) return end(error);
end(); // Do not pass stdout and stderr back to callback. end(); // Do not pass stdout and stderr back to callback.
} }
); );
}; }
exports.setName = function(string) { function validName(string) {
if (!validName(string)) throw new Error('Name must be alphanumeric only (spaces are allowed).'); // We use 70 characters as a limit to side-step any issues with Unicode
Name = string; // normalization form causing a 255 character string to exceed the fs limit.
}; return /^[a-z0-9 ]+$/i.test(string) && string.trim().length > 0 && string.length < 70;
}
function version(options, end) {
versionReadICNS(options,
function(error, buffer) {
if (error) return end(error);
var hash = Node.crypto.createHash('SHA256');
hash.update('sudo-prompt 2.0.0');
hash.update(options.name);
hash.update(buffer);
end(undefined, hash.digest('hex').slice(-32));
}
);
}
function versionReadICNS(options, end) {
if (!options.icns || Node.process.platform !== 'darwin') {
return end(undefined, new Buffer(0));
}
// options.icns is supported only on Mac.
Node.fs.readFile(options.icns, end);
}
module.exports.exec = exec;
// DEPRECATED:
module.exports.setName = setName;
// DEPRECATED:
module.exports.touch = touch;

BIN
osx.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

After

Width:  |  Height:  |  Size: 298 KiB

50
test.js
View file

@ -1,22 +1,40 @@
var fs = require('fs');
var sudo = require('./'); var sudo = require('./');
var exec = require('child_process').exec; var exec = require('child_process').exec;
function cleanup() { function kill(end) {
exec('sudo -k'); exec('sudo -k', end);
} }
function icns() {
console.log('sudo.setName("Test")'); if (process.platform !== 'darwin') return undefined;
console.log('sudo.exec("echo hello")'); var path = '/Applications/Chess.app/Contents/Resources/Chess.icns';
sudo.setName('Test'); try {
sudo.exec('echo hello', fs.statSync(path);
function(error, stdout, stderr) { return path;
console.log('error: ' + error); } catch (error) {}
console.log('stdout: ' + JSON.stringify(stdout)); return undefined;
console.log('stderr: ' + JSON.stringify(stderr)); }
cleanup(); kill(
if (error) throw error; function() {
if (stdout !== 'hello\n') throw new Error('stdout != "hello\n"'); var options = {
if (stderr !== "") throw new Error('stderr != ""'); icns: icns(),
console.log('OK'); name: 'Chess'
};
console.log('sudo.exec("echo hello", ' + JSON.stringify(options) + ')');
sudo.exec('echo hello', options,
function(error, stdout, stderr) {
console.log('error: ' + error);
console.log('stdout: ' + JSON.stringify(stdout));
console.log('stderr: ' + JSON.stringify(stderr));
kill(
function() {
if (error) throw error;
if (stdout !== 'hello\n') throw new Error('stdout != "hello\n"');
if (stderr !== "") throw new Error('stderr != ""');
console.log('OK');
}
);
}
);
} }
); );