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 { DockgeSocket } from "./util-server"; import { DockerSocketHandler } from "./socket-handlers/docker-socket-handler"; import { Terminal } from "./terminal"; export interface Arguments { sslKey? : string; sslCert? : string; sslKeyPassphrase? : string; port? : number; hostname? : string; dataDir? : string; } export class DockgeServer { app : Express; httpServer : http.Server; packageJSON : PackageJson; io : socketIO.Server; config : Arguments; indexHTML : string; terminal : Terminal; /** * List of express routers */ routerList : Router[] = [ new MainRouter(), ]; /** * List of socket handlers */ socketHandlerList : SocketHandler[] = [ new MainSocketHandler(), new DockerSocketHandler(), ]; /** * Show Setup Page */ needSetup = false; jwtSecret? : string; /** * */ constructor() { if (!process.env.NODE_ENV) { process.env.NODE_ENV = "production"; } // Log NODE ENV log.info("server", "NODE_ENV: " + process.env.NODE_ENV); // Load arguments const args = this.config = 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, } }); // Load from environment variables or default values if args are not set args.sslKey = args.sslKey || process.env.DOCKGE_SSL_KEY || undefined; args.sslCert = args.sslCert || process.env.DOCKGE_SSL_CERT || undefined; args.sslKeyPassphrase = args.sslKeyPassphrase || process.env.DOCKGE_SSL_KEY_PASSPHRASE || undefined; args.port = args.port || parseInt(process.env.DOCKGE_PORT) || 5001; args.hostname = args.hostname || process.env.DOCKGE_HOSTNAME || undefined; args.dataDir = args.dataDir || process.env.DOCKGE_DATA_DIR || "./data/"; log.debug("server", args); this.packageJSON = packageJSON as PackageJson; this.initDataDir(); this.terminal = new Terminal(this); } /** * */ 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; } // Create express this.app = express(); 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); } try { this.indexHTML = fs.readFileSync("./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 'dist/index.html', did you install correctly?"); process.exit(1); } } for (const router of this.routerList) { this.app.use(router.create(this.app, this)); } 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); } }); // 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}`); } }); } /** * 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 { 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) { 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() { // Check if a directory if (!fs.lstatSync(this.config.dataDir).isDirectory()) { throw new Error(`Fatal error: ${this.config.dataDir} is not a directory`); } if (! fs.existsSync(this.config.dataDir)) { fs.mkdirSync(this.config.dataDir, { 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; } }