Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions .github/workflows/ci-extended.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,8 @@ jobs:
- { target: custom@1.21.1, java: 25 }
- { target: paper@1.20.4, java: 17 }
- { target: fabric@1.16.5, java: 8 }
- target: forge@1.12.2
java: 8
known_issue: Forge's Maven publishes no .sha256 for 1.12.2 installers, and Craftbox requires one
# Forge's Maven has no .sha256 for this installer, only .sha1/.md5
- { target: forge@1.12.2, java: 8 }
- { target: forge@1.16.5, java: 8 }
- { target: forge@1.20.1, java: 17 }
- { target: neoforge@1.21.1, java: 21 }
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/docker-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ jobs:
type=semver,pattern={{major}},value=${{ env.TAG }},enable=${{ env.PRERELEASE != 'true' }}
type=raw,value=latest,enable=${{ env.PRERELEASE != 'true' }}
type=raw,value=next,enable=${{ env.PRERELEASE == 'true' }}
# GitHub's licence detection gives AGPL-3.0; package.json declares
# AGPL-3.0-only
labels: |
org.opencontainers.image.licenses=AGPL-3.0-only

- name: Set up QEMU
uses: docker/setup-qemu-action@v3
Expand Down
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ WORKDIR /app
# Compiled dependencies from the builder stage
COPY --from=builder --chown=craftbox:craftbox /app/node_modules ./node_modules
COPY --chown=craftbox:craftbox package*.json ./
# The AGPL's text goes with every copy distributed, the image included
COPY --chown=craftbox:craftbox LICENSE ./

# Application source
COPY --chown=craftbox:craftbox src/ ./src/
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<div align="center">

