Merge pull request #1455 from balena-io/1428-os-initialize

Fix 'os initialize' (auto sudo execution)
This commit is contained in:
Paulo Castro 2019-10-16 00:17:28 +01:00 committed by GitHub
commit 82c486b202
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 327 additions and 94 deletions

View File

@ -54,35 +54,3 @@ exports.scanDevices =
.then (hostnameOrIp) ->
console.error("==> Selected device: #{hostnameOrIp}")
.nodeify(done)
exports.sudo =
signature: 'internal sudo <command>'
description: 'execute arbitrary commands in a privileged subprocess'
help: '''
Don't use this command directly!
<command> must be passed as a single argument. That means, you need to make sure
you enclose <command> in quotes (eg. balena internal sudo 'ls -alF') if for
whatever reason you invoke the command directly or, typically, pass <command>
as a single argument to spawn (eg. `spawn('balena', [ 'internal', 'sudo', 'ls -alF' ])`).
Furthermore, this command will naively split <command> on whitespace and directly
forward the parts as arguments to `sudo`, so be careful.
'''
hidden: true
action: (params, options, done) ->
os = require('os')
Promise = require('bluebird')
return Promise.try ->
if os.platform() is 'win32'
windosu = require('windosu')
windosu.exec(params.command, {})
else
{ spawn } = require('child_process')
{ wait } = require('rindle')
cmd = params.command.split(' ')
ps = spawn('sudo', cmd, stdio: 'inherit', env: process.env)
wait(ps)
.nodeify(done)

View File

@ -128,7 +128,6 @@ capitano.command(actions.util.availableDrives)
# ---------- Internal utils ----------
capitano.command(actions.internal.osInit)
capitano.command(actions.internal.scanDevices)
capitano.command(actions.internal.sudo)
#------------ Local build and deploy -------
capitano.command(actions.build)

View File

