[FEATURE] Basic System with file manager (#4)

Co-authored-by: dunemask <dunemask@gmail.com>
Co-authored-by: Dunemask <dunemask@gmail.com>
Reviewed-on: https://gitea.dunemask.dev/elysium/minecluster/pulls/4
This commit is contained in:
dunemask 2023-12-20 03:20:04 +00:00
parent 8fb5b34c77
commit 4f19cf19d9
62 changed files with 5910 additions and 1190 deletions

8
dist/app.js vendored
View file

@ -1,4 +1,3 @@
import stream from "stream";
import k8s from "@kubernetes/client-node"; import k8s from "@kubernetes/client-node";
import Minecluster from "../lib/Minecluster.js"; import Minecluster from "../lib/Minecluster.js";
const mcl = new Minecluster(); const mcl = new Minecluster();
@ -7,11 +6,6 @@ mcl.start();
async function main(){ async function main(){
const kc = new k8s.KubeConfig(); const kc = new k8s.KubeConfig();
kc.loadFromDefault(); kc.loadFromDefault();
/*const k8sApi = kc.makeApiClient(k8s.CoreV1Api);
const res = await k8sApi.listNamespacedPod('mc-garden-default');
const pods = res.body.items.map((vp1) => vp1.metadata.name);
console.log(pods);*/
} }
main().catch((e)=>{console.log(e)}); main().catch((e)=>{console.log(e)});

View file

@ -7,6 +7,7 @@ import { INFO, OK, logInfo } from "./util/logging.js";
// Import Core Modules // Import Core Modules
import buildRoutes from "./server/router.js"; import buildRoutes from "./server/router.js";
import injectSockets from "./server/sockets.js"; import injectSockets from "./server/sockets.js";
import pg from "./database/postgres.js";
// Constants // Constants
const title = "MCL"; const title = "MCL";
@ -23,6 +24,7 @@ export default class Minecluster {
logInfo(fig.textSync(title, "Larry 3D")); logInfo(fig.textSync(title, "Larry 3D"));
INFO("INIT", "Initializing..."); INFO("INIT", "Initializing...");
this.app = express(); this.app = express();
this.pg = pg;
this.server = http.createServer(this.app); this.server = http.createServer(this.app);
this.sockets = injectSockets(this.server, this.jobs); this.sockets = injectSockets(this.server, this.jobs);
this.routes = buildRoutes(this.sockets); this.routes = buildRoutes(this.sockets);
@ -31,11 +33,12 @@ export default class Minecluster {
} }
async _connect() { async _connect() {
// await this.pg.connect(); await this.pg.connect();
} }
start() { start() {
const mcl = this; const mcl = this;
return new Promise(async function init(res) { return new Promise(async function init(res) {
mcl._preinitialize(); mcl._preinitialize();
await mcl._connect(); await mcl._connect();

View file

@ -0,0 +1,70 @@
import {
createServerFolder,
getServerItem,
listServerFiles,
removeServerItem,
uploadServerItem,
} from "../k8s/server-files.js";
import { sendError } from "../util/ExpressClientError.js";
export async function listFiles(req, res) {
const serverSpec = req.body;
if (!serverSpec) return res.sendStatus(400);
if (!serverSpec.name) return res.status(400).send("Server name required!");
listServerFiles(serverSpec)
.then((f) => {
const fileData = f.map((fi, i) => ({
name: fi.name,
isDir: fi.type === 2,
id: `${fi.name}-${i}`,
isHidden: fi.name.startsWith("."),
isSymLink: !!fi.link,
size: fi.size,
}));
res.json(fileData);
})
.catch(sendError(res));
}
export async function createFolder(req, res) {
const serverSpec = req.body;
if (!serverSpec) return res.sendStatus(400);
if (!serverSpec.name) return res.status(400).send("Server name required!");
if (!serverSpec.path) return res.status(400).send("Path required!");
createServerFolder(serverSpec)
.then(() => res.sendStatus(200))
.catch(sendError(res));
}
export async function deleteItem(req, res) {
const serverSpec = req.body;
if (!serverSpec) return res.sendStatus(400);
if (!serverSpec.name) return res.status(400).send("Server name required!");
if (!serverSpec.path) return res.status(400).send("Path required!");
if (serverSpec.isDir === undefined || serverSpec.isDir === null)
return res.status(400).send("IsDIr required!");
removeServerItem(serverSpec)
.then(() => res.sendStatus(200))
.catch(sendError(res));
}
export async function uploadItem(req, res) {
const serverSpec = req.body;
if (!serverSpec.name) return res.status(400).send("Server name required!");
if (!serverSpec.path) return res.status(400).send("Path required!");
uploadServerItem(serverSpec, req.file)
.then(() => res.sendStatus(200))
.catch(sendError(res));
}
export async function getItem(req, res) {
const serverSpec = req.body;
if (!serverSpec.name) return res.status(400).send("Server name required!");
if (!serverSpec.path) return res.status(400).send("Path required!");
getServerItem(serverSpec, res)
.then(({ ds, ftpTransfer }) => {
ds.pipe(res).on("error", sendError(res));
return ftpTransfer;
})
.catch(sendError(res));
}

View file

@ -0,0 +1,93 @@
import createServerResources from "../k8s/server-create.js";
import deleteServerResources from "../k8s/server-delete.js";
import {
createServerEntry,
deleteServerEntry,
getServerEntry,
} from "../database/queries/server-queries.js";
import { sendError } from "../util/ExpressClientError.js";
import {
startServerContainer,
stopServerContainer,
} from "../k8s/server-control.js";
import { toggleServer } from "../k8s/k8s-server-control.js";
function payloadFilter(req, res) {
const serverSpec = req.body;
if (!serverSpec) return res.sendStatus(400);
const { name, host, version, serverType, difficulty, gamemode, memory } =
serverSpec;
if (!name) return res.status(400).send("Server name is required!");
if (!host) return res.status(400).send("Server host is required!");
if (!version) return res.status(400).send("Server version is required!");
if (!difficulty)
return res.status(400).send("Server difficulty is required!");
if (!serverType) return res.status(400).send("Server type is required!");
if (!gamemode) return res.status(400).send("Server Gamemode is required!");
if (!memory) return res.status(400).send("Memory is required!");
req.body.name = req.body.name.toLowerCase();
return "filtered";
}
function checkServerName(serverSpec) {
if (!serverSpec) throw new ExpressClientError({ c: 400 });
if (!serverSpec.name)
throw new ExpressClientError({ c: 400, m: "Server name required!" });
}
export async function createServer(req, res) {
if (payloadFilter(req, res) !== "filtered") return;
const serverSpec = req.body;
try {
const serverSpecs = await getServerEntry(serverSpec.name);
if (serverSpecs.length !== 0) throw Error("Server already exists in DB!");
await createServerResources(serverSpec);
await createServerEntry(serverSpec);
res.sendStatus(200);
} catch (e) {
sendError(res)(e);
}
}
export async function deleteServer(req, res) {
// Ensure spec is safe
const serverSpec = req.body;
try {
checkServerName(serverSpec);
} catch (e) {
return sendError(res)(e);
}
const deleteEntry = deleteServerEntry(serverSpec.name);
const deleteResources = deleteServerResources(serverSpec);
Promise.all([deleteEntry, deleteResources])
.then(() => res.sendStatus(200))
.catch(sendError(res));
}
export async function startServer(req, res) {
// Ensure spec is safe
const serverSpec = req.body;
try {
checkServerName(serverSpec);
} catch (e) {
return sendError(res)(e);
}
const { name } = serverSpec;
toggleServer(name, true)
.then(() => res.sendStatus(200))
.catch(sendError(res));
}
export async function stopServer(req, res) {
// Ensure spec is safe
const serverSpec = req.body;
try {
checkServerName(serverSpec);
} catch (e) {
return sendError(res)(e);
}
const { name } = serverSpec;
toggleServer(name, false)
.then(() => res.sendStatus(200))
.catch(sendError(res));
}

View file

@ -0,0 +1,18 @@
import { getDeployments } from "../k8s/k8s-server-control.js";
import { getInstances } from "../k8s/server-control.js";
import { sendError } from "../util/ExpressClientError.js";
export function serverList(req, res) {
getDeployments()
.then((sd) => res.json(sd.map((s) => s.metadata.name.substring(4))))
.catch((e) => {
ERR("SERVER CONTROL", e);
res.status(500).send("Couldn't get server list");
});
}
export function serverInstances(req, res) {
getInstances()
.then((i) => res.json(i))
.catch(sendError(res));
}

View file

@ -0,0 +1,65 @@
// Imports
import k8s from "@kubernetes/client-node";
import { Rcon as RconClient } from "rcon-client";
import stream from "stream";
import { ERR, WARN } from "../../util/logging.js";
// Kubernetes Configuration
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
const k8sCore = kc.makeApiClient(k8s.CoreV1Api);
const namespace = process.env.MCL_SERVER_NAMESPACE;
// Retrieves logs from the minecraft server container
export async function webConsoleLogs(socket) {
const { serverName } = socket.mcs;
const podName = `mcl-${serverName}`;
const containerName = `${podName}-server`;
const podResponse = await k8sCore.listNamespacedPod(namespace);
const pods = podResponse.body.items.map((vp1) => vp1.metadata.name);
const mcsPods = pods.filter((p) => p.startsWith(podName));
if (mcsPods.length === 0)
throw Error(`Could not find a pod that starts with ${podName}`);
if (mcsPods.length > 1)
throw Error(`Multiple pods match the name ${podName}`);
const log = new k8s.Log(kc);
const logStream = new stream.PassThrough();
logStream.on("data", (chunk) =>
socket.emit("push", Buffer.from(chunk).toString()),
);
log
.log(namespace, mcsPods[0], containerName, logStream, {
follow: true,
pretty: false,
timestamps: false,
})
.catch((e) => ERR("CONSOLE CONTROLLER", "Error streaming logs", e));
}
// Creates an RCON connection to the minecraft container
export async function webConsoleRcon(socket) {
if (socket.rconClient)
return VERB("RCON", "Socket already connected to RCON");
const rconSecret = `mcl-${socket.mcs.serverName}-rcon-secret`;
const rconRes = await k8sCore.readNamespacedSecret(rconSecret, namespace);
const rconPassword = Buffer.from(
rconRes.body.data["rcon-password"],
"base64",
).toString("utf8");
const { serverName } = socket.mcs;
const rconHost = `mcl-${serverName}-rcon.${namespace}.svc.cluster.local`;
const rcon = new RconClient({
host: rconHost,
port: 25575,
password: rconPassword,
});
rcon.on("error", (error) => socket.emit("push", error));
try {
await rcon.connect();
} catch (error) {
socket.emit("push", "Could not connect RCON Input to server!");
WARN("RCON", `Could not connect to '${rconHost}'`);
}
socket.rconClient = rcon;
}

View file

@ -0,0 +1,12 @@
CREATE SEQUENCE servers_id_seq;
CREATE TABLE servers (
id bigint NOT NULL DEFAULT nextval('servers_id_seq') PRIMARY KEY,
name varchar(255) DEFAULT NULL,
host varchar(255) DEFAULT NULL,
version varchar(63) DEFAULT 'latest',
server_type varchar(63) DEFAULT 'VANILLA',
memory varchar(63) DEFAULT '512',
CONSTRAINT unique_name UNIQUE(name),
CONSTRAINT unique_host UNIQUE(host)
);
ALTER SEQUENCE servers_id_seq OWNED BY servers.id;

121
lib/database/pg-query.js Normal file
View file

@ -0,0 +1,121 @@
const buildPostgresEntry = (entry) => {
const pgEntry = { ...entry };
Object.keys(pgEntry).forEach((col) => {
if (pgEntry[col] === undefined) delete pgEntry[col];
});
return pgEntry;
};
export const buildPostgresValue = (jsVar) => {
if (jsVar === null) return "null";
if (typeof jsVar === "string") return buildPostgresString(jsVar);
if (Array.isArray(jsVar) && jsVar.length === 0) return "null";
if (Array.isArray(jsVar) && isTypeArray(jsVar, "string"))
return buildPostgresStringArray(jsVar);
return jsVar;
};
const buildPostgresStringArray = (jsonArray) => {
if (jsonArray.length === 0) return null;
var pgArray = [...jsonArray];
var arrayString = "ARRAY [";
pgArray.forEach((e, i) => (pgArray[i] = `'${e}'`));
arrayString += pgArray.join(",");
arrayString += "]";
return arrayString;
};
const isTypeArray = (jsonArray, type) =>
jsonArray.every((e) => typeof e === type);
const buildPostgresString = (jsonString) =>
(jsonString && `'${jsonString.replaceAll("'", "''")}'`) || null;
export const insertQuery = (table, jsEntry) => {
if (typeof jsEntry !== "object") throw Error("PG Inserts must be objects!");
const entry = buildPostgresEntry(jsEntry);
const cols = Object.keys(entry);
cols.forEach((col, i) => {
entry[col] = buildPostgresValue(entry[col]);
cols[i] = `"${col}"`;
});
var query = `INSERT INTO ${table}(${cols.join(",")})\n`;
query += `VALUES(${Object.values(entry).join(",")})`;
return query;
};
export const deleteQuery = (table, jsEntry) => {
if (typeof jsEntry !== "object")
throw Error("PG Delete conditionals must be an object!");
const entry = buildPostgresEntry(jsEntry);
const cols = Object.keys(entry);
const conditionals = [];
for (var col of cols) {
entry[col] = buildPostgresValue(entry[col]);
if (entry[col] === "null") conditionals.push(`x.${col} IS NULL`);
else conditionals.push(`x.${col}=${entry[col]}`);
}
return `DELETE FROM ${table} x WHERE ${conditionals.join(" AND ")}`;
};
export const onConflictUpdate = (conflicts, updates) => {
if (!Array.isArray(conflicts)) throw Error("PG Conflicts must be an array!");
if (typeof updates !== "object") throw Error("PG Updates must be objects!");
const entry = buildPostgresEntry(updates);
var query = `ON CONFLICT (${conflicts.join(",")}) DO UPDATE SET\n`;
const cols = Object.keys(entry);
for (var col of cols) {
entry[col] = buildPostgresValue(entry[col]);
}
query += cols.map((c) => `${c}=${entry[c]}`).join(",");
return query;
};
export const clearTableQuery = (table) => {
return `TRUNCATE ${table}`;
};
export const selectWhereQuery = (table, jsEntry, joinWith) => {
if (typeof jsEntry !== "object") throw Error("PG Where must be an object!");
const where = buildPostgresEntry(jsEntry);
const cols = Object.keys(where);
var query = `SELECT * FROM ${table} AS x WHERE\n`;
for (var col of cols) {
where[col] = buildPostgresValue(where[col]);
}
return (query += cols.map((c) => `x.${c}=${where[c]}`).join(joinWith));
};
export const updateWhereQuery = (table, updates, wheres, joinWith) => {
if (typeof updates !== "object") throw Error("PG Updates must be an object!");
if (typeof wheres !== "object") throw Error("PG Wheres must be an object!");
const update = buildPostgresEntry(updates);
const where = buildPostgresEntry(wheres);
const updateCols = Object.keys(update);
const whereCols = Object.keys(where);
var query = `UPDATE ${table}\n`;
var updateQuery = updateCols
.map((c) => `${c} = ${buildPostgresValue(update[c])}`)
.join(",");
var whereQuery = whereCols
.map((c) => `${c} = ${buildPostgresValue(where[c])}`)
.join(joinWith);
return (query += `SET ${updateQuery} WHERE ${whereQuery}`);
};
export const updateWhereAnyQuery = (table, updates, wheres) =>
updateWhereQuery(table, updates, wheres, " OR ");
export const updateWhereAllQuery = (table, updates, wheres) =>
updateWhereQuery(table, updates, wheres, " AND ");
export const selectWhereAnyQuery = (table, where) =>
selectWhereQuery(table, where, " OR ");
export const selectWhereAllQuery = (table, where) =>
selectWhereQuery(table, where, " AND ");
export default {
selectWhereAnyQuery,
selectWhereAllQuery,
updateWhereAnyQuery,
updateWhereAllQuery,
insertQuery,
deleteQuery,
buildPostgresValue,
onConflictUpdate,
clearTableQuery,
};

63
lib/database/postgres.js Normal file
View file

@ -0,0 +1,63 @@
// Imports
import path from "node:path";
import { URL } from "node:url";
import { migrate } from "postgres-migrations";
import createPgp from "pg-promise";
import moment from "moment";
import { INFO, WARN, OK, VERB } from "../util/logging.js";
// Environment Variables
const {
MCL_POSTGRES_DATABASE: database,
MCL_POSTGRES_ENABLED: pgEnabled,
MCL_POSTGRES_HOST: host,
MCL_POSTGRES_PASSWORD: password,
MCL_POSTGRES_PORT: port,
MCL_POSTGRES_USER: user,
} = process.env;
// Postgres-promise Configuration
// Ensure dates get saved as UTC date strings
// This prevents the parser from doing strange datetime operations
const pgp = createPgp();
pgp.pg.types.setTypeParser(1114, (str) => moment.utc(str).format());
// Database Config
const dbConfig = {
database: database ?? "minecluster",
user: user ?? "postgres",
password: password ?? "postgres",
host: host ?? "localhost",
port: port ?? 5432,
ensureDatabaseExists: true,
};
const databaseDir = new URL(".", import.meta.url).pathname;
const migrationsDir = path.resolve(databaseDir, "migrations/");
const queryMock = (str) => INFO("POSTGRES MOCK", str);
const connect = (pg) => async () => {
if (pgEnabled === "false") {
WARN("POSTGRES", "Postgres Disabled!");
return { query: queryMock };
}
VERB("POSTGRES", "Migrating...");
await migrate(dbConfig, migrationsDir);
// Override fake methods
const pgInstance = pgp(dbConfig);
for (var k in pgInstance) pg[k] = pgInstance[k];
VERB("POSTGRES", "Migrated Successfully!");
await pg.connect();
VERB("POSTGRES", "Postgres connected Successfully!");
OK("POSTGRES", `Connected to database ${dbConfig.database}!`);
};
const buildPostgres = () => {
var pg = { query: queryMock };
pg.connect = connect(pg);
return pg;
};
export default buildPostgres();

View file

@ -0,0 +1,41 @@
import pg from "../postgres.js";
import { deleteQuery, insertQuery, selectWhereQuery } from "../pg-query.js";
import ExpressClientError from "../../util/ExpressClientError.js";
const table = "servers";
const asExpressClientError = (e) => {
throw new ExpressClientError({ m: e.message, c: 409 });
};
export async function createServerEntry(serverSpec) {
const { name, host, version, serverType: server_type, memory } = serverSpec;
const q = insertQuery(table, { name, host, version, server_type, memory });
return pg.query(q).catch(asExpressClientError);
}
export async function deleteServerEntry(serverName) {
if (!serverName) asExpressClientError({ message: "Server Name Required!" });
const q = deleteQuery(table, { name: serverName });
return pg.query(q).catch(asExpressClientError);
}
export async function getServerEntry(serverName) {
if (!serverName) asExpressClientError({ message: "Server Name Required!" });
const q = selectWhereQuery(table, { name: serverName });
try {
const serverSpecs = await pg.query(q);
if (serverSpecs.length === 0) return [];
if (!serverSpecs.length === 1)
throw Error("Multiple servers found with the same name!");
const {
name,
host,
version,
server_type: serverType,
memory,
} = serverSpecs[0];
return { name, host, version, serverType, memory };
} catch (e) {
asExpressClientError(e);
}
}

View file

View file

@ -1,8 +0,0 @@
import k8s from "@kubernetes/client-node";
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
const k8sApi = kc.makeApiClient(k8s.CoreV1Api);
k8sApi.listNamespacedPod("mc-garden-default").then((res) => {
console.log(res.body);
});

View file

@ -0,0 +1,36 @@
env:
- name: FTP_USER
value: "minecluster"
- name: FTP_PASS
value: "minecluster"
image: garethflowers/ftp-server
imagePullPolicy: IfNotPresent
livenessProbe:
exec:
command: ["echo"]
failureThreshold: 20
initialDelaySeconds: 30
periodSeconds: 5
successThreshold: 1
timeoutSeconds: 1
name: changeme-name-ftp
ports: [] # Programatically add all the ports for easier readability, Ports include: 20,21,40000-400009
readinessProbe:
exec:
command: ["echo"]
failureThreshold: 20
initialDelaySeconds: 30
periodSeconds: 5
successThreshold: 1
timeoutSeconds: 1
resources:
requests:
cpu: 50m
memory: 64Mi
stdin: true
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
tty: true
volumeMounts:
- mountPath: /home/minecluster
name: datadir

View file

@ -0,0 +1,63 @@
env:
- name: SRC_DIR
value: /data
- name: BACKUP_NAME
value: world
- name: INITIAL_DELAY
value: 2m
- name: BACKUP_INTERVAL
value: 24h
- name: PRUNE_BACKUPS_DAYS
value: "2"
- name: PAUSE_IF_NO_PLAYERS
value: "true"
- name: SERVER_PORT
value: "25565"
- name: RCON_HOST
value: localhost
- name: RCON_PORT
value: "25575"
- name: RCON_PASSWORD
valueFrom:
secretKeyRef:
key: rcon-password
name: changeme-rcon-secret
- name: RCON_RETRIES
value: "5"
- name: RCON_RETRY_INTERVAL
value: 10s
- name: EXCLUDES
value: "*.jar,cache,logs"
- name: BACKUP_METHOD
value: rclone
- name: DEST_DIR
value: /backups
- name: LINK_LATEST
value: "false"
- name: TAR_COMPRESS_METHOD
value: gzip
- name: ZSTD_PARAMETERS
value: -3 --long=25 --single-thread
- name: RCLONE_REMOTE
value: mc-dunemask-net
- name: RCLONE_DEST_DIR
value: /minecraft-backups/deltasmp-backups
- name: RCLONE_COMPRESS_METHOD
value: gzip
image: itzg/mc-backup:latest
imagePullPolicy: IfNotPresent
name: mcs-deltasmp-minecraft-mc-backup
resources:
requests:
cpu: 500m
memory: 512Mi
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
volumeMounts:
- mountPath: /data
name: datadir
readOnly: true
- mountPath: /backups
name: backupdir
- mountPath: /config/rclone
name: rclone-config

View file

@ -0,0 +1,113 @@
env:
# System Values
- name: JVM_OPTS
- name: JVM_XX_OPTS
- name: OVERRIDE_SERVER_PROPERTIES
value: "false"
- name: EULA
value: "TRUE"
# Updated at recreation
- name: MEMORY
value: 1024M
- name: TYPE
value: VANILLA
- name: VERSION
value: "latest"
# Set at creation but not updated on recreation
- name: DIFFICULTY
value: easy
- name: WHITELIST
- name: OPS
- name: MAX_PLAYERS
value: "20"
- name: MAX_WORLD_SIZE
value: "10000"
- name: ALLOW_NETHER
value: "true"
- name: ANNOUNCE_PLAYER_ACHIEVEMENTS
value: "true"
- name: ENABLE_COMMAND_BLOCK
value: "true"
- name: FORCE_GAMEMODE
value: "false"
- name: GENERATE_STRUCTURES
value: "true"
- name: HARDCORE
value: "false"
- name: MAX_BUILD_HEIGHT
value: "256"
- name: MAX_TICK_TIME
value: "60000"
- name: SPAWN_ANIMALS
value: "true"
- name: SPAWN_MONSTERS
value: "true"
- name: SPAWN_NPCS
value: "true"
- name: SPAWN_PROTECTION
value: "16"
- name: VIEW_DISTANCE
value: "10"
- name: SEED
- name: MODE
value: survival
- name: MOTD
value: §6Minecluster Hosting
- name: PVP
value: "true"
- name: LEVEL_TYPE
value: DEFAULT
- name: GENERATOR_SETTINGS
- name: LEVEL
value: world
- name: ONLINE_MODE
value: "true"
- name: ENABLE_RCON
value: "true"
- name: RCON_PASSWORD
valueFrom:
secretKeyRef:
key: rcon-password
name: changeme-rcon-secret
image: itzg/minecraft-server:latest
imagePullPolicy: IfNotPresent
livenessProbe:
exec:
command:
- mc-health
failureThreshold: 20
initialDelaySeconds: 30
periodSeconds: 5
successThreshold: 1
timeoutSeconds: 1
name: changeme-name-server
ports:
- containerPort: 25565
name: minecraft
protocol: TCP
- containerPort: 25575
name: rcon
protocol: TCP
readinessProbe:
exec:
command:
- mc-health
failureThreshold: 20
initialDelaySeconds: 30
periodSeconds: 5
successThreshold: 1
timeoutSeconds: 1
resources:
requests:
cpu: 500m
memory: 512Mi
stdin: true
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
tty: true
volumeMounts:
- mountPath: /data
name: datadir
- mountPath: /backups
name: backupdir
readOnly: true

View file

@ -3,6 +3,8 @@ data:
rcon-password: UEphT3V2aGJlQjNvc3M0dElwQU5YTUZrSkltR1RsRVl0ZGx3elFqZjJLdVZrZXNtV0hja1VhUUd3bmZDcElpbA== rcon-password: UEphT3V2aGJlQjNvc3M0dElwQU5YTUZrSkltR1RsRVl0ZGx3elFqZjJLdVZrZXNtV0hja1VhUUd3bmZDcElpbA==
kind: Secret kind: Secret
metadata: metadata:
annotations:
minecluster.dunemask.net/server-name: changeme-server-name
labels: labels:
app: changeme-app-label app: changeme-app-label
name: changeme-rcon-secret name: changeme-rcon-secret

View file

@ -2,6 +2,7 @@ apiVersion: v1
kind: Service kind: Service
metadata: metadata:
annotations: annotations:
minecluster.dunemask.net/server-name: changeme-server-name
labels: labels:
app: changeme-app app: changeme-app
name: changeme-rcon name: changeme-rcon

View file

@ -1,6 +1,8 @@
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
annotations:
minecluster.dunemask.net/server-name: changeme-server-name
name: changeme-name name: changeme-name
namespace: changeme-namespace namespace: changeme-namespace
spec: spec:
@ -14,191 +16,18 @@ spec:
type: Recreate type: Recreate
template: template:
metadata: metadata:
annotations:
minecluster.dunemask.net/server-name: changeme-server-name
labels: labels:
app: changeme-app app: changeme-app
spec: spec:
containers: containers: []
- env:
- name: SRC_DIR
value: /data
- name: BACKUP_NAME
value: world
- name: INITIAL_DELAY
value: 2m
- name: BACKUP_INTERVAL
value: 24h
- name: PRUNE_BACKUPS_DAYS
value: "2"
- name: PAUSE_IF_NO_PLAYERS
value: "true"
- name: SERVER_PORT
value: "25565"
- name: RCON_HOST
value: localhost
- name: RCON_PORT
value: "25575"
- name: RCON_PASSWORD
valueFrom:
secretKeyRef:
key: rcon-password
name: changeme-rcon-secret
- name: RCON_RETRIES
value: "5"
- name: RCON_RETRY_INTERVAL
value: 10s
- name: EXCLUDES
value: "*.jar,cache,logs"
- name: BACKUP_METHOD
value: rclone
- name: DEST_DIR
value: /backups
- name: LINK_LATEST
value: "false"
- name: TAR_COMPRESS_METHOD
value: gzip
- name: ZSTD_PARAMETERS
value: -3 --long=25 --single-thread
- name: RCLONE_REMOTE
value: mc-dunemask-net
- name: RCLONE_DEST_DIR
value: /minecraft-backups/deltasmp-backups
- name: RCLONE_COMPRESS_METHOD
value: gzip
image: itzg/mc-backup:latest
imagePullPolicy: IfNotPresent
name: mcs-deltasmp-minecraft-mc-backup
resources:
requests:
cpu: 500m
memory: 512Mi
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
volumeMounts:
- mountPath: /data
name: datadir
readOnly: true
- mountPath: /backups
name: backupdir
- mountPath: /config/rclone
name: rclone-config
- env:
- name: EULA
value: "TRUE"
- name: TYPE
value: VANILLA
- name: VERSION
value: "latest"
- name: DIFFICULTY
value: easy
- name: WHITELIST
- name: OPS
- name: ICON
- name: MAX_PLAYERS
value: "20"
- name: MAX_WORLD_SIZE
value: "10000"
- name: ALLOW_NETHER
value: "true"
- name: ANNOUNCE_PLAYER_ACHIEVEMENTS
value: "true"
- name: ENABLE_COMMAND_BLOCK
value: "true"
- name: FORCE_GAMEMODE
value: "false"
- name: GENERATE_STRUCTURES
value: "true"
- name: HARDCORE
value: "false"
- name: MAX_BUILD_HEIGHT
value: "256"
- name: MAX_TICK_TIME
value: "60000"
- name: SPAWN_ANIMALS
value: "true"
- name: SPAWN_MONSTERS
value: "true"
- name: SPAWN_NPCS
value: "true"
- name: SPAWN_PROTECTION
value: "16"
- name: VIEW_DISTANCE
value: "10"
- name: SEED
- name: MODE
value: survival
- name: MOTD
value: §6Minecluster Hosting
- name: PVP
value: "true"
- name: LEVEL_TYPE
value: DEFAULT
- name: GENERATOR_SETTINGS
- name: LEVEL
value: world
- name: MODPACK
- name: ONLINE_MODE
value: "true"
- name: MEMORY
value: 1024M
- name: JVM_OPTS
- name: JVM_XX_OPTS
- name: OVERRIDE_SERVER_PROPERTIES
value: "true"
- name: ENABLE_RCON
value: "true"
- name: RCON_PASSWORD
valueFrom:
secretKeyRef:
key: rcon-password
name: changeme-rcon-secret
image: itzg/minecraft-server:latest
imagePullPolicy: IfNotPresent
livenessProbe:
exec:
command:
- mc-health
failureThreshold: 20
initialDelaySeconds: 30
periodSeconds: 5
successThreshold: 1
timeoutSeconds: 1
name: changeme-name
ports:
- containerPort: 25565
name: minecraft
protocol: TCP
- containerPort: 25575
name: rcon
protocol: TCP
readinessProbe:
exec:
command:
- mc-health
failureThreshold: 20
initialDelaySeconds: 30
periodSeconds: 5
successThreshold: 1
timeoutSeconds: 1
resources:
requests:
cpu: 500m
memory: 512Mi
stdin: true
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
tty: true
volumeMounts:
- mountPath: /data
name: datadir
- mountPath: /backups
name: backupdir
readOnly: true
dnsPolicy: ClusterFirst dnsPolicy: ClusterFirst
restartPolicy: Always restartPolicy: Always
schedulerName: default-scheduler schedulerName: default-scheduler
securityContext: # securityContext:
fsGroup: 2000 # fsGroup: 2000
runAsUser: 1000 # runAsUser: 1000
terminationGracePeriodSeconds: 30 terminationGracePeriodSeconds: 30
volumes: volumes:
- name: datadir - name: datadir

View file

@ -1,6 +1,8 @@
apiVersion: v1 apiVersion: v1
kind: PersistentVolumeClaim kind: PersistentVolumeClaim
metadata: metadata:
annotations:
minecluster.dunemask.net/server-name: changeme-server-name
labels: labels:
service: changeme-service-name service: changeme-service-name
name: changeme-pvc-name name: changeme-pvc-name

View file

@ -4,6 +4,7 @@ metadata:
annotations: annotations:
ingress.qumine.io/hostname: changeme-url ingress.qumine.io/hostname: changeme-url
ingress.qumine.io/portname: minecraft ingress.qumine.io/portname: minecraft
minecluster.dunemask.net/server-name: changeme-server-name
labels: labels:
app: changeme-app app: changeme-app
name: changeme-name name: changeme-name
@ -13,11 +14,15 @@ spec:
ipFamilies: ipFamilies:
- IPv4 - IPv4
ipFamilyPolicy: SingleStack ipFamilyPolicy: SingleStack
ports: ports: # Programatically add all FTP ports. Port range includes 20, 21, 40000-40001
- name: minecraft - name: minecraft
port: 25565 port: 25565
protocol: TCP protocol: TCP
targetPort: minecraft targetPort: minecraft
# - name: ftp-data
# port: 20
# protocol: TCP
# targetPort: ftp-data
selector: selector:
app: changeme-app app: changeme-app
sessionAffinity: None sessionAffinity: None

View file

@ -0,0 +1,146 @@
import k8s from "@kubernetes/client-node";
import yaml from "js-yaml";
import { VERB, ERR } from "../util/logging.js";
import { getServerEntry } from "../database/queries/server-queries.js";
import {
getFtpContainer,
getCoreServerContainer,
getBackupContainer,
} from "./server-containers.js";
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
const k8sDeps = kc.makeApiClient(k8s.AppsV1Api);
const k8sCore = kc.makeApiClient(k8s.CoreV1Api);
const namespace = process.env.MCL_SERVER_NAMESPACE;
const loadYaml = (f) => yaml.load(fs.readFileSync(path.resolve(f), "utf8"));
const mineclusterManaged = (o) =>
o.metadata &&
o.metadata.annotations &&
o.metadata.annotations["minecluster.dunemask.net/server-name"] !== undefined;
export const serverMatch = (serverName) => (o) =>
o.metadata.annotations["minecluster.dunemask.net/server-name"] === serverName;
export async function getDeployments() {
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace);
const serverDeployments = deploymentRes.body.items.filter(mineclusterManaged);
return serverDeployments;
}
export async function getServices() {
const serviceRes = await k8sCore.listNamespacedService(namespace);
const serverServices = serviceRes.body.items.filter(mineclusterManaged);
return serverServices;
}
export async function getSecrets() {
const secretRes = await k8sCore.listNamespacedSecret(namespace);
const serverSecrets = secretRes.body.items.filter(mineclusterManaged);
return serverSecrets;
}
export async function getVolumes() {
const volumeRes =
await k8sCore.listNamespacedPersistentVolumeClaim(namespace);
const serverVolumes = volumeRes.body.items.filter(mineclusterManaged);
return serverVolumes;
}
export function getServerAssets(serverName) {
const serverFilter = serverMatch(serverName);
return Promise.all([
getDeployments(),
getServices(),
getSecrets(),
getVolumes(),
])
.then(([deps, svcs, scrts, vols]) => {
const deployments = deps.filter(serverFilter);
const services = svcs.filter(serverFilter);
const secrets = scrts.filter(serverFilter);
const volumes = vols.filter(serverFilter);
if (deployments.length > 1) throw Error("Deployment filter broken!");
if (volumes.length > 1) throw Error("Volume filter broken!");
if (secrets.length > 1) throw Error("Secrets broken!");
const serverAssets = {
deployment: deployments[0],
service: services.find(
(s) => s.metadata.name === `mcl-${serverName}-server`,
),
volume: volumes[0],
rconService: services.find(
(s) => s.metadata.name === `mcl-${serverName}-rcon`,
),
rconSecret: secrets[0],
};
for (var k in serverAssets) if (serverAssets[k]) return serverAssets;
// If no assets exist, return nothing
})
.catch((e) => ERR("SERVER ASSETS", e));
}
export async function getDeployment(serverName) {
const servers = await getDeployments();
const serverDeployment = servers.find(
(s) =>
s.metadata.annotations["minecluster.dunemask.net/server-name"] ===
serverName,
);
if (!serverDeployment)
throw Error(`MCL Deployment '${serverName}' could not be found!`);
return serverDeployment;
}
export async function getContainers(serverName) {
const deployment = await getDeployment(serverName);
return deployment.spec.template.spec.containers;
}
async function containerControl(serverName, deployment, scaleUp) {
const { containers } = deployment.spec.template.spec;
const depFtp = containers.find((c) => c.name.endsWith("-ftp"));
const depServer = containers.find((c) => c.name.endsWith("-server"));
const depBackup = containers.find((c) => c.name.endsWith("-backup"));
const serverSpec = await getServerEntry(serverName);
const ftpContainer = depFtp ?? getFtpContainer(serverSpec);
const serverContainer = depServer ?? getCoreServerContainer(serverSpec);
const backupContainer = depBackup ?? getBackupContainer(serverSpec);
if (scaleUp) return [ftpContainer, serverContainer];
return [ftpContainer];
}
export async function toggleServer(serverName, scaleUp = false) {
const deployment = await getDeployment(serverName);
deployment.spec.template.spec.containers = await containerControl(
serverName,
deployment,
scaleUp,
);
return k8sDeps.replaceNamespacedDeployment(
deployment.metadata.name,
namespace,
deployment,
);
}
export async function scaleDeployment(serverName, scaleUp = false) {
const deployment = await getDeployment(serverName);
if (deployment.spec.replicas === 1 && scaleUp)
return VERB(
"KSC",
`MCL Deployment '${serverName}' is already scaled! Ignoring scale adjustment.`,
);
deployment.spec.replicas = scaleUp ? 1 : 0;
return k8sDeps.replaceNamespacedDeployment(
deployment.metadata.name,
namespace,
deployment,
);
}

View file

@ -1,30 +0,0 @@
import stream from "stream";
import k8s from "@kubernetes/client-node";
import { ERR } from "../util/logging.js";
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
const k8sApi = kc.makeApiClient(k8s.CoreV1Api);
export default async function liveLogging(socket, serverNamespace) {
const containerName = `mcl-${socket.mcs.serverName}`;
const podResponse = await k8sApi.listNamespacedPod(serverNamespace);
const pods = podResponse.body.items.map((vp1) => vp1.metadata.name);
const mcsPods = pods.filter((p) => p.startsWith(containerName));
if (mcsPods.length === 0)
throw Error(`Could not find a pod that starts with ${containerName}`);
if (mcsPods.length > 1)
throw Error(`Multiple pods match the name ${containerName}`);
const log = new k8s.Log(kc);
const logStream = new stream.PassThrough();
logStream.on("data", (chunk) =>
socket.emit("push", Buffer.from(chunk).toString())
);
log
.log(serverNamespace, mcsPods[0], containerName, logStream, {
follow: true,
pretty: false,
timestamps: false,
})
.catch((e) => ERR("K8S", e));
}

View file

@ -0,0 +1,67 @@
import fs from "node:fs";
import path from "node:path";
import yaml from "js-yaml";
const loadYaml = (f) => yaml.load(fs.readFileSync(path.resolve(f), "utf8"));
export function getFtpContainer(serverSpec) {
const { name } = serverSpec;
const ftpContainer = loadYaml("lib/k8s/configs/containers/ftp-server.yml");
ftpContainer.name = `mcl-${name}-ftp`;
const ftpPortList = [
{ p: 20, n: "ftp-data" },
{ p: 21, n: "ftp-commands" },
];
for (var p = 40000; p <= 40009; p++)
ftpPortList.push({ p, n: `ftp-passive-${p - 40000}` });
ftpContainer.ports = ftpPortList.map(({ p: containerPort, n: name }) => ({
containerPort,
name,
protocol: "TCP",
}));
return ftpContainer;
}
export function getCoreServerContainer(serverSpec) {
const { name, version, serverType, memory } = serverSpec;
const container = loadYaml("lib/k8s/configs/containers/minecraft-server.yml");
// Container Updates
container.name = `mcl-${name}-server`;
container.resources.requests.memory = `${memory}Mi`;
const findEnv = (k) => container.env.find(({ name: n }) => n === k);
const updateEnv = (k, v) => (findEnv(k).value = v);
// Enviornment variables
updateEnv("TYPE", serverType);
updateEnv("VERSION", version);
updateEnv("MEMORY", `${memory}M`);
// RCON
const rs = `mcl-${name}-rcon-secret`;
findEnv("RCON_PASSWORD").valueFrom.secretKeyRef.name = rs;
return container;
}
export function getServerContainer(serverSpec) {
const { difficulty, gamemode, motd, maxPlayers, seed, ops, whitelist } =
serverSpec;
const container = getCoreServerContainer(serverSpec);
const findEnv = (k) => container.env.find(({ name: n }) => n === k);
const updateEnv = (k, v) => (findEnv(k).value = v);
// Enviornment variables
updateEnv("DIFFICULTY", difficulty);
updateEnv("MODE", gamemode);
updateEnv("MOTD", motd);
updateEnv("MAX_PLAYERS", maxPlayers);
updateEnv("SEED", seed);
updateEnv("OPS", ops);
updateEnv("WHITELIST", whitelist);
return container;
}
export function getBackupContainer(serverSpec) {
const container = loadYaml("lib/k8s/configs/containers/minecraft-backup.yml");
return container;
}

View file

@ -1,95 +1,86 @@
import k8s from "@kubernetes/client-node"; import k8s from "@kubernetes/client-node";
import {
getDeployment,
getDeployments,
getServerAssets,
scaleDeployment,
} from "./k8s-server-control.js";
import { ERR } from "../util/logging.js"; import { ERR } from "../util/logging.js";
import ExpressClientError from "../util/ExpressClientError.js";
const kc = new k8s.KubeConfig(); const kc = new k8s.KubeConfig();
kc.loadFromDefault(); kc.loadFromDefault();
const k8sDeps = kc.makeApiClient(k8s.AppsV1Api);
const k8sCore = kc.makeApiClient(k8s.CoreV1Api);
const k8sMetrics = new k8s.Metrics(kc); const k8sMetrics = new k8s.Metrics(kc);
const k8sDeps = kc.makeApiClient(k8s.AppsV1Api);
const namespace = process.env.MCL_SERVER_NAMESPACE; const namespace = process.env.MCL_SERVER_NAMESPACE;
export async function startServer(req, res) { export async function startServerContainer(serverSpec) {
const serverSpec = req.body;
if (!serverSpec) return res.sendStatus(400);
if (!serverSpec.name) return res.status(400).send("Server name required!");
const { name } = serverSpec; const { name } = serverSpec;
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace); try {
const dep = deploymentRes.body.items.find( await scaleDeployment(name, true);
(i) => i.metadata.name === `mcl-${name}` } catch (e) {
); ERR("SERVER CONTROL", e);
if (!dep) return res.status(409).send("Server does not exist!"); throw new ExpressClientError({
if (dep.spec.replicas === 1) c: 500,
return res.status(409).send("Server already started!"); m: `Error updating server '${name}'!\n`,
dep.spec.replicas = 1; });
k8sDeps.replaceNamespacedDeployment(`mcl-${name}`, namespace, dep); }
res.sendStatus(200);
} }
export async function stopServer(req, res) { export async function stopServerContainer(serverSpec) {
const serverSpec = req.body;
if (!serverSpec) return res.sendStatus(400);
if (!serverSpec.name) return res.status(400).send("Server name required!");
const { name } = serverSpec; const { name } = serverSpec;
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace); try {
const dep = deploymentRes.body.items.find( await scaleDeployment(name, false);
(i) => i.metadata.name === `mcl-${name}` } catch (e) {
); ERR("SERVER CONTROL", e);
if (!dep) return res.status(409).send("Server does not exist!"); throw new ExpressClientError({
if (dep.spec.replicas === 0) c: 500,
return res.status(409).send("Server already stopped!"); m: `Error updating server '${name}'!`,
dep.spec.replicas = 0; });
k8sDeps.replaceNamespacedDeployment(`mcl-${name}`, namespace, dep); }
res.sendStatus(200);
} }
export async function serverList(req, res) { export async function getInstances() {
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace); const serverDeployments = await getDeployments();
const deployments = deploymentRes.body.items.map((i) => i.metadata.name);
// TODO Add an annotation and manage using that
const serverDeployments = deployments.filter((d) => d.startsWith("mcl-"));
res.json(serverDeployments.map((sd) => sd.substring(4)));
}
export async function getServers(req, res) {
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace);
const deployments = deploymentRes.body.items;
const podMetricsResponse = await k8sMetrics.getPodMetrics(namespace); const podMetricsResponse = await k8sMetrics.getPodMetrics(namespace);
// TODO Add an annotation and manage using that
const serverDeployments = deployments.filter((d) =>
d.metadata.name.startsWith("mcl-")
);
var name, metrics, started; var name, metrics, started;
const servers = serverDeployments.map((s) => { const serverInstances = serverDeployments.map((s) => {
name = s.metadata.name.substring(4); name = s.metadata.annotations["minecluster.dunemask.net/server-name"];
metrics = null; metrics = null;
started = !!s.spec.replicas; started = !!s.spec.template.spec.containers.find((c) =>
c.name.includes(`mcl-${name}-server`),
);
const pod = podMetricsResponse.items.find(({ metadata: md }) => { const pod = podMetricsResponse.items.find(({ metadata: md }) => {
return md.labels && md.labels.app && md.labels.app === `mcl-${name}-app`; return md.labels && md.labels.app && md.labels.app === `mcl-${name}-app`;
}); });
if (pod) { if (started && pod) {
const podCpus = pod.containers.map( const podCpus = pod.containers.map(
({ usage }) => parseInt(usage.cpu) / 1_000_000 ({ usage }) => parseInt(usage.cpu) / 1_000_000,
); );
const podMems = pod.containers.map( const podMems = pod.containers.map(
({ usage }) => parseInt(usage.memory) / 1024 ({ usage }) => parseInt(usage.memory) / 1024,
); );
metrics = { metrics = {
cpu: Math.ceil(podCpus.reduce((a, b) => a + b)), cpu: Math.ceil(podCpus.reduce((a, b) => a + b)),
memory: Math.ceil(podMems.reduce((a, b) => a + b)), memory: Math.ceil(podMems.reduce((a, b) => a + b)),
}; };
} }
return { name, metrics, started }; return { name, metrics, started };
}); });
return serverInstances;
}
export async function getNamespaceMetrics() {
const serverInstances = await getInstances();
var clusterMetrics = { cpu: 0, memory: 0 }; var clusterMetrics = { cpu: 0, memory: 0 };
if (servers.length > 1) { if (servers.length > 1) {
const clusterCpu = servers const clusterCpu = serverInstances
.map(({ metrics }) => (metrics ? metrics.cpu : 0)) .map(({ metrics }) => (metrics ? metrics.cpu : 0))
.reduce((a, b) => a + b); .reduce((a, b) => a + b);
const clusterMem = servers const clusterMem = serverInstances
.map(({ metrics }) => (metrics ? metrics.memory : 0)) .map(({ metrics }) => (metrics ? metrics.memory : 0))
.reduce((a, b) => a + b); .reduce((a, b) => a + b);
clusterMetrics = { cpu: clusterCpu, memory: clusterMem }; clusterMetrics = { cpu: clusterCpu, memory: clusterMem };
} }
res.json({ servers, clusterMetrics }); return clusterMetrics;
} }

View file

@ -4,158 +4,130 @@ import k8s from "@kubernetes/client-node";
import yaml from "js-yaml"; import yaml from "js-yaml";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import ExpressClientError from "../util/ExpressClientError.js";
import {
getFtpContainer,
getServerContainer,
getBackupContainer,
} from "./server-containers.js";
const kc = new k8s.KubeConfig(); const kc = new k8s.KubeConfig();
kc.loadFromDefault(); kc.loadFromDefault();
const k8sDeps = kc.makeApiClient(k8s.AppsV1Api); const k8sDeps = kc.makeApiClient(k8s.AppsV1Api);
const k8sCore = kc.makeApiClient(k8s.CoreV1Api); const k8sCore = kc.makeApiClient(k8s.CoreV1Api);
const namespace = process.env.MCL_SERVER_NAMESPACE; const namespace = process.env.MCL_SERVER_NAMESPACE;
function payloadFilter(req, res) { const loadYaml = (f) => yaml.load(fs.readFileSync(path.resolve(f), "utf8"));
const serverSpec = req.body;
if (!serverSpec) return res.sendStatus(400);
const { name, url, version, serverType, difficulty, gamemode, memory } =
serverSpec;
if (!name) return res.status(400).send("Server name is required!");
if (!url) return res.status(400).send("Server url is required!");
if (!version) return res.status(400).send("Server version is required!");
if (!difficulty)
return res.status(400).send("Server difficulty is required!");
if (!serverType) return res.status(400).send("Server type is required!");
if (!gamemode) return res.status(400).send("Server Gamemode is required!");
if (!memory) return res.status(400).send("Memory is required!");
req.body.name = req.body.name.toLowerCase();
return "filtered";
}
function createRconSecret(serverSpec) { function createRconSecret(serverSpec) {
const { name } = serverSpec; const { name } = serverSpec;
const rconYaml = yaml.load( const rconYaml = loadYaml("lib/k8s/configs/rcon-secret.yml");
fs.readFileSync(path.resolve("lib/k8s/configs/rcon-secret.yml"), "utf8")
);
// TODO: Dyamic rconPassword // TODO: Dyamic rconPassword
const rconPassword = bcrypt.hashSync(uuidv4(), 10); const rconPassword = bcrypt.hashSync(uuidv4(), 10);
rconYaml.data["rcon-password"] = Buffer.from(rconPassword).toString("base64"); rconYaml.data["rcon-password"] = Buffer.from(rconPassword).toString("base64");
rconYaml.metadata.labels.app = `mcl-${name}-app`; rconYaml.metadata.labels.app = `mcl-${name}-app`;
rconYaml.metadata.name = `mcl-${name}-rcon-secret`; rconYaml.metadata.name = `mcl-${name}-rcon-secret`;
rconYaml.metadata.namespace = namespace; rconYaml.metadata.namespace = namespace;
rconYaml.metadata.annotations["minecluster.dunemask.net/server-name"] = name;
return rconYaml; return rconYaml;
} }
function createServerVolume(serverSpec) { function createServerVolume(serverSpec) {
const { name } = serverSpec; const { name } = serverSpec;
const volumeYaml = yaml.load( const volumeYaml = loadYaml("lib/k8s/configs/server-pvc.yml");
fs.readFileSync(path.resolve("lib/k8s/configs/server-pvc.yml"), "utf8")
);
volumeYaml.metadata.labels.service = `mcl-${name}-server`; volumeYaml.metadata.labels.service = `mcl-${name}-server`;
volumeYaml.metadata.name = `mcl-${name}-volume`; volumeYaml.metadata.name = `mcl-${name}-volume`;
volumeYaml.metadata.namespace = namespace; volumeYaml.metadata.namespace = namespace;
volumeYaml.metadata.annotations["minecluster.dunemask.net/server-name"] =
name;
volumeYaml.spec.resources.requests.storage = "1Gi"; // TODO: Changeme volumeYaml.spec.resources.requests.storage = "1Gi"; // TODO: Changeme
return volumeYaml; return volumeYaml;
} }
function createServerDeploy(serverSpec) { function createServerDeploy(serverSpec) {
const { const { name, host } = serverSpec;
name, const deployYaml = loadYaml("lib/k8s/configs/server-deployment.yml");
version, const { metadata } = deployYaml;
serverType, const serverContainer = getServerContainer(serverSpec);
difficulty, const backupContainer = getBackupContainer(serverSpec);
gamemode, const ftpContainer = getFtpContainer(serverSpec);
memory,
motd, // Configure Metadata;
maxPlayers, metadata.name = `mcl-${name}`;
seed, metadata.namespace = namespace;
modpack, metadata.annotations["minecluster.dunemask.net/server-name"] = name;
ops, deployYaml.metadata = metadata;
whitelist,
} = serverSpec; // Configure Lables & Selectors
const deployYaml = yaml.load(
fs.readFileSync(
path.resolve("lib/k8s/configs/server-deployment.yml"),
"utf8"
)
);
deployYaml.metadata.name = `mcl-${name}`;
deployYaml.metadata.namespace = namespace;
deployYaml.spec.replicas = 0; // TODO: User control for autostart
deployYaml.spec.selector.matchLabels.app = `mcl-${name}-app`; deployYaml.spec.selector.matchLabels.app = `mcl-${name}-app`;
deployYaml.spec.template.metadata.labels.app = `mcl-${name}-app`; deployYaml.spec.template.metadata.labels.app = `mcl-${name}-app`;
deployYaml.spec.template.spec.containers.splice(0, 1); //TODO: Currently removing backup container
const serverContainer = deployYaml.spec.template.spec.containers[0];
// Enviornment variables
serverContainer.env.find(({ name: n }) => n === "TYPE").value = serverType;
serverContainer.env.find(({ name: n }) => n === "VERSION").value = version;
serverContainer.env.find(({ name: n }) => n === "DIFFICULTY").value =
difficulty;
serverContainer.env.find(({ name: n }) => n === "MODE").value = gamemode;
serverContainer.env.find(({ name: n }) => n === "MOTD").value = motd;
serverContainer.env.find(({ name: n }) => n === "MAX_PLAYERS").value =
maxPlayers;
serverContainer.env.find(({ name: n }) => n === "SEED").value = seed;
serverContainer.env.find(({ name: n }) => n === "OPS").value = ops;
serverContainer.env.find(({ name: n }) => n === "WHITELIST").value =
whitelist;
serverContainer.env.find(
({ name: n }) => n === "MEMORY"
).value = `${memory}M`;
if (version !== "VANILLA")
delete serverContainer.env.find(({ name: n }) => n === "MODPACK").value;
else
serverContainer.env.find(({ name: n }) => n === "MODPACK").value = modpack;
serverContainer.env.find(
({ name }) => name === "RCON_PASSWORD"
).valueFrom.secretKeyRef.name = `mcl-${name}-rcon-secret`;
// Server Container Name
serverContainer.name = `mcl-${name}`;
// Resources
serverContainer.resources.requests.memory = `${memory}Mi`;
// serverContainer.resources.limits.memory = `${memory}Mi`; // TODO Allow for limits beyond initial startup
// Volumes // Volumes
deployYaml.spec.template.spec.volumes.find( deployYaml.spec.template.spec.volumes.find(
({ name }) => name === "datadir" ({ name }) => name === "datadir",
).persistentVolumeClaim.claimName = `mcl-${name}-volume`; ).persistentVolumeClaim.claimName = `mcl-${name}-volume`;
deployYaml.spec.template.spec.containers[0] = serverContainer;
// Apply Containers TODO: User control for autostart
deployYaml.spec.template.spec.containers.push(serverContainer);
deployYaml.spec.template.spec.containers.push(ftpContainer);
deployYaml.spec.replicas = 1;
return deployYaml; return deployYaml;
} }
function createServerService(serverSpec) { function createServerService(serverSpec) {
const { name, url } = serverSpec; const { name, host } = serverSpec;
const serviceYaml = yaml.load( const serviceYaml = loadYaml("lib/k8s/configs/server-svc.yml");
fs.readFileSync(path.resolve("lib/k8s/configs/server-svc.yml"), "utf8") serviceYaml.metadata.annotations["ingress.qumine.io/hostname"] = host;
); serviceYaml.metadata.annotations["mc-router.itzg.me/externalServerName"] =
serviceYaml.metadata.annotations["ingress.qumine.io/hostname"] = url; host;
serviceYaml.metadata.labels.app = `mcl-${name}-app`; serviceYaml.metadata.labels.app = `mcl-${name}-app`;
serviceYaml.metadata.name = `mcl-${name}-server`; serviceYaml.metadata.name = `mcl-${name}-server`;
serviceYaml.metadata.namespace = namespace; serviceYaml.metadata.namespace = namespace;
serviceYaml.metadata.annotations["minecluster.dunemask.net/server-name"] =
name;
serviceYaml.spec.selector.app = `mcl-${name}-app`; serviceYaml.spec.selector.app = `mcl-${name}-app`;
// Port List:
const serverPortList = [{ p: 25565, n: "minecraft" }];
// Apply FTP Port List
const ftpPortList = [
{ p: 20, n: "ftp-data" },
{ p: 21, n: "ftp-commands" },
];
for (var p = 40000; p <= 40009; p++)
ftpPortList.push({ p, n: `ftp-passive-${p - 40000}` });
const portList = [...serverPortList, ...ftpPortList];
serviceYaml.spec.ports = portList.map(({ p: port, n: name }) => ({
port,
name,
protocol: "TCP",
targetPort: port,
}));
return serviceYaml; return serviceYaml;
} }
function createRconService(serverSpec) { function createRconService(serverSpec) {
const { name, url } = serverSpec; const { name } = serverSpec;
const rconSvcYaml = yaml.load( const rconSvcYaml = loadYaml("lib/k8s/configs/rcon-svc.yml");
fs.readFileSync(path.resolve("lib/k8s/configs/rcon-svc.yml"), "utf8")
);
rconSvcYaml.metadata.labels.app = `mcl-${name}-app`; rconSvcYaml.metadata.labels.app = `mcl-${name}-app`;
rconSvcYaml.metadata.name = `mcl-${name}-rcon`; rconSvcYaml.metadata.name = `mcl-${name}-rcon`;
rconSvcYaml.metadata.namespace = namespace; rconSvcYaml.metadata.namespace = namespace;
rconSvcYaml.metadata.annotations["minecluster.dunemask.net/server-name"] =
name;
rconSvcYaml.spec.selector.app = `mcl-${name}-app`; rconSvcYaml.spec.selector.app = `mcl-${name}-app`;
return rconSvcYaml; return rconSvcYaml;
} }
export default async function createServer(req, res) { export default async function createServerResources(serverSpec) {
if (payloadFilter(req, res) !== "filtered") return;
const serverSpec = req.body;
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace); const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace);
const deployments = deploymentRes.body.items.map((i) => i.metadata.name); const deployments = deploymentRes.body.items.map((i) => i.metadata.name);
if (deployments.includes(`mcl-${serverSpec.name}`)) if (deployments.includes(`mcl-${serverSpec.name}`))
return res.status(409).send("Server already exists!"); throw new ExpressClientError({ m: "Server already exists!", c: 409 });
const pvcRes = await k8sCore.listNamespacedPersistentVolumeClaim(namespace); const pvcRes = await k8sCore.listNamespacedPersistentVolumeClaim(namespace);
const pvcs = pvcRes.body.items.map((i) => i.metadata.name); const pvcs = pvcRes.body.items.map((i) => i.metadata.name);
if (pvcs.includes(`mcl-${serverSpec.name}-volume`)) if (pvcs.includes(`mcl-${serverSpec.name}-volume`))
return res.status(409).send("Server PVC already exists!"); throw new ExpressClientError({ m: "Server PVC already exists!", c: 409 });
const rconSecret = createRconSecret(serverSpec); const rconSecret = createRconSecret(serverSpec);
const serverVolume = createServerVolume(serverSpec); const serverVolume = createServerVolume(serverSpec);
const serverDeploy = createServerDeploy(serverSpec); const serverDeploy = createServerDeploy(serverSpec);
@ -166,6 +138,4 @@ export default async function createServer(req, res) {
k8sCore.createNamespacedService(namespace, serverService); k8sCore.createNamespacedService(namespace, serverService);
k8sCore.createNamespacedService(namespace, rconService); k8sCore.createNamespacedService(namespace, rconService);
k8sDeps.createNamespacedDeployment(namespace, serverDeploy); k8sDeps.createNamespacedDeployment(namespace, serverDeploy);
res.sendStatus(200);
} }

View file

@ -1,5 +1,7 @@
import k8s from "@kubernetes/client-node"; import k8s from "@kubernetes/client-node";
import { ERR } from "../util/logging.js"; import { ERR } from "../util/logging.js";
import { getServerAssets } from "./k8s-server-control.js";
import ExpressClientError from "../util/ExpressClientError.js";
const kc = new k8s.KubeConfig(); const kc = new k8s.KubeConfig();
kc.loadFromDefault(); kc.loadFromDefault();
@ -7,49 +9,52 @@ const k8sDeps = kc.makeApiClient(k8s.AppsV1Api);
const k8sCore = kc.makeApiClient(k8s.CoreV1Api); const k8sCore = kc.makeApiClient(k8s.CoreV1Api);
const namespace = process.env.MCL_SERVER_NAMESPACE; const namespace = process.env.MCL_SERVER_NAMESPACE;
const deleteError = (res) => (err) => { const deleteError = (err) => {
res.status(500).send("Error deleting a resource!");
ERR("K8S", "An error occurred while deleting a resource", err); ERR("K8S", "An error occurred while deleting a resource", err);
throw new ExpressClientError({
c: 500,
m: "Error deleting a resource!\n" + err,
});
}; };
export default async function deleteServer(req, res) { function deleteOnExist(o, fn) {
const serverSpec = req.body; if (o) return fn(o.metadata.name);
if (!serverSpec) return res.sendStatus(400); }
if (!serverSpec.name) return res.status(400).send("Server name required!");
export default async function deleteServerResources(serverSpec) {
const { name } = serverSpec; const { name } = serverSpec;
// Ensure deployment exists // Ensure deployment exists
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace); const server = await getServerAssets(name);
const deployments = deploymentRes.body.items.map((i) => i.metadata.name); if (!server)
if (!deployments.includes(`mcl-${serverSpec.name}`)) throw new ExpressClientError({
return res.status(409).send("Server does not exist!"); c: 404,
m: "No Resources for that server were found!",
});
// Delete in reverse order // Delete in reverse order
const deleteDeploy = k8sDeps.deleteNamespacedDeployment( const deleteDeploy = deleteOnExist(server.deployment, (name) =>
`mcl-${serverSpec.name}`, k8sDeps.deleteNamespacedDeployment(name, namespace),
namespace
); );
const deleteService = k8sCore.deleteNamespacedService(
`mcl-${name}-server`, const deleteService = deleteOnExist(server.service, (name) =>
namespace k8sCore.deleteNamespacedService(name, namespace),
); );
const deleteRconService = k8sCore.deleteNamespacedService( const deleteRconService = deleteOnExist(server.rconService, (name) =>
`mcl-${name}-rcon`, k8sCore.deleteNamespacedService(name, namespace),
namespace
); );
await deleteDeploy.catch(deleteError(res)); if (deleteDeploy) await deleteDeploy.catch(deleteError);
const deleteRconSecret = k8sCore.deleteNamespacedSecret(
`mcl-${name}-rcon-secret`, const deleteRconSecret = deleteOnExist(server.rconSecret, (name) =>
namespace k8sCore.deleteNamespacedSecret(name, namespace),
); );
const deleteVolume = k8sCore.deleteNamespacedPersistentVolumeClaim( const deleteVolume = deleteOnExist(server.volume, (name) =>
`mcl-${name}-volume`, k8sCore.deleteNamespacedPersistentVolumeClaim(name, namespace),
namespace
); );
Promise.all([
return Promise.all([
deleteService, deleteService,
deleteRconService, deleteRconService,
deleteRconSecret, deleteRconSecret,
deleteVolume, deleteVolume,
]) ]).catch(deleteError);
.then(() => res.sendStatus(200))
.catch(deleteError(res));
} }