![license](https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square)
![version](https://img.shields.io/badge/version-1.2.2-brightgreen?style=flat-square)
![version](https://img.shields.io/badge/version-1.2.3-brightgreen?style=flat-square)
![docker](https://img.shields.io/badge/docker-supported-blue?style=flat-square)

[![discord](https://img.shields.io/discord/667479986214666272?logo=discord&logoColor=white&style=flat-square)](https://diamonddigital.dev/discord)
Expand Down
37 changes: 24 additions & 13 deletions docs/API.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "craftbox",
"version": "1.2.2",
"version": "1.2.3",
"description": "A modern self-hosted platform for managing Minecraft servers with built-in mod support.",
"main": "src/server.js",
"funding": {
Expand Down
29 changes: 29 additions & 0 deletions public/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,35 @@ function flashToast(message, type) {
} catch (_) { /* ignore */ }
})();

// ── Shared port warning ──
// A port field tagged data-ports-in-use (the servers' {id, name, port}, from
// the page) warns in the element tagged data-port-clash-for="<its id>" when
// the port typed is one another server uses too, worded as the API words the
// same warning on save. data-port-exclude leaves out the server being edited.
// Sharing a port is allowed; only running both at once isn't.
document.querySelectorAll('input[data-ports-in-use]').forEach(function (input) {
var hint = document.querySelector('[data-port-clash-for="' + input.id + '"]');
var ports;
try { ports = JSON.parse(input.dataset.portsInUse); } catch (_) { return; }
if (!hint || !Array.isArray(ports)) return;
var exclude = input.dataset.portExclude || null;

function update() {
var port = parseInt(input.value, 10);
var names = ports
.filter(function (p) { return p.id !== exclude && p.port === port; })
.map(function (p) { return '"' + p.name + '"'; })
// Each name once: an import or a copy can share its source's name
.filter(function (name, i, all) { return all.indexOf(name) === i; });
hint.classList.toggle('d-none', names.length === 0);
hint.textContent = names.length === 0 ? '' : 'Port ' + port + ' is also used by '
+ (names.length === 1 ? names[0] : names.slice(0, -1).join(', ') + ' and ' + names[names.length - 1])
+ '. Only one of them can run at a time.';
}
input.addEventListener('input', update);
update();
});

// ── Wait for a background operation to finish ──
// Resolves with the `operation` WebSocket message for this server (dispatched as
// craftbox:operation by console.js / serverState.js). Call this BEFORE kicking
Expand Down
5 changes: 4 additions & 1 deletion public/js/console.js
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@
if (crashReason && (
crashReason.indexOf('Provisioning failed') === 0 ||
crashReason.indexOf('Duplication failed') === 0 ||
crashReason.indexOf('Import failed') === 0 ||
crashReason.indexOf('Jar upgrade interrupted') === 0 ||
crashReason.indexOf('Provisioning interrupted') === 0 ||
crashReason.indexOf('Modpack install failed') === 0
Expand All @@ -230,7 +231,9 @@
} else {
var reasonText = crashReason === 'oom'
? ' due to Out of Memory'
: '';
: crashReason === 'signal'
? ': its process was killed, as the system does when it runs out of memory'
: '';
var exitText = exitCode != null
? ' Exit code: <strong>' + exitCode + '</strong>.'
: '';
Expand Down
3 changes: 3 additions & 0 deletions public/js/create.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ form.addEventListener('submit', async (e) => {
restoreCreateBtn();
return;
}
((res.data && res.data.warnings) || []).forEach(function (w) { flashToast(w, 'warning'); });
var newId = res.data && res.data.server && res.data.server.id;
window.location.href = newId ? '/servers/' + newId : '/dashboard';
});
Expand Down Expand Up @@ -632,6 +633,7 @@ async function submitFromModpack() {
restoreCreateBtn();
return;
}
((res.data && res.data.warnings) || []).forEach(function (w) { flashToast(w, 'warning'); });
const newId = res.data && res.data.server && res.data.server.id;
window.location.href = newId ? '/servers/' + newId : '/dashboard';
}
Expand Down Expand Up @@ -661,6 +663,7 @@ async function submitFromMrpack() {
return;
}
flashToast('Modpack uploaded. Installing...', 'info');
((res.data && res.data.warnings) || []).forEach(function (w) { flashToast(w, 'warning'); });
const newId = res.data && res.data.server && res.data.server.id;
window.location.href = newId ? '/servers/' + newId : '/dashboard';
}
Expand Down
23 changes: 10 additions & 13 deletions public/js/download.js
Original file line number Diff line number Diff line change
Expand Up @@ -174,23 +174,20 @@
var msg = e.detail || {};
if (msg.operation !== 'download') return;

// A failure is reported as a plain string, so it carries no token and
// cannot be matched to one click. With downloads started one at a time
// this is the one in flight; with several, the message still names what
// failed, which beats staying silent.
if (msg.status === 'failed') {
var tokens = Object.keys(pending);
if (tokens.length === 1) {
settle(tokens[0], function () {}, true);
}
showToast(msg.error || 'Download failed.', 'danger');
return;
}

// Every report carries the token, a failure included, so one that
// doesn't match belongs to another page (another tab, or another user
// on the same server) and isn't this page's to announce.
var payload = msg.payload || {};
var entry = pending[payload.token];
if (!entry) return;

if (msg.status === 'failed') {
settle(payload.token, function () {
showToast(msg.error || 'Download failed.', 'danger');
}, true);
return;
}

if (msg.status === 'progress') {
entry.statusEl.set(describeProgress(entry.label, payload));
armTimeout(payload.token);
Expand Down
3 changes: 3 additions & 0 deletions public/js/edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ function _formToBody(form) {
return;
}
flashToast('Settings saved.', 'success');
((res.data && res.data.warnings) || []).forEach(function (w) { flashToast(w, 'warning'); });
// Reload with ?saved=1 so the restart-modal auto-shows
window.location.href = '/servers/' + serverId + '/edit?saved=1';
});
Expand Down Expand Up @@ -90,6 +91,7 @@ function _formToBody(form) {
flashToast('Backup created and settings saved.' +
(payload.restarted ? ' Server restarted.' : ''), 'success');
}
((res.data && res.data.warnings) || []).forEach(function (w) { flashToast(w, 'warning'); });
window.location.href = '/servers/' + serverId + '/edit';
}
})();
Expand Down Expand Up @@ -588,6 +590,7 @@ function _formToBody(form) {
return;
}
flashToast('Server duplicated.', 'success');
((res.data && res.data.warnings) || []).forEach(function (w) { flashToast(w, 'warning'); });
var newId = res.data && res.data.server && res.data.server.id;
window.location.href = newId ? '/servers/' + newId : '/dashboard';
}
Expand Down
2 changes: 2 additions & 0 deletions public/js/properties.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
return;
}
flashToast('Properties saved.', 'success');
((res.data && res.data.warnings) || []).forEach(function (w) { flashToast(w, 'warning'); });
window.location.href = '/servers/' + serverId + '/properties?saved=1';
});

