Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
6bc89d73dThis file is pinned to the selected release. Download the original to open it in your design tool.
/*
* Copyright (C) 2021 Sienci Labs Inc.
*
* This file is part of gSender.
*
* gSender is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, under version 3 of the License.
*
* gSender is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with gSender. If not, see <https://www.gnu.org/licenses/>.
*
* Contact for information regarding this program and its license
* can be sent through gSender@sienci.com or mailed to the main office
* of Sienci Labs Inc. in Waterloo, Ontario, Canada.
*
*/
import bcrypt from "bcrypt-nodejs";
import chalk from "chalk";
import dns from "dns";
import ensureArray from "ensure-array";
import expandTilde from "expand-tilde";
import express from "express";
import fs from "fs";
import httpProxy from "http-proxy";
import escapeRegExp from "lodash/escapeRegExp";
import get from "lodash/get";
import isEqual from "lodash/isEqual";
import set from "lodash/set";
import size from "lodash/size";
import trimEnd from "lodash/trimEnd";
import uniqWith from "lodash/uniqWith";
import os from "os";
import path from "path";
import url from "url";
import webappengine from "webappengine";
import app from "./app";
import settings from "./config/settings";
import { ensureString } from "./lib/ensure-type";
import logger, { setLevel } from "./lib/logger";
import urljoin from "./lib/urljoin";
import cncengine from "./services/cncengine";
import config, { validateAndRepairConfigFile } from "./services/configstore";
import errorConfig from "./services/configstore/alarmStore";
import jobConfig from "./services/configstore/jobStore";
import monitor from "./services/monitor";
import pluginRegistry from "./services/pluginregistry";
const log = logger("init");
const createServer = (options, callback) => {
options = { ...options };
{
// verbosity
const verbosity = options.verbosity;
// https://github.com/winstonjs/winston#logging-levels
if (verbosity === 1) {
set(settings, "verbosity", verbosity);
setLevel("verbose");
}
if (verbosity === 2) {
set(settings, "verbosity", verbosity);
setLevel("debug");
}
if (verbosity === 3) {
set(settings, "verbosity", verbosity);
setLevel("silly");
}
}
const rcfile = path.resolve(options.configFile || settings.rcfile);
const errorFile = path.resolve(options.errorFile || settings.errorFile);
const jobFile = path.resolve(options.jobFile || settings.jobFile);
// Safeguard: if the config file is corrupted (unparseable), back it up and
// reset it to defaults before loading so startup can't crash on bad JSON.
const configRecovery = validateAndRepairConfigFile(rcfile);
// configstore service
log.info(
`Loading configuration from ${chalk.yellow(JSON.stringify(rcfile))}`,
);
errorConfig.load(rcfile, errorFile);
jobConfig.load(rcfile, jobFile);
config.load(rcfile);
// rcfile
settings.rcfile = rcfile;
// secret
if (!config.get("secret")) {
// generate a secret key
const secret = bcrypt.genSaltSync(); // TODO: use a strong secret
config.set("secret", secret);
}
settings.secret = config.get("secret", settings.secret);
{
// watchDirectory
const watchDirectory =
options.watchDirectory || config.get("watchDirectory");
if (watchDirectory) {
if (fs.existsSync(watchDirectory)) {
log.info(
`Watching ${chalk.yellow(JSON.stringify(watchDirectory))} for file changes`,
);
// monitor service
monitor.start({ watchDirectory: watchDirectory });
} else {
log.error(
`The directory ${chalk.yellow(JSON.stringify(watchDirectory))} does not exist.`,
);
}
}
}
{
// accessTokenLifetime
const accessTokenLifetime =
options.accessTokenLifetime || config.get("accessTokenLifetime");
if (accessTokenLifetime) {
set(settings, "accessTokenLifetime", accessTokenLifetime);
}
}
{
// allowRemoteAccess
const allowRemoteAccess =
options.allowRemoteAccess || config.get("allowRemoteAccess", false);
if (allowRemoteAccess) {
if (size(config.get("users")) === 0) {
log.warn(
"You've enabled remote access to the server. It's recommended to create an user account to protect against malicious attacks.",
);
}
set(settings, "allowRemoteAccess", allowRemoteAccess);
}
}
{
// kiosk mode
const kiosk = options.kiosk || config.get("kiosk", false);
if (kiosk) {
set(settings, "kiosk", kiosk);
}
}
let { backlog, port = 0, host } = options;
const isInDevelopmentMode = process.env.NODE_ENV === "development";
// If headless setting is ON, change to correct port and IP
const remoteSettings = config.get("remoteSettings", {});
// Don't do this if: disabled, default IP, dev mode
const setPort = get(remoteSettings, "port", null);
const setIP = get(remoteSettings, "ip", null);
if (
remoteSettings.headlessStatus &&
!isInDevelopmentMode &&
setIP &&
setPort &&
setIP !== "0.0.0.0" &&
remoteSettings.ip.length > 0
) {
port = setPort;
host = setIP;
}
// Serve the plugin SDK's built runtime so the import map in a plugin's
// index.html can resolve the bare @sienci/gsender-plugin-sdk specifiers
const pluginSdkMountPoints = [];
if (settings.pluginSdkDir && fs.existsSync(settings.pluginSdkDir)) {
pluginSdkMountPoints.push({
route: settings.pluginSdkRoute,
target: settings.pluginSdkDir,
});
} else {
log.warn(
`Plugin SDK runtime not found at ${chalk.yellow(JSON.stringify(settings.pluginSdkDir))}; plugins importing the SDK will fail to load. Build packages/plugin-sdk first.`,
);
}
const mountPoints = uniqWith(
[
...ensureArray(options.mountPoints),
...ensureArray(config.get("mountPoints")),
...pluginRegistry.getMountPointsFromPlugins(),
...pluginSdkMountPoints,
],
isEqual,
).filter((mount) => {
if (!mount?.route || mount.route === "/") {
log.error(
`Must specify a valid route path ${JSON.stringify(mount.route)}.`,
);
return false;
}
return true;
});
const routes = [];
mountPoints.forEach((mount) => {
if (ensureString(mount.target).match(/^(http|https):\/\//i)) {
log.info(
`Starting a proxy server to proxy all requests starting with ${chalk.yellow(mount.route)} to ${chalk.yellow(mount.target)}`,
);
routes.push({
type: "server",
route: mount.route,
server: (options) => {
const { route = "/" } = { ...options };
const routeWithoutTrailingSlash = trimEnd(route, "/");
const target = mount.target;
const targetPathname = url.parse(target).pathname;
const proxyPathPattern = new RegExp(
"^" +
escapeRegExp(urljoin(targetPathname, routeWithoutTrailingSlash)),
"i",
);
log.debug(`> route=${chalk.yellow(route)}`);
log.debug(
`> routeWithoutTrailingSlash=${chalk.yellow(routeWithoutTrailingSlash)}`,
);
log.debug(`> target=${chalk.yellow(target)}`);
log.debug(`> targetPathname=${chalk.yellow(targetPathname)}`);
log.debug(
`> proxyPathPattern=RegExp(${chalk.yellow(proxyPathPattern)})`,
);
const proxy = httpProxy.createProxyServer({
// Change the origin of the host header to the target URL
changeOrigin: true,
// Do not verify the SSL certificate for self-signed certs
//secure: false,
target: target,
});
proxy.on("proxyReq", (proxyReq, req, res, options) => {
const originalPath = proxyReq.path || "";
proxyReq.path = originalPath
.replace(proxyPathPattern, targetPathname)
.replace("//", "/");
log.debug(
`proxy.on('proxyReq'): modifiedPath=${chalk.yellow(proxyReq.path)}, originalPath=${chalk.yellow(originalPath)}`,
);
});
proxy.on("proxyRes", (proxyRes, req, res) => {
log.debug(
`proxy.on('proxyRes'): headers=${JSON.stringify(proxyRes.headers, true, 2)}`,
);
});
const app = express();
// Matched routes:
// /widget/
// /widget/v1/
app.all(urljoin(routeWithoutTrailingSlash, "*"), (req, res) => {
const url = req.url;
log.debug(`proxy.web(): url=${chalk.yellow(url)}`);
proxy.web(req, res);
});
// Matched routes:
// /widget
app.all(routeWithoutTrailingSlash, (req, res, next) => {
const url = req.url;
// Redirect URL with a trailing slash
if (
url.indexOf(routeWithoutTrailingSlash) === 0 &&
url.indexOf(routeWithoutTrailingSlash + "/") < 0
) {
const redirectUrl =
routeWithoutTrailingSlash +
"/" +
url.slice(routeWithoutTrailingSlash.length);
log.debug(
`redirect: url=${chalk.yellow(url)}, redirectUrl=${chalk.yellow(redirectUrl)}`,
);
res.redirect(301, redirectUrl);
return;
}
next();
});
return app;
},
});
} else {
// expandTilde('~') => '/Users/<userhome>'
const directory = expandTilde(ensureString(mount.target)).trim();
log.info(
`Mounting a directory ${chalk.yellow(JSON.stringify(directory))} to serve requests starting with ${chalk.yellow(mount.route)}`,
);
if (!directory) {
log.error(
`The directory path ${chalk.yellow(JSON.stringify(directory))} must not be empty.`,
);
return;
}
if (!path.isAbsolute(directory)) {
log.error(
`The directory path ${chalk.yellow(JSON.stringify(directory))} must be absolute.`,
);
return;
}
if (!fs.existsSync(directory)) {
log.error(
`The directory path ${chalk.yellow(JSON.stringify(directory))} does not exist.`,
);
return;
}
routes.push({
type: "static",
route: mount.route,
directory: directory,
});
}
});
routes.push({
type: "server",
route: "/",
server: () => app(),
});
webappengine({ port, host, backlog, routes })
.on("ready", (server) => {
// cncengine service
cncengine.start(
server,
options.controller || config.get("controller", ""),
);
// Dev-only: live-reload plugin iframes when their files change.
if (process.env.NODE_ENV === "development") {
pluginRegistry.watchPlugins(({ dir, filename }) => {
log.info(
`Plugin change detected in ${dir}${filename ? ` (${filename})` : ""}; notifying clients`,
);
// Dev only, and this fires on every file save — so any open
// plugin parser block is flushed with reason "reload" quite
// often here. That is expected in dev; it is logged at info so
// it isn't mistaken for a parser bug.
cncengine.reloadPluginParsers();
cncengine.emit("plugins:changed", { dir, filename });
});
}
const address = server.address().address;
const port = server.address().port;
callback &&
callback(null, {
address,
port,
mountPoints,
configRestored: configRecovery.restored,
configBackupPath: configRecovery.backupPath,
});
if (address !== "0.0.0.0") {
log.info(
"Starting the server at " + chalk.yellow(`http://${address}:${port}`),
);
return;
}
dns.lookup(os.hostname(), { family: 4, all: true }, (err, addresses) => {
if (err) {
log.error("Can't resolve host name:", err);
return;
}
addresses.forEach(({ address, family }) => {
log.info(
"Starting the server at " +
chalk.yellow(`http://${address}:${port}`),
);
});
});
})
.on("error", (err) => {
log.error(err);
log.error(err.name);
const errData = {};
// Handle invalid IP by disabling remote mode until enabled again and signaling error
if (
err.message.includes("address not available") ||
err.message.includes("address already in use")
) {
config.set("remoteSettings.headlessStatus", false);
config.set("remoteSettings.error", true);
errData.bindingErr = true;
}
callback && callback(err, errData);
});
};
export { createServer };