// Modules to control application life and create native browser window const {app, ipcMain, dialog, screen, BrowserWindow, Menu, MenuItem } = require('electron'); const path = require('path'); const https = require('https'); const fs = require('fs'); const zlib = require('zlib'); const crypto = require('crypto'); const child_process = require('child_process'); const os = require('os'); const util = require('util'); const checkDiskSpace = require('check-disk-space'); const parse5 = require('parse5'); const etcherSDK = require('etcher-sdk'); const WebTorrent = require('webtorrent'); const sha512crypt = require("sha512crypt-node"); const sshpk = require('sshpk'); const open = require('open'); const { translations } = require('./strings.js'); const utils = require('./utils.js'); const myLog = utils.myLog; const settings = require('./settings.js'); const encryption = require('./encryption.js'); const objectStorageFactory = require('./objectStorage.js'); const imageOverlayFiles = require('./imageOverlayFiles.js') const runNodeWithElevatedPermissions = require('./sudo-exec/runNodeWithElevatedPermissions.js') // process.on('unhandledRejection', (reason, p) => { // p.catch(() => { myLog( new Error("asd").stack ); }); // }); process.on('uncaughtException', error => { // prevent socket errors from displaying popups dialogs if(error && (error.code == "ECONNREFUSED" || error.code == "ECONNRESET") ) { myLog(error); } else { throw error; } }); const gandiAPIUrl = 'https://api.gandi.net/v5'; const digitalOceanAPIUrl = 'https://api.digitalocean.com/v2' const locale = app.getLocale(); const simpleLocale = locale.split('-')[0]; if(translations[locale]) { strings = translations[locale]; } else if(translations[simpleLocale]) { strings = translations[simpleLocale]; } else { strings = translations['en'] } let logStream = null; let appState = { previouslyOpenedFiles: [] }; let currentlyOpenSeedFile = null; let objectStorage = null; let blockDeviceScanner = null; let downloadStatus = { file: "", inProgress: false, method: null, progress: 0, phase: "", peers: 0, bytesPerSecond: 0, error: null, allDownloadsDone: false }; let writeImageStatus = { progress: 0, bytesPerSecond: 0, phase: "", error: null, done: false, cancelled: false, }; const encryptionCache = {}; const getEncryptionInstanceFromPassphrase = (passphrase) => { if(encryptionCache[passphrase]) { return encryptionCache[passphrase]; } encryptionCache[passphrase] = encryption.deriveFrom(passphrase); return encryptionCache[passphrase]; } ipcMain.handle('request-filepath', (event, mode) => wrapError(() => requestFilepath(mode))); ipcMain.handle('read-file', (event, filepath, passphrase) => { return wrapError(() => readFile(filepath, getEncryptionInstanceFromPassphrase(passphrase))); }); ipcMain.handle('write-file', (event, filepath, passphrase, object) => { return wrapError(() => writeFile(filepath, getEncryptionInstanceFromPassphrase(passphrase), object)); }); ipcMain.handle('get-download-folder', (event) => wrapError(() => path.join(utils.getAppSupportFolder(), "os-images"))); ipcMain.handle('get-recently-opened-files', (event) => wrapError(() => getRecentlyOpenedFiles())); ipcMain.handle('check-backblaze', (event, settings) => wrapError(() => checkBackblaze(settings))); ipcMain.handle('create-backblaze', (event, settings) => wrapError(() => createBackblaze(settings))); ipcMain.handle('check-gandi', (event, settings) => wrapError(() => checkGandi(settings))); ipcMain.handle('digitalocean-get-regions', (event, settings) => wrapError(() => getDigitalOceanRegions(settings))); ipcMain.handle('ensure-disk-space', (event, images, phase) => wrapError(() => ensureDiskSpace(images, phase) )); ipcMain.handle('start-downloading-images', (event) => wrapError(() => { startDownloadingImages(); return true; } )); ipcMain.handle('get-pre-existing-ssh-keys', (event) => wrapError(() => getPreExistingSSHKeys() )); ipcMain.handle('generate-ssh-key', (event, saveToSSHDirectory, encryptWithPassphrase, comment) => wrapError( () => generateSSHKey(saveToSSHDirectory, encryptWithPassphrase, comment) ) ); ipcMain.handle('object-storage-get', (event, key, skipDecrypt) => wrapError(() => objectStorageGet(key, skipDecrypt))); ipcMain.handle('object-storage-list', (event, key) => wrapError(() => objectStorage.list(key))); ipcMain.handle('object-storage-put', (event, key, object) => wrapError(() => objectStorage.put(key, object))); ipcMain.handle('get-download-status', (event) => wrapError(() => downloadStatus)); ipcMain.handle('write-image-to-block-device', (event, filename, blockDevice, serverName, serialNumber) => wrapError(() => { writeImageToBlockDevice(filename, blockDevice, serverName, serialNumber); return true; } )); ipcMain.handle('get-write-image-status', (event) => wrapError(() => writeImageStatus)); // This is needed to avoid Electron logging a message every time one of these promises is rejected :( function wrapError(doTheThing) { // electron IPC cant pass null because it serializes everything as JSON and the serializer strips null :( const errorToErrorResult = (error) => { let stack = error != null ? error.stack : null; let errorString = stack ? stack : (typeof error.toString == 'function' ? error.toString() : String(error)); return { error: error == null ? '__electron-ipc-wrapped-null__' : error, errorString: errorString } } try { result = doTheThing(); if(result && result.then && typeof result.then == "function") { return result.then( result => { return {result: result == null ? '__electron-ipc-wrapped-null__' : result} }, errorToErrorResult ); } else { return {result: result == null ? '__electron-ipc-wrapped-null__' : result}; } } catch (error) { return Promise.resolve(errorToErrorResult(error)) } } function initializeApplication(webContents) { fs.mkdir(utils.getAppSupportFolder(), {recursive: true, mode: 0744}, err => { if(!err || err.code == 'EEXIST') { utils.startLoggingToFile("seedpacket.log"); myLog("log file opened"); fs.readFile(`${utils.getAppSupportFolder()}/appState.json`, "utf8", (err, jsonString) => { if(!err) { try { appState = JSON.parse(jsonString) } catch (err) { myLog(`cant parse the contents of ${utils.getAppSupportFolder()}/appState.json: ${err}`); } } else { const filepath = `${utils.getAppSupportFolder()}/appState.json`; let errorMessage = `cant read ${utils.getAppSupportFolder()}/appState.json: ${err}`; if(String(err).indexOf(filepath) != -1) { errorMessage = `cant read appState file: ${err}` } myLog(errorMessage); } webContents.send("initialize"); }); } }); } function objectStorageGet(key, skipDecrypt) { return objectStorage.get(key, skipDecrypt).then(result => { if(result.file && result.file.content ) { result.file.content = result.file.content.toString('utf-8') } return result; }); } // https://github.com/balena-io-modules/etcher-sdk/blob/f7d318fa67a10601a703b6b9758118825de9dc7a/examples/scanner.ts async function startBlockDeviceScanner(webContents) { //myLog("webContents", webContents) const adapters = [ new etcherSDK.scanner.adapters.BlockDeviceAdapter({ includeSystemDrives: () => false, }), new etcherSDK.scanner.adapters.UsbbootDeviceAdapter(), ]; if (process.platform === 'win32') { if (etcherSDK.scanner.adapters.DriverlessDeviceAdapter !== undefined) { adapters.push(new etcherSDK.scanner.adapters.DriverlessDeviceAdapter()); } } blockDeviceScanner = new etcherSDK.scanner.Scanner(adapters); blockDeviceScanner.on( 'attach', async (drive) => { //myLog("webContents2", webContents) webContents.send("update-block-devices", {attach: drive}); // if (drive.emitsProgress) { // drive.on('progress', (progress) => { // myLog(drive, progress, '%'); // }); // } }, ); blockDeviceScanner.on( 'detach', (drive) => { webContents.send("update-block-devices", {detach: drive}); }, ); blockDeviceScanner.on('error', (error) => { webContents.send("update-block-devices", {error: error}); }); await blockDeviceScanner.start(); blockDeviceScanner.getBy( 'devicePath', 'pci-0000:00:14.0-usb-0:2:1.0-scsi-0:0:0:0', ); } function getRootsystemConfigJSON(serverName, serialNumber) { const file = currentlyOpenSeedFile; const cloudType = file['cloud-service-type']; const providers = file['choose-providers']; const variables = {}; // TODO handle this properly when there's no passphrase const credentials = [{ "Type": "ObjectStoragePassphrase", "Password": file.passphrase || crypto.randomBytes(32).toString("hex") }]; const globalModules = []; const localModules = []; const applicationModules = ['hello-world']; const objectStorageConfig = { "Encryption": "ObjectStoragePassphrase", "Backends": [] }; file.locations.filter(x => !x.ethernet).forEach(x => { credentials.push({ "Type": "Wifi", "SSID": x.ssid, "Password": x.password }); }); if(file.ingress == 'cloud') { globalModules.push("ansible-threshold-server"); localModules.push("ansible-threshold-client"); applicationModules.push('servergarden-ingress'); } if(cloudType == 'diy') { if(providers.objectStorage == "backblaze") { objectStorageConfig.Backends.push({ "Provider": "BackblazeB2", "Name": file['backblaze-account'].bucketName }); credentials.push({ "Type": "BackblazeB2", "Username": file['backblaze-account'].keyId, "Password": file['backblaze-account'].secretKey }); } else { throw new Error(`unsupported providers.objectStorage ${providers.objectStorage}`); } if(providers.domainName == "gandi") { variables.domain_name = file['gandi-account'].domainName; globalModules.push("dns-gandi"); credentials.push({ "Type": "Gandi", "Password": file['gandi-account'].apiKey }); } else { throw new Error(`unsupported providers.domainName ${providers.domainName}`); } if(file.ingress == 'cloud') { if(providers.cloudCompute == 'digitalocean') { variables.digitalocean_region = file['digitalocean-account'].region; globalModules.push("gateway-instance-digitalocean"); credentials.push({ "Type": "DigitalOcean", "Password": file['digitalocean-account'].apiKey }); } else { throw new Error(`unsupported providers.cloudCompute ${providers.cloudCompute}`); } } else { throw new Error(`unsupported ingress type ${file.ingress}`); } } else { throw new Error(`unsupported cloudType ${cloudType}`); } return JSON.stringify( { "SerialNumber": serialNumber, "Host": { "Name": serverName }, "ApplicationModules": applicationModules, "Terraform": { "GlobalModules": globalModules, "LocalModules": localModules, "Variables": variables }, "ObjectStorage": objectStorageConfig, "Credentials": credentials }, null, " " ); } function getOverlayTemplateVariables(seedfileObject, serverName, serialNumber) { const backblaze = seedfileObject['backblaze-account'] || {}; const serverObject = seedfileObject.servers.filter(x => x.id == serverName)[0] || {}; const isRaspberryPi = serverObject.image.startsWith('debian_raspi'); const isRaspberryPi4 = serverObject.image == "debian_raspi_4"; //const isArmbian = serverObject.image.startsWith('armbian'); return { SERIAL_NUMBER: serialNumber, BACKBLAZE_BUCKET_NAME: backblaze.bucketName || "", BACKBLAZE_KEY_ID: backblaze.keyId || "", BACKBLAZE_SECRET_KEY: backblaze.secretKey || "", WIFI_CREDS: (seedfileObject.locations || []).filter(x => !x.ethernet && x.ssid) .map(x => `${x.ssid}@%%${x.password}`).join('\\n'), EXTERNAL_DISK_EXPECTED: serverObject.storage == "external-ssd-or-hdd" ? "yes" : "no", SERVER_NAME: serverName, APT_CPU_ARCH: isRaspberryPi4 ? 'arm64' : 'armhf', DOCKER_LINUX_DIST: isRaspberryPi ? 'debian' : 'ubuntu', DOCKER_LINUX_VERSION_NAME: isRaspberryPi ? 'buster' : 'bionic', }; } async function writeImageToBlockDevice(image, blockDevicePath, serverName, serialNumber) { writeImageStatus.done = false; writeImageStatus.cancelled = false; writeImageStatus.phase = 'description_Configuring'; writeImageStatus.progress = 0; writeImageStatus.bytesPerSecond = 0; const imageMeta = await getImageMetadata(image); const imagePath = path.join( utils.getAppSupportFolder(), "os-images", imageMeta.filename); const passwordSalt = (await util.promisify(crypto.randomBytes)(12)).toString('base64'); const daysSinceUnixEpoch = Math.floor(new Date().getTime()/(1000*60*60*24)); const passwordHash = sha512crypt.b64_sha512crypt(currentlyOpenSeedFile['root-password'], passwordSalt); const rootUserShadowLine = `root:${passwordHash}:${daysSinceUnixEpoch}:0:99999:7:::`; const overlay = { "/etc/shadow": { replaceLine: { match: '^root:', replace: rootUserShadowLine }, mode: 0o640 }, "/etc/shadow-": { replaceLine: { match: '^root:', replace: rootUserShadowLine }, mode: 0o640 }, // make sure logging in with password (not ssh key) is disabled "/etc/ssh/sshd_config": { content: imageOverlayFiles['sshd_config'], mode: 0o644, }, "/root/.ssh/authorized_keys": { "/root/.ssh": 0o700, content: currentlyOpenSeedFile['ssh-keys'].keys.map(x => x.publicKey).join("\n")+"\n", mode: 0o640, } }; if(image.startsWith('armbian')) { // disable the manual setup prompt that happens when root logs in for the first time. overlay["/etc/profile.d/armbian-check-first-login.sh"] = { content: imageOverlayFiles["armbian-check-first-login.sh"], mode: 0o644, }; // hijack armbians optional user configuration service for ourselves. // why not create our own service? Because node-ext2fs doesn't support creating symlinks yet // so we can't create a systemd service that is enabled (symlinked) // this armbian service has roughly the same purpose anyways so I don't feel bad about hijacking it. overlay["/usr/lib/systemd/system/armbian-firstrun-config.service"] = { content: imageOverlayFiles['armbian-firstrun-config.service'], mode: 0o644, }; } if(image.startsWith('debian_raspi')) { // hijack the first run config service that is built into the debian raspberry pi images just like we did for armbian. overlay["/etc/systemd/system/rpi-set-sysconf.service"] = { content: imageOverlayFiles['rpi-set-sysconf.service'], mode: 0o644, }; } const seedpacketPath = "/usr/lib/servergarden-seedpacket"; overlay[`${seedpacketPath}/rootsystem-config.json`] = { [seedpacketPath]: 0o700, content: getRootsystemConfigJSON(serverName, serialNumber), mode: 0o600 }; overlay[`${seedpacketPath}/ssh/servergarden_builtin_ed22519`] = { [seedpacketPath]: 0o700, [`${seedpacketPath}/ssh`]: 0o700, content: currentlyOpenSeedFile['ssh-keys'].builtin.privateKey, mode: 0o600 }; overlay[`${seedpacketPath}/ssh/servergarden_builtin_ed22519.pub`] = { [seedpacketPath]: 0o700, [`${seedpacketPath}/ssh`]: 0o700, content: currentlyOpenSeedFile['ssh-keys'].builtin.publicKey, mode: 0o600 }; currentlyOpenSeedFile['ssh-keys'].keys.forEach(key => { const filePathSplit = new RegExp("[\\\\/]")[Symbol.split](key.publicKeyPath); const filename = filePathSplit[filePathSplit.length-1]; overlay[`${seedpacketPath}/ssh/${filename}`] = { [seedpacketPath]: 0o700, [`${seedpacketPath}/ssh`]: 0o700, content: key.publicKey, mode: 0o600 }; }); overlay[`${seedpacketPath}/servergarden-rootsystem.service`] = { content: imageOverlayFiles['servergarden-rootsystem.service'], mode: 0o644, }; const overlayTemplateVariables = getOverlayTemplateVariables(currentlyOpenSeedFile, serverName, serialNumber); const renderOverlayTemplate = (filename) => { let file = imageOverlayFiles[filename]; Object.entries(overlayTemplateVariables).forEach(kv => { // search and replace file = file.split(`__${kv[0]}__`).join(kv[1]); }); return file; }; [ "init.sh", "network-manager-wifi-setup.sh", "wpa-supplicant-wifi-setup.sh", "rootsystem-install.sh", "backblaze-put.sh", "migrate-os-to-external-disk.sh", "sync-log-to-object-storage.sh", ].forEach(filename => { overlay[`${seedpacketPath}/${filename}`] = { [seedpacketPath]: 0o700, content: renderOverlayTemplate(filename), mode: 0o700 } }); // its ok to log the file names on the overlay but probably not the overlay itself because it contains credentials // myLog(`overlay: ${JSON.stringify(overlay, null, " ")}`); myLog(`image overlay: ${JSON.stringify(Object.keys(overlay), null, " ")}`); return new Promise((resolve, reject) => { myLog(`runNodeWithElevatedPermissions( 'writeImageToBlockDevice.js', imagePath='${imagePath}', overlay={...}, blockDevicePath='${blockDevicePath}' )`) const child = runNodeWithElevatedPermissions( 'writeImageToBlockDevice.js', imagePath, overlay, blockDevicePath ); child.on('status', (status) => { writeImageStatus.progress = status.progress; writeImageStatus.phase = status.phase; writeImageStatus.bytesPerSecond = status.bytesPerSecond; }); child.on('resolve', resolve); child.on('reject', reject); }).then( (result) => { myLog(`runNodeWithElevatedPermissions resolved with ${JSON.stringify(result, null, " ")}`); // TODO check result.cancelled writeImageStatus.done = true; writeImageStatus.bytesPerSecond = 0; writeImageStatus.progress = 1; writeImageStatus.cancelled = result.cancelled; }, err => { myLog(`runNodeWithElevatedPermissions rejected: ${err}`); // TODO handle errors writeImageStatus.done = true; writeImageStatus.bytesPerSecond = 0; writeImageStatus.error = err; writeImageStatus.cancelled = result.cancelled; } ); } async function myHTTPSWithResponseBodyString(method, url, headers, body, statusCodeCondition) { const response = await myHTTPS(method, url, headers, body); response.responseBodyString = await streamToString(response); if(statusCodeCondition) { if(!statusCodeCondition(response.statusCode)) { throw new Error(`got unexpected HTTP ${response.statusCode} (${response.statusMessage}) from ${method} ${url}: ${response.responseBodyString}`); } } return response; } async function myHTTPSWithResponseBodyBuffer(method, url, headers, body, statusCodeCondition) { const response = await myHTTPS(method, url, headers, body); if(statusCodeCondition) { if(!statusCodeCondition(response.statusCode)) { throw new Error(`got unexpected HTTP ${response.statusCode} (${response.statusMessage}) from ${method} ${url}`); } } response.responseBodyBuffer = await streamToBuffer(response); return response; } function myHTTPS(method, url, headers, body) { return new Promise((resolve, reject) => { followRedirects(method, url, headers).then( actualURL => { let urlForErrorMessage = actualURL; if(url != actualURL) { urlForErrorMessage = `${url} --> ${actualURL}`; } headers = headers || {}; if(typeof body == 'object') { body = JSON.stringify(body); headers["Content-Type"] = 'application/json'; } const req = https.request(actualURL, { method: method, headers: headers, timeout: 5000 }, (response) => { if(response.statusCode == 200) { resolve(response); } else { reject(new Error(`HTTP ${response.statusCode} (${response.statusMessage}) from ${method} ${urlForErrorMessage}`)) } }); req.on('abort', () => reject(new Error(`${method} ${urlForErrorMessage}: aborted`))); req.on('error', (err) => reject(new Error(`${method} ${urlForErrorMessage}: ${err}`))); //req.on('response', ); if(body) { req.write(body); } req.end(); }, reject ); }); } function followRedirects(method, url, headers, redirectCount) { if(!redirectCount) { redirectCount = 0; } return new Promise((resolve, reject) => { // Follow redirects headers = headers || {}; const req = https.request(url, { method: method == "GET" ? method : "HEAD", headers: headers, timeout: 5000 }, (response) => { if(response.statusCode > 299 && response.statusCode < 400 && response.headers.location) { const redirectUrl = new URL(response.headers.location) // Don't try to follow a redirect if it does not look like a valid URL if (redirectUrl.protocol == "http:" || redirectUrl.protocol == "https:") { if(redirectCount < 10) { followRedirects(method, response.headers.location, headers, redirectCount+1).then(resolve, reject); } else { reject(new Error(`HEAD ${url}: Too many HTTP redirects!`)); } } else { myLog(`HEAD ${url}: invalid redirect url ${response.headers.location}. retrying...`); followRedirects(method, url, headers, redirectCount+1).then(resolve, reject); } } else if(response.statusCode == 200) { resolve(url); } else { reject(new Error(`HTTP ${response.statusCode} (${response.statusMessage}) from HEAD ${url}`)) } response.destroy() }); req.on('abort', () => reject(new Error(`HEAD ${url}: aborted`))); req.on('error', (err) => reject(new Error(`HEAD ${url}: ${err}`))); req.end(); }); } const osImagesCache = {}; async function getImageMetadata(image) { if(osImagesCache[image]) { return Promise.resolve(osImagesCache[image]) } if(osImagesCache[`${image}_promise`]) { return osImagesCache[`${image}_promise`]; } osImagesCache[`${image}_promise`] = getImageMetadataRaw(image); const result = await osImagesCache[`${image}_promise`]; osImagesCache[image] = result; return result; } async function getImageMetadataRaw(image) { const scrapeLinks = async (url) => { const response = await myHTTPSWithResponseBodyString("GET", url, {}, null, x => x == 200); const document = parse5.parse(response.responseBodyString); const links = []; const scrapeLinksRecurse = (node, links) => { if(node.tagName == "a") { const hrefs = node.attrs.filter(x => x.name == "href"); if(hrefs.length > 0) { const currentPageURL = new URL(url); links.push((new URL(hrefs[0].value, currentPageURL)).toString()); } } if(node.childNodes) { node.childNodes.forEach(x => scrapeLinksRecurse(x, links)); } }; scrapeLinksRecurse(document, links); return links; }; const standardScrape = async (url, linkFilter, imageSuffix, hashSuffix, signatureSuffix) => { const links = (await scrapeLinks(url)).filter(linkFilter); myLog(`scrape results: ${url} [\n${links.join(',\n')}\n]`); const imageURL = links.filter(x => x.endsWith(imageSuffix))[0]; const hashURL = links.filter(x => x.endsWith(hashSuffix))[0]; const signatureURL = links.filter(x => x.endsWith(signatureSuffix))[0]; const torrentURL = links.filter(x => x.endsWith('.torrent'))[0]; const errors = [ imageURL ? null : "imageURL", hashURL ? null : "hashURL", signatureURL ? null : "signatureURL" ].filter(x => x); if(errors.length) { throw new Error(`unable to get [${errors.join(", ")}] for image ${image} (${url})`); } const results = await Promise.all([ myHTTPSWithResponseBodyString("GET", hashURL, {}, null, x => x == 200), myHTTPSWithResponseBodyString("GET", signatureURL, {}, null, x => x == 200), torrentURL ? myHTTPSWithResponseBodyBuffer("GET", torrentURL, {}, null, x => x == 200) : Promise.resolve(null) ]); const finalURL = await followRedirects("HEAD", imageURL) const urlSplit = finalURL.split('/'); const imageFilename = urlSplit[urlSplit.length-1]; return { url: finalURL, filename: imageFilename, hash: results[0].responseBodyString, signature: results[1].responseBodyString, torrentBase64: results[2] ? results[2].responseBodyBuffer.toString('base64') : null }; }; if(image == "debian_raspi_4") { return await standardScrape( 'https://raspi.debian.net/verified/', x => x.includes('raspi_4'), '.img.xz', '.sha256', '.sha256.asc' ); } else if(image == "armbian_odroid_xu4") { // TODO hack in the pre baked image return { url: "https://f000.backblazeb2.com/file/server-garden-artifacts/Armbian_20.11.0-trunk_Odroidxu4_buster_current_5.4.72_minimal.img.xz", filename: "Armbian_20.11.0-trunk_Odroidxu4_buster_current_5.4.72_minimal.img.xz", hash: "d38e1c5659dd452bdc39de3703aad04726ae0afa4201126fb715ce01506cb892", signature: "", }; // return await standardScrape( // 'https://www.armbian.com/odroid-xu4/', // x => (x.startsWith('https://redirect.armbian.com/odroidxu4/') && x.includes('Focal_current')), // 'Focal_current', // no suffix // '.sha', // '.asc' // ); } else { throw new Error(`Unknown image '${image}'`); } } const ensureDiskSpaceCache = {}; function ensureDiskSpace(images, phase) { images.sort() const cacheKey = `${images.join(',')}:${phase}`; if (ensureDiskSpaceCache[cacheKey]) { return Promise.resolve(ensureDiskSpaceCache[cacheKey]); } // collect the total and free bytes from the disk we are using return checkDiskSpace(utils.getAppSupportFolder()).then( stats => { let imagesBytesPromise = null; if(phase == "download") { // requesting the header data for all the images we have to download imagesBytesPromise = Promise.all( images.map(async (image) => { const imageMeta = await getImageMetadata(image); const response = await myHTTPS("HEAD", imageMeta.url); return Number(response.headers['content-length']); }) ); } else if(phase == "extract") { imagesBytesPromise = Promise.all( images.map(async (image) => { const imageMeta = await getImageMetadata(image); const fullyQualifiedPath = path.join( utils.getAppSupportFolder(), "os-images", imageMeta.filename); let source = new etcherSDK.sourceDestination.File({ path: fullyQualifiedPath }); source = await source.getInnerSource(); const requiredFreeBytes = (await source.getSize()).size; return requiredFreeBytes; }) ); } else { return Promise.reject(new Error(`ensureDiskSpace: invalid phase '${phase}'`)) } return imagesBytesPromise.then(results => { stats.required = results.reduce((a,b) => a+b, 0); ensureDiskSpaceCache[cacheKey] = stats; return stats; }); }, (e) => Promise.reject(e) ); } let needToCheckOrDownloadImages = true async function startDownloadingImages() { if(currentlyOpenSeedFile && currentlyOpenSeedFile['download-heads-up'] && needToCheckOrDownloadImages) { needToCheckOrDownloadImages = false; const imagesMap = {}; currentlyOpenSeedFile.servers.forEach(x => imagesMap[x.image] = 1); myLog("startDownloadingImages: ", imagesMap); const downloadOneByOne = Promise.resolve(); for(const image of Object.keys(imagesMap)) { try { await downloadSupportedOSImage(image); } catch (e) { myLog("startDownloadingImages failed:", e) break } } downloadStatus.allDownloadsDone = true myLog("startDownloadingImages success! allDownloadsDone = true "); } } async function downloadSupportedOSImage(image) { const imageMeta = await getImageMetadata(image); if(!imageMeta.url) { throw new Error(`the OS image "${image}" is unknown!`); } if(downloadStatus.inProgress) { throw new Error('there is already a download in progress!') } myLog("begin downloading " + imageMeta.filename); downloadStatus.file = imageMeta.filename; let fileAlreadyVerified = false; const getVerifyPromise = () => new Promise((resolve, reject) => { // since we attempt to verify once at the beginning if it already exists, and once at the end in case it was just downloaded, // skip this if we already verified it at the beginning & the download was skipped if(fileAlreadyVerified) { resolve(); return } myLog("begin verifying " + imageMeta.filename); downloadStatus.phase = "description_Verifying"; downloadStatus.file = imageMeta.filename; const sha256 = crypto.createHash('sha256'); const fileStream = fs.createReadStream(path.join(utils.getAppSupportFolder(), "os-images", imageMeta.filename)) fileStream.on('data', function(chunk) { sha256.update(chunk); }) fileStream.on('close', () => { const hash = sha256.digest('hex').toLowerCase(); const expectedHash = imageMeta.hash.trim().split(' ')[0].toLowerCase(); downloadStatus.phase = ""; downloadStatus.file = ""; downloadStatus.inProgress = false; if(hash == expectedHash) { myLog("done verifying " + imageMeta.filename); fileAlreadyVerified = true; resolve(); } else { myLog("done verifying " + imageMeta.filename + ". (hash check failed)"); reject( new Error(`hash of ${imageMeta.filename} (${hash}) did not match expected hash (${expectedHash})`)); } }); fileStream.on('error', (err) => { downloadStatus.phase = ""; downloadStatus.file = ""; downloadStatus.inProgress = false; myLog("error verifying " + imageMeta.filename + ": " + err); reject(err); }); }); // start out assuming the file does not exist let verifyExistingFilePromise = Promise.reject(); // if it does, check its integrity. // ENOENT = no one cares try { if(fs.statSync(path.join(utils.getAppSupportFolder(), "os-images", imageMeta.filename)).isFile()) { // prevent unhandled rejection of placeholder-promise. verifyExistingFilePromise.catch(() => {}); verifyExistingFilePromise = getVerifyPromise(); } } catch (noOneCares) {} const torrentPromise = verifyExistingFilePromise.then( // if the file is already verified, no need to try to download it again! () => Promise.resolve(), // if the file is not already verified, lets try torrenting it. () => { return new Promise((resolve, reject) => { myLog("begin torrenting " + imageMeta.filename); var client = new WebTorrent() if(!imageMeta.torrentBase64) { myLog("torrent file for " + imageMeta.filename + " not found! "); reject(); return; } const torrentFile = Buffer.from(imageMeta.torrentBase64, 'base64'); const clientOpts = { path: path.join(utils.getAppSupportFolder(), "os-images") }; let pollTorrent = null; downloadStatus.method = "description_MethodTorrent"; downloadStatus.phase = "description_ObtainingMetadata"; downloadStatus.inProgress = true; // If the torrent doesnt download anything after 20 seconds, rage quit and try HTTP const torrentTimeout = setTimeout(() => { if(downloadStatus.progress == 0) { clearInterval(pollTorrent); client.destroy(() => { myLog("torrenting " + imageMeta.filename + " timed out! "); reject(new Error(strings.error_TimedOutWatingForTorrent)); }); } }, 20000); client.add(torrentFile, clientOpts, (torrent) => { downloadStatus.phase = "description_Connecting"; pollTorrent = setInterval(() => { if(torrent.numPeers > 0 && downloadStatus.phase == "description_Connecting") { downloadStatus.phase = "description_Downloading"; } downloadStatus.peers = torrent.numPeers; downloadStatus.bytesPerSecond = client.downloadSpeed; downloadStatus.progress = torrent.progress; }, 500); torrent.on('done', () => { clearTimeout(torrentTimeout); clearInterval(pollTorrent); downloadStatus.progress = 1; downloadStatus.method = ""; downloadStatus.peers = 0; downloadStatus.bytesPerSecond = 0; myLog("torrenting " + imageMeta.filename + " succeeded! "); // I ran into an error where the verification would start before // the torrent had completely closed the file. // so I put this here to try to resolve that. setTimeout(() => resolve(), 100); }); }); client.on('error', (err) => { myLog("error torrenting " + imageMeta.filename + ": ", err); clearInterval(pollTorrent); reject(err); }); }); } ); const downloadPromise = torrentPromise.then( // if the torrent promise resolved, that means the file was successfully torrented, so nothing to do. () => Promise.resolve(), // else, let's try HTTP instead. (err) => { myLog("begin https " + imageMeta.filename); downloadStatus.method = "description_MethodHTTPS"; downloadStatus.phase = "description_Connecting"; return myHTTPS("GET", imageMeta.url).then( (response) => new Promise((resolve, reject) => { downloadStatus.phase = "description_Downloading"; downloadStatus.peers = 0; const contentLength = parseInt(response.headers['content-length']); const guage = new utils.BytesPerSecondGuage(); const pollDownload = setInterval(() => { downloadStatus.progress = guage.totalBytes/contentLength; downloadStatus.bytesPerSecond = guage.getBytesPerSecond(); }, 500); const file = fs.createWriteStream(path.join(utils.getAppSupportFolder(), "os-images", imageMeta.filename)); response.pipe(file); response.on('data', chunk => { guage.recordBytes(chunk.length); }); response.on('close', () => { clearInterval(pollDownload); downloadStatus.progress = 1; downloadStatus.method = ""; downloadStatus.peers = 0; downloadStatus.bytesPerSecond = 0; myLog("https " + imageMeta.filename + " succeeded! "); resolve(); }); response.on('error', (err) => { myLog("error https " + imageMeta.filename + ": " + err); clearInterval(pollDownload); reject(err); }); }), (e) => Promise.reject(e) ); } ); const verifyHashPromise = downloadPromise.then( () => getVerifyPromise(), // if the download failed, skip verifying the hash (err) => { downloadStatus.phase = ""; downloadStatus.inProgress = false; return Promise.reject(err); } ); return verifyHashPromise; } async function getPreExistingSSHKeys() { let sshFiles = []; let preExisting = ''; try { sshFiles = await util.promisify(fs.readdir)(path.join(os.homedir(), ".ssh")); } catch (err) { } // first look for all the default ssh key names for(const file of sshFiles) { if(file.startsWith('servergarden') || file == 'id_ed25519' || file == 'id_rsa' || file == 'id_ecdsa' || file == 'id_dsa') { if(sshFiles.includes(`${file}.pub`)) { preExisting = path.join(os.homedir(), ".ssh", `${file}.pub`); break; } } } // next look for other names which follow the openssh pattern in case the user customized the key name if(preExisting == '') { for(const file of sshFiles) { if(sshFiles.includes(`${file}.pub`)) { preExisting = path.join(os.homedir(), ".ssh", `${file}.pub`); break; } } } let createNew = 'id_ed25519'; if(sshFiles.includes(createNew) || sshFiles.includes(`${createNew}.pub`)) { createNew = 'servergarden_ed25519' } if(sshFiles.includes(createNew) || sshFiles.includes(`${createNew}.pub`)) { let i = 1; createNew = `servergarden${i}_ed25519`; while(sshFiles.includes(createNew) || sshFiles.includes(`${createNew}.pub`)) { i++; createNew = `servergarden${i}_ed25519`; } } let preExistingContent = ''; if(preExisting != '') { preExistingContent = (await fs.promises.readFile(preExisting)).toString('utf-8'); } return { preExistingContent, preExisting, createNew }; } async function generateSSHKey(saveToSSHDirectory, encryptWithPassphrase, comment) { const systemRandom = Uint8Array.from(crypto.randomBytes(32)); const sha256 = crypto.createHash('sha256'); sha256.update(JSON.stringify(currentlyOpenSeedFile)+String(new Date().getTime())); const extraEntropy = Uint8Array.from(sha256.digest()); // XOR the system random result with a shoddily constructed random number of our own. // I do this because it has a chance to improve security against backdoored system random, // there is no downside to this because // (FOO XOR RANDOM) is guaranteed to be just as random as (RANDOM) regardless of what FOO is. const seed = systemRandom.map((byte, i) => byte ^ extraEntropy[i % extraEntropy.length]); const privateKey = sshpk.generatePrivateKey('ed25519', { seed }); privateKey.comment = comment || `${os.userInfo().username}@${os.hostname()}` const publicKey = privateKey.toPublic(); const options = {}; if(encryptWithPassphrase && currentlyOpenSeedFile.passphrase != "") { options.passphrase = currentlyOpenSeedFile.passphrase; } const privateKeyBytes = privateKey.toBuffer('ssh-private', options); const publicKeyBytes = publicKey.toBuffer('ssh'); const toReturn = { privateKey: privateKeyBytes.toString('utf-8'), publicKey: publicKeyBytes.toString('utf-8') }; if(saveToSSHDirectory) { const filenames = await getPreExistingSSHKeys(); try { await fs.promises.mkdir(path.join(os.homedir(), ".ssh"), { mode: 0o700 }); } catch (noOneCares) {} const privateKeyPath = path.join(os.homedir(), ".ssh", filenames.createNew); const publicKeyPath = path.join(os.homedir(), ".ssh", `${filenames.createNew}.pub`); fs.writeFileSync(privateKeyPath, privateKeyBytes, { mode: 0o600 }); fs.writeFileSync(publicKeyPath, publicKeyBytes, { mode: 0o644 }); toReturn.publicKeyPath = publicKeyPath; } return toReturn; } function checkBackblaze(settings) { return objectStorageFactory({ "choose-providers": { objectStorage: "backblaze" }, "backblaze-account": settings }).list("/"); } function createBackblaze(settings) { return objectStorageFactory({ "choose-providers": { objectStorage: "backblaze" }, "backblaze-account": settings }).createIfNotExists(); } async function checkGandi(settings) { // https://api.gandi.net/docs/domains/#v5-domain-domains const response = await myHTTPSWithResponseBodyString( "GET", `${gandiAPIUrl}/domain/domains?fqdn=${settings.domainName}`, { "Authorization": `Apikey ${settings.apiKey}` }, null, statusCode => statusCode == 200, ); const responseArray = JSON.parse(response.responseBodyString); if(!responseArray.length) { throw new Error(strings.errorMessage_TheDomainXIsNotOwnedByThisAccount(settings.domainName)); } } async function getDigitalOceanRegions(settings) { // https://api.gandi.net/docs/domains/#v5-domain-domains const response = await myHTTPSWithResponseBodyString( "GET", `${digitalOceanAPIUrl}/regions`, { "Authorization": `Bearer ${settings.apiKey}` }, null, statusCode => statusCode == 200, ); return JSON.parse(response.responseBodyString).regions; } function getRecentlyOpenedFiles() { return Promise.all(appState.previouslyOpenedFiles.map( x => new Promise((resolve, reject) => { fs.access(x.path, fs.constants.F_OK | fs.constants.W_OK, (err) => { resolve(err != null ? null : x); }); }) )).then(results => { const notNullFileNames = results.filter(x => x != null); const uniqueFileNames = Object.values( notNullFileNames.reduce((accum, x) => { if(!accum[x.path] || accum[x.path].lastModified < x.lastModified) { accum[x.path] = x; } return accum; }, {}) ); uniqueFileNames.sort((a, b) => a.lastModified < b.lastModified ? 1 : (a.lastModified > b.lastModified ? -1 : 0)); return {previouslyOpenedFiles: uniqueFileNames.slice(0, 3).map(x => x.path)}; }); } function requestFilepath(mode) { return new Promise((resolve, reject) => { let filepath; if(mode == 'open-ssh') { let defaultPath = path.join(process.env.HOME, '.ssh'); if(!fs.existsSync(defaultPath)) { defaultPath = process.env.HOME; } filepath = dialog.showOpenDialogSync(BrowserWindow.getFocusedWindow(), { defaultPath: defaultPath, title: strings.title_ChooseSSHPublicKey, properties: ['openFile', 'multiSelections', 'showHiddenFiles'], filters: [{ name: strings.description_FileTypeOpenSSHPublicKey, extensions: ['pub'] }, { name: strings.description_FileTypeAllFiles, extensions: ['*'] }] }); } else if(mode == 'open') { filepath = dialog.showOpenDialogSync(BrowserWindow.getFocusedWindow(), { title: strings.title_OpenASeedFile, properties: ['openFile'], filters: [{ name: strings.description_FileTypeSGSeed, extensions: ['sgseed'] }, { name: strings.description_FileTypeAllFiles, extensions: ['*'] }] }); if(Array.isArray(filepath)) { filepath = filepath[0]; } } else if(mode == 'new') { filepath = dialog.showSaveDialogSync(BrowserWindow.getFocusedWindow(), { title: strings.title_WhereToSaveYourFile, defaultPath: strings.defaultFileName }); } else { reject(new Error(strings.errorMessage_UnsupportedRequestFilepathMode(mode))) } if(filepath != null) { const checkFilepaths = Array.isArray(filepath) ? filepath : [filepath]; Promise.all( checkFilepaths.map(x => new Promise( (resolve) => fs.access(x, fs.constants.F_OK | fs.constants.W_OK, resolve) )) ).then(errors => { errors.forEach((err, i) => { if(err && (mode != 'new' || err.code !== 'ENOENT')) { const fsAccess = `fs.access${err.message.includes(checkFilepaths[i]) ? '' : `("${checkFilepaths[i]}")`}`; reject(new Error(strings.errorMessage_XReturnedY(fsAccess, `"${err.message}"`))); return } }); const toReturn = {path: filepath, mode: mode}; // open-ssh wants an array of file paths. if(mode == 'open-ssh') { toReturn.paths = checkFilepaths; toReturn.contents = checkFilepaths.map(x => fs.readFileSync(x).toString('utf8')) } resolve(toReturn); }); } else { reject('cancelled_symbol'); } }); } function bumpRecentlyOpened(filepath) { const existing = appState.previouslyOpenedFiles.filter(x => x.path == filepath)[0]; if(!existing) { appState.previouslyOpenedFiles.push({ path: filepath, lastModified: new Date().getTime() }) } else { existing.lastModified = new Date().getTime(); } const appStateFilepath = `${utils.getAppSupportFolder()}/appState.json`; fs.writeFile(appStateFilepath, JSON.stringify(appState), { mode: 0644 }, (err) => { if(err) { let errorMessage = `cant write ${utils.getAppSupportFolder()}/appState.json: ${err}`; if(String(err).indexOf(appStateFilepath) != -1) { errorMessage = `cant write appState file: ${err}` } myLog(errorMessage); } }); } function readFile(filepath, encryptionInstance) { return new Promise((resolve, reject) => { fs.readFile(filepath, (err, encryptedData) => { if(err) { reject(new Error(strings.errorMessage_CantXBecauseYReturnedZ(`readFile("${filepath}")`, "fs.readFile", String(err)))); } else { let data; try { data = Buffer.from( encryptionInstance.decryptToBytes(encryptedData) ); } catch (err) { reject(new Error(strings.errorMessage_CantXBecauseYReturnedZ( `readFile("${filepath}")`, "encryptionInstance.decryptToBytes", String(err) ))); return; } zlib.gunzip(data, (err, jsonString) => { if(err) { const formatError = strings.errorMessage_FormatErrorX(err) reject(new Error(strings.errorMessage_CantXBecauseYReturnedZ(`readFile("${filepath}")`, "zlib.gunzip", formatError))); } else { try { const result = JSON.parse(jsonString); currentlyOpenSeedFile = result; // automatically start/resume any required downloads in the background. startDownloadingImages(); try { objectStorage = objectStorageFactory(currentlyOpenSeedFile, getEncryptionInstanceFromPassphrase); } catch (err) { myLog(`failed to setup object storage: ${err}`) } resolve(result); bumpRecentlyOpened(filepath); } catch (err) { reject(new Error(strings.errorMessage_CantXBecauseYReturnedZ(`readFile("${filepath}")`, "JSON.parse", String(err)))); } } }); } }); }); } function writeFile(filepath, encryptionInstance, object) { currentlyOpenSeedFile = object; //myLog(JSON.stringify(object, null, " ")) try { objectStorage = objectStorageFactory(currentlyOpenSeedFile, getEncryptionInstanceFromPassphrase); } catch (err) { myLog(`failed to setup object storage: ${err}`) } return new Promise((resolve, reject) => { let jsonString = JSON.stringify(object); zlib.gzip(jsonString, (err, jsonStringGzip) => { if(err) { reject(new Error(`can't writeFile("${filepath}", ...) because zlib.gzip returned ${String(err)}`)); } else { const encryptedData = Buffer.from( encryptionInstance.encryptBytes(jsonStringGzip) ) fs.writeFile(filepath, encryptedData, { mode: 0644 }, err => { if(err) { reject(new Error(`can't writeFile("${filepath}", ...) because fs.writeFile returned ${String(err)}`)); } else { resolve(); bumpRecentlyOpened(filepath); } }); } }); }); } function streamToString (stream) { const chunks = []; return new Promise((resolve, reject) => { stream.on('data', chunk => chunks.push(chunk)); stream.on('error', reject); stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) }) } function streamToBuffer (stream) { const chunks = []; return new Promise((resolve, reject) => { stream.on('data', chunk => chunks.push(chunk)); stream.on('error', reject); stream.on('end', () => resolve(Buffer.concat(chunks))); }) } function createWindow () { // Create the browser window. mainWindow = new BrowserWindow({ icon: 'electron-packager-stuff/icon/icon_256.png', width: 768, height: 768, webPreferences: { devTools: !settings.isProduction, nodeIntegration: false, enableRemoteModule: false, preload: path.join(__dirname, 'preload.js') } }); if(!settings.isProduction) { mainWindow.maximize(); } // and load the index.html of the app. mainWindow.loadFile('webcontent/index.html') if(!settings.isProduction) { mainWindow.webContents.openDevTools() } else { mainWindow.setMenu(null); } mainWindow.webContents.on('new-window', function(event, url) { event.preventDefault(); open(url); }); mainWindow.webContents.on('did-finish-load', () => { startBlockDeviceScanner(mainWindow.webContents).then( () => {}, (e) => { mainWindow.webContents.send("update-block-devices", {error: e}); } ); initializeApplication(mainWindow.webContents); }); // Emitted when the window is closed. mainWindow.on('closed', function () { // Dereference the window object, usually you would store windows // in an array if your app supports multi windows, this is the time // when you should delete the corresponding element. mainWindow = null }) } // Feature flag for electron to get rid of deprecation warning. // https://github.com/electron/electron/issues/18397 app.allowRendererProcessReuse = true; // 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.on('ready', createWindow) // Quit when all windows are closed. app.on('window-all-closed', function () { app.quit() }) app.on('activate', function () { // On macOS it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open. if (mainWindow === null) createWindow() })