@ -15,17 +15,63 @@
* limitations under the License.
*/
import { globalInit } from './app-common';
import { AppOptions, routeCliFramework } from './preparser';
/**
* CLI entrypoint, but see also `bin/balena` and `bin/balena-dev` which
* call this function.
*/
export async function run(cliArgs = process.argv, options: AppOptions = {}) {
export async function run(
cliArgs = process.argv,
options: import('./preparser').AppOptions = {},
) {
// The 'pkgExec' special/internal command provides a Node.js interpreter
// for use of the standalone zip package. See pkgExec function.
if (cliArgs.length > 3 && cliArgs[2] === 'pkgExec') {
return pkgExec(cliArgs[3], cliArgs.slice(4));
}
const { globalInit } = await import('./app-common');
const { routeCliFramework } = await import('./preparser');
// globalInit() must be called very early on (before other imports) because
// it sets up Sentry error reporting, global HTTP proxy settings, balena-sdk
// shared options, and performs node version requirement checks.
globalInit();
await routeCliFramework(cliArgs, options);
}
/**
* Implements the 'pkgExec' command, used as a way to provide a Node.js
* interpreter for child_process.spawn()-like operations when the CLI is
* executing as a standalone zip package (built-in Node interpreter) and
* the system may not have a separate Node.js installation. A present use
* case is a patched version of the 'windosu' package that requires a
* Node.js interpreter to spawn a privileged child process.
*
* @param modFunc Path to a JS module that will be executed via require().
* The modFunc argument may optionally contain a function name separated
* by '::', for example '::main' in:
* 'C:\\snapshot\\balena-cli\\node_modules\\windosu\\lib\\pipe.js::main'
* in which case that function is executed in the require'd module.
* @param args Optional arguments to passed through process.argv and as
* arguments to the function specified via modFunc.
*/
async function pkgExec(modFunc: string, args: string[]) {
const [modPath, funcName] = modFunc.split('::');
let replacedModPath = modPath;
const match = modPath
.replace(/\\/g, '/')
.match(/\/snapshot\/balena-cli\/(.+)/);
if (match) {
replacedModPath = `../${match[1]}`;
}
process.argv = [process.argv[0], process.argv[1], ...args];
try {
const mod: any = await import(replacedModPath);
if (funcName) {
await mod[funcName](...args);
}
} catch (err) {
console.error(`Error executing pkgExec "${modFunc}" [${args.join()}]`);
console.error(err);
}
}

View File

@ -20,11 +20,10 @@ import chalk from 'chalk';
import _ = require('lodash');
import os = require('os');
import visuals = require('resin-cli-visuals');
import rindle = require('rindle');
import * as ShellEscape from 'shell-escape';
import { InitializeEmitter, OperationState } from 'balena-device-init';
const waitStreamAsync = Bluebird.promisify(rindle.wait);
const balena = BalenaSdk.fromSharedOptions();
export function getGroupDefaults(group: {
@ -59,19 +58,44 @@ export function stateToString(state: OperationState) {
}
}
export function sudo(
/**
* Execute a child process with admin / superuser privileges, prompting the user for
* elevation as needed, and taking care of shell-escaping arguments in a suitable way
* for Windows and Linux/Mac.
*
* @param command Unescaped array of command and args to be executed. If isCLIcmd is
* true, the command should not include the 'node' or 'balena' components, for example:
* ['internal', 'osinit', ...]. This function will add argv[0] and argv[1] as needed
* (taking process.pkg into account -- CLI standalone zip package), and will also
* shell-escape the arguments as needed, taking into account the differences between
* bash/sh and the Windows cmd.exe in relation to escape characters.
* @param msg Optional message for the user, before the password prompt
* @param stderr Optional stream to which stderr should be piped
* @param isCLIcmd (default: true) Whether the command array is a balena CLI command
* (e.g. ['internal', 'osinit', ...]), in which case process.argv[0] and argv[1] are
* added as necessary, depending on whether the CLI is running as a standalone zip
* package (with Node built in).
*/
export async function sudo(
command: string[],
{ stderr, msg }: { stderr?: NodeJS.WritableStream; msg?: string } = {},
{
stderr,
msg,
isCLIcmd,
}: { stderr?: NodeJS.WritableStream; msg?: string; isCLIcmd?: boolean } = {},
) {
const { executeWithPrivileges } = require('./sudo');
const { executeWithPrivileges } = await import('./sudo');
if (os.platform() !== 'win32') {
console.log(
msg || 'If asked please type your computer password to continue',
msg ||
'Admin privileges required: you may be asked for your computer password to continue.',
);
}
return executeWithPrivileges(command, stderr);
if (isCLIcmd == null) {
isCLIcmd = true;
}
await executeWithPrivileges(command, stderr, isCLIcmd);
}
export function runCommand(command: string): Bluebird<void> {
@ -106,7 +130,7 @@ export async function getOsVersion(
return init.getImageOsVersion(image, manifest);
}
export function osProgressHandler(step: InitializeEmitter) {
export async function osProgressHandler(step: InitializeEmitter) {
step.on('stdout', process.stdout.write.bind(process.stdout));
step.on('stderr', process.stderr.write.bind(process.stderr));
@ -124,7 +148,10 @@ export function osProgressHandler(step: InitializeEmitter) {
step.on('burn', state => progressBars[state.type].update(state));
return waitStreamAsync(step);
await new Promise((resolve, reject) => {
step.on('error', reject);
step.on('end', resolve);
});
}
export function getArchAndDeviceType(
@ -272,3 +299,50 @@ export function getManualSortCompareFunction<T, U = T>(
}
};
}
/**
* Shell argument escaping compatible with sh, bash and Windows cmd.exe.
* @param arg Arguments to be escaped
* @param detectShell Whether to use the SHELL and ComSpec environment
* variables to determine the shell type (sh / bash / cmd.exe). This may be
* useful to detect MSYS / MSYS2, which use bash on Windows. However, if the
* purpose is to use child_process.spawn(..., {shell: true}) and related
* functions, set this to false because child_process.spawn() always uses
* env.ComSpec (cmd.exe) on Windows, even when running on MSYS / MSYS2.
*/
export function shellEscape(args: string[], detectShell = false): string[] {
let isWindowsCmdExeShell: boolean;
if (detectShell) {
isWindowsCmdExeShell =
// neither bash nor sh (e.g. not MSYS, MSYS2, WSL)
process.env.SHELL == null &&
// Windows cmd.exe or PowerShell
process.env.ComSpec != null &&
process.env.ComSpec.endsWith('cmd.exe');
} else {
isWindowsCmdExeShell = process.platform === 'win32';
}
if (isWindowsCmdExeShell) {
return args.map(v => windowsCmdExeEscapeArg(v));
} else {
const shellEscapeFunc: typeof ShellEscape = require('shell-escape');
return args.map(v => shellEscapeFunc([v]));
}
}
/**
* Escape a string argument to be passed through the Windows cmd.exe shell.
* cmd.exe escaping has some peculiarities, like using the caret character
* instead of a backslash for reserved / metacharacters. Reference:
* https://blogs.msdn.microsoft.com/twistylittlepassagesallalike/2011/04/23/everyone-quotes-command-line-arguments-the-wrong-way/
*/
function windowsCmdExeEscapeArg(arg: string): string {
// if it is already double quoted, remove the double quotes
if (arg.length > 1 && arg.startsWith('"') && arg.endsWith('"')) {
arg = arg.slice(1, -1);
}
// escape cmd.exe metacharacters with the '^' (caret) character
arg = arg.replace(/[()%!^<>&|]/g, '^$&');
// duplicate internal double quotes, and double quote overall
return `"${arg.replace(/["]/g, '""')}"`;
}

View File

@ -174,7 +174,7 @@ async function getOrSelectLocalDevice(deviceIp?: string): Promise<string> {
stream.pipe(process.stderr);
const { sudo } = await import('../utils/helpers');
const command = process.argv.slice(0, 2).concat(['internal', 'scandevices']);
const command = ['internal', 'scandevices'];
await sudo(command, {
stderr: stream,
msg:

View File

@ -14,34 +14,107 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { spawn, StdioOptions } from 'child_process';
import * as Bluebird from 'bluebird';
import * as rindle from 'rindle';
import { ChildProcess, spawn, SpawnOptions } from 'child_process';
import { stripIndent } from 'common-tags';
import * as _ from 'lodash';
/**
* Execute a child process with admin / superuser privileges, prompting the user for
* elevation as needed, and taking care of shell-escaping arguments in a suitable way
* for Windows and Linux/Mac.
*
* @param command Unescaped array of command and args to be executed. If isCLIcmd is
* true, the command should not include the 'node' or 'balena' components, for
* example: ['internal', 'osinit', ...]. This function will add argv[0] and argv[1]
* as needed (taking process.pkg into account -- CLI standalone zip package), and
* will also shell-escape the arguments as needed, taking into account the
* differences between bash/sh and the Windows cmd.exe in relation to escape
* characters.
* @param stderr Optional stream to which stderr should be piped
* @param isCLIcmd (default: true) Whether the command array is a balena CLI command
* (e.g. ['internal', 'osinit', ...]), in which case process.argv[0] and argv[1] are
* added as necessary, depending on whether the CLI is running as a standalone zip
* package (with Node built in).
*/
export async function executeWithPrivileges(
command: string[],
stderr?: NodeJS.WritableStream,
): Promise<string> {
const stdio: StdioOptions = [
'inherit',
'inherit',
stderr ? 'pipe' : 'inherit',
];
const opts = {
isCLIcmd = true,
): Promise<void> {
// whether the CLI is already running with admin / super user privileges
const isElevated = await (await import('is-elevated'))();
const { shellEscape } = await import('./helpers');
const opts: SpawnOptions = {
env: process.env,
stdio,
stdio: ['inherit', 'inherit', stderr ? 'pipe' : 'inherit'],
};
const args = process.argv
.slice(0, 2)
.concat(['internal', 'sudo', command.join(' ')]);
const ps = spawn(args[0], args.slice(1), opts);
if (stderr) {
ps.stderr.pipe(stderr);
if (isElevated) {
if (isCLIcmd) {
// opts.shell is false, so preserve pkg's '/snapshot' at argv[1]
command = [process.argv[0], process.argv[1], ...command];
}
// already running with privileges: simply spawn the command
await spawnAndPipe(command[0], command.slice(1), opts, stderr);
} else {
if (isCLIcmd) {
// In the case of a CLI standalone zip package (process.pkg is truthy),
// the Node executable is bundled with the source code and node_modules
// folder in a single file named in argv[0]. In this case, argv[1]
// contains a "/snapshot" path that should be discarded when opts.shell
// is true.
command = (process as any).pkg
? [process.argv[0], ...command]
: [process.argv[0], process.argv[1], ...command];
}
opts.shell = true;
const escapedCmd = shellEscape(command);
// running as ordinary user: elevate privileges
if (process.platform === 'win32') {
await windosuExec(escapedCmd, stderr);
} else {
await spawnAndPipe('sudo', escapedCmd, opts, stderr);
}
}
return Bluebird.fromCallback<string>(callback => rindle.wait(ps, callback));
}
async function spawnAndPipe(
spawnCmd: string,
spawnArgs: string[],
spawnOpts: SpawnOptions,
stderr?: NodeJS.WritableStream,
) {
await new Promise((resolve, reject) => {
const ps: ChildProcess = spawn(spawnCmd, spawnArgs, spawnOpts);
ps.on('error', reject);
ps.on('exit', codeOrSignal => {
if (codeOrSignal !== 0) {
const errMsgCmd = `[${[spawnCmd, ...spawnArgs].join()}]`;
reject(
new Error(
`Child process exited with error code "${codeOrSignal}" for command:\n${errMsgCmd}`,
),
);
} else {
resolve();
}
});
if (stderr) {
ps.stderr.pipe(stderr);
}
});
}
async function windosuExec(
escapedArgs: string[],
stderr?: NodeJS.WritableStream,
): Promise<void> {
if (stderr) {
const msg = stripIndent`
Error: unable to elevate privileges. Please run the command prompt as an Administrator:
https://www.howtogeek.com/194041/how-to-open-the-command-prompt-as-administrator-in-windows-8.1/
`;
throw new Error(msg);
}
return require('windosu').exec(escapedArgs.join(' '));
}

68
npm-shrinkwrap.json generated
View File

@ -2704,6 +2704,20 @@
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz",
"integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo="
},
"is-elevated": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-elevated/-/is-elevated-1.0.0.tgz",
"integrity": "sha1-+IThcowajY1ez2I/iHM8bm7h4u4=",
"requires": {
"is-admin": "^1.0.2",
"is-root": "^1.0.0"
}
},
"is-root": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-root/-/is-root-1.0.0.tgz",
"integrity": "sha1-B7bCM7w5TNnQK6FclmvWZg1jQtU="
}
}
},
@ -7599,12 +7613,22 @@
"integrity": "sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE="
},
"is-elevated": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-elevated/-/is-elevated-1.0.0.tgz",
"integrity": "sha1-+IThcowajY1ez2I/iHM8bm7h4u4=",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-elevated/-/is-elevated-3.0.0.tgz",
"integrity": "sha512-wjcp6RkouU9jpg55zERl+BglvV5j4jx5c/EMvQ+d12j/+nIEenNWPu+qc0tCg3JkLodbKZMg1qhJzEwG4qjclg==",
"requires": {
"is-admin": "^1.0.2",
"is-root": "^1.0.0"
"is-admin": "^3.0.0",
"is-root": "^2.1.0"
},
"dependencies": {
"is-admin": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-admin/-/is-admin-3.0.0.tgz",
"integrity": "sha512-wOa3CXFJAu8BZ2BDtG9xYOOrsq6oiSvc2jFPy4X/HINx5bmJUcW8e+apItVbU2E7GIfBVaFVO7Zit4oAWtTJcw==",
"requires": {
"execa": "^1.0.0"
}
}
}
},
"is-extendable": {
@ -7741,9 +7765,9 @@
"integrity": "sha1-EaBgVotnM5REAz0BJaYaINVk+zQ="
},
"is-root": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-root/-/is-root-1.0.0.tgz",
"integrity": "sha1-B7bCM7w5TNnQK6FclmvWZg1jQtU="
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-root/-/is-root-2.1.0.tgz",
"integrity": "sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg=="
},
"is-scoped": {
"version": "1.0.0",
@ -14993,15 +15017,15 @@
}
},
"resin-cli-visuals": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/resin-cli-visuals/-/resin-cli-visuals-1.4.3.tgz",
"integrity": "sha512-vzj6iTdWd0zq1pRXNJZmgMPL6cKS66lLOZlT4lr2kam/ohWFRQNZfmwigZRbrtUOOtBwdVujqSqYAP9USjX9NQ==",
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/resin-cli-visuals/-/resin-cli-visuals-1.4.4.tgz",
"integrity": "sha512-EchgufKC2loXoxB4xjnFtR2bJZbR5xNtJ/pVLc8G2But45TwgkORC5RDbWNe6fCdXJvgWFW1RgZ0Z0tc7Zd+Nw==",
"requires": {
"bluebird": "^3.5.1",
"chalk": "^2.3.1",
"cli-spinner": "0.2.7",
"columnify": "^1.5.1",
"drivelist": "^8.0.6",
"drivelist": "^8.0.7",
"inquirer": "^0.11.0",
"inquirer-dynamic-list": "^1.0.0",
"is-promise": "^2.1.0",
@ -15041,14 +15065,14 @@
}
},
"drivelist": {
"version": "8.0.6",
"resolved": "https://registry.npmjs.org/drivelist/-/drivelist-8.0.6.tgz",
"integrity": "sha512-KkXaGslqZP7Y13rofyFZBUl9dQrs0wqT5aXq5PAr67uPE2IDXEUYH+LNGTv69xpd7cUImjQvvB9H4bkziLIs1w==",
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/drivelist/-/drivelist-8.0.7.tgz",
"integrity": "sha512-KgFVzXux+rdRjzt1b1Vv4btqCmB/XC0wOfodtUev9MkJcL8VyxjTsopt+lE3GlSv4M+XPgS9dqf9fBowJ8I/2w==",
"requires": {
"bindings": "^1.3.0",
"debug": "^3.1.0",
"mz": "^2.7.0",
"nan": "^2.10.0",
"nan": "^2.14.0",
"prebuild-install": "^5.2.4"
}
},
@ -15099,6 +15123,11 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz",
"integrity": "sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y="
},
"nan": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz",
"integrity": "sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg=="
},
"run-async": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/run-async/-/run-async-0.1.0.tgz",
@ -17839,13 +17868,14 @@
}
},
"windosu": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/windosu/-/windosu-0.2.0.tgz",
"integrity": "sha1-oqGqymWHczDqCxtoXvPACQIKY4k=",
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/windosu/-/windosu-0.3.0.tgz",
"integrity": "sha1-Bhx/5KgRikrZ9P420/kA0ivApHk=",
"optional": true,
"requires": {
"cli-width": "^1.1.0",
"q": "~0.9.7",
"rimraf": "^2.5.2",
"tail": "~0.3.1",
"temp": "~0.6.0"
},

