You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1482 lines
49 KiB
1482 lines
49 KiB
// 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() |
|
})
|
|
|