This commit is contained in:
Louis Lam
2023-10-23 19:30:58 +08:00
parent 205bff2359
commit 5f70fa6baf
64 changed files with 10431 additions and 0 deletions

71
backend/check-version.ts Normal file
View File

@@ -0,0 +1,71 @@
import { log } from "./log";
import compareVersions from "compare-versions";
import packageJSON from "../package.json";
import { Settings } from "./settings";
export const obj = {
version: packageJSON.version,
latestVersion: null,
};
export default obj;
// How much time in ms to wait between update checks
const UPDATE_CHECKER_INTERVAL_MS = 1000 * 60 * 60 * 48;
const CHECK_URL = "https://uptime.kuma.pet/version";
let interval : NodeJS.Timeout;
export function startInterval() {
const check = async () => {
if (await Settings.get("checkUpdate") === false) {
return;
}
log.debug("update-checker", "Retrieving latest versions");
try {
const res = await fetch(CHECK_URL);
const data = await res.json();
// For debug
if (process.env.TEST_CHECK_VERSION === "1") {
data.slow = "1000.0.0";
}
const checkBeta = await Settings.get("checkBeta");
if (checkBeta && data.beta) {
if (compareVersions.compare(data.beta, data.slow, ">")) {
obj.latestVersion = data.beta;
return;
}
}
if (data.slow) {
obj.latestVersion = data.slow;
}
} catch (_) {
log.info("update-checker", "Failed to check for new versions");
}
};
check();
interval = setInterval(check, UPDATE_CHECKER_INTERVAL_MS);
}
/**
* Enable the check update feature
* @param value Should the check update feature be enabled?
* @returns
*/
export async function enableCheckUpdate(value : boolean) {
await Settings.set("checkUpdate", value);
clearInterval(interval);
if (value) {
startInterval();
}
}

247
backend/database.ts Normal file
View File

