diff --git a/.github/workflows/ci-extended.yml b/.github/workflows/ci-extended.yml
index b25c11f..2f578c4 100644
--- a/.github/workflows/ci-extended.yml
+++ b/.github/workflows/ci-extended.yml
@@ -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 }
diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml
index ac74ea5..9d4df77 100644
--- a/.github/workflows/docker-image.yml
+++ b/.github/workflows/docker-image.yml
@@ -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
diff --git a/Dockerfile b/Dockerfile
index 14b51d0..feb7b7d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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/
diff --git a/README.md b/README.md
index 75474cf..20bf586 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@

-
+

[](https://diamonddigital.dev/discord)
diff --git a/docs/API.md b/docs/API.md
index d7c793a..31bee56 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -30,7 +30,7 @@ Failed authentication returns `401 {"error": "unauthorized"}`.
### Session cookies (browser clients)
-The panel frontend authenticates with a session cookie (`POST /login`, rate limited to 5 attempts per 15 minutes per IP). Cookie-based callers must send a CSRF token on every mutating request (POST/DELETE), either as an `X-CSRF-Token` header or a `_csrf` body field. The token is embedded in every panel page. CSRF failures return `403 {"error": "forbidden"}`.
+The panel frontend authenticates with a session cookie (`POST /login`, rate limited to 5 failed sign-ins per 15 minutes per IP). Cookie-based callers must send a CSRF token on every mutating request (POST/DELETE), either as an `X-CSRF-Token` header or a `_csrf` body field. The token is embedded in every panel page. CSRF failures return `403 {"error": "forbidden"}`.
> If you are building an external tool, use an API key. Sessions are `SameSite=Strict`, expire after one hour, and require CSRF handling.
@@ -87,8 +87,19 @@ Completion is signalled over the WebSocket as an `operation` message (see [WebSo
Allowed lifecycle actions: **start** from `stopped`/`crashed`; **stop** from `running`/`starting`; **restart** from `running`; **kill** from `running`/`starting`/`stopping`.
+A server whose process ends without Craftbox asking it to is `crashed`, and `crashReason` says why: `oom` (an `OutOfMemoryError` in the console, on which Craftbox kills the server straight away), `crash_report` (Minecraft wrote a crash report), `exit_code` (a non-zero exit, given in `exitCode`) or `signal` (the process was killed by a signal, so `exitCode` is null; this is how the system's out-of-memory killer ends a server in a memory-limited container). A failed operation leaves a sentence instead, such as `Duplication failed: …`. A stop, restart or kill from Craftbox, or `stop` typed into the console, ends in `stopped`.
+
+When the panel restarts, a server that was `starting`, `running`, `stopping`, `backing_up` or `restoring` comes back `stopped` (auto-start then starts the ones that have it on), and one caught `provisioning` or `upgrading_jar` comes back `crashed` with a reason saying so. A `crashed` server stays `crashed`, with its `crashReason` and `exitCode`; before 1.2.3 a panel restart turned it into `stopped`.
+
> **Provisioning is exclusive.** A server created, imported, duplicated or built from a modpack stays `provisioning` until its directory is fully assembled, and can only leave that state for `stopped` or `crashed`. Backups, restores, jar upgrades, restarts, and the settings/properties restore-point saves all reject with `409 {"error": "Wait for the server to finish provisioning."}` until it clears — `stopFirst` does not override this. Poll `GET /servers/:id` or watch the WebSocket `state` message to know when it is ready.
+### Shared ports
+
+Two servers can be saved with the same port, since keeping a copy of a server beside it is a fair use; only one of them can run at a time. A server's `server-ip` (blank means every address) is taken into account, so servers bound to two different specific addresses don't share anything.
+
+- **Starting** or **restarting** a server while another server's process holds its port is refused with `409 {"error": "Port 25565 is in use by \"Survival\". Stop that server, or change this one's port."}`. Before 1.2.3 the second server was started anyway and crashed when Minecraft couldn't bind the port. The operations that start a server afterwards (`startAfter`, a restore-point save of a running server, auto-start) report the same reason as they report any failed start.
+- **Saving** a server with a port another server uses answers as usual, with a `warnings` array of sentences such as `Port 25565 is also used by "Survival". Only one of them can run at a time.` This happens on every create (`/servers`, `/servers/from-modpack`, `/servers/from-mrpack`), on `/duplicate` and on `/import`, and on `/edit` and `/properties` when the request changes the port (or, on `/properties`, `server-ip`). Those responses always carry `warnings`, empty when there's nothing to say, `202` restore-point answers included.
+
## Servers
@@ -110,10 +121,10 @@ The server object returned by these endpoints contains the full configuration (n
| Method | Path | Description |
|---|---|---|
-| POST | `/servers` | Create a server. Body: `{name, serverType, version, port, memory, eula, javaArgs?, gamemode?, difficulty?, levelType?, seed?, group?, customJarUrl?}`. `eula` must be truthy; `customJarUrl` required (http/https) when `serverType` is `custom`. `gamemode` defaults to `survival` and `difficulty` to `normal`. See [World Type](#world-type) for `levelType`. Returns `201 {"success": true, "server": {...}}`; provisioning continues in the background |
+| POST | `/servers` | Create a server. Body: `{name, serverType, version, port, memory, eula, javaArgs?, gamemode?, difficulty?, levelType?, seed?, group?, customJarUrl?}`. `eula` must be truthy; `customJarUrl` required (http/https) when `serverType` is `custom`. `gamemode` defaults to `survival` and `difficulty` to `normal`; both are always names here and in the server record, but Minecraft before 1.14 reads them only as numbers (a name there quietly plays Survival on Easy), so for those versions Craftbox writes the numbers to `server.properties`. See [World Type](#world-type) for `levelType`. Returns `201 {"success": true, "server": {...}, "warnings": [...]}` (see [Shared ports](#shared-ports)); provisioning continues in the background |
| POST | `/servers/from-modpack` | Create from a Modrinth modpack — see [Modrinth](#modrinth) |
| POST | `/servers/from-mrpack` | Create from an uploaded `.mrpack` file — see [Modrinth](#modrinth) |
-| POST | `/servers/:id/duplicate` | Clone a server. Body: `{name, port, includeWorld?, stopFirst?, startAfter?}`. `409` if running and `stopFirst` is not set. Returns `201` |
+| POST | `/servers/:id/duplicate` | Clone a server. Body: `{name, port, includeWorld?, stopFirst?, startAfter?}`. Without `includeWorld: true` the copy leaves the world out: the folder `level-name` names, its `_nether` and `_the_end` folders, and `world`, `world_nether` and `world_the_end`. `409` if running and `stopFirst` is not set. Returns `201 {"success": true, "server": {...}, "warnings": [...]}` (see [Shared ports](#shared-ports)) |
| POST | `/servers/import` | Import a transfer archive — see [Server transfer](#server-transfer) |
| DELETE | `/servers/:id` | Delete a server and its data. `409` unless `stopped`/`crashed` |
@@ -121,9 +132,9 @@ The server object returned by these endpoints contains the full configuration (n
| Method | Path | Description |
|---|---|---|
-| POST | `/servers/:id/start` | Start. Returns `{"success": true, "message": ...}`; `400` on invalid state transition |
+| POST | `/servers/:id/start` | Start. Returns `{"success": true, "message": ...}`; `400` on invalid state transition; `409` while another server holds its port (see [Shared ports](#shared-ports)) |
| POST | `/servers/:id/stop` | Graceful stop |
-| POST | `/servers/:id/restart` | Restart. Body: `{backup?: true}` to back up first (returns `202`; `409` if a backup is already in progress) |
+| POST | `/servers/:id/restart` | Restart. Body: `{backup?: true}` to back up first (returns `202`; `409` if a backup is already in progress). `409` while another server holds the port it would restart on, checked before it stops |
| POST | `/servers/:id/kill` | Force-kill the process |
| POST | `/servers/:id/command` | Send a console line. Body: `{command}`. `409` if not running |
@@ -145,14 +156,14 @@ The [WebSocket](#websocket-protocol) is the live feed, but it does not accept be
| Method | Path | Description |
|---|---|---|
-| POST | `/servers/:id/edit` | Edit config. Body: `{name, port, memory, javaArgs?, gamemode?, difficulty?, levelType?, seed?, group?, version?, customJarUrl?, backup?}`. Version changes must be upgrades (release versions compare numerically; snapshot/pre-release versions compare by the provider's chronological ordering) and require the server stopped (`409` otherwise); may download a new jar. Returns `{"success": true, "server": {...}, "versionChanged": bool, "jarChanged": bool}`. With `backup: true` see [Restore-point backups](#restore-point-backups) — returns `202` instead |
+| POST | `/servers/:id/edit` | Edit config. Body: `{name, port, memory, javaArgs?, gamemode?, difficulty?, levelType?, seed?, group?, version?, customJarUrl?, backup?}`. Version changes must be upgrades (release versions compare numerically; snapshot/pre-release versions compare by the provider's chronological ordering) and require the server stopped (`409` otherwise); may download a new jar. Returns `{"success": true, "server": {...}, "versionChanged": bool, "jarChanged": bool, "warnings": [...]}` (see [Shared ports](#shared-ports)). With `backup: true` see [Restore-point backups](#restore-point-backups) — returns `202` instead |
| POST | `/servers/:id/group` | Assign the dashboard group. Body: `{group}` (empty/null to ungroup). Returns `{"group": ..., "color": ...}` — `color` is the group's folder color (null when ungrouped) |
| POST | `/servers/:id/autorestart` | Body: `{enabled: bool}`. Returns `{"autoRestart": bool}` |
| POST | `/servers/:id/autostart` | Body: `{enabled: bool}`. Returns `{"autoStart": bool}` |
| POST | `/servers/:id/statuspublic` | Toggle listing on the `/status` index. Body: `{enabled: bool}`. Does **not** gate direct access — see [Public status endpoints](#public-status-endpoints) |
| POST | `/servers/:id/advertisedip` | Set the address shown on the status page. Body: `{value}` |
| POST | `/servers/:id/motd` | Set the MOTD. Body: `{motd}` |
-| POST | `/servers/:id/properties` | Update `server.properties`. Body: an object keyed by property name, plus an optional `backup` flag (reserved — never written as a property). A partial update: properties left out keep their current value, and keys not already in the file are ignored. Toggles take `true`/`false` (boolean or string) — anything else is `400`. With `backup: true` see [Restore-point backups](#restore-point-backups) — returns `202` instead of `{"success": true}` |
+| POST | `/servers/:id/properties` | Update `server.properties`. Body: an object keyed by property name, plus an optional `backup` flag (reserved — never written as a property). A partial update: properties left out keep their current value, and keys not already in the file are ignored. Toggles take `true`/`false` (boolean or string) — anything else is `400`. `gamemode` and `difficulty` take names, written as the numbers a server before Minecraft 1.14 reads (see `POST /servers`). With `backup: true` see [Restore-point backups](#restore-point-backups) — returns `202` instead of `{"success": true, "warnings": [...]}` (see [Shared ports](#shared-ports)) |
| POST | `/servers/:id/edit-file` | Save a text file inside the server directory. Body: `{filePath, content}`. `403` on path traversal, `400` if the target is not text (see [Text vs binary](#files)) |
### World Type
@@ -246,8 +257,8 @@ The response is `202 {"success": true, "status": "started"}` instead of the endp
| POST | `/servers/:id/backups` | Create a backup. Body: `{name?, stopFirst?, startAfter?}`. Returns `202`; `409` if running without `stopFirst`, or if a backup is already in progress |
| POST | `/servers/:id/backups/:backupId/restore` | Restore. Body: `{startAfter?}`. Returns `202` |
| DELETE | `/servers/:id/backups/:backupId` | Delete a backup |
-| POST | `/servers/:id/backup-schedule` | Body: `{enabled, intervalHours (1–168), countdownMinutes (1–30)}`. Returns `{"backupSchedule": {...}, "nextBackupAt": ...}` |
-| POST | `/servers/:id/backup-retention` | Body: `{retentionCount (0–100), retentionDays (0–365)}` (0 = unlimited) |
+| POST | `/servers/:id/backup-schedule` | Body: `{enabled, intervalHours (1–168), countdownMinutes (1–30)}`. Returns `{"backupSchedule": {...}, "nextBackupAt": ...}`. A field left out or `null` keeps its value. `400` if `enabled` isn't `true`/`false` or a number isn't a whole number in range, and then nothing is saved |
+| POST | `/servers/:id/backup-retention` | Body: `{retentionCount (0–100), retentionDays (0–365)}` (0 = unlimited). A field left out or `null` keeps its value. `400` if a number isn't a whole number in range, and then nothing is saved |
| GET | `/servers/:id/backups/:backupId/download` | Stream the backup archive as `application/zip`, with an exact `Content-Length` read off the file rather than the record. `404` if the backup does not belong to this server |
@@ -285,7 +296,7 @@ Import behavior:
- The source server UUID is kept when free on the target instance, otherwise a new UUID is generated. Backup and event records always get fresh IDs.
- All settings are preserved, `advertisedIp` included — the archive is a snapshot of the server as it was, so an address that does not apply on the new host is an edit away rather than something to remember. Only runtime state is reset (`exitCode`, `crashReason`, timestamps); the server stays stopped after import until started.
- The dashboard group comes across by name, and its color travels in the manifest alongside it. A group that already exists on the target instance keeps the color chosen there — an import never restyles servers that were already in it.
-- A port collision with an existing server does not block the import; a warning is returned instead.
+- A port shared with an existing server does not block the import; a warning is returned instead (see [Shared ports](#shared-ports)).
## Chunked uploads (DGUP)
@@ -396,7 +407,7 @@ Two quirks of Modrinth's search are worked around inside the proxy, so these end
| Method | Path | Description |
|---|---|---|
-| POST | `/servers/from-modpack` | Body: `{projectId, versionId, name, port, memory, eula, javaArgs?, gamemode?, difficulty?, levelType?, seed?, group?}`. Pack metadata is re-fetched server-side (client values cannot spoof it); the loader (Fabric/Forge/NeoForge) and Minecraft version come from the pack itself. Returns `201 {"success": true, "server": {...}}`; the install continues in the background (see progress below). `400` for Quilt packs, loaderless packs, or versions with no `.mrpack` file; `404`/`429`/`502` from the Modrinth lookups as above |
+| POST | `/servers/from-modpack` | Body: `{projectId, versionId, name, port, memory, eula, javaArgs?, gamemode?, difficulty?, levelType?, seed?, group?}`. Pack metadata is re-fetched server-side (client values cannot spoof it); the loader (Fabric/Forge/NeoForge) and Minecraft version come from the pack itself. Returns `201 {"success": true, "server": {...}, "warnings": [...]}`; the install continues in the background (see progress below). `400` for Quilt packs, loaderless packs, or versions with no `.mrpack` file; `404`/`429`/`502` from the Modrinth lookups as above |
| POST | `/servers/from-mrpack` | Create from an uploaded `.mrpack`. Multipart, file field `mrpack`, max 2 GiB, plus the same base fields as text fields. The pack is parsed and the loader resolved **before** any server record is created, so malformed or Quilt packs fail with a clean `400`. Returns `201` + background install. Also accepts [chunked uploads](#chunked-uploads-dgup) at `/servers/from-mrpack/upload/*` |
The background install downloads the pack's files (SHA-512 verified; download hosts restricted to the mrpack spec whitelist), installs the loader server pinned to the pack's loader version, and applies `overrides/` then `server-overrides/`. No pack file, whether from the manifest or the overrides, may land among the server's launch files: anything under `libraries/` (where Forge and NeoForge keep the argument file handed to the JVM), a `.jar` or `*_args.txt` at the top of the server directory, `user_jvm_args.txt`, `run.sh`/`run.bat` or `fabric-server-launcher.properties`. Such files are skipped and named in the `complete` message's `warnings`, so a pack cannot change how the server is started. A `server.properties` the pack ships is kept: the port, gamemode and difficulty from the request are written over it (and the seed and `levelType`, when given — left blank, the pack's are kept), `online-mode`, `enable-rcon`, `rcon.password` and `server-ip` are always reset to `true`, `false`, empty and empty, and Craftbox's defaults fill in any other key it leaves out. If the pack had set any of those four differently, the `complete` message's `warnings` says so. Mods the pack marks as unsupported on the server are still installed, but land disabled on disk and tagged `client` in the mod environment map — so they show as **Client Only** on the plugins page and are included in the status page's mods download for players, without the loader ever seeing them. Progress streams over the WebSocket as `operation: "modpack-install"`, `status: "progress"` messages with payload `{phase, done?, total?}` — phases: `download`, `parse`, `loader`, `files`, `overrides`, `finalize` — ending in `complete` or `failed`. The `files` phase carries `done`/`total` counts of **mods** (every jar destined for `mods/`, from the manifest and from the overrides, client-only ones included — so the total matches what the mods page lists afterwards, not the raw file count); it keeps ticking during the `overrides` phase as any mods shipped there land. On `failed` the half-built server is removed automatically (see [Asynchronous operations](#asynchronous-operations)). The created server records a `modpack` block (`{projectId, versionId, name, versionNumber, iconUrl, source: "modrinth"|"file", installedAt}`) for future tooling; it survives export/import.
@@ -485,9 +496,9 @@ The server pings every 30 seconds and drops sockets that miss a pong.
> **`operation: "download"` reports how a download went.** A browser download is invisible to the page that started it, so any download endpoint under a server reports its own outcome here — including the ones that are plain links rather than API calls. Add `?dl=
` (any opaque string, up to 64 characters) to the download URL and the token comes back in every message about it, which is how a client matches an outcome to the request it made. Without the token nothing is emitted; API clients read the HTTP status instead.
>
-> `progress` carries `{token, label, phase, done, total}` where `phase` is `packing` (bytes read so far, out of the estimated source size) or `sending` (`total` is the finished archive's size), throttled to one message a second. `complete` and `cancelled` carry `{token, label, bytes, sizeFormatted}` — `cancelled` means the client hung up before the last byte, whether during packing or mid-transfer. `failed` carries the reason in `error` and covers everything a download can be refused for, including the guard failures (`409` server running, `404` missing file, `507` no staging space) whose response body the browser never shows.
+> `progress` carries `{token, label, phase, done, total}` where `phase` is `packing` (bytes read so far, out of the estimated source size) or `sending` (`total` is the finished archive's size), throttled to one message a second. `complete` and `cancelled` carry `{token, label, bytes, sizeFormatted}` — `cancelled` means the client hung up before the last byte, whether during packing or mid-transfer. `failed` carries the reason in `error`, beside a payload of `{token, label}` (before 1.2.3 it had no payload, so no token), and covers everything a download can be refused for, including the guard failures (`409` server running, `404` missing file, `507` no staging space) whose response body the browser never shows.
## Rate limiting
-Only `POST /login` is rate limited (5 attempts per 15 minutes per IP; behind a reverse proxy, set `TRUST_PROXY` to the number of proxies in front of Craftbox so the client IP is read from `X-Forwarded-For` rather than being the proxy's own). There is currently **no rate limiting on `/api/v1`, `/status`, or the WebSocket** — be a considerate client, and treat API keys like passwords.
+Only `POST /login` is rate limited: 5 failed sign-ins per 15 minutes per IP, after which every attempt is refused with `429` until the window has passed, the right password included. A successful sign-in isn't counted (before 1.2.3 it was), although it still shows in the `RateLimit-Remaining` header of its own response. Behind a reverse proxy, set `TRUST_PROXY` to the number of proxies in front of Craftbox so the client IP is read from `X-Forwarded-For` rather than being the proxy's own. There is currently **no rate limiting on `/api/v1`, `/status`, or the WebSocket** — be a considerate client, and treat API keys like passwords.
diff --git a/package-lock.json b/package-lock.json
index 16eb497..20c09dd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "craftbox",
- "version": "1.2.2",
+ "version": "1.2.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "craftbox",
- "version": "1.2.2",
+ "version": "1.2.3",
"license": "AGPL-3.0-only",
"dependencies": {
"archiver": "^8.0.0",
diff --git a/package.json b/package.json
index 5d79fa8..214852c 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/public/js/app.js b/public/js/app.js
index a799b56..5484a75 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -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="" 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
diff --git a/public/js/console.js b/public/js/console.js
index 001a3b3..7813cb4 100644
--- a/public/js/console.js
+++ b/public/js/console.js
@@ -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
@@ -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: ' + exitCode + '.'
: '';
diff --git a/public/js/create.js b/public/js/create.js
index ec9dbf1..2794639 100644
--- a/public/js/create.js
+++ b/public/js/create.js
@@ -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';
});
@@ -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';
}
@@ -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';
}
diff --git a/public/js/download.js b/public/js/download.js
index 01a1641..6f0bc94 100644
--- a/public/js/download.js
+++ b/public/js/download.js
@@ -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);
diff --git a/public/js/edit.js b/public/js/edit.js
index b9f3e96..88bf31f 100644
--- a/public/js/edit.js
+++ b/public/js/edit.js
@@ -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';
});
@@ -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';
}
})();
@@ -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';
}
diff --git a/public/js/properties.js b/public/js/properties.js
index 6a77164..00d01eb 100644
--- a/public/js/properties.js
+++ b/public/js/properties.js
@@ -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';
});
@@ -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';
}
})();
diff --git a/src/db.js b/src/db.js
index c04fd46..3ab39b0 100644
--- a/src/db.js
+++ b/src/db.js
@@ -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) {
diff --git a/src/mc/ServerManager.js b/src/mc/ServerManager.js
index 830ef92..92d5208 100644
--- a/src/mc/ServerManager.js
+++ b/src/mc/ServerManager.js
@@ -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() {
@@ -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);
@@ -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();
@@ -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();
@@ -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);
}
diff --git a/src/mc/ServerProcess.js b/src/mc/ServerProcess.js
index 1038f71..14b5de6 100644
--- a/src/mc/ServerProcess.js
+++ b/src/mc/ServerProcess.js
@@ -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 \(/;
@@ -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() {
@@ -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'],
@@ -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
@@ -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);
@@ -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
@@ -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);
diff --git a/src/mc/serverProperties.js b/src/mc/serverProperties.js
index b00cbf4..cac9e2f 100644
--- a/src/mc/serverProperties.js
+++ b/src/mc/serverProperties.js
@@ -1,6 +1,40 @@
const fs = require('fs');
const path = require('path');
const { firstStartProperties } = require('./worldTypes');
+const { isAtLeast } = require('../utils/mcVersion');
+
+// Before 1.14 Minecraft reads gamemode and difficulty as numbers. A name
+// fails to parse, so the server quietly plays Survival on Easy and rewrites
+// the file with those numbers. 1.14 onwards reads a number or a name, and
+// rewrites a number as its name. (Probed on the real server jars, 1.8.9 to
+// 26.3.) Craftbox keeps names everywhere else and writes numbers where the
+// version needs them; 1.14's snapshots get numbers too, being read either way.
+const MODE_NAMES = {
+ gamemode: ['survival', 'creative', 'adventure', 'spectator'],
+ difficulty: ['peaceful', 'easy', 'normal', 'hard']
+};
+
+function readsModeNames(version) {
+ return isAtLeast(version, '1.14', '19w15a');
+}
+
+/** `props` with gamemode and difficulty in the form `version` reads. */
+function modeValuesFor(props, version) {
+ if (readsModeNames(version)) return props;
+ const result = { ...props };
+ for (const [key, names] of Object.entries(MODE_NAMES)) {
+ const id = key in result ? names.indexOf(String(result[key])) : -1;
+ if (id !== -1) result[key] = String(id);
+ }
+ return result;
+}
+
+/** A gamemode or difficulty value as its name, whichever form the file holds. */
+function modeNameOf(key, value) {
+ const text = String(value ?? '').trim();
+ const names = MODE_NAMES[key];
+ return names && /^\d$/.test(text) && names[Number(text)] ? names[Number(text)] : text;
+}
const DEFAULT_PROPERTIES = {
'server-port': 25565,
@@ -88,11 +122,11 @@ function writeServerProperties(serverDir, overrides = {}, { mergeExisting = fals
for (const [key, value] of Object.entries(defaultsFor(version))) {
if (!(key in existing)) missingDefaults[key] = value;
}
- updateServerProperties(serverDir, { ...missingDefaults, ...managed });
+ updateServerProperties(serverDir, { ...missingDefaults, ...managed }, { version });
return;
}
- const props = { ...defaultsFor(version), ...managed };
+ const props = modeValuesFor({ ...defaultsFor(version), ...managed }, version);
const lines = ['#Minecraft server properties', `#Generated by Craftbox on ${new Date().toISOString()}`];
for (const [key, value] of Object.entries(props)) {
@@ -142,10 +176,15 @@ function parseServerProperties(serverDir) {
/**
* Update specific keys in an existing server.properties file,
* preserving comments and ordering.
+ * @param {object} [options]
+ * @param {string} [options.version] - The server's Minecraft version. Given,
+ * gamemode and difficulty are written in the form it reads (numbers before
+ * 1.14); left out, they're written as given.
*/
-function updateServerProperties(serverDir, updates) {
+function updateServerProperties(serverDir, updates, { version } = {}) {
const filePath = path.join(serverDir, 'server.properties');
if (!fs.existsSync(filePath)) return;
+ if (version !== undefined) updates = modeValuesFor(updates, version);
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split('\n');
const updatedKeys = new Set();
@@ -176,4 +215,7 @@ function updateServerProperties(serverDir, updates) {
fs.writeFileSync(filePath, result.join('\n'));
}
-module.exports = { writeServerProperties, writeEula, parseServerProperties, updateServerProperties, DEFAULT_PROPERTIES };
+module.exports = {
+ writeServerProperties, writeEula, parseServerProperties, updateServerProperties, DEFAULT_PROPERTIES,
+ modeValuesFor, modeNameOf
+};
diff --git a/src/mc/serverTypes/_verifyChecksum.js b/src/mc/serverTypes/_verifyChecksum.js
index dfd1d4e..2eaea83 100644
--- a/src/mc/serverTypes/_verifyChecksum.js
+++ b/src/mc/serverTypes/_verifyChecksum.js
@@ -37,4 +37,25 @@ function verifyChecksum(buffer, algo, expectedHex, label) {
}
}
-module.exports = { verifyChecksum, ChecksumMismatchError };
+// A Maven repository publishes checksum sidecars beside each artifact, but
+// not always the same set: Forge's has only .sha1 and .md5 for many older
+// installers (1.12.2, 1.14.4 and 1.15.2 among them). Verify against the
+// strongest one published, moving on only when a sidecar is missing (404).
+// Any other failure, or no sidecar at all, refuses the download rather than
+// installing something unverified. Returns the algorithm used.
+const MAVEN_SIDECARS = [['sha256', '.sha256'], ['sha1', '.sha1'], ['md5', '.md5']];
+
+async function verifyMavenChecksum(buffer, artifactUrl, label) {
+ for (const [algo, ext] of MAVEN_SIDECARS) {
+ const res = await fetch(artifactUrl + ext);
+ if (res.status === 404) continue;
+ if (!res.ok) {
+ throw new Error(`Could not fetch ${label} installer checksum: HTTP ${res.status}.`);
+ }
+ verifyChecksum(buffer, algo, await res.text(), label);
+ return algo;
+ }
+ throw new Error(`${label}: no checksum published for the installer — cannot verify integrity.`);
+}
+
+module.exports = { verifyChecksum, verifyMavenChecksum, ChecksumMismatchError };
diff --git a/src/mc/serverTypes/forge.js b/src/mc/serverTypes/forge.js
index 53e27cb..da7385c 100644
--- a/src/mc/serverTypes/forge.js
+++ b/src/mc/serverTypes/forge.js
@@ -3,7 +3,7 @@ const path = require('path');
const { spawn } = require('child_process');
const { log } = require('../../utils/log');
const { getJavaForVersion } = require('../../utils/javaVersion');
-const { verifyChecksum } = require('./_verifyChecksum');
+const { verifyMavenChecksum } = require('./_verifyChecksum');
const { pickLatestBuild, compareBuilds } = require('./_channels');
const PROMOTIONS_URL = 'https://files.minecraftforge.net/net/minecraftforge/forge/promotions_slim.json';
@@ -132,17 +132,14 @@ module.exports = {
fs.mkdirSync(serverDir, { recursive: true });
const installerBuffer = Buffer.from(await installerRes.arrayBuffer());
- // Verify against the SHA-256 sidecar published by Maven before the
+ // Verify against the checksum sidecar published by Maven before the
// installer is written to disk and executed. Mirror compromise or
- // in-path tampering would otherwise yield host RCE here.
- const checksumRes = await fetch(installerUrl + '.sha256');
- if (!checksumRes.ok) {
- throw new Error(`Could not fetch Forge installer checksum: HTTP ${checksumRes.status}.`);
- }
- verifyChecksum(installerBuffer, 'sha256', await checksumRes.text(), 'Forge');
+ // in-path tampering would otherwise yield host RCE here. Many older
+ // installers (1.12.2 among them) have no .sha256, only .sha1/.md5.
+ const algo = await verifyMavenChecksum(installerBuffer, installerUrl, 'Forge');
fs.writeFileSync(installerPath, installerBuffer);
- log('info', `Forge installer downloaded and SHA-256 verified (${(installerBuffer.length / 1024 / 1024).toFixed(1)} MB). Running installer...`);
+ log('info', `Forge installer downloaded and ${algo.toUpperCase().replace(/^SHA/, 'SHA-')} verified (${(installerBuffer.length / 1024 / 1024).toFixed(1)} MB). Running installer...`);
// Run the installer
// Use the Java version required for the target MC version (important in Docker where multiple JREs are present)
diff --git a/src/mc/serverTypes/neoforge.js b/src/mc/serverTypes/neoforge.js
index fe324ab..6e76473 100644
--- a/src/mc/serverTypes/neoforge.js
+++ b/src/mc/serverTypes/neoforge.js
@@ -3,7 +3,7 @@ const path = require('path');
const { spawn } = require('child_process');
const { log } = require('../../utils/log');
const { getJavaForVersion } = require('../../utils/javaVersion');
-const { verifyChecksum } = require('./_verifyChecksum');
+const { verifyMavenChecksum } = require('./_verifyChecksum');
const { pickLatestBuild, compareBuilds } = require('./_channels');
const MAVEN_API = 'https://maven.neoforged.net/api/maven/versions/releases/net/neoforged/neoforge';
@@ -133,17 +133,13 @@ module.exports = {
fs.mkdirSync(serverDir, { recursive: true });
const installerBuffer = Buffer.from(await installerRes.arrayBuffer());
- // Verify against the SHA-256 sidecar published by Maven before the
+ // Verify against the checksum sidecar published by Maven before the
// installer is written to disk and executed. Mirror compromise or
// in-path tampering would otherwise yield host RCE here.
- const checksumRes = await fetch(installerUrl + '.sha256');
- if (!checksumRes.ok) {
- throw new Error(`Could not fetch NeoForge installer checksum: HTTP ${checksumRes.status}.`);
- }
- verifyChecksum(installerBuffer, 'sha256', await checksumRes.text(), 'NeoForge');
+ const algo = await verifyMavenChecksum(installerBuffer, installerUrl, 'NeoForge');
fs.writeFileSync(installerPath, installerBuffer);
- log('info', `NeoForge installer downloaded and SHA-256 verified (${(installerBuffer.length / 1024 / 1024).toFixed(1)} MB). Running installer...`);
+ log('info', `NeoForge installer downloaded and ${algo.toUpperCase().replace(/^SHA/, 'SHA-')} verified (${(installerBuffer.length / 1024 / 1024).toFixed(1)} MB). Running installer...`);
// Run the installer with the correct Java for this MC version
const javaPath = getJavaForVersion(version);
diff --git a/src/mc/syncServerConfig.js b/src/mc/syncServerConfig.js
index a3b1ad0..0407714 100644
--- a/src/mc/syncServerConfig.js
+++ b/src/mc/syncServerConfig.js
@@ -1,7 +1,7 @@
const fs = require('fs');
const path = require('path');
const { serversDb, SERVERS_DIR } = require('../db');
-const { parseServerProperties } = require('./serverProperties');
+const { parseServerProperties, modeNameOf } = require('./serverProperties');
const { log } = require('../utils/log');
/**
@@ -31,16 +31,15 @@ async function syncServerConfig(serverId) {
}
}
- if (props['gamemode'] && props['gamemode'] !== server.gamemode) {
- log('info', `[${server.name}] Config sync: gamemode ${server.gamemode} → ${props['gamemode']}`);
- server.gamemode = props['gamemode'];
- changed = true;
- }
-
- if (props['difficulty'] && props['difficulty'] !== server.difficulty) {
- log('info', `[${server.name}] Config sync: difficulty ${server.difficulty} → ${props['difficulty']}`);
- server.difficulty = props['difficulty'];
- changed = true;
+ // Before 1.14 the file holds these as numbers (see serverProperties.js);
+ // the record always holds the name
+ for (const key of ['gamemode', 'difficulty']) {
+ const value = props[key] ? modeNameOf(key, props[key]) : '';
+ if (value && value !== server[key]) {
+ log('info', `[${server.name}] Config sync: ${key} ${server[key]} → ${value}`);
+ server[key] = value;
+ changed = true;
+ }
}
if (props['level-seed'] !== undefined && props['level-seed'] !== server.seed) {
diff --git a/src/routes/api-v1/servers.js b/src/routes/api-v1/servers.js
index 7d993e4..4e5eb7d 100644
--- a/src/routes/api-v1/servers.js
+++ b/src/routes/api-v1/servers.js
@@ -43,6 +43,7 @@ const {
} = require('../../utils/fileBrowser');
const { readConsoleTail } = require('../../utils/consoleLog');
const { cleanupServerData } = require('../../utils/serverCleanup');
+const { portClashWarnings, serverIpOf } = require('../../utils/portClash');
const { installModpack, parseMrpack, resolveLoader, pickLoaderFromArray } = require('../../mc/modpackInstaller');
const { assertWhitelistedUrl } = require('../../utils/httpDownload');
const modrinth = require('../../services/modrinth');
@@ -175,7 +176,7 @@ function httpError(status, message) {
// Responds 202 immediately; completion is broadcast as `operation` over the
// WebSocket. If the backup or the change fails, the server is put back the way
// it was found and the failure is broadcast.
-async function runWithRestorePoint({ req, res, server, label, operation, apply }) {
+async function runWithRestorePoint({ req, res, server, label, operation, apply, warnings = [] }) {
const id = server.id;
const serverManager = req.app.get('serverManager');
const initiatedBy = req.user.username;
@@ -200,7 +201,7 @@ async function runWithRestorePoint({ req, res, server, label, operation, apply }
await serverManager.setOperationalState(id, STATES.BACKING_UP);
lockOwnedByRoute = false; // handed off to the task below
- res.status(202).json({ success: true, status: 'started' });
+ res.status(202).json({ success: true, status: 'started', warnings });
(async () => {
let result;
@@ -511,7 +512,9 @@ router.get('/servers/:id/check-upgrade', async (req, res) => {
const provider = getProvider(type);
if (!provider) return res.json({ upgradeAvailable: false });
- if (type === 'custom' || (!provider.getBuilds && !provider.getLatestBuild)) {
+ // Vanilla has a getBuilds() for the provider interface, but a Mojang
+ // release is one jar with no builds, so it always returns null.
+ if (type === 'custom' || type === 'vanilla' || (!provider.getBuilds && !provider.getLatestBuild)) {
return res.json({ upgradeAvailable: false, reason: 'No build tracking for this server type.' });
}
@@ -699,13 +702,47 @@ router.post('/servers/:id/upgrade-jar', async (req, res) => {
}
});
+// The backup schedule's numeric settings, with the range each accepts and how
+// a refusal reads (the Backups page shows it as is).
+const BACKUP_SETTINGS = {
+ intervalHours: { min: 1, max: 168, error: 'The backup interval must be a whole number of hours between 1 and 168.' },
+ countdownMinutes: { min: 1, max: 30, error: 'The warning countdown must be a whole number of minutes between 1 and 30.' },
+ retentionCount: { min: 0, max: 100, error: 'The number of backups to keep must be a whole number between 0 and 100.' },
+ retentionDays: { min: 0, max: 365, error: 'The number of days to keep backups for must be a whole number between 0 and 365.' }
+};
+
+// Read the named backup settings from a request body, all or nothing: a
+// setting left out (or null, which is what the page sends for an empty box)
+// keeps its value, and anything else must be a whole number in range, as a
+// number or a string of digits. parseInt used to take '12abc' as 12 and 1.5
+// as 1, and an out-of-range value was dropped while the request still
+// answered 200.
+function readBackupSettings(body, keys) {
+ const values = {};
+ for (const key of keys) {
+ const raw = body[key];
+ if (raw == null) continue;
+ const n = typeof raw === 'number' ? raw
+ : typeof raw === 'string' && /^\d+$/.test(raw.trim()) ? Number(raw) : NaN;
+ const { min, max, error } = BACKUP_SETTINGS[key];
+ if (!Number.isInteger(n) || n < min || n > max) return { error };
+ values[key] = n;
+ }
+ return { values };
+}
+
// POST /servers/:id/backup-schedule — Update backup schedule settings
router.post('/servers/:id/backup-schedule', async (req, res) => {
try {
const server = await loadServerOr404(req, res);
if (!server) return;
- const { enabled, intervalHours, countdownMinutes } = req.body;
+ const body = req.body || {};
+ if (body.enabled != null && ![true, false, 'true', 'false'].includes(body.enabled)) {
+ return res.status(400).json({ error: 'enabled must be true or false.' });
+ }
+ const { values, error } = readBackupSettings(body, ['intervalHours', 'countdownMinutes']);
+ if (error) return res.status(400).json({ error });
if (!server.backupSchedule) {
server.backupSchedule = {
@@ -717,15 +754,8 @@ router.post('/servers/:id/backup-schedule', async (req, res) => {
};
}
- if (typeof enabled === 'boolean') server.backupSchedule.enabled = enabled;
- if (intervalHours != null) {
- const h = parseInt(intervalHours, 10);
- if (h >= 1 && h <= 168) server.backupSchedule.intervalHours = h;
- }
- if (countdownMinutes != null) {
- const m = parseInt(countdownMinutes, 10);
- if (m >= 1 && m <= 30) server.backupSchedule.countdownMinutes = m;
- }
+ if (body.enabled != null) server.backupSchedule.enabled = body.enabled === true || body.enabled === 'true';
+ Object.assign(server.backupSchedule, values);
delete server.backupSchedule.nextBackupAt;
@@ -757,6 +787,9 @@ router.post('/servers/:id/backup-retention', async (req, res) => {
const server = await loadServerOr404(req, res);
if (!server) return;
+ const { values, error } = readBackupSettings(req.body || {}, ['retentionCount', 'retentionDays']);
+ if (error) return res.status(400).json({ error });
+
if (!server.backupSchedule) {
server.backupSchedule = {
enabled: false,
@@ -767,15 +800,7 @@ router.post('/servers/:id/backup-retention', async (req, res) => {
};
}
- const { retentionCount, retentionDays } = req.body;
- if (retentionCount != null) {
- const n = parseInt(retentionCount, 10);
- if (n >= 0 && n <= 100) server.backupSchedule.retentionCount = n;
- }
- if (retentionDays != null) {
- const d = parseInt(retentionDays, 10);
- if (d >= 0 && d <= 365) server.backupSchedule.retentionDays = d;
- }
+ Object.assign(server.backupSchedule, values);
await serversDb.set(`server_${server.id}`, server);
res.json({ backupSchedule: server.backupSchedule });
@@ -1189,7 +1214,11 @@ router.post('/servers', async (req, res) => {
await serversDb.set(`server_${id}`, server);
notifyDashboard(req);
- res.status(201).json({ success: true, server: publicServer(server) });
+ res.status(201).json({
+ success: true,
+ server: publicServer(server),
+ warnings: await portClashWarnings(server.port, { excludeId: server.id })
+ });
const serverManager = req.app.get('serverManager');
(async () => {
@@ -1486,7 +1515,11 @@ router.post('/servers/from-modpack', async (req, res) => {
log('info', `Creating server "${base.trimmedName}" (${id}) from Modrinth modpack "${project.title}" ${version.version_number}`);
notifyDashboard(req);
- res.status(201).json({ success: true, server: publicServer(server) });
+ res.status(201).json({
+ success: true,
+ server: publicServer(server),
+ warnings: await portClashWarnings(server.port, { excludeId: server.id })
+ });
provisionModpackServer({
req,
@@ -1575,7 +1608,11 @@ const createFromMrpackHandler = async (req, res) => {
log('info', `Creating server "${base.trimmedName}" (${id}) from uploaded modpack "${manifest.name || req.file.originalname}"`);
notifyDashboard(req);
- res.status(201).json({ success: true, server: publicServer(server) });
+ res.status(201).json({
+ success: true,
+ server: publicServer(server),
+ warnings: await portClashWarnings(server.port, { excludeId: server.id })
+ });
provisionModpackServer({
req,
@@ -1675,6 +1712,9 @@ router.post('/servers/:id/restart', async (req, res) => {
let lockOwnedByRoute = true;
try {
const proc = serverManager?.getProcess(id);
+ // Refused up front: found only at the start after the backup, a port
+ // clash would leave the server down and read as a failed backup.
+ if (proc) serverManager.assertPortFree(proc);
if (proc && ['running', 'starting'].includes(proc.state)) {
await serverManager.stopServer(id, { initiatedBy });
await proc.waitForState('stopped', 60000);
@@ -1865,13 +1905,10 @@ const importServerHandler = async (req, res) => {
finalId = uuidv4();
}
- const warnings = [];
- const allServers = await serversDb.all();
- const portClash = allServers.map(row => row.value).find(s => s && s.port === portNum);
- if (portClash) {
- warnings.push(`Port ${portNum} is already used by "${portClash.name}". Edit this server's port before starting it.`);
- log('warn', `Import of "${trimmedName}": port ${portNum} clashes with "${portClash.name}"`);
- }
+ // The archive's server.properties isn't unpacked yet, so its server-ip
+ // is taken as every address, which can only warn more, never less.
+ const warnings = await portClashWarnings(portNum, { excludeId: finalId });
+ if (warnings.length > 0) log('warn', `Import of "${trimmedName}": ${warnings[0]}`);
const groupResult = normalizeGroupName(source.group);
const importedServer = {
@@ -2130,16 +2167,31 @@ router.post('/servers/:id/duplicate', async (req, res) => {
await serversDb.set(`server_${newId}`, newServer);
notifyDashboard(req);
- res.status(201).json({ success: true, server: publicServer(newServer), warning: null });
+ res.status(201).json({
+ success: true,
+ server: publicServer(newServer),
+ warning: null,
+ // The copy keeps the source's server.properties, server-ip included
+ warnings: await portClashWarnings(portNum, { excludeId: newId, serverIp: serverIpOf(id) })
+ });
(async () => {
try {
await fs.promises.cp(sourceDir, newDir, { recursive: true });
if (includeWorld !== 'true' && includeWorld !== true) {
- const worldDirs = ['world', 'world_nether', 'world_the_end'];
+ // The world lives under level-name ("world" unless changed),
+ // split into three folders by Paper, Purpur and Folia. The
+ // default names go too, as they did before, in case a world
+ // was generated under them before level-name changed.
+ const level = parseServerProperties(newDir)['level-name'] || 'world';
+ const worldDirs = new Set(['world', 'world_nether', 'world_the_end',
+ level, `${level}_nether`, `${level}_the_end`]);
for (const dir of worldDirs) {
const worldPath = path.join(newDir, dir);
+ // level-name is free text: never let "." or "../x"
+ // point this at the copy itself or outside it
+ if (path.resolve(worldPath) === path.resolve(newDir) || !isPathInside(newDir, worldPath)) continue;
await fs.promises.rm(worldPath, { recursive: true, force: true });
}
}
@@ -2409,7 +2461,7 @@ router.post('/servers/:id/edit', async (req, res) => {
'difficulty': difficultyStr,
...(levelTypeStr !== undefined ? { 'level-type': levelTypeStr } : {}),
'level-seed': seedStr
- });
+ }, { version: server.version });
}
const proc = req.app.get('serverManager')?.getProcess(id);
@@ -2418,6 +2470,13 @@ router.post('/servers/:id/edit', async (req, res) => {
return { versionChanged, jarChanged };
};
+ // Moving onto a port another server uses is allowed, with a warning.
+ // Saving a port that was already shared says nothing new, so it doesn't
+ // warn again (the Settings page shows it beside the field either way).
+ const warnings = Number(server.port) !== portNum
+ ? await portClashWarnings(portNum, { excludeId: id, serverIp: serverIpOf(id) })
+ : [];
+
// A backup taken after the save can't undo it — so when one is asked for,
// it is taken first and the edit is applied on the far side of it.
if (req.body?.backup === true || req.body?.backup === 'true') {
@@ -2425,14 +2484,15 @@ router.post('/servers/:id/edit', async (req, res) => {
req, res, server,
label: 'Pre-edit backup',
operation: 'settings-save',
- apply: applyEdit
+ apply: applyEdit,
+ warnings
});
}
try {
const { versionChanged, jarChanged } = await applyEdit();
notifyDashboard(req);
- res.json({ success: true, server: publicServer(server), versionChanged, jarChanged });
+ res.json({ success: true, server: publicServer(server), versionChanged, jarChanged, warnings });
} catch (err) {
res.status(err.status || 500).json({ error: err.message });
}
@@ -2466,7 +2526,9 @@ router.post('/servers/:id/properties', async (req, res) => {
updates[key] = String(body[key]);
}
- updateServerProperties(serverDir, updates);
+ // Game mode and difficulty are shown and taken as names; a server
+ // before 1.14 is written the numbers it reads
+ updateServerProperties(serverDir, updates, { version: server.version });
await syncServerConfig(id);
const changed = Object.keys(updates).filter(key => updates[key] !== currentProps[key]);
@@ -2476,18 +2538,29 @@ router.post('/servers/:id/properties', async (req, res) => {
return {};
};
+ // Warn, as /edit does, when this moves the server onto a port (or an
+ // address) that shares one with another server. Only keys already in the
+ // file are written, so only those can change anything.
+ const fileProps = parseServerProperties(path.join(SERVERS_DIR, id));
+ const sent = (key) => (key in fileProps && body[key] !== undefined ? String(body[key]).trim() : fileProps[key] || '');
+ const newPort = parseInt(sent('server-port'), 10);
+ const warnings = newPort && (sent('server-port') !== (fileProps['server-port'] || '') || sent('server-ip') !== (fileProps['server-ip'] || ''))
+ ? await portClashWarnings(newPort, { excludeId: id, serverIp: sent('server-ip') })
+ : [];
+
// As with /edit: a backup only rolls the change back if it predates it.
if (req.body?.backup === true || req.body?.backup === 'true') {
return runWithRestorePoint({
req, res, server,
label: 'Pre-properties backup',
operation: 'settings-save',
- apply: applyProperties
+ apply: applyProperties,
+ warnings
});
}
await applyProperties();
- res.json({ success: true });
+ res.json({ success: true, warnings });
});
// Where ServerProcess writes the console log for a server.
diff --git a/src/routes/auth.js b/src/routes/auth.js
index 9719870..a7af2c3 100644
--- a/src/routes/auth.js
+++ b/src/routes/auth.js
@@ -118,6 +118,8 @@ router.post('/login', loginLimiter, (req, res, next) => {
req.login(user, (err) => {
if (err) return next(err);
+ // Tells loginLimiter not to count this attempt
+ res.locals.loginSucceeded = true;
res.redirect(safeReturn);
});
})(req, res, next);
diff --git a/src/routes/servers.js b/src/routes/servers.js
index 1b62a26..e08a67f 100644
--- a/src/routes/servers.js
+++ b/src/routes/servers.js
@@ -8,13 +8,14 @@ const blockWhileProvisioning = require('../middleware/blockWhileProvisioning');
const { isEditableFile, listDirectory, MAX_TEXT_BYTES } = require('../utils/fileBrowser');
const { formatSize, getDirectorySize } = require('../utils/resourceStats');
const { serversDb, SERVERS_DIR } = require('../db');
-const { parseServerProperties } = require('../mc/serverProperties');
+const { parseServerProperties, modeNameOf } = require('../mc/serverProperties');
const { PROPERTY_META, GROUPS } = require('../mc/propertyMeta');
const { WORLD_PRESETS, LEGACY_WORLD_TYPES, worldTypesFor, worldTypeFor } = require('../mc/worldTypes');
const { log } = require('../utils/log');
const { hasIcon } = require('../utils/serverIcon');
const { isPathInside } = require('../utils/pathSafety');
const { getDistinctGroups } = require('../utils/serverGroups');
+const { portsInUse } = require('../utils/portClash');
// GET /servers/create — Server creation form
router.get('/servers/create', ensureAuth, async (req, res) => {
@@ -26,6 +27,8 @@ router.get('/servers/create', ensureAuth, async (req, res) => {
groupNames: await getDistinctGroups().catch(() => []),
// The version is picked on the page, so both World Type lists go along
worldTypeLists: { presets: WORLD_PRESETS, legacy: LEGACY_WORLD_TYPES },
+ // A new server listens on every address, so every server's port counts
+ portsInUse: await portsInUse(),
messages: req.session.flash || {},
csrfToken: res.locals.csrfToken
});
@@ -113,6 +116,8 @@ router.get('/servers/:id/edit', ensureAuth, blockWhileProvisioning, async (req,
? (worldTypeFor(props['level-type'], server.version) || props['level-type'])
: worldTypesFor(server.version)[0].value,
hasIcon: hasIcon(server.id),
+ // For the Port and Duplicate fields; a copy keeps this server-ip too
+ portsInUse: await portsInUse({ serverIp: props['server-ip'] || '' }),
user: req.user,
groupNames: await getDistinctGroups().catch(() => []),
messages: req.session.flash || {},
@@ -135,6 +140,11 @@ router.get('/servers/:id/properties', ensureAuth, blockWhileProvisioning, async
const serverDir = path.join(SERVERS_DIR, server.id);
const properties = parseServerProperties(serverDir);
+ // A server before 1.14 holds these as numbers; the selects show names,
+ // and a save writes the numbers back (POST /api/v1/servers/:id/properties)
+ for (const key of ['gamemode', 'difficulty']) {
+ if (key in properties) properties[key] = modeNameOf(key, properties[key]);
+ }
res.render('servers/properties', {
title: server.name + ' Properties',
diff --git a/src/security.js b/src/security.js
index 0e4ebb4..d624ff0 100644
--- a/src/security.js
+++ b/src/security.js
@@ -87,10 +87,16 @@ function csrfValidate(req, res, next) {
next();
}
-// Rate limiter for login endpoint (using express-rate-limit)
+// Rate limiter for login endpoint (using express-rate-limit). Only failed
+// sign-ins count: every attempt is counted as it arrives, so parallel guesses
+// are still capped, and a successful one is taken back off once it has
+// answered. A success and a failure are both 302s, so POST /login marks a
+// success in res.locals for requestWasSuccessful to read.
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
+ skipSuccessfulRequests: true,
+ requestWasSuccessful: (_req, res) => res.locals.loginSucceeded === true,
standardHeaders: true,
legacyHeaders: false,
handler: (_req, res) => {
diff --git a/src/utils/download.js b/src/utils/download.js
index 3eb8e23..7e1497a 100644
--- a/src/utils/download.js
+++ b/src/utils/download.js
@@ -149,7 +149,8 @@ class DownloadReporter {
if (!this.active) return;
try {
this.serverManager.broadcastOperation(
- this.serverId, 'download', 'failed', `${this.label}: ${message}`
+ this.serverId, 'download', 'failed', `${this.label}: ${message}`,
+ { token: this.token, label: this.label }
);
} catch (err) {
log('warn', `Download report failed: ${err.message}`);
diff --git a/src/utils/portClash.js b/src/utils/portClash.js
new file mode 100644
index 0000000..2b4d935
--- /dev/null
+++ b/src/utils/portClash.js
@@ -0,0 +1,81 @@
+const path = require('path');
+const { serversDb, SERVERS_DIR } = require('../db');
+const { parseServerProperties } = require('../mc/serverProperties');
+const { log } = require('./log');
+
+// Two Minecraft servers can't listen on the same port at the same address.
+// server-ip is blank unless someone sets it, and blank means every address,
+// so a shared port only works when both servers are bound to different
+// specific addresses.
+function addressesOverlap(ipA, ipB) {
+ const a = String(ipA || '').trim();
+ const b = String(ipB || '').trim();
+ return !a || !b || a === b;
+}
+
+/** The address a server's server.properties binds it to ('' for every one). */
+function serverIpOf(serverId) {
+ return parseServerProperties(path.join(SERVERS_DIR, serverId))['server-ip'] || '';
+}
+
+/**
+ * The other servers saved with `port` at an address overlapping `serverIp`,
+ * whatever state they're in.
+ * @param {number|string} port
+ * @param {{ excludeId?: string|null, serverIp?: string }} [opts]
+ */
+async function serversSharingPort(port, { excludeId = null, serverIp = '' } = {}) {
+ const rows = await serversDb.all();
+ return rows
+ .map(row => row?.value)
+ .filter(s => s && s.id && s.id !== excludeId && Number(s.port) === Number(port))
+ .filter(s => !serverIp || addressesOverlap(serverIp, serverIpOf(s.id)));
+}
+
+/**
+ * Every server's port, for a form to warn about a shared one as it's typed
+ * (public/js/app.js watchPortClash). Servers bound to an address that can't
+ * overlap `serverIp` are left out, as they are from the warnings.
+ * @returns {Promise>}
+ */
+async function portsInUse({ serverIp = '' } = {}) {
+ try {
+ const rows = await serversDb.all();
+ return rows
+ .map(row => row?.value)
+ .filter(s => s && s.id && Number.isInteger(Number(s.port)))
+ .filter(s => !serverIp || addressesOverlap(serverIp, serverIpOf(s.id)))
+ .map(s => ({ id: s.id, name: s.name, port: Number(s.port) }));
+ } catch (err) {
+ log('warn', `Listing ports in use failed: ${err.message}`);
+ return [];
+ }
+}
+
+// Each name once: an import or a copy can share its source's name
+function listNames(servers) {
+ const names = [...new Set(servers.map(s => `"${s.name}"`))];
+ return names.length === 1 ? names[0] : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
+}
+
+/**
+ * The warnings to return when a server is saved (created, edited, duplicated,
+ * imported) with a port other servers use too. Sharing one isn't refused: only
+ * one of them can run at a time, which is a fair way to keep a copy of a
+ * server beside it. Starting one while another holds the port is what's
+ * refused (ServerManager.startServer). A warning never fails the save it
+ * comes with, so a failure to work one out is logged and nothing is returned.
+ * @returns {Promise}
+ */
+async function portClashWarnings(port, opts) {
+ try {
+ const others = await serversSharingPort(port, opts);
+ if (others.length === 0) return [];
+ return [`Port ${port} is also used by ${listNames(others)}. Only one of them can run at a time.`];
+ } catch (err) {
+ log('warn', `Port clash check failed: ${err.message}`);
+ return [];
+ }
+}
+
+module.exports = { addressesOverlap, serverIpOf, serversSharingPort, portClashWarnings, portsInUse };
diff --git a/test/ci/browser.mjs b/test/ci/browser.mjs
index 09ec24b..2d1ed45 100644
--- a/test/ci/browser.mjs
+++ b/test/ci/browser.mjs
@@ -76,6 +76,32 @@ if (signedIn && vanilla && fabric) {
});
console.log('Flows');
+ await run.step('warns beside a port field when another server uses the port', async () => {
+ // Other scripts sharing this panel may have servers on these ports
+ // too, so each check looks for its own server's name
+ const hint = (id) => page.eval(`(() => {
+ const h = document.querySelector('[data-port-clash-for="${id}"]');
+ return h && !h.classList.contains('d-none') ? h.textContent : '';
+ })()`);
+ const warning = /^Port \d+ is also used by .+\. Only one of them can run at a time\.$/;
+ await page.goto('/servers/create');
+ let text = await hint('port');
+ assert(warning.test(text) && text.includes('"CI Browser"'), `create page on 25565: "${text}"`);
+ await page.type('#port', '25566');
+ text = await hint('port');
+ assert(text.includes('"CI Browser Fabric"') && !text.includes('"CI Browser"'), `create page on 25566: "${text}"`);
+ await page.type('#port', '25599');
+ assert(await hint('port') === '', `create page on 25599: "${await hint('port')}"`);
+
+ await page.goto(`/servers/${vanilla.id}/edit`);
+ text = await hint('port');
+ assert(!text.includes('"CI Browser"'), `Settings counts the server's own port: "${text}"`);
+ // Duplicate offers the next port up, which is the Fabric server's
+ text = await hint('dup-port');
+ assert(warning.test(text) && text.includes('"CI Browser Fabric"'), `Duplicate on 25566: "${text}"`);
+ await assertClean('the create and Settings pages');
+ });
+
let created = null;
await run.step('creates a server from the create page', async () => {
await page.goto('/servers/create');
diff --git a/test/ci/lib.mjs b/test/ci/lib.mjs
index 922ed4e..6f905a9 100644
--- a/test/ci/lib.mjs
+++ b/test/ci/lib.mjs
@@ -236,8 +236,9 @@ export async function bootstrapPanel({ username, password } = CI_USER) {
return { key: await mintApiKey(session), session, page: (path) => pageOf(session, path) };
}
-// Log in to a panel that's already set up (each attempt counts towards the
-// login rate limit of 5 per 15 minutes) and mint an API key.
+// Log in to a panel that's already set up and mint an API key. Only failed
+// sign-ins count towards the login rate limit of 5 per 15 minutes, but
+// before 1.2.3 every one did, and the upgrade test signs in to older releases.
export async function loginPanel({ username, password } = CI_USER) {
const session = createSession();
await session.get('/login');
@@ -251,7 +252,8 @@ export async function loginPanel({ username, password } = CI_USER) {
// Set up a fresh panel, or sign in to one another script already set up.
// With CRAFTBOX_SESSION_FILE, scripts run one after another share a single
// signed-in session and API key through that file instead of each signing
-// in, since the login rate limit allows only 5 sign-ins in 15 minutes.
+// in. That saves a sign-in per script, which mattered before 1.2.3, when the
+// login rate limit counted successful ones and allowed 5 in 15 minutes.
export async function openPanel(credentials = CI_USER) {
const file = process.env.CRAFTBOX_SESSION_FILE;
if (file && fs.existsSync(file)) {
diff --git a/test/ci/operations.mjs b/test/ci/operations.mjs
index 32225d2..c3f147d 100644
--- a/test/ci/operations.mjs
+++ b/test/ci/operations.mjs
@@ -3,9 +3,11 @@
// can run them side by side:
//
// - lifecycle: a version upgrade (and the refusals around it), restart,
-// restart behind a backup, kill, the guards on a running server, crash
+// restart behind a backup, kill, the guards on a running server, a second
+// server sharing its port (warned about, never run beside it), crash
// detection and auto-restart (the JVM killed inside the container), a
-// restore-point settings save, and auto-start across a panel restart;
+// restore-point settings save, and a crash and auto-start across a panel
+// restart;
// - backups: backup, download, restore, restore-point Properties saves,
// backing up and restoring a running server, and the schedule and
// retention settings.
@@ -90,7 +92,7 @@ async function lifecycle() {
await run.step('offers vanilla no build upgrade, and says why', async () => {
const res = await api('GET', `/servers/${id}/check-upgrade`);
assertStatus(res, 200, 'check-upgrade');
- assert(res.body.upgradeAvailable === false && res.body.reason, JSON.stringify(res.body));
+ assert(res.body.upgradeAvailable === false && /no build tracking/i.test(res.body.reason), JSON.stringify(res.body));
});
await run.step(`upgrades it to ${latest}, refusing bad versions and downgrades`, async () => {
assertStatus(await api('POST', `/servers/${id}/upgrade-jar`, { version: '../1.21' }), 400, 'bad version');
@@ -139,6 +141,46 @@ async function lifecycle() {
assert(wrong.length === 0, wrong.join('\n'));
assert((await server(id)).state === 'running', 'no longer running');
});
+ await run.step('warns when a port is shared, and won\'t run two servers on one', async () => {
+ const shares = (res) => res.body?.warnings?.length === 1 && res.body.warnings[0].includes('"CI Lifecycle"');
+ let other = null;
+ try {
+ let created = null;
+ other = (await provisionServer(api, {
+ name: 'CI Port Share', serverType: 'vanilla', version: latest, port: 25565, memory: 1024, eula: true
+ }, { timeoutMs: PROVISION_TIMEOUT, label: 'vanilla', onCreated: (res) => { created = res; } })).id;
+ assert(shares(created), `create: ${JSON.stringify(created.body.warnings)}`);
+ const refused = await api('POST', `/servers/${other}/start`);
+ assert(refused.status === 409 && /in use by "CI Lifecycle"/.test(refused.body?.error), `start: ${refused.status} ${refused.text}`);
+ assert((await server(other)).state === 'stopped', `left ${(await server(other)).state}`);
+
+ // Only a change of port warns
+ const edit = (port) => api('POST', `/servers/${other}/edit`, { name: 'CI Port Share', port, memory: 1024 });
+ let res = await edit(25590);
+ assert(res.status === 200 && res.body.warnings?.length === 0, `edit away: ${res.status} ${res.text}`);
+ res = await edit(25565);
+ assert(res.status === 200 && shares(res), `edit back: ${res.status} ${res.text}`);
+ res = await edit(25565);
+ assert(res.status === 200 && res.body.warnings?.length === 0, `unchanged port: ${res.text}`);
+ res = await api('POST', `/servers/${other}/properties`, { 'server-port': 25590 });
+ assert(res.status === 200 && res.body.warnings?.length === 0, `properties away: ${res.status} ${res.text}`);
+
+ // Moved onto a running server's port while running, a server saves
+ // (with a warning) but won't restart onto it, and keeps running
+ await start(other);
+ res = await api('POST', `/servers/${id}/edit`, { name: 'CI Lifecycle', port: 25590, memory: 1536 });
+ assert(res.status === 200 && res.body.warnings?.some((w) => w.includes('"CI Port Share"')), `edit onto it: ${res.status} ${res.text}`);
+ res = await api('POST', `/servers/${id}/restart`);
+ assert(res.status === 409 && /in use by "CI Port Share"/.test(res.body?.error), `restart: ${res.status} ${res.text}`);
+ assert((await server(id)).state === 'running', 'the refused restart stopped it');
+ assertStatus(await api('POST', `/servers/${id}/edit`, { name: 'CI Lifecycle', port: 25565, memory: 1536 }), 200, 'edit back');
+ } finally {
+ if (other) {
+ if ((await server(other)).state === 'running') await stop(other);
+ await api('DELETE', `/servers/${other}`);
+ }
+ }
+ });
await run.step('restarts behind a backup', async () => {
const since = socket.mark();
assertStatus(await api('POST', `/servers/${id}/restart`, { backup: true }), 202, 'restart with backup');
@@ -169,7 +211,7 @@ async function lifecycle() {
const crash = 'detects a crash, and keeps its details off the status page';
const autoRestart = 'restarts itself after a crash when auto-restart is on';
const signalled = 'detects a JVM killed by a signal (as the kernel\'s OOM killer does)';
- const autoStart = 'auto-starts across a panel restart only when asked to';
+ const autoStart = 'keeps a crash, and auto-starts only when asked to, across a panel restart';
if (!CONTAINER) {
for (const name of [crash, autoRestart, signalled, autoStart]) run.skip(name, 'CRAFTBOX_CONTAINER not set');
// Leave the port free for whatever uses this panel next
@@ -199,12 +241,12 @@ async function lifecycle() {
await run.step(signalled, async () => {
const since = socket.mark();
signalJava('KILL');
- await socket.waitFor((m) => m.type === 'state' && m.serverId === id && ['crashed', 'stopped'].includes(m.state), { since, what: 'the exit' });
- const s = await server(id);
- assert(s.state === 'crashed' || s.state === 'starting' || s.state === 'running',
- `recorded as a clean stop (state ${s.state}, exitCode ${s.exitCode}, crashReason ${s.crashReason}), so auto-restart never fires`);
+ const exit = await socket.waitFor((m) => m.type === 'state' && m.serverId === id && ['crashed', 'stopped'].includes(m.state), { since, what: 'the exit' });
+ assert(exit.state === 'crashed' && exit.crashReason === 'signal' && exit.exitCode === null,
+ `recorded as ${exit.state} (exitCode ${exit.exitCode}, crashReason ${exit.crashReason}), so auto-restart never fires`);
+ await socket.waitFor((m) => m.type === 'event' && m.serverId === id && m.eventType === 'crashed', { since, what: 'crashed event' });
await socket.waitFor((m) => m.type === 'state' && m.serverId === id && m.state === 'running', { since, what: 'the auto-restart', timeoutMs: START_TIMEOUT });
- }, { knownIssue: 'a JVM that dies from a signal exits with code null, which ServerProcess treats as a clean stop' });
+ });
await run.step(autoStart, async () => {
const restartPanel = async () => {
socket.close();
@@ -213,11 +255,20 @@ async function lifecycle() {
socket = await openSocket({ cookie: session.cookie });
};
assertStatus(await api('POST', `/servers/${id}/autostart`, { enabled: false }), 200, 'autostart off');
+ // Crashed going in, which the restart must keep (it used to become
+ // a clean stop, losing the crash banner)
+ assertStatus(await api('POST', `/servers/${id}/autorestart`, { enabled: false }), 200, 'autorestart off');
+ signalJava('TERM');
+ await waitForState(api, id, ['crashed'], { timeoutMs: 60_000 });
await restartPanel();
await sleep(5000);
const after = await server(id);
- assert(after.state === 'stopped', `with auto-start off it came back ${after.state}`);
+ assert(after.state === 'crashed' && after.crashReason === 'exit_code' && after.exitCode === 143,
+ `with auto-start off it came back ${after.state} (crashReason ${after.crashReason}, exitCode ${after.exitCode})`);
assert(after.name === 'CI Lifecycle' && after.version === latest, 'the record changed across the restart');
+ const html = (await session.get(`/servers/${id}`)).text;
+ assert(/id="crash-banner"\s+style="display: flex;"/.test(html) && /Exit code: 143<\/strong>/.test(html),
+ 'no crash banner on the server page after the restart');
assertStatus(await api('POST', `/servers/${id}/autostart`, { enabled: true }), 200, 'autostart on');
await restartPanel();
@@ -291,9 +342,9 @@ async function backupsSection() {
await stop(id);
});
await run.step('keeps the backup schedule within range', async () => {
- const schedule = async (body) => {
+ const schedule = async (body, status = 200) => {
const res = await api('POST', `/servers/${id}/backup-schedule`, body);
- assert(res.status === 200 || res.status === 400, `${JSON.stringify(body)}: HTTP ${res.status}`);
+ assert(res.status === status, `${JSON.stringify(body)}: HTTP ${res.status}, expected ${status}`);
return (await server(id)).backupSchedule;
};
let s = await schedule({ enabled: true, intervalHours: 12, countdownMinutes: 3 });
@@ -301,24 +352,30 @@ async function backupsSection() {
const res = await api('POST', `/servers/${id}/backup-schedule`, { enabled: true, intervalHours: 12, countdownMinutes: 3 });
const next = Date.parse(res.body.nextBackupAt) - Date.now();
assert(next > 11 * 3600_000 && next <= 12 * 3600_000, `next backup in ${Math.round(next / 60_000)} minutes`);
- for (const bad of [{ intervalHours: 0 }, { intervalHours: 169 }, { intervalHours: -5 }, { countdownMinutes: 0 }, { countdownMinutes: 31 }]) {
- s = await schedule(bad);
- assert(s.intervalHours === 12 && s.countdownMinutes === 3, `${JSON.stringify(bad)} was stored: ${JSON.stringify(s)}`);
+ // Refused whole, so the valid half of the last one isn't saved either
+ for (const bad of [{ intervalHours: 0 }, { intervalHours: 169 }, { intervalHours: -5 }, { intervalHours: '12abc' },
+ { intervalHours: 1.5 }, { countdownMinutes: 0 }, { countdownMinutes: 31 }, { countdownMinutes: 'soon' },
+ { enabled: 'yes' }, { intervalHours: 6, countdownMinutes: 31 }]) {
+ s = await schedule(bad, 400);
+ assert(s.enabled === true && s.intervalHours === 12 && s.countdownMinutes === 3, `${JSON.stringify(bad)} was stored: ${JSON.stringify(s)}`);
}
+ s = await schedule({ intervalHours: '6', countdownMinutes: null });
+ assert(s.intervalHours === 6 && s.countdownMinutes === 3, `a digit string, and null for "keep": ${JSON.stringify(s)}`);
s = await schedule({ enabled: false });
assert(s.enabled === false, 'not disabled');
assert((await api('POST', `/servers/${id}/backup-schedule`, {})).body.nextBackupAt === null, 'a disabled schedule has a next backup');
});
await run.step('keeps retention within range and applies it', async () => {
- const retention = async (body) => {
+ const retention = async (body, status = 200) => {
const res = await api('POST', `/servers/${id}/backup-retention`, body);
- assert(res.status === 200 || res.status === 400, `${JSON.stringify(body)}: HTTP ${res.status}`);
+ assert(res.status === status, `${JSON.stringify(body)}: HTTP ${res.status}, expected ${status}`);
return (await server(id)).backupSchedule;
};
let s = await retention({ retentionCount: 2, retentionDays: 0 });
assert(s.retentionCount === 2 && s.retentionDays === 0, JSON.stringify(s));
- for (const bad of [{ retentionCount: -1 }, { retentionCount: 101 }, { retentionDays: 366 }, { retentionDays: -1 }]) {
- s = await retention(bad);
+ for (const bad of [{ retentionCount: -1 }, { retentionCount: 101 }, { retentionCount: '3x' }, { retentionDays: 366 },
+ { retentionDays: -1 }, { retentionDays: 0.5 }, { retentionCount: 4, retentionDays: 366 }]) {
+ s = await retention(bad, 400);
assert(s.retentionCount === 2 && s.retentionDays === 0, `${JSON.stringify(bad)} was stored: ${JSON.stringify(s)}`);
}
for (const name of ['CI retention 1', 'CI retention 2']) {
diff --git a/test/ci/server-start.mjs b/test/ci/server-start.mjs
index 772a637..60c1cab 100644
--- a/test/ci/server-start.mjs
+++ b/test/ci/server-start.mjs
@@ -1,5 +1,6 @@
// Server start test: for each server type, create a server through the API,
-// start it, wait for the "Done" line, round-trip a console command, stop it
+// start it, wait for the "Done" line, check it plays the game mode and
+// difficulty it was given, round-trip a console command, stop it
// and delete it.
//
// CRAFTBOX_URL=http://localhost:6464 node test/ci/server-start.mjs paper fabric
@@ -88,7 +89,8 @@ for (const target of targets) {
const label = `${type} ${version}`;
await run.step(`${label}: provision`, async () => {
- const body = { name: `CI ${type}`, serverType: type, version, port: 25565, memory: 2048, eula: true };
+ // Not the defaults, so a version that can't read them shows it
+ const body = { name: `CI ${type}`, serverType: type, version, port: 25565, memory: 2048, eula: true, gamemode: 'creative', difficulty: 'hard' };
if (type === 'custom') {
body.customJarUrl = await mojangServerJar(version);
delete body.version;
@@ -121,10 +123,21 @@ for (const target of targets) {
});
}
+ await run.step(`${label}: play the game mode and difficulty it was given`, async () => {
+ // A value the server couldn't read is rewritten as the default it
+ // used instead (Survival, Easy) by the time it's up. Before 1.14
+ // the file holds numbers.
+ const props = (await api('GET', `/servers/${id}/file?path=server.properties`)).body?.file?.content || '';
+ const value = (key) => new RegExp(`^${key}=(.*)$`, 'm').exec(props)?.[1]?.trim();
+ assert(['1', 'creative'].includes(value('gamemode')) && ['3', 'hard'].includes(value('difficulty')),
+ `gamemode=${value('gamemode')}, difficulty=${value('difficulty')}`);
+ });
+
await run.step(`${label}: answer a console command`, async () => {
assertStatus(await api('POST', `/servers/${id}/command`, { command: 'list' }), 200, 'command');
- // "There are 0 of a max of 20 players online", or "0/20" before 1.13
- await waitForConsole(api, id, /There are \d+(\/\d+| of a max of \d+)? players online/i, 30_000);
+ // "There are 0 of a max of 20 players online", "0 of a max 20" from
+ // 1.13 to 1.15, or "0/20" before 1.13
+ await waitForConsole(api, id, /There are \d+(\/\d+| of a max (of )?\d+)? players online/i, 30_000);
});
await run.step(`${label}: stop cleanly`, async () => {
diff --git a/test/ci/transfers.mjs b/test/ci/transfers.mjs
index d5f84b9..df89377 100644
--- a/test/ci/transfers.mjs
+++ b/test/ci/transfers.mjs
@@ -167,10 +167,10 @@ await run.step('reports plugin downloads over the WebSocket', async () => {
assert(msg.payload?.token === 'ci-all', JSON.stringify(msg.payload));
since = socket.mark();
assertStatus(await fetchBytes(`${p}/plugins/download?file=missing.jar&dl=ci-missing`), 404, 'a missing plugin');
- // A failure carries its reason as a string, naming the file but not the
- // token (public/js/download.js allows for that)
+ // A failure carries its reason as a string naming the file, and the token
+ // like every other report, so the page can tell which download failed
msg = await socket.waitFor((m) => m.type === 'operation' && m.operation === 'download' && m.status === 'failed', { since, what: 'the failed download' });
- assert(/missing\.jar/.test(msg.error), JSON.stringify(msg));
+ assert(/missing\.jar/.test(msg.error) && msg.payload?.token === 'ci-missing', JSON.stringify(msg));
assertStatus(await api('POST', `${p}/plugins/delete-all`), 200, 'delete-all');
assert((await api('GET', `${p}/plugins`)).body.files.length === 0, 'plugins left after delete-all');
});
@@ -279,6 +279,20 @@ await run.step('duplicates a server, with and without its world', async () => {
const whole = await copy('CI Copy World', 25571, true);
assert(await readFile(whole, 'world/ci.txt') === 'a world\n', 'the world was left behind');
assertStatus(await api('POST', `${v}/duplicate`, { name: 'bad/name', port: 25572 }), 400, 'a bad name');
+
+ // A world under its own level-name, as Paper splits it into three
+ // folders, is left behind just the same
+ assertStatus(await api('POST', `${v}/properties`, { 'level-name': 'ci-realm' }), 200, 'level-name');
+ const realm = ['ci-realm', 'ci-realm_nether', 'ci-realm_the_end'];
+ for (const dir of realm) {
+ assertStatus(await api('POST', `${v}/files/mkdir`, { name: dir }), [200, 201], dir);
+ assertStatus(await api('POST', `${v}/edit-file`, { filePath: `${dir}/ci.txt`, content: 'a realm\n' }), 200, `${dir} file`);
+ }
+ const named = await copy('CI Copy Named World', 25573, false);
+ const copied = [];
+ for (const dir of realm) if (await exists(named, `${dir}/ci.txt`)) copied.push(dir);
+ assert(copied.length === 0, `came along without includeWorld: ${copied.join(', ')}`);
+ assertStatus(await api('POST', `${v}/properties`, { 'level-name': 'world' }), 200, 'level-name back');
});
await run.step('saves a template from a server and deletes it', async () => {
assertStatus(await api('POST', '/templates', { serverId: vanilla.id, name: 'bad/name' }), 400, 'a bad name');
diff --git a/test/ci/web.mjs b/test/ci/web.mjs
index d0736fc..3c2b684 100644
--- a/test/ci/web.mjs
+++ b/test/ci/web.mjs
@@ -305,9 +305,22 @@ await run.step('offers players the client-side mods as a zip', async () => {
assert(page.includes(`/status/${fabric.id}/mods`), 'status page has no mods download');
});
-// Last: from here on this address can't sign in for 15 minutes
console.log('Rate limiting');
-await run.step('refuses a sixth sign-in inside 15 minutes, even with the right password', async () => {
+await run.step('doesn\'t count successful sign-ins toward the limit', async () => {
+ // Each attempt is counted as it arrives and a success is taken back off
+ // after, so every one of these sees the same number left
+ const left = [];
+ for (let attempt = 0; attempt < 6; attempt++) {
+ const visitor = createSession();
+ await visitor.get('/login');
+ const res = await visitor.post('/login', CI_USER);
+ assert(res.status === 302 && res.location === '/dashboard', `sign-in ${attempt + 1}: ${res.status} → ${res.location}`);
+ left.push(res.headers.get('ratelimit-remaining'));
+ }
+ assert(new Set(left).size === 1, `attempts left after each: ${left.join(', ')}`);
+});
+// Last: from here on this address can't sign in for 15 minutes
+await run.step('refuses a sign-in after five failed ones inside 15 minutes, even with the right password', async () => {
const visitor = createSession();
await visitor.get('/login');
let remaining = null;
diff --git a/test/unit/serverProperties.test.js b/test/unit/serverProperties.test.js
index aab08d8..ffe8222 100644
--- a/test/unit/serverProperties.test.js
+++ b/test/unit/serverProperties.test.js
@@ -4,7 +4,7 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
const {
- writeServerProperties, writeEula, parseServerProperties, updateServerProperties, DEFAULT_PROPERTIES
+ writeServerProperties, writeEula, parseServerProperties, updateServerProperties, DEFAULT_PROPERTIES, modeNameOf
} = require('../../src/mc/serverProperties');
const { PROPERTY_META } = require('../../src/mc/propertyMeta');
@@ -165,6 +165,43 @@ test('update does nothing when there is no file', (t) => {
assert.equal(fs.existsSync(path.join(dir, 'server.properties')), false);
});
+// Before 1.14 Minecraft can't read a game mode or difficulty name: it plays
+// Survival on Easy and rewrites the file with those numbers
+test('game mode and difficulty are written as numbers before 1.14', (t) => {
+ const modes = (dir) => { const p = parseServerProperties(dir); return `${p.gamemode} ${p.difficulty}`; };
+ const cases = {
+ '1.8.9': '1 3', '1.12.2': '1 3', '1.13.2': '1 3', '19w14b': '1 3',
+ '1.14': 'creative hard', '1.20.4': 'creative hard', '26.3': 'creative hard', '': 'creative hard'
+ };
+ for (const [version, expected] of Object.entries(cases)) {
+ const dir = tempDir(t);
+ writeServerProperties(dir, { gamemode: 'creative', difficulty: 'hard' }, { version });
+ assert.equal(modes(dir), expected, `new file on ${version || 'no version'}`);
+ }
+
+ const dir = tempDir(t);
+ writeServerProperties(dir, {}, { version: '1.12.2' });
+ assert.equal(modes(dir), '0 2', 'the defaults');
+
+ write(dir, 'gamemode=survival\ndifficulty=easy\nmotd=A pack\n');
+ writeServerProperties(dir, { gamemode: 'adventure', difficulty: 'peaceful' }, { mergeExisting: true, version: '1.12.2' });
+ assert.equal(modes(dir), '2 0', 'merged into a pack\'s file');
+
+ updateServerProperties(dir, { gamemode: 'spectator', difficulty: 'normal' }, { version: '1.7.10' });
+ assert.equal(modes(dir), '3 2', 'an update given the version');
+ updateServerProperties(dir, { gamemode: 'creative' });
+ assert.equal(modes(dir), 'creative 2', 'an update not given one writes what it\'s given');
+});
+
+test('modeNameOf reads either form as the name', () => {
+ assert.equal(modeNameOf('difficulty', '2'), 'normal');
+ assert.equal(modeNameOf('difficulty', ' 0 '), 'peaceful');
+ assert.equal(modeNameOf('gamemode', '3'), 'spectator');
+ assert.equal(modeNameOf('gamemode', 'creative'), 'creative');
+ assert.equal(modeNameOf('gamemode', '7'), '7');
+ assert.equal(modeNameOf('motd', '1'), '1');
+});
+
test('writeEula accepts the EULA', (t) => {
const dir = tempDir(t);
writeEula(dir);
diff --git a/test/unit/verifyChecksum.test.js b/test/unit/verifyChecksum.test.js
new file mode 100644
index 0000000..10105f5
--- /dev/null
+++ b/test/unit/verifyChecksum.test.js
@@ -0,0 +1,60 @@
+// Forge and NeoForge installers are verified against a checksum sidecar on
+// Maven before they run. Not every installer has every sidecar (Forge's
+// 1.12.2 builds have no .sha256), so these drive the fallback with Maven
+// stubbed.
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const crypto = require('crypto');
+const { verifyMavenChecksum, ChecksumMismatchError } = require('../../src/mc/serverTypes/_verifyChecksum');
+
+const URL = 'https://maven.example/forge-installer.jar';
+const INSTALLER = Buffer.from('installer bytes');
+const hash = (algo) => crypto.createHash(algo).update(INSTALLER).digest('hex');
+
+// Answer each sidecar URL from `sidecars` ({'.sha1': body or HTTP status}),
+// recording what was asked for
+function stubMaven(t, sidecars) {
+ const asked = [];
+ t.mock.method(globalThis, 'fetch', async (url) => {
+ const ext = String(url).slice(URL.length);
+ asked.push(ext);
+ const answer = sidecars[ext] ?? 404;
+ if (typeof answer === 'number') return { ok: answer < 400, status: answer, text: async () => '' };
+ return { ok: true, status: 200, text: async () => answer };
+ });
+ return asked;
+}
+
+test('uses the SHA-256 sidecar when there is one', async (t) => {
+ const asked = stubMaven(t, { '.sha256': hash('sha256'), '.sha1': hash('sha1') });
+ assert.equal(await verifyMavenChecksum(INSTALLER, URL, 'Forge'), 'sha256');
+ assert.deepEqual(asked, ['.sha256']);
+});
+
+test('falls back to SHA-1, then MD5, when the stronger sidecars are missing', async (t) => {
+ let asked = stubMaven(t, { '.sha1': `${hash('sha1')} forge-installer.jar\n`, '.md5': hash('md5') });
+ assert.equal(await verifyMavenChecksum(INSTALLER, URL, 'Forge'), 'sha1');
+ assert.deepEqual(asked, ['.sha256', '.sha1']);
+
+ t.mock.restoreAll();
+ asked = stubMaven(t, { '.md5': hash('md5') });
+ assert.equal(await verifyMavenChecksum(INSTALLER, URL, 'Forge'), 'md5');
+ assert.deepEqual(asked, ['.sha256', '.sha1', '.md5']);
+});
+
+test('refuses when no sidecar is published', async (t) => {
+ stubMaven(t, {});
+ await assert.rejects(verifyMavenChecksum(INSTALLER, URL, 'Forge'), /no checksum published/);
+});
+
+test('refuses instead of falling back when a sidecar fails with anything but 404', async (t) => {
+ const asked = stubMaven(t, { '.sha256': 503, '.sha1': hash('sha1') });
+ await assert.rejects(verifyMavenChecksum(INSTALLER, URL, 'Forge'), /HTTP 503/);
+ assert.deepEqual(asked, ['.sha256']);
+});
+
+test('refuses a mismatch in the sidecar it used, without trying a weaker one', async (t) => {
+ const asked = stubMaven(t, { '.sha1': '0'.repeat(40), '.md5': hash('md5') });
+ await assert.rejects(verifyMavenChecksum(INSTALLER, URL, 'Forge'), ChecksumMismatchError);
+ assert.deepEqual(asked, ['.sha256', '.sha1']);
+});
diff --git a/views/partials/serverCard.ejs b/views/partials/serverCard.ejs
index f64b344..cd4e15a 100644
--- a/views/partials/serverCard.ejs
+++ b/views/partials/serverCard.ejs
@@ -18,6 +18,7 @@ if (server.crashReason && (server.crashReason.startsWith('Provisioning failed')
crashFooterText = 'Setup failed: ' + server.crashReason.replace(/^(Provisioning failed|Modpack install failed): /, '');
} else {
if (server.crashReason === 'oom') crashFooterText += ' (Out of Memory)';
+ if (server.crashReason === 'signal') crashFooterText += ' (Process Killed)';
if (server.exitCode != null) crashFooterText += ' — Exit code: ' + server.exitCode;
}
%>
diff --git a/views/servers/create.ejs b/views/servers/create.ejs
index 0d4a472..1a150a2 100644
--- a/views/servers/create.ejs
+++ b/views/servers/create.ejs
@@ -147,7 +147,8 @@
diff --git a/views/servers/edit.ejs b/views/servers/edit.ejs
index 4588a98..ea0dbb8 100644
--- a/views/servers/edit.ejs
+++ b/views/servers/edit.ejs
@@ -88,7 +88,11 @@ const _gate = 'stopped crashed'; %>