Expand Down Expand Up @@ -73,6 +74,7 @@
flashToast('Backup created and properties saved.' +
(payload.restarted ? ' Server restarted.' : ''), 'success');
}
((res.data && res.data.warnings) || []).forEach(function (w) { flashToast(w, 'warning'); });
window.location.href = '/servers/' + serverId + '/properties';
}
})();
Expand Down
5 changes: 4 additions & 1 deletion src/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ async function markAllServersStopped({ reason } = {}) {
for (const row of rows) {
const server = row?.value;
if (!server || typeof server !== 'object') continue;
if (server.state === 'stopped') continue;
// Only states a restart makes untrue are reset. A crash stays one,
// with its reason and exit code: resetting it to `stopped` lost the
// banner, and left a failed duplicate or import looking whole.
if (server.state === 'stopped' || server.state === 'crashed') continue;

const incompleteReason = INCOMPLETE_STATES[server.state];
if (incompleteReason) {
Expand Down
44 changes: 41 additions & 3 deletions src/mc/ServerManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const { serversDb } = require('../db');
const { canPerformAction, canTransition } = require('./stateMachine');
const { syncServerConfig } = require('./syncServerConfig');
const { log } = require('../utils/log');
const { addressesOverlap, serverIpOf } = require('../utils/portClash');

class ServerManager {
constructor() {
Expand Down Expand Up @@ -79,6 +80,33 @@ class ServerManager {
throw err;
}

/**
* The other server whose JVM holds `port` at an address overlapping
* `serverIp` (or held it until a moment ago and is restarting), or null.
* @param {string} serverId - the server asking, which is skipped
*/
portHolder(serverId, port, serverIp) {
for (const [id, proc] of this.processes) {
if (id === serverId || !(proc.child || proc._restarting)) continue;
if (proc.boundPort === Number(port) && addressesOverlap(proc.boundIp, serverIp)) return proc;
}
return null;
}

/**
* Throw a 409-tagged error if another server holds the port `proc` would
* listen on. Left to the JVM, the second server fails to bind and dies
* with a BindException, which reads as a crash.
*/
assertPortFree(proc) {
const port = Number(proc.config.port);
const holder = this.portHolder(proc.id, port, serverIpOf(proc.id));
if (!holder) return;
const err = new Error(`Port ${port} is in use by "${holder.config.name}". Stop that server, or change this one's port.`);
err.status = 409;
throw err;
}

async _ensureProcess(serverId) {
let proc = this.processes.get(serverId);

Expand Down Expand Up @@ -126,6 +154,7 @@ class ServerManager {
if (!canPerformAction(proc.state, 'start')) {
throw new Error(`Cannot start server in state: ${proc.state}`);
}
this.assertPortFree(proc);

if (opts.initiatedBy) proc._initiatedBy = opts.initiatedBy;
await proc.start();
Expand Down Expand Up @@ -162,6 +191,9 @@ class ServerManager {
if (!canPerformAction(proc.state, 'restart')) {
throw new Error(`Cannot restart server in state: ${proc.state}`);
}
// A port edited while the server ran takes effect on this restart, so
// check it before stopping rather than leave the server down.
this.assertPortFree(proc);

if (opts.initiatedBy) proc._initiatedBy = opts.initiatedBy;
await proc.restart();
Expand Down Expand Up @@ -267,13 +299,19 @@ class ServerManager {
* @param {'backup'|'restore'|'jar-upgrade'|'settings-save'|'create'|'duplicate'|'import'|'modpack-install'|'download'} operation
* @param {'complete'|'failed'|'progress'|'cancelled'} status
* @param {object|string} payloadOrError - error message on failed, payload object otherwise
* @param {object} [failedPayload] - on failed, a payload to send beside the
* error (a download's `dl` token, so the page can tell which one failed)
*/
broadcastOperation(serverId, operation, status, payloadOrError) {
broadcastOperation(serverId, operation, status, payloadOrError, failedPayload) {
const proc = this.getProcess(serverId);
if (!proc) return;
const data = { type: 'operation', serverId, operation, status };
if (status === 'failed') data.error = String(payloadOrError);
else data.payload = payloadOrError || {};
if (status === 'failed') {
data.error = String(payloadOrError);
if (failedPayload) data.payload = failedPayload;
} else {
data.payload = payloadOrError || {};
}
proc.broadcast(data);
}

Expand Down
28 changes: 24 additions & 4 deletions src/mc/ServerProcess.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const { logEvent, pruneEvents } = require('../utils/eventLogger');
const { getProvider } = require('./serverTypes');
const { clearCpuTracking } = require('../utils/resourceStats');
const { reconcileModFiles } = require('../utils/modEnvironment');
const { parseServerProperties } = require('./serverProperties');

// Pattern that indicates the server is done starting
const DONE_PATTERN = /\]:?\s+Done \(/;
Expand Down Expand Up @@ -43,6 +44,8 @@ class ServerProcess extends EventEmitter {
this._crashDetected = false; // Set when crash report is detected in logs
this._oomKillInProgress = false; // Guards against multiple OOM kill attempts
this._initiatedBy = null; // Who triggered the current action (username or system label)
this.boundPort = null; // Port and address of the current run (set on spawn)
this.boundIp = '';
}

get serverDir() {
Expand Down Expand Up @@ -224,6 +227,14 @@ class ServerProcess extends EventEmitter {
fs.mkdirSync(logsDir, { recursive: true });
this.logStream = fs.createWriteStream(this.logFilePath, { flags: 'a' });

// Where this run listens, as the JVM will read it, so ServerManager
// can refuse another server the same port while this one holds it.
// Taken now rather than from config later: a port edited while the
// server runs only takes effect when it next starts.
const props = parseServerProperties(this.serverDir);
this.boundPort = Number(props['server-port']) || Number(this.config.port);
this.boundIp = props['server-ip'] || '';

this.child = spawn(javaPath, args, {
cwd: this.serverDir,
stdio: ['pipe', 'pipe', 'pipe'],
Expand Down Expand Up @@ -684,7 +695,13 @@ class ServerProcess extends EventEmitter {
const wasOOM = this.lastLines.some(l => OOM_PATTERN.test(l));
const hadCrashReport = this._crashDetected;
const wasNonZeroExit = code !== null && code !== 0;
const isCrash = !this._stopRequested && (wasOOM || hadCrashReport || wasNonZeroExit);
// A process ended by a signal has no exit code at all. That's how the
// kernel's OOM killer ends a JVM in a memory-limited container
// (SIGKILL), so it has to count as a crash, not a clean exit. kill()
// and the stop timeout signal the JVM too, but they set
// _stopRequested first, so they still read as a requested stop.
const wasSignalled = code === null && !!signal;
const isCrash = !this._stopRequested && (wasOOM || hadCrashReport || wasNonZeroExit || wasSignalled);

if (this._stopRequested && !hadCrashReport) {
// Clean shutdown — user requested stop
Expand Down Expand Up @@ -732,7 +749,7 @@ class ServerProcess extends EventEmitter {
}
} else if (isCrash) {
// Crash or unexpected exit
const crashReason = wasOOM ? 'oom' : hadCrashReport ? 'crash_report' : 'exit_code';
const crashReason = wasOOM ? 'oom' : hadCrashReport ? 'crash_report' : wasSignalled ? 'signal' : 'exit_code';
this.config.exitCode = code;
this.config.crashReason = crashReason;
await this._setStateRobust(STATES.CRASHED);
Expand All @@ -741,7 +758,9 @@ class ServerProcess extends EventEmitter {
? 'Out of Memory detected.'
: hadCrashReport
? 'Crash report detected.'
: `Exit code: ${code}`;
: wasSignalled
? `Process killed (${signal}).`
: `Exit code: ${code}`;
this._appendLine(`[Craftbox] Server crashed! ${crashMsg}`);

// Update DB with crash info
Expand All @@ -768,7 +787,8 @@ class ServerProcess extends EventEmitter {
}, 5000);
}
} else {
// Process exited with code 0 but stop wasn't requested — treat as clean stop
// Process exited with code 0 but stop wasn't requested (a `stop`
// typed into the console) — treat as clean stop
this.config.exitCode = code;
this.config.crashReason = null;
await this._setStateRobust(STATES.STOPPED);
Expand Down
Loading
Loading