@@ -0,0 +1,247 @@
import { log } from "./log";
import { R } from "redbean-node";
import { DockgeServer } from "./dockge-server";
import fs from "fs";
import path from "path";
import knex from "knex";
import Dialect from "knex/lib/dialects/sqlite3/index.js";
import sqlite from "@louislam/sqlite3";
import { sleep } from "./util-common";
interface DBConfig {
type?: "sqlite" | "mysql";
}
export class Database {
/**
* SQLite file path (Default: ./data/kuma.db)
* @type {string}
*/
static sqlitePath;
static noReject = true;
static dbConfig: DBConfig = {};
static knexMigrationsPath = "./backend/migrations";
private static server : DockgeServer;
/**
* Use for decode the auth object
*/
jwtSecret? : string;
static async init(server : DockgeServer) {
this.server = server;
log.debug("server", "Connecting to the database");
await Database.connect();
log.info("server", "Connected to the database");
// Patch the database
await Database.patch();
}
/**
* Read the database config
* @throws {Error} If the config is invalid
* @typedef {string|undefined} envString
* @returns {{type: "sqlite"} | {type:envString, hostname:envString, port:envString, database:envString, username:envString, password:envString}} Database config
*/
static readDBConfig() {
const dbConfigString = fs.readFileSync(path.join(this.server.config.dataDir, "db-config.json")).toString("utf-8");
const dbConfig = JSON.parse(dbConfigString);
if (typeof dbConfig !== "object") {
throw new Error("Invalid db-config.json, it must be an object");
}
if (typeof dbConfig.type !== "string") {
throw new Error("Invalid db-config.json, type must be a string");
}
return dbConfig;
}
/**
* @typedef {string|undefined} envString
* @param {{type: "sqlite"} | {type:envString, hostname:envString, port:envString, database:envString, username:envString, password:envString}} dbConfig the database configuration that should be written
* @returns {void}
*/
static writeDBConfig(dbConfig) {
fs.writeFileSync(path.join(this.server.config.dataDir, "db-config.json"), JSON.stringify(dbConfig, null, 4));
}
/**
* Connect to the database
* @param {boolean} autoloadModels Should models be automatically loaded?
* @param {boolean} noLog Should logs not be output?
* @returns {Promise<void>}
*/
static async connect(autoloadModels = true, noLog = false) {
const acquireConnectionTimeout = 120 * 1000;
let dbConfig;
try {
dbConfig = this.readDBConfig();
Database.dbConfig = dbConfig;
} catch (err) {
log.warn("db", err.message);
dbConfig = {
type: "sqlite",
};
this.writeDBConfig(dbConfig);
}
let config = {};
log.info("db", `Database Type: ${dbConfig.type}`);
if (dbConfig.type === "sqlite") {
this.sqlitePath = path.join(this.server.config.dataDir, "dockge.db");
Dialect.prototype._driver = () => sqlite;
config = {
client: Dialect,
connection: {
filename: Database.sqlitePath,
acquireConnectionTimeout: acquireConnectionTimeout,
},
useNullAsDefault: true,
pool: {
min: 1,
max: 1,
idleTimeoutMillis: 120 * 1000,
propagateCreateError: false,
acquireTimeoutMillis: acquireConnectionTimeout,
}
};
} else {
throw new Error("Unknown Database type: " + dbConfig.type);
}
const knexInstance = knex(config);
// @ts-ignore
R.setup(knexInstance);
if (process.env.SQL_LOG === "1") {
R.debug(true);
}
// Auto map the model to a bean object
R.freeze(true);
if (autoloadModels) {
await R.autoloadModels("./server/model");
}
if (dbConfig.type === "sqlite") {
await this.initSQLite();
}
}
/**
@returns {Promise<void>}
*/
static async initSQLite() {
await R.exec("PRAGMA foreign_keys = ON");
// Change to WAL
await R.exec("PRAGMA journal_mode = WAL");
await R.exec("PRAGMA cache_size = -12000");
await R.exec("PRAGMA auto_vacuum = INCREMENTAL");
// This ensures that an operating system crash or power failure will not corrupt the database.
// FULL synchronous is very safe, but it is also slower.
// Read more: https://sqlite.org/pragma.html#pragma_synchronous
await R.exec("PRAGMA synchronous = NORMAL");
log.debug("db", "SQLite config:");
log.debug("db", await R.getAll("PRAGMA journal_mode"));
log.debug("db", await R.getAll("PRAGMA cache_size"));
log.debug("db", "SQLite Version: " + await R.getCell("SELECT sqlite_version()"));
}
/**
* Patch the database
* @returns {void}
*/
static async patch() {
// Using knex migrations
// https://knexjs.org/guide/migrations.html
// https://gist.github.com/NigelEarle/70db130cc040cc2868555b29a0278261
try {
await R.knex.migrate.latest({
directory: Database.knexMigrationsPath,
});
} catch (e) {
// Allow missing patch files for downgrade or testing pr.
if (e.message.includes("the following files are missing:")) {
log.warn("db", e.message);
log.warn("db", "Database migration failed, you may be downgrading Dockge.");
} else {
log.error("db", "Database migration failed");
throw e;
}
}
}
/**
* Special handle, because tarn.js throw a promise reject that cannot be caught
* @returns {Promise<void>}
*/
static async close() {
const listener = () => {
Database.noReject = false;
};
process.addListener("unhandledRejection", listener);
log.info("db", "Closing the database");
// Flush WAL to main database
if (Database.dbConfig.type === "sqlite") {
await R.exec("PRAGMA wal_checkpoint(TRUNCATE)");
}
while (true) {
Database.noReject = true;
await R.close();
await sleep(2000);
if (Database.noReject) {
break;
} else {
log.info("db", "Waiting to close the database");
}
}
log.info("db", "Database closed");
process.removeListener("unhandledRejection", listener);
}
/**
* Get the size of the database (SQLite only)
* @returns {number} Size of database
*/
static getSize() {
if (Database.dbConfig.type === "sqlite") {
log.debug("db", "Database.getSize()");
const stats = fs.statSync(Database.sqlitePath);
log.debug("db", stats);
return stats.size;
}
return 0;
}
/**
* Shrink the database
* @returns {Promise<void>}
*/
static async shrink() {
if (Database.dbConfig.type === "sqlite") {
await R.exec("VACUUM");
}
}
}

3
backend/docker.ts Normal file
View File

@@ -0,0 +1,3 @@
export class Docker {
}

381
backend/dockge-server.ts Normal file
View File

@@ -0,0 +1,381 @@
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<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,
}
});
// 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<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() {
// 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<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;
}
}

6
backend/index.ts Normal file
View File

@@ -0,0 +1,6 @@
import { DockgeServer } from "./dockge-server";
import { log } from "./log";
log.info("server", "Welcome to dockge!");
const server = new DockgeServer();
await server.serve();

208
backend/log.ts Normal file
View File

