diff --git a/README.md b/README.md index c0ffb4f..ac01654 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,18 @@ # 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 +`sudo-prompt` has no external dependencies and does not require any native bindings. ``` npm install sudo-prompt ``` @@ -18,8 +22,8 @@ Note: Your command should not start with the `sudo` prefix. ``` var sudo = require('sudo-prompt'); var options = { - name: 'Ronomon', - icns: '/path/to/icns/file', // (optional) + name: 'Electron', + icns: '/Applications/Electron.app/Contents/Resources/Electron.icns', // (optional) }; 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 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 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. ## 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 $ sudo -k diff --git a/index.js b/index.js index c2fb12f..0358a2b 100644 --- a/index.js +++ b/index.js @@ -9,6 +9,8 @@ var Node = { }; 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 // a password. If a password is required, sudo will return an error and exit. var command = []; @@ -23,7 +25,6 @@ function Attempt(instance, end) { Node.child.exec(command, function(error, stdout, stderr) { if (/sudo: /i.test(stderr)) { - var platform = Node.process.platform; if (platform === 'linux') { return Linux(instance, end); } else if (platform === 'darwin') { @@ -97,7 +98,7 @@ function Exec() { } } 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.')); } var instance = { @@ -169,10 +170,6 @@ function Mac(instance, callback) { UUID(instance, function(error, uuid) { 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.path = Node.path.join( temp, @@ -184,7 +181,7 @@ function Mac(instance, callback) { function(errorRemove) { if (error) return callback(error); 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) { - 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 = []; - command.push('/bin/rm'); - command.push('-rf'); - command.push('"' + EscapeDoubleQuotes(Node.path.normalize(path)) + '"'); + 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('-rf'); + command.push('"' + EscapeDoubleQuotes(Node.path.normalize(path)) + '"'); + } command = command.join(' '); Node.child.exec(command, end); } @@ -356,7 +362,12 @@ function UUID(instance, end) { hash.update(instance.options.name); hash.update(instance.command); 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; } +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; // We used to expect that applet.app would be included with this module. diff --git a/linux.png b/linux.png new file mode 100644 index 0000000..a50f0ae Binary files /dev/null and b/linux.png differ diff --git a/osx.png b/osx.png index 794e21a..d32dffa 100644 Binary files a/osx.png and b/osx.png differ diff --git a/test-concurrent.js b/test-concurrent.js index 09977a3..808540c 100644 --- a/test-concurrent.js +++ b/test-concurrent.js @@ -2,6 +2,7 @@ var sudo = require('./'); var exec = require('child_process').exec; function kill(end) { + if (process.platform === 'win32') return end(); exec('sudo -k', end); } kill( @@ -9,7 +10,12 @@ kill( var options = { 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) { console.log(error, stdout, stderr); } diff --git a/test.js b/test.js index 7e3c8b1..f7e5930 100644 --- a/test.js +++ b/test.js @@ -3,24 +3,32 @@ var sudo = require('./'); var exec = require('child_process').exec; function kill(end) { + if (process.platform === 'win32') return end(); exec('sudo -k', end); } + function icns() { 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 { fs.statSync(path); return path; } catch (error) {} return undefined; } + kill( function() { var options = { icns: icns(), - name: 'Sudo Prompt' + name: 'Electron' }; 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) + ')'); sudo.exec(command, options, function(error, stdout, stderr) { @@ -30,7 +38,9 @@ kill( kill( function() { 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 != ""'); console.log('OK'); } diff --git a/windows.png b/windows.png new file mode 100644 index 0000000..c3dbc0f Binary files /dev/null and b/windows.png differ