Get rid of application-wide elevation (#423)

Signed-off-by: Juan Cruz Viotti <jviottidc@gmail.com>
This commit is contained in:
Juan Cruz Viotti 2016-06-20 21:49:16 -04:00 committed by GitHub
parent 57169b8313
commit 95deab0b0d
19 changed files with 797 additions and 311 deletions

View File

@ -1,6 +1,6 @@
[Desktop Entry]
Name=Etcher
Exec=elevate.wrapper
Exec=etcher.wrapper
Comment=Burn images to SD cards & USB drives, safe & easy
Icon=icon
Type=Application

View File

@ -47,7 +47,6 @@ As mentioned before, the Etcher GUI forks the CLI and retrieves information from
- [Well-documented exit codes.](https://github.com/resin-io/etcher/blob/master/lib/src/exit-codes.js)
- A `--robot` option, which causes the Etcher CLI to output state in a way that can be easily machine-parsed.
- An `--ipc` option, which causes the Etcher CLI to use [NodeJS's built-in IPC channel](https://nodejs.org/api/child_process.html#child_process_child_send_message_sendhandle_options_callback) to emit messages from the child to the parent.
GUI fifty-thousand foot view
----------------------------

View File

@ -81,8 +81,8 @@ module.exports = yargs
})
.check(function(argv) {
if (argv.ipc && !process.send) {
throw new Error('Can\'t establish an IPC channel with the parent process');
if (argv.log && !argv.robot) {
throw new Error('The `--log` option requires `--robot`');
}
return true;
@ -115,10 +115,10 @@ module.exports = yargs
boolean: true,
alias: 'r'
},
ipc: {
describe: 'communicate through a fork IPC channel',
boolean: true,
alias: 'i'
log: {
describe: 'output log file',
string: true,
alias: 'l'
},
yes: {
describe: 'confirm non-interactively',

View File

@ -21,6 +21,7 @@ const form = require('resin-cli-form');
const writer = require('./writer');
const utils = require('./utils');
const options = require('./cli');
const log = require('./log');
const EXIT_CODES = require('../src/exit-codes');
form.run([
@ -42,7 +43,7 @@ form.run([
// If `options.yes` is `false`, pass `undefined`,
// otherwise the question will not be asked because
// `false` is a defined value.
yes: options.robot || options.ipc || options.yes || undefined
yes: options.robot || options.yes || undefined
}
}).then(function(answers) {
@ -63,23 +64,13 @@ form.run([
}, function(state) {
if (options.robot) {
console.log([
log.toStdout([
'progress',
state.type,
Math.floor(state.percentage) + '%',
state.eta + 's',
Math.floor(state.speed)
].join(' '));
} else if (options.ipc) {
process.send({
command: 'progress',
data: {
type: state.type,
percentage: Math.floor(state.percentage),
eta: state.eta,
speed: Math.floor(state.speed)
}
});
} else {
progressBars[state.type].update(state);
}
@ -88,12 +79,7 @@ form.run([
}).then(function(results) {
if (options.robot) {
console.log(`done ${results.passedValidation} ${results.sourceChecksum}`);
} else if (options.ipc) {
process.send({
command: 'done',
data: results
});
log.toStdout(`done ${results.passedValidation} ${results.sourceChecksum}`);
} else {
if (results.passedValidation) {
console.log('Your flash is complete!');
@ -112,15 +98,10 @@ form.run([
}).catch(function(error) {
if (options.robot) {
console.error(error.message);
} else if (options.ipc) {
process.send({
command: 'error',
data: error
});
log.toStderr(error.message);
} else {
utils.printError(error);
}
process.exit(EXIT_CODES.GENERAL_ERROR);
});
}).finally(log.close);

87
lib/cli/log.js Normal file
View File

@ -0,0 +1,87 @@
/*
* Copyright 2016 Resin.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict';
const fs = require('fs');
const options = require('./cli');
const logStream = options.log ? fs.createWriteStream(options.log) : null;
const STDOUT_STREAM = logStream || process.stdout;
const STDERR_STREAM = logStream || process.stderr;
/**
* The purpose of this module is to workaround an Electron Windows issue
* where an Electron process with `ELECTRON_RUN_AS_NODE` enabled will
* not attach `stdout`/`stderr` to the process, therefore not allowing
* us to redirect output to a file from `child_process` or console pipes.
*
* A temporary solution is to implement logic that redirects output
* to a log file in the Etcher CLI.
*
* TODO: Delete this file, and the corresponding `--log` option once
* this issue is fixed in Electron.
* See: https://github.com/electron/electron/issues/5715
*/
/**
* @summary Write a line to stdout
* @function
* @public
*
* @description
* If the `--log` option was passed, this function writes the line
* to it, otherwise to `process.stdout`.
*
* @param {String} line - line
*
* @example
* log.toStdout('Hello world!');
*/
exports.toStdout = function(line) {
STDOUT_STREAM.write(line + '\n');
};
/**
* @summary Write a line to stderr
* @function
* @public
*
* @description
* If the `--log` option was passed, this function writes the line
* to it, otherwise to `process.stderr`.
*
* @param {String} line - line
*
* @example
* log.toStderr('Hello world!');
*/
exports.toStderr = function(line) {
STDERR_STREAM.write(line + '\n');
};
/**
* @summary Close any used streams, if needed
* @function
* @public
*
* @example
* log.close();
*/
exports.close = function() {
if (logStream) {
logStream.close();
}
};

View File

@ -282,6 +282,11 @@ app.controller('AppController', function(
return self.writer.flash(image, drive).then(function(results) {
if (results.cancelled) {
self.writer.resetState();
return;
}
// TODO: Find a better way to manage flash/check
// success/error state than a global boolean flag.
self.success = results.passedValidation;

View File

@ -18,8 +18,6 @@
const electron = require('electron');
const path = require('path');
const elevate = require('../src/elevate');
const packageJSON = require('../../package.json');
let mainWindow = null;
electron.app.on('window-all-closed', electron.app.quit);
@ -29,13 +27,6 @@ electron.app.on('ready', function() {
// No menu bar
electron.Menu.setApplicationMenu(null);
elevate.require(electron.app, packageJSON.displayName, function(error) {
if (error) {
electron.dialog.showErrorBox('Elevation Error', error.message);
return process.exit(1);
}
mainWindow = new electron.BrowserWindow({
width: 800,
height: 380,
@ -92,5 +83,4 @@ electron.app.on('ready', function() {
mainWindow.loadURL(`file://${path.join(__dirname, 'index.html')}`);
});
});

View File

@ -21,20 +21,15 @@
*/
const angular = require('angular');
const path = require('path');
const isRunningInAsar = require('electron-is-running-in-asar');
const electron = require('electron');
const childProcess = require('child_process');
const EXIT_CODES = require('../../src/exit-codes');
const childWriter = require('../../src/child-writer');
const MODULE_NAME = 'Etcher.image-writer';
const imageWriter = angular.module(MODULE_NAME, [
require('../models/settings'),
require('../modules/analytics'),
require('../utils/notifier/notifier')
]);
imageWriter.service('ImageWriterService', function($q, $timeout, SettingsModel, NotifierService, AnalyticsService) {
imageWriter.service('ImageWriterService', function($q, $timeout, SettingsModel, NotifierService) {
let self = this;
let flashing = false;
@ -117,79 +112,11 @@ imageWriter.service('ImageWriterService', function($q, $timeout, SettingsModel,
* });
*/
this.performWrite = function(image, drive, onProgress) {
const argv = [ image ];
argv.push('--drive', drive.device);
// Make use of `child_process.fork()` facility to send
// messages to the parent through an IPC channel.
// This allows us to receive the progress state by
// listening to the `message` event of the
// `ChildProcess` object.
argv.push('--ipc');
// Explicitly set the boolen flag in positive
// or negative way in order to be on the safe
// side in case the Etcher CLI changes the
// default value of these options.
if (SettingsModel.data.unmountOnSuccess) {
argv.push('--unmount');
} else {
argv.push('--no-unmount');
}
if (SettingsModel.data.validateWriteOnSuccess) {
argv.push('--check');
} else {
argv.push('--no-check');
}
return $q(function(resolve, reject) {
let executable;
if (isRunningInAsar()) {
executable = path.join(process.resourcesPath, 'app.asar');
} else {
executable = electron.remote.process.argv[1];
}
AnalyticsService.log(`Forking: ${executable} ${argv.join(' ')}`);
const child = childProcess.fork(executable, argv, {
// Pipe stdout/stderr to the parent
// We're not using it directly but its
// handy for debugging reasons.
silent: true
});
child.on('message', function(message) {
switch (message.command) {
case 'progress': {
onProgress(message.data);
break;
}
case 'done': {
resolve(message.data);
break;
}
case 'error': {
reject(message.data);
break;
}
}
});
const child = childWriter.write(image, drive, SettingsModel.data);
child.on('error', reject);
child.on('close', function(code) {
if (code !== EXIT_CODES.SUCCESS && code !== EXIT_CODES.VALIDATION_ERROR) {
return reject(new Error(`Child process exitted with error code: ${code}`));
}
});
child.on('done', resolve);
child.on('progress', onProgress);
});
};

View File

@ -0,0 +1,35 @@
# Etcher Child Writer
This module is in charge of dealing with the gory details of elevating and managing the child writer process. As a word of warning, it contains tons of workarounds and "hacks" to deal with platform differences, packaging, and inter-process communication. This empowers us to write this small guide to explain how it works in a more high level manner, hoping to make it easier to grok for contributors.
## The problem
Elevating a forked process is an easy task. Thanks to the widely available NPM modules to display nice GUI prompt dialogs, elevation is just a matter of executing the process with one of those modules instead of with `child_process` directly.
The main problems we faced are:
- The modules that implement elevation provide "execution" support, but don't allow us to fork/spawn the process and consume its `stdout` and `stderr` in a stream fashion. This also means that we can't use the nice `process.send` IPC communication channel directly that `child_process.fork` gives us to send messages back to the parent.
- Since we can't assume anything from the environment Etcher is running on, we must make use of the same application entry point to execute both the GUI and the CLI code, which starts to get messy once we throw `asar` packaging into the mix.
- Each elevation mechanism has its quirks, mainly on GNU/Linux. Making sure that the forked process was elevated correctly and could work without issues required various workarounds targeting `pkexec` or `kdesudo`.
## How it works
The Etcher binary runs in CLI or GUI mode depending on an environment variable called `ELECTRON_RUN_AS_NODE`. When this variable is set, it instructs Electron to run as a normal NodeJS process (without Chromium, etc), but still keep any patches applied by Electron, like `asar` support.
When the Etcher GUI is ran, and the user presses the "Flash!" button, it passes all the required information, like the image path, the device path, current settings, etc; to the child writer module, which based on this information computes an array of arguments, including an option called `--robot`, which can be passed to the Etcher CLI to perform the writing as the user instructed. The `--robot` option basically tells the Etcher CLI to output state information in a way that can be very easily parsed by the parent process.
Afterwards, the child writer entry point *forks* yet another script passing all the command line arguments collected so far and blindly proxies all IPC messages from its child to the parent, the GUI process.
Once the new script starts, it creates a temporary log file, starts tailing it, parses what it gets, and sends any data to its parent using IPC messages (which are proxied to the GUI). After all is set, the script checks if its currently elevated, and if not, prompts the user for his password and re-executes itself, while keeping the original process alive and tailing.
Since elevation is done by executing, not forking, we lose IPC communication at this point. Since the non-elevated version of the script is alive, we can go ahead and spawn the Etcher CLI with all the arguments computed before, and redirect `stdout` to the temporary log file.
The non-elevated process receives the `--robot` output by tailing the temporary file, parses each line and sends back the results to the GUI, which nicely displays them in the progress bar the user sees.
## Summary
There are lots of details we're omitting for the sake of clarity. Feel free to dive in inside the child writer code, which is heavily commented to explain the reasons behind each decision or workaround.
Don't hesitate in getting in touch if you have any suggestion, or just want to know more!

View File

@ -0,0 +1,46 @@
/*
* Copyright 2016 Resin.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict';
const path = require('path');
/**
* @summary Child writer constants
* @namespace CONSTANTS
* @public
*/
module.exports = {
/**
* @property {String} PROJECT_ROOT
* @memberof CONSTANTS
*/
PROJECT_ROOT: path.join(__dirname, '..', '..', '..'),
/**
* @property {String} WRITER_PROXY_SCRIPT
* @memberof CONSTANTS
*/
WRITER_PROXY_SCRIPT: path.join(__dirname, 'writer-proxy.js'),
/**
* @property {String} TEMPORARY_LOG_FILE_ENVIRONMENT_VARIABLE
* @memberof CONSTANTS
*/
TEMPORARY_LOG_FILE_ENVIRONMENT_VARIABLE: 'ETCHER_INTERNAL_LOG_FILE'
};

View File

@ -0,0 +1,106 @@
/*
* Copyright 2016 Resin.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict';
const EventEmitter = require('events').EventEmitter;
const childProcess = require('child_process');
const rendererUtils = require('./renderer-utils');
const utils = require('./utils');
const CONSTANTS = require('./constants');
const EXIT_CODES = require('../exit-codes');
/**
* @summary Perform a write
* @function
* @public
*
* @param {String} image - image
* @param {Object} drive - drive
* @param {Object} options - options
* @returns {EventEmitter} event emitter
*
* @example
* const child = childWriter.write('path/to/rpi.img', {
* device: '/dev/disk2'
* }, {
* validateWriteOnSuccess: true,
* unmountOnSuccess: true
* });
*
* child.on('progress', function(state) {
* console.log(state);
* });
*
* child.on('error', function(error) {
* throw error;
* });
*
* child.on('done', function(results) {
* if (results.passedValidation) {
* console.log('Validation was successful!');
* }
* });
*/
exports.write = function(image, drive, options) {
const emitter = new EventEmitter();
utils.getTemporaryLogFilePath().then(function(logFile) {
const argv = utils.getCLIWriterArguments({
entryPoint: rendererUtils.getApplicationEntryPoint(),
logFile: logFile,
image: image,
device: drive.device,
validateWriteOnSuccess: options.validateWriteOnSuccess,
unmountOnSuccess: options.unmountOnSuccess
});
// Make writer proxy inherit the temporary log file location
// while keeping current environment variables intact.
process.env[CONSTANTS.TEMPORARY_LOG_FILE_ENVIRONMENT_VARIABLE] = logFile;
const child = childProcess.fork(CONSTANTS.WRITER_PROXY_SCRIPT, argv, {
silent: true,
env: process.env
});
child.stderr.on('data', function(data) {
emitter.emit('error', new Error(data.toString()));
});
child.on('message', function(message) {
emitter.emit(message.command, message.data);
});
child.on('error', function(error) {
emitter.emit('error', error);
});
child.on('close', function(code) {
if (code === EXIT_CODES.CANCELLED) {
return emitter.emit('done', {
cancelled: true
});
}
if (code !== EXIT_CODES.SUCCESS && code !== EXIT_CODES.VALIDATION_ERROR) {
return emitter.emit('error', new Error(`Child process exitted with error code: ${code}`));
}
});
});
return emitter;
};

View File

@ -0,0 +1,48 @@
/*
* Copyright 2016 Resin.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict';
/**
* This file is only meant to be loaded by the renderer process.
*/
const path = require('path');
const isRunningInAsar = require('electron-is-running-in-asar');
const electron = require('electron');
const CONSTANTS = require('./constants');
/**
* @summary Get application entry point
* @function
* @public
*
* @returns {String} entry point
*
* @example
* const entryPoint = rendererUtils.getApplicationEntryPoint();
*/
exports.getApplicationEntryPoint = function() {
if (isRunningInAsar()) {
return path.join(process.resourcesPath, 'app.asar');
}
// On GNU/Linux, `pkexec` resolves relative paths
// from `/root`, therefore we pass an absolute path,
// in order to be on the safe side.
return path.join(CONSTANTS.PROJECT_ROOT, electron.remote.process.argv[1]);
};

View File

@ -0,0 +1,181 @@
/*
* Copyright 2016 Resin.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict';
const _ = require('lodash');
const Bluebird = require('bluebird');
const tmp = Bluebird.promisifyAll(require('tmp'));
const packageJSON = require('../../../package.json');
/**
* @summary Get the explicit boolean form of an argument
* @function
* @private
*
* @description
* We refer as "explicit boolean form of an argument" to a boolean
* argument in either normal or negated form.
*
* For example: `--check` and `--no-check`;
*
* @param {String} argumentName - argument name
* @param {Boolean} value - argument value
* @returns {String} argument
*
* @example
* console.log(utils.getBooleanArgumentForm('check', true));
* > '--check'
*
* @example
* console.log(utils.getBooleanArgumentForm('check', false));
* > '--no-check'
*/
exports.getBooleanArgumentForm = function(argumentName, value) {
const prefix = value ? '--' : '--no-';
return prefix + argumentName;
};
/**
* @summary Get CLI writer arguments
* @function
* @public
*
* @param {Object} options - options
* @param {String} options.image - image
* @param {String} options.device - device
* @param {String} options.entryPoint - entry point
* @param {String} [options.logFile] - log file
* @param {Boolean} [options.validateWriteOnSuccess] - validate write on success
* @param {Boolean} [options.unmountOnSuccess] - unmount on success
* @returns {String[]} arguments
*
* @example
* const argv = utils.getCLIWriterArguments({
* image: 'path/to/rpi.img',
* device: '/dev/disk2'
* entryPoint: 'path/to/app.asar',
* validateWriteOnSuccess: true,
* unmountOnSuccess: true
* });
*/
exports.getCLIWriterArguments = function(options) {
const argv = [
options.entryPoint,
options.image,
'--robot',
'--drive',
options.device,
// Explicitly set the boolen flag in positive
// or negative way in order to be on the safe
// side in case the Etcher CLI changes the
// default value of these options.
exports.getBooleanArgumentForm('unmount', options.unmountOnSuccess),
exports.getBooleanArgumentForm('check', options.validateWriteOnSuccess)
];
// This temporarily workarounds a Windows Electron issue where
// we can't pipe `stdout`/`stderr` with system pipes.
// See: https://github.com/electron/electron/issues/5715
if (options.logFile) {
argv.push('--log', options.logFile);
}
return argv;
};
/**
* @summary Escape white spaces from arguments
* @function
* @public
*
* @param {String[]} argv - argv
* @returns {String[]} escaped arguments
*
* @example
* const escapedArguments = utils.escapeWhiteSpacesFromArguments(process.argv);
*/
exports.escapeWhiteSpacesFromArguments = function(argv) {
return _.map(argv, function(argument) {
return argument.replace(/\s/g, '\\ ');
});
};
/**
* @summary Get a temporary log file path
* @function
* @public
*
* @fulfil {String} - file path
* @returns {Promise}
*
* @example
* utils.getTemporaryLogFilePath().then(function(filePath) {
* console.log(filePath);
* });
*/
exports.getTemporaryLogFilePath = function() {
return tmp.fileAsync({
prefix: `${packageJSON.name}-`,
postfix: '.log'
}).then(function(logFilePath) {
return logFilePath;
});
};
/**
* @summary Parse an output line from the Etcher CLI
* @function
* @public
*
* @description
* This function parses an output line for when the CLI
* was called with the `--robot` argument.
*
* @param {String} line - line
* @returns {(Object|Undefined)} parsed output
*
* @example
* const data = utils.parseEtcherCLIRobotLine('progress write 50% 0.5s 400');
*/
exports.parseEtcherCLIRobotLine = function(line) {
const data = line.split(' ');
const command = _.first(data);
if (command === 'progress') {
return {
command: command,
data: {
type: _.nth(data, 1),
percentage: _.parseInt(_.nth(data, 2)),
eta: _.parseInt(_.nth(data, 3)),
speed: _.parseInt(_.nth(data, 4))
}
};
}
if (command === 'done') {
return {
command: command,
data: {
passedValidation: _.nth(data, 1) === 'true',
sourceChecksum: _.nth(data, 2)
}
};
}
};

View File

@ -0,0 +1,208 @@
/*
* Copyright 2016 Resin.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict';
const Bluebird = require('bluebird');
const childProcess = require('child_process');
const isElevated = Bluebird.promisify(require('is-elevated'));
const _ = require('lodash');
const os = require('os');
const Tail = require('tail').Tail;
const fileTail = require('file-tail');
const sudoPrompt = Bluebird.promisifyAll(require('sudo-prompt'));
const EXIT_CODES = require('../exit-codes');
const packageJSON = require('../../../package.json');
const CONSTANTS = require('./constants');
const utils = require('./utils');
// This script is in charge of spawning the writer process and
// ensuring it has the necessary privileges. It might look a bit
// complex at first sight, but this is only because elevation
// modules don't work in a spawn/fork fashion.
//
// This script spawns the writer process and redirects its `stdout`
// to a temporary 'log file', which is tailed by this same script.
// The output is then parsed, and sent as IPC messages to the
// parent process, taking care of the writer elevation as needed.
const EXECUTABLE = process.argv[0];
const ETCHER_ARGUMENTS = process.argv.slice(2);
return isElevated().then(function(elevated) {
const logFile = process.env[CONSTANTS.TEMPORARY_LOG_FILE_ENVIRONMENT_VARIABLE];
if (process.send) {
// Sadly, `fs.createReadStream()` won't work since
// the stream that function returns gets closed
// when it initially reaches EOF, instead of waiting
// for the file to receive more data.
const tail = _.attempt(function() {
if (os.platform() === 'win32') {
// This tail module overcomes various Windows
// issues by not using `fs.watch()`, but doesn't
// work 100% as expected on other operating systems,
// therefore we only use it on Windows.
return fileTail.startTailing(logFile);
}
return new Tail(logFile);
});
tail.on('error', function(error) {
console.error(error);
process.exit(1);
});
tail.on('line', function(line) {
const data = utils.parseEtcherCLIRobotLine(line);
if (data) {
process.send(data);
}
});
}
if (!elevated) {
if (os.platform() === 'win32') {
const elevator = Bluebird.promisifyAll(require('elevator'));
return elevator.executeAsync([
[
'set ELECTRON_RUN_AS_NODE=1 &&',
`set ${CONSTANTS.TEMPORARY_LOG_FILE_ENVIRONMENT_VARIABLE}=${logFile} &&`,
// This is a trick to make the binary afterwards catch
// the environment variables set just previously.
'call'
].concat(process.argv).join(' ')
], {
hidden: true,
terminating: true,
doNotPushdCurrentDirectory: true,
waitForTermination: true
}).catch({
code: 'ELEVATE_CANCELLED'
}, function() {
process.exit(EXIT_CODES.CANCELLED);
});
}
const command = _.attempt(function() {
const commandPrefix = [
// Some elevation tools, like `pkexec` or `kdesudo`, don't
// provide a way to preserve the environment, therefore we
// have to make sure the environment variables we're interested
// in are manually inherited.
'env',
'ELECTRON_RUN_AS_NODE=1',
`${CONSTANTS.TEMPORARY_LOG_FILE_ENVIRONMENT_VARIABLE}=${logFile}`
];
// Executing a binary from inside an AppImage as other user
// (e.g: `root`) fails with a permission error because of a
// security measure imposed by FUSE.
//
// As a workaround, if we're inside an AppImage, we re-mount
// the same AppImage to another temporary location without
// FUSE, and re-call to writer proxy as `root` from there.
if (process.env.APPIMAGE && process.env.APPDIR) {
const mountPoint = process.env.APPDIR + '-elevated';
// We wrap the command with `sh -c` since it seems
// the only way to effectively run many commands
// with a graphical sudo interface,
return 'sh -c \'' + [
'mkdir',
'-p',
mountPoint,
'&&',
'mount',
'-o',
'loop',
// We re-mount the AppImage as "read-only", since `mount`
// will refuse to mount the same AppImage in two different
// locations otherwise.
'-o',
'ro',
process.env.APPIMAGE,
mountPoint,
'&&'
]
.concat(commandPrefix)
// Translate the current arguments to
// point to the new mount location.
.concat(_.map(process.argv, function(argv) {
return argv.replace(process.env.APPDIR, mountPoint);
}))
.concat([
';',
// We need to sleep for a little bit for `umount` to
// succeed, otherwise it complains with an `EBUSY` error.
'sleep',
'1',
';',
'umount',
mountPoint
]).join(' ') + '\'';
}
return commandPrefix.concat(
utils.escapeWhiteSpacesFromArguments(process.argv)
).join(' ');
});
return sudoPrompt.execAsync(command, {
name: packageJSON.displayName
}).then(function(stdout, stderr) {
if (!_.isEmpty(stderr)) {
throw new Error(stderr);
}
}).catch({
message: 'User did not grant permission.'
}, function() {
process.exit(EXIT_CODES.CANCELLED);
});
}
return new Bluebird(function(resolve, reject) {
const child = childProcess.spawn(EXECUTABLE, ETCHER_ARGUMENTS);
child.on('error', reject);
child.on('close', resolve);
}).then(function(exitCode) {
process.exit(exitCode);
});
}).catch(function(error) {
console.error(error);
process.exit(EXIT_CODES.GENERAL_ERROR);
});

View File

@ -1,65 +0,0 @@
/*
* Copyright 2016 Resin.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict';
const Bluebird = require('bluebird');
const isElevated = Bluebird.promisify(require('is-elevated'));
const sudoPrompt = Bluebird.promisifyAll(require('sudo-prompt'));
const platform = require('os').platform();
exports.require = function(app, applicationName, callback) {
return isElevated().then(function(elevated) {
if (elevated) {
return;
}
return Bluebird.try(function() {
// This environment variable is usually set
// for debugging purposes, or for power users
// that really know what they're doing.
if (!process.env.ETCHER_BYPASS_ELEVATION) {
if (platform === 'darwin') {
// Keep parent process hidden
app.dock.hide();
return sudoPrompt.execAsync(process.argv.join(' '), {
name: applicationName
});
}
if (platform === 'win32') {
const elevator = Bluebird.promisifyAll(require('elevator'));
return elevator.executeAsync(process.argv, {});
}
}
throw new Error('Please run this application as root or administrator');
}).then(function() {
// Don't keep the original parent process alive
setTimeout(function() {
process.exit(0);
});
});
}).nodeify(callback);
};

View File

@ -52,6 +52,16 @@ module.exports = {
* @description
* This exit code is used to represent a validation error.
*/
VALIDATION_ERROR: 2
VALIDATION_ERROR: 2,
/**
* @property {Number} CANCELLED
* @memberof EXIT_CODES
*
* @description
* This exit code is used to represent a cancelled write process.
*/
CANCELLED: 3
};

View File

@ -45,7 +45,7 @@
}
},
"optionalDependencies": {
"elevator": "^1.0.0",
"elevator": "^2.1.0",
"removedrive": "^1.0.0"
},
"dependencies": {
@ -62,6 +62,7 @@
"electron-is-running-in-asar": "^1.0.0",
"etcher-image-stream": "^2.1.0",
"etcher-image-write": "^5.0.1",
"file-tail": "^0.3.0",
"flexboxgrid": "^6.3.0",
"is-elevated": "^1.0.0",
"lodash": "^4.5.1",
@ -70,7 +71,9 @@
"resin-cli-form": "^1.4.1",
"resin-cli-visuals": "^1.2.8",
"semver": "^5.1.0",
"sudo-prompt": "^3.1.0",
"sudo-prompt": "^5.0.3",
"tail": "^1.1.0",
"tmp": "0.0.28",
"trackjs": "^2.1.16",
"umount": "^1.1.3",
"username": "^2.1.0",

View File

@ -139,8 +139,7 @@ function app_dir_create {
cp ./Etcher.desktop $output_directory
cp ./assets/icon.png $output_directory
cp -rf $source_directory/* $output_directory/usr/bin
cp ./scripts/desktopintegration $output_directory/usr/bin/elevate.wrapper
cp ./scripts/elevate-linux.sh $output_directory/usr/bin/elevate
cp ./scripts/desktopintegration $output_directory/usr/bin/etcher.wrapper
}
function installer {

View File

@ -1,74 +0,0 @@
#!/bin/bash
###
# Copyright 2016 Resin.io
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
###
set -e
binary=usr/bin/etcher
error() {
if [ -x /usr/bin/zenity ] ; then
LD_LIBRARY_PATH="" zenity --error --text "${1}" 2>/dev/null
elif [ -x /usr/bin/kdialog ] ; then
LD_LIBRARY_PATH="" kdialog --msgbox "${1}" 2>/dev/null
elif [ -x /usr/bin/Xdialog ] ; then
LD_LIBRARY_PATH="" Xdialog --msgbox "${1}" 2>/dev/null
else
echo "${1}"
fi
exit 1
}
# Check if we're running as root
if [ "$EUID" -eq 0 ]; then
$APPDIR/$binary
else
# Determine a unique mountpoint based on the current mount point.
mountpoint=$(mount | grep $(basename $APPIMAGE) | grep fuse | head -n 1 | awk '{ print $3 }')-elevated
# We remount the AppImage to be able to workaround FUSE a
# security measure of not allowing root to run binaries
# from FUSE mounted filesystems.
#
# - The `ro` option is required, since otherwise `mount` will
# refuse to mount the same AppImage in two different locations.
#
# - We don't want to go through the desktop integration helper
# again, so we call the `etcher` binary directly.
#
# - We need to wait for a little bit for `umount` to be
# successfull, otherwise it complains with an `EBUSY` error.
runcommand="mkdir -p $mountpoint && mount -o loop -o ro $APPIMAGE $mountpoint && $mountpoint/$binary; sleep 1; umount $mountpoint"
# We prefer gksudo since it gives a nicer looking dialog
if command -v gksudo 2>/dev/null; then
gksudo --preserve-env --description "Etcher" -- sh -c "$runcommand"
elif command -v kdesudo 2>/dev/null; then
kdesudo -d --comment "Etcher" -- sh -c "$runcommand"
elif command -v pkexec 2>/dev/null; then
# We need to inherit DISPLAY and XAUTHORITY, otherwise
# pkexec will not know how to run X11 applications.
# See http://askubuntu.com/a/332847
pkexec env DISPLAY=$DISPLAY XAUTHORITY=$XAUTHORITY sh -c "$runcommand"
else
error "Please install gksudo, kdesudo, or pkexec to run this application."
fi
fi