mirror of
https://github.com/louislam/dockge.git
synced 2026-05-21 14:02:17 +00:00
wip
This commit is contained in:
71
backend/check-version.ts
Normal file
71
backend/check-version.ts
Normal 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
247
backend/database.ts
Normal 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
3
backend/docker.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export class Docker {
|
||||
|
||||
}
|
||||
381
backend/dockge-server.ts
Normal file
381
backend/dockge-server.ts
Normal 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
6
backend/index.ts
Normal 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
208
backend/log.ts
Normal 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();
|
||||
14
backend/migrations/2023-10-20-0829-setting-table.ts
Normal file
14
backend/migrations/2023-10-20-0829-setting-table.ts
Normal 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");
|
||||
}
|
||||
19
backend/migrations/2023-10-20-0829-user-table.ts
Normal file
19
backend/migrations/2023-10-20-0829-user-table.ts
Normal 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
44
backend/models/user.ts
Normal 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
47
backend/password-hash.ts
Normal 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
75
backend/rate-limiter.ts
Normal 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
6
backend/router.ts
Normal 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;
|
||||
}
|
||||
16
backend/routers/main-router.ts
Normal file
16
backend/routers/main-router.ts
Normal 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
174
backend/settings.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
6
backend/socket-handler.ts
Normal file
6
backend/socket-handler.ts
Normal 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;
|
||||
}
|
||||
61
backend/socket-handlers/docker-socket-handler.ts
Normal file
61
backend/socket-handlers/docker-socket-handler.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
}
|
||||
220
backend/socket-handlers/main-socket-handler.ts
Normal file
220
backend/socket-handlers/main-socket-handler.ts
Normal 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
43
backend/terminal.ts
Normal 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
118
backend/util-common.ts
Normal 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
11
backend/util-server.ts
Normal 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.");
|
||||
}
|
||||
}
|
||||
24
backend/utils/limit-queue.ts
Normal file
24
backend/utils/limit-queue.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user