diff --git a/src/acme2.js b/src/acme2.js index f5107936e..b60ccedfc 100644 --- a/src/acme2.js +++ b/src/acme2.js @@ -74,7 +74,7 @@ function b64(str) { async function getModulus(pem) { assert.strictEqual(typeof pem, 'string'); - const stdout = await shell.exec('openssl rsa -modulus -noout', { input: pem }); + const stdout = await shell.spawn('openssl', ['rsa', '-modulus', '-noout'], { encoding: 'utf8', input: pem }); const match = stdout.match(/Modulus=([0-9a-fA-F]+)$/m); if (!match) throw new BoxError(BoxError.OPENSSL_ERROR, 'Could not get modulus'); return Buffer.from(match[1], 'hex'); @@ -153,7 +153,7 @@ Acme2.prototype.updateContact = async function (registrationUri) { }; async function generateAccountKey() { - const acmeAccountKey = await shell.exec('openssl genrsa 4096', {}); + const acmeAccountKey = await shell.spawn('openssl', ['genrsa', '4096'], { encoding: 'utf8' }); return acmeAccountKey; } @@ -294,7 +294,7 @@ Acme2.prototype.signCertificate = async function (finalizationUrl, csrPem) { assert.strictEqual(typeof finalizationUrl, 'string'); assert.strictEqual(typeof csrPem, 'string'); - const csrDer = await shell.exec('openssl req -inform pem -outform der', { input: csrPem, encoding: 'buffer' }); + const csrDer = await shell.spawn('openssl', ['req', '-inform', 'pem', '-outform', 'der'], { input: csrPem, encoding: 'buffer' }); const payload = { csr: b64(csrDer) @@ -316,7 +316,7 @@ Acme2.prototype.ensureKey = async function () { debug(`ensureKey: generating new key for ${this.cn}`); // same as prime256v1. openssl ecparam -list_curves. we used to use secp384r1 but it doesn't seem to be accepted by few mail servers - const newKey = await shell.exec('openssl ecparam -genkey -name secp256r1', {}); + const newKey = await shell.spawn('openssl', ['ecparam', '-genkey', '-name', 'secp256r1'], { encoding: 'utf8' }); return newKey; }; @@ -344,7 +344,7 @@ Acme2.prototype.createCsr = async function (key) { if (!safe.fs.writeFileSync(opensslConfigFile, conf)) throw new BoxError(BoxError.FS_ERROR, `Failed to write openssl config: ${safe.error.message}`); // while we pass the CN anyways, subjectAltName takes precedence - const csrPem = await shell.exec(`openssl req -new -key ${keyFilePath} -outform PEM -subj /CN=${this.cn} -config ${opensslConfigFile}`, {}); + const csrPem = await shell.spawn('openssl', ['req', '-new', '-key', keyFilePath, '-outform', 'PEM', '-subj', `/CN=${this.cn}`, '-config', opensslConfigFile], { encoding: 'utf8' }); await safe(fs.promises.rm(tmpdir, { recursive: true, force: true })); debug(`createCsr: csr file created for ${this.cn}`); return csrPem; // inspect with openssl req -text -noout -in hostname.csr -inform pem diff --git a/src/appstore.js b/src/appstore.js index 908afe4f1..396f54a73 100644 --- a/src/appstore.js +++ b/src/appstore.js @@ -394,7 +394,7 @@ async function createTicket(info, auditSource) { const logPaths = await apps.getLogPaths(info.app); for (const logPath of logPaths) { - const [error, logs] = await safe(shell.exec(`tail --lines=1000 ${logPath}`, {})); + const [error, logs] = await safe(shell.spawn('tail', ['--lines=1000', logPath], { encoding: 'utf8' })); if (!error && logs) request.attach(path.basename(logPath), logs, path.basename(logPath)); } } else { diff --git a/src/backupformat/rsync.js b/src/backupformat/rsync.js index e4b2551fd..f62df9d6e 100644 --- a/src/backupformat/rsync.js +++ b/src/backupformat/rsync.js @@ -125,13 +125,13 @@ async function saveFsMetadata(dataLayout, metadataFile) { // we assume small number of files. spawnSync will raise a ENOBUFS error after maxBuffer for (const lp of dataLayout.localPaths()) { - const emptyDirs = await shell.exec(`find ${lp} -type d -empty`, { maxBuffer: 1024 * 1024 * 80 }); + const emptyDirs = await shell.spawn('find', [lp, '-type', 'd', '-empty'], { encoding: 'utf8', maxBuffer: 1024 * 1024 * 80 }); if (emptyDirs.length) metadata.emptyDirs = metadata.emptyDirs.concat(emptyDirs.trim().split('\n').map((ed) => dataLayout.toRemotePath(ed))); - const execFiles = await shell.exec(`find ${lp} -type f -executable`, { maxBuffer: 1024 * 1024 * 80 }); + const execFiles = await shell.spawn('find', [lp, '-type', 'f', '-executable'], { encoding: 'utf8', maxBuffer: 1024 * 1024 * 80 }); if (execFiles.length) metadata.execFiles = metadata.execFiles.concat(execFiles.trim().split('\n').map((ef) => dataLayout.toRemotePath(ef))); - const symlinkFiles = await shell.exec(`find ${lp} -type l`, { maxBuffer: 1024 * 1024 * 30 }); + const symlinkFiles = await shell.spawn('find', [lp, '-type', 'l'], { encoding: 'utf8', maxBuffer: 1024 * 1024 * 30 }); if (symlinkFiles.length) metadata.symlinks = metadata.symlinks.concat(symlinkFiles.trim().split('\n').map((sl) => { const target = safe.fs.readlinkSync(sl); return { path: dataLayout.toRemotePath(sl), target }; diff --git a/src/backuptask.js b/src/backuptask.js index 416ff11b4..d59c6740e 100644 --- a/src/backuptask.js +++ b/src/backuptask.js @@ -61,7 +61,7 @@ async function checkPreconditions(backupConfig, dataLayout) { let used = 0; for (const localPath of dataLayout.localPaths()) { debug(`checkPreconditions: getting disk usage of ${localPath}`); - const result = await shell.execArgs('du', [ '-Dsb', '--exclude=*.lock', '--exclude=dovecot.list.index.log.*', localPath], {}); + const result = await shell.spawn('du', [ '-Dsb', '--exclude=*.lock', '--exclude=dovecot.list.index.log.*', localPath], {}); used += parseInt(result, 10); } diff --git a/src/database.js b/src/database.js index 1d73acd5a..46034b4d5 100644 --- a/src/database.js +++ b/src/database.js @@ -146,7 +146,7 @@ async function exportToFile(file) { assert.strictEqual(typeof file, 'string'); // latest mysqldump enables column stats by default which is not present in 5.7 util - const mysqlDumpHelp = await shell.exec('/usr/bin/mysqldump --help', {}); + const mysqlDumpHelp = await shell.spawn('/usr/bin/mysqldump', ['--help'], { encoding: 'utf8' }); const hasColStats = mysqlDumpHelp.includes('column-statistics'); const colStats = hasColStats ? '--column-statistics=0' : ''; diff --git a/src/df.js b/src/df.js index f64ef5523..79c3c3ca9 100644 --- a/src/df.js +++ b/src/df.js @@ -37,7 +37,7 @@ function parseLine(line) { } async function disks() { - const [error, output] = await safe(shell.exec('df -B1 --output=source,fstype,size,used,avail,pcent,target', { timeout: 5000 })); + const [error, output] = await safe(shell.spawn('df', ['-B1', '--output=source,fstype,size,used,avail,pcent,target'], { encoding: 'utf8', timeout: 5000 })); if (error) { debug(`disks: df command failed. error: ${error}\n stdout: ${error.stdout}\n stderr: ${error.stderr}`); throw new BoxError(BoxError.FS_ERROR, `Error running df: ${error.message}`); @@ -54,7 +54,7 @@ async function disks() { async function file(filename) { assert.strictEqual(typeof filename, 'string'); - const [error, output] = await safe(shell.exec(`df -B1 --output=source,fstype,size,used,avail,pcent,target ${filename}`, { timeout: 5000 })); + const [error, output] = await safe(shell.spawn('df', ['-B1', '--output=source,fstype,size,used,avail,pcent,target', filename], { encoding: 'utf8', timeout: 5000 })); if (error) { debug(`file: df command failed. error: ${error}\n stdout: ${error.stdout}\n stderr: ${error.stderr}`); throw new BoxError(BoxError.FS_ERROR, `Error running df: ${error.message}`); diff --git a/src/docker.js b/src/docker.js index 6ca5a3746..4bfdd1a76 100644 --- a/src/docker.js +++ b/src/docker.js @@ -245,7 +245,7 @@ async function getAddressesForPort53() { const addresses = []; for (const phy of physicalDevices) { - const [error, output] = await safe(shell.exec(`ip -f inet -j addr show dev ${phy.name} scope global`, {})); + const [error, output] = await safe(shell.spawn('ip', ['-f', 'inet', '-j', 'addr', 'show', 'dev', phy.name, 'scope', 'global'], { encoding: 'utf8 '})); if (error) continue; const inet = safe.JSON.parse(output) || []; for (const r of inet) { @@ -650,7 +650,7 @@ async function update(name, memory) { // scale back db containers, if possible. this is retried because updating memory constraints can fail // with failed to write to memory.memsw.limit_in_bytes: write /sys/fs/cgroup/memory/docker/xx/memory.memsw.limit_in_bytes: device or resource busy for (let times = 0; times < 10; times++) { - const [error] = await safe(shell.exec(`docker update --memory ${memory} --memory-swap -1 ${name}`, {})); + const [error] = await safe(shell.spawn('docker', ['update', '--memory', memory, '--memory-swap', '-1', name], {})); if (!error) return; await timers.setTimeout(60 * 1000); } diff --git a/src/mailserver.js b/src/mailserver.js index 7e6596cd3..319166086 100644 --- a/src/mailserver.js +++ b/src/mailserver.js @@ -53,8 +53,8 @@ async function generateDkimKey() { const privateKeyFilePath = path.join(os.tmpdir(), `dkim-${crypto.randomBytes(4).readUInt32LE(0)}.private`); // https://www.unlocktheinbox.com/dkim-key-length-statistics/ and https://docs.aws.amazon.com/ses/latest/DeveloperGuide/send-email-authentication-dkim-easy.html for key size - await shell.exec(`openssl genrsa -out ${privateKeyFilePath} 1024`, {}); - await shell.exec(`openssl rsa -in ${privateKeyFilePath} -out ${publicKeyFilePath} -pubout -outform PEM`, {}); + await shell.spawn('openssl', ['genrsa', '-out', privateKeyFilePath, '1024'], {}); + await shell.spawn('openssl', ['rsa', '-in', privateKeyFilePath, '-out', publicKeyFilePath, '-pubout', '-outform', 'PEM'], {}); const publicKey = safe.fs.readFileSync(publicKeyFilePath, 'utf8'); if (!publicKey) throw new BoxError(BoxError.FS_ERROR, safe.error.message); diff --git a/src/mounts.js b/src/mounts.js index f1c3ca119..0aaf4e0e1 100644 --- a/src/mounts.js +++ b/src/mounts.js @@ -80,7 +80,7 @@ async function renderMountFile(mount) { let options, what, type; switch (mountType) { case 'cifs': { - const out = await shell.execArgs('systemd-escape', [ '-p', hostPath ], {}); // this ensures uniqueness of creds file + const out = await shell.spawn('systemd-escape', [ '-p', hostPath ], {}); // this ensures uniqueness of creds file const credentialsFilePath = path.join(paths.CIFS_CREDENTIALS_DIR, `${out.trim()}.cred`); if (!safe.fs.writeFileSync(credentialsFilePath, `username=${mountOptions.username}\npassword=${mountOptions.password}\n`, { mode: 0o600 })) throw new BoxError(BoxError.FS_ERROR, `Could not write credentials file: ${safe.error.message}`); @@ -139,7 +139,7 @@ async function removeMount(mount) { const keyFilePath = path.join(paths.SSHFS_KEYS_DIR, `id_rsa_${mountOptions.host}`); safe.fs.unlinkSync(keyFilePath); } else if (mountType === 'cifs') { - const out = await shell.execArgs('systemd-escape', [ '-p', hostPath ], {}); + const out = await shell.spawn('systemd-escape', [ '-p', hostPath ], {}); const credentialsFilePath = path.join(paths.CIFS_CREDENTIALS_DIR, `${out.trim()}.cred`); safe.fs.unlinkSync(credentialsFilePath); } @@ -151,7 +151,7 @@ async function getStatus(mountType, hostPath) { if (mountType === 'filesystem') return { state: 'active', message: 'Mounted' }; - const [error] = await safe(shell.execArgs('mountpoint', [ '-q', '--', hostPath ], { timeout: 5000 })); + const [error] = await safe(shell.spawn('mountpoint', [ '-q', '--', hostPath ], { timeout: 5000 })); const state = error ? 'inactive' : 'active'; if (mountType === 'mountpoint') return { state, message: state === 'active' ? 'Mounted' : 'Not mounted' }; diff --git a/src/platform.js b/src/platform.js index 85101a8e6..d6656c80a 100644 --- a/src/platform.js +++ b/src/platform.js @@ -70,7 +70,7 @@ async function pruneInfraImages() { const imageIdToPrune = tag === '' ? `${repo}@${digest}` : `${repo}:${tag}`; // untagged, use digest console.log(`pruneInfraImages: removing unused image of ${imageName}: ${imageIdToPrune}`); - const [error] = await safe(shell.execArgs('docker', [ 'rmi', imageIdToPrune ], {})); + const [error] = await safe(shell.spawn('docker', [ 'rmi', imageIdToPrune ], {})); if (error) console.log(`Error removing image ${imageIdToPrune}: ${error.mesage}`); } } @@ -79,16 +79,17 @@ async function pruneInfraImages() { async function pruneVolumes() { debug('pruneVolumes: remove all unused local volumes'); - const [error] = await safe(shell.execArgs('docker', [ 'volume', 'prune', '--all', '--force' ], {})); + const [error] = await safe(shell.spawn('docker', [ 'volume', 'prune', '--all', '--force' ], {})); if (error) console.log(`Error pruning volumes: ${error.mesage}`); } async function createDockerNetwork() { debug('createDockerNetwork: recreating docker network'); - await shell.exec('docker network rm -f cloudron', {}); + await shell.spawn('docker', ['network', 'rm', '-f', 'cloudron'], {}); // the --ipv6 option will work even in ipv6 is disabled. fd00 is IPv6 ULA - await shell.exec(`docker network create --subnet=${constants.DOCKER_IPv4_SUBNET} --ip-range=${constants.DOCKER_IPv4_RANGE} --gateway ${constants.DOCKER_IPv4_GATEWAY} --ipv6 --subnet=fd00:c107:d509::/64 cloudron`, {}); + await shell.spawn('docker', ['network', 'create', `--subnet=${constants.DOCKER_IPv4_SUBNET}`, `--ip-range=${constants.DOCKER_IPv4_RANGE}`, + `--gateway ${constants.DOCKER_IPv4_GATEWAY}`, '--ipv6', '--subnet=fd00:c107:d509::/64', 'cloudron'], {}); } async function removeAllContainers() { diff --git a/src/provision.js b/src/provision.js index 79499bc0d..d0797c624 100644 --- a/src/provision.js +++ b/src/provision.js @@ -60,7 +60,7 @@ function setProgress(task, message) { async function ensureDhparams() { if (fs.existsSync(paths.DHPARAMS_FILE)) return; debug('ensureDhparams: generating dhparams'); - const dhparams = await shell.exec('openssl dhparam -dsaparam 2048', {}); + const dhparams = await shell.spawn('openssl', ['dhparam', '-dsaparam', '2048'], { encoding: 'utf8' }); if (!safe.fs.writeFileSync(paths.DHPARAMS_FILE, dhparams)) throw new BoxError(BoxError.FS_ERROR, `Could not save dhparams.pem: ${safe.error.message}`); } diff --git a/src/reverseproxy.js b/src/reverseproxy.js index f5b94ae14..302299530 100644 --- a/src/reverseproxy.js +++ b/src/reverseproxy.js @@ -75,7 +75,7 @@ function nginxLocation(s) { async function getCertificateDates(cert) { assert.strictEqual(typeof cert, 'string'); - const [error, result] = await safe(shell.exec('openssl x509 -startdate -enddate -subject -noout', { input: cert })); + const [error, result] = await safe(shell.spawn('openssl', ['x509', '-startdate', '-enddate', '-subject', '-noout'], { encoding: 'utf8', input: cert })); if (error) return { startDate: null, endDate: null } ; // some error const lines = result.trim().split('\n'); @@ -103,7 +103,7 @@ async function isOcspEnabled(certFilePath) { // We used to check for the must-staple in the cert using openssl x509 -text -noout -in ${certFilePath} | grep -q status_request // however, we cannot set the must-staple because first request to nginx fails because of it's OCSP caching behavior - const [error, result] = await safe(shell.exec(`openssl x509 -in ${certFilePath} -noout -ocsp_uri`, {})); + const [error, result] = await safe(shell.spawn('openssl', ['x509', '-in', certFilePath, '-noout', '-ocsp_uri'], { encoding: 'utf8' })); return !error && result.length > 0; // no error and has uri } @@ -112,7 +112,7 @@ async function providerMatches(domainObject, cert) { assert.strictEqual(typeof domainObject, 'object'); assert.strictEqual(typeof cert, 'string'); - const [error, subjectAndIssuer] = await safe(shell.exec('openssl x509 -noout -subject -issuer', { input: cert })); + const [error, subjectAndIssuer] = await safe(shell.spawn('openssl', ['x509', '-noout', '-subject', '-issuer'], { encoding: 'utf8', input: cert })); if (error) return false; // something bad happenned const subject = subjectAndIssuer.match(/^subject=(.*)$/m)[1]; @@ -153,20 +153,20 @@ async function validateCertificate(subdomain, domain, certificate) { // -checkhost checks for SAN or CN exclusively. SAN takes precedence and if present, ignores the CN. const fqdn = dns.fqdn(subdomain, domain); - const [checkHostError, checkHostOutput] = await safe(shell.exec(`openssl x509 -noout -checkhost ${fqdn}`, { input: cert })); + const [checkHostError, checkHostOutput] = await safe(shell.spawn('openssl', ['x509', '-noout', '-checkhost', fqdn], { encoding: 'utf8', input: cert })); if (checkHostError) throw new BoxError(BoxError.BAD_FIELD, 'Could not validate certificate'); if (checkHostOutput.indexOf('does match certificate') === -1) throw new BoxError(BoxError.BAD_FIELD, `Certificate is not valid for this domain. Expecting ${fqdn}`); // check if public key in the cert and private key matches. pkey below works for RSA and ECDSA keys - const [pubKeyError1, pubKeyFromCert] = await safe(shell.exec('openssl x509 -noout -pubkey', { input: cert })); + const [pubKeyError1, pubKeyFromCert] = await safe(shell.spawn('openssl', ['x509', '-noout', '-pubkey'], { encoding: 'utf8', input: cert })); if (pubKeyError1) throw new BoxError(BoxError.BAD_FIELD, 'Could not get public key from cert'); - const [pubKeyError2, pubKeyFromKey] = await safe(shell.exec('openssl pkey -pubout', { input: key })); + const [pubKeyError2, pubKeyFromKey] = await safe(shell.spawn('openssl', ['pkey', '-pubout'], { encoding: 'utf8', input: key })); if (pubKeyError2) throw new BoxError(BoxError.BAD_FIELD, 'Could not get public key from private key'); if (pubKeyFromCert !== pubKeyFromKey) throw new BoxError(BoxError.BAD_FIELD, 'Public key does not match the certificate.'); // check expiration - const [error] = await safe(shell.exec('openssl x509 -checkend 0', { input: cert })); + const [error] = await safe(shell.spawn('openssl', ['x509', '-checkend', '0'], { input: cert })); if (error) throw new BoxError(BoxError.BAD_FIELD, 'Certificate has expired'); return null; @@ -205,8 +205,7 @@ async function generateFallbackCertificate(domain) { const configFile = path.join(os.tmpdir(), 'openssl-' + crypto.randomBytes(4).readUInt32LE(0) + '.conf'); safe.fs.writeFileSync(configFile, opensslConfWithSan, 'utf8'); // the days field is chosen to be less than 825 days per apple requirement (https://support.apple.com/en-us/HT210176) - const certCommand = `openssl req -x509 -newkey rsa:2048 -keyout ${keyFilePath} -out ${certFilePath} -days 800 -subj /CN=*.${cn} -extensions SAN -config ${configFile} -nodes`; - await shell.exec(certCommand, {}); + await shell.spawn('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-keyout', keyFilePath, '-out', certFilePath, '-days', '800', '-subj', `/CN=*.${cn}`, '-extensions', 'SAN', '-config', configFile, '-nodes'], { encoding: 'utf8 '}); safe.fs.unlinkSync(configFile); const cert = safe.fs.readFileSync(certFilePath, 'utf8'); @@ -740,7 +739,7 @@ async function writeDefaultConfig(options) { const cn = 'cloudron-' + (new Date()).toISOString(); // randomize date a bit to keep firefox happy // the days field is chosen to be less than 825 days per apple requirement (https://support.apple.com/en-us/HT210176) - await shell.exec(`openssl req -x509 -newkey rsa:2048 -keyout ${keyFilePath} -out ${certFilePath} -days 800 -subj /CN=${cn} -nodes`, {}); + await shell.spawn('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-keyout', keyFilePath, '-out', certFilePath, '-days', '800', '-subj', `/CN=${cn}`, '-nodes'], { encoding: 'utf8' }); } const data = { diff --git a/src/services.js b/src/services.js index 9dd8c8572..3a26df043 100644 --- a/src/services.js +++ b/src/services.js @@ -283,7 +283,7 @@ function requiresUpgrade(existingObjOrImageName, currentImageName) { async function hasAVX() { // mongodb 5 and above requires AVX - const [error] = await safe(shell.exec('grep -q avx /proc/cpuinfo', {})); + const [error] = await safe(shell.spawn('grep', ['-q', 'avx', '/proc/cpuinfo'], {})); return !error; } @@ -523,7 +523,7 @@ async function rebuildService(id, auditSource) { await mailServer.start({ version: 'none' }); break; case 'redis': { - await safe(shell.exec(`docker rm -f redis-${instance}`, {})); // ignore error + await safe(shell.spawn('docker', ['rm', '-f', `redis-${instance}`], {})); // ignore error const app = await apps.get(instance); if (app) await setupRedis(app, app.manifest.addons.redis); // starts the container break; @@ -752,7 +752,7 @@ async function exportDatabase(addon) { safe.fs.writeFileSync(path.join(paths.ADDON_CONFIG_DIR, `exported-${addon}`), '', 'utf8'); if (safe.error) throw BoxError(BoxError.FS_ERROR, 'Error writing export checkpoint file'); // note: after this point, we are restart safe. it's ok if the box code crashes at this point - await shell.exec(`docker rm -f ${addon}`, {}); // what if db writes something when quitting ... + await shell.spawn('docker', ['rm', '-f', addon], {}); // what if db writes something when quitting ... await shell.promises.sudo([ RMADDONDIR_CMD, addon ], {}); // ready to start afresh } @@ -1977,7 +1977,7 @@ async function restartDocker() { } async function statusUnbound() { - const [error] = await safe(shell.exec('systemctl is-active unbound', {})); + const [error] = await safe(shell.spawn('systemctl', ['is-active', 'unbound'], {})); if (error) return { status: exports.SERVICE_STATUS_STOPPED }; const [digError, digResult] = await safe(dig.resolve('ipv4.api.cloudron.io', 'A', { timeout: 10000 })); @@ -1993,7 +1993,7 @@ async function restartUnbound() { } async function statusNginx() { - const [error] = await safe(shell.exec('systemctl is-active nginx', {})); + const [error] = await safe(shell.spawn('systemctl', ['is-active', 'nginx'], {})); return { status: error ? exports.SERVICE_STATUS_STOPPED : exports.SERVICE_STATUS_ACTIVE }; } diff --git a/src/shell.js b/src/shell.js index 7f1710fdb..b8ed3743e 100644 --- a/src/shell.js +++ b/src/shell.js @@ -7,32 +7,62 @@ const assert = require('assert'), once = require('./once.js'), util = require('util'); +exports = module.exports = shell; + +function shell(tag) { + assert.strictEqual(typeof tag, 'string'); + + return { + exec: exec.bind(null, tag), + spawn: spawn.bind(null, tag), + sudo: sudo.bind(null, tag), + promises: { sudo: util.promisify(sudo.bind(null, tag)) } + }; +} + const SUDO = '/usr/bin/sudo'; -// default encoding utf8, no shell, handles input, separate args, wait for process to finish -async function execArgs(tag, file, args, options) { +// default no shell, handles input, separate args, wait for process to finish +function spawn(tag, file, args, options) { assert.strictEqual(typeof tag, 'string'); assert.strictEqual(typeof file, 'string'); assert(Array.isArray(args)); assert.strictEqual(typeof options, 'object'); - debug(`${tag} execArgs: ${file} ${JSON.stringify(args)}`); + debug(`${tag}: ${file} ${JSON.stringify(args)}`); - const execOptions = Object.assign({ encoding: 'utf8', shell: false }, options); + const spawnOptions = Object.assign({ shell: false }, options); // note: no encoding! return new Promise((resolve, reject) => { - const cp = child_process.execFile(file, args, execOptions, function (error, stdout, stderr) { - if (!error) return resolve(stdout); + const cp = child_process.spawn(file, args, spawnOptions); + const stdoutBuffers = [], stderrBuffers = []; - const e = new BoxError(BoxError.SHELL_ERROR, `${tag} errored with code ${error.code} message ${error.message}`); + cp.stdout.on('data', (data) => stdoutBuffers.push(data)); + cp.stderr.on('data', (data) => stderrBuffers.push(data)); + + cp.on('close', function (code, signal) { // always called. after 'exit' or 'error' + const stdoutBuffer = Buffer.concat(stdoutBuffers); + const stdout = options.encoding ? stdoutBuffer.toString(options.encoding) : stdoutBuffer; + if (code === 0) return resolve(stdout); + + const stderrBuffer = Buffer.concat(stderrBuffers); + const stderr = options.encoding ? stderrBuffer.toString(options.encoding) : stderrBuffer; + + const e = new BoxError(BoxError.SHELL_ERROR, `${file} exited with code ${code} signal ${signal}`); e.stdout = stdout; // when promisified, this is the way to get stdout e.stderr = stderr; // when promisified, this is the way to get stderr - e.code = error.code; - e.signal = error.signal; - debug(`${tag}: ${file} with args ${args.join(' ')} errored`, error); + e.code = code; + e.signal = signal; + + debug(`${tag}: ${file} ${args.join(' ').replace(/\n/g, '\\n')} errored`, e); + reject(e); }); + cp.on('error', function (error) { // when the command itself could not be started + debug(`${tag}: ${file} ${args.join(' ').replace(/\n/g, '\\n')} errored`, error); + }); + // https://github.com/nodejs/node/issues/25231 if (options.input) { cp.stdin.write(options.input); @@ -50,7 +80,7 @@ async function exec(tag, cmd, options) { if (!options.shell) { cmd = cmd.replace(/\s+/g, ' '); // collapse spaces when not using shell. note: no more complexity like parsing quotes here! const [file, ...args] = cmd.split(' '); - return await execArgs(tag, file, args, options); + return await spawn(tag, file, args, options); } debug(`${tag} exec: ${cmd}`); @@ -145,16 +175,3 @@ function sudo(tag, args, options, callback) { return cp; } - -function shell(tag) { - assert.strictEqual(typeof tag, 'string'); - - return { - exec: exec.bind(null, tag), - execArgs: execArgs.bind(null, tag), - sudo: sudo.bind(null, tag), - promises: { sudo: util.promisify(sudo.bind(null, tag)) } - }; -} - -exports = module.exports = shell; diff --git a/src/storage/filesystem.js b/src/storage/filesystem.js index 798d6c4e3..76ecbfcfd 100644 --- a/src/storage/filesystem.js +++ b/src/storage/filesystem.js @@ -163,13 +163,13 @@ async function copy(apiConfig, oldFilePath, newFilePath, progressCallback) { const sshOptions = [ '-o', '"StrictHostKeyChecking no"', '-i', identityFilePath, '-p', apiConfig.mountOptions.port, `${apiConfig.mountOptions.user}@${apiConfig.mountOptions.host}` ]; const sshArgs = sshOptions.concat([ 'cp', cpOptions, oldFilePath.replace('/mnt/cloudronbackup/', ''), newFilePath.replace('/mnt/cloudronbackup/', '') ]); - const [remoteCopyError] = await safe(shell.execArgs('ssh', sshArgs, { shell: true })); + const [remoteCopyError] = await safe(shell.spawn('ssh', sshArgs, { shell: true })); if (!remoteCopyError) return; if (remoteCopyError.code === 255) throw new BoxError(BoxError.EXTERNAL_ERROR, `SSH connection error: ${remoteCopyError.message}`); // do not attempt fallback copy for ssh errors debug('SSH remote copy failed, trying ssfs copy'); // this can happen for sshfs mounted windows server } - const [copyError] = await safe(shell.execArgs('cp', [ cpOptions, oldFilePath, newFilePath ], {})); + const [copyError] = await safe(shell.spawn('cp', [ cpOptions, oldFilePath, newFilePath ], {})); if (copyError) throw new BoxError(BoxError.EXTERNAL_ERROR, copyError.message); } @@ -194,7 +194,7 @@ async function removeDir(apiConfig, pathPrefix, progressCallback) { progressCallback({ message: `Removing directory ${pathPrefix}` }); - const [error] = await safe(shell.execArgs('rm', [ '-rf', pathPrefix ], {})); + const [error] = await safe(shell.spawn('rm', [ '-rf', pathPrefix ], {})); if (error) throw new BoxError(BoxError.EXTERNAL_ERROR, error.message); } @@ -232,7 +232,7 @@ async function testConfig(apiConfig) { const error = validateBackupTarget(apiConfig.mountPoint); if (error) throw error; - const [mountError] = await safe(shell.exec(`mountpoint -q -- ${apiConfig.mountPoint}`, { timeout: 5000 })); + const [mountError] = await safe(shell.spawn('mountpoint', ['-q', '--', apiConfig.mountPoint], { timeout: 5000 })); if (mountError) throw new BoxError(BoxError.BAD_FIELD, `${apiConfig.mountPoint} is not mounted: ${mountError.message}`); } diff --git a/src/system.js b/src/system.js index 98c698fa6..6a9daf115 100644 --- a/src/system.js +++ b/src/system.js @@ -67,7 +67,7 @@ async function hdparm(file) { } async function getSwaps() { - const [error, stdout] = await safe(shell.exec('swapon --noheadings --raw --bytes --show=type,size,used,name', {})); + const [error, stdout] = await safe(shell.spawn('swapon', ['--noheadings', '--raw', '--bytes', '--show=type,size,used,name'], { encoding: 'utf8' })); if (error) return {}; const output = stdout.trim(); if (!output) return {}; // no swaps @@ -314,7 +314,7 @@ async function getLogs(unit, options) { } async function getBlockDevices() { - const result = await shell.exec('lsblk --paths --json --list --fs --output +rota', {}); + const result = await shell.spawn('lsblk', ['--paths', '--json', '--list', '--fs', '--output', '+rota'], { encoding: 'utf8' }); const info = safe.JSON.parse(result); if (!info) throw new BoxError(BoxError.INTERNAL_ERROR, `failed to parse lsblk: ${safe.error.message}`); diff --git a/src/test/shell-test.js b/src/test/shell-test.js index fa7fa8138..de69563f7 100644 --- a/src/test/shell-test.js +++ b/src/test/shell-test.js @@ -11,18 +11,18 @@ const BoxError = require('../boxerror.js'), shell = require('../shell.js')('test'); describe('shell', function () { - describe('execArgs', function () { + describe('spawn', function () { it('can run valid program', async function () { - await shell.execArgs('ls', [ '-l' ], {}); + await shell.spawn('ls', [ '-l' ], {}); }); it('fails on invalid program', async function () { - const [error] = await safe(shell.execArgs('randomprogram', [ ], {})); + const [error] = await safe(shell.spawn('randomprogram', [], {})); expect(error.reason).to.be(BoxError.SHELL_ERROR); }); it('fails on failing program', async function () { - const [error] = await safe(shell.execArgs('/usr/bin/false', [ ], {})); + const [error] = await safe(shell.spawn('/usr/bin/false', [], {})); expect(error.reason).to.be(BoxError.SHELL_ERROR); }); }); @@ -49,19 +49,19 @@ describe('shell', function () { }); }); - describe('exec', function () { - it('exec throws for invalid program', async function () { - const [error] = await safe(shell.exec('cannotexist', {})); + describe('spawn', function () { + it('spawn throws for invalid program', async function () { + const [error] = await safe(shell.spawn('cannotexist', [], {})); expect(error.reason).to.be(BoxError.SHELL_ERROR); }); - it('exec throws for failed program', async function () { - const [error] = await safe(shell.exec('false', {})); + it('spawn throws for failed program', async function () { + const [error] = await safe(shell.spawn('false', [], {})); expect(error.reason).to.be(BoxError.SHELL_ERROR); }); - it('exec times out properly', async function () { - const [error] = await safe(shell.exec('sleep 20', { timeout: 1000 })); + it('spawn times out properly', async function () { + const [error] = await safe(shell.spawn('sleep', ['20'], { timeout: 1000 })); expect(error.reason).to.be(BoxError.SHELL_ERROR); }); diff --git a/src/updater.js b/src/updater.js index 81529e43e..ffa0f62c5 100644 --- a/src/updater.js +++ b/src/updater.js @@ -66,7 +66,7 @@ async function downloadUrl(url, file) { await promiseRetry({ times: 10, interval: 5000, debug }, async function () { debug(`downloadUrl: downloading ${url} to ${file}`); - const [error] = await safe(shell.exec(`curl -s --fail ${url} -o ${file}`, {})); + const [error] = await safe(shell.spawn('curl', ['-s', '--fail', url, '-o', file], {})); if (error) throw new BoxError(BoxError.NETWORK_ERROR, `Failed to download ${url}: ${error.message}`); debug('downloadUrl: done'); }); @@ -76,11 +76,7 @@ async function gpgVerify(file, sig) { assert.strictEqual(typeof file, 'string'); assert.strictEqual(typeof sig, 'string'); - const cmd = `/usr/bin/gpg --status-fd 1 --no-default-keyring --keyring ${RELEASES_PUBLIC_KEY} --verify ${sig} ${file}`; - - debug(`gpgVerify: ${cmd}`); - - const [error, stdout] = await safe(shell.exec(cmd, {})); + const [error, stdout] = await safe(shell.spawn('/usr/bin/gpg', ['--status-fd', '1', '--no-default-keyring', '--keyring', RELEASES_PUBLIC_KEY, '--verify', sig, file], { encoding: 'utf8' })); if (error) { debug(`gpgVerify: command failed. error: ${error}\n stdout: ${error.stdout}\n stderr: ${error.stderr}`); throw new BoxError(BoxError.NOT_SIGNED, `The signature in ${path.basename(sig)} could not be verified (command failed)`); @@ -99,7 +95,7 @@ async function extractTarball(tarball, dir) { debug(`extractTarball: extracting ${tarball} to ${dir}`); - const [error] = await safe(shell.exec(`tar -zxf ${tarball} -C ${dir}`, {})); + const [error] = await safe(shell.spawn('tar', ['-zxf', tarball, '-C', dir], {})); if (error) throw new BoxError(BoxError.FS_ERROR, `Failed to extract release package: ${error.message}`); safe.fs.unlinkSync(tarball);