View File

@ -33,7 +33,9 @@
"build/actions-oclif",
"build/auth/pages/*.ejs",
"build/hooks",
"node_modules/resin-discoverable-services/services/**/*"
"node_modules/resin-discoverable-services/services/**/*",
"node_modules/windosu/*.bat",
"node_modules/windosu/*.cmd"
]
},
"scripts": {
@ -135,7 +137,6 @@
"publish-release": "^1.6.0",
"resin-lint": "^3.0.1",
"rewire": "^3.0.2",
"shell-escape": "^0.2.0",
"sinon": "^7.4.1",
"ts-node": "^8.1.0",
"typescript": "^3.4.5"
@ -184,7 +185,8 @@
"humanize": "0.0.9",
"ignore": "^5.1.1",
"inquirer": "^3.1.1",
"is-root": "^1.0.0",
"is-elevated": "^3.0.0",
"is-root": "^2.1.0",
"js-yaml": "^3.13.1",
"klaw": "^3.0.0",
"livepush": "^2.0.1",
@ -205,7 +207,7 @@
"reconfix": "^0.1.0",
"request": "^2.81.0",
"resin-cli-form": "^2.0.1",
"resin-cli-visuals": "^1.4.0",
"resin-cli-visuals": "^1.4.4",
"resin-compose-parse": "^2.1.0",
"resin-doodles": "0.0.1",
"resin-image-fs": "^5.0.8",
@ -216,6 +218,7 @@
"rimraf": "^2.4.3",
"rindle": "^1.3.4",
"semver": "^5.7.0",
"shell-escape": "^0.2.0",
"split": "^1.0.1",
"string-width": "^2.1.1",
"strip-ansi-stream": "^1.0.0",
@ -231,6 +234,6 @@
},
"optionalDependencies": {
"net-keepalive": "^1.2.1",
"windosu": "^0.2.0"
"windosu": "^0.3.0"
}
}

