diff --git a/lib/gui/app/components/settings/settings.tsx b/lib/gui/app/components/settings/settings.tsx index d2f2110c..134c9ea8 100644 --- a/lib/gui/app/components/settings/settings.tsx +++ b/lib/gui/app/components/settings/settings.tsx @@ -23,6 +23,7 @@ import { Badge, Checkbox, Modal } from 'rendition'; import styled from 'styled-components'; import { version } from '../../../../../package.json'; +import { Dictionary } from '../../../../shared/utils'; import * as settings from '../../models/settings'; import * as store from '../../models/store'; import * as analytics from '../../modules/analytics'; @@ -118,10 +119,6 @@ interface SettingsModalProps { toggleModal: (value: boolean) => void; } -interface Dictionary { - [key: string]: T; -} - export const SettingsModal: any = styled( ({ toggleModal }: SettingsModalProps) => { const [currentSettings, setCurrentSettings]: [ diff --git a/lib/gui/app/modules/image-writer.js b/lib/gui/app/modules/image-writer.js index 0a92f4a9..1a94481c 100644 --- a/lib/gui/app/modules/image-writer.js +++ b/lib/gui/app/modules/image-writer.js @@ -27,6 +27,7 @@ const settings = require('../models/settings') const flashState = require('../models/flash-state') // eslint-disable-next-line node/no-missing-require const errors = require('../../../shared/errors') +// eslint-disable-next-line node/no-missing-require const permissions = require('../../../shared/permissions') // eslint-disable-next-line node/no-missing-require const windowProgress = require('../os/window-progress') diff --git a/lib/shared/errors.ts b/lib/shared/errors.ts index fa0233bf..b430b969 100644 --- a/lib/shared/errors.ts +++ b/lib/shared/errors.ts @@ -198,7 +198,7 @@ export function createError(options: { export function createUserError(options: { title: string; description: string; - code: string; + code?: string; }): Error { return createError({ title: options.title, diff --git a/lib/shared/permissions.js b/lib/shared/permissions.js deleted file mode 100755 index 66f7c0f8..00000000 --- a/lib/shared/permissions.js +++ /dev/null @@ -1,241 +0,0 @@ -/* - * Copyright 2017 balena.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. - */ - -/* eslint-disable lodash/prefer-lodash-method,quotes,no-magic-numbers,require-jsdoc */ - -'use strict' - -const bindings = require('bindings') -const Bluebird = require('bluebird') -const childProcess = Bluebird.promisifyAll(require('child_process')) -const fs = require('fs') -const _ = require('lodash') -const os = require('os') -const semver = require('semver') -const sudoPrompt = Bluebird.promisifyAll(require('sudo-prompt')) -const { promisify } = require('util') - -// eslint-disable-next-line node/no-missing-require -const errors = require('./errors') - -// eslint-disable-next-line node/no-missing-require -const { tmpFileDisposer } = require('./utils') -// eslint-disable-next-line node/no-missing-require -const { sudo: catalinaSudo } = require('./catalina-sudo/sudo') - -const writeFileAsync = promisify(fs.writeFile) - -/** - * @summary The user id of the UNIX "superuser" - * @constant - * @type {Number} - */ -const UNIX_SUPERUSER_USER_ID = 0 - -/** - * @summary Check if the current process is running with elevated permissions - * @function - * @public - * - * @description - * This function has been adapted from https://github.com/sindresorhus/is-elevated, - * which was originally licensed under MIT. - * - * We're not using such module directly given that it - * contains dependencies with dynamic undeclared dependencies, - * causing a mess when trying to concatenate the code. - * - * @fulfil {Boolean} - whether the current process has elevated permissions - * @returns {Promise} - * - * @example - * permissions.isElevated().then((isElevated) => { - * if (isElevated) { - * console.log('This process has elevated permissions'); - * } - * }); - */ -exports.isElevated = () => { - if (os.platform() === 'win32') { - // `fltmc` is available on WinPE, XP, Vista, 7, 8, and 10 - // Works even when the "Server" service is disabled - // See http://stackoverflow.com/a/28268802 - return childProcess.execAsync('fltmc') - .then(_.constant(true)) - .catch({ - code: os.constants.errno.EPERM - }, _.constant(false)) - } - - return Bluebird.resolve(process.geteuid() === UNIX_SUPERUSER_USER_ID) -} - -/** - * @summary Check if the current process is running with elevated permissions - * @function - * @public - * - * @description - * - * @returns {Boolean} - * - * @example - * permissions.isElevatedUnixSync() - * if (isElevated) { - * console.log('This process has elevated permissions'); - * } - * }); - */ -exports.isElevatedUnixSync = () => { - return (process.geteuid() === UNIX_SUPERUSER_USER_ID) -} - -const escapeSh = (value) => { - // Make sure it's a string - // Replace ' -> '\'' (closing quote, escaped quote, opening quote) - // Surround with quotes - return `'${String(value).replace(/'/g, "'\\''")}'` -} - -const escapeParamCmd = (value) => { - // Make sure it's a string - // Escape " -> \" - // Surround with double quotes - return `"${String(value).replace(/"/g, '\\"')}"` -} - -const setEnvVarSh = (value, name) => { - return `export ${name}=${escapeSh(value)}` -} - -const setEnvVarCmd = (value, name) => { - return `set "${name}=${String(value)}"` -} - -// Exported for tests -exports.createLaunchScript = (command, argv, environment) => { - const isWindows = os.platform() === 'win32' - const lines = [] - if (isWindows) { - // Switch to utf8 - lines.push('chcp 65001') - } - const [ setEnvVarFn, escapeFn ] = isWindows ? [ setEnvVarCmd, escapeParamCmd ] : [ setEnvVarSh, escapeSh ] - lines.push(..._.map(environment, setEnvVarFn)) - lines.push([ command, ...argv ].map(escapeFn).join(' ')) - return lines.join(os.EOL) -} - -const elevateScriptWindows = async (path) => { - // 'elevator' imported here as it only exists on windows - // TODO: replace this with sudo-prompt once https://github.com/jorangreef/sudo-prompt/issues/96 is fixed - const elevateAsync = promisify(bindings({ bindings: 'elevator' }).elevate) - - // '&' needs to be escaped here (but not when written to a .cmd file) - const cmd = [ 'cmd', '/c', escapeParamCmd(path).replace(/&/g, '^&') ] - const { cancelled } = await elevateAsync(cmd) - return { cancelled } -} - -const elevateScriptUnix = async (path, name) => { - const cmd = [ 'bash', escapeSh(path) ].join(' ') - const [ , stderr ] = await sudoPrompt.execAsync(cmd, { name }) - if (!_.isEmpty(stderr)) { - throw errors.createError({ title: stderr }) - } - return { cancelled: false } -} - -const elevateScriptCatalina = async (path) => { - const cmd = [ 'bash', escapeSh(path) ].join(' ') - try { - const { cancelled } = await catalinaSudo(cmd) - return { cancelled } - } catch (error) { - return errors.createError({ title: error.stderr }) - } -} - -/** - * @summary Elevate a command - * @function - * @public - * - * @param {String[]} command - command arguments - * @param {Object} options - options - * @param {String} options.applicationName - application name - * @param {Object} options.environment - environment variables - * @fulfil {Object} - elevation results - * @returns {Promise} - * - * @example - * permissions.elevateCommand([ 'foo', 'bar' ], { - * applicationName: 'My App', - * environment: { - * FOO: 'bar' - * } - * }).then((results) => { - * if (results.cancelled) { - * console.log('Elevation has been cancelled'); - * } - * }); - */ -exports.elevateCommand = async (command, options) => { - if (await exports.isElevated()) { - await childProcess.execFileAsync(command[0], command.slice(1), { env: options.environment }) - return { cancelled: false } - } - const isWindows = os.platform() === 'win32' - const launchScript = exports.createLaunchScript(command[0], command.slice(1), options.environment) - return Bluebird.using(tmpFileDisposer({ postfix: '.cmd' }), async ({ path }) => { - await writeFileAsync(path, launchScript) - if (isWindows) { - return elevateScriptWindows(path) - } - if (os.platform() === 'darwin' && semver.compare(os.release(), '19.0.0') >= 0) { - // >= macOS Catalina - return elevateScriptCatalina(path) - } - try { - return await elevateScriptUnix(path, options.applicationName) - } catch (error) { - // We're hardcoding internal error messages declared by `sudo-prompt`. - // There doesn't seem to be a better way to handle these errors, so - // for now, we should make sure we double check if the error messages - // have changed every time we upgrade `sudo-prompt`. - console.log('error', error) - if (_.includes(error.message, 'is not in the sudoers file')) { - throw errors.createUserError({ - title: "Your user doesn't have enough privileges to proceed", - description: 'This application requires sudo privileges to be able to write to drives' - }) - } else if (_.startsWith(error.message, 'Command failed:')) { - throw errors.createUserError({ - title: 'The elevated process died unexpectedly', - description: `The process error code was ${error.code}` - }) - } else if (error.message === 'User did not grant permission.') { - return { cancelled: true } - } else if (error.message === 'No polkit authentication agent found.') { - throw errors.createUserError({ - title: 'No polkit authentication agent found', - description: 'Please install a polkit authentication agent for your desktop environment of choice to continue' - }) - } - throw error - } - }) -} diff --git a/lib/shared/permissions.ts b/lib/shared/permissions.ts new file mode 100755 index 00000000..6deac918 --- /dev/null +++ b/lib/shared/permissions.ts @@ -0,0 +1,206 @@ +/* + * Copyright 2017 balena.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. + */ + +import bindings = require('bindings'); +import * as Bluebird from 'bluebird'; +import * as childProcess from 'child_process'; +import { promises as fs } from 'fs'; +import * as _ from 'lodash'; +import * as os from 'os'; +import * as semver from 'semver'; +import * as sudoPrompt from 'sudo-prompt'; +import { promisify } from 'util'; + +import { sudo as catalinaSudo } from './catalina-sudo/sudo'; +import * as errors from './errors'; +import { Dictionary, tmpFileDisposer } from './utils'; + +const execAsync = promisify(childProcess.exec); +const execFileAsync = promisify(childProcess.execFile); +const sudoExecAsync = promisify(sudoPrompt.exec); + +/** + * @summary The user id of the UNIX "superuser" + */ +const UNIX_SUPERUSER_USER_ID = 0; + +export async function isElevated(): Promise { + if (os.platform() === 'win32') { + // `fltmc` is available on WinPE, XP, Vista, 7, 8, and 10 + // Works even when the "Server" service is disabled + // See http://stackoverflow.com/a/28268802 + try { + await execAsync('fltmc'); + } catch (error) { + if (error.code === os.constants.errno.EPERM) { + return false; + } + throw error; + } + return true; + } + return process.geteuid() === UNIX_SUPERUSER_USER_ID; +} + +/** + * @summary Check if the current process is running with elevated permissions + */ +export function isElevatedUnixSync(): boolean { + return process.geteuid() === UNIX_SUPERUSER_USER_ID; +} + +function escapeSh(value: any): string { + // Make sure it's a string + // Replace ' -> '\'' (closing quote, escaped quote, opening quote) + // Surround with quotes + return `'${String(value).replace(/'/g, "'\\''")}'`; +} + +function escapeParamCmd(value: any): string { + // Make sure it's a string + // Escape " -> \" + // Surround with double quotes + return `"${String(value).replace(/"/g, '\\"')}"`; +} + +function setEnvVarSh(value: any, name: string): string { + return `export ${name}=${escapeSh(value)}`; +} + +function setEnvVarCmd(value: any, name: string): string { + return `set "${name}=${String(value)}"`; +} + +// Exported for tests +export function createLaunchScript( + command: string, + argv: string[], + environment: Dictionary, +): string { + const isWindows = os.platform() === 'win32'; + const lines = []; + if (isWindows) { + // Switch to utf8 + lines.push('chcp 65001'); + } + const [setEnvVarFn, escapeFn] = isWindows + ? [setEnvVarCmd, escapeParamCmd] + : [setEnvVarSh, escapeSh]; + lines.push(..._.map(environment, setEnvVarFn)); + lines.push([command, ...argv].map(escapeFn).join(' ')); + return lines.join(os.EOL); +} + +async function elevateScriptWindows( + path: string, +): Promise<{ cancelled: boolean }> { + // 'elevator' imported here as it only exists on windows + // TODO: replace this with sudo-prompt once https://github.com/jorangreef/sudo-prompt/issues/96 is fixed + const elevateAsync = promisify(bindings('elevator').elevate); + + // '&' needs to be escaped here (but not when written to a .cmd file) + const cmd = ['cmd', '/c', escapeParamCmd(path).replace(/&/g, '^&')]; + const { cancelled } = await elevateAsync(cmd); + return { cancelled }; +} + +async function elevateScriptUnix( + path: string, + name: string, +): Promise<{ cancelled: boolean }> { + const cmd = ['bash', escapeSh(path)].join(' '); + const [, stderr] = await sudoExecAsync(cmd, { name }); + if (!_.isEmpty(stderr)) { + throw errors.createError({ title: stderr }); + } + return { cancelled: false }; +} + +async function elevateScriptCatalina( + path: string, +): Promise<{ cancelled: boolean }> { + const cmd = ['bash', escapeSh(path)].join(' '); + try { + const { cancelled } = await catalinaSudo(cmd); + return { cancelled }; + } catch (error) { + throw errors.createError({ title: error.stderr }); + } +} + +export async function elevateCommand( + command: string[], + options: { environment: Dictionary; applicationName: string }, +): Promise<{ cancelled: boolean }> { + if (await exports.isElevated()) { + await execFileAsync(command[0], command.slice(1), { + env: options.environment, + }); + return { cancelled: false }; + } + const isWindows = os.platform() === 'win32'; + const launchScript = exports.createLaunchScript( + command[0], + command.slice(1), + options.environment, + ); + return Bluebird.using( + tmpFileDisposer({ postfix: '.cmd' }), + async ({ path }) => { + await fs.writeFile(path, launchScript); + if (isWindows) { + return elevateScriptWindows(path); + } + if ( + os.platform() === 'darwin' && + semver.compare(os.release(), '19.0.0') >= 0 + ) { + // >= macOS Catalina + return elevateScriptCatalina(path); + } + try { + return await elevateScriptUnix(path, options.applicationName); + } catch (error) { + // We're hardcoding internal error messages declared by `sudo-prompt`. + // There doesn't seem to be a better way to handle these errors, so + // for now, we should make sure we double check if the error messages + // have changed every time we upgrade `sudo-prompt`. + console.log('error', error); + if (_.includes(error.message, 'is not in the sudoers file')) { + throw errors.createUserError({ + title: "Your user doesn't have enough privileges to proceed", + description: + 'This application requires sudo privileges to be able to write to drives', + }); + } else if (_.startsWith(error.message, 'Command failed:')) { + throw errors.createUserError({ + title: 'The elevated process died unexpectedly', + description: `The process error code was ${error.code}`, + }); + } else if (error.message === 'User did not grant permission.') { + return { cancelled: true }; + } else if (error.message === 'No polkit authentication agent found.') { + throw errors.createUserError({ + title: 'No polkit authentication agent found', + description: + 'Please install a polkit authentication agent for your desktop environment of choice to continue', + }); + } + throw error; + } + }, + ); +} diff --git a/lib/shared/utils.ts b/lib/shared/utils.ts index 899d596f..c9e873c5 100755 --- a/lib/shared/utils.ts +++ b/lib/shared/utils.ts @@ -24,6 +24,10 @@ import * as errors from './errors'; const getAsync = promisify(request.get); +export interface Dictionary { + [key: string]: T; +} + export function isValidPercentage(percentage: any): boolean { return _.every([_.isNumber(percentage), percentage >= 0, percentage <= 100]); } diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index ae8c3517..f83b8d04 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1092,6 +1092,12 @@ "defer-to-connect": "^1.0.1" } }, + "@types/bindings": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@types/bindings/-/bindings-1.3.0.tgz", + "integrity": "sha512-mTWOE6wC64MoEpv33otJNpQob81l5Pi+NsUkdiiP8EkESraQM94zuus/2s/Vz2Idy1qQkctNINYDZ61nfG1ngQ==", + "dev": true + }, "@types/bluebird": { "version": "3.5.28", "resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.5.28.tgz", @@ -1317,6 +1323,12 @@ "@types/htmlparser2": "*" } }, + "@types/semver": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-6.2.0.tgz", + "integrity": "sha512-1OzrNb4RuAzIT7wHSsgZRlMBlNsJl+do6UblR7JMW4oB7bbR+uBEYtUh7gEc/jM84GGilh68lSOokyM/zNUlBA==", + "dev": true + }, "@types/styled-components": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/@types/styled-components/-/styled-components-4.1.8.tgz", diff --git a/package.json b/package.json index 3a6dcfe4..d2f1471e 100644 --- a/package.json +++ b/package.json @@ -94,10 +94,12 @@ "@babel/plugin-proposal-function-bind": "^7.2.0", "@babel/preset-env": "^7.6.0", "@babel/preset-react": "^7.0.0", + "@types/bindings": "^1.3.0", "@types/mime-types": "^2.1.0", "@types/node": "^12.12.24", "@types/react-dom": "^16.8.4", "@types/request": "^2.48.4", + "@types/semver": "^6.2.0", "@types/tmp": "^0.1.0", "babel-loader": "^8.0.4", "chalk": "^1.1.3", diff --git a/tests/shared/permissions.spec.js b/tests/shared/permissions.spec.js index c80f5459..f622eac0 100644 --- a/tests/shared/permissions.spec.js +++ b/tests/shared/permissions.spec.js @@ -20,6 +20,7 @@ const m = require('mochainon') const os = require('os') +// eslint-disable-next-line node/no-missing-require const permissions = require('../../lib/shared/permissions') describe('Shared: permissions', function () { diff --git a/typings/sudo-prompt/index.d.ts b/typings/sudo-prompt/index.d.ts new file mode 100644 index 00000000..2bafb92b --- /dev/null +++ b/typings/sudo-prompt/index.d.ts @@ -0,0 +1 @@ +declare module 'sudo-prompt';