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 gracefulShutdown from "http-graceful-shutdown"; import User from "./models/user"; 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/dockge"); }; 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); // Default stacks directory let defaultStacksDir; if (process.platform === "win32") { defaultStacksDir = "./stacks"; } else { defaultStacksDir = "/opt/stacks"; } // Define all possible arguments let args = parse({ 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, }, stacksDir: { 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 || Number(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/"; this.config.stacksDir = args.stacksDir || process.env.DOCKGE_STACKS_DIR || defaultStacksDir; this.stacksDir = this.config.stacksDir; 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", async (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); } // *************************** // Better do anything after added all socket handlers here // *************************** log.debug("auth", "check auto login"); if (await Settings.get("disableAuth")) { log.info("auth", "Disabled Auth: auto login to admin"); this.afterLogin(socket as DockgeSocket, await R.findOne("user") as User); socket.emit("autoLogin"); } else { log.debug("auth", "need auth"); } }); this.io.on("disconnect", () => { }); } async afterLogin(socket : DockgeSocket, user : User) { socket.userID = user.id; socket.join(user.id.toString()); this.sendInfo(socket); try { this.sendStackList(); } catch (e) { log.error("server", e); } } /** * */ async serve() { // Connect to database try { await Database.init(this); } catch (e) { if (e instanceof Error) { 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 Cron("*/2 * * * * *", { protect: true, // Enabled over-run protection. }, () => { log.debug("server", "Cron job running"); this.sendStackList(true); }); }); gracefulShutdown(this.httpServer, { signals: "SIGINT SIGTERM", timeout: 30000, // timeout: 30 secs development: false, // not in dev mode forceExit: true, // triggers process.exit() at the end of shutdown process onShutdown: this.shutdownFunction, // shutdown function (async) - e.g. for cleanup DB, ... finally: this.finalFunction, // finally function (sync) - e.g. for logging }); } /** * 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", { version: versionProperty, latestVersion: latestVersionProperty, isContainer, primaryHostname: await Settings.get("primaryHostname"), //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 { 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} Current timezone */ async getTimezone() { // From process.env.TZ try { if (process.env.TZ) { this.checkTimezone(process.env.TZ); return process.env.TZ; } } catch (e) { if (e instanceof Error) { 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) { if (e instanceof Error) { 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 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 { 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 roomList = this.io.sockets.adapter.rooms.keys(); let map : Map | undefined; for (let room of roomList) { // Check if the room is a number (user id) if (Number(room)) { // Get the list only if there is a room if (!map) { map = new Map(); let stackList = Stack.getStackList(this, useCache); for (let [ stackName, stack ] of stackList) { map.set(stackName, stack.toSimpleJSON()); } } log.debug("server", "Send stack list to room " + room); this.io.to(room).emit("stackList", { ok: true, stackList: Object.fromEntries(map), }); } } } 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); } } } getDockerNetworkList() : string[] { let res = childProcess.spawnSync("docker", [ "network", "ls", "--format", "{{.Name}}" ]); let list = res.stdout.toString().split("\n"); // Remove empty string item list = list.filter((item) => { return item !== ""; }).sort((a, b) => { return a.localeCompare(b); }); return list; } get stackDirFullPath() { return path.resolve(this.stacksDir); } /** * Shutdown the application * Stops all monitors and closes the database connection. * @param signal The signal that triggered this function to be called. */ async shutdownFunction(signal : string | undefined) { log.info("server", "Shutdown requested"); log.info("server", "Called signal: " + signal); // TODO: Close all terminals? await Database.close(); Settings.stopCacheCleaner(); } /** * Final function called before application exits */ finalFunction() { log.info("server", "Graceful shutdown successful!"); } }