Compare commits
38 commits
ep/Oct1-20
...
master
Author | SHA1 | Date | |
---|---|---|---|
c4882cb22f | |||
27b11fcd50 | |||
968aa1fc74 | |||
b2f093111f | |||
f22b9a3262 | |||
6e9c71568d | |||
8c7e41b21b | |||
6eed3fd694 | |||
626ebf9d1d | |||
40f020d27b | |||
2ba97fcb70 | |||
87e87f89d3 | |||
7eaa13113e | |||
![]() |
6efa50e86b | ||
![]() |
332f84972c | ||
b15f616adb | |||
e9bd043924 | |||
c93c97b275 | |||
![]() |
ace95a20c6 | ||
69bc98d17d | |||
![]() |
fc60df27ac | ||
![]() |
0a0f9c8463 | ||
![]() |
4959d6c1fe | ||
![]() |
2fe79d0c57 | ||
![]() |
1741356e5f | ||
![]() |
bee4e61c87 | ||
![]() |
1eaa7ff5a5 | ||
40b3a42c73 | |||
![]() |
78c5b72482 | ||
![]() |
edbfc2348a | ||
4390f90b1c | |||
![]() |
8a70fad76a | ||
![]() |
43c4409498 | ||
![]() |
3d73f69678 | ||
![]() |
23efaafe1d | ||
![]() |
6eb4ed3e95 | ||
![]() |
fb57c03ba7 | ||
![]() |
4f19cf19d9 |
31
.forgejo/workflows/deploy-edge-proxy.yml
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
# name: Deploy Edge Proxy
|
||||||
|
# run-name: ${{ gitea.actor }} Deploy Edge Proxy
|
||||||
|
# on:
|
||||||
|
# push:
|
||||||
|
# branches: [ master ]
|
||||||
|
|
||||||
|
# env:
|
||||||
|
# GARDEN_DEPLOY_ACTION: minecluster-proxy
|
||||||
|
|
||||||
|
# jobs:
|
||||||
|
# deploy-edge:
|
||||||
|
# steps:
|
||||||
|
# # Setup Oasis
|
||||||
|
# - name: Oasis Setup
|
||||||
|
# uses: https://gitea.dunemask.dev/elysium/elysium-actions@oasis-setup-auto
|
||||||
|
# with:
|
||||||
|
# deploy-env: edge
|
||||||
|
# infisical-token: ${{ secrets.INFISICAL_ELYSIUM_EDGE_READ_TOKEN }}
|
||||||
|
# # Deploy to Edge Cluster
|
||||||
|
# - name: Deploy to Edge Cluster
|
||||||
|
# run: garden deploy $GARDEN_DEPLOY_ACTION --force --force-build --env usw-edge
|
||||||
|
# working-directory: ${{ env.OASIS_WORKSPACE }}
|
||||||
|
# # Alert via Discord
|
||||||
|
# - name: Discord Alert
|
||||||
|
# if: always()
|
||||||
|
# uses: https://gitea.dunemask.dev/elysium/elysium-actions@discord-status
|
||||||
|
# with:
|
||||||
|
# status: ${{ job.status }}
|
||||||
|
# channel: deployments
|
||||||
|
# header: DEPLOY EDGE
|
||||||
|
# additional-content: "Minecluster Proxy"
|
44
.forgejo/workflows/deploy-edge.yml
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
name: Deploy USW-MC
|
||||||
|
run-name: ${{ forgejo.actor }} Deploy USW-MC
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
env:
|
||||||
|
GARDEN_DEPLOY_ACTION: minecluster
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy-edge:
|
||||||
|
steps:
|
||||||
|
# Configure proper kubeconfig (Used when cluster does not match the edge environment)
|
||||||
|
- name: Get usw-mc deployment kubeconfig
|
||||||
|
uses: https://forgejo.dunemask.dev/elysium/elysium-actions@infisical-env
|
||||||
|
with:
|
||||||
|
infisical-token: ${{ secrets.INFISICAL_ELYSIUM_EDGE_READ_TOKEN }}
|
||||||
|
project-id: ${{ vars.INFISICAL_DEPLOYMENTS_PROJECT_ID }}
|
||||||
|
secret-envs: edge
|
||||||
|
secret-paths: /kubernetes/usw-mc
|
||||||
|
# Setup Oasis
|
||||||
|
- name: Oasis Setup
|
||||||
|
uses: https://forgejo.dunemask.dev/elysium/elysium-actions@oasis-setup-auto
|
||||||
|
with:
|
||||||
|
deploy-env: edge
|
||||||
|
infisical-token: ${{ secrets.INFISICAL_ELYSIUM_EDGE_READ_TOKEN }}
|
||||||
|
infisical-project: ${{ vars.INFISICAL_DEPLOYMENTS_PROJECT_ID }}
|
||||||
|
extra-secret-paths: /dashboard
|
||||||
|
extra-secret-envs: edge
|
||||||
|
# Deploy to Edge
|
||||||
|
- name: Deploy to Edge env
|
||||||
|
run: garden deploy $GARDEN_DEPLOY_ACTION --force --force-build --env usw-edge
|
||||||
|
working-directory: ${{ env.OASIS_WORKSPACE }}
|
||||||
|
env: # (Used when cluster does not match the edge environment)
|
||||||
|
MCL_KUBECONFIG: ${{ env.KUBERNETES_CONFIG_USW_MC }}
|
||||||
|
# Alert via Discord
|
||||||
|
- name: Discord Alert
|
||||||
|
if: always()
|
||||||
|
uses: https://forgejo.dunemask.dev/elysium/elysium-actions@discord-status
|
||||||
|
with:
|
||||||
|
status: ${{ job.status }}
|
||||||
|
channel: deployments
|
||||||
|
header: DEPLOY MC
|
||||||
|
additional-content: "Minecluster Server Manager Deployment"
|
42
.forgejo/workflows/qa-api-tests.yml
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
# name: QA API Tests
|
||||||
|
# run-name: ${{ gitea.actor }} QA API Test
|
||||||
|
# on:
|
||||||
|
# pull_request:
|
||||||
|
# branches: [ master ]
|
||||||
|
|
||||||
|
# env:
|
||||||
|
# REPO_DIR: ${{ gitea.workspace }}/minecluster
|
||||||
|
# GARDEN_LINK_ACTION: build.minecluster-image
|
||||||
|
|
||||||
|
# jobs:
|
||||||
|
# qa-api-tests:
|
||||||
|
# steps:
|
||||||
|
# # Setup Oasis
|
||||||
|
# - name: Oasis Setup
|
||||||
|
# uses: https://gitea.dunemask.dev/elysium/elysium-actions@oasis-setup-auto
|
||||||
|
# with:
|
||||||
|
# deploy-env: ci
|
||||||
|
# infisical-token: ${{ secrets.INFISICAL_ELYSIUM_CI_READ_TOKEN }}
|
||||||
|
# # Test Code
|
||||||
|
# - name: Checkout repository
|
||||||
|
# uses: actions/checkout@v3
|
||||||
|
# with:
|
||||||
|
# path: ${{ env.REPO_DIR }}
|
||||||
|
# # Garden link
|
||||||
|
# - name: Link Repo code to Garden
|
||||||
|
# run: garden link action $GARDEN_LINK_ACTION $REPO_DIR --env usw-ci --var cubit-projects=cairo,minecluster
|
||||||
|
# working-directory: ${{ env.OASIS_WORKSPACE }}
|
||||||
|
# # Cubit CI Tests
|
||||||
|
# - name: Run Cubit tests in CI env
|
||||||
|
# run: garden workflow qa-api-tests --env usw-ci --var ci-ttl=25m
|
||||||
|
# working-directory: ${{ env.OASIS_WORKSPACE }}
|
||||||
|
# # Discord Alert
|
||||||
|
# - name: Discord Alert
|
||||||
|
# if: always()
|
||||||
|
# uses: https://gitea.dunemask.dev/elysium/elysium-actions@discord-status
|
||||||
|
# with:
|
||||||
|
# status: ${{ job.status }}
|
||||||
|
# channel: ci
|
||||||
|
# header: QA API Tests
|
||||||
|
# additional-content: "CI Namespace: `${{env.CI_NAMESPACE}}`"
|
||||||
|
|
17
.forgejo/workflows/s3-repo-backup.yml
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
name: S3 Repo Backup
|
||||||
|
run-name: ${{ forgejo.actor }} S3 Repo Backup
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ master ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
s3-repo-backup:
|
||||||
|
steps:
|
||||||
|
- name: S3 Backup
|
||||||
|
uses: https://forgejo.dunemask.dev/elysium/elysium-actions@s3-backup
|
||||||
|
with:
|
||||||
|
infisical-token: ${{ secrets.INFISICAL_ELYSIUM_EDGE_READ_TOKEN }}
|
||||||
|
infisical-project: ${{ vars.INFISICAL_DEPLOYMENTS_PROJECT_ID }}
|
||||||
|
- name: Status Alert
|
||||||
|
if: always()
|
||||||
|
run: echo "The Job ended with status ${{ job.status }}."
|
|
@ -1,31 +0,0 @@
|
||||||
name: S3 Repo Backup
|
|
||||||
run-name: ${{ gitea.actor }} S3 Repo Backup
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ master ]
|
|
||||||
|
|
||||||
env:
|
|
||||||
S3_BACKUP_ENDPOINT: https://s3.dunemask.dev
|
|
||||||
S3_BACKUP_KEY_ID: gitea-repo-backup
|
|
||||||
S3_BACKUP_KEY: ${{ secrets.S3_REPO_BACKUP_KEY }}
|
|
||||||
REPO_DIR: ${{ gitea.workspace }}/${{ gitea.respository }}
|
|
||||||
jobs:
|
|
||||||
s3-repo-backup:
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
with:
|
|
||||||
path: ${{ env.REPO_DIR }}
|
|
||||||
- name: S3 Backup
|
|
||||||
uses: peter-evans/s3-backup@v1
|
|
||||||
env:
|
|
||||||
ACCESS_KEY_ID: ${{ env.S3_BACKUP_KEY_ID }}
|
|
||||||
SECRET_ACCESS_KEY: ${{ env.S3_BACKUP_KEY }}
|
|
||||||
MIRROR_SOURCE: ${{ env.REPO_DIR }}
|
|
||||||
MIRROR_TARGET: repository-backups/${{ gitea.repository }}
|
|
||||||
STORAGE_SERVICE_URL: ${{env.S3_BACKUP_ENDPOINT}}
|
|
||||||
with:
|
|
||||||
args: --overwrite --remove
|
|
||||||
- name: Status Alert
|
|
||||||
if: always()
|
|
||||||
run: echo "The Job ended with status ${{ job.status }}."
|
|
1
.gitignore
vendored
|
@ -1,2 +1,3 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
|
.env
|
||||||
|
|
||||||
|
|
|
@ -8,8 +8,9 @@ RUN npm i
|
||||||
COPY public public
|
COPY public public
|
||||||
COPY dist dist
|
COPY dist dist
|
||||||
COPY src src
|
COPY src src
|
||||||
COPY lib lib
|
|
||||||
COPY index.html .
|
COPY index.html .
|
||||||
COPY vite.config.js .
|
COPY vite.config.js .
|
||||||
RUN npm run build:react
|
RUN npm run build:react
|
||||||
|
# Copy Backend resources over
|
||||||
|
COPY lib lib
|
||||||
CMD ["npm","start"]
|
CMD ["npm","start"]
|
||||||
|
|
|
@ -2,3 +2,6 @@
|
||||||
Minecluster or MCL is a web interface used to manage multiple instance of Minecraft Servers in Kubernetes. This app is built to be an all in one for self-hosting Minecraft server. It uses rendered helm charts based on itzg/minecraft-server
|
Minecluster or MCL is a web interface used to manage multiple instance of Minecraft Servers in Kubernetes. This app is built to be an all in one for self-hosting Minecraft server. It uses rendered helm charts based on itzg/minecraft-server
|
||||||
|
|
||||||
More info coming soon.
|
More info coming soon.
|
||||||
|
|
||||||
|
## ⚠ Warning ⚠
|
||||||
|
Development is very active and there is no garuntee for compatability or migration across versions 1/15/24
|
||||||
|
|
10
dist/app.js
vendored
|
@ -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.error(e)});
|
||||||
|
|
|
@ -4,6 +4,15 @@
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="description" content="Minecraft Servers in Kubernetes" />
|
<meta name="description" content="Minecraft Servers in Kubernetes" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png?v=feb4-24-mineblock">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32x32.png?v=feb4-24-mineblock">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16x16.png?v=feb4-24-mineblock">
|
||||||
|
<link rel="manifest" href="/icons/site.webmanifest?v=feb4-24-mineblock">
|
||||||
|
<link rel="mask-icon" href="/icons/safari-pinned-tab.svg?v=feb4-24-mineblock" color="#00bd70">
|
||||||
|
<link rel="shortcut icon" href="/icons/favicon.ico?v=feb4-24-mineblock">
|
||||||
|
<meta name="msapplication-TileColor" content="#00aba9">
|
||||||
|
<meta name="msapplication-config" content="/icons/browserconfig.xml?v=feb4-24-mineblock">
|
||||||
|
<meta name="theme-color" content="#249c6b">
|
||||||
<title>Minecluster</title>
|
<title>Minecluster</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
@ -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();
|
||||||
|
|
97
lib/controllers/file-controller.js
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
import {
|
||||||
|
createServerFolder,
|
||||||
|
getServerItem,
|
||||||
|
listServerFiles,
|
||||||
|
removeServerItem,
|
||||||
|
uploadServerItem,
|
||||||
|
moveServerItems,
|
||||||
|
} from "../k8s/server-files.js";
|
||||||
|
import { sendError } from "../util/ExpressClientError.js";
|
||||||
|
import { checkAuthorization } from "../database/queries/server-queries.js";
|
||||||
|
|
||||||
|
export async function listFiles(req, res) {
|
||||||
|
const serverSpec = req.body;
|
||||||
|
if (!serverSpec) return res.sendStatus(400);
|
||||||
|
if (!serverSpec.id) return res.status(400).send("Server id missing!");
|
||||||
|
const authorized = await checkAuthorization(serverSpec.id, req.cairoId);
|
||||||
|
if (!authorized) return res.sendStatus(403);
|
||||||
|
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.id) return res.status(400).send("Server id missing!");
|
||||||
|
if (!serverSpec.path) return res.status(400).send("Path required!");
|
||||||
|
const authorized = await checkAuthorization(serverSpec.id, req.cairoId);
|
||||||
|
if (!authorized) return res.sendStatus(403);
|
||||||
|
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.id) return res.status(400).send("Server id missing!");
|
||||||
|
if (!serverSpec.path) return res.status(400).send("Path required!");
|
||||||
|
if (serverSpec.isDir === undefined || serverSpec.isDir === null)
|
||||||
|
return res.status(400).send("IsDIr required!");
|
||||||
|
const authorized = await checkAuthorization(serverSpec.id, req.cairoId);
|
||||||
|
if (!authorized) return res.sendStatus(403);
|
||||||
|
removeServerItem(serverSpec)
|
||||||
|
.then(() => res.sendStatus(200))
|
||||||
|
.catch(sendError(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadItem(req, res) {
|
||||||
|
const serverSpec = req.body;
|
||||||
|
if (!serverSpec.id) return res.status(400).send("Server id missing!");
|
||||||
|
if (!serverSpec.path) return res.status(400).send("Path required!");
|
||||||
|
const authorized = await checkAuthorization(serverSpec.id, req.cairoId);
|
||||||
|
if (!authorized) return res.sendStatus(403);
|
||||||
|
uploadServerItem(serverSpec, req.file)
|
||||||
|
.then(() => res.sendStatus(200))
|
||||||
|
.catch(sendError(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getItem(req, res) {
|
||||||
|
const serverSpec = req.body;
|
||||||
|
if (!serverSpec.id) return res.status(400).send("Server id missing!");
|
||||||
|
if (!serverSpec.path) return res.status(400).send("Path required!");
|
||||||
|
const authorized = await checkAuthorization(serverSpec.id, req.cairoId);
|
||||||
|
if (!authorized) return res.sendStatus(403);
|
||||||
|
getServerItem(serverSpec, res)
|
||||||
|
.then(({ ds, ftpTransfer }) => {
|
||||||
|
ds.pipe(res).on("error", sendError(res));
|
||||||
|
return ftpTransfer;
|
||||||
|
})
|
||||||
|
.catch(sendError(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveItems(req, res) {
|
||||||
|
const serverSpec = req.body;
|
||||||
|
if (!serverSpec.id) return res.status(400).send("Server id missing!");
|
||||||
|
if (!serverSpec.destination)
|
||||||
|
return res.status(400).send("Destination required!");
|
||||||
|
if (!serverSpec.origin) return res.status(400).send("Origin required!");
|
||||||
|
if (!serverSpec.files || !Array.isArray(serverSpec.files))
|
||||||
|
return res.status(400).send("Files required!");
|
||||||
|
const authorized = await checkAuthorization(serverSpec.id, req.cairoId);
|
||||||
|
if (!authorized) return res.sendStatus(403);
|
||||||
|
moveServerItems(serverSpec)
|
||||||
|
.then(() => res.sendStatus(200))
|
||||||
|
.catch(sendError(res));
|
||||||
|
}
|
179
lib/controllers/lifecycle-controller.js
Normal file
|
@ -0,0 +1,179 @@
|
||||||
|
import createServerResources from "../k8s/server-create.js";
|
||||||
|
import deleteServerResources from "../k8s/server-delete.js";
|
||||||
|
import {
|
||||||
|
createServerEntry,
|
||||||
|
deleteServerEntry,
|
||||||
|
getServerEntry,
|
||||||
|
modifyServerEntry,
|
||||||
|
} from "../database/queries/server-queries.js";
|
||||||
|
import ExpressClientError, { sendError } from "../util/ExpressClientError.js";
|
||||||
|
import { toggleServer } from "../k8s/k8s-server-control.js";
|
||||||
|
import { checkAuthorization } from "../database/queries/server-queries.js";
|
||||||
|
import { WARN } from "../util/logging.js";
|
||||||
|
import modifyServerResources from "../k8s/server-modify.js";
|
||||||
|
|
||||||
|
const dnsRegex = new RegExp(
|
||||||
|
`^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])(\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9]))*$`,
|
||||||
|
);
|
||||||
|
|
||||||
|
function backupPayloadFilter(req, res) {
|
||||||
|
const serverSpec = req.body;
|
||||||
|
const {
|
||||||
|
storage,
|
||||||
|
backupHost,
|
||||||
|
backupBucket,
|
||||||
|
backupId,
|
||||||
|
backupKey,
|
||||||
|
backupInterval,
|
||||||
|
} = serverSpec;
|
||||||
|
// TODO: Impliment non creation time backups
|
||||||
|
if (
|
||||||
|
!!backupHost ||
|
||||||
|
!!backupBucket ||
|
||||||
|
!!backupId ||
|
||||||
|
!!backupKey ||
|
||||||
|
!!backupInterval
|
||||||
|
) {
|
||||||
|
if (storage === 0)
|
||||||
|
return res.status(400).send("Backups cannot be used if storage is zero!");
|
||||||
|
// If any keys are required, all are required
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
!!backupHost &&
|
||||||
|
!!backupBucket &&
|
||||||
|
!!backupId &&
|
||||||
|
!!backupKey &&
|
||||||
|
!!backupInterval
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return res.status(400).send("All backup keys are required!");
|
||||||
|
if (!dnsRegex.test(backupHost))
|
||||||
|
return res.status(400).send("Backup Host invalid!");
|
||||||
|
}
|
||||||
|
return "filtered";
|
||||||
|
}
|
||||||
|
|
||||||
|
function payloadFilter(req, res) {
|
||||||
|
const serverSpec = req.body;
|
||||||
|
if (!serverSpec) return res.sendStatus(400);
|
||||||
|
const { name, host, version, serverType, memory, extraPorts } = serverSpec;
|
||||||
|
if (!name) return res.status(400).send("Server name is required!");
|
||||||
|
if (!host) return res.status(400).send("Server host is required!");
|
||||||
|
if (!dnsRegex.test(host)) return res.status(400).send("Hostname invalid!");
|
||||||
|
if (!version) return res.status(400).send("Server version is required!");
|
||||||
|
if (!serverType) return res.status(400).send("Server type is required!");
|
||||||
|
if (!memory) return res.status(400).send("Memory is required!");
|
||||||
|
if (
|
||||||
|
!!extraPorts &&
|
||||||
|
(!Array.isArray(extraPorts) ||
|
||||||
|
extraPorts.find((e) => typeof e !== "string" || e.length > 5))
|
||||||
|
)
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.send("Extra ports must be a list of strings with length of 5!");
|
||||||
|
if (host !== host.toLowerCase())
|
||||||
|
WARN("CREATE", "Host automatically being lowercasified...");
|
||||||
|
req.body.host = host.toLowerCase();
|
||||||
|
return "filtered";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkServerId(cairoId, serverSpec) {
|
||||||
|
if (!serverSpec) throw new ExpressClientError({ c: 400 });
|
||||||
|
if (!serverSpec.id)
|
||||||
|
throw new ExpressClientError({ c: 400, m: "Server id missing!" });
|
||||||
|
const authorized = await checkAuthorization(serverSpec.id, cairoId);
|
||||||
|
if (!authorized)
|
||||||
|
throw new ExpressClientError({ c: 403, m: "Access forbidden!" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createServer(req, res) {
|
||||||
|
if (payloadFilter(req, res) !== "filtered") return;
|
||||||
|
if (backupPayloadFilter(req, res) !== "filtered") return;
|
||||||
|
const serverSpec = req.body;
|
||||||
|
try {
|
||||||
|
const serverEntry = await createServerEntry(req.cairoId, serverSpec);
|
||||||
|
await createServerResources(serverEntry);
|
||||||
|
res.json(serverEntry);
|
||||||
|
} catch (e) {
|
||||||
|
sendError(res)(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteServer(req, res) {
|
||||||
|
// Ensure spec is safe
|
||||||
|
const serverSpec = req.body;
|
||||||
|
try {
|
||||||
|
await checkServerId(req.cairoId, serverSpec);
|
||||||
|
} catch (e) {
|
||||||
|
return sendError(res)(e);
|
||||||
|
}
|
||||||
|
const deleteEntry = deleteServerEntry(serverSpec.id);
|
||||||
|
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 {
|
||||||
|
await checkServerId(req.cairoId, serverSpec);
|
||||||
|
} catch (e) {
|
||||||
|
return sendError(res)(e);
|
||||||
|
}
|
||||||
|
const { id } = serverSpec;
|
||||||
|
toggleServer(id, true)
|
||||||
|
.then(() => res.sendStatus(200))
|
||||||
|
.catch(sendError(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopServer(req, res) {
|
||||||
|
// Ensure spec is safe
|
||||||
|
const serverSpec = req.body;
|
||||||
|
try {
|
||||||
|
await checkServerId(req.cairoId, serverSpec);
|
||||||
|
} catch (e) {
|
||||||
|
return sendError(res)(e);
|
||||||
|
}
|
||||||
|
const { id } = serverSpec;
|
||||||
|
toggleServer(id, false)
|
||||||
|
.then(() => res.sendStatus(200))
|
||||||
|
.catch(sendError(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServer(req, res) {
|
||||||
|
// Ensure spec is safe
|
||||||
|
const serverSpec = req.body;
|
||||||
|
try {
|
||||||
|
await checkServerId(req.cairoId, serverSpec);
|
||||||
|
} catch (e) {
|
||||||
|
return sendError(res)(e);
|
||||||
|
}
|
||||||
|
const { id } = serverSpec;
|
||||||
|
getServerEntry(id).then((s) => {
|
||||||
|
delete s.backupKey; // Do not let this ever get to an API client
|
||||||
|
s.backupBucket = s.backupPath;
|
||||||
|
delete s.backupPath;
|
||||||
|
delete s.backupId; // Do not let this ever get to an API client
|
||||||
|
res.json(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function modifyServer(req, res) {
|
||||||
|
if (payloadFilter(req, res) !== "filtered") return;
|
||||||
|
const serverSpec = req.body;
|
||||||
|
if (!!serverSpec.host)
|
||||||
|
WARN(
|
||||||
|
"MODIFY",
|
||||||
|
"Warning, hostname changing is not implimented yet! Please ask the developer if you'd like to see this added!",
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await checkServerId(req.cairoId, serverSpec);
|
||||||
|
const serverEntry = await modifyServerEntry(serverSpec);
|
||||||
|
await modifyServerResources(serverEntry);
|
||||||
|
res.sendStatus(200);
|
||||||
|
} catch (e) {
|
||||||
|
sendError(res)(e);
|
||||||
|
}
|
||||||
|
}
|
84
lib/controllers/s3-controller.js
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
import { S3, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||||
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||||
|
import { basename } from "node:path";
|
||||||
|
import { getServerEntry } from "../database/queries/server-queries.js";
|
||||||
|
import { ERR } from "../util/logging.js";
|
||||||
|
import { checkAuthorization } from "../database/queries/server-queries.js";
|
||||||
|
const s3Region = "us-east-1";
|
||||||
|
|
||||||
|
async function getS3BackupData(serverId) {
|
||||||
|
const serverEntry = await getServerEntry(serverId);
|
||||||
|
if (!serverEntry?.backupHost) return undefined;
|
||||||
|
const s3Config = {
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: serverEntry.backupId,
|
||||||
|
secretAccessKey: serverEntry.backupKey,
|
||||||
|
},
|
||||||
|
endpoint: `https://${serverEntry.backupHost}`,
|
||||||
|
forcePathStyle: true,
|
||||||
|
region: s3Region,
|
||||||
|
};
|
||||||
|
const pathParts = serverEntry.backupPath.split("/");
|
||||||
|
if (pathParts[0] === "") pathParts.shift();
|
||||||
|
const bucket = pathParts.shift();
|
||||||
|
const backupPrefix = pathParts.join("/");
|
||||||
|
return { s3Config, bucket, backupPrefix };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listS3Backups(req, res) {
|
||||||
|
const serverSpec = req.body;
|
||||||
|
if (!serverSpec.id) return res.status(400).send("Server id missing!");
|
||||||
|
const authorized = await checkAuthorization(serverSpec.id, req.cairoId);
|
||||||
|
if (!authorized)
|
||||||
|
return res
|
||||||
|
.status(403)
|
||||||
|
.send("You do not have permission to access that server!");
|
||||||
|
const s3Data = await getS3BackupData(serverSpec.id);
|
||||||
|
if (!s3Data) return res.status(409).send("Backup not configured!");
|
||||||
|
const { s3Config, bucket, backupPrefix } = s3Data;
|
||||||
|
const s3Client = new S3(s3Config);
|
||||||
|
try {
|
||||||
|
const listResponse = await s3Client.listObjectsV2({
|
||||||
|
Bucket: bucket,
|
||||||
|
Prefix: backupPrefix,
|
||||||
|
});
|
||||||
|
const files =
|
||||||
|
listResponse.Contents?.map((f) => ({
|
||||||
|
name: basename(f.Key),
|
||||||
|
lastModified: f.LastModified,
|
||||||
|
path: f.Key,
|
||||||
|
size: f.Size,
|
||||||
|
})) ?? [];
|
||||||
|
res.json(files);
|
||||||
|
} catch (e) {
|
||||||
|
ERR("S3", e);
|
||||||
|
res.sendStatus(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getS3BackupUrl(req, res) {
|
||||||
|
const serverSpec = req.body;
|
||||||
|
if (!serverSpec.id) return res.status(400).send("Server id missing!");
|
||||||
|
if (!serverSpec.backupPath)
|
||||||
|
return res.status(400).send("Backup path missing!");
|
||||||
|
const authorized = await checkAuthorization(serverSpec.id, req.cairoId);
|
||||||
|
if (!authorized)
|
||||||
|
return res
|
||||||
|
.status(403)
|
||||||
|
.send("You do not have permission to access that server!");
|
||||||
|
const s3Data = await getS3BackupData(serverSpec.id);
|
||||||
|
if (!s3Data) return res.status(409).send("Backup not configured!");
|
||||||
|
const { s3Config, bucket } = s3Data;
|
||||||
|
const s3Client = new S3(s3Config);
|
||||||
|
try {
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: serverSpec.backupPath,
|
||||||
|
});
|
||||||
|
const url = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
|
||||||
|
res.json({ url });
|
||||||
|
} catch (e) {
|
||||||
|
ERR("S3", e);
|
||||||
|
res.sendStatus(500);
|
||||||
|
}
|
||||||
|
}
|
18
lib/controllers/status-controller.js
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import { getUserDeployments } from "../k8s/k8s-server-control.js";
|
||||||
|
import { getInstances } from "../k8s/server-status.js";
|
||||||
|
import { sendError } from "../util/ExpressClientError.js";
|
||||||
|
|
||||||
|
export function serverList(req, res) {
|
||||||
|
getUserDeployments(req.cairoId)
|
||||||
|
.then((sd) => res.json(sd.map((s) => s.metadata.name.substring(4))))
|
||||||
|
.catch((e) => {
|
||||||
|
ERR("STATUS CONTROLLER", e);
|
||||||
|
res.status(500).send("Couldn't get server list");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serverInstances(req, res) {
|
||||||
|
getInstances(req.cairoId)
|
||||||
|
.then((i) => res.json(i))
|
||||||
|
.catch(sendError(res));
|
||||||
|
}
|
71
lib/controllers/sub-controllers/console-controller.js
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
// 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";
|
||||||
|
import { getServerEntry } from "../../database/queries/server-queries.js";
|
||||||
|
import kc from "../../k8s/k8s-config.js";
|
||||||
|
// Kubernetes Configuration
|
||||||
|
|
||||||
|
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 { serverId } = socket.mcs;
|
||||||
|
const server = await getServerEntry(serverId);
|
||||||
|
const podName = `mcl-${server.mclName}`;
|
||||||
|
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();
|
||||||
|
var logstreamBuffer = "";
|
||||||
|
logStream.on("data", (chunk) => {
|
||||||
|
const bufferString = Buffer.from(chunk).toString();
|
||||||
|
if (!bufferString.includes("\n")) return (logstreamBuffer += bufferString);
|
||||||
|
const clientChunks = `${logstreamBuffer}${bufferString}`.split("\n");
|
||||||
|
for (var c of clientChunks) socket.emit("push", c);
|
||||||
|
});
|
||||||
|
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 { serverId } = socket.mcs;
|
||||||
|
const server = await getServerEntry(serverId);
|
||||||
|
const rconSecret = `mcl-${server.mclName}-rcon-secret`;
|
||||||
|
const rconRes = await k8sCore.readNamespacedSecret(rconSecret, namespace);
|
||||||
|
const rconPassword = Buffer.from(
|
||||||
|
rconRes.body.data["rcon-password"],
|
||||||
|
"base64",
|
||||||
|
).toString("utf8");
|
||||||
|
const rconHost = `mcl-${server.mclName}-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("rcon-error", "Could not connect RCON Input to server!");
|
||||||
|
WARN("RCON", `Could not connect to '${rconHost}'`);
|
||||||
|
}
|
||||||
|
socket.rconClient = rcon;
|
||||||
|
}
|
21
lib/database/migrations/1_create_servers_table.sql
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
CREATE SEQUENCE servers_id_seq;
|
||||||
|
CREATE TABLE servers (
|
||||||
|
id bigint NOT NULL DEFAULT nextval('servers_id_seq') PRIMARY KEY,
|
||||||
|
owner_cairo_id varchar(63),
|
||||||
|
host varchar(255) DEFAULT NULL,
|
||||||
|
name varchar(255) DEFAULT NULL,
|
||||||
|
version varchar(63) DEFAULT 'latest',
|
||||||
|
server_type varchar(63) DEFAULT 'VANILLA',
|
||||||
|
cpu varchar(63) DEFAULT '500',
|
||||||
|
memory varchar(63) DEFAULT '512',
|
||||||
|
storage varchar(63) DEFAULT NULL,
|
||||||
|
backup_enabled BOOLEAN DEFAULT FALSE,
|
||||||
|
backup_host varchar(255) DEFAULT NULL,
|
||||||
|
backup_bucket_path varchar(255) DEFAULT NULL,
|
||||||
|
backup_id varchar(255) DEFAULT NULL,
|
||||||
|
backup_key varchar(255) DEFAULT NULL,
|
||||||
|
backup_interval varchar(255) DEFAULT NULL,
|
||||||
|
extra_ports varchar(7)[] DEFAULT NULL,
|
||||||
|
CONSTRAINT unique_host UNIQUE(host)
|
||||||
|
);
|
||||||
|
ALTER SEQUENCE servers_id_seq OWNED BY servers.id;
|
121
lib/database/pg-query.js
Normal 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
|
@ -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_PASS: 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();
|
252
lib/database/queries/server-queries.js
Normal file
|
@ -0,0 +1,252 @@
|
||||||
|
import pg from "../postgres.js";
|
||||||
|
import {
|
||||||
|
deleteQuery,
|
||||||
|
insertQuery,
|
||||||
|
selectWhereAllQuery,
|
||||||
|
updateWhereAllQuery,
|
||||||
|
} from "../pg-query.js";
|
||||||
|
import ExpressClientError from "../../util/ExpressClientError.js";
|
||||||
|
const table = "servers";
|
||||||
|
|
||||||
|
const asExpressClientError = (e) => {
|
||||||
|
throw new ExpressClientError({ m: e.message, c: 409 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMclName = (host, id) =>
|
||||||
|
`${host.toLowerCase().replaceAll(".", "-")}-${id}`;
|
||||||
|
|
||||||
|
export async function checkAuthorization(serverId, cairoId) {
|
||||||
|
console.log(
|
||||||
|
`Checking Authorization for user ${cairoId} for serverId ${serverId}`,
|
||||||
|
);
|
||||||
|
if (!cairoId) return false;
|
||||||
|
const q = selectWhereAllQuery(table, {
|
||||||
|
id: serverId,
|
||||||
|
owner_cairo_id: cairoId,
|
||||||
|
});
|
||||||
|
return (await pg.query(q)).length === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createServerEntry(cairoId, serverSpec) {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
host,
|
||||||
|
version,
|
||||||
|
serverType: server_type,
|
||||||
|
cpu, // TODO: Ignored for now by the K8S manifests
|
||||||
|
memory,
|
||||||
|
storage: storage_val,
|
||||||
|
extraPorts: extra_ports,
|
||||||
|
backupHost: backup_host,
|
||||||
|
backupBucket: backup_bucket_path,
|
||||||
|
backupId: backup_id,
|
||||||
|
backupKey: backup_key,
|
||||||
|
backupInterval: backup_interval,
|
||||||
|
} = serverSpec;
|
||||||
|
|
||||||
|
var q = insertQuery(table, {
|
||||||
|
name,
|
||||||
|
owner_cairo_id: cairoId,
|
||||||
|
host,
|
||||||
|
version,
|
||||||
|
server_type,
|
||||||
|
cpu, // TODO: Ignored for now by the K8S manifests
|
||||||
|
memory,
|
||||||
|
storage: !storage_val || storage_val === "0" ? null : storage_val, // 0, undefined, null, or "0" becomes null
|
||||||
|
extra_ports,
|
||||||
|
backup_enabled: !!backup_interval ? true : null, // We already verified the payload, so any backup key will work
|
||||||
|
backup_host,
|
||||||
|
backup_bucket_path,
|
||||||
|
backup_id,
|
||||||
|
backup_key,
|
||||||
|
backup_interval,
|
||||||
|
});
|
||||||
|
q += "\n RETURNING *";
|
||||||
|
try {
|
||||||
|
const entries = await pg.query(q);
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
owner_cairo_id: ownerCairoId,
|
||||||
|
name,
|
||||||
|
host,
|
||||||
|
version,
|
||||||
|
server_type: serverType,
|
||||||
|
cpu, // TODO: Ignored for now by the K8S manifests
|
||||||
|
memory,
|
||||||
|
storage,
|
||||||
|
extra_ports: extraPorts,
|
||||||
|
backup_enabled: backupEnabled,
|
||||||
|
backup_host: backupHost,
|
||||||
|
backup_bucket_path: backupPath,
|
||||||
|
backup_id: backupId,
|
||||||
|
backup_key: backupKey,
|
||||||
|
backup_interval: backupInterval,
|
||||||
|
} = entries[0];
|
||||||
|
const mclName = getMclName(host, id);
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
mclName,
|
||||||
|
id,
|
||||||
|
ownerCairoId,
|
||||||
|
host,
|
||||||
|
version,
|
||||||
|
serverType,
|
||||||
|
cpu, // TODO: Ignored for now by the K8S manifests
|
||||||
|
memory,
|
||||||
|
storage,
|
||||||
|
extraPorts,
|
||||||
|
backupEnabled,
|
||||||
|
backupHost,
|
||||||
|
backupPath,
|
||||||
|
backupId,
|
||||||
|
backupKey,
|
||||||
|
backupInterval,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
asExpressClientError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteServerEntry(serverId) {
|
||||||
|
if (!serverId) asExpressClientError({ message: "Server ID Required!" });
|
||||||
|
const q = deleteQuery(table, { id: serverId });
|
||||||
|
return pg.query(q).catch(asExpressClientError);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerEntry(serverId) {
|
||||||
|
if (!serverId) asExpressClientError({ message: "Server ID Required!" });
|
||||||
|
const q = selectWhereAllQuery(table, { id: serverId });
|
||||||
|
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 {
|
||||||
|
id,
|
||||||
|
owner_cairo_id: ownerCairoId,
|
||||||
|
name,
|
||||||
|
host,
|
||||||
|
version,
|
||||||
|
server_type: serverType,
|
||||||
|
cpu, // TODO: Ignored for now by the K8S manifests
|
||||||
|
memory,
|
||||||
|
storage,
|
||||||
|
extra_ports: extraPorts,
|
||||||
|
backup_enabled: backupEnabled,
|
||||||
|
backup_host: backupHost,
|
||||||
|
backup_bucket_path: backupPath,
|
||||||
|
backup_id: backupId,
|
||||||
|
backup_key: backupKey,
|
||||||
|
backup_interval: backupInterval,
|
||||||
|
} = serverSpecs[0];
|
||||||
|
const mclName = getMclName(host, id);
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
mclName,
|
||||||
|
id,
|
||||||
|
ownerCairoId,
|
||||||
|
host,
|
||||||
|
version,
|
||||||
|
serverType,
|
||||||
|
cpu, // TODO: Ignored for now by the K8S manifests
|
||||||
|
memory,
|
||||||
|
storage,
|
||||||
|
extraPorts,
|
||||||
|
backupEnabled,
|
||||||
|
backupHost,
|
||||||
|
backupPath,
|
||||||
|
backupId,
|
||||||
|
backupKey,
|
||||||
|
backupInterval,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
asExpressClientError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function modifyServerEntry(serverSpec) {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
// ownerCairoId: owner_cairo_id, // DIsabled! If these becomes a reqest, please create a new function!
|
||||||
|
name,
|
||||||
|
// host, // TODO: Can only be updated if service name is generic and non descriptive
|
||||||
|
version,
|
||||||
|
serverType: server_type,
|
||||||
|
cpu, // TODO: Ignored for now by the K8S manifests
|
||||||
|
memory,
|
||||||
|
// storage, // DO NOT INCLUDE THIS KEY, Not all storage providers in kubernetes allow for dynamically resizable PVCs
|
||||||
|
extraPorts: extra_ports,
|
||||||
|
backupEnabled: backup_enabled,
|
||||||
|
backupHost: backup_host,
|
||||||
|
backupBucket: backup_bucket_path,
|
||||||
|
backupId: backup_id,
|
||||||
|
backupKey: backup_key,
|
||||||
|
backupInterval: backup_interval,
|
||||||
|
} = serverSpec;
|
||||||
|
|
||||||
|
const q =
|
||||||
|
updateWhereAllQuery(
|
||||||
|
table,
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
// host, // TODO: Can only be updated if service name is generic and non descriptive
|
||||||
|
version,
|
||||||
|
server_type,
|
||||||
|
cpu, // TODO: Ignored for now by the K8S manifests
|
||||||
|
memory,
|
||||||
|
// storage, // DO NOT INCLUDE THIS KEY, Not all storage providers in kubernetes allow for dynamically resizable PVCs
|
||||||
|
extra_ports,
|
||||||
|
backup_enabled,
|
||||||
|
backup_host,
|
||||||
|
backup_bucket_path,
|
||||||
|
backup_id,
|
||||||
|
backup_key,
|
||||||
|
backup_interval,
|
||||||
|
},
|
||||||
|
{ id },
|
||||||
|
) + ` RETURNING *;`;
|
||||||
|
try {
|
||||||
|
const entries = await pg.query(q);
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
host, // Should always read the database value
|
||||||
|
server_type: serverType,
|
||||||
|
storage,
|
||||||
|
extra_ports: extraPorts,
|
||||||
|
backup_enabled: backupEnabled,
|
||||||
|
backup_host: backupHost,
|
||||||
|
backup_bucket_path: backupPath,
|
||||||
|
backup_id: backupId,
|
||||||
|
backup_key: backupKey,
|
||||||
|
backup_interval: backupInterval,
|
||||||
|
} = entries[0];
|
||||||
|
|
||||||
|
const mclName = getMclName(host, id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name, // Could change
|
||||||
|
mclName, // Shouldn't change
|
||||||
|
id, // Won't change
|
||||||
|
host, // TODO: Can only be updated if service name is generic and non descriptive, this returns the host from the database
|
||||||
|
version,
|
||||||
|
serverType,
|
||||||
|
cpu, // TODO: Ignored for now by the K8S manifests
|
||||||
|
memory,
|
||||||
|
storage,
|
||||||
|
extraPorts,
|
||||||
|
backupEnabled,
|
||||||
|
backupHost,
|
||||||
|
backupPath,
|
||||||
|
backupId,
|
||||||
|
backupKey,
|
||||||
|
backupInterval,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
asExpressClientError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerEntries() {
|
||||||
|
const q = `SELECT * FROM ${table}`;
|
||||||
|
return pg.query(q);
|
||||||
|
}
|
|
@ -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);
|
|
||||||
});
|
|
11
lib/k8s/configs/backup-secret.yml
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
annotations:
|
||||||
|
minecluster.dunemask.net/id: changeme-server-id
|
||||||
|
labels:
|
||||||
|
app: changeme-app-label
|
||||||
|
name: changeme-backup-secret
|
||||||
|
type: Opaque
|
||||||
|
data:
|
||||||
|
rclone.conf: ""
|
34
lib/k8s/configs/containers/ftp-server.yml
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
env:
|
||||||
|
- name: FTP_USER
|
||||||
|
value: "minecluster"
|
||||||
|
- name: FTP_PASS
|
||||||
|
value: "minecluster"
|
||||||
|
image: garethflowers/ftp-server
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
livenessProbe:
|
||||||
|
exec: { command: ["/bin/sh", "-c", "netstat -a | grep -q ftp"] }
|
||||||
|
failureThreshold: 20
|
||||||
|
initialDelaySeconds: 0
|
||||||
|
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: ["/bin/sh", "-c", "netstat -a | grep -q ftp"] }
|
||||||
|
failureThreshold: 20
|
||||||
|
initialDelaySeconds: 0
|
||||||
|
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
|
63
lib/k8s/configs/containers/minecraft-backup.yml
Normal 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: "true"
|
||||||
|
- name: TAR_COMPRESS_METHOD
|
||||||
|
value: gzip
|
||||||
|
- name: ZSTD_PARAMETERS
|
||||||
|
value: -3 --long=25 --single-thread
|
||||||
|
- name: RCLONE_REMOTE
|
||||||
|
value: mcl-backup-changeme
|
||||||
|
- name: RCLONE_DEST_DIR
|
||||||
|
value: /mcl/backups/changeme
|
||||||
|
- name: RCLONE_COMPRESS_METHOD
|
||||||
|
value: gzip
|
||||||
|
image: itzg/mc-backup:latest
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
name: mcl-backup-changeme
|
||||||
|
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
|
109
lib/k8s/configs/containers/minecraft-server.yml
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
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: 200
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 3
|
||||||
|
successThreshold: 1
|
||||||
|
timeoutSeconds: 1
|
||||||
|
name: changeme-name-server
|
||||||
|
ports:
|
||||||
|
- containerPort: 25565
|
||||||
|
name: minecraft
|
||||||
|
protocol: TCP
|
||||||
|
- containerPort: 25575
|
||||||
|
name: rcon
|
||||||
|
protocol: TCP
|
||||||
|
# readinessProbe: # Disabling this allows for users to manipulate files even if the container is starting
|
||||||
|
# exec: {command: [mc-health]}
|
||||||
|
# failureThreshold: 200
|
||||||
|
# initialDelaySeconds: 30
|
||||||
|
# periodSeconds: 3
|
||||||
|
# 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
|
23
lib/k8s/configs/extra-svc.yml
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
annotations:
|
||||||
|
minecluster.dunemask.net/id: changeme-server-id
|
||||||
|
labels:
|
||||||
|
app: changeme-app
|
||||||
|
name: changeme-extra
|
||||||
|
namespace: changeme-namespace
|
||||||
|
spec:
|
||||||
|
internalTrafficPolicy: Cluster
|
||||||
|
ipFamilies:
|
||||||
|
- IPv4
|
||||||
|
ipFamilyPolicy: SingleStack
|
||||||
|
# ports: Programatically generated
|
||||||
|
# - name: port-name
|
||||||
|
# port: 1234
|
||||||
|
# protocol: TCP
|
||||||
|
# targetPort: port-name
|
||||||
|
selector:
|
||||||
|
app: changeme-app
|
||||||
|
sessionAffinity: None
|
||||||
|
type: LoadBalancer
|
|
@ -3,6 +3,8 @@ data:
|
||||||
rcon-password: UEphT3V2aGJlQjNvc3M0dElwQU5YTUZrSkltR1RsRVl0ZGx3elFqZjJLdVZrZXNtV0hja1VhUUd3bmZDcElpbA==
|
rcon-password: UEphT3V2aGJlQjNvc3M0dElwQU5YTUZrSkltR1RsRVl0ZGx3elFqZjJLdVZrZXNtV0hja1VhUUd3bmZDcElpbA==
|
||||||
kind: Secret
|
kind: Secret
|
||||||
metadata:
|
metadata:
|
||||||
|
annotations:
|
||||||
|
minecluster.dunemask.net/id: changeme-server-id
|
||||||
labels:
|
labels:
|
||||||
app: changeme-app-label
|
app: changeme-app-label
|
||||||
name: changeme-rcon-secret
|
name: changeme-rcon-secret
|
||||||
|
|
|
@ -2,6 +2,7 @@ apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
annotations:
|
annotations:
|
||||||
|
minecluster.dunemask.net/id: changeme-server-id
|
||||||
labels:
|
labels:
|
||||||
app: changeme-app
|
app: changeme-app
|
||||||
name: changeme-rcon
|
name: changeme-rcon
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
|
annotations:
|
||||||
|
minecluster.dunemask.net/id: changeme-server-id
|
||||||
name: changeme-name
|
name: changeme-name
|
||||||
namespace: changeme-namespace
|
namespace: changeme-namespace
|
||||||
spec:
|
spec:
|
||||||
|
@ -14,202 +16,31 @@ spec:
|
||||||
type: Recreate
|
type: Recreate
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
|
annotations:
|
||||||
|
minecluster.dunemask.net/id: changeme-server-id
|
||||||
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
|
- emptyDir: {}
|
||||||
persistentVolumeClaim:
|
name: datadir
|
||||||
claimName: changeme-pvc-name
|
|
||||||
- emptyDir: {}
|
- emptyDir: {}
|
||||||
name: backupdir
|
name: backupdir
|
||||||
- name: rclone-config
|
# - name: datadir
|
||||||
secret:
|
# persistentVolumeClaim:
|
||||||
defaultMode: 420
|
# claimName: changeme-pvc-name
|
||||||
items:
|
# - name: rclone-config
|
||||||
- key: rclone.conf
|
# secret:
|
||||||
path: rclone.conf
|
# defaultMode: 420
|
||||||
secretName: rclone-config
|
# items:
|
||||||
|
# - key: rclone.conf
|
||||||
|
# path: rclone.conf
|
||||||
|
# secretName: rclone-config
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: PersistentVolumeClaim
|
kind: PersistentVolumeClaim
|
||||||
metadata:
|
metadata:
|
||||||
|
annotations:
|
||||||
|
minecluster.dunemask.net/id: changeme-server-id
|
||||||
labels:
|
labels:
|
||||||
service: changeme-service-name
|
service: changeme-service-name
|
||||||
name: changeme-pvc-name
|
name: changeme-pvc-name
|
||||||
|
|
|
@ -4,20 +4,23 @@ 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/id: changeme-server-id
|
||||||
labels:
|
labels:
|
||||||
app: changeme-app
|
app: changeme-app
|
||||||
name: changeme-name
|
name: changeme-name
|
||||||
namespace: changeme-namespace
|
namespace: changeme-namespace
|
||||||
spec:
|
spec:
|
||||||
internalTrafficPolicy: Cluster
|
internalTrafficPolicy: Cluster
|
||||||
ipFamilies:
|
|
||||||
- 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
|
||||||
|
|
14
lib/k8s/k8s-config.js
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import k8s from "@kubernetes/client-node";
|
||||||
|
const MCL_KUBECONFIG = process.env.MCL_KUBECONFIG;
|
||||||
|
const envConfig = MCL_KUBECONFIG ? MCL_KUBECONFIG : null;
|
||||||
|
const kc = new k8s.KubeConfig();
|
||||||
|
try {
|
||||||
|
if (!!envConfig)
|
||||||
|
kc.loadFromString(Buffer.from(envConfig, "base64").toString("utf8"));
|
||||||
|
else kc.loadFromDefault();
|
||||||
|
} catch (e) {
|
||||||
|
kc.loadFromDefault();
|
||||||
|
}
|
||||||
|
if(kc.contexts.length === 1) kc.setCurrentContext(kc.contexts[0].name);
|
||||||
|
if(!kc.currentContext) throw new Error("Could not infer current context! Please set it manually in the Kubeconfig!");
|
||||||
|
export default kc;
|
154
lib/k8s/k8s-server-control.js
Normal file
|
@ -0,0 +1,154 @@
|
||||||
|
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";
|
||||||
|
import { checkAuthorization } from "../database/queries/server-queries.js";
|
||||||
|
import kc from "./k8s-config.js";
|
||||||
|
|
||||||
|
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/id"] !== undefined;
|
||||||
|
|
||||||
|
export const serverMatch = (serverId) => (o) =>
|
||||||
|
o.metadata.annotations["minecluster.dunemask.net/id"] === serverId;
|
||||||
|
|
||||||
|
export const cairoMatch = (cairoId) => (o) =>
|
||||||
|
checkAuthorization(
|
||||||
|
o.metadata.annotations["minecluster.dunemask.net/id"],
|
||||||
|
cairoId,
|
||||||
|
);
|
||||||
|
|
||||||
|
export async function getUserDeployments(cairoId) {
|
||||||
|
const authFIlter = cairoMatch(cairoId);
|
||||||
|
const allDeployments = await getDeployments();
|
||||||
|
const authChecks = allDeployments.map(authFIlter);
|
||||||
|
const authorizations = await Promise.all(authChecks);
|
||||||
|
return allDeployments.filter((_d, i) => authorizations[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(serverId) {
|
||||||
|
const serverFilter = serverMatch(serverId);
|
||||||
|
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 > 2) throw Error("Secrets broken!");
|
||||||
|
const serverAssets = {
|
||||||
|
deployment: deployments[0],
|
||||||
|
service: services.find((s) => s.metadata.name.endsWith("-server")),
|
||||||
|
volume: volumes[0],
|
||||||
|
rconService: services.find((s) => s.metadata.name.endsWith("-rcon")),
|
||||||
|
rconSecret: secrets.find((s) =>
|
||||||
|
s.metadata.name.endsWith("-rcon-secret"),
|
||||||
|
),
|
||||||
|
backupSecret: secrets.find((s) =>
|
||||||
|
s.metadata.name.endsWith("-backup-secret"),
|
||||||
|
),
|
||||||
|
extraService: services.find((s) => s.metadata.name.endsWith("-extra")),
|
||||||
|
};
|
||||||
|
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(serverId) {
|
||||||
|
const servers = await getDeployments();
|
||||||
|
const serverDeployment = servers.find(
|
||||||
|
(s) => s.metadata.annotations["minecluster.dunemask.net/id"] === serverId,
|
||||||
|
);
|
||||||
|
if (!serverDeployment)
|
||||||
|
throw Error(`MCL Deployment with ID '${serverId}' could not be found!`);
|
||||||
|
|
||||||
|
return serverDeployment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getContainers(serverId) {
|
||||||
|
const deployment = await getDeployment(serverId);
|
||||||
|
return deployment.spec.template.spec.containers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function containerControl(serverSpec, 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 ftpContainer = depFtp ?? getFtpContainer(serverSpec);
|
||||||
|
const serverContainer = depServer ?? getCoreServerContainer(serverSpec);
|
||||||
|
const backupContainer = depBackup ?? getBackupContainer(serverSpec);
|
||||||
|
if (scaleUp && serverSpec.backupEnabled)
|
||||||
|
return [ftpContainer, serverContainer, backupContainer];
|
||||||
|
else if (scaleUp) return [ftpContainer, serverContainer];
|
||||||
|
return [ftpContainer];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function terminationControl(containers) {
|
||||||
|
return containers.length > 1 ? 30 /*seconds*/ : 1 /*seconds */;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleServer(serverId, scaleUp = false) {
|
||||||
|
const [deployment, serverSpec] = await Promise.all([
|
||||||
|
getDeployment(serverId),
|
||||||
|
getServerEntry(serverId),
|
||||||
|
]);
|
||||||
|
const containers = await containerControl(serverSpec, deployment, scaleUp);
|
||||||
|
const ts = terminationControl(containers);
|
||||||
|
|
||||||
|
// Speed up container termination if not running a server
|
||||||
|
deployment.spec.template.spec.terminationGracePeriodSeconds = ts;
|
||||||
|
deployment.spec.template.spec.containers = containers;
|
||||||
|
|
||||||
|
return k8sDeps.replaceNamespacedDeployment(
|
||||||
|
deployment.metadata.name,
|
||||||
|
namespace,
|
||||||
|
deployment,
|
||||||
|
);
|
||||||
|
}
|
|
@ -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));
|
|
||||||
}
|
|
90
lib/k8s/server-containers.js
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
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 { mclName, storage } = serverSpec;
|
||||||
|
const ftpContainer = loadYaml("lib/k8s/configs/containers/ftp-server.yml");
|
||||||
|
ftpContainer.name = `mcl-${mclName}-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",
|
||||||
|
}));
|
||||||
|
if (!storage) delete ftpContainer.volumeMounts;
|
||||||
|
return ftpContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCoreServerContainer(serverSpec) {
|
||||||
|
const { mclName, version, serverType, memory, storage } = serverSpec;
|
||||||
|
const container = loadYaml("lib/k8s/configs/containers/minecraft-server.yml");
|
||||||
|
// Container Updates
|
||||||
|
container.name = `mcl-${mclName}-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-${mclName}-rcon-secret`;
|
||||||
|
findEnv("RCON_PASSWORD").valueFrom.secretKeyRef.name = rs;
|
||||||
|
if (!storage) delete container.volumeMounts;
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServerContainer(serverSpec) {
|
||||||
|
const {
|
||||||
|
difficulty,
|
||||||
|
gamemode,
|
||||||
|
motd,
|
||||||
|
maxPlayers,
|
||||||
|
seed,
|
||||||
|
ops,
|
||||||
|
whitelist,
|
||||||
|
storage,
|
||||||
|
} = 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); */
|
||||||
|
if (!storage) delete container.volumeMounts;
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBackupContainer(serverSpec) {
|
||||||
|
const { mclName, backupEnabled, backupPath, storage } = serverSpec;
|
||||||
|
const container = loadYaml("lib/k8s/configs/containers/minecraft-backup.yml");
|
||||||
|
if (!backupEnabled) return;
|
||||||
|
const findEnv = (k) => container.env.find(({ name: n }) => n === k);
|
||||||
|
const updateEnv = (k, v) => (findEnv(k).value = v);
|
||||||
|
updateEnv("RCLONE_REMOTE", `${mclName}-backup`);
|
||||||
|
updateEnv("RCLONE_DEST_DIR", backupPath);
|
||||||
|
container.name = `mcl-${mclName}-backup`;
|
||||||
|
// RCON
|
||||||
|
const rs = `mcl-${mclName}-rcon-secret`;
|
||||||
|
findEnv("RCON_PASSWORD").valueFrom.secretKeyRef.name = rs;
|
||||||
|
if (!storage) delete container.volumeMounts;
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
|
@ -1,95 +0,0 @@
|
||||||
import k8s from "@kubernetes/client-node";
|
|
||||||
import { ERR } from "../util/logging.js";
|
|
||||||
const kc = new k8s.KubeConfig();
|
|
||||||
kc.loadFromDefault();
|
|
||||||
|
|
||||||
const k8sDeps = kc.makeApiClient(k8s.AppsV1Api);
|
|
||||||
const k8sCore = kc.makeApiClient(k8s.CoreV1Api);
|
|
||||||
const k8sMetrics = new k8s.Metrics(kc);
|
|
||||||
const namespace = process.env.MCL_SERVER_NAMESPACE;
|
|
||||||
|
|
||||||
export async function startServer(req, res) {
|
|
||||||
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 deploymentRes = await k8sDeps.listNamespacedDeployment(namespace);
|
|
||||||
const dep = deploymentRes.body.items.find(
|
|
||||||
(i) => i.metadata.name === `mcl-${name}`
|
|
||||||
);
|
|
||||||
if (!dep) return res.status(409).send("Server does not exist!");
|
|
||||||
if (dep.spec.replicas === 1)
|
|
||||||
return res.status(409).send("Server already started!");
|
|
||||||
dep.spec.replicas = 1;
|
|
||||||
k8sDeps.replaceNamespacedDeployment(`mcl-${name}`, namespace, dep);
|
|
||||||
res.sendStatus(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function stopServer(req, res) {
|
|
||||||
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 deploymentRes = await k8sDeps.listNamespacedDeployment(namespace);
|
|
||||||
const dep = deploymentRes.body.items.find(
|
|
||||||
(i) => i.metadata.name === `mcl-${name}`
|
|
||||||
);
|
|
||||||
if (!dep) return res.status(409).send("Server does not exist!");
|
|
||||||
if (dep.spec.replicas === 0)
|
|
||||||
return res.status(409).send("Server already stopped!");
|
|
||||||
dep.spec.replicas = 0;
|
|
||||||
k8sDeps.replaceNamespacedDeployment(`mcl-${name}`, namespace, dep);
|
|
||||||
res.sendStatus(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function serverList(req, res) {
|
|
||||||
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace);
|
|
||||||
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);
|
|
||||||
// TODO Add an annotation and manage using that
|
|
||||||
const serverDeployments = deployments.filter((d) =>
|
|
||||||
d.metadata.name.startsWith("mcl-")
|
|
||||||
);
|
|
||||||
var name, metrics, started;
|
|
||||||
const servers = serverDeployments.map((s) => {
|
|
||||||
name = s.metadata.name.substring(4);
|
|
||||||
metrics = null;
|
|
||||||
started = !!s.spec.replicas;
|
|
||||||
const pod = podMetricsResponse.items.find(({ metadata: md }) => {
|
|
||||||
return md.labels && md.labels.app && md.labels.app === `mcl-${name}-app`;
|
|
||||||
});
|
|
||||||
if (pod) {
|
|
||||||
const podCpus = pod.containers.map(
|
|
||||||
({ usage }) => parseInt(usage.cpu) / 1_000_000
|
|
||||||
);
|
|
||||||
const podMems = pod.containers.map(
|
|
||||||
({ usage }) => parseInt(usage.memory) / 1024
|
|
||||||
);
|
|
||||||
metrics = {
|
|
||||||
cpu: Math.ceil(podCpus.reduce((a, b) => a + b)),
|
|
||||||
memory: Math.ceil(podMems.reduce((a, b) => a + b)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { name, metrics, started };
|
|
||||||
});
|
|
||||||
var clusterMetrics = { cpu: 0, memory: 0 };
|
|
||||||
if (servers.length > 1) {
|
|
||||||
const clusterCpu = servers
|
|
||||||
.map(({ metrics }) => (metrics ? metrics.cpu : 0))
|
|
||||||
.reduce((a, b) => a + b);
|
|
||||||
const clusterMem = servers
|
|
||||||
.map(({ metrics }) => (metrics ? metrics.memory : 0))
|
|
||||||
.reduce((a, b) => a + b);
|
|
||||||
clusterMetrics = { cpu: clusterCpu, memory: clusterMem };
|
|
||||||
}
|
|
||||||
res.json({ servers, clusterMetrics });
|
|
||||||
}
|
|
|
@ -4,168 +4,226 @@ 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";
|
||||||
const kc = new k8s.KubeConfig();
|
|
||||||
kc.loadFromDefault();
|
import {
|
||||||
|
getFtpContainer,
|
||||||
|
getServerContainer,
|
||||||
|
getBackupContainer,
|
||||||
|
} from "./server-containers.js";
|
||||||
|
|
||||||
|
import kc from "./k8s-config.js";
|
||||||
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);
|
export function createExtraService(serverSpec) {
|
||||||
const { name, url, version, serverType, difficulty, gamemode, memory } =
|
const { mclName, id, extraPorts } = serverSpec;
|
||||||
serverSpec;
|
if (!extraPorts) return;
|
||||||
if (!name) return res.status(400).send("Server name is required!");
|
const serviceYaml = loadYaml("lib/k8s/configs/extra-svc.yml");
|
||||||
if (!url) return res.status(400).send("Server url is required!");
|
serviceYaml.metadata.labels.app = `mcl-${mclName}-app`;
|
||||||
if (!version) return res.status(400).send("Server version is required!");
|
serviceYaml.metadata.name = `mcl-${mclName}-extra`;
|
||||||
if (!difficulty)
|
serviceYaml.metadata.namespace = namespace;
|
||||||
return res.status(400).send("Server difficulty is required!");
|
serviceYaml.metadata.annotations["minecluster.dunemask.net/id"] = id;
|
||||||
if (!serverType) return res.status(400).send("Server type is required!");
|
serviceYaml.spec.selector.app = `mcl-${mclName}-app`;
|
||||||
if (!gamemode) return res.status(400).send("Server Gamemode is required!");
|
// Port List:
|
||||||
if (!memory) return res.status(400).send("Memory is required!");
|
const portList = extraPorts.map((p) => ({
|
||||||
req.body.name = req.body.name.toLowerCase();
|
port: parseInt(p),
|
||||||
return "filtered";
|
name: `mcl-extra-${p}`,
|
||||||
|
}));
|
||||||
|
const tcpPorts = portList.map(({ port, name }) => ({
|
||||||
|
port,
|
||||||
|
name: `${name}-tcp`,
|
||||||
|
protocol: "TCP",
|
||||||
|
targetPort: port,
|
||||||
|
}));
|
||||||
|
const udpPorts = portList.map(({ port, name }) => ({
|
||||||
|
port,
|
||||||
|
name: `${name}-udp`,
|
||||||
|
protocol: "UDP",
|
||||||
|
targetPort: port,
|
||||||
|
}));
|
||||||
|
|
||||||
|
serviceYaml.spec.ports = [...tcpPorts, ...udpPorts];
|
||||||
|
return serviceYaml;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBackupSecret(serverSpec) {
|
||||||
|
if (!serverSpec.backupEnabled) return; // If backup not defined, don't create RCLONE secret
|
||||||
|
const { mclName, id, backupId, backupKey, backupHost } = serverSpec;
|
||||||
|
const backupYaml = loadYaml("lib/k8s/configs/backup-secret.yml");
|
||||||
|
backupYaml.metadata.labels.app = `mcl-${mclName}-app`;
|
||||||
|
backupYaml.metadata.name = `mcl-${mclName}-backup-secret`;
|
||||||
|
backupYaml.metadata.namespace = namespace;
|
||||||
|
backupYaml.metadata.annotations["minecluster.dunemask.net/id"] = id;
|
||||||
|
const rcloneConfig = [
|
||||||
|
`[${mclName}-backup]`,
|
||||||
|
"type = s3",
|
||||||
|
"provider = Minio",
|
||||||
|
"env_auth = false",
|
||||||
|
`access_key_id = ${backupId}`,
|
||||||
|
`secret_access_key = ${backupKey}`,
|
||||||
|
`endpoint = ${backupHost}`,
|
||||||
|
`acl = private`,
|
||||||
|
`no_check_bucket = true`,
|
||||||
|
`no_check_container = true`,
|
||||||
|
].join("\n");
|
||||||
|
backupYaml.data["rclone.conf"] = Buffer.from(rcloneConfig).toString("base64");
|
||||||
|
return backupYaml;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createRconSecret(serverSpec) {
|
function createRconSecret(serverSpec) {
|
||||||
const { name } = serverSpec;
|
const { mclName, id } = 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-${mclName}-app`;
|
||||||
rconYaml.metadata.name = `mcl-${name}-rcon-secret`;
|
rconYaml.metadata.name = `mcl-${mclName}-rcon-secret`;
|
||||||
rconYaml.metadata.namespace = namespace;
|
rconYaml.metadata.namespace = namespace;
|
||||||
|
rconYaml.metadata.annotations["minecluster.dunemask.net/id"] = id;
|
||||||
return rconYaml;
|
return rconYaml;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createServerVolume(serverSpec) {
|
function createServerVolume(serverSpec) {
|
||||||
const { name } = serverSpec;
|
const { mclName, id, storage } = serverSpec;
|
||||||
const volumeYaml = yaml.load(
|
if (!storage) return;
|
||||||
fs.readFileSync(path.resolve("lib/k8s/configs/server-pvc.yml"), "utf8")
|
const volumeYaml = loadYaml("lib/k8s/configs/server-pvc.yml");
|
||||||
);
|
volumeYaml.metadata.labels.service = `mcl-${mclName}-server`;
|
||||||
volumeYaml.metadata.labels.service = `mcl-${name}-server`;
|
volumeYaml.metadata.name = `mcl-${mclName}-volume`;
|
||||||
volumeYaml.metadata.name = `mcl-${name}-volume`;
|
|
||||||
volumeYaml.metadata.namespace = namespace;
|
volumeYaml.metadata.namespace = namespace;
|
||||||
volumeYaml.spec.resources.requests.storage = "1Gi"; // TODO: Changeme
|
volumeYaml.metadata.annotations["minecluster.dunemask.net/id"] = id;
|
||||||
|
volumeYaml.spec.resources.requests.storage = `${storage}Gi`;
|
||||||
return volumeYaml;
|
return volumeYaml;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createServerDeploy(serverSpec) {
|
function createServerDeploy(serverSpec) {
|
||||||
const {
|
const { mclName, id, backupEnabled, storage } = 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,
|
|
||||||
maxPlayers,
|
|
||||||
seed,
|
|
||||||
modpack,
|
|
||||||
ops,
|
|
||||||
whitelist,
|
|
||||||
} = serverSpec;
|
|
||||||
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.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
|
// Configure Metadata;
|
||||||
serverContainer.env.find(({ name: n }) => n === "TYPE").value = serverType;
|
metadata.name = `mcl-${mclName}`;
|
||||||
serverContainer.env.find(({ name: n }) => n === "VERSION").value = version;
|
metadata.namespace = namespace;
|
||||||
serverContainer.env.find(({ name: n }) => n === "DIFFICULTY").value =
|
metadata.annotations["minecluster.dunemask.net/id"] = id;
|
||||||
difficulty;
|
deployYaml.metadata = metadata;
|
||||||
serverContainer.env.find(({ name: n }) => n === "MODE").value = gamemode;
|
|
||||||
serverContainer.env.find(({ name: n }) => n === "MOTD").value = motd;
|
deployYaml.spec.template.spec.terminationGracePeriodSeconds = 1;
|
||||||
serverContainer.env.find(({ name: n }) => n === "MAX_PLAYERS").value =
|
|
||||||
maxPlayers;
|
// Configure Lables & Selectors
|
||||||
serverContainer.env.find(({ name: n }) => n === "SEED").value = seed;
|
deployYaml.spec.selector.matchLabels.app = `mcl-${mclName}-app`;
|
||||||
serverContainer.env.find(({ name: n }) => n === "OPS").value = ops;
|
deployYaml.spec.template.metadata.labels.app = `mcl-${mclName}-app`;
|
||||||
serverContainer.env.find(({ name: n }) => n === "WHITELIST").value =
|
deployYaml.spec.template.metadata.annotations["minecluster.dunemask.net/id"] =
|
||||||
whitelist;
|
id;
|
||||||
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(
|
if (!!storage) {
|
||||||
({ name }) => name === "datadir"
|
const dvi = deployYaml.spec.template.spec.volumes.findIndex(
|
||||||
).persistentVolumeClaim.claimName = `mcl-${name}-volume`;
|
({ name }) => name === "datadir",
|
||||||
deployYaml.spec.template.spec.containers[0] = serverContainer;
|
);
|
||||||
|
delete deployYaml.spec.template.spec.volumes[dvi].emptyDir;
|
||||||
|
deployYaml.spec.template.spec.volumes[dvi] = {
|
||||||
|
...deployYaml.spec.template.spec.volumes[dvi],
|
||||||
|
persistentVolumeClaim: {
|
||||||
|
claimName: `mcl-${mclName}-volume`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backups
|
||||||
|
if (backupEnabled) {
|
||||||
|
deployYaml.spec.template.spec.volumes.push({
|
||||||
|
name: "rclone-config",
|
||||||
|
secret: {
|
||||||
|
defaultMode: 420,
|
||||||
|
items: [{ key: "rclone.conf", path: "rclone.conf" }],
|
||||||
|
secretName: `mcl-${mclName}-backup-secret`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
export function createServerService(serverSpec) {
|
||||||
const { name, url } = serverSpec;
|
const { mclName, host, id } = 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-${mclName}-app`;
|
||||||
serviceYaml.metadata.name = `mcl-${name}-server`;
|
serviceYaml.metadata.name = `mcl-${mclName}-server`;
|
||||||
serviceYaml.metadata.namespace = namespace;
|
serviceYaml.metadata.namespace = namespace;
|
||||||
serviceYaml.spec.selector.app = `mcl-${name}-app`;
|
serviceYaml.metadata.annotations["minecluster.dunemask.net/id"] = id;
|
||||||
|
serviceYaml.spec.selector.app = `mcl-${mclName}-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(createSpec) {
|
||||||
const { name, url } = serverSpec;
|
const { id, mclName } = createSpec;
|
||||||
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-${mclName}-app`;
|
||||||
);
|
rconSvcYaml.metadata.name = `mcl-${mclName}-rcon`;
|
||||||
rconSvcYaml.metadata.labels.app = `mcl-${name}-app`;
|
|
||||||
rconSvcYaml.metadata.name = `mcl-${name}-rcon`;
|
|
||||||
rconSvcYaml.metadata.namespace = namespace;
|
rconSvcYaml.metadata.namespace = namespace;
|
||||||
rconSvcYaml.spec.selector.app = `mcl-${name}-app`;
|
rconSvcYaml.metadata.annotations["minecluster.dunemask.net/id"] = id;
|
||||||
|
rconSvcYaml.spec.selector.app = `mcl-${mclName}-app`;
|
||||||
return rconSvcYaml;
|
return rconSvcYaml;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function createServer(req, res) {
|
export default async function createServerResources(createSpec) {
|
||||||
if (payloadFilter(req, res) !== "filtered") return;
|
const backupSecret = createBackupSecret(createSpec);
|
||||||
const serverSpec = req.body;
|
const rconSecret = createRconSecret(createSpec);
|
||||||
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace);
|
const serverVolume = createServerVolume(createSpec);
|
||||||
const deployments = deploymentRes.body.items.map((i) => i.metadata.name);
|
const serverDeploy = createServerDeploy(createSpec);
|
||||||
if (deployments.includes(`mcl-${serverSpec.name}`))
|
const serverService = createServerService(createSpec);
|
||||||
return res.status(409).send("Server already exists!");
|
const rconService = createRconService(createSpec);
|
||||||
const pvcRes = await k8sCore.listNamespacedPersistentVolumeClaim(namespace);
|
const extraService = createExtraService(createSpec);
|
||||||
const pvcs = pvcRes.body.items.map((i) => i.metadata.name);
|
const serverResources = [];
|
||||||
if (pvcs.includes(`mcl-${serverSpec.name}-volume`))
|
if (!!serverVolume)
|
||||||
return res.status(409).send("Server PVC already exists!");
|
serverResources.push(
|
||||||
const rconSecret = createRconSecret(serverSpec);
|
k8sCore.createNamespacedPersistentVolumeClaim(namespace, serverVolume),
|
||||||
const serverVolume = createServerVolume(serverSpec);
|
);
|
||||||
const serverDeploy = createServerDeploy(serverSpec);
|
if (!!extraService)
|
||||||
const serverService = createServerService(serverSpec);
|
serverResources.push(
|
||||||
const rconService = createRconService(serverSpec);
|
k8sCore.createNamespacedService(namespace, extraService),
|
||||||
k8sCore.createNamespacedPersistentVolumeClaim(namespace, serverVolume);
|
);
|
||||||
k8sCore.createNamespacedSecret(namespace, rconSecret);
|
if (!!backupSecret)
|
||||||
k8sCore.createNamespacedService(namespace, serverService);
|
serverResources.push(
|
||||||
k8sCore.createNamespacedService(namespace, rconService);
|
k8sCore.createNamespacedSecret(namespace, backupSecret),
|
||||||
k8sDeps.createNamespacedDeployment(namespace, serverDeploy);
|
);
|
||||||
|
serverResources.push(k8sCore.createNamespacedSecret(namespace, rconSecret));
|
||||||
res.sendStatus(200);
|
serverResources.push(
|
||||||
|
k8sCore.createNamespacedService(namespace, serverService),
|
||||||
|
);
|
||||||
|
serverResources.push(k8sCore.createNamespacedService(namespace, rconService));
|
||||||
|
serverResources.push(
|
||||||
|
k8sDeps.createNamespacedDeployment(namespace, serverDeploy),
|
||||||
|
);
|
||||||
|
return await Promise.all(serverResources);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,55 +1,69 @@
|
||||||
import k8s from "@kubernetes/client-node";
|
import k8s from "@kubernetes/client-node";
|
||||||
import { ERR } from "../util/logging.js";
|
import { ERR } from "../util/logging.js";
|
||||||
const kc = new k8s.KubeConfig();
|
import { getServerAssets } from "./k8s-server-control.js";
|
||||||
kc.loadFromDefault();
|
import ExpressClientError from "../util/ExpressClientError.js";
|
||||||
|
import kc from "./k8s-config.js";
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
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!");
|
|
||||||
const { name } = serverSpec;
|
export default async function deleteServerResources(serverSpec) {
|
||||||
|
const { id } = serverSpec;
|
||||||
// Ensure deployment exists
|
// Ensure deployment exists
|
||||||
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace);
|
const server = await getServerAssets(id);
|
||||||
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(
|
|
||||||
`mcl-${name}-volume`,
|
const deleteBackupSecret = deleteOnExist(server.backupSecret, (name) =>
|
||||||
namespace
|
k8sCore.deleteNamespacedSecret(name, namespace),
|
||||||
);
|
);
|
||||||
Promise.all([
|
|
||||||
|
const deleteExtraService = deleteOnExist(server.extraService, (name) =>
|
||||||
|
k8sCore.deleteNamespacedService(name, namespace),
|
||||||
|
);
|
||||||
|
const deleteVolume = deleteOnExist(server.volume, (name) =>
|
||||||
|
k8sCore.deleteNamespacedPersistentVolumeClaim(name, namespace),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Promise.all([
|
||||||
deleteService,
|
deleteService,
|
||||||
deleteRconService,
|
deleteRconService,
|
||||||
deleteRconSecret,
|
deleteRconSecret,
|
||||||
|
deleteExtraService,
|
||||||
|
deleteBackupSecret,
|
||||||
deleteVolume,
|
deleteVolume,
|
||||||
])
|
]).catch(deleteError);
|
||||||
.then(() => res.sendStatus(200))
|
|
||||||
.catch(deleteError(res));
|
|
||||||
}
|
}
|
||||||
|
|
109
lib/k8s/server-files.js
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
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, Transform } from "node:stream";
|
||||||
|
import { dirname, basename } from "node:path";
|
||||||
|
|
||||||
|
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 { id } = serverSpec;
|
||||||
|
const server = await getServerAssets(id);
|
||||||
|
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.ensureDir(dirname(path));
|
||||||
|
await c.uploadFrom(fileStream, basename(path));
|
||||||
|
}).catch(handleError);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerItem(serverSpec) {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveServerItems(serverSpec) {
|
||||||
|
const { destination, origin, files } = serverSpec;
|
||||||
|
useServerFtp(serverSpec, async (c) =>
|
||||||
|
Promise.all(
|
||||||
|
files.map((f) => c.rename(`${origin}/${f}`, `${destination}/${f}`)),
|
||||||
|
),
|
||||||
|
).catch(handleError);
|
||||||
|
return files;
|
||||||
|
}
|
59
lib/k8s/server-modify.js
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
import k8s from "@kubernetes/client-node";
|
||||||
|
import {
|
||||||
|
createExtraService,
|
||||||
|
createBackupSecret,
|
||||||
|
createServerService,
|
||||||
|
} from "./server-create.js";
|
||||||
|
import kc from "./k8s-config.js";
|
||||||
|
import { getServerAssets } from "./k8s-server-control.js";
|
||||||
|
const k8sCore = kc.makeApiClient(k8s.CoreV1Api);
|
||||||
|
const namespace = process.env.MCL_SERVER_NAMESPACE;
|
||||||
|
|
||||||
|
export default async function modifyServerResources(modifySpec) {
|
||||||
|
const { id: serverId } = modifySpec;
|
||||||
|
const serverAssets = await getServerAssets(serverId);
|
||||||
|
const serverService = createServerService(modifySpec);
|
||||||
|
const extraService = createExtraService(modifySpec);
|
||||||
|
const backupSecret = createBackupSecret(modifySpec);
|
||||||
|
const serverResources = [];
|
||||||
|
|
||||||
|
if (!!serverService)
|
||||||
|
// Will Always Exist
|
||||||
|
serverResources.push(
|
||||||
|
k8sCore.replaceNamespacedService(
|
||||||
|
serverAssets.service.metadata.name,
|
||||||
|
namespace,
|
||||||
|
serverService,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!!extraService && !!serverAssets.extraService)
|
||||||
|
// Might not exist
|
||||||
|
serverResources.push(
|
||||||
|
k8sCore.replaceNamespacedService(
|
||||||
|
serverAssets.extraService.metadata.name,
|
||||||
|
namespace,
|
||||||
|
extraService,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
else if (!!extraService)
|
||||||
|
serverResources.push(
|
||||||
|
k8sCore.createNamespacedService(namespace, extraService),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!!backupSecret && !!serverAssets.backupSecret)
|
||||||
|
// Might not exist
|
||||||
|
serverResources.push(
|
||||||
|
k8sCore.replaceNamespacedSecret(
|
||||||
|
serverAssets.backupSecret.metadata.name,
|
||||||
|
namespace,
|
||||||
|
backupSecret,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
else if (!!backupSecret)
|
||||||
|
serverResources.push(
|
||||||
|
k8sCore.createNamespacedSecret(namespace, backupSecret),
|
||||||
|
);
|
||||||
|
|
||||||
|
return await Promise.all(serverResources);
|
||||||
|
}
|
85
lib/k8s/server-status.js
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
import k8s from "@kubernetes/client-node";
|
||||||
|
import { getUserDeployments } from "./k8s-server-control.js";
|
||||||
|
import { getServerEntries } from "../database/queries/server-queries.js";
|
||||||
|
import kc from "./k8s-config.js";
|
||||||
|
|
||||||
|
const k8sMetrics = new k8s.Metrics(kc);
|
||||||
|
const namespace = process.env.MCL_SERVER_NAMESPACE;
|
||||||
|
|
||||||
|
function getServerMetrics(podMetricsRes, serverId, serverAvailable) {
|
||||||
|
const pod = podMetricsRes.items.find(({ metadata: md }) => {
|
||||||
|
return (
|
||||||
|
md.annotations &&
|
||||||
|
md.annotations["minecluster.dunemask.net/id"] === serverId
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (!serverAvailable || !pod) return null;
|
||||||
|
const podCpus = pod.containers.map(
|
||||||
|
({ usage }) => parseInt(usage.cpu) / 1_000_000,
|
||||||
|
);
|
||||||
|
const podMems = pod.containers.map(
|
||||||
|
({ usage }) => parseInt(usage.memory) / 1024,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
cpu: Math.ceil(podCpus.reduce((a, b) => a + b)),
|
||||||
|
memory: Math.ceil(podMems.reduce((a, b) => a + b)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerStatus(server) {
|
||||||
|
const { containers } = server.spec.template.spec;
|
||||||
|
const services = containers.map(({ name }) => name.split("-").pop());
|
||||||
|
const serverStatusList = server.status.conditions.map(
|
||||||
|
({ type: statusType, status: sts }) => ({ statusType, sts }),
|
||||||
|
);
|
||||||
|
const deploymentAvailable =
|
||||||
|
serverStatusList.find(
|
||||||
|
(ss) => ss.statusType === "Available" && ss.sts === "True",
|
||||||
|
) !== undefined;
|
||||||
|
const serverAvailable = services.includes(`server`) && deploymentAvailable;
|
||||||
|
const ftpAvailable = services.includes("ftp"); // TODO this needs some handling for container creation
|
||||||
|
return { serverAvailable, ftpAvailable, services, deploymentAvailable };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getInstances(cairoId) {
|
||||||
|
const [serverDeployments, podMetricsRes, entries] = await Promise.all([
|
||||||
|
getUserDeployments(cairoId),
|
||||||
|
k8sMetrics.getPodMetrics(namespace),
|
||||||
|
getServerEntries(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
var serverId, metrics;
|
||||||
|
const serverInstances = serverDeployments.map((s) => {
|
||||||
|
serverId = s.metadata.annotations["minecluster.dunemask.net/id"];
|
||||||
|
const entry = entries.find((e) => e.id === serverId);
|
||||||
|
const { ftpAvailable, serverAvailable, services, deploymentAvailable } =
|
||||||
|
getServerStatus(s);
|
||||||
|
metrics = getServerMetrics(podMetricsRes, serverId, serverAvailable);
|
||||||
|
return {
|
||||||
|
name: !!entry ? entry.name : "Unknown",
|
||||||
|
host: !!entry ? entry.host : "Unkonwn",
|
||||||
|
id: serverId,
|
||||||
|
metrics,
|
||||||
|
services,
|
||||||
|
serverAvailable,
|
||||||
|
ftpAvailable,
|
||||||
|
deploymentAvailable,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return serverInstances;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNamespaceMetrics() {
|
||||||
|
const serverInstances = await getInstances();
|
||||||
|
var clusterMetrics = { cpu: 0, memory: 0 };
|
||||||
|
if (servers.length > 1) {
|
||||||
|
const clusterCpu = serverInstances
|
||||||
|
.map(({ metrics }) => (metrics ? metrics.cpu : 0))
|
||||||
|
.reduce((a, b) => a + b);
|
||||||
|
const clusterMem = serverInstances
|
||||||
|
.map(({ metrics }) => (metrics ? metrics.memory : 0))
|
||||||
|
.reduce((a, b) => a + b);
|
||||||
|
clusterMetrics = { cpu: clusterCpu, memory: clusterMem };
|
||||||
|
}
|
||||||
|
return clusterMetrics;
|
||||||
|
}
|
19
lib/routes/auth-route.js
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import cairoAuthMiddleware from "./middlewares/auth-middleware.js";
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const cairoProjectId = process.env.MCL_CAIRO_PROJECT;
|
||||||
|
if(!cairoProjectId) throw Error("Cairo Project Required!");
|
||||||
|
|
||||||
|
const ok = (_r, res) => res.sendStatus(200);
|
||||||
|
|
||||||
|
function cairoRedirect(req, res) {
|
||||||
|
res.redirect(
|
||||||
|
`${process.env.MCL_CAIRO_URL}/cairo/authenticate?redirectUri=${req.query.redirectUri}&projectId=${cairoProjectId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/verify", cairoAuthMiddleware, ok);
|
||||||
|
router.get("/redirect", cairoRedirect);
|
||||||
|
|
||||||
|
export default router;
|
17
lib/routes/error-route.js
Normal 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 });
|
||||||
|
}
|
25
lib/routes/files-route.js
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import { Router, json as jsonMiddleware } from "express";
|
||||||
|
import multer from "multer";
|
||||||
|
import {
|
||||||
|
createFolder,
|
||||||
|
deleteItem,
|
||||||
|
listFiles,
|
||||||
|
uploadItem,
|
||||||
|
getItem,
|
||||||
|
moveItems,
|
||||||
|
} from "../controllers/file-controller.js";
|
||||||
|
|
||||||
|
import cairoAuthMiddleware from "./middlewares/auth-middleware.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use([jsonMiddleware(), cairoAuthMiddleware]);
|
||||||
|
const multerMiddleware = multer();
|
||||||
|
|
||||||
|
router.post("/list", listFiles);
|
||||||
|
router.post("/folder", createFolder);
|
||||||
|
router.delete("/item", deleteItem);
|
||||||
|
router.post("/item", getItem);
|
||||||
|
router.post("/move", moveItems);
|
||||||
|
router.post("/upload", multerMiddleware.single("file"), uploadItem);
|
||||||
|
|
||||||
|
export default router;
|
47
lib/routes/middlewares/auth-middleware.js
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
// Imports
|
||||||
|
import { Router } from "express";
|
||||||
|
import bearerTokenMiddleware from "express-bearer-token";
|
||||||
|
import { ERR, VERB } from "../../util/logging.js";
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
const { MCL_CAIRO_URL, MCL_CAIRO_PROJECT } = process.env;
|
||||||
|
const cairoAuthMiddleware = Router();
|
||||||
|
|
||||||
|
const cairoAuthenticate = async (token) => {
|
||||||
|
const config = { headers: { Authorization: `Bearer ${token}` } };
|
||||||
|
return fetch(`${MCL_CAIRO_URL}/api/${MCL_CAIRO_PROJECT}/auth/credentials`, config).then(async (res) => {
|
||||||
|
if (res.status >= 300) {
|
||||||
|
const errorMessage = await res
|
||||||
|
.json()
|
||||||
|
.then((data) => JSON.stringify(data))
|
||||||
|
.catch(() => res.statusText);
|
||||||
|
throw Error(
|
||||||
|
`Could not authenticate with user, receieved message: ${errorMessage}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
const cairoAuthHandler = (req, res, next) => {
|
||||||
|
if (!req.token) return res.status(401).send("Cairo auth required!");
|
||||||
|
cairoAuthenticate(req.token)
|
||||||
|
.then((authData) => {
|
||||||
|
console.log(authData);
|
||||||
|
if (!authData?.user?.id)
|
||||||
|
throw Error(`Cairo didn't return the expected data! ${authData?.user?.id}`);
|
||||||
|
req.cairoId = authData?.user?.id;
|
||||||
|
})
|
||||||
|
.then(() => next())
|
||||||
|
.catch((err) => {
|
||||||
|
ERR("AUTH", err.response ? err.response.data : err.message);
|
||||||
|
if (!err.response) return res.status(500).send(`Auth failure ${err}`);
|
||||||
|
return res.status(err.response.status).send(err.response.data);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
cairoAuthMiddleware.use([bearerTokenMiddleware(), cairoAuthHandler]);
|
||||||
|
|
||||||
|
export default cairoAuthMiddleware;
|
2
lib/routes/react-route.js
vendored
|
@ -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;
|
||||||
|
|
11
lib/routes/s3-route.js
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import { Router, json as jsonMiddleware } from "express";
|
||||||
|
import { getS3BackupUrl, listS3Backups } from "../controllers/s3-controller.js";
|
||||||
|
import cairoAuthMiddleware from "./middlewares/auth-middleware.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use([cairoAuthMiddleware, jsonMiddleware()]);
|
||||||
|
|
||||||
|
router.post("/backups", listS3Backups);
|
||||||
|
router.post("/backup-url", getS3BackupUrl);
|
||||||
|
|
||||||
|
export default router;
|
|
@ -1,19 +1,28 @@
|
||||||
import { Router, json as jsonMiddleware } from "express";
|
import { Router, json as jsonMiddleware } from "express";
|
||||||
import {
|
import {
|
||||||
|
createServer,
|
||||||
|
deleteServer,
|
||||||
startServer,
|
startServer,
|
||||||
stopServer,
|
stopServer,
|
||||||
|
getServer,
|
||||||
|
modifyServer,
|
||||||
|
} 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 cairoAuthMiddleware from "./middlewares/auth-middleware.js";
|
||||||
import deleteServer from "../k8s/server-delete.js";
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
router.use(jsonMiddleware());
|
router.use([jsonMiddleware(), cairoAuthMiddleware]);
|
||||||
// Routes
|
// Routes
|
||||||
router.post("/create", createServer);
|
router.post("/create", createServer);
|
||||||
router.delete("/delete", deleteServer);
|
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);
|
||||||
|
router.post("/blueprint", getServer);
|
||||||
|
router.post("/modify", modifyServer);
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
@ -1,12 +1,17 @@
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import k8s from "@kubernetes/client-node";
|
import k8s from "@kubernetes/client-node";
|
||||||
import { WARN } from "../util/logging.js";
|
import { WARN } from "../util/logging.js";
|
||||||
|
import kc from "../k8s/k8s-config.js";
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const kc = new k8s.KubeConfig();
|
|
||||||
kc.loadFromDefault();
|
import cairoAuthMiddleware from "./middlewares/auth-middleware.js";
|
||||||
|
router.use(cairoAuthMiddleware);
|
||||||
|
|
||||||
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 }) => ({
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -3,9 +3,17 @@ import express from "express";
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
import vitals from "../routes/vitals-route.js";
|
import vitals from "../routes/vitals-route.js";
|
||||||
|
import authRoute from "../routes/auth-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 s3Route from "../routes/s3-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();
|
||||||
|
@ -16,9 +24,15 @@ export default function buildRoutes(pg, skio) {
|
||||||
// Middlewares
|
// Middlewares
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
|
router.use("/api/auth", authRoute);
|
||||||
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("/api/s3", s3Route);
|
||||||
|
router.use(["/mcl", "/mcl/*"], reactRoute); // Static Build Route
|
||||||
|
/*router.use(logErrors);
|
||||||
|
router.use(clientErrorHandler);
|
||||||
|
router.use(errorHandler);*/
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
@ -18,10 +18,10 @@ async function rconSend(socket, m) {
|
||||||
|
|
||||||
const socketConnect = async (io, socket) => {
|
const socketConnect = async (io, socket) => {
|
||||||
VERB("WS", "Websocket connecting");
|
VERB("WS", "Websocket connecting");
|
||||||
socket.mcs = { serverName: socket.handshake.query.serverName };
|
socket.mcs = { serverId: socket.handshake.query.serverId };
|
||||||
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);
|
||||||
|
|
28
lib/util/ExpressClientError.js
Normal 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);
|
||||||
|
};
|
4792
package-lock.json
generated
50
package.json
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "minecluster",
|
"name": "minecluster",
|
||||||
"version": "0.0.1-alpha.0",
|
"version": "0.0.1-alpha.1",
|
||||||
"description": "Minecraft Server management using Kubernetes",
|
"description": "Minecraft Server management using Kubernetes",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@ -8,7 +8,7 @@
|
||||||
"start": "node dist/app.js",
|
"start": "node dist/app.js",
|
||||||
"dev:server": "nodemon dist/app.js",
|
"dev:server": "nodemon dist/app.js",
|
||||||
"dev:react": "vite",
|
"dev:react": "vite",
|
||||||
"kub": "nodemon lib/k8s.js",
|
"lint": "npx prettier -w src lib vite.config.js",
|
||||||
"start:dev": "concurrently -k \"MCL_DEV_PORT=52025 npm run dev:server\" \" MCL_VITE_DEV_PORT=52000 MCL_VITE_BACKEND_URL=http://localhost:52025 npm run dev:react\" -n s,v -p -c green,yellow",
|
"start:dev": "concurrently -k \"MCL_DEV_PORT=52025 npm run dev:server\" \" MCL_VITE_DEV_PORT=52000 MCL_VITE_BACKEND_URL=http://localhost:52025 npm run dev:react\" -n s,v -p -c green,yellow",
|
||||||
"start:dev:garden": "concurrently -k \"npm run dev:server\" \"npm run dev:react\" -n s,v -p -c green,yellow"
|
"start:dev:garden": "concurrently -k \"npm run dev:server\" \"npm run dev:react\" -n s,v -p -c green,yellow"
|
||||||
},
|
},
|
||||||
|
@ -22,30 +22,44 @@
|
||||||
"author": "Dunemask",
|
"author": "Dunemask",
|
||||||
"license": "LGPL-2.1",
|
"license": "LGPL-2.1",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@emotion/react": "^11.11.1",
|
"@emotion/react": "^11.11.3",
|
||||||
"@emotion/styled": "^11.11.0",
|
"@emotion/styled": "^11.11.0",
|
||||||
"@mui/icons-material": "^5.14.3",
|
"@mui/icons-material": "^5.15.9",
|
||||||
"@mui/material": "^5.14.5",
|
"@mui/material": "^5.15.9",
|
||||||
"@tanstack/react-query": "^4.33.0",
|
"@tanstack/react-query": "^5.20.1",
|
||||||
"@vitejs/plugin-react": "^4.0.4",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"concurrently": "^8.2.0",
|
"chonky": "^2.3.2",
|
||||||
"nodemon": "^3.0.1",
|
"chonky-icon-fontawesome": "^2.3.2",
|
||||||
"prettier": "^3.0.2",
|
"concurrently": "^8.2.2",
|
||||||
|
"nodemon": "^3.0.3",
|
||||||
|
"prettier": "^3.2.5",
|
||||||
"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-quill": "^2.0.0",
|
||||||
"socket.io-client": "^4.7.2",
|
"react-router-dom": "^6.22.0",
|
||||||
"vite": "^4.4.9"
|
"react-toastify": "^10.0.4",
|
||||||
|
"socket.io-client": "^4.7.4",
|
||||||
|
"vite": "^5.1.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kubernetes/client-node": "^0.18.1",
|
"@aws-sdk/client-s3": "^3.529.1",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.529.1",
|
||||||
|
"@kubernetes/client-node": "^0.20.0",
|
||||||
|
"basic-ftp": "^5.0.4",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"figlet": "^1.6.0",
|
"express-bearer-token": "^2.4.0",
|
||||||
|
"figlet": "^1.7.0",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"rcon-client": "^4.2.3",
|
"moment": "^2.30.1",
|
||||||
"socket.io": "^4.7.2",
|
"multer": "^1.4.5-lts.1",
|
||||||
"uuid": "^9.0.0"
|
"multer-s3": "^3.0.1",
|
||||||
|
"pg-promise": "^11.5.4",
|
||||||
|
"postgres-migrations": "^5.3.0",
|
||||||
|
"rcon-client": "^4.2.4",
|
||||||
|
"react-dropzone": "^14.2.3",
|
||||||
|
"socket.io": "^4.7.4",
|
||||||
|
"uuid": "^9.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
BIN
public/icons/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 8.6 KiB |
BIN
public/icons/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 59 KiB |
BIN
public/icons/apple-touch-icon-120x120-precomposed.png
Normal file
After Width: | Height: | Size: 4.3 KiB |
BIN
public/icons/apple-touch-icon-120x120.png
Normal file
After Width: | Height: | Size: 4 KiB |
BIN
public/icons/apple-touch-icon-152x152-precomposed.png
Normal file
After Width: | Height: | Size: 6.2 KiB |
BIN
public/icons/apple-touch-icon-152x152.png
Normal file
After Width: | Height: | Size: 6.1 KiB |
BIN
public/icons/apple-touch-icon-180x180-precomposed.png
Normal file
After Width: | Height: | Size: 8.4 KiB |
BIN
public/icons/apple-touch-icon-180x180.png
Normal file
After Width: | Height: | Size: 8 KiB |
BIN
public/icons/apple-touch-icon-60x60-precomposed.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
public/icons/apple-touch-icon-60x60.png
Normal file
After Width: | Height: | Size: 1.6 KiB |
BIN
public/icons/apple-touch-icon-76x76-precomposed.png
Normal file
After Width: | Height: | Size: 2.4 KiB |
BIN
public/icons/apple-touch-icon-76x76.png
Normal file
After Width: | Height: | Size: 2.2 KiB |
BIN
public/icons/apple-touch-icon-precomposed.png
Normal file
After Width: | Height: | Size: 8.4 KiB |
BIN
public/icons/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 8 KiB |
9
public/icons/browserconfig.xml
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<browserconfig>
|
||||||
|
<msapplication>
|
||||||
|
<tile>
|
||||||
|
<square150x150logo src="/icons/mstile-150x150.png?v=feb4-24-mineblock"/>
|
||||||
|
<TileColor>#00aba9</TileColor>
|
||||||
|
</tile>
|
||||||
|
</msapplication>
|
||||||
|
</browserconfig>
|
BIN
public/icons/favicon-16x16.png
Normal file
After Width: | Height: | Size: 444 B |
BIN
public/icons/favicon-32x32.png
Normal file
After Width: | Height: | Size: 854 B |
BIN
public/icons/favicon.ico
Normal file
After Width: | Height: | Size: 7.2 KiB |
BIN
public/icons/mstile-150x150.png
Normal file
After Width: | Height: | Size: 5 KiB |
252
public/icons/safari-pinned-tab.svg
Normal file
|
@ -0,0 +1,252 @@
|
||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||||
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
|
||||||
|
preserveAspectRatio="xMidYMid meet">
|
||||||
|
<metadata>
|
||||||
|
Created by potrace 1.14, written by Peter Selinger 2001-2017
|
||||||
|
</metadata>
|
||||||
|
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
|
||||||
|
fill="#000000" stroke="none">
|
||||||
|
<path d="M0 2560 l0 -2560 2560 0 2560 0 0 2560 0 2560 -2560 0 -2560 0 0
|
||||||
|
-2560z m2622 2261 c10 5 18 7 18 3 0 -3 8 0 17 7 11 10 14 10 9 2 -4 -7 -2
|
||||||
|
-14 3 -14 6 -1 17 -2 24 -3 6 -1 11 -7 10 -14 -3 -14 19 -12 29 3 5 6 8 3 8
|
||||||
|
-7 0 -9 -6 -19 -12 -21 -8 -3 -6 -6 5 -6 9 -1 17 5 17 13 0 17 33 27 51 15 8
|
||||||
|
-5 10 -9 4 -9 -5 0 1 -7 13 -16 13 -9 26 -13 29 -10 3 3 15 -1 26 -9 13 -9 27
|
||||||
|
-11 43 -5 17 7 27 5 37 -5 8 -8 17 -11 21 -8 3 4 6 1 6 -5 0 -5 -5 -12 -12
|
||||||
|
-14 -6 -2 -8 -10 -4 -17 6 -8 11 -9 21 -1 9 7 21 7 40 0 15 -6 32 -7 38 -3 7
|
||||||
|
3 9 3 5 -1 -4 -5 2 -17 12 -29 16 -17 22 -18 35 -7 13 11 14 10 8 -6 -6 -17
|
||||||
|
-5 -18 12 -4 12 10 16 10 11 2 -10 -17 13 -44 32 -37 8 3 16 -3 19 -15 3 -11
|
||||||
|
9 -21 14 -21 5 -1 13 -2 19 -3 5 0 15 -7 22 -13 6 -7 18 -13 26 -13 7 0 11 -4
|
||||||
|
8 -9 -3 -5 10 -12 29 -15 19 -4 35 -11 35 -17 0 -6 -6 -7 -12 -4 -7 4 -5 0 5
|
||||||
|
-8 9 -8 17 -19 17 -24 0 -6 8 -10 18 -10 23 1 67 -18 67 -29 0 -5 3 -8 7 -7 5
|
||||||
|
2 8 -1 8 -5 0 -4 23 -16 50 -26 28 -10 50 -22 50 -28 0 -18 76 -83 94 -80 10
|
||||||
|
2 27 -5 38 -15 12 -10 18 -12 14 -5 -4 6 -2 12 3 12 6 0 11 -6 11 -13 0 -7 9
|
||||||
|
-18 20 -25 12 -7 20 -23 20 -40 0 -20 3 -23 9 -13 8 12 15 9 37 -17 16 -18 34
|
||||||
|
-32 41 -32 7 0 23 -12 37 -26 15 -17 30 -25 41 -21 9 4 14 3 10 -3 -3 -6 1
|
||||||
|
-10 9 -10 9 0 13 -7 11 -17 -2 -13 4 -18 22 -20 15 0 36 -13 49 -30 49 -57
|
||||||
|
104 -113 114 -113 6 0 15 -11 20 -25 5 -14 16 -25 25 -25 8 0 15 -9 15 -20 0
|
||||||
|
-11 5 -20 10 -20 6 0 9 -3 8 -7 -6 -29 50 -103 78 -103 17 0 17 -1 -2 -15 -12
|
||||||
|
-9 -14 -14 -6 -15 7 0 11 -4 7 -9 -3 -6 4 -15 15 -21 18 -10 19 -14 8 -28 -10
|
||||||
|
-12 -10 -14 0 -8 6 4 12 2 12 -3 0 -6 7 -11 15 -11 9 0 12 -6 9 -15 -4 -8 -1
|
||||||
|
-22 6 -30 6 -8 9 -19 6 -24 -4 -5 -2 -12 4 -16 6 -4 8 -11 5 -16 -4 -5 -2 -9
|
||||||
|
3 -9 6 0 8 -10 4 -22 -4 -16 -2 -19 8 -13 10 6 12 3 8 -13 -4 -12 -3 -22 1
|
||||||
|
-22 3 0 6 -22 5 -50 -1 -27 2 -50 7 -50 5 0 9 -10 9 -23 0 -13 5 -28 10 -33
|
||||||
|
13 -13 22 -57 11 -50 -4 3 -8 -8 -7 -24 1 -17 6 -30 13 -30 19 0 27 -21 12
|
||||||
|
-31 -11 -8 -10 -12 5 -24 20 -15 20 -15 0 -15 -16 0 -17 -2 -4 -10 10 -6 11
|
||||||
|
-10 3 -10 -7 0 -13 -5 -13 -11 0 -5 5 -7 11 -3 8 5 10 -1 7 -20 -3 -14 -1 -26
|
||||||
|
3 -26 5 0 9 8 9 18 0 14 2 15 10 2 5 -8 5 -17 1 -20 -7 -4 -8 -135 -1 -135 1
|
||||||
|
0 4 -23 5 -52 2 -28 6 -57 10 -64 5 -7 4 -10 -2 -6 -6 4 -17 -1 -24 -10 -8 -9
|
||||||
|
-9 -14 -2 -10 6 4 19 0 28 -9 15 -15 15 -18 -1 -35 -10 -11 -12 -19 -6 -19 6
|
||||||
|
0 9 -7 6 -15 -5 -11 -1 -14 12 -9 18 5 18 5 2 -6 -10 -6 -18 -18 -18 -25 0
|
||||||
|
-18 12 -19 27 -4 9 9 10 6 6 -10 -4 -15 -11 -20 -22 -16 -8 3 -12 3 -9 -1 4
|
||||||
|
-4 3 -15 -2 -25 -12 -22 8 -39 21 -18 7 11 9 10 9 -3 0 -10 -5 -18 -10 -18 -6
|
||||||
|
0 -9 -12 -7 -27 2 -16 0 -43 -4 -60 -4 -19 -3 -30 2 -27 5 3 17 0 26 -5 15 -8
|
||||||
|
15 -11 3 -12 -8 -1 -21 -1 -27 0 -7 1 -13 -3 -13 -9 0 -5 5 -10 10 -10 6 0 10
|
||||||
|
-5 10 -11 0 -5 -4 -8 -9 -5 -5 4 -7 -4 -3 -16 4 -16 2 -19 -8 -13 -9 5 -11 4
|
||||||
|
-6 -3 5 -9 15 -7 34 5 24 16 26 16 14 1 -10 -13 -11 -22 -2 -40 10 -22 10 -22
|
||||||
|
-4 -4 -9 10 -22 16 -31 13 -18 -7 -20 -27 -3 -27 9 0 9 -3 0 -12 -7 -7 -12
|
||||||
|
-19 -12 -27 0 -14 1 -14 10 -1 12 19 13 8 0 -24 -5 -15 -6 -28 0 -31 5 -3 7
|
||||||
|
-12 4 -20 -4 -8 -11 -12 -17 -8 -6 4 -7 1 -1 -8 4 -8 9 -21 11 -29 1 -8 10
|
||||||
|
-21 20 -29 16 -12 16 -13 1 -8 -12 4 -21 -1 -28 -13 -8 -15 -8 -20 1 -20 7 0
|
||||||
|
4 -9 -7 -22 -10 -12 -13 -18 -5 -13 9 5 8 0 -3 -18 -19 -29 -29 -70 -15 -62 5
|
||||||
|
4 7 10 4 15 -3 4 2 13 9 19 11 9 16 9 22 0 4 -8 3 -9 -4 -5 -7 4 -12 1 -12 -8
|
||||||
|
0 -8 -6 -21 -12 -28 -15 -15 -18 -78 -4 -78 4 0 -2 -9 -15 -21 -13 -11 -18
|
||||||
|
-18 -11 -14 15 8 15 0 2 -26 -7 -13 -7 -19 0 -19 5 0 10 -4 10 -10 0 -5 -7
|
||||||
|
-10 -15 -10 -8 0 -15 -4 -15 -10 0 -12 6 -13 29 -1 13 8 17 5 18 -12 0 -12 -1
|
||||||
|
-16 -4 -9 -3 6 -9 12 -14 12 -6 0 -8 -4 -5 -8 3 -5 -4 -16 -15 -25 -21 -19
|
||||||
|
-26 -47 -7 -47 6 0 3 -7 -8 -16 -10 -8 -13 -13 -6 -9 6 3 12 1 12 -4 0 -6 -4
|
||||||
|
-11 -10 -11 -5 0 -7 -7 -4 -15 4 -8 2 -21 -4 -28 -7 -8 -6 -18 3 -31 16 -21 9
|
||||||
|
-45 -17 -64 -19 -14 -25 -42 -9 -42 5 0 12 8 15 17 5 12 10 13 17 6 12 -12 3
|
||||||
|
-36 -11 -28 -4 3 -10 -2 -13 -9 -3 -8 -2 -17 4 -20 5 -2 4 -3 -3 -2 -7 2 -13
|
||||||
|
8 -13 14 0 6 -6 17 -13 24 -7 7 -13 9 -12 3 2 -5 1 -12 0 -15 -7 -16 -4 -66 5
|
||||||
|
-72 6 -5 3 -8 -7 -8 -9 0 -20 -5 -24 -12 -4 -6 -3 -8 4 -4 12 7 10 -20 -3 -31
|
||||||
|
-4 -4 -5 -2 -1 5 4 7 1 12 -8 12 -10 0 -16 -9 -16 -25 0 -14 -6 -25 -12 -25
|
||||||
|
-9 0 -8 -4 2 -10 10 -6 11 -10 3 -10 -7 0 -13 -8 -13 -17 0 -14 -2 -15 -9 -5
|
||||||
|
-6 10 -11 9 -22 -4 -8 -10 -9 -15 -2 -11 6 4 13 1 16 -5 6 -18 -3 -39 -14 -32
|
||||||
|
-5 3 -9 2 -8 -3 1 -4 1 -17 0 -28 -1 -15 -4 -16 -13 -7 -7 7 -17 12 -22 12 -6
|
||||||
|
0 -3 -5 7 -11 21 -12 22 -29 1 -29 -9 0 -12 5 -8 12 4 7 3 8 -4 4 -24 -15 -11
|
||||||
|
-28 23 -23 19 3 38 1 41 -4 3 -5 -5 -10 -18 -10 -17 -1 -18 -3 -5 -6 19 -5 23
|
||||||
|
-28 7 -38 -6 -4 -4 -12 5 -22 9 -8 14 -17 11 -20 -2 -3 -14 5 -25 16 -12 12
|
||||||
|
-16 21 -10 21 5 0 8 4 5 9 -4 5 -2 11 4 13 6 2 1 9 -10 16 -18 11 -20 10 -20
|
||||||
|
-9 0 -11 -4 -18 -9 -14 -5 3 -13 0 -17 -6 -4 -8 -3 -9 5 -5 8 6 10 0 5 -21 -3
|
||||||
|
-15 -5 -43 -4 -61 0 -21 -3 -31 -10 -27 -5 3 -10 -2 -11 -12 0 -10 -2 -25 -3
|
||||||
|
-33 -2 -8 -6 -48 -10 -89 -6 -75 -31 -120 -66 -121 -8 0 0 10 17 23 l33 22 0
|
||||||
|
687 c0 639 -1 688 -17 697 -12 6 -27 4 -48 -7 -16 -8 -43 -17 -59 -19 -15 -2
|
||||||
|
-49 -13 -75 -24 -25 -12 -56 -22 -67 -23 -12 -1 -32 -10 -43 -20 -12 -11 -31
|
||||||
|
-21 -43 -23 -12 -2 -26 -7 -32 -12 -27 -22 -157 -81 -166 -76 -6 4 -9 1 -8 -6
|
||||||
|
3 -13 -62 -50 -74 -42 -5 2 -8 0 -8 -5 0 -6 -10 -12 -23 -16 -13 -3 -29 -17
|
||||||
|
-35 -31 -6 -14 -16 -26 -22 -26 -11 -2 -25 -5 -37 -9 -5 -1 -11 -2 -15 -1 -5
|
||||||
|
0 -8 -3 -8 -9 0 -5 -5 -10 -11 -10 -7 0 -21 -9 -33 -21 -42 -42 -189 -129
|
||||||
|
-218 -129 -10 0 -18 -5 -18 -11 0 -5 -4 -8 -9 -4 -5 3 -12 1 -16 -5 -4 -6 -11
|
||||||
|
-8 -16 -5 -5 3 -39 -8 -76 -26 -38 -17 -84 -39 -103 -48 -19 -8 -39 -22 -43
|
||||||
|
-29 -5 -7 -12 -10 -18 -7 -5 4 -12 -1 -15 -9 -3 -9 -13 -16 -21 -16 -8 0 -27
|
||||||
|
-6 -41 -14 -44 -24 -111 -46 -137 -46 -33 0 -137 28 -149 40 -5 5 -17 10 -27
|
||||||
|
10 -9 0 -28 9 -42 20 -14 11 -32 20 -41 20 -9 0 -16 5 -16 10 0 6 -11 10 -25
|
||||||
|
10 -14 0 -44 9 -65 20 -22 11 -40 18 -40 15 0 -3 -10 1 -22 9 -13 8 -38 18
|
||||||
|
-56 21 -37 6 -192 84 -192 97 0 4 -7 8 -15 8 -8 0 -15 5 -15 11 0 6 -7 9 -15
|
||||||
|
5 -8 -3 -15 -1 -15 4 0 11 -64 54 -125 84 -22 11 -42 23 -45 28 -9 12 -120 69
|
||||||
|
-120 61 0 -4 -7 2 -16 13 -8 10 -12 13 -9 5 5 -9 -1 -8 -19 4 -14 10 -26 20
|
||||||
|
-26 24 0 4 -20 15 -45 25 -25 11 -45 23 -45 28 0 4 -8 8 -18 8 -10 0 -24 6
|
||||||
|
-31 13 -6 6 -15 12 -19 12 -4 1 -22 9 -39 18 -18 10 -33 15 -33 12 0 -4 -6 -3
|
||||||
|
-13 2 -18 11 -52 16 -182 26 -16 1 -35 9 -42 17 -10 13 -14 13 -30 -3 -17 -17
|
||||||
|
-18 -51 -15 -673 3 -538 1 -655 -10 -651 -7 2 -12 14 -11 26 2 15 -1 20 -10
|
||||||
|
15 -9 -6 -9 -5 0 8 7 8 10 18 6 22 -4 4 -8 28 -9 54 -3 79 -4 82 -15 82 -5 0
|
||||||
|
-7 -5 -3 -12 5 -7 3 -8 -6 -3 -10 6 -12 4 -7 -9 4 -11 3 -15 -4 -11 -6 4 -8
|
||||||
|
14 -5 22 4 9 1 22 -6 31 -10 12 -10 16 0 19 23 8 13 31 -10 26 -13 -3 -19 -2
|
||||||
|
-15 2 12 14 40 15 54 3 10 -8 12 -7 7 6 -4 9 -14 16 -23 15 -38 -3 -45 1 -26
|
||||||
|
15 18 13 18 14 -4 21 -15 4 -26 3 -31 -4 -6 -11 -8 9 -10 86 0 12 -5 25 -10
|
||||||
|
28 -6 4 -8 12 -4 18 3 7 -2 3 -11 -8 -17 -20 -17 -20 -11 2 6 18 3 21 -16 17
|
||||||
|
-13 -2 -17 -2 -10 2 6 3 9 11 5 17 -4 7 0 8 13 3 37 -15 49 -18 38 -9 -6 4
|
||||||
|
-10 16 -8 26 1 9 -2 15 -7 11 -5 -3 -7 -10 -4 -15 4 -5 1 -9 -5 -9 -6 0 -13 7
|
||||||
|
-16 15 -4 8 -10 12 -15 9 -5 -3 -9 2 -9 11 0 9 4 14 9 10 6 -3 9 4 8 15 -1 21
|
||||||
|
-17 28 -17 8 0 -9 -3 -8 -10 2 -5 8 -5 17 -1 20 4 3 11 13 14 24 6 14 3 17
|
||||||
|
-11 14 -11 -3 -19 0 -19 8 0 8 5 14 11 14 6 0 4 5 -4 10 -8 5 -10 10 -5 10 6
|
||||||
|
0 10 11 10 25 0 26 -25 42 -47 30 -7 -4 -2 2 11 13 20 16 21 20 7 25 -9 4 -16
|
||||||
|
2 -16 -4 0 -6 -5 -7 -10 -4 -19 12 1 25 27 19 18 -5 24 -3 19 5 -4 6 -15 11
|
||||||
|
-24 11 -15 0 -15 2 -2 10 13 9 13 10 0 10 -12 0 -12 2 -1 9 10 7 8 12 -10 24
|
||||||
|
-20 14 -20 17 -6 23 10 3 14 11 10 18 -4 6 -8 13 -9 16 -11 37 -18 48 -27 46
|
||||||
|
-7 -2 -9 -1 -4 1 4 3 4 10 1 15 -4 7 -2 8 4 4 7 -4 12 -3 12 3 0 5 -4 12 -8
|
||||||
|
15 -4 2 -7 17 -7 32 1 16 -7 36 -19 48 -12 12 -17 18 -12 14 16 -13 26 -9 20
|
||||||
|
7 -4 8 -14 16 -23 16 -14 1 -14 2 1 6 14 4 16 8 8 24 -8 13 -7 19 0 19 6 0 8
|
||||||
|
5 3 12 -4 7 -8 18 -9 24 -1 6 -5 22 -9 35 -5 19 -4 21 4 9 6 -8 11 -13 12 -10
|
||||||
|
0 3 2 13 4 23 2 9 -1 17 -6 17 -6 0 -7 5 -4 10 3 6 1 10 -5 10 -11 0 -10 37 3
|
||||||
|
57 4 6 2 13 -5 15 -8 3 -7 13 5 37 14 28 14 32 2 27 -12 -4 -14 0 -9 22 4 15
|
||||||
|
7 39 7 54 0 17 4 25 11 21 6 -4 4 2 -5 13 -9 10 -15 30 -13 43 3 14 -1 28 -8
|
||||||
|
33 -10 6 -10 8 1 8 10 0 11 3 2 14 -9 10 -8 16 4 26 11 9 12 15 5 20 -7 5 -8
|
||||||
|
15 -1 32 7 18 6 29 -2 37 -6 6 -7 11 -3 11 5 0 3 7 -4 16 -11 12 -11 15 -1 12
|
||||||
|
8 -2 15 5 16 15 2 9 -1 15 -6 11 -13 -8 -11 10 3 24 9 9 9 12 -3 12 -12 0 -12
|
||||||
|
2 0 9 12 7 12 13 3 24 -7 7 -8 18 -4 22 4 5 1 5 -5 1 -7 -4 -13 -2 -13 3 0 6
|
||||||
|
5 11 11 11 6 0 9 6 6 14 -3 7 -1 18 4 23 6 6 6 19 0 35 -5 14 -7 28 -4 31 3 4
|
||||||
|
6 31 6 61 1 43 5 57 18 61 13 4 16 19 17 73 0 44 4 66 11 64 6 -1 11 2 11 8 0
|
||||||
|
5 -5 10 -12 10 -6 0 -8 3 -5 6 3 4 2 15 -4 26 -9 16 -7 20 10 25 11 3 18 9 15
|
||||||
|
15 -3 5 -2 14 3 21 5 7 8 28 7 47 0 19 3 43 8 54 7 13 7 16 -1 12 -6 -4 -11
|
||||||
|
-3 -11 3 0 5 8 15 18 20 13 8 14 10 2 11 -13 0 -13 1 0 10 10 6 11 10 3 10 -7
|
||||||
|
0 -13 5 -13 11 0 6 7 8 16 5 8 -3 12 -2 9 4 -3 6 -3 10 2 10 8 0 9 6 15 97 2
|
||||||
|
29 7 55 11 59 4 4 7 16 7 26 0 11 5 29 12 41 6 12 13 37 14 55 1 18 10 45 20
|
||||||
|
60 10 15 17 34 16 42 -1 8 12 30 28 49 32 36 39 51 15 31 -13 -11 -14 -10 -9
|
||||||
|
4 3 9 12 16 19 16 17 0 56 35 48 44 -3 3 -1 6 5 6 6 0 9 6 6 13 -4 12 46 70
|
||||||
|
106 120 11 10 18 22 15 27 -4 6 0 10 8 10 17 0 77 67 68 76 -3 4 -1 4 5 1 6
|
||||||
|
-3 20 3 32 13 12 11 26 20 31 20 5 0 13 5 17 12 4 7 3 8 -4 4 -7 -4 -12 -3
|
||||||
|
-12 1 0 15 23 33 42 33 12 0 18 7 18 21 0 11 5 17 10 14 5 -3 10 -1 10 4 0 6
|
||||||
|
3 10 8 9 15 -3 42 15 42 29 0 8 5 11 10 8 6 -3 10 1 10 10 0 9 4 14 8 11 5 -3
|
||||||
|
28 13 52 35 46 42 103 72 116 61 4 -4 5 -2 1 3 -3 6 3 16 14 23 12 8 19 9 19
|
||||||
|
1 0 -5 5 -7 10 -4 6 4 8 11 5 16 -4 5 0 9 8 9 14 0 55 38 57 55 1 6 6 9 11 8
|
||||||
|
5 -2 9 2 9 8 0 8 5 7 15 -1 13 -11 14 -10 9 3 -4 11 0 16 13 16 43 1 51 5 45
|
||||||
|
19 -4 11 -1 13 8 7 10 -6 12 -4 7 8 -4 10 -2 14 6 11 7 -2 28 9 46 25 18 17
|
||||||
|
45 33 60 36 15 3 31 15 36 27 6 11 15 21 20 21 6 0 30 11 54 24 24 12 52 23
|
||||||
|
62 23 11 0 28 9 39 20 12 12 26 18 32 14 6 -4 8 -3 5 3 -8 13 70 56 88 49 8
|
||||||
|
-3 17 -1 21 5 3 6 10 6 17 1 16 -13 57 3 57 22 0 8 5 18 12 22 7 4 8 3 4 -5
|
||||||
|
-5 -8 10 -10 57 -9 36 2 63 6 60 11 -4 7 42 12 85 8 12 -1 22 3 22 9 0 7 8 5
|
||||||
|
22 -4 15 -11 26 -12 40 -5z m272 -29 c3 -5 -1 -9 -9 -9 -8 0 -12 4 -9 9 3 4 7
|
||||||
|
8 9 8 2 0 6 -4 9 -8z m-1334 -405 c0 -7 -8 -17 -18 -23 -13 -9 -21 -8 -32 4
|
||||||
|
-13 14 -12 15 3 9 10 -3 22 0 29 8 16 18 18 18 18 2z m2870 -1057 c0 -5 -2
|
||||||
|
-10 -4 -10 -3 0 -8 5 -11 10 -3 6 -1 10 4 10 6 0 11 -4 11 -10z m91 -567 c13
|
||||||
|
-16 12 -17 -3 -4 -17 13 -22 21 -14 21 2 0 10 -8 17 -17z m24 -93 c3 -6 -1 -7
|
||||||
|
-9 -4 -18 7 -21 14 -7 14 6 0 13 -4 16 -10z m8 -315 c-3 -9 -8 -14 -10 -11 -3
|
||||||
|
3 -2 9 2 15 9 16 15 13 8 -4z m-3933 -169 c0 -11 -19 -15 -25 -6 -3 5 1 10 9
|
||||||
|
10 9 0 16 -2 16 -4z m3820 -526 c0 -5 -5 -10 -11 -10 -5 0 -7 5 -4 10 3 6 8
|
||||||
|
10 11 10 2 0 4 -4 4 -10z m-3740 -80 c0 -5 -5 -10 -11 -10 -5 0 -7 5 -4 10 3
|
||||||
|
6 8 10 11 10 2 0 4 -4 4 -10z m3610 -375 c-7 -9 -15 -13 -19 -10 -3 3 1 10 9
|
||||||
|
15 21 14 24 12 10 -5z m-45 -85 c3 -5 1 -10 -4 -10 -6 0 -11 5 -11 10 0 6 2
|
||||||
|
10 4 10 3 0 8 -4 11 -10z m8 -27 c-7 -2 -19 -2 -25 0 -7 3 -2 5 12 5 14 0 19
|
||||||
|
-2 13 -5z m-3418 -102 c3 -5 2 -12 -3 -15 -5 -3 -9 1 -9 9 0 17 3 19 12 6z
|
||||||
|
m3366 -54 c-10 -9 -11 -8 -5 6 3 10 9 15 12 12 3 -3 0 -11 -7 -18z m-3092
|
||||||
|
-357 c164 -96 162 -94 160 -181 -2 -78 -17 -81 -22 -4 -2 27 -8 50 -15 53 -7
|
||||||
|
2 -9 8 -6 14 7 11 -27 39 -38 31 -5 -2 -11 3 -14 11 -3 9 -12 16 -20 16 -8 0
|
||||||
|
-14 5 -14 10 0 6 -7 10 -15 10 -8 0 -15 5 -15 10 0 6 -9 10 -20 10 -10 0 -23
|
||||||
|
8 -29 18 -5 9 -12 16 -16 14 -8 -4 -85 60 -85 71 0 6 5 6 13 0 6 -6 68 -43
|
||||||
|
136 -83z m2944 45 c-3 -9 -28 -28 -56 -43 -27 -15 -61 -33 -76 -41 -14 -8 -28
|
||||||
|
-17 -31 -21 -3 -4 -19 -13 -36 -20 -30 -13 -32 -18 -45 -104 l-14 -91 -3 75
|
||||||
|
c-4 95 1 113 35 131 16 8 33 18 38 22 25 20 171 106 182 106 7 1 10 -6 6 -14z"/>
|
||||||
|
<path d="M2800 3974 c-18 -5 -45 -62 -42 -86 3 -16 -8 -23 -30 -19 -5 0 -8 -4
|
||||||
|
-8 -11 0 -9 -3 -9 -11 -1 -7 7 -22 8 -46 1 -19 -5 -37 -7 -41 -4 -3 3 -11 1
|
||||||
|
-19 -5 -8 -8 -13 -8 -13 -1 0 11 -18 11 -37 -1 -7 -4 -23 -1 -37 8 -16 11 -26
|
||||||
|
13 -31 6 -3 -6 0 -12 7 -13 7 -1 -16 -4 -52 -7 -36 -3 -70 -5 -77 -3 -7 2 -19
|
||||||
|
26 -28 53 -16 48 -37 69 -69 69 -53 0 -204 -113 -176 -130 7 -5 27 -6 44 -4
|
||||||
|
59 7 67 -1 64 -64 -3 -55 -4 -58 -40 -75 -44 -20 -45 -26 -17 -83 32 -67 35
|
||||||
|
-67 133 -34 172 58 362 58 548 0 37 -11 66 -16 72 -10 5 5 17 33 27 62 l19 53
|
||||||
|
-32 27 c-34 30 -34 31 17 102 39 54 46 103 18 128 -10 9 -40 23 -66 32 -49 16
|
||||||
|
-51 17 -77 10z"/>
|
||||||
|
<path d="M3295 3625 c-16 -8 -46 -14 -65 -15 -62 -1 -79 -10 -90 -49 -5 -20
|
||||||
|
-10 -51 -10 -69 0 -18 -6 -41 -12 -52 -10 -15 -10 -24 -2 -37 107 -167 124
|
||||||
|
-199 139 -266 23 -97 30 -292 16 -398 -23 -175 -38 -207 -154 -338 -35 -38
|
||||||
|
-38 -45 -24 -58 8 -9 49 -19 93 -25 110 -14 121 -20 134 -70 27 -97 39 -105
|
||||||
|
104 -67 62 36 78 87 60 183 -5 29 -4 37 4 32 12 -7 17 11 13 43 -2 7 2 10 8 6
|
||||||
|
7 -4 11 5 11 23 0 16 6 35 14 41 18 15 120 18 138 4 7 -6 21 -9 31 -8 9 2 17
|
||||||
|
-1 17 -5 0 -5 9 -12 20 -15 11 -3 20 -13 20 -22 0 -22 93 -12 122 13 14 12 19
|
||||||
|
14 13 4 -7 -13 -6 -13 8 0 9 8 18 27 21 42 2 15 7 32 10 36 3 5 0 25 -6 45
|
||||||
|
-10 32 -17 38 -67 54 -65 20 -85 35 -69 51 6 6 12 26 13 45 1 19 5 39 9 46 4
|
||||||
|
6 1 11 -6 11 -9 0 -9 3 2 10 11 7 11 10 2 10 -7 0 -10 5 -7 10 11 17 24 11 20
|
||||||
|
-10 -2 -11 0 -20 6 -20 12 0 12 37 -2 45 -6 5 -4 12 8 22 10 8 13 12 6 9 -6
|
||||||
|
-4 -14 -2 -18 4 -3 5 0 10 7 10 9 0 9 3 -2 10 -12 7 -12 10 -1 10 7 0 11 6 8
|
||||||
|
14 -4 10 0 13 14 11 16 -3 18 -1 10 9 -8 10 -6 18 9 31 l20 18 -22 25 c-16 17
|
||||||
|
-19 23 -8 22 26 -4 40 0 40 10 0 6 -10 10 -22 11 -22 1 -22 1 2 9 14 4 18 8
|
||||||
|
11 9 -9 1 0 15 23 36 31 28 37 40 32 59 -3 14 -2 27 2 30 8 5 7 44 -1 142 -2
|
||||||
|
23 -11 51 -20 64 -8 12 -13 29 -10 36 6 16 3 16 -82 20 -70 2 -81 -4 -96 -58
|
||||||
|
-8 -32 -14 -39 -29 -35 -11 3 -29 -4 -42 -14 -12 -11 -28 -17 -35 -14 -8 2
|
||||||
|
-12 1 -9 -4 8 -12 -43 -33 -94 -38 -61 -5 -78 -8 -85 -15 -9 -8 -55 3 -49 13
|
||||||
|
3 5 -13 27 -36 50 -45 46 -43 56 14 79 34 13 54 45 51 80 -1 8 -3 28 -3 43 -2
|
||||||
|
35 -43 77 -74 76 -13 0 -36 -7 -53 -14z"/>
|
||||||
|
<path d="M1709 3613 c-46 -19 -61 -123 -23 -160 8 -8 14 -19 14 -24 0 -5 6 -9
|
||||||
|
14 -9 7 0 16 -11 18 -24 4 -18 0 -26 -15 -30 -12 -3 -16 -9 -11 -18 5 -9 2 -8
|
||||||
|
-11 2 -17 14 -17 13 -10 -10 4 -14 4 -22 0 -18 -5 4 -14 3 -22 -4 -11 -8 -12
|
||||||
|
-7 -7 6 9 24 -11 20 -30 -6 -9 -13 -14 -18 -11 -11 7 15 -35 27 -70 21 -14 -3
|
||||||
|
-25 -1 -25 4 0 5 -4 6 -10 3 -5 -3 -10 -1 -10 6 0 7 -7 4 -16 -7 -14 -17 -15
|
||||||
|
-17 -10 -1 3 10 2 15 -3 12 -5 -3 -12 -1 -16 5 -3 5 -15 10 -26 10 -11 0 -18
|
||||||
|
4 -15 9 3 5 -7 22 -22 39 -22 24 -40 32 -85 37 -96 12 -126 -6 -130 -80 -1
|
||||||
|
-18 -4 -32 -7 -30 -3 2 -5 -19 -5 -46 0 -27 3 -49 7 -49 7 0 4 -24 -7 -52 -4
|
||||||
|
-11 -3 -18 4 -18 6 0 11 -8 11 -17 0 -9 3 -14 6 -10 4 3 12 1 20 -6 7 -7 21
|
||||||
|
-18 29 -26 9 -7 14 -20 10 -29 -3 -8 -2 -18 2 -21 14 -10 44 -61 36 -60 -20 3
|
||||||
|
-24 -2 -13 -16 7 -8 18 -15 24 -15 6 0 4 5 -4 10 -9 6 -10 10 -3 10 19 0 26
|
||||||
|
-20 9 -26 -9 -3 -16 -13 -16 -21 0 -8 5 -11 10 -8 6 3 10 1 10 -4 0 -6 -6 -11
|
||||||
|
-12 -11 -9 0 -8 -3 2 -10 17 -11 20 -30 7 -47 -6 -9 -5 -13 2 -13 7 0 9 -4 6
|
||||||
|
-10 -3 -5 -1 -10 5 -10 6 0 10 -8 8 -17 -2 -10 3 -17 9 -16 7 2 12 0 11 -5 -1
|
||||||
|
-4 1 -17 6 -29 4 -12 2 -31 -4 -43 -8 -15 -8 -20 2 -20 9 0 9 -3 -2 -10 -11
|
||||||
|
-7 -11 -10 -2 -10 7 0 10 -4 7 -10 -3 -5 -14 -10 -24 -10 -10 0 -26 -9 -36
|
||||||
|
-20 -10 -11 -23 -19 -29 -17 -5 2 -22 -8 -37 -22 -22 -21 -26 -32 -23 -67 3
|
||||||
|
-50 34 -86 84 -99 3 0 22 -7 42 -14 32 -11 43 -10 68 2 20 10 30 12 30 4 0 -6
|
||||||
|
7 -2 16 9 8 10 13 14 10 7 -4 -6 -2 -14 4 -18 5 -3 10 -1 10 5 0 6 5 8 13 3
|
||||||
|
20 -12 37 -12 37 0 0 7 5 6 11 -3 8 -11 12 -12 16 -2 7 19 22 14 18 -6 -3 -16
|
||||||
|
3 -18 31 -17 19 2 34 -2 34 -7 0 -5 6 -6 13 -2 9 6 9 5 0 -7 -12 -15 -14 -91
|
||||||
|
-2 -83 4 2 8 -4 8 -13 -1 -37 19 -91 40 -114 9 -10 33 -20 53 -22 42 -5 62 12
|
||||||
|
91 76 18 40 36 50 107 61 26 4 57 18 73 32 l29 25 -61 64 c-81 88 -108 137
|
||||||
|
-130 241 -27 126 -34 245 -21 371 13 135 31 190 88 277 48 73 49 79 33 190
|
||||||
|
-14 99 -40 123 -134 128 -39 2 -82 -2 -98 -9z m-84 -1183 c3 -6 -1 -7 -9 -4
|
||||||
|
-18 7 -21 14 -7 14 6 0 13 -4 16 -10z"/>
|
||||||
|
<path d="M2402 3560 c-49 -9 -160 -44 -169 -54 -7 -7 58 -114 140 -230 l69
|
||||||
|
-98 -73 -76 c-70 -72 -57 -62 65 51 27 25 35 27 120 27 51 0 97 -4 104 -10 8
|
||||||
|
-7 9 -7 3 1 -6 7 16 50 63 123 103 158 122 194 109 207 -5 5 -42 21 -82 35
|
||||||
|
-81 28 -257 40 -349 24z"/>
|
||||||
|
<path d="M1656 3365 c4 -8 8 -15 10 -15 2 0 4 7 4 15 0 8 -4 15 -10 15 -5 0
|
||||||
|
-7 -7 -4 -15z"/>
|
||||||
|
<path d="M3155 3150 c-16 -4 -92 -37 -167 -74 -76 -36 -141 -66 -144 -66 -3 0
|
||||||
|
-35 32 -71 70 -36 39 -70 70 -75 70 -6 0 22 -34 61 -76 l71 -76 0 -105 0 -105
|
||||||
|
-67 -69 c-95 -98 -121 -113 -207 -113 -69 -1 -73 0 -131 43 -33 25 -79 64
|
||||||
|
-102 88 l-43 42 0 111 c0 77 -3 110 -12 110 -6 0 -90 34 -185 76 -96 42 -178
|
||||||
|
73 -182 68 -15 -17 -31 -116 -31 -188 0 -65 35 -334 45 -344 2 -2 25 6 52 18
|
||||||
|
115 53 279 120 293 120 15 0 182 -134 188 -150 2 -5 -41 -75 -95 -157 -54 -81
|
||||||
|
-99 -151 -101 -154 -7 -16 108 -43 228 -54 133 -12 360 27 360 62 0 9 -38 78
|
||||||
|
-85 154 -47 75 -85 143 -85 149 0 7 38 50 85 96 l86 83 174 -90 c95 -49 176
|
||||||
|
-86 179 -81 2 4 10 66 16 138 13 135 8 273 -13 360 -12 49 -13 50 -42 44z"/>
|
||||||
|
<path d="M3815 2790 c-3 -5 1 -10 10 -10 9 0 13 5 10 10 -3 6 -8 10 -10 10 -2
|
||||||
|
0 -7 -4 -10 -10z"/>
|
||||||
|
<path d="M2159 2214 l-22 -37 21 -18 c40 -32 20 -98 -36 -120 -34 -13 -48 -51
|
||||||
|
-32 -89 7 -16 17 -30 23 -30 5 0 33 -14 61 -32 46 -28 116 -46 116 -29 0 3 16
|
||||||
|
23 35 44 25 27 30 37 17 32 -15 -4 -19 -1 -17 17 3 32 3 31 21 13 14 -13 19
|
||||||
|
-14 25 -3 7 11 11 10 19 -2 7 -11 10 -11 10 -2 0 7 4 11 9 7 5 -3 12 -1 15 4
|
||||||
|
4 5 18 8 32 5 19 -3 24 -1 18 8 -5 10 -4 10 5 2 14 -13 54 -7 45 7 -3 5 2 9
|
||||||
|
10 9 22 0 31 -4 29 -12 -2 -4 11 -2 28 4 24 8 33 7 48 -7 13 -11 21 -13 26 -5
|
||||||
|
5 8 12 5 22 -7 8 -10 11 -12 7 -4 -5 10 -3 12 7 5 27 -16 40 -14 29 6 -18 34
|
||||||
|
-10 36 10 2 11 -18 20 -43 20 -57 0 -49 63 -55 163 -17 82 32 91 42 88 102 -2
|
||||||
|
68 -14 84 -62 77 -46 -6 -59 16 -29 48 23 25 23 27 9 78 -12 44 -19 45 -109
|
||||||
|
13 -105 -38 -251 -52 -378 -37 -94 12 -195 35 -243 55 -12 5 -23 -3 -40 -30z"/>
|
||||||
|
<path d="M4385 1510 c-4 -6 -12 -8 -18 -4 -7 3 -3 -2 7 -10 16 -13 16 -16 5
|
||||||
|
-16 -9 0 -16 -7 -16 -15 0 -20 6 -19 28 7 22 24 24 32 7 22 -8 -5 -9 -2 -5 9
|
||||||
|
7 19 2 23 -8 7z"/>
|
||||||
|
<path d="M850 1128 c0 -4 6 -8 14 -8 8 0 17 4 20 8 2 4 -4 8 -15 8 -10 0 -19
|
||||||
|
-4 -19 -8z"/>
|
||||||
|
<path d="M910 945 c-7 -8 -11 -15 -9 -16 32 -6 39 -5 39 6 0 21 -16 26 -30 10z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 18 KiB |
19
public/icons/site.webmanifest
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"name": "Minecluster",
|
||||||
|
"short_name": "Minecluster",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/android-chrome-192x192.png?v=feb4-24-mineblock",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/android-chrome-512x512.png?v=feb4-24-mineblock",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#249c6b",
|
||||||
|
"background_color": "#249c6b",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
10
src/MCL.jsx
|
@ -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>
|
||||||
|
|
103
src/components/files/FilePreview.jsx
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
|
import DialogActions from "@mui/material/DialogActions";
|
||||||
|
import Dialog from "@mui/material/Dialog";
|
||||||
|
import Toolbar from "@mui/material/Toolbar";
|
||||||
|
import TextEditor from "./TextEditor.jsx";
|
||||||
|
import { cairoAuthHeader } from "@mcl/util/auth.js";
|
||||||
|
|
||||||
|
const textFileTypes = [
|
||||||
|
"properties",
|
||||||
|
"txt",
|
||||||
|
"yaml",
|
||||||
|
"yml",
|
||||||
|
"json",
|
||||||
|
"env",
|
||||||
|
"toml",
|
||||||
|
"tml",
|
||||||
|
"text",
|
||||||
|
];
|
||||||
|
const imageFileTypes = ["png", "jpeg", "jpg"];
|
||||||
|
|
||||||
|
export const supportedFileTypes = [...textFileTypes, ...imageFileTypes];
|
||||||
|
|
||||||
|
export function useFilePreview(isOpen = false) {
|
||||||
|
const [open, setOpen] = useState(isOpen);
|
||||||
|
const dialogToggle = () => setOpen(!open);
|
||||||
|
return [open, dialogToggle];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FilePreview(props) {
|
||||||
|
const [fileText, setFileText] = useState();
|
||||||
|
const [modifiedText, setModifiedText] = useState();
|
||||||
|
const theme = useTheme();
|
||||||
|
const fullScreen = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
|
|
||||||
|
const { previewData, open, dialogToggle, server: serverId } = props;
|
||||||
|
const { fileData, name, filePath } = previewData ?? {};
|
||||||
|
const ext = name ? name.split(".").pop() : null;
|
||||||
|
const isTextFile = textFileTypes.includes(ext);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onPreviewChange();
|
||||||
|
}, [fileData]);
|
||||||
|
const editorChange = (v) => setModifiedText(v);
|
||||||
|
|
||||||
|
async function onPreviewChange() {
|
||||||
|
if (!isTextFile) return;
|
||||||
|
const text = await fileData.text();
|
||||||
|
setFileText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
if (!isTextFile) return;
|
||||||
|
const formData = new FormData();
|
||||||
|
const blob = new Blob([modifiedText], { type: "plain/text" });
|
||||||
|
formData.append("file", blob, name);
|
||||||
|
formData.append("id", serverId);
|
||||||
|
formData.append("path", filePath);
|
||||||
|
await fetch("/api/files/upload", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
headers: cairoAuthHeader(),
|
||||||
|
});
|
||||||
|
dialogToggle();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
sx={
|
||||||
|
fullScreen
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
"& .mcl-MuiDialog-paper": {
|
||||||
|
width: "100%",
|
||||||
|
maxHeight: 525,
|
||||||
|
maxWidth: "80%",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
maxWidth="xs"
|
||||||
|
open={open}
|
||||||
|
fullScreen={fullScreen}
|
||||||
|
>
|
||||||
|
<Toolbar sx={{ display: { sm: "none" } }} />
|
||||||
|
<DialogTitle>{name}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{isTextFile && <TextEditor text={fileText} onChange={editorChange} />}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button autoFocus onClick={dialogToggle}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button variant="contained" autoFocus onClick={onSave}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
200
src/components/files/MineclusterFiles.jsx
Normal file
|
@ -0,0 +1,200 @@
|
||||||
|
import { useState, useEffect, useMemo, useRef } from "react";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Dropzone from "react-dropzone";
|
||||||
|
|
||||||
|
import {
|
||||||
|
FileBrowser,
|
||||||
|
FileContextMenu,
|
||||||
|
FileList,
|
||||||
|
FileNavbar,
|
||||||
|
FileToolbar,
|
||||||
|
setChonkyDefaults,
|
||||||
|
ChonkyActions,
|
||||||
|
} from "chonky";
|
||||||
|
import { ChonkyIconFA } from "chonky-icon-fontawesome";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getServerFiles,
|
||||||
|
createServerFolder,
|
||||||
|
deleteServerItem,
|
||||||
|
getServerItem,
|
||||||
|
moveServerItems,
|
||||||
|
previewServerItem,
|
||||||
|
} from "@mcl/queries";
|
||||||
|
import { cairoAuthHeader } from "@mcl/util/auth.js";
|
||||||
|
|
||||||
|
import { supportedFileTypes } from "./FilePreview.jsx";
|
||||||
|
|
||||||
|
export default function MineclusterFiles(props) {
|
||||||
|
// Chonky configuration
|
||||||
|
setChonkyDefaults({ iconComponent: ChonkyIconFA });
|
||||||
|
const fileActions = useMemo(
|
||||||
|
() => [
|
||||||
|
ChonkyActions.CreateFolder,
|
||||||
|
ChonkyActions.UploadFiles,
|
||||||
|
ChonkyActions.DownloadFiles,
|
||||||
|
ChonkyActions.CopyFiles,
|
||||||
|
ChonkyActions.DeleteFiles,
|
||||||
|
ChonkyActions.MoveFiles,
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const { server: serverId, changePreview } = props;
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
const [dirStack, setDirStack] = useState(["."]);
|
||||||
|
const [files, setFiles] = useState([]);
|
||||||
|
|
||||||
|
const updateFiles = () => {
|
||||||
|
const dir = dirStack.join("/");
|
||||||
|
getServerFiles(serverId, dir)
|
||||||
|
.then((f) => {
|
||||||
|
const files = f.map((fi) => ({ ...fi, id: `${dir}/${fi.name}` }));
|
||||||
|
setFiles(files ?? []);
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
console.error(
|
||||||
|
"Couldn't update files, server likely hasn't started yet",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateFiles();
|
||||||
|
}, [dirStack]);
|
||||||
|
|
||||||
|
const getFolderChain = () => {
|
||||||
|
if (dirStack.length === 1) return [{ id: "./", name: "Home", isDir: true }];
|
||||||
|
return dirStack.map((d, i) => ({
|
||||||
|
id: `${dirStack.slice(0, i + 1).join("/")}`,
|
||||||
|
name: i === 0 ? "Home" : d,
|
||||||
|
isDir: true,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const openParentFolder = () => setDirStack(dirStack.slice(0, -1));
|
||||||
|
|
||||||
|
function openItem(payload) {
|
||||||
|
const { targetFile: file } = payload;
|
||||||
|
if (file && file.isDir) return setDirStack(file.id.split("/"));
|
||||||
|
if (!file || file.isDir) return; // Ensure file exists or is dir
|
||||||
|
if (supportedFileTypes.includes(file.name.split(".").pop()))
|
||||||
|
return previewFile(file);
|
||||||
|
return downloadFiles([file]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFolder() {
|
||||||
|
const name = prompt("What is the name of the new folder?");
|
||||||
|
const path = [...dirStack, name].join("/");
|
||||||
|
createServerFolder(serverId, path).then(updateFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteItems(files) {
|
||||||
|
Promise.all(
|
||||||
|
files.map((f) =>
|
||||||
|
deleteServerItem(serverId, [...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;
|
||||||
|
uploadMultipleFiles(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadMultipleFiles(files) {
|
||||||
|
Promise.all([...files].map((f) => uploadFile(f)))
|
||||||
|
.catch((e) => console.log("Error uploading a file", e))
|
||||||
|
.then(updateFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFile(file) {
|
||||||
|
const filePath = file.path.startsWith("/") ? file.path : `/${file.path}`;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
formData.append("id", serverId);
|
||||||
|
const path = `${[...dirStack].join("/")}${filePath}`;
|
||||||
|
formData.append("path", path);
|
||||||
|
await fetch("/api/files/upload", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
headers: cairoAuthHeader(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFiles(files) {
|
||||||
|
Promise.all(
|
||||||
|
files.map((f) =>
|
||||||
|
getServerItem(serverId, f.name, [...dirStack, f.name].join("/")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then(() => console.log("Done downloading files!"))
|
||||||
|
.catch((e) => console.error("Error Downloading files!", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewFile(file) {
|
||||||
|
const { name } = file;
|
||||||
|
previewServerItem(serverId, [...dirStack, name].join("/")).then(
|
||||||
|
(fileData) =>
|
||||||
|
changePreview(name, fileData, [...dirStack, name].join("/")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveFile(movePayload) {
|
||||||
|
const { files: filePayload, destination: destinationPayload } = movePayload;
|
||||||
|
if (!destinationPayload.isDir || filePayload.length === 0) return;
|
||||||
|
const files = filePayload.map((f) => f.name);
|
||||||
|
const dest = destinationPayload.id;
|
||||||
|
const origin = dirStack.join("/");
|
||||||
|
moveServerItems(serverId, files, dest, origin).then(updateFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileClick(chonkyEvent) {
|
||||||
|
const { id: clickEvent, payload } = 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 === "move_files") return moveFile(payload);
|
||||||
|
if (clickEvent !== "open_files") return; // console.log(clickEvent);
|
||||||
|
openItem(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropzone onDrop={uploadMultipleFiles}>
|
||||||
|
{({ getRootProps }) => (
|
||||||
|
<Box
|
||||||
|
className="minecluster-files"
|
||||||
|
sx={{ height: "calc(100vh - 6rem)" }}
|
||||||
|
onDrop={getRootProps().onDrop}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</Dropzone>
|
||||||
|
);
|
||||||
|
}
|
21
src/components/files/TextEditor.jsx
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import ReactQuill from "react-quill";
|
||||||
|
import { useState, useEffect, useMemo, memo } from "react";
|
||||||
|
import "react-quill/dist/quill.snow.css";
|
||||||
|
|
||||||
|
const buildDelta = (t) => {
|
||||||
|
if (!t) return;
|
||||||
|
const ops = t.split("\n").map((l) => ({ insert: `${l}\n` }));
|
||||||
|
return { ops };
|
||||||
|
};
|
||||||
|
|
||||||
|
function TextEditor(props) {
|
||||||
|
const { text, onChange } = props;
|
||||||
|
const [delta, setDelta] = useState();
|
||||||
|
const constructDelta = useMemo(() => buildDelta(text), [text]);
|
||||||
|
useEffect(() => setDelta(constructDelta), [text]);
|
||||||
|
|
||||||
|
const onEditorChange = (c, d, s, editor) => onChange(editor.getText());
|
||||||
|
|
||||||
|
return <ReactQuill theme="snow" value={delta} onChange={onEditorChange} />;
|
||||||
|
}
|
||||||
|
export default memo(TextEditor, (a, b) => a.text === b.text);
|
15
src/components/server-options/BackupBucketOption.jsx
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
export default function BackupBucketOption(props) {
|
||||||
|
const { value, onChange } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
label="Bucket Path"
|
||||||
|
onChange={onChange}
|
||||||
|
value={value}
|
||||||
|
helperText="Example: /minecraft-backups/example-backups"
|
||||||
|
FormHelperTextProps={{ sx: { ml: 0 } }}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
15
src/components/server-options/BackupHostOption.jsx
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
export default function BackupHostOption(props) {
|
||||||
|
const { value, onChange } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
label="Backup Host"
|
||||||
|
onChange={onChange}
|
||||||
|
value={value ?? ""}
|
||||||
|
helperText="Example: s3.mydomain.com"
|
||||||
|
FormHelperTextProps={{ sx: { ml: 0 } }}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
15
src/components/server-options/BackupIdOption.jsx
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
export default function BackupIdOption(props) {
|
||||||
|
const { value, onChange } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
label="S3 Access Key ID"
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={onChange}
|
||||||
|
helperText="Example: s3-access-key-id"
|
||||||
|
FormHelperTextProps={{ sx: { ml: 0 } }}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
55
src/components/server-options/BackupIntervalOption.jsx
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
|
||||||
|
const backupIntervalStepDisplay = ["Minutes", "Hours", "Days"];
|
||||||
|
export const backupIntervalDefault = "1d";
|
||||||
|
export const backupIntervalStepOptions = ["m", "h", "d"];
|
||||||
|
export default function BackupIntervalOption(props) {
|
||||||
|
const { onChange } = props;
|
||||||
|
const [interval, setInterval] = useState(1);
|
||||||
|
const [intervalStep, setIntervalStep] = useState(
|
||||||
|
backupIntervalStepOptions[2],
|
||||||
|
);
|
||||||
|
|
||||||
|
const changeStep = (e) => {
|
||||||
|
setIntervalStep(e.target.value);
|
||||||
|
onChange({ target: { value: `${interval}${e.target.value}` } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeInterval = (e) => {
|
||||||
|
setInterval(e.target.value);
|
||||||
|
onChange({ target: { value: `${e.target.value}${intervalStep}` } });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<TextField
|
||||||
|
label="Backup Interval"
|
||||||
|
sx={{ width: "70%" }}
|
||||||
|
value={interval}
|
||||||
|
onChange={changeInterval}
|
||||||
|
helperText="Examples: 1m, 3h, 3.5d"
|
||||||
|
FormHelperTextProps={{ sx: { ml: 0 } }}
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Step"
|
||||||
|
sx={{ width: "30%", minWidth: "4rem" }}
|
||||||
|
onChange={onChange}
|
||||||
|
value={intervalStep}
|
||||||
|
select
|
||||||
|
required
|
||||||
|
SelectProps={{ MenuProps: { sx: { maxHeight: "20rem" } } }}
|
||||||
|
>
|
||||||
|
{backupIntervalStepOptions.map((o, i) => (
|
||||||
|
<MenuItem value={o} key={i}>
|
||||||
|
{backupIntervalStepDisplay[i]}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
15
src/components/server-options/BackupKeyOption.jsx
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
export default function BackupKeyOption(props) {
|
||||||
|
const { value, onChange } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
label="S3 Access Key"
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={onChange}
|
||||||
|
helperText="Example: s3-access-key"
|
||||||
|
FormHelperTextProps={{ sx: { ml: 0 } }}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
26
src/components/server-options/CpuOption.jsx
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
|
||||||
|
const maxCpuSupported = 8;
|
||||||
|
export const cpuOptions = new Array(2 * maxCpuSupported)
|
||||||
|
.fill(0)
|
||||||
|
.map((v, i) => (i + 1) * 0.5);
|
||||||
|
|
||||||
|
export default function CpuOption(props) {
|
||||||
|
const { value, onChange } = props;
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
label="CPU"
|
||||||
|
onChange={onChange}
|
||||||
|
value={value ?? cpuOptions[0]}
|
||||||
|
select
|
||||||
|
required
|
||||||
|
SelectProps={{ MenuProps: { sx: { maxHeight: "20rem" } } }}
|
||||||
|
disabled // TODO: Enable on backend support
|
||||||
|
>
|
||||||
|
{cpuOptions.map((o, i) => (
|
||||||
|
<MenuItem value={o} key={i}>{`${o} CPU`}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
);
|
||||||
|
}
|
50
src/components/server-options/ExtraPortsOption.jsx
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import Autocomplete from "@mui/material/Autocomplete";
|
||||||
|
import Chip from "@mui/material/Chip";
|
||||||
|
|
||||||
|
const validatePort = (p) =>
|
||||||
|
p !== "25565" && p !== "25575" && p.length < 6 && parseInt(p) < 60_000;
|
||||||
|
|
||||||
|
export default function ExtraPortsOption(props) {
|
||||||
|
const { extraPorts: initExtraPorts } = props;
|
||||||
|
const [extraPorts, setExtraPorts] = useState(initExtraPorts ?? []);
|
||||||
|
const { onChange } = props;
|
||||||
|
|
||||||
|
function portChange(e, val, optionType, changedValue) {
|
||||||
|
if (optionType === "clear") {
|
||||||
|
setExtraPorts([]);
|
||||||
|
onChange("extraPorts", []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!validatePort(changedValue.option))
|
||||||
|
return alert("That port cannot be added/removed as an extra port!");
|
||||||
|
setExtraPorts(val);
|
||||||
|
onChange("extraPorts", val);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
id="extra-ports-autocomplete"
|
||||||
|
options={[]}
|
||||||
|
value={extraPorts}
|
||||||
|
onChange={portChange}
|
||||||
|
freeSolo
|
||||||
|
renderInput={(p) => (
|
||||||
|
<TextField
|
||||||
|
{...p}
|
||||||
|
label="Extra Ports"
|
||||||
|
helperText="Remember to press enter to add the port!"
|
||||||
|
FormHelperTextProps={{ sx: { ml: 0 } }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
renderTags={(value, getTagProps) =>
|
||||||
|
value.map((option, index) => {
|
||||||
|
const defaultChipProps = getTagProps({ index });
|
||||||
|
return <Chip label={option} {...defaultChipProps} />;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
21
src/components/server-options/HostOption.jsx
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
export default function HostOption(props) {
|
||||||
|
const { value, onChange, disabled } = props;
|
||||||
|
|
||||||
|
function onTextChange(e) {
|
||||||
|
e.target.value = e.target.value.toLowerCase();
|
||||||
|
onChange(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
label="Host"
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={onTextChange}
|
||||||
|
helperText="Example: host.mydomain.com"
|
||||||
|
FormHelperTextProps={{ sx: { ml: 0 } }}
|
||||||
|
required
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
24
src/components/server-options/MemoryOption.jsx
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
const maxMemSupported = 10;
|
||||||
|
export const memoryOptions = new Array(2 * maxMemSupported)
|
||||||
|
.fill(0)
|
||||||
|
.map((v, i) => (i + 1) * 512);
|
||||||
|
|
||||||
|
export default function Option(props) {
|
||||||
|
const { value, onChange } = props;
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
label="Memory"
|
||||||
|
onChange={onChange}
|
||||||
|
value={value ?? memoryOptions[1]}
|
||||||
|
select
|
||||||
|
required
|
||||||
|
SelectProps={{ MenuProps: { sx: { maxHeight: "20rem" } } }}
|
||||||
|
>
|
||||||
|
{memoryOptions.map((o, i) => (
|
||||||
|
<MenuItem value={o} key={i}>{`${o / 1024} Gi`}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
);
|
||||||
|
}
|
15
src/components/server-options/NameOption.jsx
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
export default function NameOption(props) {
|
||||||
|
const { value, onChange } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
label="Name"
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={onChange}
|
||||||
|
helperText="Example: My Survival World"
|
||||||
|
FormHelperTextProps={{ sx: { ml: 0 } }}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
25
src/components/server-options/ServerTypeOption.jsx
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
|
||||||
|
const displayOption = (o) => o.charAt(0) + o.toLowerCase().slice(1);
|
||||||
|
|
||||||
|
export const serverTypeOptions = ["VANILLA", "FABRIC", "PAPER", "SPIGOT"];
|
||||||
|
export default function ServerTypeOption(props) {
|
||||||
|
const { value, onChange } = props;
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
label="Memory"
|
||||||
|
onChange={onChange}
|
||||||
|
value={value ?? serverTypeOptions[0]}
|
||||||
|
select
|
||||||
|
required
|
||||||
|
SelectProps={{ MenuProps: { sx: { maxHeight: "20rem" } } }}
|
||||||
|
>
|
||||||
|
{serverTypeOptions.map((o, i) => (
|
||||||
|
<MenuItem value={o} key={i}>
|
||||||
|
{displayOption(o)}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
);
|
||||||
|
}
|
26
src/components/server-options/StorageOption.jsx
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
|
||||||
|
const maxStorageSupported = 80;
|
||||||
|
export const storageOptions = new Array(2 * maxStorageSupported)
|
||||||
|
.fill(0)
|
||||||
|
.map((v, i) => (i + 1) * 0.5);
|
||||||
|
|
||||||
|
export default function StorageOption(props) {
|
||||||
|
const { value, onChange } = props;
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
label="Storage"
|
||||||
|
onChange={onChange}
|
||||||
|
value={value ?? null}
|
||||||
|
select
|
||||||
|
required
|
||||||
|
SelectProps={{ MenuProps: { sx: { maxHeight: "20rem" } } }}
|
||||||
|
>
|
||||||
|
<MenuItem value={0}>No Storage</MenuItem>
|
||||||
|
{storageOptions.map((o, i) => (
|
||||||
|
<MenuItem value={o} key={i}>{`${o} GB`}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
);
|
||||||
|
}
|
37
src/components/server-options/VersionOption.jsx
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import { useVersionList } from "@mcl/queries";
|
||||||
|
|
||||||
|
export default function VersionOption(props) {
|
||||||
|
const { value, onChange } = props;
|
||||||
|
const versionList = useVersionList();
|
||||||
|
const [versions, setVersions] = useState(["latest"]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!versionList.data) return;
|
||||||
|
setVersions([
|
||||||
|
"latest",
|
||||||
|
...versionList.data.versions
|
||||||
|
.filter(({ type: releaseType }) => releaseType === "release")
|
||||||
|
.map(({ id }) => id),
|
||||||
|
]);
|
||||||
|
}, [versionList.data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
label="Version"
|
||||||
|
onChange={onChange}
|
||||||
|
value={value ?? "latest"}
|
||||||
|
select
|
||||||
|
required
|
||||||
|
SelectProps={{ MenuProps: { sx: { maxHeight: "20rem" } } }}
|
||||||
|
>
|
||||||
|
{versions.map((v, k) => (
|
||||||
|
<MenuItem value={v} key={k}>
|
||||||
|
{v}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
);
|
||||||
|
}
|
88
src/components/servers/BackupsDialog.jsx
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
|
import DialogActions from "@mui/material/DialogActions";
|
||||||
|
import Dialog from "@mui/material/Dialog";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import Toolbar from "@mui/material/Toolbar";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import DownloadIcon from "@mui/icons-material/Download";
|
||||||
|
import { getBackupUrl, getServerBackups } from "../../util/queries";
|
||||||
|
|
||||||
|
export function useBackupDialog(isOpen = false) {
|
||||||
|
const [open, setOpen] = useState(isOpen);
|
||||||
|
const dialogToggle = () => setOpen(!open);
|
||||||
|
return [open, dialogToggle];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BackupDialog(props) {
|
||||||
|
const { serverId, open, dialogToggle } = props;
|
||||||
|
const theme = useTheme();
|
||||||
|
const fullScreen = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
|
const [backups, setBackups] = useState([]);
|
||||||
|
|
||||||
|
function refreshUpdateList() {
|
||||||
|
getServerBackups(serverId).then(setBackups);
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
if (!serverId) return;
|
||||||
|
refreshUpdateList();
|
||||||
|
}, [serverId, open]);
|
||||||
|
|
||||||
|
function normalizeLastModified(lastModified) {
|
||||||
|
const d = new Date(Date.parse(lastModified));
|
||||||
|
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()} ${d.getHours()}:${d.getMinutes()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadBackup = (backup) =>
|
||||||
|
async function openBackupLink() {
|
||||||
|
const { url } = await getBackupUrl(serverId, backup.path);
|
||||||
|
window.open(url, "_blank").focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizedSize = (size) => `${(size / Math.pow(1024, 3)).toFixed(2)}GB`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
fullWidth
|
||||||
|
maxWidth="lg"
|
||||||
|
open={open}
|
||||||
|
fullScreen={fullScreen}
|
||||||
|
PaperProps={!fullScreen ? { sx: { height: "60%" } } : undefined}
|
||||||
|
>
|
||||||
|
<Toolbar sx={{ display: { md: "none" } }} />
|
||||||
|
<DialogTitle>Backups</DialogTitle>
|
||||||
|
<DialogContent sx={{ height: "100%" }}>
|
||||||
|
{backups.map((backup, i) => (
|
||||||
|
<Stack key={i} sx={{ width: "100%" }} direction="row">
|
||||||
|
<Typography variant="subtitle2" sx={{ m: "auto 0", width: "40%" }}>
|
||||||
|
{backup.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="subtitle2" sx={{ m: "auto 0", width: "20%" }}>
|
||||||
|
{normalizeLastModified(backup.lastModified)}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="subtitle2" sx={{ m: "auto 0", width: "40%" }}>
|
||||||
|
{normalizedSize(backup.size)}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
sx={{ marginLeft: "auto" }}
|
||||||
|
onClick={downloadBackup(backup)}
|
||||||
|
>
|
||||||
|
<DownloadIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button autoFocus onClick={dialogToggle}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
|
@ -16,24 +16,23 @@ export function useRconDialog(isOpen = false) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RconDialog(props) {
|
export default function RconDialog(props) {
|
||||||
const { serverName, open, dialogToggle } = props;
|
const { server, open, dialogToggle } = props;
|
||||||
|
const { name: serverName, id: serverId } = server ?? {};
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
|
const fullScreen = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
sx={
|
fullWidth
|
||||||
fullScreen
|
maxWidth="lg"
|
||||||
? {}
|
|
||||||
: { "& .MuiDialog-paper": { width: "80%", maxHeight: 525 } }
|
|
||||||
}
|
|
||||||
maxWidth="xs"
|
|
||||||
open={open}
|
open={open}
|
||||||
fullScreen={fullScreen}
|
fullScreen={fullScreen}
|
||||||
|
PaperProps={!fullScreen ? { sx: { height: "60%" } } : undefined}
|
||||||
>
|
>
|
||||||
<Toolbar sx={{ display: { sm: "none" } }} />
|
<Toolbar sx={{ display: { md: "none" } }} />
|
||||||
<DialogTitle>RCON - {serverName}</DialogTitle>
|
<DialogTitle>RCON - {serverName}</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent sx={{ height: "100%" }}>
|
||||||
<RconView serverName={serverName} />
|
<RconView serverId={serverId} />
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button autoFocus onClick={dialogToggle}>
|
<Button autoFocus onClick={dialogToggle}>
|
|
@ -1,13 +1,18 @@
|
||||||
import { io } from "socket.io-client";
|
import { io } from "socket.io-client";
|
||||||
export default class RconSocket {
|
export default class RconSocket {
|
||||||
constructor(logUpdate, serverName) {
|
constructor(logUpdate, serverId) {
|
||||||
(this.sk = io("/", { query: { serverName } })), (this.logs = []);
|
(this.sk = io("/", { query: { serverId } })), (this.logs = []);
|
||||||
this.logUpdate = logUpdate;
|
this.logUpdate = logUpdate;
|
||||||
this.sk.on("push", this.onPush.bind(this));
|
this.sk.on("push", this.onPush.bind(this));
|
||||||
this.sk.on("connect", this.onConnect.bind(this));
|
this.sk.on("connect", this.onConnect.bind(this));
|
||||||
|
this.sk.on("rcon-error", this.onRconError.bind(this));
|
||||||
|
this.sk.on("error", () => console.log("WHOOSPSIE I GUESS?"));
|
||||||
|
this.rconLive = false;
|
||||||
|
this.rconError = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
onPush(p) {
|
onPush(p) {
|
||||||
|
this.rconLive = true;
|
||||||
this.logs = [...this.logs, p];
|
this.logs = [...this.logs, p];
|
||||||
this.logUpdate(this.logs);
|
this.logUpdate(this.logs);
|
||||||
}
|
}
|
||||||
|
@ -16,7 +21,14 @@ export default class RconSocket {
|
||||||
this.sk.emit("msg", m);
|
this.sk.emit("msg", m);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onRconError(v) {
|
||||||
|
this.rconLive = false;
|
||||||
|
this.rconError = true;
|
||||||
|
console.log("Server sent: ", v);
|
||||||
|
}
|
||||||
|
|
||||||
onConnect() {
|
onConnect() {
|
||||||
|
this.sk.readyState = 1;
|
||||||
this.logs = [];
|
this.logs = [];
|
||||||
}
|
}
|
||||||
|
|
100
src/components/servers/RconView.jsx
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import Skeleton from "@mui/material/Skeleton";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import RconSocket from "./RconSocket.js";
|
||||||
|
import "@mcl/css/rcon.css";
|
||||||
|
|
||||||
|
function RconLogSkeleton() {
|
||||||
|
return (
|
||||||
|
<Skeleton
|
||||||
|
variant="text"
|
||||||
|
width="100%"
|
||||||
|
sx={{ backgroundColor: "rgba(255,255,255,.25)" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RconView(props) {
|
||||||
|
const { serverId } = props;
|
||||||
|
const logsRef = useRef(0);
|
||||||
|
const [cmd, setCmd] = useState("");
|
||||||
|
const [logs, setLogs] = useState([]);
|
||||||
|
const [rcon, setRcon] = useState();
|
||||||
|
const updateCmd = (e) => setCmd(e.target.value);
|
||||||
|
|
||||||
|
const disconnectRcon = () => {
|
||||||
|
if (!rcon || typeof rcon.disconnect !== "function") return;
|
||||||
|
rcon.disconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
function () {
|
||||||
|
if (!serverId) return;
|
||||||
|
const rs = new RconSocket(setLogs, serverId);
|
||||||
|
setRcon(rs);
|
||||||
|
return disconnectRcon;
|
||||||
|
},
|
||||||
|
[serverId],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => logsRef.current.scrollTo(0, logsRef.current.scrollHeight),
|
||||||
|
[(rcon ?? {}).logs],
|
||||||
|
);
|
||||||
|
|
||||||
|
function sendCommand() {
|
||||||
|
rcon.send(cmd);
|
||||||
|
setCmd("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ height: "100%", display: "flex", flexWrap: "wrap" }}>
|
||||||
|
<Box
|
||||||
|
className="rconLogsWrapper"
|
||||||
|
ref={logsRef}
|
||||||
|
style={{
|
||||||
|
padding: "1rem",
|
||||||
|
backgroundColor: "rgba(0,0,0,.815)",
|
||||||
|
color: "white",
|
||||||
|
borderRadius: "4px",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{logs.length === 0 &&
|
||||||
|
[...Array(20).keys()].map((_v, i) => <RconLogSkeleton key={i} />)}
|
||||||
|
{logs.length > 0 &&
|
||||||
|
logs.map((v, k) => (
|
||||||
|
<Box key={k}>
|
||||||
|
<Typography variant="subtitle2">{v}</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
className="rconActions"
|
||||||
|
sx={{ marginTop: "auto", paddingTop: "1rem", width: "100%" }}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
id="outlined-basic"
|
||||||
|
label="Command"
|
||||||
|
variant="outlined"
|
||||||
|
value={cmd}
|
||||||
|
onChange={updateCmd}
|
||||||
|
disabled={!(rcon && rcon.rconLive && !rcon.rconError)}
|
||||||
|
sx={{ width: "100%" }}
|
||||||
|
/>
|
||||||
|
{rcon && rcon.rconLive && !rcon.rconError && (
|
||||||
|
<Button onClick={sendCommand} sx={{ padding: "0 2rem" }}>
|
||||||
|
Send
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!(rcon && rcon.rconLive && !rcon.rconError) && (
|
||||||
|
<Button color="secondary">Not Connected</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|