mirror of
https://github.com/louislam/dockge.git
synced 2026-05-21 14:02:17 +00:00
Init (#1)
This commit is contained in:
559
backend/dockge-server.ts
Normal file
559
backend/dockge-server.ts
Normal file
@@ -0,0 +1,559 @@
|
||||
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<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,
|
||||
},
|
||||
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 || 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/";
|
||||
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"));
|
||||
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) {
|
||||
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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
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<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
|
||||
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 roomList = this.io.sockets.adapter.rooms.keys();
|
||||
let map : Map<string, object> | 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!");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user