97
lib/k8s/server-files.js Normal file
View file

@ -0,0 +1,97 @@
import ftp from "basic-ftp";
import { ERR } from "../util/logging.js";
import { getServerAssets } from "./k8s-server-control.js";
import ExpressClientError from "../util/ExpressClientError.js";
import { Readable, Writable, Transform } from "node:stream";
const namespace = process.env.MCL_SERVER_NAMESPACE;
const pathSecurityCheck = (path) => {
if (!path.startsWith("."))
throw new ExpressClientError({
m: "Only relative directories can be created",
c: 409,
});
};
const handleError = (e) => {
ERR("SERVER FILES", "Error occurred while preforming FTP operation!", e);
throw new ExpressClientError({
c: 500,
m: "Error occurred while performing FTP operation!",
});
};
export async function getFtpClient(serverService) {
const { name } = serverService.metadata;
const client = new ftp.Client();
await client.access({
host: `${name}.${namespace}.svc.cluster.local`,
user: "minecluster",
password: "minecluster",
});
return client;
}
export async function useServerFtp(serverSpec, fn) {
const { name } = serverSpec;
const server = await getServerAssets(name);
if (!server)
throw new ExpressClientError({
c: 404,
m: "No resources for that server were found!",
});
if (!server.service)
throw new ExpressClientError({
c: 409,
m: "Service doesn't exist, please contact your hosting provider!",
});
const client = await getFtpClient(server.service);
const result = await fn(client);
client.close();
return result;
}
export async function listServerFiles(serverSpec) {
const { path } = serverSpec;
const files = useServerFtp(serverSpec, async (c) => await c.list(path)).catch(
handleError,
);
return files;
}
export async function createServerFolder(serverSpec) {
const { path } = serverSpec;
pathSecurityCheck(path);
await useServerFtp(serverSpec, async (c) => c.ensureDir(path)).catch(
handleError,
);
}
export async function removeServerItem(serverSpec) {
const { path, isDir } = serverSpec;
pathSecurityCheck(path);
await useServerFtp(serverSpec, async (c) => {
if (isDir) await c.removeDir(path);
else await c.remove(path);
}).catch(handleError);
}
export async function uploadServerItem(serverSpec, file) {
const fileStream = Readable.from(file.buffer);
const { path } = serverSpec;
pathSecurityCheck(path);
await useServerFtp(serverSpec, async (c) => {
await c.uploadFrom(fileStream, `${path}/${file.originalname}`);
}).catch(handleError);
}
export async function getServerItem(serverSpec, writableStream) {
const { path } = serverSpec;
const ds = new Transform({ transform: (c, e, cb) => cb(null, c) });
pathSecurityCheck(path);
const ftpTransfer = useServerFtp(serverSpec, async (c) => {
await c.downloadTo(ds, path);
}).catch(handleError);
return { ds, ftpTransfer };
}

