diff --git a/build/afterPack.js b/build/afterPack.js new file mode 100644 index 0000000..200aae1 --- /dev/null +++ b/build/afterPack.js @@ -0,0 +1,29 @@ +const fs = require('fs'); +const path = require('path'); +const { ensureChromeSandboxPermissions } = require('./sandboxPermissions'); + +/** + * Ensure the bundled chrome-sandbox binary has the correct permissions on Linux + * so Chromium's sandbox can start without disabling it. + */ +exports.default = async function afterPack(context) { + if (context.electronPlatformName !== 'linux') { + return; + } + + const sandboxPath = path.join(context.appOutDir, 'chrome-sandbox'); + + const sandboxAdjusted = await ensureChromeSandboxPermissions( + sandboxPath, + (message) => context.packager.info(message), + ); + + if (!sandboxAdjusted) { + try { + await fs.promises.access(sandboxPath, fs.constants.F_OK); + context.packager.info('chrome-sandbox permissions could not be fully adjusted; ensure root ownership after installation if sandbox errors occur.'); + } catch (error) { + context.packager.info(`chrome-sandbox missing at ${sandboxPath}: ${error.message}`); + } + } +}; diff --git a/build/sandboxPermissions.js b/build/sandboxPermissions.js new file mode 100644 index 0000000..983236b --- /dev/null +++ b/build/sandboxPermissions.js @@ -0,0 +1,122 @@ +const fs = require('fs'); + +const DESIRED_MODE = 0o4755; +const USERNS_FLAG_PATH = '/proc/sys/kernel/unprivileged_userns_clone'; + +function formatUid(uid) { + return typeof uid === 'number' ? uid : 'unknown'; +} + +async function ensureChromeSandboxPermissions(sandboxPath, log) { + const logger = typeof log === 'function' ? log : () => {}; + + try { + await fs.promises.access(sandboxPath, fs.constants.X_OK); + } catch (error) { + logger(`chrome-sandbox missing or not executable at ${sandboxPath}: ${error.message}`); + return false; + } + + let stat; + try { + stat = await fs.promises.stat(sandboxPath); + } catch (error) { + logger(`Unable to read chrome-sandbox stats: ${error.message}`); + return false; + } + + const currentMode = stat.mode & 0o7777; + const needsModeUpdate = currentMode !== DESIRED_MODE; + const needsOwnerUpdate = typeof stat.uid === 'number' && stat.uid !== 0; + + if (!needsModeUpdate && !needsOwnerUpdate) { + logger(`chrome-sandbox already has correct permissions at ${sandboxPath}`); + return true; + } + + const canChown = typeof process.geteuid === 'function' && process.geteuid() === 0; + + if (needsOwnerUpdate && !canChown) { + logger(`chrome-sandbox is owned by uid ${formatUid(stat.uid)}; run with elevated privileges to set root ownership and setuid bit.`); + return false; + } + + try { + if (needsOwnerUpdate && canChown) { + await fs.promises.chown(sandboxPath, 0, typeof stat.gid === 'number' ? stat.gid : 0); + } + + if (needsModeUpdate || needsOwnerUpdate) { + await fs.promises.chmod(sandboxPath, DESIRED_MODE); + } + + logger(`Adjusted chrome-sandbox permissions to mode 4755${needsOwnerUpdate ? ' with root ownership' : ''}.`); + return true; + } catch (error) { + logger(`Unable to adjust chrome-sandbox permissions: ${error.message}`); + return false; + } +} + +async function hasUserNamespaceSupport(log) { + const logger = typeof log === 'function' ? log : () => {}; + + try { + const contents = await fs.promises.readFile(USERNS_FLAG_PATH, 'utf8'); + const isEnabled = contents.trim() === '1'; + + if (!isEnabled) { + logger(`User namespaces are disabled (value at ${USERNS_FLAG_PATH} is ${contents.trim()})`); + } + + return isEnabled; + } catch (error) { + logger(`Unable to read ${USERNS_FLAG_PATH}: ${error.message}`); + return false; + } +} + +async function disableSetuidSandboxHelper(sandboxPath, log) { + const logger = typeof log === 'function' ? log : () => {}; + + try { + await fs.promises.access(sandboxPath, fs.constants.F_OK); + } catch (error) { + logger(`chrome-sandbox missing at ${sandboxPath}, nothing to disable.`); + return true; + } + + const renamedPath = `${sandboxPath}.disabled`; + + try { + await fs.promises.rename(sandboxPath, renamedPath); + logger(`Renamed chrome-sandbox to ${renamedPath} to allow user namespace sandbox.`); + return true; + } catch (error) { + logger(`Failed to disable chrome-sandbox helper: ${error.message}`); + return false; + } +} + +async function prepareChromeSandbox(sandboxPath, log) { + const logger = typeof log === 'function' ? log : () => {}; + + const adjusted = await ensureChromeSandboxPermissions(sandboxPath, logger); + + if (adjusted) { + return true; + } + + if (await hasUserNamespaceSupport(logger)) { + logger('Falling back to user-namespace sandbox because setuid chrome-sandbox cannot be prepared.'); + return disableSetuidSandboxHelper(sandboxPath, logger); + } + + logger('Cannot adjust chrome-sandbox permissions and user namespaces are unavailable.'); + return false; +} + +module.exports = { + ensureChromeSandboxPermissions, + prepareChromeSandbox, +}; diff --git a/main.js b/main.js index 2dbe3f9..f3e2592 100644 --- a/main.js +++ b/main.js @@ -1,6 +1,7 @@ const { app, BrowserWindow, Menu, Tray } = require('electron') const path = require('path') const log = require('electron-log'); +const { prepareChromeSandbox } = require('./build/sandboxPermissions'); log.initialize(); @@ -177,6 +178,24 @@ function getIconDir() { } // check if second instance was started +async function prepareSandbox() { + if (process.platform !== 'linux') { + return; + } + + const sandboxPath = path.join(path.dirname(process.execPath), 'chrome-sandbox'); + + const prepared = await prepareChromeSandbox(sandboxPath, (message) => log.info(message)); + + if (!prepared) { + log.error('chrome-sandbox cannot be prepared and user namespaces are unavailable; Electron will abort to avoid running without sandboxing.'); + log.error('Run the following commands with sudo/root privileges to fix:'); + log.error(` sudo chown root:root ${sandboxPath}`); + log.error(` sudo chmod 4755 ${sandboxPath}`); + app.exit(1); + } +} + if (!gotTheLock) { log.info('WLED-GUI quitted'); app.quit() @@ -191,8 +210,11 @@ if (!gotTheLock) { // This method will be called when Electron has finished // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. - app.whenReady().then(createWindow) - app.whenReady().then(loadSettings) + app.whenReady().then(async () => { + await prepareSandbox(); + createWindow(); + loadSettings(); + }) // Quit when all windows are closed, except on macOS. There, it's common // for applications and their menu bar to stay active until the user quits diff --git a/package.json b/package.json index bf6b724..3ba4807 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "build mac": "electron-builder --mac --publish=never" }, "build": { + "afterPack": "./build/afterPack.js", "appId": "wled-gui.woody.pizza", "productName": "WLED", "artifactName": "${productName}-${version}-${os}.${ext}",