View File

@ -0,0 +1,38 @@
diff --git a/node_modules/windosu/lib/pipe.js b/node_modules/windosu/lib/pipe.js
index dc81fa5..a381cc7 100644
--- a/node_modules/windosu/lib/pipe.js
+++ b/node_modules/windosu/lib/pipe.js
@@ -42,7 +42,8 @@ function pipe(path, options) {
return d.promise;
}
module.exports = pipe;
-if (module === require.main) {
+
+function main() {
if (!process.argv[4]) {
console.error('Incorrect arguments!');
process.exit(-1);
@@ -52,3 +53,8 @@ if (module === require.main) {
serve: process.argv[3] == 'server'
});
}
+module.exports.main = main;
+
+if (module === require.main) {
+ main();
+}
diff --git a/node_modules/windosu/lib/windosu.js b/node_modules/windosu/lib/windosu.js
index 6502812..dd0391a 100644
--- a/node_modules/windosu/lib/windosu.js
+++ b/node_modules/windosu/lib/windosu.js
@@ -16,7 +16,9 @@ module.exports.exec = function (command, options, callback) {
temp: temp,
command: command,
cliWidth: cliWidth(),
- pipe: '"' + process.execPath + '" "' + path.join(__dirname, 'pipe.js') + '"',
+ pipe: process.pkg
+ ? '"' + process.execPath + '" pkgExec "' + path.join(__dirname, 'pipe.js') + '::main"'
+ : '"' + process.execPath + '" "' + path.join(__dirname, 'pipe.js') + '"',
input: inputName = id + '-in',
output: outputName = id + '-out',
stderr_redir: process.stdout.isTTY ? '2>&1' : '2> %ERROR%'

View File

@ -75,6 +75,8 @@ declare module 'balena-device-init' {
on(event: 'stdout' | 'stderr', callback: (msg: string) => void): void;
on(event: 'state', callback: (state: OperationState) => void): void;
on(event: 'burn', callback: (state: BurnProgress) => void): void;
on(event: 'end', callback: () => void): void;
on(event: 'error', callback: (error: Error) => void): void;
}
export function configure(