17
lib/routes/error-route.js Normal file
View file

@ -0,0 +1,17 @@
export function logErrors(err, req, res, next) {
console.error(err.stack);
next(err);
}
export function clientErrorHandler(err, req, res, next) {
if (req.xhr) {
res.status(500).send({ error: "Something failed!" });
} else {
next(err);
}
}
export function errorHandler(err, req, res, next) {
res.status(500);
res.render("error", { error: err });
}

21
lib/routes/files-route.js Normal file
View file

@ -0,0 +1,21 @@
import { Router, json as jsonMiddleware } from "express";
import multer from "multer";
import {
createFolder,
deleteItem,
listFiles,
uploadItem,
getItem,
} from "../controllers/file-controller.js";
const router = Router();
router.use(jsonMiddleware());
const multerMiddleware = multer();
router.post("/list", listFiles);
router.post("/folder", createFolder);
router.delete("/item", deleteItem);
router.post("/item", getItem);
router.post("/upload", multerMiddleware.single("file"), uploadItem);
export default router;

View file

@ -3,6 +3,6 @@ import path from "path";
const router = Router(); const router = Router();
router.use("/", express.static(path.resolve("./build"))); router.use("/", express.static(path.resolve("./build")));
router.get("/*", (req, res) => router.get("/*", (req, res) =>
res.sendFile(path.resolve("./build/index.html")) res.sendFile(path.resolve("./build/index.html")),
); );
export default router; export default router;

