mirror of
https://github.com/louislam/dockge.git
synced 2026-05-21 22:12:17 +00:00
Merge branch 'master' into close-terminal
# Conflicts: # backend/terminal.ts
This commit is contained in:
@@ -5,6 +5,7 @@ import fs from "fs";
|
||||
import path from "path";
|
||||
import knex from "knex";
|
||||
|
||||
// @ts-ignore
|
||||
import Dialect from "knex/lib/dialects/sqlite3/index.js";
|
||||
|
||||
import sqlite from "@louislam/sqlite3";
|
||||
@@ -12,6 +13,11 @@ import { sleep } from "./util-common";
|
||||
|
||||
interface DBConfig {
|
||||
type?: "sqlite" | "mysql";
|
||||
hostname?: string;
|
||||
port?: string;
|
||||
database?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export class Database {
|
||||
@@ -19,7 +25,7 @@ export class Database {
|
||||
* SQLite file path (Default: ./data/dockge.db)
|
||||
* @type {string}
|
||||
*/
|
||||
static sqlitePath;
|
||||
static sqlitePath : string;
|
||||
|
||||
static noReject = true;
|
||||
|
||||
@@ -51,7 +57,7 @@ export class Database {
|
||||
* @typedef {string|undefined} envString
|
||||
* @returns {{type: "sqlite"} | {type:envString, hostname:envString, port:envString, database:envString, username:envString, password:envString}} Database config
|
||||
*/
|
||||
static readDBConfig() {
|
||||
static readDBConfig() : DBConfig {
|
||||
const dbConfigString = fs.readFileSync(path.join(this.server.config.dataDir, "db-config.json")).toString("utf-8");
|
||||
const dbConfig = JSON.parse(dbConfigString);
|
||||
|
||||
@@ -67,10 +73,10 @@ export class Database {
|
||||
|
||||
/**
|
||||
* @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
|
||||
* @param dbConfig the database configuration that should be written
|
||||
* @returns {void}
|
||||
*/
|
||||
static writeDBConfig(dbConfig) {
|
||||
static writeDBConfig(dbConfig : DBConfig) {
|
||||
fs.writeFileSync(path.join(this.server.config.dataDir, "db-config.json"), JSON.stringify(dbConfig, null, 4));
|
||||
}
|
||||
|
||||
@@ -80,14 +86,17 @@ export class Database {
|
||||
* @param {boolean} noLog Should logs not be output?
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async connect(autoloadModels = true, noLog = false) {
|
||||
static async connect(autoloadModels = true) {
|
||||
const acquireConnectionTimeout = 120 * 1000;
|
||||
let dbConfig;
|
||||
let dbConfig : DBConfig;
|
||||
try {
|
||||
dbConfig = this.readDBConfig();
|
||||
Database.dbConfig = dbConfig;
|
||||
} catch (err) {
|
||||
log.warn("db", err.message);
|
||||
if (err instanceof Error) {
|
||||
log.warn("db", err.message);
|
||||
}
|
||||
|
||||
dbConfig = {
|
||||
type: "sqlite",
|
||||
};
|
||||
@@ -176,13 +185,15 @@ export class Database {
|
||||
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;
|
||||
if (e instanceof Error) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export class DockgeServer {
|
||||
*/
|
||||
needSetup = false;
|
||||
|
||||
jwtSecret? : string;
|
||||
jwtSecret : string = "";
|
||||
|
||||
stacksDir : string = "";
|
||||
|
||||
@@ -130,7 +130,7 @@ export class DockgeServer {
|
||||
this.config.sslKey = args.sslKey || process.env.DOCKGE_SSL_KEY || undefined;
|
||||
this.config.sslCert = args.sslCert || process.env.DOCKGE_SSL_CERT || undefined;
|
||||
this.config.sslKeyPassphrase = args.sslKeyPassphrase || process.env.DOCKGE_SSL_KEY_PASSPHRASE || undefined;
|
||||
this.config.port = args.port || parseInt(process.env.DOCKGE_PORT) || 5001;
|
||||
this.config.port = args.port || Number(process.env.DOCKGE_PORT) || 5001;
|
||||
this.config.hostname = args.hostname || process.env.DOCKGE_HOSTNAME || undefined;
|
||||
this.config.dataDir = args.dataDir || process.env.DOCKGE_DATA_DIR || "./data/";
|
||||
this.config.stacksDir = args.stacksDir || process.env.DOCKGE_STACKS_DIR || defaultStacksDir;
|
||||
@@ -219,7 +219,7 @@ export class DockgeServer {
|
||||
log.debug("auth", "check auto login");
|
||||
if (await Settings.get("disableAuth")) {
|
||||
log.info("auth", "Disabled Auth: auto login to admin");
|
||||
this.afterLogin(socket as DockgeSocket, await R.findOne("user"));
|
||||
this.afterLogin(socket as DockgeSocket, await R.findOne("user") as User);
|
||||
socket.emit("autoLogin");
|
||||
} else {
|
||||
log.debug("auth", "need auth");
|
||||
@@ -259,7 +259,9 @@ export class DockgeServer {
|
||||
try {
|
||||
await Database.init(this);
|
||||
} catch (e) {
|
||||
log.error("server", "Failed to prepare your database: " + e.message);
|
||||
if (e instanceof Error) {
|
||||
log.error("server", "Failed to prepare your database: " + e.message);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -289,7 +291,7 @@ export class DockgeServer {
|
||||
}
|
||||
|
||||
// Listen
|
||||
this.httpServer.listen(5001, this.config.hostname, () => {
|
||||
this.httpServer.listen(this.config.port, this.config.hostname, () => {
|
||||
if (this.config.hostname) {
|
||||
log.info( "server", `Listening on ${this.config.hostname}:${this.config.port}`);
|
||||
} else {
|
||||
@@ -297,7 +299,7 @@ export class DockgeServer {
|
||||
}
|
||||
|
||||
// Run every 5 seconds
|
||||
const job = Cron("*/2 * * * * *", {
|
||||
Cron("*/2 * * * * *", {
|
||||
protect: true, // Enabled over-run protection.
|
||||
}, () => {
|
||||
log.debug("server", "Cron job running");
|
||||
@@ -382,7 +384,9 @@ export class DockgeServer {
|
||||
return process.env.TZ;
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn("timezone", e.message + " in process.env.TZ");
|
||||
if (e instanceof Error) {
|
||||
log.warn("timezone", e.message + " in process.env.TZ");
|
||||
}
|
||||
}
|
||||
|
||||
const timezone = await Settings.get("serverTimezone");
|
||||
@@ -395,7 +399,9 @@ export class DockgeServer {
|
||||
return timezone;
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn("timezone", e.message + " in settings");
|
||||
if (e instanceof Error) {
|
||||
log.warn("timezone", e.message + " in settings");
|
||||
}
|
||||
}
|
||||
|
||||
// Guess
|
||||
|
||||
@@ -103,6 +103,10 @@ class Logger {
|
||||
* @param level Log level. One of INFO, WARN, ERROR, DEBUG or can be customized.
|
||||
*/
|
||||
log(module: string, msg: unknown, level: string) {
|
||||
if (level === "DEBUG" && !isDev) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.hideLog[level] && this.hideLog[level].includes(module.toLowerCase())) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export function generatePasswordHash(password : string) {
|
||||
* @param {string} hash Hash to verify against
|
||||
* @returns {boolean} Does the password match the hash?
|
||||
*/
|
||||
export function verifyPassword(password, hash) {
|
||||
export function verifyPassword(password : string, hash : string) {
|
||||
return bcrypt.compareSync(password, hash);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export const SHAKE256_LENGTH = 16;
|
||||
* @param {number} len Output length of the hash
|
||||
* @returns {string} The hashed data in hex format
|
||||
*/
|
||||
export function shake256(data, len) {
|
||||
export function shake256(data : string, len : number) {
|
||||
if (!data) {
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
// "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 { RateLimiter, RateLimiterOpts } from "limiter-es6-compat";
|
||||
import { log } from "./log";
|
||||
|
||||
export interface KumaRateLimiterOpts extends RateLimiterOpts {
|
||||
errorMessage : string;
|
||||
}
|
||||
|
||||
export type KumaRateLimiterCallback = (err : object) => void;
|
||||
|
||||
class KumaRateLimiter {
|
||||
|
||||
errorMessage : string;
|
||||
@@ -11,7 +17,7 @@ class KumaRateLimiter {
|
||||
/**
|
||||
* @param {object} config Rate limiter configuration object
|
||||
*/
|
||||
constructor(config) {
|
||||
constructor(config : KumaRateLimiterOpts) {
|
||||
this.errorMessage = config.errorMessage;
|
||||
this.rateLimiter = new RateLimiter(config);
|
||||
}
|
||||
@@ -24,11 +30,11 @@ class KumaRateLimiter {
|
||||
|
||||
/**
|
||||
* Should the request be passed through
|
||||
* @param {passCB} callback Callback function to call with decision
|
||||
* @param 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) {
|
||||
async pass(callback : KumaRateLimiterCallback, num = 1) {
|
||||
const remainingRequests = await this.removeTokens(num);
|
||||
log.info("rate-limit", "remaining requests: " + remainingRequests);
|
||||
if (remainingRequests < 0) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DockgeServer } from "../dockgeServer";
|
||||
import { DockgeServer } from "../dockge-server";
|
||||
import { Router } from "../router";
|
||||
import express, { Express, Router as ExpressRouter } from "express";
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { R } from "redbean-node";
|
||||
import { log } from "./log";
|
||||
import { LooseObject } from "./util-common";
|
||||
|
||||
export class Settings {
|
||||
|
||||
@@ -15,20 +16,19 @@ export class Settings {
|
||||
* timestamp: 12345678
|
||||
* },
|
||||
* }
|
||||
* @type {{}}
|
||||
*/
|
||||
static cacheList = {
|
||||
static cacheList : LooseObject = {
|
||||
|
||||
};
|
||||
|
||||
static cacheCleaner = null;
|
||||
static cacheCleaner? : NodeJS.Timeout;
|
||||
|
||||
/**
|
||||
* Retrieve value of setting based on key
|
||||
* @param {string} key Key of setting to retrieve
|
||||
* @returns {Promise<any>} Value
|
||||
* @param key Key of setting to retrieve
|
||||
* @returns Value
|
||||
*/
|
||||
static async get(key) {
|
||||
static async get(key : string) {
|
||||
|
||||
// Start cache clear if not started yet
|
||||
if (!Settings.cacheCleaner) {
|
||||
@@ -72,12 +72,12 @@ export class Settings {
|
||||
|
||||
/**
|
||||
* Sets the specified setting to specified value
|
||||
* @param {string} key Key of setting to set
|
||||
* @param {any} value Value to set to
|
||||
* @param key Key of setting to set
|
||||
* @param value Value to set to
|
||||
* @param {?string} type Type of setting
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async set(key, value, type = null) {
|
||||
static async set(key : string, value : object | string | number | boolean, type : string | null = null) {
|
||||
|
||||
let bean = await R.findOne("setting", " `key` = ? ", [
|
||||
key,
|
||||
@@ -95,15 +95,15 @@ export class Settings {
|
||||
|
||||
/**
|
||||
* Get settings based on type
|
||||
* @param {string} type The type of setting
|
||||
* @returns {Promise<Bean>} Settings
|
||||
* @param type The type of setting
|
||||
* @returns Settings
|
||||
*/
|
||||
static async getSettings(type) {
|
||||
static async getSettings(type : string) {
|
||||
const list = await R.getAll("SELECT `key`, `value` FROM setting WHERE `type` = ? ", [
|
||||
type,
|
||||
]);
|
||||
|
||||
const result = {};
|
||||
const result : LooseObject = {};
|
||||
|
||||
for (const row of list) {
|
||||
try {
|
||||
@@ -118,11 +118,11 @@ export class Settings {
|
||||
|
||||
/**
|
||||
* Set settings based on type
|
||||
* @param {string} type Type of settings to set
|
||||
* @param {object} data Values of settings
|
||||
* @param type Type of settings to set
|
||||
* @param data Values of settings
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async setSettings(type, data) {
|
||||
static async setSettings(type : string, data : LooseObject) {
|
||||
const keyList = Object.keys(data);
|
||||
|
||||
const promiseList = [];
|
||||
@@ -154,7 +154,7 @@ export class Settings {
|
||||
* @param {string[]} keyList Keys to remove
|
||||
* @returns {void}
|
||||
*/
|
||||
static deleteCache(keyList) {
|
||||
static deleteCache(keyList : string[]) {
|
||||
for (const key of keyList) {
|
||||
delete Settings.cacheList[key];
|
||||
}
|
||||
@@ -167,7 +167,7 @@ export class Settings {
|
||||
static stopCacheCleaner() {
|
||||
if (Settings.cacheCleaner) {
|
||||
clearInterval(Settings.cacheCleaner);
|
||||
Settings.cacheCleaner = null;
|
||||
Settings.cacheCleaner = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +187,27 @@ export class DockerSocketHandler extends SocketHandler {
|
||||
}
|
||||
});
|
||||
|
||||
// down stack
|
||||
socket.on("downStack", async (stackName : unknown, callback) => {
|
||||
try {
|
||||
checkLogin(socket);
|
||||
|
||||
if (typeof(stackName) !== "string") {
|
||||
throw new ValidationError("Stack name must be a string");
|
||||
}
|
||||
|
||||
const stack = Stack.getStack(server, stackName);
|
||||
await stack.down(socket);
|
||||
callback({
|
||||
ok: true,
|
||||
msg: "Downed"
|
||||
});
|
||||
server.sendStackList();
|
||||
} catch (e) {
|
||||
callbackError(e, callback);
|
||||
}
|
||||
});
|
||||
|
||||
// Services status
|
||||
socket.on("serviceStatusList", async (stackName : unknown, callback) => {
|
||||
try {
|
||||
@@ -196,7 +217,7 @@ export class DockerSocketHandler extends SocketHandler {
|
||||
throw new ValidationError("Stack name must be a string");
|
||||
}
|
||||
|
||||
const stack = Stack.getStack(server, stackName);
|
||||
const stack = Stack.getStack(server, stackName, true);
|
||||
const serviceStatusList = Object.fromEntries(await stack.getServiceStatusList());
|
||||
callback({
|
||||
ok: true,
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
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 { checkLogin, DockgeSocket, doubleCheckPassword } from "../util-server";
|
||||
import { checkLogin, DockgeSocket, doubleCheckPassword, JWTDecoded } from "../util-server";
|
||||
import { passwordStrength } from "check-password-strength";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { Settings } from "../settings";
|
||||
@@ -43,10 +42,12 @@ export class MainSocketHandler extends SocketHandler {
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: e.message,
|
||||
});
|
||||
if (e instanceof Error) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -57,7 +58,7 @@ export class MainSocketHandler extends SocketHandler {
|
||||
log.info("auth", `Login by token. IP=${clientIP}`);
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, server.jwtSecret);
|
||||
const decoded = jwt.verify(token, server.jwtSecret) as JWTDecoded;
|
||||
|
||||
log.info("auth", "Username from JWT: " + decoded.username);
|
||||
|
||||
@@ -91,9 +92,13 @@ export class MainSocketHandler extends SocketHandler {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) {
|
||||
console.error("Unknown error:", error);
|
||||
return;
|
||||
}
|
||||
log.error("auth", `Invalid token. IP=${clientIP}`);
|
||||
if (error.message) {
|
||||
log.error("auth", error.message, `IP=${clientIP}`);
|
||||
log.error("auth", error.message + ` IP=${clientIP}`);
|
||||
}
|
||||
callback({
|
||||
ok: false,
|
||||
@@ -149,6 +154,7 @@ export class MainSocketHandler extends SocketHandler {
|
||||
}
|
||||
|
||||
if (data.token) {
|
||||
// @ts-ignore
|
||||
const verify = notp.totp.verify(data.token, user.twofa_secret, twoFAVerifyOptions);
|
||||
|
||||
if (user.twofa_last_token !== data.token && verify) {
|
||||
@@ -211,10 +217,12 @@ export class MainSocketHandler extends SocketHandler {
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: e.message,
|
||||
});
|
||||
if (e instanceof Error) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -229,10 +237,12 @@ export class MainSocketHandler extends SocketHandler {
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: e.message,
|
||||
});
|
||||
if (e instanceof Error) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -262,22 +272,24 @@ export class MainSocketHandler extends SocketHandler {
|
||||
server.sendInfo(socket);
|
||||
|
||||
} catch (e) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: e.message,
|
||||
});
|
||||
if (e instanceof Error) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async login(username : string, password : string) {
|
||||
async login(username : string, password : string) : Promise<User | null> {
|
||||
if (typeof username !== "string" || typeof password !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await R.findOne("user", " username = ? AND active = 1 ", [
|
||||
username,
|
||||
]);
|
||||
]) as User;
|
||||
|
||||
if (user && verifyPassword(password, user.password)) {
|
||||
// Upgrade the hash to bcrypt
|
||||
|
||||
@@ -38,10 +38,12 @@ export class TerminalSocketHandler extends SocketHandler {
|
||||
throw new Error("Terminal not found or it is not a Interactive Terminal.");
|
||||
}
|
||||
} catch (e) {
|
||||
errorCallback({
|
||||
ok: false,
|
||||
msg: e.message,
|
||||
});
|
||||
if (e instanceof Error) {
|
||||
errorCallback({
|
||||
ok: false,
|
||||
msg: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
117
backend/stack.ts
117
backend/stack.ts
@@ -24,16 +24,28 @@ export class Stack {
|
||||
protected _status: number = UNKNOWN;
|
||||
protected _composeYAML?: string;
|
||||
protected _configFilePath?: string;
|
||||
protected _composeFileName: string = "compose.yaml";
|
||||
protected server: DockgeServer;
|
||||
|
||||
protected combinedTerminal? : Terminal;
|
||||
|
||||
protected static managedStackList: Map<string, Stack> = new Map();
|
||||
|
||||
constructor(server : DockgeServer, name : string, composeYAML? : string) {
|
||||
constructor(server : DockgeServer, name : string, composeYAML? : string, skipFSOperations = false) {
|
||||
this.name = name;
|
||||
this.server = server;
|
||||
this._composeYAML = composeYAML;
|
||||
|
||||
if (!skipFSOperations) {
|
||||
// Check if compose file name is different from compose.yaml
|
||||
const supportedFileNames = [ "compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml" ];
|
||||
for (const filename of supportedFileNames) {
|
||||
if (fs.existsSync(path.join(this.path, filename))) {
|
||||
this._composeFileName = filename;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() : object {
|
||||
@@ -50,6 +62,7 @@ export class Stack {
|
||||
status: this._status,
|
||||
tags: [],
|
||||
isManagedByDockge: this.isManagedByDockge,
|
||||
composeFileName: this._composeFileName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,7 +97,7 @@ export class Stack {
|
||||
get composeYAML() : string {
|
||||
if (this._composeYAML === undefined) {
|
||||
try {
|
||||
this._composeYAML = fs.readFileSync(path.join(this.path, "compose.yaml"), "utf-8");
|
||||
this._composeYAML = fs.readFileSync(path.join(this.path, this._composeFileName), "utf-8");
|
||||
} catch (e) {
|
||||
this._composeYAML = "";
|
||||
}
|
||||
@@ -135,7 +148,7 @@ export class Stack {
|
||||
}
|
||||
|
||||
// Write or overwrite the compose.yaml
|
||||
fs.writeFileSync(path.join(dir, "compose.yaml"), this.composeYAML);
|
||||
fs.writeFileSync(path.join(dir, this._composeFileName), this.composeYAML);
|
||||
}
|
||||
|
||||
async deploy(socket? : DockgeSocket) : Promise<number> {
|
||||
@@ -163,10 +176,22 @@ export class Stack {
|
||||
return exitCode;
|
||||
}
|
||||
|
||||
updateStatus() {
|
||||
let statusList = Stack.getStatusList();
|
||||
let status = statusList.get(this.name);
|
||||
|
||||
if (status) {
|
||||
this._status = status;
|
||||
} else {
|
||||
this._status = UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
static getStackList(server : DockgeServer, useCacheForManaged = false) : Map<string, Stack> {
|
||||
let stacksDir = server.stacksDir;
|
||||
let stackList : Map<string, Stack>;
|
||||
|
||||
// Use cached stack list?
|
||||
if (useCacheForManaged && this.managedStackList.size > 0) {
|
||||
stackList = this.managedStackList;
|
||||
} else {
|
||||
@@ -186,7 +211,9 @@ export class Stack {
|
||||
stack._status = CREATED_FILE;
|
||||
stackList.set(filename, stack);
|
||||
} catch (e) {
|
||||
log.warn("getStackList", `Failed to get stack ${filename}, error: ${e.message}`);
|
||||
if (e instanceof Error) {
|
||||
log.warn("getStackList", `Failed to get stack ${filename}, error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,22 +221,19 @@ export class Stack {
|
||||
this.managedStackList = new Map(stackList);
|
||||
}
|
||||
|
||||
// Also get the list from `docker compose ls --all --format json`
|
||||
// Get status from docker compose ls
|
||||
let res = childProcess.execSync("docker compose ls --all --format json");
|
||||
let composeList = JSON.parse(res.toString());
|
||||
|
||||
for (let composeStack of composeList) {
|
||||
|
||||
// Skip the dockge stack
|
||||
// TODO: Could be self managed?
|
||||
if (composeStack.Name === "dockge") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let stack = stackList.get(composeStack.Name);
|
||||
|
||||
// This stack probably is not managed by Dockge, but we still want to show it
|
||||
if (!stack) {
|
||||
// Skip the dockge stack if it is not managed by Dockge
|
||||
if (composeStack.Name === "dockge") {
|
||||
continue;
|
||||
}
|
||||
stack = new Stack(server, composeStack.Name);
|
||||
stackList.set(composeStack.Name, stack);
|
||||
}
|
||||
@@ -240,37 +264,51 @@ export class Stack {
|
||||
|
||||
/**
|
||||
* Convert the status string from `docker compose ls` to the status number
|
||||
* Input Example: "exited(1), running(1)"
|
||||
* @param status
|
||||
*/
|
||||
static statusConvert(status : string) : number {
|
||||
if (status.startsWith("created")) {
|
||||
return CREATED_STACK;
|
||||
} else if (status.startsWith("running")) {
|
||||
return RUNNING;
|
||||
} else if (status.startsWith("exited")) {
|
||||
} else if (status.includes("exited")) {
|
||||
// If one of the service is exited, we consider the stack is exited
|
||||
return EXITED;
|
||||
} else if (status.startsWith("running")) {
|
||||
// If there is no exited services, there should be only running services
|
||||
return RUNNING;
|
||||
} else {
|
||||
return UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
static getStack(server: DockgeServer, stackName: string) : Stack {
|
||||
static getStack(server: DockgeServer, stackName: string, skipFSOperations = false) : Stack {
|
||||
let dir = path.join(server.stacksDir, stackName);
|
||||
|
||||
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
||||
// Maybe it is a stack managed by docker compose directly
|
||||
let stackList = this.getStackList(server);
|
||||
let stack = stackList.get(stackName);
|
||||
if (!skipFSOperations) {
|
||||
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
||||
// Maybe it is a stack managed by docker compose directly
|
||||
let stackList = this.getStackList(server, true);
|
||||
let stack = stackList.get(stackName);
|
||||
|
||||
if (stack) {
|
||||
return stack;
|
||||
} else {
|
||||
// Really not found
|
||||
throw new ValidationError("Stack not found");
|
||||
if (stack) {
|
||||
return stack;
|
||||
} else {
|
||||
// Really not found
|
||||
throw new ValidationError("Stack not found");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.debug("getStack", "Skip FS operations");
|
||||
}
|
||||
|
||||
let stack : Stack;
|
||||
|
||||
if (!skipFSOperations) {
|
||||
stack = new Stack(server, stackName);
|
||||
} else {
|
||||
stack = new Stack(server, stackName, undefined, true);
|
||||
}
|
||||
|
||||
let stack = new Stack(server, stackName);
|
||||
stack._status = UNKNOWN;
|
||||
stack._configFilePath = path.resolve(dir);
|
||||
return stack;
|
||||
@@ -303,12 +341,29 @@ export class Stack {
|
||||
return exitCode;
|
||||
}
|
||||
|
||||
async down(socket: DockgeSocket) : Promise<number> {
|
||||
const terminalName = getComposeTerminalName(this.name);
|
||||
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "down" ], this.path);
|
||||
if (exitCode !== 0) {
|
||||
throw new Error("Failed to down, please check the terminal output for more information.");
|
||||
}
|
||||
return exitCode;
|
||||
}
|
||||
|
||||
async update(socket: DockgeSocket) {
|
||||
const terminalName = getComposeTerminalName(this.name);
|
||||
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "pull" ], this.path);
|
||||
if (exitCode !== 0) {
|
||||
throw new Error("Failed to pull, please check the terminal output for more information.");
|
||||
}
|
||||
|
||||
// If the stack is not running, we don't need to restart it
|
||||
this.updateStatus();
|
||||
log.debug("update", "Status: " + this.status);
|
||||
if (this.status !== RUNNING) {
|
||||
return exitCode;
|
||||
}
|
||||
|
||||
exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "up", "-d", "--remove-orphans" ], this.path);
|
||||
if (exitCode !== 0) {
|
||||
throw new Error("Failed to restart, please check the terminal output for more information.");
|
||||
@@ -342,16 +397,20 @@ export class Stack {
|
||||
async getServiceStatusList() {
|
||||
let statusList = new Map<string, number>();
|
||||
|
||||
let res = childProcess.execSync("docker compose ps --format json", {
|
||||
let res = childProcess.spawnSync("docker", [ "compose", "ps", "--format", "json" ], {
|
||||
cwd: this.path,
|
||||
});
|
||||
|
||||
let lines = res.toString().split("\n");
|
||||
let lines = res.stdout.toString().split("\n");
|
||||
|
||||
for (let line of lines) {
|
||||
try {
|
||||
let obj = JSON.parse(line);
|
||||
statusList.set(obj.Service, obj.State);
|
||||
if (obj.Health === "") {
|
||||
statusList.set(obj.Service, obj.State);
|
||||
} else {
|
||||
statusList.set(obj.Service, obj.Health);
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,70 +46,6 @@ export class Terminal {
|
||||
this.cwd = cwd;
|
||||
|
||||
Terminal.terminalMap.set(this.name, this);
|
||||
}
|
||||
|
||||
get rows() {
|
||||
return this._rows;
|
||||
}
|
||||
|
||||
set rows(rows : number) {
|
||||
this._rows = rows;
|
||||
try {
|
||||
this.ptyProcess?.resize(this.cols, this.rows);
|
||||
} catch (e) {
|
||||
log.debug("Terminal", "Failed to resize terminal: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
get cols() {
|
||||
return this._cols;
|
||||
}
|
||||
|
||||
set cols(cols : number) {
|
||||
this._cols = cols;
|
||||
try {
|
||||
this.ptyProcess?.resize(this.cols, this.rows);
|
||||
} catch (e) {
|
||||
log.debug("Terminal", "Failed to resize terminal: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
public start() {
|
||||
if (this._ptyProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._ptyProcess = pty.spawn(this.file, this.args, {
|
||||
name: this.name,
|
||||
cwd: this.cwd,
|
||||
cols: TERMINAL_COLS,
|
||||
rows: this.rows,
|
||||
});
|
||||
|
||||
// On Data
|
||||
this._ptyProcess.onData((data) => {
|
||||
this.buffer.push(data);
|
||||
if (this.server.io) {
|
||||
this.server.io.to(this.name).emit("terminalWrite", this.name, data);
|
||||
}
|
||||
});
|
||||
|
||||
// On Exit
|
||||
this._ptyProcess.onExit((res) => {
|
||||
this.server.io.to(this.name).emit("terminalExit", this.name, res.exitCode);
|
||||
|
||||
// Remove room
|
||||
this.server.io.in(this.name).socketsLeave(this.name);
|
||||
|
||||
Terminal.terminalMap.delete(this.name);
|
||||
log.debug("Terminal", "Terminal " + this.name + " exited with code " + res.exitCode);
|
||||
|
||||
clearInterval(this.keepAliveInterval);
|
||||
|
||||
if (this.callback) {
|
||||
this.callback(res.exitCode);
|
||||
}
|
||||
});
|
||||
|
||||
if (this.enableKeepAlive) {
|
||||
log.debug("Terminal", "Keep alive enabled for terminal " + this.name);
|
||||
@@ -131,6 +67,90 @@ export class Terminal {
|
||||
}
|
||||
}
|
||||
|
||||
get rows() {
|
||||
return this._rows;
|
||||
}
|
||||
|
||||
set rows(rows : number) {
|
||||
this._rows = rows;
|
||||
try {
|
||||
this.ptyProcess?.resize(this.cols, this.rows);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
log.debug("Terminal", "Failed to resize terminal: " + e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get cols() {
|
||||
return this._cols;
|
||||
}
|
||||
|
||||
set cols(cols : number) {
|
||||
this._cols = cols;
|
||||
try {
|
||||
this.ptyProcess?.resize(this.cols, this.rows);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
log.debug("Terminal", "Failed to resize terminal: " + e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public start() {
|
||||
if (this._ptyProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this._ptyProcess = pty.spawn(this.file, this.args, {
|
||||
name: this.name,
|
||||
cwd: this.cwd,
|
||||
cols: TERMINAL_COLS,
|
||||
rows: this.rows,
|
||||
});
|
||||
|
||||
// On Data
|
||||
this._ptyProcess.onData((data) => {
|
||||
this.buffer.pushItem(data);
|
||||
if (this.server.io) {
|
||||
this.server.io.to(this.name).emit("terminalWrite", this.name, data);
|
||||
}
|
||||
});
|
||||
|
||||
// On Exit
|
||||
this._ptyProcess.onExit(this.exit);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
log.error("Terminal", "Failed to start terminal: " + error.message);
|
||||
const exitCode = Number(error.message.split(" ").pop());
|
||||
this.exit({
|
||||
exitCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit event handler
|
||||
* @param res
|
||||
*/
|
||||
protected exit = (res : {exitCode: number, signal?: number | undefined}) => {
|
||||
this.server.io.to(this.name).emit("terminalExit", this.name, res.exitCode);
|
||||
|
||||
// Remove room
|
||||
this.server.io.in(this.name).socketsLeave(this.name);
|
||||
|
||||
Terminal.terminalMap.delete(this.name);
|
||||
log.debug("Terminal", "Terminal " + this.name + " exited with code " + res.exitCode);
|
||||
|
||||
clearInterval(this.keepAliveInterval);
|
||||
|
||||
if (this.callback) {
|
||||
this.callback(res.exitCode);
|
||||
}
|
||||
};
|
||||
|
||||
public onExit(callback : (exitCode : number) => void) {
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,11 @@ dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export interface LooseObject {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
let randomBytes : (numBytes: number) => Uint8Array;
|
||||
initRandomBytes();
|
||||
|
||||
|
||||
@@ -6,6 +6,11 @@ import { ERROR_TYPE_VALIDATION } from "./util-common";
|
||||
import { R } from "redbean-node";
|
||||
import { verifyPassword } from "./password-hash";
|
||||
|
||||
export interface JWTDecoded {
|
||||
username : string;
|
||||
h? : string;
|
||||
}
|
||||
|
||||
export interface DockgeSocket extends Socket {
|
||||
userID: number;
|
||||
consoleTerminal? : Terminal;
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
*/
|
||||
export class LimitQueue<T> extends Array<T> {
|
||||
__limit;
|
||||
__onExceed = null;
|
||||
__onExceed? : (item : T | undefined) => void;
|
||||
|
||||
constructor(limit: number) {
|
||||
super();
|
||||
this.__limit = limit;
|
||||
}
|
||||
|
||||
push(value : T) {
|
||||
pushItem(value : T) {
|
||||
super.push(value);
|
||||
if (this.length > this.__limit) {
|
||||
const item = this.shift();
|
||||
|
||||
Reference in New Issue
Block a user