@@ -0,0 +1,208 @@
// Console colors
// https://stackoverflow.com/questions/9781218/how-to-change-node-jss-console-font-color
import { intHash, isDev } from "./util-common";
import dayjs from "dayjs";
export const CONSOLE_STYLE_Reset = "\x1b[0m";
export const CONSOLE_STYLE_Bright = "\x1b[1m";
export const CONSOLE_STYLE_Dim = "\x1b[2m";
export const CONSOLE_STYLE_Underscore = "\x1b[4m";
export const CONSOLE_STYLE_Blink = "\x1b[5m";
export const CONSOLE_STYLE_Reverse = "\x1b[7m";
export const CONSOLE_STYLE_Hidden = "\x1b[8m";
export const CONSOLE_STYLE_FgBlack = "\x1b[30m";
export const CONSOLE_STYLE_FgRed = "\x1b[31m";
export const CONSOLE_STYLE_FgGreen = "\x1b[32m";
export const CONSOLE_STYLE_FgYellow = "\x1b[33m";
export const CONSOLE_STYLE_FgBlue = "\x1b[34m";
export const CONSOLE_STYLE_FgMagenta = "\x1b[35m";
export const CONSOLE_STYLE_FgCyan = "\x1b[36m";
export const CONSOLE_STYLE_FgWhite = "\x1b[37m";
export const CONSOLE_STYLE_FgGray = "\x1b[90m";
export const CONSOLE_STYLE_FgOrange = "\x1b[38;5;208m";
export const CONSOLE_STYLE_FgLightGreen = "\x1b[38;5;119m";
export const CONSOLE_STYLE_FgLightBlue = "\x1b[38;5;117m";
export const CONSOLE_STYLE_FgViolet = "\x1b[38;5;141m";
export const CONSOLE_STYLE_FgBrown = "\x1b[38;5;130m";
export const CONSOLE_STYLE_FgPink = "\x1b[38;5;219m";
export const CONSOLE_STYLE_BgBlack = "\x1b[40m";
export const CONSOLE_STYLE_BgRed = "\x1b[41m";
export const CONSOLE_STYLE_BgGreen = "\x1b[42m";
export const CONSOLE_STYLE_BgYellow = "\x1b[43m";
export const CONSOLE_STYLE_BgBlue = "\x1b[44m";
export const CONSOLE_STYLE_BgMagenta = "\x1b[45m";
export const CONSOLE_STYLE_BgCyan = "\x1b[46m";
export const CONSOLE_STYLE_BgWhite = "\x1b[47m";
export const CONSOLE_STYLE_BgGray = "\x1b[100m";
const consoleModuleColors = [
CONSOLE_STYLE_FgCyan,
CONSOLE_STYLE_FgGreen,
CONSOLE_STYLE_FgLightGreen,
CONSOLE_STYLE_FgBlue,
CONSOLE_STYLE_FgLightBlue,
CONSOLE_STYLE_FgMagenta,
CONSOLE_STYLE_FgOrange,
CONSOLE_STYLE_FgViolet,
CONSOLE_STYLE_FgBrown,
CONSOLE_STYLE_FgPink,
];
const consoleLevelColors : Record<string, string> = {
"INFO": CONSOLE_STYLE_FgCyan,
"WARN": CONSOLE_STYLE_FgYellow,
"ERROR": CONSOLE_STYLE_FgRed,
"DEBUG": CONSOLE_STYLE_FgGray,
};
class Logger {
/**
* DOCKGE_HIDE_LOG=debug_monitor,info_monitor
*
* Example:
* [
* "debug_monitor", // Hide all logs that level is debug and the module is monitor
* "info_monitor",
* ]
*/
hideLog : Record<string, string[]> = {
info: [],
warn: [],
error: [],
debug: [],
};
/**
*
*/
constructor() {
if (typeof process !== "undefined" && process.env.DOCKGE_HIDE_LOG) {
const list = process.env.DOCKGE_HIDE_LOG.split(",").map(v => v.toLowerCase());
for (const pair of list) {
// split first "_" only
const values = pair.split(/_(.*)/s);
if (values.length >= 2) {
this.hideLog[values[0]].push(values[1]);
}
}
this.debug("server", "DOCKGE_HIDE_LOG is set");
this.debug("server", this.hideLog);
}
}
/**
* Write a message to the log
* @param module The module the log comes from
* @param msg Message to write
* @param level Log level. One of INFO, WARN, ERROR, DEBUG or can be customized.
*/
log(module: string, msg: unknown, level: string) {
if (this.hideLog[level] && this.hideLog[level].includes(module.toLowerCase())) {
return;
}
module = module.toUpperCase();
level = level.toUpperCase();
let now;
if (dayjs.tz) {
now = dayjs.tz(new Date()).format();
} else {
now = dayjs().format();
}
const levelColor = consoleLevelColors[level];
const moduleColor = consoleModuleColors[intHash(module, consoleModuleColors.length)];
let timePart = CONSOLE_STYLE_FgCyan + now + CONSOLE_STYLE_Reset;
const modulePart = "[" + moduleColor + module + CONSOLE_STYLE_Reset + "]";
const levelPart = levelColor + `${level}:` + CONSOLE_STYLE_Reset;
if (level === "INFO") {
console.info(timePart, modulePart, levelPart, msg);
} else if (level === "WARN") {
console.warn(timePart, modulePart, levelPart, msg);
} else if (level === "ERROR") {
let msgPart : unknown;
if (typeof msg === "string") {
msgPart = CONSOLE_STYLE_FgRed + msg + CONSOLE_STYLE_Reset;
} else {
msgPart = msg;
}
console.error(timePart, modulePart, levelPart, msgPart);
} else if (level === "DEBUG") {
if (isDev) {
timePart = CONSOLE_STYLE_FgGray + now + CONSOLE_STYLE_Reset;
let msgPart : unknown;
if (typeof msg === "string") {
msgPart = CONSOLE_STYLE_FgGray + msg + CONSOLE_STYLE_Reset;
} else {
msgPart = msg;
}
console.debug(timePart, modulePart, levelPart, msgPart);
}
} else {
console.log(timePart, modulePart, msg);
}
}
/**
* Log an INFO message
* @param module Module log comes from
* @param msg Message to write
*/
info(module: string, msg: unknown) {
this.log(module, msg, "info");
}
/**
* Log a WARN message
* @param module Module log comes from
* @param msg Message to write
*/
warn(module: string, msg: unknown) {
this.log(module, msg, "warn");
}
/**
* Log an ERROR message
* @param module Module log comes from
* @param msg Message to write
*/
error(module: string, msg: unknown) {
this.log(module, msg, "error");
}
/**
* Log a DEBUG message
* @param module Module log comes from
* @param msg Message to write
*/
debug(module: string, msg: unknown) {
this.log(module, msg, "debug");
}
/**
* Log an exception as an ERROR
* @param module Module log comes from
* @param exception The exception to include
* @param msg The message to write
*/
exception(module: string, exception: unknown, msg: unknown) {
let finalMessage = exception;
if (msg) {
finalMessage = `${msg}: ${exception}`;
}
this.log(module, finalMessage, "error");
}
}
export const log = new Logger();

