mirror of
https://github.com/balena-io/etcher.git
synced 2025-07-29 22:26:32 +00:00
patch: refactor permission code
This commit is contained in:
parent
391164bf15
commit
f3123f3cbe
@ -98,7 +98,8 @@ async function connectToChildProcess(
|
||||
): Promise<ChildApi | { failed: boolean }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// TODO: default to IPC connections https://github.com/websockets/ws/blob/master/doc/ws.md#ipc-connections
|
||||
// TOOD: use the path as cheap authentication
|
||||
// TODO: use the path as cheap authentication
|
||||
|
||||
console.log(etcherServerId);
|
||||
|
||||
const url = `ws://${etcherServerAddress}:${etcherServerPort}`;
|
||||
@ -196,9 +197,9 @@ async function spawnChildAndConnect({
|
||||
`etcher-${Math.random().toString(36).substring(7)}`;
|
||||
|
||||
console.log(
|
||||
`Spawning ${
|
||||
`Starting ${
|
||||
withPrivileges ? 'priviledged' : 'unpriviledged'
|
||||
} sidecar on port ${etcherServerPort}`,
|
||||
} flasher sidecar on port ${etcherServerPort}`,
|
||||
);
|
||||
|
||||
// spawn the child process, which will act as the ws server
|
||||
@ -212,11 +213,11 @@ async function spawnChildAndConnect({
|
||||
etcherServerPort,
|
||||
);
|
||||
if (result.cancelled) {
|
||||
throw new Error('Spwaning the child process was cancelled');
|
||||
throw new Error('Starting flasher sidecar process was cancelled');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error spawning child process', error);
|
||||
throw new Error('Error spawning the child process');
|
||||
console.error('Error starting flasher sidecar process', error);
|
||||
throw new Error('Error starting flasher sidecar process');
|
||||
}
|
||||
}
|
||||
|
||||
@ -232,7 +233,7 @@ async function spawnChildAndConnect({
|
||||
if (failed) {
|
||||
retry++;
|
||||
console.log(
|
||||
`Retrying to connect to child process in ${connectionRetryDelay}... ${retry} / ${connectionRetryAttempts}`,
|
||||
`Connection to sidecar flasher process attempt ${retry} / ${connectionRetryAttempts} failed; retrying in ${connectionRetryDelay}ms...`,
|
||||
);
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, connectionRetryDelay),
|
||||
@ -241,10 +242,11 @@ async function spawnChildAndConnect({
|
||||
}
|
||||
return { failed, emit, registerHandler };
|
||||
}
|
||||
throw new Error('Connection to etcher-util timed out');
|
||||
// TODO: raised an error to the user if we reach this point
|
||||
throw new Error('Connection to sidecar flasher process timed out');
|
||||
} catch (error) {
|
||||
console.error('Error connecting to child process', error);
|
||||
throw new Error('Connection to etcher-util failed');
|
||||
console.error('Error connecting to sidecar flasher process process', error);
|
||||
throw new Error('Connection to sidecar flasher process failed');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -34,8 +34,6 @@ export function fromFlashState({
|
||||
status: string;
|
||||
position?: string;
|
||||
} {
|
||||
console.log(i18next.t('progress.starting'));
|
||||
|
||||
if (type === undefined) {
|
||||
return { status: i18next.t('progress.starting') };
|
||||
} else if (type === 'decompressing') {
|
||||
|
@ -14,16 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* TODO:
|
||||
* This is convoluted and needlessly complex. It should be simplified and modernized.
|
||||
* The environment variable setting and escaping should be greatly simplified by letting {linux|catalina}-sudo handle that.
|
||||
* We shouldn't need to write a script to a file and then execute it. We should be able to forwatd the command to the sudo code directly.
|
||||
*/
|
||||
|
||||
import { spawn, exec } from 'child_process';
|
||||
import { withTmpFile } from 'etcher-sdk/build/tmp';
|
||||
import { promises as fs } from 'fs';
|
||||
import { promisify } from 'util';
|
||||
import * as _ from 'lodash';
|
||||
import * as os from 'os';
|
||||
@ -41,6 +32,32 @@ const execAsync = promisify(exec);
|
||||
*/
|
||||
const UNIX_SUPERUSER_USER_ID = 0;
|
||||
|
||||
// Augment the command to pass the environment variables as args
|
||||
// This is required because both windows and linux sudo commands strips the environment
|
||||
// variables when running the elevated command, so we need to pass them as arguments
|
||||
function commandWithEnv(
|
||||
command: string[],
|
||||
env: _.Dictionary<string | undefined>,
|
||||
): string[] {
|
||||
const envFilter: string[] = [
|
||||
'ETCHER_SERVER_ADDRESS',
|
||||
'ETCHER_SERVER_PORT',
|
||||
'ETCHER_SERVER_ID',
|
||||
'ETCHER_NO_SPAWN_UTIL',
|
||||
'ETCHER_TERMINATE_TIMEOUT',
|
||||
'UV_THREADPOOL_SIZE',
|
||||
];
|
||||
|
||||
return [
|
||||
command[0],
|
||||
...command.slice(1),
|
||||
...Object.keys(env)
|
||||
.filter((key) => Object.prototype.hasOwnProperty.call(env, key))
|
||||
.filter((key) => envFilter.includes(key))
|
||||
.map((key) => `--${key}=${env[key]}`),
|
||||
];
|
||||
}
|
||||
|
||||
export async function isElevated(): Promise<boolean> {
|
||||
if (os.platform() === 'win32') {
|
||||
// `fltmc` is available on WinPE, XP, Vista, 7, 8, and 10
|
||||
@ -66,80 +83,6 @@ 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 | undefined>,
|
||||
): 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,
|
||||
name: string,
|
||||
env: any,
|
||||
): Promise<{ cancelled: false }> {
|
||||
// '&' needs to be escaped here (but not when written to a .cmd file)
|
||||
const cmd = ['cmd', '/c', escapeParamCmd(path).replace(/&/g, '^&')].join(' ');
|
||||
await winSudo(cmd, name, env);
|
||||
return { cancelled: false };
|
||||
}
|
||||
|
||||
async function elevateScriptUnix(
|
||||
path: string,
|
||||
name: string,
|
||||
): Promise<{ cancelled: boolean }> {
|
||||
const cmd = ['bash', escapeSh(path)].join(' ');
|
||||
await linuxSudo(cmd, { name });
|
||||
return { cancelled: false };
|
||||
}
|
||||
|
||||
async function elevateScriptCatalina(
|
||||
path: string,
|
||||
): Promise<{ cancelled: boolean }> {
|
||||
const cmd = ['bash', escapeSh(path)].join(' ');
|
||||
try {
|
||||
const { cancelled } = await darwinSudo(cmd);
|
||||
return { cancelled };
|
||||
} catch (error: any) {
|
||||
throw errors.createError({ title: error.stderr });
|
||||
}
|
||||
}
|
||||
|
||||
export async function elevateCommand(
|
||||
command: string[],
|
||||
options: {
|
||||
@ -147,66 +90,60 @@ export async function elevateCommand(
|
||||
applicationName: string;
|
||||
},
|
||||
): Promise<{ cancelled: boolean }> {
|
||||
// if we're running with elevated privileges, we can just spawn the command
|
||||
if (await isElevated()) {
|
||||
spawn(command[0], command.slice(1), {
|
||||
env: options.env,
|
||||
});
|
||||
return { cancelled: false };
|
||||
}
|
||||
const isWindows = os.platform() === 'win32';
|
||||
const launchScript = createLaunchScript(
|
||||
command[0],
|
||||
command.slice(1),
|
||||
options.env,
|
||||
);
|
||||
return await withTmpFile(
|
||||
{
|
||||
keepOpen: false,
|
||||
prefix: 'balena-etcher-electron-',
|
||||
postfix: '.cmd',
|
||||
},
|
||||
async ({ path }) => {
|
||||
await fs.writeFile(path, launchScript);
|
||||
if (isWindows) {
|
||||
return elevateScriptWindows(path, options.applicationName, options.env);
|
||||
}
|
||||
if (
|
||||
os.platform() === 'darwin' &&
|
||||
semver.compare(os.release(), '19.0.0') >= 0
|
||||
) {
|
||||
// >= macOS Catalina
|
||||
return elevateScriptCatalina(path);
|
||||
}
|
||||
try {
|
||||
return elevateScriptUnix(path, options.applicationName);
|
||||
} catch (error: any) {
|
||||
// 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;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
if (os.platform() === 'win32') {
|
||||
const { cancelled } = await winSudo(commandWithEnv(command, options.env));
|
||||
return { cancelled };
|
||||
}
|
||||
if (
|
||||
os.platform() === 'darwin' &&
|
||||
semver.compare(os.release(), '19.0.0') >= 0
|
||||
) {
|
||||
// >= macOS Catalina
|
||||
const { cancelled } = await darwinSudo(command, options.env);
|
||||
return { cancelled };
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw errors.createError({ title: error.stderr });
|
||||
}
|
||||
|
||||
try {
|
||||
const { cancelled } = await linuxSudo(commandWithEnv(command, options.env));
|
||||
return { cancelled };
|
||||
} catch (error: any) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2019 balena.io
|
||||
* Copyright 2025 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@ -16,15 +16,10 @@
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import { join } from 'path';
|
||||
import { env } from 'process';
|
||||
// import { promisify } from "util";
|
||||
|
||||
import { supportedLocales } from '../../gui/app/i18n';
|
||||
|
||||
// const execFileAsync = promisify(execFile);
|
||||
|
||||
const SUCCESSFUL_AUTH_MARKER = 'AUTHENTICATION SUCCEEDED';
|
||||
const EXPECTED_SUCCESSFUL_AUTH_MARKER = `${SUCCESSFUL_AUTH_MARKER}\n`;
|
||||
|
||||
function getAskPassScriptPath(lang: string): string {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
@ -36,67 +31,68 @@ function getAskPassScriptPath(lang: string): string {
|
||||
}
|
||||
|
||||
export async function sudo(
|
||||
command: string,
|
||||
command: string[],
|
||||
env: any,
|
||||
): Promise<{ cancelled: boolean; stdout?: string; stderr?: string }> {
|
||||
try {
|
||||
let lang = Intl.DateTimeFormat().resolvedOptions().locale;
|
||||
lang = lang.substr(0, 2);
|
||||
if (supportedLocales.indexOf(lang) > -1) {
|
||||
// language should be present
|
||||
} else {
|
||||
// fallback to eng
|
||||
lang = 'en';
|
||||
}
|
||||
let lang = Intl.DateTimeFormat().resolvedOptions().locale;
|
||||
lang = lang.substr(0, 2);
|
||||
if (supportedLocales.indexOf(lang) === -1) {
|
||||
lang = 'en';
|
||||
}
|
||||
|
||||
// Build the shell command string
|
||||
const shellCmd = `echo ${SUCCESSFUL_AUTH_MARKER} && ${command[0]} ${command
|
||||
.slice(1)
|
||||
.map((a) => a.replace(/\\/g, '\\\\').replace(/"/g, '\\"'))
|
||||
.join(' ')}`;
|
||||
|
||||
let elevated = 'pending';
|
||||
|
||||
try {
|
||||
const elevateProcess = spawn(
|
||||
'sudo',
|
||||
['--askpass', 'sh', '-c', `echo ${SUCCESSFUL_AUTH_MARKER} && ${command}`],
|
||||
['-E', '--askpass', 'sh', '-c', shellCmd],
|
||||
{
|
||||
// encoding: "utf8",
|
||||
env: {
|
||||
...env,
|
||||
PATH: env.PATH,
|
||||
SUDO_ASKPASS: getAskPassScriptPath(lang),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let elevated = 'pending';
|
||||
|
||||
elevateProcess.stdout.on('data', (data) => {
|
||||
// console.log(`stdout: ${data}`);
|
||||
if (data.toString().includes(SUCCESSFUL_AUTH_MARKER)) {
|
||||
// if the first data comming out of the sudo command is the expected marker we resolve the promise
|
||||
elevated = 'granted';
|
||||
} else {
|
||||
// if the first data comming out of the sudo command is not the expected marker we reject the promise
|
||||
elevated = 'rejected';
|
||||
}
|
||||
});
|
||||
|
||||
// we don't spawn or read stdout in the promise otherwise resolving stop the process
|
||||
return new Promise((resolve, reject) => {
|
||||
const checkElevation = setInterval(() => {
|
||||
if (elevated === 'granted') {
|
||||
clearInterval(checkElevation);
|
||||
resolve({ cancelled: false });
|
||||
} else if (elevated === 'rejected') {
|
||||
clearInterval(checkElevation);
|
||||
resolve({ cancelled: true });
|
||||
}
|
||||
}, 300);
|
||||
|
||||
// if the elevation didn't occured in 30 seconds we reject the promise
|
||||
setTimeout(() => {
|
||||
clearInterval(checkElevation);
|
||||
reject(new Error('Elevation timeout'));
|
||||
}, 30000);
|
||||
});
|
||||
// elevateProcess.stderr.on('data', (data) => {
|
||||
// console.log(`stderr: ${data}`);
|
||||
// });
|
||||
} catch (error: any) {
|
||||
if (error.code === 1) {
|
||||
if (!error.stdout.startsWith(EXPECTED_SUCCESSFUL_AUTH_MARKER)) {
|
||||
return { cancelled: true };
|
||||
}
|
||||
error.stdout = error.stdout.slice(EXPECTED_SUCCESSFUL_AUTH_MARKER.length);
|
||||
}
|
||||
throw error;
|
||||
console.error('Error starting sudo process', error);
|
||||
throw new Error('Error starting sudo process');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const checkElevation = setInterval(() => {
|
||||
console.log('elevated', elevated);
|
||||
if (elevated === 'granted') {
|
||||
clearInterval(checkElevation);
|
||||
resolve({ cancelled: false });
|
||||
} else if (elevated === 'rejected') {
|
||||
clearInterval(checkElevation);
|
||||
resolve({ cancelled: true });
|
||||
}
|
||||
}, 300);
|
||||
|
||||
setTimeout(() => {
|
||||
clearInterval(checkElevation);
|
||||
reject(new Error('Elevation timeout'));
|
||||
}, 30000);
|
||||
});
|
||||
}
|
||||
|
@ -1,38 +1,21 @@
|
||||
/*
|
||||
* This is heavily inspired (read: a ripof) https://github.com/balena-io-modules/sudo-prompt
|
||||
* Which was a fork of https://github.com/jorangreef/sudo-prompt
|
||||
*
|
||||
* This and the original code was released under The MIT License (MIT)
|
||||
*
|
||||
* Copyright (c) 2015 Joran Dirk Greef
|
||||
* Copyright (c) 2024 Balena
|
||||
*
|
||||
The MIT License (MIT)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
/*
|
||||
* Copyright 2025 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 { spawn } from 'child_process';
|
||||
import { access, constants } from 'fs/promises';
|
||||
import { env } from 'process';
|
||||
|
||||
// const execFileAsync = promisify(execFile);
|
||||
|
||||
const SUCCESSFUL_AUTH_MARKER = 'AUTHENTICATION SUCCEEDED';
|
||||
|
||||
@ -62,40 +45,30 @@ function escapeDoubleQuotes(escapeString: string) {
|
||||
}
|
||||
|
||||
export async function sudo(
|
||||
command: string,
|
||||
{ name }: { name: string },
|
||||
command: string[],
|
||||
): Promise<{ cancelled: boolean; stdout?: string; stderr?: string }> {
|
||||
const linuxBinary: string = (await checkLinuxBinary()) as string;
|
||||
if (!linuxBinary) {
|
||||
throw new Error('Unable to find pkexec or kdesudo.');
|
||||
throw new Error('Unable to find pkexec.');
|
||||
}
|
||||
|
||||
const parameters = [];
|
||||
|
||||
if (/kdesudo/i.test(linuxBinary)) {
|
||||
parameters.push(
|
||||
'--comment',
|
||||
`"${name} wants to make changes.
|
||||
Enter your password to allow this."`,
|
||||
);
|
||||
parameters.push('-d'); // Do not show the command to be run in the dialog.
|
||||
parameters.push('--');
|
||||
} else if (/pkexec/i.test(linuxBinary)) {
|
||||
// Add pkexec specific parameters
|
||||
if (/pkexec/i.test(linuxBinary)) {
|
||||
parameters.push('--disable-internal-agent');
|
||||
}
|
||||
|
||||
// Build the shell command string
|
||||
const shellCmd = `echo ${SUCCESSFUL_AUTH_MARKER} && ${command
|
||||
.map((a) => escapeDoubleQuotes(a))
|
||||
.join(' ')}`;
|
||||
|
||||
parameters.push('/bin/bash');
|
||||
parameters.push('-c');
|
||||
parameters.push(
|
||||
`echo ${SUCCESSFUL_AUTH_MARKER} && ${escapeDoubleQuotes(command)}`,
|
||||
);
|
||||
parameters.push(shellCmd);
|
||||
|
||||
const elevateProcess = spawn(linuxBinary, parameters, {
|
||||
// encoding: "utf8",
|
||||
env: {
|
||||
PATH: env.PATH,
|
||||
},
|
||||
});
|
||||
const elevateProcess = spawn(linuxBinary, parameters);
|
||||
|
||||
let elevated = '';
|
||||
|
||||
@ -110,17 +83,6 @@ export async function sudo(
|
||||
}
|
||||
});
|
||||
|
||||
// elevateProcess.stderr.on('data', (data) => {
|
||||
// // console.log(`stderr: ${data.toString()}`);
|
||||
// // if (data.toString().includes(SUCCESSFUL_AUTH_MARKER)) {
|
||||
// // // if the first data comming out of the sudo command is the expected marker we resolve the promise
|
||||
// // elevated = 'granted';
|
||||
// // } else {
|
||||
// // // if the first data comming out of the sudo command is not the expected marker we reject the promise
|
||||
// // elevated = 'refused';
|
||||
// // }
|
||||
// });
|
||||
|
||||
// we don't spawn or read stdout in the promise otherwise resolving stop the process
|
||||
return new Promise((resolve, reject) => {
|
||||
const checkElevation = setInterval(() => {
|
||||
|
@ -1,190 +1,85 @@
|
||||
/*
|
||||
* This is heavily inspired (read: a ripof) https://github.com/balena-io-modules/sudo-prompt
|
||||
* Which was a fork of https://github.com/jorangreef/sudo-prompt
|
||||
*
|
||||
* This and the original code was released under The MIT License (MIT)
|
||||
*
|
||||
* Copyright (c) 2015 Joran Dirk Greef
|
||||
* Copyright (c) 2024 Balena
|
||||
*
|
||||
The MIT License (MIT)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
/*
|
||||
* Copyright 2025 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 { spawn } from 'child_process';
|
||||
// import { env } from 'process';
|
||||
import { tmpdir } from 'os';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { join, sep } from 'path';
|
||||
import { mkdir, writeFile, copyFile, readFile } from 'fs/promises';
|
||||
|
||||
/**
|
||||
* TODO:
|
||||
* Migrate, modernize and clenup the windows elevation code from the old @balena/sudo-prompt package in a similar way to linux-sudo.ts and catalina-sudo files.
|
||||
*/
|
||||
|
||||
export async function sudo(
|
||||
command: string,
|
||||
_name: string,
|
||||
env: any,
|
||||
command: string[],
|
||||
): Promise<{ cancelled: boolean; stdout?: string; stderr?: string }> {
|
||||
const uuid = uuidv4();
|
||||
|
||||
const temp = tmpdir();
|
||||
if (!temp) {
|
||||
throw new Error('os.tmpdir() not defined.');
|
||||
}
|
||||
|
||||
const tmpFolder = join(temp, uuid);
|
||||
|
||||
if (/"/.test(tmpFolder)) {
|
||||
// We expect double quotes to be reserved on Windows.
|
||||
// Even so, we test for this and abort if they are present.
|
||||
throw new Error('instance.path cannot contain double-quotes.');
|
||||
}
|
||||
|
||||
const executeScriptPath = join(tmpFolder, 'execute.bat');
|
||||
const commandScriptPath = join(tmpFolder, 'command.bat');
|
||||
const stdoutPath = join(tmpFolder, 'stdout');
|
||||
const stderrPath = join(tmpFolder, 'stderr');
|
||||
const statusPath = join(tmpFolder, 'status');
|
||||
|
||||
const SUCCESSFUL_AUTH_MARKER = 'AUTHENTICATION SUCCEEDED';
|
||||
|
||||
try {
|
||||
await mkdir(tmpFolder);
|
||||
// Powershell (required to ask for elevated privileges) as of win10
|
||||
// cannot pass environment variables as a map, so we pass them as args
|
||||
// this is a workaround as we can't use an equivalent of `sudo -E` on Windows
|
||||
|
||||
// WindowsWriteExecuteScript(instance, end)
|
||||
const executeScript = `
|
||||
@echo off\r\n
|
||||
call "${commandScriptPath}" > "${stdoutPath}" 2> "${stderrPath}"\r\n
|
||||
(echo %ERRORLEVEL%) > "${statusPath}"
|
||||
`;
|
||||
|
||||
await writeFile(executeScriptPath, executeScript, 'utf-8');
|
||||
|
||||
// WindowsWriteCommandScript(instance, end)
|
||||
const cwd = 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.
|
||||
throw new Error('process.cwd() cannot contain double-quotes.');
|
||||
}
|
||||
|
||||
const commandScriptArray = [];
|
||||
commandScriptArray.push('@echo off');
|
||||
// Set code page to UTF-8:
|
||||
commandScriptArray.push('chcp 65001>nul');
|
||||
// Preserve current working directory:
|
||||
// We pass /d as an option in case the cwd is on another drive (issue 70).
|
||||
commandScriptArray.push(`cd /d "${cwd}"`);
|
||||
// Export environment variables:
|
||||
for (const key in env) {
|
||||
// "The characters <, >, |, &, ^ are special command shell characters, and
|
||||
// they must be preceded by the escape character (^) or enclosed in
|
||||
// quotation marks. If you use quotation marks to enclose a string that
|
||||
// contains one of the special characters, the quotation marks are set as
|
||||
// part of the environment variable value."
|
||||
// In other words, Windows assigns everything that follows the equals sign
|
||||
// to the value of the variable, whereas Unix systems ignore double quotes.
|
||||
if (Object.prototype.hasOwnProperty.call(env, key)) {
|
||||
const value = env[key];
|
||||
commandScriptArray.push(
|
||||
`set ${key}=${value!.replace(/([<>\\|&^])/g, '^$1')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
commandScriptArray.push(`echo ${SUCCESSFUL_AUTH_MARKER}`);
|
||||
commandScriptArray.push(command);
|
||||
await writeFile(
|
||||
commandScriptPath,
|
||||
commandScriptArray.join('\r\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
// WindowsCopyCmd(instance, end)
|
||||
if (windowsNeedsCopyCmd(tmpFolder)) {
|
||||
// Work around https://github.com/jorangreef/sudo-prompt/issues/97
|
||||
// Powershell can't properly escape amperstands in paths.
|
||||
// We work around this by copying cmd.exe in our temporary folder and running
|
||||
// it from here (see WindowsElevate below).
|
||||
// That way, we don't have to pass the path containing the amperstand at all.
|
||||
// A symlink would probably work too but you have to be an administrator in
|
||||
// order to create symlinks on Windows.
|
||||
await copyFile(
|
||||
join(process.env.SystemRoot!, 'System32', 'cmd.exe'),
|
||||
join(tmpFolder, 'cmd.exe'),
|
||||
);
|
||||
}
|
||||
|
||||
// WindowsElevate(instance, end)
|
||||
// We used to use this for executing elevate.vbs:
|
||||
// var command = 'cscript.exe //NoLogo "' + instance.pathElevate + '"';
|
||||
const spawnCommand = [];
|
||||
// spawnCommand.push("powershell.exe") // as we use spawn this one is out of the array
|
||||
spawnCommand.push('Start-Process');
|
||||
spawnCommand.push('-FilePath');
|
||||
const options: any = { encoding: 'utf8' };
|
||||
if (windowsNeedsCopyCmd(tmpFolder)) {
|
||||
// Node.path.join('.', 'cmd.exe') would return 'cmd.exe'
|
||||
spawnCommand.push(['.', 'cmd.exe'].join(sep));
|
||||
spawnCommand.push('-ArgumentList');
|
||||
spawnCommand.push('"/C","execute.bat"');
|
||||
options.cwd = tmpFolder;
|
||||
} else {
|
||||
// Escape characters for cmd using double quotes:
|
||||
// Escape characters for PowerShell using single quotes:
|
||||
// Escape single quotes for PowerShell using backtick:
|
||||
// See: https://ss64.com/ps/syntax-esc.html
|
||||
spawnCommand.push(`'${executeScriptPath.replace(/'/g, "`'")}'`);
|
||||
}
|
||||
|
||||
// Escape characters for cmd using double quotes:
|
||||
// Escape characters for PowerShell using single quotes:
|
||||
// Escape single quotes for PowerShell using backtick:
|
||||
// See: https://ss64.com/ps/syntax-esc.html
|
||||
spawnCommand.push(`'${command[0].replace(/'/g, "`'")}'`);
|
||||
|
||||
spawnCommand.push('-ArgumentList');
|
||||
|
||||
// Join and escape arguments for PowerShell
|
||||
spawnCommand.push(
|
||||
`'${command
|
||||
.slice(1)
|
||||
.map((a) => a.replace(/'/g, "`'"))
|
||||
.join(' ')}'`,
|
||||
);
|
||||
spawnCommand.push('-WindowStyle hidden');
|
||||
spawnCommand.push('-Verb runAs');
|
||||
|
||||
spawn('powershell.exe', spawnCommand);
|
||||
const child = spawn('powershell.exe', spawnCommand);
|
||||
|
||||
// setTimeout(() => {elevated = "granted"}, 5000)
|
||||
let result = { status: 'waiting' };
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
// User accepted UAC, process started
|
||||
console.log('UAC accepted, process started');
|
||||
result = { status: 'granted' };
|
||||
} else {
|
||||
// User cancelled or error occurred
|
||||
console.log('UAC cancelled or error occurred');
|
||||
result = { status: 'cancelled' };
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
result = { status: err.message };
|
||||
});
|
||||
|
||||
// we don't spawn directly in the promise otherwise resolving stop the process
|
||||
|
||||
// we don't spawn or read stdout in the promise otherwise resolving stop the process
|
||||
return new Promise((resolve, reject) => {
|
||||
const checkElevation = setInterval(async () => {
|
||||
try {
|
||||
const result = await readFile(stdoutPath, 'utf-8');
|
||||
const error = await readFile(stderrPath, 'utf-8');
|
||||
|
||||
if (error && error !== '') {
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
// TODO: should track something more generic
|
||||
if (result.includes(SUCCESSFUL_AUTH_MARKER)) {
|
||||
clearInterval(checkElevation);
|
||||
resolve({ cancelled: false });
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'Error while reading flasher elevation script output',
|
||||
error,
|
||||
);
|
||||
const checkElevation = setInterval(() => {
|
||||
if (result.status === 'waiting') {
|
||||
return;
|
||||
} else if (result.status === 'granted') {
|
||||
clearInterval(checkElevation);
|
||||
resolve({ cancelled: false });
|
||||
} else if (result.status === 'cancelled') {
|
||||
clearInterval(checkElevation);
|
||||
resolve({ cancelled: true });
|
||||
}
|
||||
}, 1000);
|
||||
}, 300);
|
||||
|
||||
// if the elevation didn't occured in 30 seconds we reject the promise
|
||||
setTimeout(() => {
|
||||
@ -192,27 +87,7 @@ export async function sudo(
|
||||
reject(new Error('Elevation timeout'));
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
// WindowsWaitForStatus(instance, end)
|
||||
|
||||
// WindowsResult(instance, end)
|
||||
} catch (error) {
|
||||
throw new Error(`Can't elevate process ${error}`);
|
||||
} finally {
|
||||
// TODO: cleanup
|
||||
// // Remove(instance.path, function (errorRemove) {
|
||||
// // if (error) return callback(error)
|
||||
// // if (errorRemove) return callback(errorRemove)
|
||||
// // callback(undefined, stdout, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
function windowsNeedsCopyCmd(path: string) {
|
||||
const specialChars = ['&', '`', "'", '"', '<', '>', '|', '^'];
|
||||
for (const specialChar of specialChars) {
|
||||
if (path.includes(specialChar)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
@ -29,6 +29,28 @@ import { getSourceMetadata } from './source-metadata';
|
||||
import type { DrivelistDrive } from '../shared/drive-constraints';
|
||||
import type { SourceMetadata } from '../shared/typings/source-selector';
|
||||
|
||||
// Utility to parse --key=value arguments into process.env if not already set
|
||||
function injectEnvFromArgs() {
|
||||
for (const arg of process.argv.slice(2)) {
|
||||
const match = arg.match(/^--([^=]+)=(.*)$/);
|
||||
if (match) {
|
||||
const key = match[1];
|
||||
const value = match[2];
|
||||
if (process.env[key] === undefined) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inject env vars from arguments if not already present
|
||||
injectEnvFromArgs();
|
||||
|
||||
console.log(
|
||||
'Etcher child process started with the following environment variables:',
|
||||
);
|
||||
console.log(JSON.stringify(process.env, null, 2));
|
||||
|
||||
const ETCHER_SERVER_ADDRESS = process.env.ETCHER_SERVER_ADDRESS as string;
|
||||
const ETCHER_SERVER_PORT = process.env.ETCHER_SERVER_PORT as string;
|
||||
// const ETCHER_SERVER_ID = process.env.ETCHER_SERVER_ID as string;
|
||||
|
2
npm-shrinkwrap.json
generated
2
npm-shrinkwrap.json
generated
@ -18,7 +18,7 @@
|
||||
"drivelist": "^12.0.2",
|
||||
"electron-squirrel-startup": "^1.0.0",
|
||||
"electron-updater": "6.1.8",
|
||||
"etcher-sdk": "9.1.2",
|
||||
"etcher-sdk": "10.0.0",
|
||||
"i18next": "23.11.2",
|
||||
"immutable": "3.8.2",
|
||||
"lodash": "4.17.21",
|
||||
|
@ -39,7 +39,7 @@
|
||||
"drivelist": "^12.0.2",
|
||||
"electron-squirrel-startup": "^1.0.0",
|
||||
"electron-updater": "6.1.8",
|
||||
"etcher-sdk": "9.1.2",
|
||||
"etcher-sdk": "10.0.0",
|
||||
"i18next": "23.11.2",
|
||||
"immutable": "3.8.2",
|
||||
"lodash": "4.17.21",
|
||||
|
@ -1,88 +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.
|
||||
*/
|
||||
|
||||
import { expect } from 'chai';
|
||||
import * as os from 'os';
|
||||
import { stub } from 'sinon';
|
||||
|
||||
import * as permissions from '../../lib/shared/permissions';
|
||||
|
||||
describe('Shared: permissions', function () {
|
||||
describe('.createLaunchScript()', function () {
|
||||
describe('given windows', function () {
|
||||
beforeEach(function () {
|
||||
this.osPlatformStub = stub(os, 'platform');
|
||||
this.osPlatformStub.returns('win32');
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
this.osPlatformStub.restore();
|
||||
});
|
||||
|
||||
it('should escape environment variables and arguments', function () {
|
||||
expect(
|
||||
permissions.createLaunchScript(
|
||||
'C:\\Users\\Alice & Bob\'s Laptop\\"what"\\balenaEtcher',
|
||||
['"a Laser"', 'arg1', "'&/ ^ \\", '" $ % *'],
|
||||
{
|
||||
key: 'value',
|
||||
key2: ' " \' ^ & = + $ % / \\',
|
||||
key3: '8',
|
||||
},
|
||||
),
|
||||
).to.equal(
|
||||
`chcp 65001${os.EOL}` +
|
||||
`set "key=value"${os.EOL}` +
|
||||
`set "key2= " ' ^ & = + $ % / \\"${os.EOL}` +
|
||||
`set "key3=8"${os.EOL}` +
|
||||
`"C:\\Users\\Alice & Bob's Laptop\\\\"what\\"\\balenaEtcher" "\\"a Laser\\"" "arg1" "'&/ ^ \\" "\\" $ % *"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
for (const platform of ['linux', 'darwin']) {
|
||||
describe(`given ${platform}`, function () {
|
||||
beforeEach(function () {
|
||||
this.osPlatformStub = stub(os, 'platform');
|
||||
this.osPlatformStub.returns(platform);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
this.osPlatformStub.restore();
|
||||
});
|
||||
|
||||
it('should escape environment variables and arguments', function () {
|
||||
expect(
|
||||
permissions.createLaunchScript(
|
||||
'/home/Alice & Bob\'s Laptop/"what"/balenaEtcher',
|
||||
['arg1', "'&/ ^ \\", '" $ % *'],
|
||||
{
|
||||
key: 'value',
|
||||
key2: ' " \' ^ & = + $ % / \\',
|
||||
key3: '8',
|
||||
},
|
||||
),
|
||||
).to.equal(
|
||||
`export key='value'${os.EOL}` +
|
||||
`export key2=' " '\\'' ^ & = + $ % / \\'${os.EOL}` +
|
||||
`export key3='8'${os.EOL}` +
|
||||
`'/home/Alice & Bob'\\''s Laptop/"what"/balenaEtcher' 'arg1' ''\\''&/ ^ \\' '" $ % *'`,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user