dockge/backend/dockge-server.ts
Louis Lam e67d08b7b3 wip
2023-10-29 15:25:52 +08:00

462 lines
14 KiB
TypeScript

import { MainRouter } from "./routers/main-router";
import * as fs from "node:fs";
import { PackageJson } from "type-fest";
import { Database } from "./database";
import packageJSON from "../package.json";
import { log } from "./log";
import * as socketIO from "socket.io";
import express, { Express } from "express";
import { parse } from "ts-command-line-args";
import https from "https";
import http from "http";
import { Router } from "./router";
import { Socket } from "socket.io";
import { MainSocketHandler } from "./socket-handlers/main-socket-handler";
import { SocketHandler } from "./socket-handler";
import { Settings } from "./settings";
import checkVersion from "./check-version";
import dayjs from "dayjs";
import { R } from "redbean-node";
import { genSecret, isDev } from "./util-common";
import { generatePasswordHash } from "./password-hash";
import { Bean } from "redbean-node/dist/bean";
import { Arguments, Config, DockgeSocket } from "./util-server";
import { DockerSocketHandler } from "./socket-handlers/docker-socket-handler";
import expressStaticGzip from "express-static-gzip";
import path from "path";
import { TerminalSocketHandler } from "./socket-handlers/terminal-socket-handler";
import { Stack } from "./stack";
import { Cron } from "croner";
import childProcess from "child_process";
export class DockgeServer {
app : Express;
httpServer : http.Server;
packageJSON : PackageJson;
io : socketIO.Server;
config : Config;
indexHTML : string = "";
/**
* List of express routers
*/
routerList : Router[] = [
new MainRouter(),
];
/**
* List of socket handlers
*/
socketHandlerList : SocketHandler[] = [
new MainSocketHandler(),
new DockerSocketHandler(),
new TerminalSocketHandler(),
];
/**
* Show Setup Page
*/
needSetup = false;
jwtSecret? : string;
stacksDir : string = "";
/**
*
*/
constructor() {
// Catch unexpected errors here
let unexpectedErrorHandler = (error : unknown) => {
console.trace(error);
console.error("If you keep encountering errors, please report to https://github.com/louislam/uptime-kuma/issues");
};
process.addListener("unhandledRejection", unexpectedErrorHandler);
process.addListener("uncaughtException", unexpectedErrorHandler);
if (!process.env.NODE_ENV) {
process.env.NODE_ENV = "production";
}
// Log NODE ENV
log.info("server", "NODE_ENV: " + process.env.NODE_ENV);
// Define all possible arguments
let args = parse<Arguments>({
sslKey: {
type: String,
optional: true,
},
sslCert: {
type: String,
optional: true,
},
sslKeyPassphrase: {
type: String,
optional: true,
},
port: {
type: Number,
optional: true,
},
hostname: {
type: String,
optional: true,
},
dataDir: {
type: String,
optional: true,
}
});
this.config = args as Config;
// Load from environment variables or default values if args are not set
this.config.sslKey = args.sslKey || process.env.DOCKGE_SSL_KEY || undefined;
this.config.sslCert = args.sslCert || process.env.DOCKGE_SSL_CERT || undefined;
this.config.sslKeyPassphrase = args.sslKeyPassphrase || process.env.DOCKGE_SSL_KEY_PASSPHRASE || undefined;
this.config.port = args.port || parseInt(process.env.DOCKGE_PORT) || 5001;
this.config.hostname = args.hostname || process.env.DOCKGE_HOSTNAME || undefined;
this.config.dataDir = args.dataDir || process.env.DOCKGE_DATA_DIR || "./data/";
log.debug("server", this.config);
this.packageJSON = packageJSON as PackageJson;
try {
this.indexHTML = fs.readFileSync("./frontend-dist/index.html").toString();
} catch (e) {
// "dist/index.html" is not necessary for development
if (process.env.NODE_ENV !== "development") {
log.error("server", "Error: Cannot find 'frontend-dist/index.html', did you install correctly?");
process.exit(1);
}
}
// Create all the necessary directories
this.initDataDir();
// Create express
this.app = express();
// Create HTTP server
if (this.config.sslKey && this.config.sslCert) {
log.info("server", "Server Type: HTTPS");
this.httpServer = https.createServer({
key: fs.readFileSync(this.config.sslKey),
cert: fs.readFileSync(this.config.sslCert),
passphrase: this.config.sslKeyPassphrase,
}, this.app);
} else {
log.info("server", "Server Type: HTTP");
this.httpServer = http.createServer(this.app);
}
// Binding Routers
for (const router of this.routerList) {
this.app.use(router.create(this.app, this));
}
// Static files
this.app.use("/", expressStaticGzip("frontend-dist", {
enableBrotli: true,
}));
// Universal Route Handler, must be at the end of all express routes.
this.app.get("*", async (_request, response) => {
response.send(this.indexHTML);
});
// Allow all CORS origins in development
let cors = undefined;
if (isDev) {
cors = {
origin: "*",
};
}
// Create Socket.io
this.io = new socketIO.Server(this.httpServer, {
cors,
});
this.io.on("connection", (socket: Socket) => {
log.info("server", "Socket connected!");
this.sendInfo(socket, true);
if (this.needSetup) {
log.info("server", "Redirect to setup page");
socket.emit("setup");
}
// Create socket handlers
for (const socketHandler of this.socketHandlerList) {
socketHandler.create(socket as DockgeSocket, this);
}
});
this.io.on("disconnect", () => {
});
}
prepareServer() {
}
/**
*
*/
async serve() {
// Connect to database
try {
await Database.init(this);
} catch (e) {
log.error("server", "Failed to prepare your database: " + e.message);
process.exit(1);
}
// First time setup if needed
let jwtSecretBean = await R.findOne("setting", " `key` = ? ", [
"jwtSecret",
]);
if (! jwtSecretBean) {
log.info("server", "JWT secret is not found, generate one.");
jwtSecretBean = await this.initJWTSecret();
log.info("server", "Stored JWT secret into database");
} else {
log.debug("server", "Load JWT secret from database.");
}
this.jwtSecret = jwtSecretBean.value;
const userCount = (await R.knex("user").count("id as count").first()).count;
log.debug("server", "User count: " + userCount);
// If there is no record in user table, it is a new Dockge instance, need to setup
if (userCount == 0) {
log.info("server", "No user, need setup");
this.needSetup = true;
}
// Listen
this.httpServer.listen(5001, this.config.hostname, () => {
if (this.config.hostname) {
log.info( "server", `Listening on ${this.config.hostname}:${this.config.port}`);
} else {
log.info("server", `Listening on ${this.config.port}`);
}
// Run every 5 seconds
const job = Cron("*/2 * * * * *", {
protect: true, // Enabled over-run protection.
}, () => {
log.debug("server", "Cron job running");
this.sendStackList(true);
});
});
}
/**
* Emits the version information to the client.
* @param socket Socket.io socket instance
* @param hideVersion Should we hide the version information in the response?
* @returns
*/
async sendInfo(socket : Socket, hideVersion = false) {
let versionProperty;
let latestVersionProperty;
let isContainer;
if (!hideVersion) {
versionProperty = packageJSON.version;
latestVersionProperty = checkVersion.latestVersion;
isContainer = (process.env.DOCKGE_IS_CONTAINER === "1");
}
socket.emit("info", {
versionProperty,
latestVersionProperty,
isContainer,
primaryBaseURL: await Settings.get("primaryBaseURL"),
serverTimezone: await this.getTimezone(),
serverTimezoneOffset: this.getTimezoneOffset(),
});
}
/**
* Get the IP of the client connected to the socket
* @param {Socket} socket Socket to query
* @returns IP of client
*/
async getClientIP(socket : Socket) : Promise<string> {
let clientIP = socket.client.conn.remoteAddress;
if (clientIP === undefined) {
clientIP = "";
}
if (await Settings.get("trustProxy")) {
const forwardedFor = socket.client.conn.request.headers["x-forwarded-for"];
if (typeof forwardedFor === "string") {
return forwardedFor.split(",")[0].trim();
} else if (typeof socket.client.conn.request.headers["x-real-ip"] === "string") {
return socket.client.conn.request.headers["x-real-ip"];
}
}
return clientIP.replace(/^::ffff:/, "");
}
/**
* Attempt to get the current server timezone
* If this fails, fall back to environment variables and then make a
* guess.
* @returns {Promise<string>} Current timezone
*/
async getTimezone() {
// From process.env.TZ
try {
if (process.env.TZ) {
this.checkTimezone(process.env.TZ);
return process.env.TZ;
}
} catch (e) {
log.warn("timezone", e.message + " in process.env.TZ");
}
const timezone = await Settings.get("serverTimezone");
// From Settings
try {
log.debug("timezone", "Using timezone from settings: " + timezone);
if (timezone) {
this.checkTimezone(timezone);
return timezone;
}
} catch (e) {
log.warn("timezone", e.message + " in settings");
}
// Guess
try {
const guess = dayjs.tz.guess();
log.debug("timezone", "Guessing timezone: " + guess);
if (guess) {
this.checkTimezone(guess);
return guess;
} else {
return "UTC";
}
} catch (e) {
// Guess failed, fall back to UTC
log.debug("timezone", "Guessed an invalid timezone. Use UTC as fallback");
return "UTC";
}
}
/**
* Get the current offset
* @returns {string} Time offset
*/
getTimezoneOffset() {
return dayjs().format("Z");
}
/**
* Throw an error if the timezone is invalid
* @param {string} timezone Timezone to test
* @returns {void}
* @throws The timezone is invalid
*/
checkTimezone(timezone : string) {
try {
dayjs.utc("2013-11-18 11:55").tz(timezone).format();
} catch (e) {
throw new Error("Invalid timezone:" + timezone);
}
}
/**
* Initialize the data directory
*/
initDataDir() {
if (! fs.existsSync(this.config.dataDir)) {
fs.mkdirSync(this.config.dataDir, { recursive: true });
}
// Check if a directory
if (!fs.lstatSync(this.config.dataDir).isDirectory()) {
throw new Error(`Fatal error: ${this.config.dataDir} is not a directory`);
}
// Create data/stacks directory
this.stacksDir = path.join(this.config.dataDir, "stacks");
if (!fs.existsSync(this.stacksDir)) {
fs.mkdirSync(this.stacksDir, { recursive: true });
}
log.info("server", `Data Dir: ${this.config.dataDir}`);
}
/**
* Init or reset JWT secret
* @returns JWT secret
*/
async initJWTSecret() : Promise<Bean> {
let jwtSecretBean = await R.findOne("setting", " `key` = ? ", [
"jwtSecret",
]);
if (!jwtSecretBean) {
jwtSecretBean = R.dispense("setting");
jwtSecretBean.key = "jwtSecret";
}
jwtSecretBean.value = generatePasswordHash(genSecret());
await R.store(jwtSecretBean);
return jwtSecretBean;
}
sendStackList(useCache = false) {
let stackList = Stack.getStackList(this, useCache);
let roomList = this.io.sockets.adapter.rooms.keys();
for (let room of roomList) {
// Check if the room is a number (user id)
if (Number(room)) {
this.io.to(room).emit("stackList", {
ok: true,
stackList: Object.fromEntries(stackList),
});
}
}
}
sendStackStatusList() {
let statusList = Stack.getStatusList();
let roomList = this.io.sockets.adapter.rooms.keys();
for (let room of roomList) {
// Check if the room is a number (user id)
if (Number(room)) {
log.debug("server", "Send stack status list to room " + room);
this.io.to(room).emit("stackStatusList", {
ok: true,
stackStatusList: Object.fromEntries(statusList),
});
} else {
log.debug("server", "Skip sending stack status list to room " + room);
}
}
}
get stackDirFullPath() {
return path.resolve(this.stacksDir);
}
}