View File

@@ -0,0 +1,14 @@
import { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
return knex.schema.createTable("setting", (table) => {
table.increments("id");
table.string("key", 200).notNullable().unique().collate("utf8_general_ci");
table.text("value");
table.string("type", 20);
});
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.dropTable("setting");
}

View File

@@ -0,0 +1,19 @@
import { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
// Create the user table
return knex.schema.createTable("user", (table) => {
table.increments("id");
table.string("username", 255).notNullable().unique().collate("utf8_general_ci");
table.string("password", 255);
table.boolean("active").notNullable().defaultTo(true);
table.string("timezone", 150);
table.string("twofa_secret", 64);
table.boolean("twofa_status").notNullable().defaultTo(false);
table.string("twofa_last_token", 6);
});
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.dropTable("user");
}

44
backend/models/user.ts Normal file
View File

@@ -0,0 +1,44 @@
import jwt from "jsonwebtoken";
import { R } from "redbean-node";
import { BeanModel } from "redbean-node/dist/bean-model";
import { generatePasswordHash, shake256, SHAKE256_LENGTH } from "../password-hash";
export class User extends BeanModel {
/**
* Reset user password
* Fix #1510, as in the context reset-password.js, there is no auto model mapping. Call this static function instead.
* @param {number} userID ID of user to update
* @param {string} newPassword Users new password
* @returns {Promise<void>}
*/
static async resetPassword(userID : number, newPassword : string) {
await R.exec("UPDATE `user` SET password = ? WHERE id = ? ", [
generatePasswordHash(newPassword),
userID
]);
}
/**
* Reset this users password
* @param {string} newPassword Users new password
* @returns {Promise<void>}
*/
async resetPassword(newPassword : string) {
await User.resetPassword(this.id, newPassword);
this.password = newPassword;
}
/**
* Create a new JWT for a user
* @param {User} user The User to create a JsonWebToken for
* @param {string} jwtSecret The key used to sign the JsonWebToken
* @returns {string} the JsonWebToken as a string
*/
static createJWT(user : User, jwtSecret : string) {
return jwt.sign({
username: user.username,
h: shake256(user.password, SHAKE256_LENGTH),
}, jwtSecret);
}
}

47
backend/password-hash.ts Normal file
View File