View file

@ -1,12 +1,14 @@
import { Router, json as jsonMiddleware } from "express"; import { Router, json as jsonMiddleware } from "express";
import { import {
createServer,
deleteServer,
startServer, startServer,
stopServer, stopServer,
} from "../controllers/lifecycle-controller.js";
import {
serverInstances,
serverList, serverList,
getServers, } from "../controllers/status-controller.js";
} from "../k8s/server-control.js";
import createServer from "../k8s/server-create.js";
import deleteServer from "../k8s/server-delete.js";
const router = Router(); const router = Router();
router.use(jsonMiddleware()); router.use(jsonMiddleware());
// Routes // Routes
@ -15,5 +17,5 @@ router.delete("/delete", deleteServer);
router.post("/start", startServer); router.post("/start", startServer);
router.post("/stop", stopServer); router.post("/stop", stopServer);
router.get("/list", serverList); router.get("/list", serverList);
router.get("/instances", getServers); router.get("/instances", serverInstances);
export default router; export default router;

View file

@ -7,6 +7,8 @@ kc.loadFromDefault();
const k8sApi = kc.makeApiClient(k8s.CoreV1Api); const k8sApi = kc.makeApiClient(k8s.CoreV1Api);
// Get Routes // Get Routes
router.get("/available", (req, res) => { router.get("/available", (req, res) => {
return res.json({ cpu: 8000, memory: 16000 });
// TODO Workaround to detect available
k8sApi.listNode().then((nodeRes) => { k8sApi.listNode().then((nodeRes) => {
const nodeAllocatable = nodeRes.body.items.map((i) => i.status.allocatable); const nodeAllocatable = nodeRes.body.items.map((i) => i.status.allocatable);
const nodeResources = nodeAllocatable.map(({ cpu, memory }) => ({ const nodeResources = nodeAllocatable.map(({ cpu, memory }) => ({

View file

@ -1,31 +0,0 @@
import k8s from "@kubernetes/client-node";
import { Rcon as RconClient } from "rcon-client";
import { ERR } from "../util/logging.js";
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
const k8sCore = kc.makeApiClient(k8s.CoreV1Api);
const namespace = process.env.MCL_SERVER_NAMESPACE;
export default async function rconInterface(socket) {
if (socket.rconClient)
return VERB("RCON", "Socket already connected to RCON");
const rconSecret = `mcl-${socket.mcs.serverName}-rcon-secret`;
const rconRes = await k8sCore.readNamespacedSecret(rconSecret, namespace);
const rconPassword = Buffer.from(
rconRes.body.data["rcon-password"],
"base64"
).toString("utf8");
const rconHost = `mcl-${socket.mcs.serverName}-rcon`;
const rcon = new RconClient({
host: rconHost,
port: 25575,
password: rconPassword,
});
rcon.on("error", (error) => socket.emit("push", error));
try {
await rcon.connect();
} catch (error) {
ERR("RCON", `Could not connect to 'mcl-${socket.mcs.serverName}-rcon'`);
}
socket.rconClient = rcon;
}

View file

@ -5,7 +5,13 @@ import express from "express";
import vitals from "../routes/vitals-route.js"; import vitals from "../routes/vitals-route.js";
import systemRoute from "../routes/system-route.js"; import systemRoute from "../routes/system-route.js";
import serverRoute from "../routes/server-route.js"; import serverRoute from "../routes/server-route.js";
import filesRoute from "../routes/files-route.js";
import reactRoute from "../routes/react-route.js"; import reactRoute from "../routes/react-route.js";
import {
logErrors,
clientErrorHandler,
errorHandler,
} from "../routes/error-route.js";
export default function buildRoutes(pg, skio) { export default function buildRoutes(pg, skio) {
const router = express.Router(); const router = express.Router();
@ -18,7 +24,11 @@ export default function buildRoutes(pg, skio) {
// Routes // Routes
router.use("/api/system", systemRoute); router.use("/api/system", systemRoute);
router.use("/api/server", serverRoute); router.use("/api/server", serverRoute);
router.use(["/mcl","/mcl/*"], reactRoute); // Static Build Route router.use("/api/files", filesRoute);
router.use(["/mcl", "/mcl/*"], reactRoute); // Static Build Route
/*router.use(logErrors);
router.use(clientErrorHandler);
router.use(errorHandler);*/
return router; return router;
} }

View file

@ -1,9 +1,9 @@
import { Server as Skio } from "socket.io"; import { Server as Skio } from "socket.io";
import { VERB, WARN, ERR } from "../util/logging.js"; import { VERB, WARN, ERR } from "../util/logging.js";
import liveLogging from "../k8s/live-logging.js"; import {
import rconInterface from "./rcon.js"; webConsoleLogs,
webConsoleRcon,
const namespace = process.env.MCL_SERVER_NAMESPACE; } from "../controllers/sub-controllers/console-controller.js";
async function rconSend(socket, m) { async function rconSend(socket, m) {
if (!socket.rconClient) if (!socket.rconClient)
@ -20,8 +20,8 @@ const socketConnect = async (io, socket) => {
VERB("WS", "Websocket connecting"); VERB("WS", "Websocket connecting");
socket.mcs = { serverName: socket.handshake.query.serverName }; socket.mcs = { serverName: socket.handshake.query.serverName };
try { try {
await liveLogging(socket, namespace); await webConsoleLogs(socket);
await rconInterface(socket); await webConsoleRcon(socket);
socket.on("msg", (m) => rconSend(socket, m)); socket.on("msg", (m) => rconSend(socket, m));
} catch (err) { } catch (err) {
ERR("SOCKETS", err); ERR("SOCKETS", err);

View file

@ -0,0 +1,34 @@
import multer from "multer";
import multerS3 from "multer-s3";
import AWS from "aws-sdk";
// Environment Variables
const {
MCL_S3_ENDPOINT: s3Endpoint,
MCL_S3_ACCESS_KEY_ID: s3KeyId,
MCL_S3_ACCESS_KEY: s3Key,
} = process.env;
export const mcl = "mcl";
export const s3 = new AWS.S3({
endpoint: s3Endpoint,
accessKeyId: s3KeyId,
secretAccessKey: s3Key,
sslEnabled: true,
s3ForcePathStyle: true,
});
const storage = multerS3({
s3,
bucket,
contentType: multerS3.AUTO_CONTENT_TYPE,
metadata: (req, file, cb) => {
cb(null, { fieldName: file.fieldname });
},
key: (req, file, cb) => {
cb(null, Date.now().toString());
},
});
export const upload = multer({ storage });

View file

@ -0,0 +1,28 @@
import { VERB } from "./logging.js";
export default class ExpressClientError extends Error {
constructor(message, clientOptions = {}) {
var msg;
if (typeof message === "object" && message.m !== undefined) msg = message.m;
else if (typeof message === "object") msg = "Unknown Express Client Error!";
super(msg);
if (typeof message === "object") this.clientOptions = message;
else this.clientOptions = { message: msg, ...clientOptions };
}
sendError(res) {
if (!this.clientOptions.m && this.clientOptions.c)
res.sendStatus(this.clientOptions.c);
else res.status(this.clientOptions.c ?? 500).send(this.toString());
}
toString() {
return this.message;
}
}
export const sendError = (res) => (e) => {
VERB("V", e);
if (e instanceof ExpressClientError) e.sendError(res);
else res.status(500).send(e);
};

4445
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -24,28 +24,37 @@
"devDependencies": { "devDependencies": {
"@emotion/react": "^11.11.1", "@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.14.3", "@mui/icons-material": "^5.14.19",
"@mui/material": "^5.14.5", "@mui/material": "^5.14.20",
"@tanstack/react-query": "^4.33.0", "@tanstack/react-query": "^5.12.2",
"@vitejs/plugin-react": "^4.0.4", "@vitejs/plugin-react": "^4.2.1",
"concurrently": "^8.2.0", "concurrently": "^8.2.2",
"nodemon": "^3.0.1", "nodemon": "^3.0.2",
"prettier": "^3.0.2", "prettier": "^3.1.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.15.0", "react-router-dom": "^6.20.1",
"socket.io-client": "^4.7.2", "socket.io-client": "^4.7.2",
"vite": "^4.4.9" "vite": "^5.0.7"
}, },
"dependencies": { "dependencies": {
"@kubernetes/client-node": "^0.18.1", "@kubernetes/client-node": "^0.20.0",
"aws-sdk": "^2.1514.0",
"basic-ftp": "^5.0.4",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"chonky": "^2.3.2",
"chonky-icon-fontawesome": "^2.3.2",
"express": "^4.18.2", "express": "^4.18.2",
"figlet": "^1.6.0", "figlet": "^1.7.0",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"rcon-client": "^4.2.3", "moment": "^2.29.4",
"multer": "^1.4.5-lts.1",
"multer-s3": "^3.0.1",
"pg-promise": "^11.5.4",
"postgres-migrations": "^5.3.0",
"rcon-client": "^4.2.4",
"socket.io": "^4.7.2", "socket.io": "^4.7.2",
"uuid": "^9.0.0" "uuid": "^9.0.1"
} }
} }

View file

@ -1,4 +1,6 @@
// Imports // Imports
import { ThemeProvider } from "@mui/material/styles";
import mclTheme from "./util/theme.js";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { SettingsProvider } from "@mcl/settings"; import { SettingsProvider } from "@mcl/settings";
import Viewport from "./nav/Viewport.jsx"; import Viewport from "./nav/Viewport.jsx";
@ -11,9 +13,11 @@ export default function MCL() {
<div className="minecluster"> <div className="minecluster">
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<SettingsProvider> <SettingsProvider>
<BrowserRouter> <ThemeProvider theme={mclTheme}>
<Viewport /> <BrowserRouter>
</BrowserRouter> <Viewport />
</BrowserRouter>
</ThemeProvider>
</SettingsProvider> </SettingsProvider>
</QueryClientProvider> </QueryClientProvider>
</div> </div>

View file

@ -0,0 +1,42 @@
// ChonkyFullFileBrowser.tsx
import { forwardRef, memo } from "react";
import {
StylesProvider,
createGenerateClassName,
} from "@material-ui/core/styles";
import {
FileBrowser,
FileList,
FileContextMenu,
FileNavbar,
FileToolbar,
setChonkyDefaults,
FileBrowserHandle,
FileBrowserProps,
} from "chonky";
import { ChonkyIconFA } from "chonky-icon-fontawesome";
setChonkyDefaults({ iconComponent: ChonkyIconFA });
const muiJSSClassNameGenerator = createGenerateClassName({
// Seed property is used to add a prefix classes generated by material ui.
seed: "chonky",
});
export default memo(
forwardRef((props, ref) => {
const { onScroll } = props;
return (
<StylesProvider generateClassName={muiJSSClassNameGenerator}>
<FileBrowser ref={ref} {...props}>
<FileNavbar />
<FileToolbar />
<FileList onScroll={onScroll} />
<FileContextMenu />
</FileBrowser>
</StylesProvider>
);
}),
);

View file

@ -0,0 +1,146 @@
import { useState, useEffect, useMemo, useRef } from "react";
import Box from "@mui/material/Box";
import {
FileBrowser,
FileContextMenu,
FileList,
FileNavbar,
FileToolbar,
setChonkyDefaults,
ChonkyActions,
} from "chonky";
import { ChonkyIconFA } from "chonky-icon-fontawesome";
import {
getServerFiles,
createServerFolder,
deleteServerItem,
getServerItem,
} from "@mcl/queries";
import "@mcl/css/header.css";
export default function MineclusterFiles(props) {
// Chonky configuration
setChonkyDefaults({ iconComponent: ChonkyIconFA });
const fileActions = useMemo(
() => [
ChonkyActions.CreateFolder,
ChonkyActions.UploadFiles,
ChonkyActions.DownloadFiles,
ChonkyActions.CopyFiles,
ChonkyActions.DeleteFiles,
],
[],
);
const { server: serverName } = props;
const inputRef = useRef(null);
const [dirStack, setDirStack] = useState(["."]);
const [files, setFiles] = useState([]);
const updateFiles = () =>
getServerFiles(serverName, dirStack.join("/")).then((f) =>
setFiles(f ?? []),
);
useEffect(() => {
updateFiles();
}, [dirStack]);
const getFolderChain = () => {
if (dirStack.length === 1) return [{ id: "home", name: "/", isDir: true }];
return dirStack.map((d, i) => ({ id: `${d}-${i}`, name: d, isDir: true }));
};
const openParentFolder = () => setDirStack(dirStack.slice(0, -1));
function openFolder(payload) {
const { targetFile: file } = payload;
if (!file || !file.isDir) return;
setDirStack([...dirStack, file.name]);
}
function createFolder() {
const name = prompt("What is the name of the new folder?");
const path = [...dirStack, name].join("/");
createServerFolder(serverName, path).then(updateFiles);
}
function deleteItems(files) {
Promise.all(
files.map((f) =>
deleteServerItem(serverName, [...dirStack, f.name].join("/"), f.isDir),
),
)
.catch((e) => console.error("Error deleting some files!", e))
.then(updateFiles);
}
function uploadFileSelection(e) {
if (!e.target.files || e.target.files.length === 0) return;
const { files } = e.target;
Promise.all([...files].map((f) => uploadFile(f)))
.catch((e) => console.log("Error uploading a file", e))
.then(updateFiles);
}
async function uploadFile(file) {
const formData = new FormData();
formData.append("file", file);
formData.append("name", serverName);
formData.append("path", [...dirStack, name].join("/"));
await fetch("/api/files/upload", {
method: "POST",
body: formData,
});
}
async function downloadFiles(files) {
Promise.all(
files.map((f) =>
getServerItem(serverName, f.name, [...dirStack, f.name].join("/")),
),
)
.then(() => console.log("Done"))
.catch((e) => console.error("Error Downloading files!", e));
}
function fileClick(chonkyEvent) {
const { id: clickEvent, payload } = chonkyEvent;
console.log(chonkyEvent);
if (clickEvent === "open_parent_folder") return openParentFolder();
if (clickEvent === "create_folder") return createFolder();
if (clickEvent === "upload_files") return inputRef.current.click();
if (clickEvent === "download_files")
return downloadFiles(chonkyEvent.state.selectedFilesForAction);
if (clickEvent === "delete_files")
return deleteItems(chonkyEvent.state.selectedFilesForAction);
if (clickEvent !== "open_files") return console.log(clickEvent);
openFolder(payload);
}
return (
<Box className="minecluster-files" sx={{ height: "calc(100vh - 6rem)" }}>
<input
type="file"
id="file"
ref={inputRef}
style={{ display: "none" }}
onChange={uploadFileSelection}
multiple
/>
<FileBrowser
files={files}
folderChain={getFolderChain()}
onFileAction={fileClick}
fileActions={fileActions}
darkMode={true}
>
<FileNavbar />
<FileToolbar />
<FileList />
<FileContextMenu />
</FileBrowser>
</Box>
);
}

View file

@ -14,6 +14,8 @@ import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import PendingIcon from "@mui/icons-material/Pending"; import PendingIcon from "@mui/icons-material/Pending";
import DeleteForeverIcon from "@mui/icons-material/DeleteForever"; import DeleteForeverIcon from "@mui/icons-material/DeleteForever";
import EditIcon from "@mui/icons-material/Edit"; import EditIcon from "@mui/icons-material/Edit";
import FolderIcon from "@mui/icons-material/Folder";
import { Link } from "react-router-dom";
export default function ServerCard(props) { export default function ServerCard(props) {
const { server, openRcon } = props; const { server, openRcon } = props;
@ -94,9 +96,24 @@ export default function ServerCard(props) {
> >
<TerminalIcon /> <TerminalIcon />
</IconButton> </IconButton>
<IconButton color="primary" aria-label="Edit" size="large"> <IconButton
color="primary"
aria-label="Edit"
size="large"
component={Link}
to={`/mcl/edit?server=${name}`}
>
<EditIcon /> <EditIcon />
</IconButton> </IconButton>
<IconButton
color="primary"
aria-label="Files"
size="large"
component={Link}
to={`/mcl/files?server=${name}`}
>
<FolderIcon />
</IconButton>
<IconButton <IconButton
color="error" color="error"
aria-label="Delete Server" aria-label="Delete Server"

31
src/css/header.css Normal file
View file

@ -0,0 +1,31 @@
.appbar-items {
font-size: 1.25rem;
font-family: "Roboto", "Helvetica", "Arial", sans-serif;
font-weight: 500;
line-height: 1.6;
letter-spacing: 0.0075em;
}
.view > header {
transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
box-shadow:
0px 2px 4px -1px rgba(0, 0, 0, 0.2),
0px 4px 5px 0px rgba(0, 0, 0, 0.14),
0px 1px 10px 0px rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: column;
width: 100%;
box-sizing: border-box;
flex-shrink: 0;
position: fixed;
top: 0;
left: auto;
right: 0;
color: rgba(0, 0, 0, 0.87);
z-index: 1302;
background-color: black;
}
.view > header > div > div > a {
height: 40px;
width: 40px;
}

View file

@ -13,7 +13,7 @@ import IconButton from "@mui/material/IconButton";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import MenuIcon from "@mui/icons-material/Menu"; import MenuIcon from "@mui/icons-material/Menu";
import Drawer from "@mui/material/Drawer"; import Drawer from "@mui/material/Drawer";
import ListItemIcon from "@mui/material/ListItemIcon"; import HomeIcon from "@mui/icons-material/Home";
import ListItemText from "@mui/material/ListItemText"; import ListItemText from "@mui/material/ListItemText";
import List from "@mui/material/List"; import List from "@mui/material/List";
import ListItemButton from "@mui/material/ListItemButton"; import ListItemButton from "@mui/material/ListItemButton";
@ -36,48 +36,23 @@ export default function MCLMenu() {
theme.zIndex.modal + 2 - (isDrawer ? 1 : 0); theme.zIndex.modal + 2 - (isDrawer ? 1 : 0);
return ( return (
<AppBar position="fixed" sx={{ bgcolor: "black", zIndex: drawerIndex() }}> <AppBar
<Box sx={{ flexGrow: 1, margin: "0 20px" }}> position="fixed"
color="primary"
sx={{ zIndex: drawerIndex(), bgcolor: "black" }}
enableColorOnDark={true}
>
<Box
sx={{ flexGrow: 1, margin: "0 20px", color: "white" }}
className="appbar-items"
>
<Toolbar disableGutters> <Toolbar disableGutters>
<IconButton <IconButton component={Link} to="/" color="inherit">
size="large" <HomeIcon />
edge="start"
color="inherit"
aria-label="menu"
sx={{ mr: 2 }}
onClick={toggleDrawer}
>
<MenuIcon />
</IconButton> </IconButton>
<Drawer <span style={{ margin: "auto 0", color: "inherit" }}>
open={drawerOpen}
onClose={closeDrawer}
sx={{ zIndex: drawerIndex(true) }}
>
<Toolbar />
<Box
sx={{ width: drawerWidth, overflow: "auto" }}
role="presentation"
>
<List>
{pages.map((page, index) => (
<ListItemButton
key={index}
component={Link}
to={page.path}
selected={location.pathname === page.path}
onClick={closeDrawer}
>
<ListItemIcon>{page.icon}</ListItemIcon>
<ListItemText primary={page.name} />
</ListItemButton>
))}
</List>
</Box>
</Drawer>
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
{navHeader()} {navHeader()}
</Typography> </span>
</Toolbar> </Toolbar>
</Box> </Box>
</AppBar> </AppBar>

View file

@ -1,5 +1,6 @@
import Home from "@mcl/pages/Home.jsx"; import Home from "@mcl/pages/Home.jsx";
import Create from "@mcl/pages/Create.jsx"; import Create from "@mcl/pages/Create.jsx";
import Files from "@mcl/pages/Files.jsx";
// Go To https://mui.com/material-ui/material-icons/ for more! // Go To https://mui.com/material-ui/material-icons/ for more!
import HomeIcon from "@mui/icons-material/Home"; import HomeIcon from "@mui/icons-material/Home";
import AddIcon from "@mui/icons-material/Add"; import AddIcon from "@mui/icons-material/Add";
@ -17,4 +18,10 @@ export default [
icon: <AddIcon />, icon: <AddIcon />,
component: <Create />, component: <Create />,
}, },
{
name: "Edit",
path: "/mcl/files",
icon: <AddIcon />,
component: <Files />,
},
]; ];

View file

@ -1,6 +1,8 @@
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Toolbar from "@mui/material/Toolbar"; import Toolbar from "@mui/material/Toolbar";
import MCLPortal from "./MCLPortal.jsx"; import MCLPortal from "./MCLPortal.jsx";
import Button from "@mui/material/Button";
import SpeedDialIcon from "@mui/material/SpeedDialIcon";
// Import Navbar // Import Navbar
/*import Navbar from "./Navbar.jsx";*/ /*import Navbar from "./Navbar.jsx";*/
import MCLMenu from "./MCLMenu.jsx"; import MCLMenu from "./MCLMenu.jsx";

View file

@ -1,152 +1,12 @@
import { useState, useEffect } from "react";
import TextField from "@mui/material/TextField";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Button from "@mui/material/Button"; import CreateOptions from "./CreateOptions.jsx";
import Select from "@mui/material/Select";
import MenuItem from "@mui/material/MenuItem";
import InputLabel from "@mui/material/InputLabel";
import FormControl from "@mui/material/FormControl";
import { useCreateServer, useVersionList } from "@mcl/queries";
const defaultServer = {
version: "latest",
name: "example",
serverType: "VANILLA",
difficulty: "easy",
maxPlayers: "20",
gamemode: "survival",
memory: "1024",
motd: "Minecluster Server Hosting",
};
export default function Create() { export default function Create() {
const [spec, setSpec] = useState(defaultServer);
const versionList = useVersionList();
const [versions, setVersions] = useState(["latest"]);
const createServer = useCreateServer(spec);
const updateSpec = (attr, val) => {
const s = { ...spec };
s[attr] = val;
setSpec(s);
};
useEffect(() => {
if (!versionList.data) return;
setVersions([
"latest",
...versionList.data.versions
.filter(({ type: releaseType }) => releaseType === "release")
.map(({ id }) => id),
]);
}, [versionList.data]);
const coreUpdate = (attr) => (e) => updateSpec(attr, e.target.value);
function upsertSpec() {
if (validateSpec() !== "validated") return;
createServer(spec);
}
function validateSpec() {
console.log("TODO CREATE VALIDATION");
if (!spec.name) return alertValidationError("Name not included");
if (!spec.version) return alertValidationError("Version cannot be blank");
if (!spec.url) return alertValidationError("Url cannot be blank");
return "validated";
}
function alertValidationError(reason) {
alert(`Could not validate spec because: ${reason}`);
}
return ( return (
<Box className="create"> <Box className="create">
<FormControl fullWidth> {/*<CreateMenu />*/}
<TextField <Box className="create-wrapper" sx={{ display: "flex" }}>
label="Name" <CreateOptions />
onChange={coreUpdate("name")} </Box>
defaultValue={spec.name}
required
/>
<TextField label="URL" onChange={coreUpdate("url")} required />
<TextField
label="Version"
onChange={coreUpdate("version")}
value={spec.version}
select
required
SelectProps={{ MenuProps: { sx: { maxHeight: "12rem" } } }}
>
{versions.map((v, k) => (
<MenuItem value={v} key={k}>
{v}
</MenuItem>
))}
</TextField>
<TextField
label="Server Type"
onChange={coreUpdate("serverType")}
value={spec.serverType}
select
required
>
<MenuItem value={"VANILLA"}>Vanilla</MenuItem>
<MenuItem value={"FABRIC"}>Fabric</MenuItem>
<MenuItem value={"PAPER"}>Paper</MenuItem>
<MenuItem value={"SPIGOT"}>Spigot</MenuItem>
</TextField>
<TextField
label="Difficulty"
onChange={coreUpdate("difficulty")}
value={spec.difficulty}
select
required
>
<MenuItem value={"peaceful"}>Peaceful</MenuItem>
<MenuItem value={"easy"}>Easy</MenuItem>
<MenuItem value={"medium"}>Medium</MenuItem>
<MenuItem value={"hard"}>Hard</MenuItem>
</TextField>
<TextField label="Whitelist" onChange={coreUpdate("whitelist")} />
<TextField label="Ops" onChange={coreUpdate("ops")} />
<TextField label="Icon" onChange={coreUpdate("icon")} required />
<TextField
label="Max Players"
onChange={coreUpdate("maxPlayers")}
defaultValue={spec.maxPlayers}
required
/>
<TextField
label="Gamemode"
onChange={coreUpdate("gamemode")}
value={spec.gamemode}
select
required
>
<MenuItem value={"survival"}>Survival</MenuItem>
<MenuItem value={"creative"}>Creative</MenuItem>
<MenuItem value={"adventure"}>Adventure</MenuItem>
<MenuItem value={"spectator"}>Spectator</MenuItem>
</TextField>
<TextField label="Seed" onChange={coreUpdate("seed")} />
<TextField label="Modpack" onChange={coreUpdate("modpack")} />
<TextField
label="Memory"
onChange={coreUpdate("memory")}
defaultValue={spec.memory}
required
/>
<TextField
label="MOTD"
onChange={coreUpdate("motd")}
defaultValue={spec.motd}
required
/>
<Button onClick={upsertSpec} variant="contained">
Create
</Button>
</FormControl>
</Box> </Box>
); );
} }

248
src/pages/CreateOptions.jsx Normal file
View file

@ -0,0 +1,248 @@
import { useState, useEffect } from "react";
import Autocomplete from "@mui/material/Autocomplete";
import TextField from "@mui/material/TextField";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Chip from "@mui/material/Chip";
import Select from "@mui/material/Select";
import MenuItem from "@mui/material/MenuItem";
import InputLabel from "@mui/material/InputLabel";
import FormControl from "@mui/material/FormControl";
import { useCreateServer, useVersionList } from "@mcl/queries";
const defaultServer = {
version: "latest",
serverType: "VANILLA",
difficulty: "easy",
maxPlayers: "5",
gamemode: "survival",
memory: "512",
motd: `\\u00A7e\\u00A7ka\\u00A7l\\u00A7aMine\\u00A76Cluster\\u00A7r\\u00A78\\u00A7b\\u00A7ka`,
};
export default function Create() {
const [wl, setWl] = useState([]);
const [ops, setOps] = useState([]);
const [spec, setSpec] = useState(defaultServer);
const versionList = useVersionList();
const [versions, setVersions] = useState(["latest"]);
const createServer = useCreateServer(spec);
const updateSpec = (attr, val) => {
const s = { ...spec };
s[attr] = val;
setSpec(s);
console.log(s);
};
useEffect(() => {
if (!versionList.data) return;
setVersions([
"latest",
...versionList.data.versions
.filter(({ type: releaseType }) => releaseType === "release")
.map(({ id }) => id),
]);
}, [versionList.data]);
const coreUpdate = (attr) => (e) => updateSpec(attr, e.target.value);
function opsAdd(e) {
const opEntry = e.target.innerHTML ?? e.target.value;
if (!opEntry) return;
const newOps = [...ops, opEntry];
setOps(newOps);
updateSpec("ops", newOps.join(","));
}
function whitelistAdd(e) {
const wlEntry = e.target.value;
if (!wlEntry) return;
const newWl = [...wl, wlEntry];
setWl(newWl);
updateSpec("whitelist", newWl.join(","));
}
const opsRemove =
(name, { onDelete: updateAutoComplete }) =>
(e) => {
updateAutoComplete(e);
const newOps = [...ops];
const entryIndex = newOps.indexOf(name);
if (entryIndex === -1) return;
newOps.splice(entryIndex, 1);
setOps(newOps);
updateSpec("ops", newOps.join(","));
};
const whitelistRemove =
(name, { onDelete: updateAutocomplete }) =>
(e) => {
updateAutocomplete(e);
const newWl = [...wl];
const entryIndex = newWl.indexOf(name);
if (entryIndex === -1) return;
newWl.splice(entryIndex, 1);
setWl(newWl);
updateSpec("whitelist", newWl.join(","));
};
const opUpdate = (e) => alert("Op not implimented");
function upsertSpec() {
if (validateSpec() !== "validated") return;
createServer(spec);
}
function validateSpec() {
console.log("TODO CREATE VALIDATION");
if (!spec.name) return alertValidationError("Name not included");
if (!spec.version) return alertValidationError("Version cannot be blank");
if (!spec.host) return alertValidationError("Host cannot be blank");
return "validated";
}
function alertValidationError(reason) {
alert(`Could not validate spec because: ${reason}`);
}
return (
<Box
className="create-options"
sx={{ width: "100%", maxWidth: "600px", margin: "auto" }}
>
<FormControl fullWidth sx={{ mt: "2rem", display: "flex", gap: ".5rem" }}>
<TextField
label="Name"
onChange={coreUpdate("name")}
helperText="Example: My Survival World"
defaultValue={spec.name}
FormHelperTextProps={{ sx: { ml: 0 } }}
required
/>
<TextField
label="Host"
onChange={coreUpdate("host")}
helperText="Example: host.mc.example.com"
FormHelperTextProps={{ sx: { ml: 0 } }}
required
/>
<TextField
label="Version"
onChange={coreUpdate("version")}
value={spec.version}
select
required
SelectProps={{ MenuProps: { sx: { maxHeight: "20rem" } } }}
>
{versions.map((v, k) => (
<MenuItem value={v} key={k}>
{v}
</MenuItem>
))}
</TextField>
<TextField
label="Server Type"
onChange={coreUpdate("serverType")}
value={spec.serverType}
select
required
>
<MenuItem value={"VANILLA"}>Vanilla</MenuItem>
<MenuItem value={"FABRIC"}>Fabric</MenuItem>
<MenuItem value={"PAPER"}>Paper</MenuItem>
<MenuItem value={"SPIGOT"}>Spigot</MenuItem>
</TextField>
<TextField
label="Difficulty"
onChange={coreUpdate("difficulty")}
value={spec.difficulty}
select
required
>
<MenuItem value={"peaceful"}>Peaceful</MenuItem>
<MenuItem value={"easy"}>Easy</MenuItem>
<MenuItem value={"medium"}>Medium</MenuItem>
<MenuItem value={"hard"}>Hard</MenuItem>
</TextField>
<Autocomplete
multiple
id="whitelist-autocomplete"
options={[]}
onChange={whitelistAdd}
freeSolo
renderInput={(p) => <TextField {...p} label="Whitelist" />}
renderTags={(value, getTagProps) =>
value.map((option, index) => {
const defaultChipProps = getTagProps({ index });
return (
<Chip
label={option}
{...defaultChipProps}
onDelete={whitelistRemove(option, defaultChipProps)}
/>
);
})
}
/>
<Autocomplete
filterSelectedOptions={true}
multiple
id="ops-autocomplete"
options={wl}
onChange={opsAdd}
renderInput={(p) => <TextField {...p} label="Ops" />}
renderTags={(value, getTagProps) =>
value.map((option, index) => {
const defaultChipProps = getTagProps({ index });
return (
<Chip
label={option}
{...defaultChipProps}
onDelete={opsRemove(option, defaultChipProps)}
/>
);
})
}
/>
{/*<TextField label="Ops" onChange={coreUpdate("ops")} />*/}
{/*<TextField label="Icon" onChange={coreUpdate("icon")} required />*/}
<TextField
label="Max Players"
onChange={coreUpdate("maxPlayers")}
defaultValue={spec.maxPlayers}
required
/>
<TextField
label="Gamemode"
onChange={coreUpdate("gamemode")}
value={spec.gamemode}
select
required
>
<MenuItem value={"survival"}>Survival</MenuItem>
<MenuItem value={"creative"}>Creative</MenuItem>
<MenuItem value={"adventure"}>Adventure</MenuItem>
<MenuItem value={"spectator"}>Spectator</MenuItem>
</TextField>
<TextField label="Seed" onChange={coreUpdate("seed")} />
{/*<TextField label="Modpack" onChange={coreUpdate("modpack")} />*/}
<TextField
label="Memory"
onChange={coreUpdate("memory")}
defaultValue={spec.memory}
required
/>
<TextField
label="MOTD"
onChange={coreUpdate("motd")}
defaultValue={spec.motd}
required
/>
<Button onClick={upsertSpec} variant="contained">
Create
</Button>
</FormControl>
</Box>
);
}

20
src/pages/Files.jsx Normal file
View file

@ -0,0 +1,20 @@
import { useEffect } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Toolbar from "@mui/material/Toolbar";
import MineclusterFiles from "@mcl/components/files/MineclusterFiles.jsx";
export default function Files() {
const [searchParams] = useSearchParams();
const currentServer = searchParams.get("server");
const nav = useNavigate();
useEffect(() => {
if (!currentServer) nav("/");
}, [currentServer]);
return (
<Box className="edit" sx={{ height: "100%" }}>
<MineclusterFiles server={currentServer} />
</Box>
);
}

View file

@ -1,19 +1,25 @@
import { Link } from "react-router-dom";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import ServerCard from "../servers/ServerCard.jsx"; import ServerCard from "@mcl/components/servers/ServerCard.jsx";
import RconDialog, { useRconDialog } from "../servers/RconDialog.jsx"; import RconDialog, {
import Overview from "../overview/Overview.jsx"; useRconDialog,
} from "@mcl/components/servers/RconDialog.jsx";
import Overview from "@mcl/components/overview/Overview.jsx";
import Button from "@mui/material/Button";
import SpeedDialIcon from "@mui/material/SpeedDialIcon";
import "@mcl/css/server-card.css"; import "@mcl/css/server-card.css";
import "@mcl/css/overview.css"; import "@mcl/css/overview.css";
import { useServerInstances } from "@mcl/queries"; import { useServerInstances } from "@mcl/queries";
export default function Home() { export default function Home() {
const clusterMetrics = { cpu: 0, memory: 0 };
const [server, setServer] = useState(); const [server, setServer] = useState();
const [servers, setServers] = useState([]); const [servers, setServers] = useState([]);
const [rdOpen, rconToggle] = useRconDialog(); const [rdOpen, rconToggle] = useRconDialog();
const { isLoading, data: serversData } = useServerInstances(); const { isLoading, data: serversData } = useServerInstances();
const { servers: serverInstances, clusterMetrics } = serversData ?? {}; const serverInstances = serversData ?? [];
useEffect(() => { useEffect(() => {
if (!serverInstances) return; if (!serverInstances) return;
serverInstances.sort((a, b) => a.name.localeCompare(b.name)); serverInstances.sort((a, b) => a.name.localeCompare(b.name));
@ -53,6 +59,23 @@ export default function Home() {
dialogToggle={rconToggle} dialogToggle={rconToggle}
serverName={server} serverName={server}
/> />
<Button
component={Link}
to="/mcl/create"
color="primary"
variant="contained"
sx={{
position: "absolute",
bottom: 16,
right: 16,
padding: "1rem",
borderRadius: "100%",
height: "4rem",
width: "4rem",
}}
>
<SpeedDialIcon />
</Button>
</Box> </Box>
); );
} }

View file

@ -2,6 +2,15 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
const fetchApi = (subPath) => async () => const fetchApi = (subPath) => async () =>
fetch(`/api${subPath}`).then((res) => res.json()); fetch(`/api${subPath}`).then((res) => res.json());
const fetchApiCore = async (subPath, json, method = "POST", jsonify = false) =>
fetch(`/api${subPath}`, {
method,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(json),
}).then((res) => (jsonify ? res.json() : res));
const fetchApiPost = (subPath, json) => async () => const fetchApiPost = (subPath, json) => async () =>
fetch(`/api${subPath}`, { fetch(`/api${subPath}`, {
method: "POST", method: "POST",
@ -12,16 +21,16 @@ const fetchApiPost = (subPath, json) => async () =>
}).then((res) => res.json()); }).then((res) => res.json());
export const useServerStatus = (server) => export const useServerStatus = (server) =>
useQuery( useQuery({
[`server-status-${server}`], queryKey: [`server-status-${server}`],
fetchApiPost("/server/status", { name: server }) queryFn: fetchApiPost("/server/status", { name: server }),
); });
export const useServerMetrics = (server) => export const useServerMetrics = (server) =>
useQuery( useQuery({
[`server-metrics-${server}`], queryKey: [`server-metrics-${server}`],
fetchApiPost("/server/metrics", { name: server }), queryFn: fetchApiPost("/server/metrics", { name: server }),
{ refetchInterval: 10000 } refetchInterval: 10000,
); });
export const useStartServer = (server) => export const useStartServer = (server) =>
postJsonApi("/server/start", { name: server }, "server-instances"); postJsonApi("/server/start", { name: server }, "server-instances");
export const useStopServer = (server) => export const useStopServer = (server) =>
@ -30,20 +39,61 @@ export const useDeleteServer = (server) =>
postJsonApi("/server/delete", { name: server }, "server-instances", "DELETE"); postJsonApi("/server/delete", { name: server }, "server-instances", "DELETE");
export const useCreateServer = (spec) => export const useCreateServer = (spec) =>
postJsonApi("/server/create", spec, "server-list"); postJsonApi("/server/create", spec, "server-list");
export const getServerFiles = async (server, path) =>
fetchApiCore("/files/list", { name: server, path }, "POST", true);
export const createServerFolder = async (server, path) =>
fetchApiCore("/files/folder", {
name: server,
path,
}); /*postJsonApi("/files/folder", {name: server, path});*/
export const deleteServerItem = async (server, path, isDir) =>
fetchApiCore("/files/item", { name: server, path, isDir }, "DELETE");
export const getServerItem = async (server, name, path) =>
fetchApiCore("/files/item", { name: server, path })
.then((resp) =>
resp.status === 200
? resp.blob()
: Promise.reject("something went wrong"),
)
.then((blob) => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.style.display = "none";
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
});
export const useInvalidator = () => {
const qc = useQueryClient();
return (q) => qc.invalidateQueries([q]);
};
export const useServerList = () => export const useServerList = () =>
useQuery(["server-list"], fetchApi("/server/list")); useQuery({ queryKey: ["server-list"], queryFn: fetchApi("/server/list") });
export const useServerInstances = () => export const useServerInstances = () =>
useQuery(["server-instances"], fetchApi("/server/instances"), { useQuery({
queryKey: ["server-instances"],
queryFn: fetchApi("/server/instances"),
refetchInterval: 5000, refetchInterval: 5000,
}); });
export const useSystemAvailable = () => export const useSystemAvailable = () =>
useQuery(["system-available"], fetchApi("/system/available")); useQuery({
queryKey: ["system-available"],
queryFn: fetchApi("/system/available"),
});
export const useVersionList = () => export const useVersionList = () =>
useQuery(["minecraft-versions"], () => useQuery({
fetch("https://piston-meta.mojang.com/mc/game/version_manifest.json").then( queryKey: ["minecraft-versions"],
(r) => r.json() queryFn: () =>
) fetch(
); "https://piston-meta.mojang.com/mc/game/version_manifest.json",
).then((r) => r.json()),
});
const postJsonApi = (subPath, body, invalidate, method = "POST") => { const postJsonApi = (subPath, body, invalidate, method = "POST") => {
const qc = useQueryClient(); const qc = useQueryClient();
@ -55,6 +105,7 @@ const postJsonApi = (subPath, body, invalidate, method = "POST") => {
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
qc.invalidateQueries([invalidate]); if (invalidate) qc.invalidateQueries([invalidate]);
return res.json();
}; };
}; };

16
src/util/theme.js Normal file
View file

@ -0,0 +1,16 @@
// Generated using https://zenoo.github.io/mui-theme-creator/
import { createTheme } from "@mui/material/styles";
const themeOptions = {
palette: {
mode: "light",
primary: {
main: "rgba(109,216,144,255)",
},
secondary: {
main: "#f50057",
},
},
};
export default createTheme(themeOptions);

View file

@ -15,6 +15,7 @@ export default () => {
proxy: { proxy: {
"/api": backendUrl, "/api": backendUrl,
"/socket.io": backendUrl, "/socket.io": backendUrl,
"/healthz": backendUrl,
}, },
hmr: { hmr: {
protocol: process.env.MCL_VITE_DEV_PROTOCOL, protocol: process.env.MCL_VITE_DEV_PROTOCOL,
@ -26,10 +27,11 @@ export default () => {
base: "/mcl/", base: "/mcl/",
resolve: { resolve: {
alias: { alias: {
"@mcl/css": path.resolve("./public/css"), "@mcl/css": path.resolve("./src/css"),
"@mcl/settings": path.resolve("./src/ctx/SettingsContext.jsx"), "@mcl/settings": path.resolve("./src/ctx/SettingsContext.jsx"),
"@mcl/pages": path.resolve("./src/pages"), "@mcl/pages": path.resolve("./src/pages"),
"@mcl/queries": path.resolve("./src/util/queries.js"), "@mcl/queries": path.resolve("./src/util/queries.js"),
"@mcl/components": path.resolve("./src/components"),
"@mcl": path.resolve("./src"), "@mcl": path.resolve("./src"),
}, },
}, },