User-facing desktop application for server.garden
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

// 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()
})