@@ -0,0 +1,47 @@
import bcrypt from "bcryptjs";
import crypto from "crypto";
const saltRounds = 10;
/**
* Hash a password
* @param {string} password Password to hash
* @returns {string} Hash
*/
export function generatePasswordHash(password : string) {
return bcrypt.hashSync(password, saltRounds);
}
/**
* Verify a password against a hash
* @param {string} password Password to verify
* @param {string} hash Hash to verify against
* @returns {boolean} Does the password match the hash?
*/
export function verifyPassword(password, hash) {
return bcrypt.compareSync(password, hash);
}
/**
* Does the hash need to be rehashed?
* @param {string} hash Hash to check
* @returns {boolean} Needs to be rehashed?
*/
export function needRehashPassword(hash : string) : boolean {
return false;
}
export const SHAKE256_LENGTH = 16;
/**
* @param {string} data The data to be hashed
* @param {number} len Output length of the hash
* @returns {string} The hashed data in hex format
*/
export function shake256(data, len) {
if (!data) {
return "";
}
return crypto.createHash("shake256", { outputLength: len })
.update(data)
.digest("hex");
}

75
backend/rate-limiter.ts Normal file
View File

@@ -0,0 +1,75 @@
// "limit" is bugged in Typescript, use "limiter-es6-compat" instead
// See https://github.com/jhurliman/node-rate-limiter/issues/80
import { RateLimiter } from "limiter-es6-compat";
import { log } from "./log";
class KumaRateLimiter {
errorMessage : string;
rateLimiter : RateLimiter;
/**
* @param {object} config Rate limiter configuration object
*/
constructor(config) {
this.errorMessage = config.errorMessage;
this.rateLimiter = new RateLimiter(config);
}
/**
* Callback for pass
* @callback passCB
* @param {object} err Too many requests
*/
/**
* Should the request be passed through
* @param {passCB} callback Callback function to call with decision
* @param {number} num Number of tokens to remove
* @returns {Promise<boolean>} Should the request be allowed?
*/
async pass(callback, num = 1) {
const remainingRequests = await this.removeTokens(num);
log.info("rate-limit", "remaining requests: " + remainingRequests);
if (remainingRequests < 0) {
if (callback) {
callback({
ok: false,
msg: this.errorMessage,
});
}
return false;
}
return true;
}
/**
* Remove a given number of tokens
* @param {number} num Number of tokens to remove
* @returns {Promise<number>} Number of remaining tokens
*/
async removeTokens(num = 1) {
return await this.rateLimiter.removeTokens(num);
}
}
export const loginRateLimiter = new KumaRateLimiter({
tokensPerInterval: 20,
interval: "minute",
fireImmediately: true,
errorMessage: "Too frequently, try again later."
});
export const apiRateLimiter = new KumaRateLimiter({
tokensPerInterval: 60,
interval: "minute",
fireImmediately: true,
errorMessage: "Too frequently, try again later."
});
export const twoFaRateLimiter = new KumaRateLimiter({
tokensPerInterval: 30,
interval: "minute",
fireImmediately: true,
errorMessage: "Too frequently, try again later."
});

6
backend/router.ts Normal file
View File

@@ -0,0 +1,6 @@
import { DockgeServer } from "./dockge-server";
import { Express, Router as ExpressRouter } from "express";
export abstract class Router {
abstract create(app : Express, server : DockgeServer): ExpressRouter;
}

View File

@@ -0,0 +1,16 @@
import { DockgeServer } from "../dockgeServer";
import { Router } from "../router";
import express, { Express, Router as ExpressRouter } from "express";
export class MainRouter extends Router {
create(app: Express, server: DockgeServer): ExpressRouter {
const router = express.Router();
router.get("/", (req, res) => {
});
return router;
}
}

174
backend/settings.ts Normal file
View File

