diff --git a/README.md b/README.md index c53d3a9..61922e0 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # 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 ``` @@ -14,24 +14,32 @@ npm install sudo-prompt ``` ## 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'); -sudo.exec('echo hello', function(error) {}); - -// To update the sudo timestamp for the current user: -// 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.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') +var options = { + name: 'Ronomon', + icns: '/path/to/icns/file' // (optional) +}; +sudo.exec('echo hello', options, function(error) {}); ``` +`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 -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 -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. diff --git a/applet.app/Contents/Info.plist b/applet.app/Contents/Info.plist new file mode 100644 index 0000000..08dd8ba --- /dev/null +++ b/applet.app/Contents/Info.plist @@ -0,0 +1,35 @@ + + + + + CFBundleAllowMixedLocalizations + + CFBundleDevelopmentRegion + English + CFBundleExecutable + applet + CFBundleIconFile + applet.icns + CFBundleIdentifier + com.sudo-prompt + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Password Prompt + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleSignature + aplt + LSMinimumSystemVersionByArchitecture + + x86_64 + 10.6 + + LSRequiresCarbon + + LSUIElement + + + diff --git a/applet.app/Contents/MacOS/applet b/applet.app/Contents/MacOS/applet new file mode 100755 index 0000000..2bbb6dd Binary files /dev/null and b/applet.app/Contents/MacOS/applet differ diff --git a/applet.app/Contents/PkgInfo b/applet.app/Contents/PkgInfo new file mode 100644 index 0000000..3253614 --- /dev/null +++ b/applet.app/Contents/PkgInfo @@ -0,0 +1 @@ +APPLaplt \ No newline at end of file diff --git a/applet.app/Contents/Resources/Scripts/main.scpt b/applet.app/Contents/Resources/Scripts/main.scpt new file mode 100644 index 0000000..64219bd Binary files /dev/null and b/applet.app/Contents/Resources/Scripts/main.scpt differ diff --git a/applet.app/Contents/Resources/applet.icns b/applet.app/Contents/Resources/applet.icns new file mode 100644 index 0000000..8983ddb Binary files /dev/null and b/applet.app/Contents/Resources/applet.icns differ diff --git a/applet.app/Contents/Resources/applet.rsrc b/applet.app/Contents/Resources/applet.rsrc new file mode 100644 index 0000000..b859093 Binary files /dev/null and b/applet.app/Contents/Resources/applet.rsrc differ diff --git a/applet.app/Contents/Resources/description.rtfd/TXT.rtf b/applet.app/Contents/Resources/description.rtfd/TXT.rtf new file mode 100644 index 0000000..c19da88 --- /dev/null +++ b/applet.app/Contents/Resources/description.rtfd/TXT.rtf @@ -0,0 +1,4 @@ +{\rtf1\ansi\ansicpg1252\cocoartf1404\cocoasubrtf130 +{\fonttbl} +{\colortbl;\red255\green255\blue255;} +} \ No newline at end of file diff --git a/index.js b/index.js index cade3e3..0a8f63e 100644 --- a/index.js +++ b/index.js @@ -1,39 +1,35 @@ var Node = { child: require('child_process'), + crypto: require('crypto'), fs: require('fs'), os: require('os'), path: require('path'), - process: process + process: process, + util: require('util') }; -function escapeDoubleQuotes(string) { - return string.replace(/"/g, '\\"'); -} - -function validName(string) { - return /^[a-z0-9 ]+$/i.test(string) && string.trim().length > 0; -} - -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). +function attempt(attempts, command, options, end) { + if (typeof attempts !== 'number' || Math.floor(attempts) !== attempts || attempts < 0) { + return end(new Error('Attempts argument should be a positive integer.')); + } + // The -n (non-interactive) option prevents sudo from prompting the user for + // a password. If a password is required for the command to run, sudo will + // display an error message and exit. Node.child.exec('/usr/bin/sudo -n ' + command, function(error, stdout, 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) { 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)) { @@ -43,84 +39,297 @@ Sudo.Mac = function(command, end, count) { } } ); -}; +} -Sudo.Mac.prompt = function(end) { - var self = this; - var title = Name || Node.process.title; - if (!validName(title)) return end(new Error('Please use sudo.setName(string) to set your app name (process.title contains invalid characters).')); - var temp = Node.os.tmpdir(); - 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) { - // Already waiting for user to enter password... - // 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. - self.prompting.push(end); - } else { - // Prompting user for password... - self.prompting = [end]; - var finish = function(error) { - var callbacks = self.prompting; - self.prompting = false; - for (var index = 0, length = callbacks.length; index < length; index++) { - callbacks[index](error); +function copy(source, target, end) { + source = escapeDoubleQuotes(Node.path.normalize(source)); + target = escapeDoubleQuotes(Node.path.normalize(target)); + var command = '/bin/cp -R -p "' + source + '" "' + target + '"'; + Node.child.exec(command, end); +} + +function escapeDoubleQuotes(string) { + return string.replace(/"/g, '\\"'); +} + +function exec() { + if (arguments.length < 1 || arguments.length > 3) { + throw new Error('Wrong number of arguments.'); + } + var command = arguments[0]; + var options = {}; + var end = function() {}; + if (typeof command !== 'string') { + throw new Error('Command should be a string.'); + } + 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. - // We can then use this new binary to change the sudo timestamp, and OS X will use - // the title of this process when asking the user for permission. - Node.child.exec('which osascript', - function(error, stdout, stderr) { - if (error) return finish(error); - var source = stdout.trim(); - var target = Node.path.join(temp, title); - Node.fs.readFile(source, - function(error, buffer) { - if (error) return finish(error); - Node.fs.writeFile(target, buffer, - function(error) { - if (error) return finish(error); - Node.fs.chmod(target, 0755, - function(error) { - if (error) return finish(error); - // Set the sudo timestamp for our user: - var command = '"' + escapeDoubleQuotes(target) + '" -e \'do shell script "mkdir -p /var/db/sudo/$USER; touch /var/db/sudo/$USER" with administrator privileges\''; - Node.child.exec(command, - function(error, stdout, stderr) { - if (/user canceled/i.test(error)) error = new Error('User did not grant permission.'); - if (error) return finish(error); - Node.fs.unlink(target, finish); - } - ); - } - ); - } - ); + } + } else if (!validName(options.name)) { + return end(new Error('options.name must be alphanumeric only (spaces are allowed).')); + } + if (typeof options.icns !== 'undefined') { + if (typeof options.icns !== 'string') { + return end(new Error('options.icns must be a string if provided.')); + } else if (options.icns.trim().length === 0) { + return end(new Error('options.icns must be a non-empty string if provided.')); + } + } + if (Node.process.platform !== 'darwin' && Node.process.platform !== 'linux') { + return end(new Error('Platform not yet supported.')); + } + attempt(0, command, options, end); +} + +function linux(command, options, end) { + linuxBinary( + function(error, binary) { + if (error) return end(error); + linuxExecute(binary, command, options, end); + } + ); +} + +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 uses existing sudo-prompt machinery. - Sudo('echo touchingsudotimestamp', + exec('echo touchingsudotimestamp', {}, function(error, stdout, stderr) { if (error) return end(error); end(); // Do not pass stdout and stderr back to callback. } ); -}; +} -exports.setName = function(string) { - if (!validName(string)) throw new Error('Name must be alphanumeric only (spaces are allowed).'); - Name = string; -}; +function validName(string) { + // We use 70 characters as a limit to side-step any issues with Unicode + // 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; diff --git a/osx.png b/osx.png index 11b3f21..794e21a 100644 Binary files a/osx.png and b/osx.png differ diff --git a/test.js b/test.js index 10e1ea3..5f496f7 100644 --- a/test.js +++ b/test.js @@ -1,22 +1,40 @@ +var fs = require('fs'); var sudo = require('./'); var exec = require('child_process').exec; -function cleanup() { - exec('sudo -k'); +function kill(end) { + exec('sudo -k', end); } - -console.log('sudo.setName("Test")'); -console.log('sudo.exec("echo hello")'); -sudo.setName('Test'); -sudo.exec('echo hello', - function(error, stdout, stderr) { - console.log('error: ' + error); - console.log('stdout: ' + JSON.stringify(stdout)); - console.log('stderr: ' + JSON.stringify(stderr)); - cleanup(); - if (error) throw error; - if (stdout !== 'hello\n') throw new Error('stdout != "hello\n"'); - if (stderr !== "") throw new Error('stderr != ""'); - console.log('OK'); +function icns() { + if (process.platform !== 'darwin') return undefined; + var path = '/Applications/Chess.app/Contents/Resources/Chess.icns'; + try { + fs.statSync(path); + return path; + } catch (error) {} + return undefined; +} +kill( + function() { + var options = { + icns: icns(), + 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'); + } + ); + } + ); } );