Merge pull request #4185 from balena-io/reverse-control-flow

Patch: switch from node-ipc to ws
This commit is contained in:
flowzone-app[bot] 2024-04-23 10:27:14 +00:00 committed by GitHub
commit 0a243caf35
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1015 additions and 678 deletions

View File

@ -41,8 +41,8 @@ const config: ForgeConfig = {
darwinDarkModeSupport: true,
protocols: [{ name: 'etcher', schemes: ['etcher'] }],
extraResource: [
'lib/shared/catalina-sudo/sudo-askpass.osascript-zh.js',
'lib/shared/catalina-sudo/sudo-askpass.osascript-en.js',
'lib/shared/sudo/sudo-askpass.osascript-zh.js',
'lib/shared/sudo/sudo-askpass.osascript-en.js',
],
osxSign: {
optionsForFile: () => ({

View File

@ -31,7 +31,7 @@ import * as flashState from './models/flash-state';
import * as settings from './models/settings';
import { Actions, observe, store } from './models/store';
import * as analytics from './modules/analytics';
import { startApiAndSpawnChild } from './modules/api';
import { spawnChildAndConnect } from './modules/api';
import * as exceptionReporter from './modules/exception-reporter';
import * as osDialog from './os/dialog';
import * as windowProgress from './os/window-progress';
@ -139,11 +139,11 @@ function setDrives(drives: Dictionary<DrivelistDrive>) {
export let requestMetadata: any;
// start the api and spawn the child process
startApiAndSpawnChild({
spawnChildAndConnect({
withPrivileges: false,
}).then(({ emit, registerHandler }) => {
// start scanning
emit('scan');
emit('scan', {});
// make the sourceMetada awaitable to be used on source selection
requestMetadata = async (params: any): Promise<SourceMetadata> => {

View File

@ -12,19 +12,16 @@
* - centralise the api for both the writer and the scanner instead of having two instances running
*/
import * as ipc from 'node-ipc';
import { spawn } from 'child_process';
import WebSocket from 'ws'; // (no types for wrapper, this is expected)
import { spawn, exec } from 'child_process';
import * as os from 'os';
import * as path from 'path';
import * as packageJSON from '../../../../package.json';
import * as permissions from '../../../shared/permissions';
import * as errors from '../../../shared/errors';
const THREADS_PER_CPU = 16;
// NOTE: Ensure this isn't disabled, as it will cause
// the stdout maxBuffer size to be exceeded when flashing
ipc.config.silent = true;
const connectionRetryDelay = 1000;
const connectionRetryAttempts = 10;
async function writerArgv(): Promise<string[]> {
let entryPoint = await window.etcher.getEtcherUtilPath();
@ -45,15 +42,17 @@ async function writerArgv(): Promise<string[]> {
}
}
function writerEnv(
IPC_CLIENT_ID: string,
IPC_SERVER_ID: string,
IPC_SOCKET_ROOT: string,
async function spawnChild(
withPrivileges: boolean,
etcherServerId: string,
etcherServerAddress: string,
etcherServerPort: string,
) {
return {
IPC_SERVER_ID,
IPC_CLIENT_ID,
IPC_SOCKET_ROOT,
const argv = await writerArgv();
const env: any = {
ETCHER_SERVER_ADDRESS: etcherServerAddress,
ETCHER_SERVER_ID: etcherServerId,
ETCHER_SERVER_PORT: etcherServerPort,
UV_THREADPOOL_SIZE: (os.cpus().length * THREADS_PER_CPU).toString(),
// This environment variable prevents the AppImages
// desktop integration script from presenting the
@ -61,123 +60,192 @@ function writerEnv(
SKIP: '1',
...(process.platform === 'win32' ? {} : process.env),
};
}
async function spawnChild({
withPrivileges,
IPC_CLIENT_ID,
IPC_SERVER_ID,
IPC_SOCKET_ROOT,
}: {
withPrivileges: boolean;
IPC_CLIENT_ID: string;
IPC_SERVER_ID: string;
IPC_SOCKET_ROOT: string;
}) {
const argv = await writerArgv();
const env = writerEnv(IPC_CLIENT_ID, IPC_SERVER_ID, IPC_SOCKET_ROOT);
if (withPrivileges) {
return await permissions.elevateCommand(argv, {
console.log('... with privileges ...');
return permissions.elevateCommand(argv, {
applicationName: packageJSON.displayName,
environment: env,
});
} else {
const process = await spawn(argv[0], argv.slice(1), {
env,
});
return { cancelled: false, process };
}
}
function terminateServer(server: any) {
// Turns out we need to destroy all sockets for
// the server to actually close. Otherwise, it
// just stops receiving any further connections,
// but remains open if there are active ones.
// @ts-ignore (no Server.sockets in @types/node-ipc)
for (const socket of server.sockets) {
socket.destroy();
}
server.stop();
}
// TODO: replace the custom ipc events by one generic "message" for all communication with the backend
function startApiAndSpawnChild({
withPrivileges,
}: {
withPrivileges: boolean;
}): Promise<any> {
// There might be multiple Etcher instances running at
// the same time, also we might spawn multiple child and api so we must ensure each IPC
// server/client has a different name.
const IPC_SERVER_ID = `etcher-server-${process.pid}-${Date.now()}-${
withPrivileges ? 'privileged' : 'unprivileged'
}`;
const IPC_CLIENT_ID = `etcher-client-${process.pid}-${Date.now()}-${
withPrivileges ? 'privileged' : 'unprivileged'
}`;
const IPC_SOCKET_ROOT = path.join(
process.env.XDG_RUNTIME_DIR || os.tmpdir(),
path.sep,
);
ipc.config.id = IPC_SERVER_ID;
ipc.config.socketRoot = IPC_SOCKET_ROOT;
return new Promise((resolve, reject) => {
ipc.serve();
// log is special message which brings back the logs from the child process and prints them to the console
ipc.server.on('log', (message: string) => {
console.log(message);
} else {
if (process.platform === 'win32') {
// we need to ensure we reset the env as a previous elevation process might have kept them in a wrong state
const envCommand = [];
for (const key in env) {
if (Object.prototype.hasOwnProperty.call(env, key)) {
envCommand.push(`set ${key}=${env[key]}`);
}
}
await exec(envCommand.join(' && '));
}
const spawned = await spawn(argv[0], argv.slice(1), {
env,
});
return { cancelled: false, spawned };
}
}
// api to register more handlers with callbacks
const registerHandler = (event: string, handler: any) => {
ipc.server.on(event, handler);
type ChildApi = {
emit: (type: string, payload: any) => void;
registerHandler: (event: string, handler: any) => void;
failed: boolean;
};
async function connectToChildProcess(
etcherServerAddress: string,
etcherServerPort: string,
etcherServerId: string,
): 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
console.log(etcherServerId);
const url = `ws://${etcherServerAddress}:${etcherServerPort}`;
const ws = new WebSocket(url);
let heartbeat: any;
const startHeartbeat = (emit: any) => {
console.log('start heartbeat');
heartbeat = setInterval(() => {
emit('heartbeat', {});
}, 1000);
};
// once api is ready (means child process is connected) we pass the emit and terminate function to the caller
ipc.server.on('ready', (_: any, socket) => {
const emit = (channel: string, data: any) => {
ipc.server.emit(socket, channel, data);
};
resolve({
emit,
terminateServer: () => terminateServer(ipc.server),
registerHandler,
});
});
const stopHeartbeat = () => {
console.log('stop heartbeat');
clearInterval(heartbeat);
};
// on api error we terminate
ipc.server.on('error', (error: any) => {
terminateServer(ipc.server);
const errorObject = errors.fromJSON(error);
reject(errorObject);
});
// when the api is started we spawn the child process
ipc.server.on('start', async () => {
try {
const results = await spawnChild({
withPrivileges,
IPC_CLIENT_ID,
IPC_SERVER_ID,
IPC_SOCKET_ROOT,
ws.on('error', (error: any) => {
if (error.code === 'ECONNREFUSED') {
resolve({
failed: true,
});
} else {
stopHeartbeat();
reject({
failed: true,
});
// this will happen if the child is spawned withPrivileges and privileges has been rejected
if (results.cancelled) {
reject();
}
} catch (error) {
reject(error);
}
});
// start the server
ipc.server.start();
ws.on('open', () => {
const emit = (type: string, payload: any) => {
ws.send(JSON.stringify({ type, payload }));
};
emit('ready', {});
// parse and route messages
const messagesHandler: any = {
log: (message: any) => {
console.log(`CHILD LOG: ${message}`);
},
error: (error: any) => {
const errorObject = errors.fromJSON(error);
console.error('CHILD ERROR', errorObject);
stopHeartbeat();
},
// once api is ready (means child process is connected) we pass the emit function to the caller
ready: () => {
console.log('CHILD READY');
startHeartbeat(emit);
resolve({
failed: false,
emit,
registerHandler,
});
},
};
ws.on('message', (jsonData: any) => {
const data = JSON.parse(jsonData);
const message = messagesHandler[data.type];
if (message) {
message(data.payload);
} else {
throw new Error(`Unknown message type: ${data.type}`);
}
});
// api to register more handlers with callbacks
const registerHandler = (event: string, handler: any) => {
messagesHandler[event] = handler;
};
});
});
}
export { startApiAndSpawnChild };
async function spawnChildAndConnect({
withPrivileges,
}: {
withPrivileges: boolean;
}): Promise<ChildApi> {
const etcherServerAddress = process.env.ETCHER_SERVER_ADDRESS ?? '127.0.0.1'; // localhost
const etcherServerPort =
process.env.ETCHER_SERVER_PORT ?? withPrivileges ? '3435' : '3434';
const etcherServerId =
process.env.ETCHER_SERVER_ID ??
`etcher-${Math.random().toString(36).substring(7)}`;
console.log(
`Spawning ${
withPrivileges ? 'priviledged' : 'unpriviledged'
} sidecar on port ${etcherServerPort}`,
);
// spawn the child process, which will act as the ws server
// ETCHER_NO_SPAWN_UTIL can be set to launch a GUI only version of etcher, in that case you'll probably want to set other ENV to match your setup
if (!process.env.ETCHER_NO_SPAWN_UTIL) {
try {
const result = await spawnChild(
withPrivileges,
etcherServerId,
etcherServerAddress,
etcherServerPort,
);
if (result.cancelled) {
throw new Error('Spwaning the child process was cancelled');
}
} catch (error) {
console.error('Error spawning child process', error);
throw new Error('Error spawning the child process');
}
}
// try to connect to the ws server, retrying if necessary, until the connection is established
try {
let retry = 0;
while (retry < connectionRetryAttempts) {
const { emit, registerHandler, failed } = await connectToChildProcess(
etcherServerAddress,
etcherServerPort,
etcherServerId,
);
if (failed) {
retry++;
console.log(
`Retrying to connect to child process in ${connectionRetryDelay}... ${retry} / ${connectionRetryAttempts}`,
);
await new Promise((resolve) =>
setTimeout(resolve, connectionRetryDelay),
);
continue;
}
return { failed, emit, registerHandler };
}
throw new Error('Connection to etcher-util timed out');
} catch (error) {
console.error('Error connecting to child process', error);
throw new Error('Connection to etcher-util failed');
}
}
export { spawnChildAndConnect };

View File

@ -24,7 +24,7 @@ import * as selectionState from '../models/selection-state';
import * as settings from '../models/settings';
import * as analytics from '../modules/analytics';
import * as windowProgress from '../os/window-progress';
import { startApiAndSpawnChild } from './api';
import { spawnChildAndConnect } from './api';
/**
* @summary Handle a flash error and log it to analytics
@ -78,15 +78,14 @@ async function performWrite(
): Promise<{ cancelled?: boolean }> {
const { autoBlockmapping, decompressFirst } = await settings.getAll();
console.log({ image, drives });
// Spawn the child process with privileges and wait for the connection to be made
const { emit, registerHandler, terminateServer } =
await startApiAndSpawnChild({
withPrivileges: true,
});
const { emit, registerHandler } = await spawnChildAndConnect({
withPrivileges: true,
});
return await new Promise((resolve, reject) => {
// if the connection failed, reject the promise
const flashResults: FlashResults = {};
const analyticsData = {
@ -108,25 +107,25 @@ async function performWrite(
finish();
};
const onDone = (event: any) => {
console.log('done event');
event.results.errors = event.results.errors.map(
const onDone = (payload: any) => {
console.log('CHILD: flash done', payload);
payload.results.errors = payload.results.errors.map(
(data: Dictionary<any> & { message: string }) => {
return errors.fromJSON(data);
},
);
flashResults.results = event.results;
flashResults.results = payload.results;
finish();
};
const onAbort = () => {
console.log('abort event');
console.log('CHILD: flash aborted');
flashResults.cancelled = true;
finish();
};
const onSkip = () => {
console.log('skip event');
console.log('CHILD: validation skipped');
flashResults.skip = true;
finish();
};
@ -151,8 +150,6 @@ async function performWrite(
);
}
console.log('Terminating IPC server');
terminateServer();
resolve(flashResults);
};
@ -162,7 +159,7 @@ async function performWrite(
registerHandler('abort', onAbort);
registerHandler('skip', onSkip);
cancelEmitter = (cancelStatus: string) => emit(cancelStatus);
cancelEmitter = (cancelStatus: string) => emit('cancel', cancelStatus);
// Now that we know we're connected we can instruct the child process to start the write
const parameters = {
@ -212,7 +209,9 @@ export async function flash(
// start api and call the flasher
try {
const result = await write(image, drives, flashState.setProgressState);
console.log('got results', result);
await flashState.unsetFlashingFlag(result);
console.log('removed flashing flag');
} catch (error: any) {
await flashState.unsetFlashingFlag({
cancelled: false,

View File

@ -14,41 +14,27 @@
* limitations under the License.
*/
import * as childProcess from 'child_process';
/**
* 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';
import * as semver from 'semver';
import * as sudoPrompt from '@balena/sudo-prompt';
import { promisify } from 'util';
import { sudo as catalinaSudo } from './catalina-sudo/sudo';
import { sudo as darwinSudo } from './sudo/darwin';
import { sudo as linuxSudo } from './sudo/linux';
import { sudo as winSudo } from './sudo/windows';
import * as errors from './errors';
const execAsync = promisify(childProcess.exec);
const execFileAsync = promisify(childProcess.execFile);
type Std = string | Buffer | undefined;
function sudoExecAsync(
cmd: string,
options: { name: string },
): Promise<{ stdout: Std; stderr: Std }> {
return new Promise((resolve, reject) => {
sudoPrompt.exec(
cmd,
options,
(error: Error | undefined, stdout: Std, stderr: Std) => {
if (error) {
reject(error);
} else {
resolve({ stdout, stderr });
}
},
);
});
}
const execAsync = promisify(exec);
/**
* @summary The user id of the UNIX "superuser"
@ -125,10 +111,11 @@ export function createLaunchScript(
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 sudoExecAsync(cmd, { name });
await winSudo(cmd, name, env);
return { cancelled: false };
}
@ -137,7 +124,7 @@ async function elevateScriptUnix(
name: string,
): Promise<{ cancelled: boolean }> {
const cmd = ['bash', escapeSh(path)].join(' ');
await sudoExecAsync(cmd, { name });
await linuxSudo(cmd, { name });
return { cancelled: false };
}
@ -146,7 +133,7 @@ async function elevateScriptCatalina(
): Promise<{ cancelled: boolean }> {
const cmd = ['bash', escapeSh(path)].join(' ');
try {
const { cancelled } = await catalinaSudo(cmd);
const { cancelled } = await darwinSudo(cmd);
return { cancelled };
} catch (error: any) {
throw errors.createError({ title: error.stderr });
@ -156,13 +143,13 @@ async function elevateScriptCatalina(
export async function elevateCommand(
command: string[],
options: {
environment: _.Dictionary<string | undefined>;
env: _.Dictionary<string | undefined>;
applicationName: string;
},
): Promise<{ cancelled: boolean }> {
if (await isElevated()) {
await execFileAsync(command[0], command.slice(1), {
env: options.environment,
spawn(command[0], command.slice(1), {
env: options.env,
});
return { cancelled: false };
}
@ -170,7 +157,7 @@ export async function elevateCommand(
const launchScript = createLaunchScript(
command[0],
command.slice(1),
options.environment,
options.env,
);
return await withTmpFile(
{
@ -181,7 +168,7 @@ export async function elevateCommand(
async ({ path }) => {
await fs.writeFile(path, launchScript);
if (isWindows) {
return elevateScriptWindows(path, options.applicationName);
return elevateScriptWindows(path, options.applicationName, options.env);
}
if (
os.platform() === 'darwin' &&
@ -191,7 +178,7 @@ export async function elevateCommand(
return elevateScriptCatalina(path);
}
try {
return await elevateScriptUnix(path, options.applicationName);
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

View File

@ -14,14 +14,14 @@
* limitations under the License.
*/
import { execFile } from 'child_process';
import { spawn } from 'child_process';
import { join } from 'path';
import { env } from 'process';
import { promisify } from 'util';
// import { promisify } from "util";
import { supportedLocales } from '../../gui/app/i18n';
const execFileAsync = promisify(execFile);
// const execFileAsync = promisify(execFile);
const SUCCESSFUL_AUTH_MARKER = 'AUTHENTICATION SUCCEEDED';
const EXPECTED_SUCCESSFUL_AUTH_MARKER = `${SUCCESSFUL_AUTH_MARKER}\n`;
@ -48,22 +48,48 @@ export async function sudo(
lang = 'en';
}
const { stdout, stderr } = await execFileAsync(
const elevateProcess = spawn(
'sudo',
['--askpass', 'sh', '-c', `echo ${SUCCESSFUL_AUTH_MARKER} && ${command}`],
{
encoding: 'utf8',
// encoding: "utf8",
env: {
PATH: env.PATH,
SUDO_ASKPASS: getAskPassScriptPath(lang),
},
},
);
return {
cancelled: false,
stdout: stdout.slice(EXPECTED_SUCCESSFUL_AUTH_MARKER.length),
stderr,
};
let elevated = 'pending';
elevateProcess.stdout.on('data', (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);
});
} catch (error: any) {
if (error.code === 1) {
if (!error.stdout.startsWith(EXPECTED_SUCCESSFUL_AUTH_MARKER)) {

142
lib/shared/sudo/linux.ts Normal file
View File

@ -0,0 +1,142 @@
/*
* 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.
*/
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';
/** Check for kdesudo or pkexec */
function checkLinuxBinary() {
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve, reject) => {
// We used to prefer gksudo over pkexec since it enabled a better prompt.
// However, gksudo cannot run multiple commands concurrently.
const paths = ['/usr/bin/kdesudo', '/usr/bin/pkexec'];
for (const path of paths) {
try {
// check if the file exist and is executable
await access(path, constants.X_OK);
resolve(path);
} catch (error: any) {
continue;
}
}
reject('Unable to find pkexec or kdesudo.');
});
}
function escapeDoubleQuotes(escapeString: string) {
return escapeString.replace(/"/g, '\\"');
}
export async function sudo(
command: string,
{ name }: { name: 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.');
}
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)) {
parameters.push('--disable-internal-agent');
}
parameters.push('/bin/bash');
parameters.push('-c');
parameters.push(
`echo ${SUCCESSFUL_AUTH_MARKER} && ${escapeDoubleQuotes(command)}`,
);
const elevateProcess = spawn(linuxBinary, parameters, {
// encoding: "utf8",
env: {
PATH: env.PATH,
},
});
let elevated = '';
elevateProcess.stdout.on('data', (data) => {
// console.log(`stdout: ${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';
}
});
// 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(() => {
if (elevated === 'granted') {
clearInterval(checkElevation);
resolve({ cancelled: false });
} else if (elevated === 'refused') {
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);
});
}

220
lib/shared/sudo/windows.ts Normal file
View File

@ -0,0 +1,220 @@
/*
* 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.
*/
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,
): Promise<{ cancelled: boolean; stdout?: string; stderr?: string }> {
// console.log('name', name);
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);
// 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, "`'")}'`);
}
spawnCommand.push('-WindowStyle hidden');
spawnCommand.push('-Verb runAs');
spawn('powershell.exe', spawnCommand);
// setTimeout(() => {elevated = "granted"}, 5000)
// 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,
);
}
}, 1000);
// if the elevation didn't occured in 30 seconds we reject the promise
setTimeout(() => {
clearInterval(checkElevation);
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;
}

View File

@ -14,190 +14,278 @@
* limitations under the License.
*/
import * as ipc from 'node-ipc';
import { WebSocketServer } from 'ws';
import { Dictionary, values } from 'lodash';
import type { MultiDestinationProgress } from 'etcher-sdk/build/multi-write';
import { toJSON } from '../shared/errors';
import { GENERAL_ERROR, SUCCESS } from '../shared/exit-codes';
import { delay } from '../shared/utils';
import { WriteOptions } from './types/types';
import { write, cleanup } from './child-writer';
import { startScanning } from './scanner';
import { getSourceMetadata } from './source-metadata';
import { DrivelistDrive } from '../shared/drive-constraints';
import { SourceMetadata } from '../shared/typings/source-selector';
ipc.config.id = process.env.IPC_CLIENT_ID as string;
ipc.config.socketRoot = process.env.IPC_SOCKET_ROOT as string;
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;
// NOTE: Ensure this isn't disabled, as it will cause
// the stdout maxBuffer size to be exceeded when flashing
ipc.config.silent = true;
const ETCHER_TERMINATE_TIMEOUT: number = parseInt(
process.env.ETCHER_TERMINATE_TIMEOUT ?? '10000',
10,
);
// > If set to 0, the client will NOT try to reconnect.
// See https://github.com/RIAEvangelist/node-ipc/
//
// The purpose behind this change is for this process
// to emit a "disconnect" event as soon as the GUI
// process is closed, so we can kill this process as well.
const host = ETCHER_SERVER_ADDRESS ?? '127.0.0.1';
const port = parseInt(ETCHER_SERVER_PORT || '3434', 10);
// const path = ETCHER_SERVER_ID || "etcher";
// @ts-ignore (0 is a valid value for stopRetrying and is not the same as false)
ipc.config.stopRetrying = 0;
// TODO: use the path as cheap authentication
const DISCONNECT_DELAY = 100;
const IPC_SERVER_ID = process.env.IPC_SERVER_ID as string;
const wss = new WebSocketServer({ host, port });
/**
* @summary Send a message to the IPC server
*/
function emit(channel: string, message?: any) {
ipc.of[IPC_SERVER_ID].emit(channel, message);
}
// hold emit functions
let emitLog: (message: string) => void | undefined;
let emitState: (state: MultiDestinationProgress) => void | undefined;
let emitFail: (data: any) => void | undefined;
let emitDrives: (drives: Dictionary<DrivelistDrive>) => void | undefined;
let emitSourceMetadata: (
sourceMetadata: SourceMetadata | Record<string, never>,
) => void | undefined; // Record<string, never> means an empty object
/**
* @summary Send a log debug message to the IPC server
*/
function log(message: string) {
if (console?.log) {
console.log(message);
}
emit('log', message);
}
/**
* @summary Terminate the child process
*/
async function terminate(exitCode: number) {
ipc.disconnect(IPC_SERVER_ID);
// Terminate the child process
async function terminate(exitCode?: number) {
await cleanup(Date.now());
process.nextTick(() => {
process.exit(exitCode || SUCCESS);
});
}
/**
* @summary Handle errors
*/
async function handleError(error: Error) {
emit('error', toJSON(error));
await delay(DISCONNECT_DELAY);
await terminate(GENERAL_ERROR);
// kill the process if no initila connections or heartbeat for X sec (default 10)
function setTerminateTimeout() {
if (ETCHER_TERMINATE_TIMEOUT > 0) {
return setTimeout(() => {
console.log(
`no connections or heartbeat for ${ETCHER_TERMINATE_TIMEOUT} ms, terminating`,
);
terminate();
}, ETCHER_TERMINATE_TIMEOUT);
} else {
return null;
}
}
/**
* @summary Abort handler
* @example
*/
const onAbort = async (exitCode: number) => {
log('Abort');
emit('abort');
await delay(DISCONNECT_DELAY);
await terminate(exitCode);
};
const onSkip = async (exitCode: number) => {
log('Skip validation');
emit('skip');
await delay(DISCONNECT_DELAY);
await terminate(exitCode);
};
ipc.connectTo(IPC_SERVER_ID, () => {
// Gracefully exit on the following cases. If the parent
// process detects that child exit successfully but
// no flashing information is available, then it will
// assume that the child died halfway through.
process.once('uncaughtException', handleError);
process.once('SIGINT', async () => {
await terminate(SUCCESS);
});
process.once('SIGTERM', async () => {
await terminate(SUCCESS);
});
// The IPC server failed. Abort.
ipc.of[IPC_SERVER_ID].on('error', async () => {
await terminate(SUCCESS);
});
// The IPC server was disconnected. Abort.
ipc.of[IPC_SERVER_ID].on('disconnect', async () => {
await terminate(SUCCESS);
});
ipc.of[IPC_SERVER_ID].on('sourceMetadata', async (params) => {
const { selected, SourceType, auth } = JSON.parse(params);
try {
const sourceMatadata = await getSourceMetadata(
selected,
SourceType,
auth,
);
emitSourceMetadata(sourceMatadata);
} catch (error: any) {
emitFail(error);
}
});
ipc.of[IPC_SERVER_ID].on('scan', async () => {
startScanning();
});
// write handler
ipc.of[IPC_SERVER_ID].on('write', async (options: WriteOptions) => {
// Remove leftover tmp files older than 1 hour
cleanup(Date.now() - 60 * 60 * 1000);
let exitCode = SUCCESS;
ipc.of[IPC_SERVER_ID].on('cancel', () => onAbort(exitCode));
ipc.of[IPC_SERVER_ID].on('skip', () => onSkip(exitCode));
const results = await write(options);
if (results.errors.length > 0) {
results.errors = results.errors.map((error: any) => {
return toJSON(error);
});
exitCode = GENERAL_ERROR;
}
emit('done', { results });
await delay(DISCONNECT_DELAY);
await terminate(exitCode);
});
ipc.of[IPC_SERVER_ID].on('connect', () => {
log(
`Successfully connected to IPC server: ${IPC_SERVER_ID}, socket root ${ipc.config.socketRoot}`,
);
emit('ready', {});
});
// terminate the process cleanly on SIGINT
process.once('SIGINT', async () => {
await terminate(SUCCESS);
});
function emitLog(message: string) {
log(message);
// terminate the process cleanly on SIGTERM
process.once('SIGTERM', async () => {
await terminate(SUCCESS);
});
let terminateInterval = setTerminateTimeout();
interface EmitLog {
emit: (channel: string, message: object | string) => void;
log: (message: string) => void;
}
function emitState(state: MultiDestinationProgress) {
emit('state', state);
function setup(): Promise<EmitLog> {
return new Promise((resolve, reject) => {
wss.on('connection', (ws) => {
console.log('connection established... setting up');
/**
* @summary Send a message to the IPC server
*/
function emit(type: string, payload?: object | string) {
ws.send(JSON.stringify({ type, payload }));
// ipc.of[IPC_SERVER_ID].emit("message", { type, payload });
}
/**
* @summary Print logs and send them back to client
*/
function log(message: string) {
console.log(message);
emit('log', message);
}
/**
* @summary Handle `errors`
*/
async function handleError(error: Error) {
emit('error', toJSON(error));
await terminate(GENERAL_ERROR);
}
/**
* @summary Handle `abort` from client
*/
const onAbort = async (exitCode: number) => {
log('Abort');
emit('abort');
await terminate(exitCode);
};
/**
* @summary Handle `skip` from client; skip validation
*/
const onSkip = async (exitCode: number) => {
log('Skip validation');
emit('skip');
await terminate(exitCode);
};
/**
* @summary Handle `write` from client; start writing to the drives
*/
const onWrite = async (options: WriteOptions) => {
log('write requested');
// Remove leftover tmp files older than 1 hour
cleanup(Date.now() - 60 * 60 * 1000);
let exitCode = SUCCESS;
// Write to the drives
const results = await write(options);
// handle potential errors from the write process
if (results.errors.length > 0) {
results.errors = results.errors.map(toJSON);
exitCode = GENERAL_ERROR;
}
// send the results back to the client
emit('done', { results });
// terminate this process
await terminate(exitCode);
};
/**
* @summary Handle `sourceMetadata` from client; get source metadata
*/
const onSourceMetadata = async (params: any) => {
log('sourceMetadata requested');
const { selected, SourceType, auth } = JSON.parse(params);
try {
const sourceMatadata = await getSourceMetadata(
selected,
SourceType,
auth,
);
emitSourceMetadata(sourceMatadata);
} catch (error: any) {
emitFail(error);
}
};
// handle uncaught exceptions
process.once('uncaughtException', handleError);
// terminate the process if the connection is closed
ws.on('error', async () => {
await terminate(SUCCESS);
});
// route messages from the client by `type`
const messagesHandler: any = {
// terminate the process
terminate: () => terminate(SUCCESS),
/*
receive a `heartbeat`, reset the terminate timeout
this mechanism ensure the process will be terminated if the client is disconnected
*/
heartbeat: () => {
if (terminateInterval) {
clearTimeout(terminateInterval);
}
terminateInterval = setTerminateTimeout();
},
// resolve the setup promise when the client is ready
ready: () => {
log('Ready ...');
resolve({ emit, log });
},
// start scanning for drives
scan: () => {
log('Scan requested');
startScanning();
},
// route `cancel` from client
cancel: () => onAbort(GENERAL_ERROR),
// route `skip` from client
skip: () => onSkip(GENERAL_ERROR),
// route `write` from client
write: async (options: WriteOptions) => onWrite(options),
// route `sourceMetadata` from client
sourceMetadata: async (params: any) => onSourceMetadata(params),
};
// message handler, parse and route messages coming on WS
ws.on('message', async (jsonData: any) => {
const data = JSON.parse(jsonData);
const message = messagesHandler[data.type];
if (message) {
await message(data.payload);
} else {
throw new Error(`Unknown message type: ${data.type}`);
}
});
// inform the client that the server is ready to receive messages
emit('ready', {});
ws.on('error', (error) => {
reject(error);
});
});
});
}
function emitFail(data: any) {
emit('fail', data);
}
// setTimeout(() => console.log('wss', wss.address()), 1000);
console.log('waiting for connection...');
function emitDrives(drives: Dictionary<DrivelistDrive>) {
emit('drives', JSON.stringify(values(drives)));
}
setup().then(({ emit, log }: EmitLog) => {
// connection is established, clear initial terminate timeout
if (terminateInterval) {
clearInterval(terminateInterval);
}
function emitSourceMetadata(sourceMetadata: any) {
emit('sourceMetadata', JSON.stringify(sourceMetadata));
}
console.log('waiting for instruction...');
// set the exportable emit functions
emitLog = (message) => {
log(message);
};
emitState = (state) => {
emit('state', state);
};
emitFail = (data) => {
emit('fail', data);
};
emitDrives = (drives) => {
emit('drives', JSON.stringify(values(drives)));
};
emitSourceMetadata = (sourceMetadata) => {
emit('sourceMetadata', JSON.stringify(sourceMetadata));
};
});
export { emitLog, emitState, emitFail, emitDrives, emitSourceMetadata };

View File

@ -146,7 +146,7 @@ export async function cleanup(until: number) {
* @param {Boolean} autoBlockmapping - whether to trim ext partitions before writing
* @param {Function} onProgress - function to call on progress
* @param {Function} onFail - function to call on fail
* @returns {Promise<{ bytesWritten, devices, errors} >}
* @returns {Promise<{ bytesWritten, devices, errors }>}
*/
async function writeAndValidate({
source,

View File

@ -68,7 +68,8 @@ async function getSourceMetadata(
selected: string | DrivelistDrive,
SourceType: Source,
auth?: Authentication,
) {
): Promise<SourceMetadata | Record<string, never>> {
// `Record<string, never>` means an empty object
if (isString(selected)) {
const source = await createSource(selected, SourceType, auth);
@ -80,13 +81,12 @@ async function getSourceMetadata(
return metadata;
} catch (error: any) {
// TODO: handle error
return {};
} finally {
try {
await source.close();
} catch (error: any) {
// Noop
}
await source.close();
}
} else {
return {};
}
}

235
npm-shrinkwrap.json generated
View File

@ -9,20 +9,20 @@
"version": "1.19.9",
"license": "Apache-2.0",
"dependencies": {
"@balena/sudo-prompt": "9.2.1-workaround-windows-amperstand-in-username-0849e215b947987a643fe5763902aea201255534",
"@electron/remote": "^2.1.0",
"@fortawesome/fontawesome-free": "6.5.1",
"@ronomon/direct-io": "^3.0.1",
"@sentry/electron": "^4.15.1",
"analytics-client": "^2.0.1",
"axios": "^1.6.0",
"debug": "4.3.4",
"drivelist": "^12.0.2",
"electron-squirrel-startup": "^1.0.0",
"electron-updater": "6.1.7",
"etcher-sdk": "9.0.0",
"etcher-sdk": "9.0.7",
"i18next": "23.7.8",
"immutable": "3.8.2",
"lodash": "4.17.21",
"node-ipc": "9.2.1",
"outdent": "0.8.0",
"path-is-inside": "1.0.2",
"pretty-bytes": "5.6.0",
@ -34,7 +34,8 @@
"semver": "7.5.4",
"styled-components": "5.3.6",
"sys-class-rgb-led": "3.0.1",
"uuid": "9.0.1"
"uuid": "9.0.1",
"ws": "^8.16.0"
},
"devDependencies": {
"@balena/lint": "7.2.4",
@ -53,7 +54,6 @@
"@types/mime-types": "2.1.4",
"@types/mocha": "^10.0.6",
"@types/node": "^20.11.6",
"@types/node-ipc": "9.2.3",
"@types/react": "17.0.2",
"@types/react-dom": "17.0.2",
"@types/semver": "7.5.6",
@ -83,7 +83,11 @@
"xvfb-maybe": "^0.2.1"
},
"engines": {
"node": ">=18 <20"
"node": ">=20 <21"
},
"optionalDependencies": {
"bufferutil": "^4.0.8",
"utf-8-validate": "^5.0.10"
}
},
"node_modules/@aashutoshrathi/word-wrap": {
@ -2608,10 +2612,6 @@
"node": ">=16"
}
},
"node_modules/@balena/sudo-prompt": {
"version": "9.2.1-workaround-windows-amperstand-in-username-0849e215b947987a643fe5763902aea201255534",
"license": "MIT"
},
"node_modules/@balena/udif": {
"version": "1.1.2",
"license": "MIT",
@ -5101,8 +5101,9 @@
},
"node_modules/@ronomon/direct-io": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@ronomon/direct-io/-/direct-io-3.0.1.tgz",
"integrity": "sha512-NkKB32bjq7RfMdAMiWayphMlVWzsfPiKelK+btXLqggv1vDVgv2xELqeo0z4uYLLt86fVReLPxQj7qpg0zWvow==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@ronomon/queue": "^3.0.1"
}
@ -6380,14 +6381,6 @@
"undici-types": "~5.26.4"
}
},
"node_modules/@types/node-ipc": {
"version": "9.2.3",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/parse-json": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
@ -8505,6 +8498,19 @@
"node": ">=0.2.0"
}
},
"node_modules/bufferutil": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.8.tgz",
"integrity": "sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"node-gyp-build": "^4.3.0"
},
"engines": {
"node": ">=6.14.2"
}
},
"node_modules/builder-util-runtime": {
"version": "9.2.3",
"license": "MIT",
@ -10360,17 +10366,26 @@
}
},
"node_modules/drivelist": {
"version": "11.1.0",
"version": "12.0.2",
"resolved": "https://registry.npmjs.org/drivelist/-/drivelist-12.0.2.tgz",
"integrity": "sha512-Nps4pc1ukIqDj7v00wGgBkS7P3VVEZZKcaTPVcE1Yl+dLojXuEv76BuSg6HgmhjeOFIIMz8q7Y+2tux6gYqCvg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"bindings": "^1.5.0",
"debug": "^4.3.4",
"node-addon-api": "^5.0.0",
"node-addon-api": "^8.0.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": ">=16 < 19"
"node": ">=18"
}
},
"node_modules/drivelist/node_modules/node-addon-api": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.0.0.tgz",
"integrity": "sha512-ipO7rsHEBqa9STO5C5T10fj732ml+5kLN1cAG8/jdHd56ldQeGj3Q7+scUS+VHK/qy1zLEwC4wMK5+yM0btPvw==",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/ds-store": {
@ -10392,13 +10407,6 @@
"version": "0.2.0",
"license": "MIT"
},
"node_modules/easy-stack": {
"version": "1.0.1",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/easymde": {
"version": "2.18.0",
"license": "MIT",
@ -11674,8 +11682,9 @@
}
},
"node_modules/etcher-sdk": {
"version": "9.0.0",
"license": "Apache-2.0",
"version": "9.0.7",
"resolved": "https://registry.npmjs.org/etcher-sdk/-/etcher-sdk-9.0.7.tgz",
"integrity": "sha512-RyTYtZXk2hTg9ZjVu9h6yQ5qFgGD7EraO+BhAElKWP9v0CKScHoVHLL0cWcoqWUBGecriqgu26XMrC3r0obexA==",
"dependencies": {
"@balena/node-beaglebone-usbboot": "^3.0.0",
"@balena/udif": "^1.1.2",
@ -11687,7 +11696,7 @@
"check-disk-space": "^3.4.0",
"cyclic-32": "^1.1.0",
"debug": "^4.3.4",
"drivelist": "^11.1.0",
"drivelist": "^11.2.0",
"file-disk": "^8.0.1",
"file-type": "^16.0.0",
"glob": "^10.3.10",
@ -11720,6 +11729,21 @@
"balanced-match": "^1.0.0"
}
},
"node_modules/etcher-sdk/node_modules/drivelist": {
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/drivelist/-/drivelist-11.2.2.tgz",
"integrity": "sha512-shzkC4h3Q6sVkF9v9lbT1j49LN47O7h0GJk9E4VtJe81Xp6GF1O36gpnWpqRL6VvFya086eu4XcBEOwSXHHjeQ==",
"hasInstallScript": true,
"dependencies": {
"bindings": "^1.5.0",
"debug": "^4.3.4",
"node-addon-api": "^5.0.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": ">=16"
}
},
"node_modules/etcher-sdk/node_modules/glob": {
"version": "10.3.10",
"license": "ISC",
@ -11761,13 +11785,6 @@
"es5-ext": "~0.10.14"
}
},
"node_modules/event-pubsub": {
"version": "4.3.0",
"license": "Unlicense",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"dev": true,
@ -12527,6 +12544,7 @@
"node_modules/fs-xattr": {
"version": "0.3.1",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
@ -14672,23 +14690,6 @@
"node": ">=14"
}
},
"node_modules/js-message": {
"version": "1.0.7",
"license": "MIT",
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/js-queue": {
"version": "2.0.2",
"license": "MIT",
"dependencies": {
"easy-stack": "^1.0.1"
},
"engines": {
"node": ">=1.0.0"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"license": "MIT"
@ -15937,6 +15938,7 @@
"node_modules/macos-alias": {
"version": "0.2.11",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
@ -17624,18 +17626,6 @@
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-ipc": {
"version": "9.2.1",
"license": "MIT",
"dependencies": {
"event-pubsub": "4.3.0",
"js-message": "1.0.7",
"js-queue": "2.0.2"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/node-loader": {
"version": "2.0.0",
"dev": true,
@ -22552,6 +22542,19 @@
"which": "bin/which"
}
},
"node_modules/utf-8-validate": {
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz",
"integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"node-gyp-build": "^4.3.0"
},
"engines": {
"node": ">=6.14.2"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"license": "MIT"
@ -23265,9 +23268,9 @@
}
},
"node_modules/ws": {
"version": "8.13.0",
"dev": true,
"license": "MIT",
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz",
"integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==",
"engines": {
"node": ">=10.0.0"
},
@ -25227,9 +25230,6 @@
"@balena/node-crc-utils": {
"version": "3.0.0"
},
"@balena/sudo-prompt": {
"version": "9.2.1-workaround-windows-amperstand-in-username-0849e215b947987a643fe5763902aea201255534"
},
"@balena/udif": {
"version": "1.1.2",
"requires": {
@ -26862,6 +26862,8 @@
},
"@ronomon/direct-io": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@ronomon/direct-io/-/direct-io-3.0.1.tgz",
"integrity": "sha512-NkKB32bjq7RfMdAMiWayphMlVWzsfPiKelK+btXLqggv1vDVgv2xELqeo0z4uYLLt86fVReLPxQj7qpg0zWvow==",
"requires": {
"@ronomon/queue": "^3.0.1"
}
@ -27719,13 +27721,6 @@
"undici-types": "~5.26.4"
}
},
"@types/node-ipc": {
"version": "9.2.3",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/parse-json": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
@ -29211,6 +29206,15 @@
"buffers": {
"version": "0.1.1"
},
"bufferutil": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.8.tgz",
"integrity": "sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==",
"optional": true,
"requires": {
"node-gyp-build": "^4.3.0"
}
},
"builder-util-runtime": {
"version": "9.2.3",
"requires": {
@ -30399,12 +30403,21 @@
"version": "5.0.1"
},
"drivelist": {
"version": "11.1.0",
"version": "12.0.2",
"resolved": "https://registry.npmjs.org/drivelist/-/drivelist-12.0.2.tgz",
"integrity": "sha512-Nps4pc1ukIqDj7v00wGgBkS7P3VVEZZKcaTPVcE1Yl+dLojXuEv76BuSg6HgmhjeOFIIMz8q7Y+2tux6gYqCvg==",
"requires": {
"bindings": "^1.5.0",
"debug": "^4.3.4",
"node-addon-api": "^5.0.0",
"node-addon-api": "^8.0.0",
"prebuild-install": "^7.1.1"
},
"dependencies": {
"node-addon-api": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.0.0.tgz",
"integrity": "sha512-ipO7rsHEBqa9STO5C5T10fj732ml+5kLN1cAG8/jdHd56ldQeGj3Q7+scUS+VHK/qy1zLEwC4wMK5+yM0btPvw=="
}
}
},
"ds-store": {
@ -30423,9 +30436,6 @@
"eastasianwidth": {
"version": "0.2.0"
},
"easy-stack": {
"version": "1.0.1"
},
"easymde": {
"version": "2.18.0",
"requires": {
@ -31348,7 +31358,9 @@
"dev": true
},
"etcher-sdk": {
"version": "9.0.0",
"version": "9.0.7",
"resolved": "https://registry.npmjs.org/etcher-sdk/-/etcher-sdk-9.0.7.tgz",
"integrity": "sha512-RyTYtZXk2hTg9ZjVu9h6yQ5qFgGD7EraO+BhAElKWP9v0CKScHoVHLL0cWcoqWUBGecriqgu26XMrC3r0obexA==",
"requires": {
"@balena/node-beaglebone-usbboot": "^3.0.0",
"@balena/udif": "^1.1.2",
@ -31360,7 +31372,7 @@
"check-disk-space": "^3.4.0",
"cyclic-32": "^1.1.0",
"debug": "^4.3.4",
"drivelist": "^11.1.0",
"drivelist": "^11.2.0",
"file-disk": "^8.0.1",
"file-type": "^16.0.0",
"glob": "^10.3.10",
@ -31387,6 +31399,17 @@
"balanced-match": "^1.0.0"
}
},
"drivelist": {
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/drivelist/-/drivelist-11.2.2.tgz",
"integrity": "sha512-shzkC4h3Q6sVkF9v9lbT1j49LN47O7h0GJk9E4VtJe81Xp6GF1O36gpnWpqRL6VvFya086eu4XcBEOwSXHHjeQ==",
"requires": {
"bindings": "^1.5.0",
"debug": "^4.3.4",
"node-addon-api": "^5.0.0",
"prebuild-install": "^7.1.1"
}
},
"glob": {
"version": "10.3.10",
"requires": {
@ -31412,9 +31435,6 @@
"es5-ext": "~0.10.14"
}
},
"event-pubsub": {
"version": "4.3.0"
},
"eventemitter3": {
"version": "4.0.7",
"dev": true
@ -33199,15 +33219,6 @@
"js-cookie": {
"version": "3.0.5"
},
"js-message": {
"version": "1.0.7"
},
"js-queue": {
"version": "2.0.2",
"requires": {
"easy-stack": "^1.0.1"
}
},
"js-tokens": {
"version": "4.0.0"
},
@ -35157,14 +35168,6 @@
"node-gyp-build": {
"version": "4.6.1"
},
"node-ipc": {
"version": "9.2.1",
"requires": {
"event-pubsub": "4.3.0",
"js-message": "1.0.7",
"js-queue": "2.0.2"
}
},
"node-loader": {
"version": "2.0.0",
"dev": true,
@ -38320,6 +38323,15 @@
}
}
},
"utf-8-validate": {
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz",
"integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==",
"optional": true,
"requires": {
"node-gyp-build": "^4.3.0"
}
},
"util-deprecate": {
"version": "1.0.2"
},
@ -38798,8 +38810,9 @@
}
},
"ws": {
"version": "8.13.0",
"dev": true,
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz",
"integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==",
"requires": {}
},
"xdg-basedir": {

View File

@ -31,20 +31,20 @@
"author": "Balena Ltd. <hello@balena.io>",
"license": "Apache-2.0",
"dependencies": {
"@balena/sudo-prompt": "9.2.1-workaround-windows-amperstand-in-username-0849e215b947987a643fe5763902aea201255534",
"@electron/remote": "^2.1.0",
"@fortawesome/fontawesome-free": "6.5.1",
"@ronomon/direct-io": "^3.0.1",
"@sentry/electron": "^4.15.1",
"analytics-client": "^2.0.1",
"axios": "^1.6.0",
"debug": "4.3.4",
"drivelist": "^12.0.2",
"electron-squirrel-startup": "^1.0.0",
"electron-updater": "6.1.7",
"etcher-sdk": "9.0.0",
"etcher-sdk": "9.0.7",
"i18next": "23.7.8",
"immutable": "3.8.2",
"lodash": "4.17.21",
"node-ipc": "9.2.1",
"outdent": "0.8.0",
"path-is-inside": "1.0.2",
"pretty-bytes": "5.6.0",
@ -56,7 +56,8 @@
"semver": "7.5.4",
"styled-components": "5.3.6",
"sys-class-rgb-led": "3.0.1",
"uuid": "9.0.1"
"uuid": "9.0.1",
"ws": "^8.16.0"
},
"devDependencies": {
"@balena/lint": "7.2.4",
@ -75,13 +76,13 @@
"@types/mime-types": "2.1.4",
"@types/mocha": "^10.0.6",
"@types/node": "^20.11.6",
"@types/node-ipc": "9.2.3",
"@types/react": "17.0.2",
"@types/react-dom": "17.0.2",
"@types/semver": "7.5.6",
"@types/sinon": "17.0.2",
"@types/tmp": "0.2.6",
"@vercel/webpack-asset-relocator-loader": "1.7.3",
"@yao-pkg/pkg": "^5.11.1",
"catch-uncommitted": "^2.0.0",
"chai": "4.3.10",
"css-loader": "5.2.7",
@ -93,7 +94,6 @@
"native-addon-loader": "2.0.1",
"node-loader": "^2.0.0",
"omit-deep-lodash": "1.1.7",
"@yao-pkg/pkg": "^5.11.1",
"sinon": "17.0.1",
"string-replace-loader": "3.1.0",
"style-loader": "3.3.3",
@ -144,9 +144,13 @@
]
},
"engines": {
"node": ">=18 <20"
"node": ">=20 <21"
},
"versionist": {
"publishedAt": "2024-04-22T10:20:10.994Z"
"publishedAt": "2024-01-26T17:29:27.845Z"
},
"optionalDependencies": {
"bufferutil": "^4.0.8",
"utf-8-validate": "^5.0.10"
}
}

View File

@ -1,182 +0,0 @@
/*
* This is a test wrapper for etcher-utils.
* The only use for this file is debugging while developing etcher-utils.
* It will create a IPC server, spawn the cli version of etcher-writer, and wait for it to connect.
* Requires elevated privileges to work (launch with sudo)
* Note that you'll need to to edit `ipc.server.on('ready', ...` function based on what you want to test.
*/
import * as ipc from 'node-ipc';
import * as os from 'os';
import * as path from 'path';
import * as packageJSON from './package.json';
import * as permissions from './lib/shared/permissions';
// if (process.argv.length !== 3) {
// console.error('Expects an image to flash as only arg!');
// process.exit(1);
// }
const THREADS_PER_CPU = 16;
// There might be multiple Etcher instances running at
// the same time, therefore we must ensure each IPC
// server/client has a different name.
const IPC_SERVER_ID = `etcher-server-${process.pid}`;
const IPC_CLIENT_ID = `etcher-client-${process.pid}`;
ipc.config.id = IPC_SERVER_ID;
ipc.config.socketRoot = path.join(
process.env.XDG_RUNTIME_DIR || os.tmpdir(),
path.sep,
);
// NOTE: Ensure this isn't disabled, as it will cause
// the stdout maxBuffer size to be exceeded when flashing
ipc.config.silent = true;
function writerArgv(): string[] {
const entryPoint = path.join('./generated/etcher-util');
return [entryPoint];
}
function writerEnv() {
return {
IPC_SERVER_ID,
IPC_CLIENT_ID,
IPC_SOCKET_ROOT: ipc.config.socketRoot,
UV_THREADPOOL_SIZE: (os.cpus().length * THREADS_PER_CPU).toString(),
// This environment variable prevents the AppImages
// desktop integration script from presenting the
// "installation" dialog
SKIP: '1',
...(process.platform === 'win32' ? {} : process.env),
};
}
async function start(): Promise<any> {
ipc.serve();
return await new Promise((resolve, reject) => {
ipc.server.on('error', (message) => {
console.log('IPC server error', message);
});
ipc.server.on('log', (message) => {
console.log('log', message);
});
ipc.server.on('fail', ({ device, error }) => {
console.log('failure', error, device);
});
ipc.server.on('done', (event) => {
console.log('done', event);
});
ipc.server.on('abort', () => {
console.log('abort');
});
ipc.server.on('skip', () => {
console.log('skip');
});
ipc.server.on('state', (progress) => {
console.log('progress', progress);
});
ipc.server.on('drives', (drives) => {
console.log('drives', drives);
});
ipc.server.on('ready', (_data, socket) => {
console.log('ready');
ipc.server.emit(socket, 'scan', {});
// ipc.server.emit(socket, "hello", { message: "world" });
// ipc.server.emit(socket, "write", {
// image: {
// path: process.argv[2],
// displayName: "Random image for test",
// description: "Random image for test",
// SourceType: "File",
// },
// destinations: [
// {
// size: 15938355200,
// isVirtual: false,
// enumerator: "DiskArbitration",
// logicalBlockSize: 512,
// raw: "/dev/rdisk4",
// error: null,
// isReadOnly: false,
// displayName: "/dev/disk4",
// blockSize: 512,
// isSCSI: false,
// isRemovable: true,
// device: "/dev/disk4",
// busVersion: null,
// isSystem: false,
// busType: "USB",
// isCard: false,
// isUSB: true,
// devicePath:
// "IODeviceTree:/arm-io@10F00000/usb-drd1@2280000/usb-drd1-port-hs@01100000",
// mountpoints: [
// {
// path: "/Volumes/flash-rootB",
// label: "flash-rootB",
// },
// {
// path: "/Volumes/flash-rootA",
// label: "flash-rootA",
// },
// {
// path: "/Volumes/flash-boot",
// label: "flash-boot",
// },
// ],
// description: "Generic Flash Disk Media",
// isUAS: null,
// partitionTableType: "mbr",
// },
// ],
// SourceType: "File",
// autoBlockmapping: true,
// decompressFirst: true,
// });
});
const argv = writerArgv();
ipc.server.on('start', async () => {
console.log(`Elevating command: ${argv.join(' ')}`);
const env = writerEnv();
try {
await permissions.elevateCommand(argv, {
applicationName: packageJSON.displayName,
environment: env,
});
} catch (error: any) {
console.log('error', error);
// This happens when the child is killed using SIGKILL
const SIGKILL_EXIT_CODE = 137;
if (error.code === SIGKILL_EXIT_CODE) {
error.code = 'ECHILDDIED';
}
reject(error);
} finally {
console.log('Terminating IPC server');
}
resolve(true);
});
// Clear the update lock timer to prevent longer
// flashing timing it out, and releasing the lock
ipc.server.start();
});
}
start();

View File

@ -1,26 +0,0 @@
/*
* Copyright 2018 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 ipc from 'node-ipc';
import('../../../lib/gui/modules/child-writer');
describe('Browser: childWriter', function () {
it('should have the ipc config set to silent', function () {
expect(ipc.config.silent).to.be.true;
});
});

View File

@ -17,7 +17,6 @@
import { expect } from 'chai';
import { Drive as DrivelistDrive } from 'drivelist';
import { sourceDestination } from 'etcher-sdk';
import * as ipc from 'node-ipc';
import { assert, SinonStub, stub } from 'sinon';
import { SourceMetadata } from '../../../lib/gui/app/components/source-selector/source-selector';
@ -140,11 +139,4 @@ describe('Browser: imageWriter', () => {
});
});
});
describe('.performWrite()', function () {
it('should set the ipc config to silent', function () {
// Reset this value as it can persist from other tests
expect(ipc.config.silent).to.be.true;
});
});
});

View File

@ -15,6 +15,7 @@
*/
import type { Configuration, ModuleOptions } from 'webpack';
import { resolve } from 'path';
import {
BannerPlugin,
@ -112,8 +113,13 @@ export const rendererConfig: Configuration = {
raw: true,
}),
],
resolve: {
extensions: ['.js', '.ts', '.jsx', '.tsx', '.css'],
alias: {
// need to alias ws to the wrapper to avoid the browser fake version to be used
ws: resolve(__dirname, 'node_modules/ws/wrapper.mjs'),
},
},
};