@@ -0,0 +1,174 @@
import { R } from "redbean-node";
import { log } from "./log";
export class Settings {
/**
* Example:
* {
* key1: {
* value: "value2",
* timestamp: 12345678
* },
* key2: {
* value: 2,
* timestamp: 12345678
* },
* }
* @type {{}}
*/
static cacheList = {
};
static cacheCleaner = null;
/**
* Retrieve value of setting based on key
* @param {string} key Key of setting to retrieve
* @returns {Promise<any>} Value
*/
static async get(key) {
// Start cache clear if not started yet
if (!Settings.cacheCleaner) {
Settings.cacheCleaner = setInterval(() => {
log.debug("settings", "Cache Cleaner is just started.");
for (key in Settings.cacheList) {
if (Date.now() - Settings.cacheList[key].timestamp > 60 * 1000) {
log.debug("settings", "Cache Cleaner deleted: " + key);
delete Settings.cacheList[key];
}
}
}, 60 * 1000);
}
// Query from cache
if (key in Settings.cacheList) {
const v = Settings.cacheList[key].value;
log.debug("settings", `Get Setting (cache): ${key}: ${v}`);
return v;
}
const value = await R.getCell("SELECT `value` FROM setting WHERE `key` = ? ", [
key,
]);
try {
const v = JSON.parse(value);
log.debug("settings", `Get Setting: ${key}: ${v}`);
Settings.cacheList[key] = {
value: v,
timestamp: Date.now()
};
return v;
} catch (e) {
return value;
}
}
/**
* Sets the specified setting to specified value
* @param {string} key Key of setting to set
* @param {any} value Value to set to
* @param {?string} type Type of setting
* @returns {Promise<void>}
*/
static async set(key, value, type = null) {
let bean = await R.findOne("setting", " `key` = ? ", [
key,
]);
if (!bean) {
bean = R.dispense("setting");
bean.key = key;
}
bean.type = type;
bean.value = JSON.stringify(value);
await R.store(bean);
Settings.deleteCache([ key ]);
}
/**
* Get settings based on type
* @param {string} type The type of setting
* @returns {Promise<Bean>} Settings
*/
static async getSettings(type) {
const list = await R.getAll("SELECT `key`, `value` FROM setting WHERE `type` = ? ", [
type,
]);
const result = {};
for (const row of list) {
try {
result[row.key] = JSON.parse(row.value);
} catch (e) {
result[row.key] = row.value;
}
}
return result;
}
/**
* Set settings based on type
* @param {string} type Type of settings to set
* @param {object} data Values of settings
* @returns {Promise<void>}
*/
static async setSettings(type, data) {
const keyList = Object.keys(data);
const promiseList = [];
for (const key of keyList) {
let bean = await R.findOne("setting", " `key` = ? ", [
key
]);
if (bean == null) {
bean = R.dispense("setting");
bean.type = type;
bean.key = key;
}
if (bean.type === type) {
bean.value = JSON.stringify(data[key]);
promiseList.push(R.store(bean));
}
}
await Promise.all(promiseList);
Settings.deleteCache(keyList);
}
/**
* Delete selected keys from settings cache
* @param {string[]} keyList Keys to remove
* @returns {void}
*/
static deleteCache(keyList) {
for (const key of keyList) {
delete Settings.cacheList[key];
}
}
/**
* Stop the cache cleaner if running
* @returns {void}
*/
static stopCacheCleaner() {
if (Settings.cacheCleaner) {
clearInterval(Settings.cacheCleaner);
Settings.cacheCleaner = null;
}
}
}

View File

@@ -0,0 +1,6 @@
import { DockgeServer } from "./dockge-server";
import { DockgeSocket } from "./util-server";
export abstract class SocketHandler {
abstract create(socket : DockgeSocket, server : DockgeServer): void;
}

View File

@@ -0,0 +1,61 @@
import { SocketHandler } from "../socket-handler.js";
import { DockgeServer } from "../dockge-server";
import { checkLogin, DockgeSocket } from "../util-server";
import { log } from "../log";
const allowedCommandList : string[] = [
"docker",
];
export class DockerSocketHandler extends SocketHandler {
create(socket : DockgeSocket, server : DockgeServer) {
socket.on("composeUp", async (compose, callback) => {
});
socket.on("terminalInput", async (cmd : unknown, errorCallback) => {
try {
checkLogin(socket);
if (typeof(cmd) !== "string") {
throw new Error("Command must be a string.");
}
// Check if the command is allowed
const cmdParts = cmd.split(" ");
const executable = cmdParts[0].trim();
log.debug("console", "Executable: " + executable);
log.debug("console", "Executable length: " + executable.length);
if (!allowedCommandList.includes(executable)) {
throw new Error("Command not allowed.");
}
server.terminal.write(cmd);
} catch (e) {
errorCallback({
ok: false,
msg: e.message,
});
}
});
// Setup
socket.on("getTerminalBuffer", async (callback) => {
try {
checkLogin(socket);
callback({
ok: true,
buffer: server.terminal.getBuffer(),
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
}
}

View File

@@ -0,0 +1,220 @@
import { SocketHandler } from "../socket-handler.js";
import { Socket } from "socket.io";
import { DockgeServer } from "../dockge-server";
import { log } from "../log";
import { R } from "redbean-node";
import { loginRateLimiter, twoFaRateLimiter } from "../rate-limiter";
import { generatePasswordHash, needRehashPassword, shake256, SHAKE256_LENGTH, verifyPassword } from "../password-hash";
import { User } from "../models/user";
import { DockgeSocket } from "../util-server";
import { passwordStrength } from "check-password-strength";
import jwt from "jsonwebtoken";
export class MainSocketHandler extends SocketHandler {
create(socket : DockgeSocket, server : DockgeServer) {
// ***************************
// Public Socket API
// ***************************
// Setup
socket.on("setup", async (username, password, callback) => {
try {
if (passwordStrength(password).value === "Too weak") {
throw new Error("Password is too weak. It should contain alphabetic and numeric characters. It must be at least 6 characters in length.");
}
if ((await R.knex("user").count("id as count").first()).count !== 0) {
throw new Error("Dockge has been initialized. If you want to run setup again, please delete the database.");
}
const user = R.dispense("user");
user.username = username;
user.password = generatePasswordHash(password);
await R.store(user);
server.needSetup = false;
callback({
ok: true,
msg: "successAdded",
msgi18n: true,
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
// Login by token
socket.on("loginByToken", async (token, callback) => {
const clientIP = await server.getClientIP(socket);
log.info("auth", `Login by token. IP=${clientIP}`);
try {
const decoded = jwt.verify(token, server.jwtSecret);
log.info("auth", "Username from JWT: " + decoded.username);
const user = await R.findOne("user", " username = ? AND active = 1 ", [
decoded.username,
]) as User;
if (user) {
// Check if the password changed
if (decoded.h !== shake256(user.password, SHAKE256_LENGTH)) {
throw new Error("The token is invalid due to password change or old token");
}
log.debug("auth", "afterLogin");
await this.afterLogin(socket, user);
log.debug("auth", "afterLogin ok");
log.info("auth", `Successfully logged in user ${decoded.username}. IP=${clientIP}`);
callback({
ok: true,
});
} else {
log.info("auth", `Inactive or deleted user ${decoded.username}. IP=${clientIP}`);
callback({
ok: false,
msg: "authUserInactiveOrDeleted",
msgi18n: true,
});
}
} catch (error) {
log.error("auth", `Invalid token. IP=${clientIP}`);
if (error.message) {
log.error("auth", error.message, `IP=${clientIP}`);
}
callback({
ok: false,
msg: "authInvalidToken",
msgi18n: true,
});
}
});
// Login
socket.on("login", async (data, callback) => {
const clientIP = await server.getClientIP(socket);
log.info("auth", `Login by username + password. IP=${clientIP}`);
// Checking
if (typeof callback !== "function") {
return;
}
if (!data) {
return;
}
// Login Rate Limit
if (!await loginRateLimiter.pass(callback)) {
log.info("auth", `Too many failed requests for user ${data.username}. IP=${clientIP}`);
return;
}
const user = await this.login(data.username, data.password);
if (user) {
if (user.twofa_status === 0) {
this.afterLogin(socket, user);
log.info("auth", `Successfully logged in user ${data.username}. IP=${clientIP}`);
callback({
ok: true,
token: User.createJWT(user, server.jwtSecret),
});
}
if (user.twofa_status === 1 && !data.token) {
log.info("auth", `2FA token required for user ${data.username}. IP=${clientIP}`);
callback({
tokenRequired: true,
});
}
if (data.token) {
const verify = notp.totp.verify(data.token, user.twofa_secret, twoFAVerifyOptions);
if (user.twofa_last_token !== data.token && verify) {
this.afterLogin(socket, user);
await R.exec("UPDATE `user` SET twofa_last_token = ? WHERE id = ? ", [
data.token,
socket.userID,
]);
log.info("auth", `Successfully logged in user ${data.username}. IP=${clientIP}`);
callback({
ok: true,
token: User.createJWT(user, server.jwtSecret),
});
} else {
log.warn("auth", `Invalid token provided for user ${data.username}. IP=${clientIP}`);
callback({
ok: false,
msg: "authInvalidToken",
msgi18n: true,
});
}
}
} else {
log.warn("auth", `Incorrect username or password for user ${data.username}. IP=${clientIP}`);
callback({
ok: false,
msg: "authIncorrectCreds",
msgi18n: true,
});
}
});
}
async afterLogin(socket : DockgeSocket, user : User) {
socket.userID = user.id;
socket.join(user.id + "");
socket.join("terminal");
}
async login(username : string, password : string) {
if (typeof username !== "string" || typeof password !== "string") {
return null;
}
const user = await R.findOne("user", " username = ? AND active = 1 ", [
username,
]);
if (user && verifyPassword(password, user.password)) {
// Upgrade the hash to bcrypt
if (needRehashPassword(user.password)) {
await R.exec("UPDATE `user` SET password = ? WHERE id = ? ", [
generatePasswordHash(password),
user.id,
]);
}
return user;
}
return null;
}
}

43
backend/terminal.ts Normal file
View File

@@ -0,0 +1,43 @@
import { DockgeServer } from "./dockge-server";
import * as os from "node:os";
import * as pty from "node-pty";
import { LimitQueue } from "./utils/limit-queue";
const shell = os.platform() === "win32" ? "pwsh.exe" : "bash";
export class Terminal {
ptyProcess;
private server : DockgeServer;
private buffer : LimitQueue<string> = new LimitQueue(100);
constructor(server : DockgeServer) {
this.server = server;
this.ptyProcess = pty.spawn(shell, [], {
name: "dockge-terminal",
cwd: "./tmp",
});
// this.ptyProcess.write("npm remove lodash\r");
//this.ptyProcess.write("npm install lodash\r");
this.ptyProcess.onData((data) => {
this.buffer.push(data);
this.server.io.to("terminal").emit("commandOutput", data);
});
}
write(input : string) {
this.ptyProcess.write(input);
}
/**
* Get the terminal output string for re-connecting
*/
getBuffer() : string {
return this.buffer.join("");
}
}

118
backend/util-common.ts Normal file
View File

@@ -0,0 +1,118 @@
import dayjs from "dayjs";
// For loading dayjs plugins, don't remove event though it is not used in this file
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { randomBytes } from "crypto";
export const isDev = process.env.NODE_ENV === "development";
/**
* Generate a decimal integer number from a string
* @param str Input
* @param length Default is 10 which means 0 - 9
*/
export function intHash(str : string, length = 10) : number {
// A simple hashing function (you can use more complex hash functions if needed)
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash += str.charCodeAt(i);
}
// Normalize the hash to the range [0, 10]
return (hash % length + length) % length; // Ensure the result is non-negative
}
/**
* Delays for specified number of seconds
* @param ms Number of milliseconds to sleep for
*/
export function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Generate a random alphanumeric string of fixed length
* @param length Length of string to generate
* @returns string
*/
export function genSecret(length = 64) {
let secret = "";
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const charsLength = chars.length;
for ( let i = 0; i < length; i++ ) {
secret += chars.charAt(getCryptoRandomInt(0, charsLength - 1));
}
return secret;
}
/**
* Get a random integer suitable for use in cryptography between upper
* and lower bounds.
* @param min Minimum value of integer
* @param max Maximum value of integer
* @returns Cryptographically suitable random integer
*/
export function getCryptoRandomInt(min: number, max: number):number {
// synchronous version of: https://github.com/joepie91/node-random-number-csprng
const range = max - min;
if (range >= Math.pow(2, 32)) {
console.log("Warning! Range is too large.");
}
let tmpRange = range;
let bitsNeeded = 0;
let bytesNeeded = 0;
let mask = 1;
while (tmpRange > 0) {
if (bitsNeeded % 8 === 0) {
bytesNeeded += 1;
}
bitsNeeded += 1;
mask = mask << 1 | 1;
tmpRange = tmpRange >>> 1;
}
const randomBytes = getRandomBytes(bytesNeeded);
let randomValue = 0;
for (let i = 0; i < bytesNeeded; i++) {
randomValue |= randomBytes[i] << 8 * i;
}
randomValue = randomValue & mask;
if (randomValue <= range) {
return min + randomValue;
} else {
return getCryptoRandomInt(min, max);
}
}
/**
* Returns either the NodeJS crypto.randomBytes() function or its
* browser equivalent implemented via window.crypto.getRandomValues()
*/
const getRandomBytes = (
(typeof window !== "undefined" && window.crypto)
// Browsers
? function () {
return (numBytes: number) => {
const randomBytes = new Uint8Array(numBytes);
for (let i = 0; i < numBytes; i += 65536) {
window.crypto.getRandomValues(randomBytes.subarray(i, i + Math.min(numBytes - i, 65536)));
}
return randomBytes;
};
}
// Node
: function () {
// eslint-disable-next-line @typescript-eslint/no-var-requires
return randomBytes;
}
)();

11
backend/util-server.ts Normal file
View File

@@ -0,0 +1,11 @@
import { Socket } from "socket.io";
export interface DockgeSocket extends Socket {
userID: number;
}
export function checkLogin(socket : DockgeSocket) {
if (!socket.userID) {
throw new Error("You are not logged in.");
}
}

View File

@@ -0,0 +1,24 @@
/**
* Limit Queue
* The first element will be removed when the length exceeds the limit
*/
export class LimitQueue<T> extends Array<T> {
__limit;
__onExceed = null;
constructor(limit: number) {
super();
this.__limit = limit;
}
push(value : T) {
super.push(value);
if (this.length > this.__limit) {
const item = this.shift();
if (this.__onExceed) {
this.__onExceed(item);
}
}
}
}