diff --git a/Dockerfile b/Dockerfile index dc5756e..1034ba6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -74,6 +74,21 @@ RUN node /tmp/patch-custom-game.js /app COPY docker/patch-auto-verify-email.js /tmp/patch-auto-verify-email.js RUN node /tmp/patch-auto-verify-email.js /app +# Local patch: make the Steam/MAS auth-id requirement for tutorial and class +# rewards optional (docker/patch-tutorial-reward-auth.js). Controlled at runtime +# by options.tutorial_reward_requires_auth_id. +COPY docker/patch-tutorial-reward-auth.js /tmp/patch-tutorial-reward-auth.js +RUN node /tmp/patch-tutorial-reward-auth.js /app + +# Local patch: make the merchant and non-merchant multi-account limits +# configurable (docker/patch-multi-account-limits.js). Upstream hard-codes one +# merchant per IP/owner and three non-merchant characters per IP/owner in the +# backend enforcement cron, while login uses options.ip_limit/character_limit. +# The patch makes both places use options.merchant_ip_limit, +# options.merchant_owner_limit, options.ip_limit and options.character_limit. +COPY docker/patch-multi-account-limits.js /tmp/patch-multi-account-limits.js +RUN node /tmp/patch-multi-account-limits.js /app + FROM ${NODE_IMAGE} AS runtime diff --git a/README.md b/README.md index fd9d954..a4215ad 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,12 @@ und Game-Server. Bei späteren Starts überspringt sich der Seed selbst. [Zum Entwickeln]“** klicken, E-Mail + Passwort eingeben. Danach Charakter erstellen und auf den Charakter klicken – man landet direkt im Spiel. +**Charakter Limits anheben** Folgendes Kommando setzt für alle existierenden Spieler das Charakter Limit auf 18. +```sh +docker compose exec al-mongo mongosh adventureland --eval \ + 'db.user.updateMany({}, { $set: { "info.slots": 18 } })' +``` + **Admin werden:** Die Admin-Seiten (`/admin/executor`, `/admin/renderer`) sind standardmäßig gesperrt. `is_admin` in `common/admin.js` prüft das Feld `user.admin`; setzen lässt es sich direkt in MongoDB: @@ -40,6 +46,37 @@ Alternativ in `config/options.js` `unsecure_admin: true` setzen – dann gilt **jeder** Besucher als Admin (`/admin/executor` erlaubt Code-Ausführung), daher nur bei rein lokalem Zugriff sinnvoll. +**GM werden (Game):** Der Web-Admin ist **kein** Game-GM. Die GM-Rolle hängt am +Charakter, nicht am Benutzerkonto: Der Game-Server prüft `player.role` bzw. +`player.p.role` auf `"gm"`. Charakter vorher ausloggen, dann in MongoDB setzen: + +```sh +docker compose exec al-mongo mongosh adventureland --eval \ + 'db.character.updateOne( + { name: "CharName" }, + { $set: { role: "gm", "info.p.role": "gm" } } + )' +``` + +Danach den Charakter neu einloggen. `role: "gm"` gibt die In-Game-GM-Rechte und +ist unabhängig von `user.admin` (Web-Admin). Wer **nur** von den Multi-Account- +Limits ausgenommen werden will, aber keine GM-Rechte braucht, setzt stattdessen +`info.p.free`: + +```sh +docker compose exec al-mongo mongosh adventureland --eval \ + 'db.character.updateOne( + { name: "CharName" }, + { $set: { "info.p.free": true } } + )' +``` + +| Feld | Wirkung | +| --- | --- | +| `user.admin: true` | Web-Admin: `/admin/executor`, `/admin/renderer`; keine In-Game-GM-Rechte | +| Charakter `role: "gm"` | In-Game-GM; wird außerdem von den Limits ausgenommen | +| Charakter `info.p.free: true` | nur Limit-Ausnahme; keine GM-Rechte | + ## Aufbau | Service | Image | Aufgabe | @@ -61,7 +98,8 @@ ersetzt das Template aus dem Upstream-Repo: - `config/keys.js` – MongoDB-URI, Master-Keys, Stripe/Steam/SES (alles leer bzw. feste Entwicklungs-Defaults). Die Werte sind absichtlich stabil, damit Sitzungen und Serverdatensätze Neustarts überleben. -- `config/options.js` – `base_url`, Ports, Serverdefinition `local`. +- `config/options.js` – `base_url`, Ports, Serverdefinition `local`, + Multi-Account-Limits. Beide Dateien sind normale Node-Module; nach Änderungen Container neu starten: @@ -73,6 +111,39 @@ docker compose restart backend game > `docker-compose.yml` (`ports`), `config/options.js` (`port`, `local_port`, > `address`) und `base_url` (inkl. Port). +### Multi-Account-Limits + +`config/options.js` steuert, wie viele Charaktere gleichzeitig online sein +dürfen: + +| Option | Bedeutung | aktuell | +| --- | --- | --- | +| `ip_limit` | Nicht-Händler pro IP (mal `ipx` einer IP-Ausnahme) | `9` | +| `character_limit` | Nicht-Händler pro Konto | `3` | +| `merchant_ip_limit` | Händler pro IP | `3` | +| `merchant_owner_limit` | Händler pro Konto | `1` | + +Die Werte gelten beim Login (`is_player_allowed`) und im Backend-Cron +(`enforce_limitations`). Der Patch +`docker/patch-multi-account-limits.js` ersetzt dabei die früher fest codierten +Grenzen (3 Nicht-Händler, 1 Händler). Mit den aktuellen Werten können z. B. drei +Konten von derselben IP jeweils drei Nicht-Händler-Charaktere online haben +(9 insgesamt); ein zehntes Nicht-Händler-Charakter bzw. ein vierter Händler wird +abgewiesen. `character_limit` ist die Zahl der **gleichzeitig online** erlaubten +Charaktere, nicht die Zahl der Charakterplätze (`info.slots`); `ip_limit` ist +ebenfalls keine Slotzahl. Eine `IP_`-Ausnahme mit +`{ exception: true, info: { limit: N } }` multipliziert nur `ip_limit` für +Nicht-Händler, nicht die Händler-Limits. Nicht-freie Händler zählen; `p.free` +und GM-Charaktere fallen heraus. `merchant_owner_limit: 1` erlaubt einen Händler +pro Konto; Admin- und User-Händler auf getrennten Konten sind damit kein +Problem. Sollen beide auf **demselben** Konto laufen, `merchant_owner_limit` auf +`2` setzen. + +> Hinter einem Reverse-Proxy (Caddy) muss die echte Client-IP als +> `X-Forwarded-For` ankommen. Sonst sieht der Server nur die Proxy-/Docker-IP +> und alle Spieler teilen sich dieselben Limits. Siehe +> `deploy/Caddyfile.example`. + ### MongoDB-Zugriff Die Datenbank ist nur im Compose-Netz erreichbar. Für einen Blick hinein: @@ -145,6 +216,21 @@ Login als verifiziert und überspringt die Verifikationsmail. Ohne den Schalter bleibt das Upstream-Verhalten unverändert. Auch dieser Patch bricht den Build ab, wenn ein Anker in `api.js` fehlt. +`docker/patch-tutorial-reward-auth.js` macht die `auth_id`-Pflicht der +Tutorial- und Klassenbelohnungen abschaltbar. Bei +`options.tutorial_reward_requires_auth_id: false` können auch reine Web-Accounts +`c0` und Klassenbelohnungen abholen; fehlt die Option oder steht sie auf `true`, +gilt das Upstream-Verhalten. Fehlt einer der beiden Anker in `node/server.js`, +bricht der Build ab. + +`docker/patch-multi-account-limits.js` macht die Händler- und Nicht-Händler- +Limits konfigurierbar. Upstream prüft beim Login `options.ip_limit` und +`options.character_limit`, würgt im Backend-Cron aber pauschal nach 3 +Nicht-Händlern bzw. 1 Händler ab. Der Patch liest beide Stellen aus +`config/options.js` (`ip_limit`, `character_limit`, `merchant_ip_limit`, +`merchant_owner_limit`). Fehlt ein Anker in `node/server_functions.js` oder +`adventure_functions.js`, bricht der Build ab. + ## Spieldaten anpassen (`files/custom/game.js`) Werte bestehender Elemente und neue Elemente lassen sich ohne Quelltextänderung @@ -207,15 +293,17 @@ Custom-Datei zurückgesetzt werden; dann den Game-Server neu starten. auf `true` setzen (siehe Admin-Befehl oben). Die `/ev`-Route bleibt in beiden Fällen funktionsfähig. -- **Tutorial-Belohnung (`c0`):** Jedes Konto mit `auth_id` (Steam/MAS) kann nach +- **Tutorial-Belohnung (`c0`):** Jedes verifizierte Konto kann nach abgeschlossenem Tutorial einmal `c0` abholen. Die Belohnung ist ein zufälliger kosmetischer Look aus dem `cosmo0`-Pool (über 100 Varianten). Der Game-Server - prüft dafür `player.verified` **und** `player.auth_id` - (`node/server.js:5272`). Bei reinen Web-Accounts ohne Steam/MAS bleibt - `auth_id` leer, deshalb ist die Belohnung trotz Auto-Verifizierung weiterhin - gesperrt. Die alten Klassen-Belohnungen (`creward`) sind in - `design/classes.js` auskommentiert und werden vom Client nicht angefragt; - Hardcore-Rewards gelten nur auf dem Hardcore-Server. + prüft `player.verified` und normalerweise `player.auth_id` (Steam/MAS, + `node/server.js`, `ureward`/`creward`). Bei reinen Web-Accounts ist `auth_id` + leer; deshalb steht `options.tutorial_reward_requires_auth_id` hier auf + `false`, damit auch sie die Belohnung erhalten. Auf `true` gestellt, gilt + wieder die Upstream-Sperre für Web-Accounts. Die alten Klassen-Belohnungen + (`creward`) nutzen denselben Schalter, sind aber in `design/classes.js` + auskommentiert und werden vom Client nicht angefragt; Hardcore-Rewards gelten + nur auf dem Hardcore-Server. - **„Authorization Failure“ (Gold/Glück −85 %, XP −20 %):** Das ist die DRM-Prüfung des Game-Servers. Sie verlangt für jedes Konto ab 2019 eine diff --git a/deploy/Caddyfile.example b/deploy/Caddyfile.example index d3f7c0c..a91ea9d 100644 --- a/deploy/Caddyfile.example +++ b/deploy/Caddyfile.example @@ -20,7 +20,12 @@ al.example.com { # /server.api/ backend -> game (server_eval, protected by ACCESS_MASTER) @game path /socket.io/* /socket.io-msgpack/* /server.api/* handle @game { - reverse_proxy 10.10.16.28:7192 + reverse_proxy 10.10.16.28:7192 { + # Overwrite, not append: Adventure Land reads the first + # X-Forwarded-For entry for its IP limits. Without this a client + # could send a fake header and bypass the limits. + header_up X-Forwarded-For {remote_host} + } } # --- Web backend (container port 8090) ------------------------------ @@ -30,6 +35,8 @@ al.example.com { # Keep the public Host: the backend derives base_url and the # auth cookie domain from it. header_up Host {host} + # Overwrite the client IP; see the game handler above. + header_up X-Forwarded-For {remote_host} } } diff --git a/docker/patch-multi-account-limits.js b/docker/patch-multi-account-limits.js new file mode 100644 index 0000000..1b2003d --- /dev/null +++ b/docker/patch-multi-account-limits.js @@ -0,0 +1,165 @@ +#!/usr/bin/env node +// Local patch: make the multi-account limits configurable. +// +// Upstream hard-codes: +// - one merchant per IP and per owner, +// - three non-merchant characters per IP and per owner, +// and only checks some of those values at login. +// +// This patch reads the limits from options.js in both places: +// - is_player_allowed() (login, node/server_functions.js) +// - enforce_limitations() (backend cron, adventure_functions.js) +// +// New options (defaults match upstream): +// merchant_ip_limit: 1 +// merchant_owner_limit: 1 +// ip_limit: 3 +// character_limit: 3 +// +// The patch is idempotent and fails loudly if an anchor is missing. + +const fs = require("node:fs"); +const path = require("node:path"); + +const app = path.resolve(process.argv[2] || "/app"); +const serverFunctionsFile = path.join(app, "node", "server_functions.js"); +const adventureFunctionsFile = path.join(app, "adventure_functions.js"); + +function fail(message) { + console.error("patch-multi-account-limits: " + message); + process.exit(1); +} + +function replaceOnce(source, from, to, label) { + const count = source.split(from).length - 1; + if (count !== 1) { + fail(label + ": expected 1 anchor, found " + count); + } + return source.replace(from, to); +} + +function patchServerFunctions(source) { + const marker = "function get_multi_account_identity(player) {"; + const oldMerchant = + '\tif (player.type == "merchant") {\n' + + "\t\tfor (var id in players) {\n" + + "\t\t\tif ((players[id].stones && 0) || id == player.socket.id || is_free(player)) {\n" + + "\t\t\t\tcontinue;\n" + + "\t\t\t}\n" + + '\t\t\tif (players[id].owner == player.owner && players[id].type == "merchant") {\n' + + "\t\t\t\treturn false;\n" + + "\t\t\t}\n" + + "\t\t}\n" + + "\t\treturn true;\n" + + "\t}"; + + if (source.includes(marker)) { + if (source.includes(oldMerchant)) { + fail("server_functions.js has a partial merchant patch"); + } + console.log("patch-multi-account-limits: server_functions.js already patched"); + return source; + } + + source = replaceOnce( + source, + 'function get_limit_identity(player) {\n\treturn player && player.mainframe === true ? "mainframe:" + player.owner : get_ip_server(player);\n}', + 'function get_limit_identity(player) {\n\treturn player && player.mainframe === true ? "mainframe:" + player.owner : get_ip_server(player);\n}\n\n' + + "// Multi-account identity; mirrors the auth/temp_auth handling in enforce_limitations().\n" + + "function get_multi_account_identity(player) {\n" + + '\tif (player.temp_auth) return "owner:" + player.owner;\n' + + '\tif (player.auth_id) return "auth:" + player.auth_id;\n' + + "\treturn get_limit_identity(player);\n" + + "}", + "get_limit_identity", + ); + + const newMerchant = + "\t// Multi-account limits: configured in options.js; the backend cron uses the same values.\n" + + '\tif (player.type == "merchant") {\n' + + "\t\tvar merchant_limit = options.merchant_owner_limit === undefined ? 1 : options.merchant_owner_limit;\n" + + "\t\tvar merchant_ip_limit = options.merchant_ip_limit === undefined ? 1 : options.merchant_ip_limit;\n" + + "\t\tvar merchants = 0;\n" + + "\t\tvar merchant_ips = 0;\n" + + "\t\tvar merchant_identity = get_multi_account_identity(player);\n" + + "\t\tfor (var id in players) {\n" + + '\t\t\tif (id == player.socket.id || is_free(players[id]) || players[id].type != "merchant") {\n' + + "\t\t\t\tcontinue;\n" + + "\t\t\t}\n" + + "\t\t\tif (players[id].owner == player.owner) {\n" + + "\t\t\t\tmerchants++;\n" + + "\t\t\t}\n" + + "\t\t\tif (get_multi_account_identity(players[id]) == merchant_identity) {\n" + + "\t\t\t\tmerchant_ips++;\n" + + "\t\t\t}\n" + + "\t\t}\n" + + "\t\tif (merchants >= merchant_limit || merchant_ips >= merchant_ip_limit) {\n" + + "\t\t\treturn false;\n" + + "\t\t}\n" + + "\t\treturn true;\n" + + "\t}"; + + source = replaceOnce(source, oldMerchant, newMerchant, "merchant branch"); + return source; +} + +function patchAdventureFunctions(source) { + const marker = "var merchant_ip_limit = options.merchant_ip_limit === undefined ? 1 : options.merchant_ip_limit;"; + const oldMerchant = + '\t\t\t\tif (player.type === "merchant" && (mips[player.ip] > 1 || mowners[player.owner] > 1) && to_disconnect.indexOf(player.name) === -1) {'; + const oldOther = + '\t\t\t\tif (player.type !== "merchant" && (ips[player.ip] > 3 * (ipx[player.ip] || 1) || owners[player.owner] > 3) && to_disconnect.indexOf(player.name) === -1) {'; + + if (source.includes(marker)) { + if (source.includes(oldMerchant) || source.includes(oldOther)) { + fail("adventure_functions.js has a partial limits patch"); + } + console.log("patch-multi-account-limits: adventure_functions.js already patched"); + return source; + } + + source = replaceOnce( + source, + "\t\t\tvar to_disconnect = [];", + "\t\t\tvar to_disconnect = [];\n" + + "\t\t\tvar merchant_ip_limit = options.merchant_ip_limit === undefined ? 1 : options.merchant_ip_limit;\n" + + "\t\t\tvar merchant_owner_limit = options.merchant_owner_limit === undefined ? 1 : options.merchant_owner_limit;\n" + + "\t\t\tvar ip_limit = options.ip_limit === undefined ? 3 : options.ip_limit;\n" + + "\t\t\tvar character_limit = options.character_limit === undefined ? 3 : options.character_limit;", + "limit variables", + ); + + source = replaceOnce( + source, + oldMerchant, + "\t\t\t\tif (\n" + + '\t\t\t\t\tplayer.type === "merchant" &&\n' + + "\t\t\t\t\t(mips[player.ip] > merchant_ip_limit || mowners[player.owner] > merchant_owner_limit) &&\n" + + "\t\t\t\t\tto_disconnect.indexOf(player.name) === -1\n" + + "\t\t\t\t) {", + "merchant condition", + ); + + source = replaceOnce( + source, + oldOther, + "\t\t\t\tif (\n" + + '\t\t\t\t\tplayer.type !== "merchant" &&\n' + + "\t\t\t\t\t(ips[player.ip] > ip_limit * (ipx[player.ip] || 1) || owners[player.owner] > character_limit) &&\n" + + "\t\t\t\t\tto_disconnect.indexOf(player.name) === -1\n" + + "\t\t\t\t) {", + "non-merchant condition", + ); + + return source; +} + +let serverFunctions = fs.readFileSync(serverFunctionsFile, "utf8"); +serverFunctions = patchServerFunctions(serverFunctions); +fs.writeFileSync(serverFunctionsFile, serverFunctions); + +let adventureFunctions = fs.readFileSync(adventureFunctionsFile, "utf8"); +adventureFunctions = patchAdventureFunctions(adventureFunctions); +fs.writeFileSync(adventureFunctionsFile, adventureFunctions); + +console.log("patch-multi-account-limits: wrote " + serverFunctionsFile + " and " + adventureFunctionsFile); diff --git a/docker/patch-tutorial-reward-auth.js b/docker/patch-tutorial-reward-auth.js new file mode 100644 index 0000000..1f0abb7 --- /dev/null +++ b/docker/patch-tutorial-reward-auth.js @@ -0,0 +1,47 @@ +#!/usr/bin/env node +// Local patch: make the platform auth-id requirement for rewards optional. +// +// Adventure Land's tutorial (ureward) and class (creward) handlers require +// player.verified and player.auth_id. auth_id is only populated for Steam/MAS +// accounts; plain web accounts have an empty pid/auth_id and therefore can +// never claim these rewards. On a self-hosted server that requirement is +// usually unnecessary. +// +// options.tutorial_reward_requires_auth_id: +// true/absent -> upstream behaviour (auth_id required) +// false -> verified web accounts may claim the rewards too +// +// The patch is idempotent and fails loudly if the two anchors are gone. + +const fs = require("node:fs"); +const path = require("node:path"); + +const app = path.resolve(process.argv[2] || "/app"); +const file = path.join(app, "node", "server.js"); +let source = fs.readFileSync(file, "utf8"); + +const from = `\t\t\tif (!player.verified || !player.auth_id) {`; +const to = `\t\t\tif ( +\t\t\t\t!player.verified || +\t\t\t\t((typeof options === "undefined" || options.tutorial_reward_requires_auth_id !== false) && !player.auth_id) +\t\t\t) {`; + +function fail(message) { + console.error("patch-tutorial-reward-auth: " + message); + process.exit(1); +} + +const unpatched = source.split(from).length - 1; +const patched = source.split(to).length - 1; + +if (unpatched === 2) { + source = source.split(from).join(to); + console.log("patch-tutorial-reward-auth: patched ureward and creward gates"); +} else if (unpatched === 0 && patched === 2) { + console.log("patch-tutorial-reward-auth: already patched"); +} else { + fail("expected 2 reward gates, found " + unpatched + " unpatched and " + patched + " patched; refusing partial patch"); +} + +fs.writeFileSync(file, source); +console.log("patch-tutorial-reward-auth: wrote " + file); diff --git a/files/config/options.js b/files/config/options.js index 5ed5620..611957a 100644 --- a/files/config/options.js +++ b/files/config/options.js @@ -49,12 +49,24 @@ module.exports = { merchant_map: "main", port: 8090, close_timeout: 4000, - ip_limit: 3, + // Max concurrent non-merchant characters per IP (multiplied by an IP + // exception's limit) and per account. Login and the backend cron both use + // these values; 9 here allows 3 accounts with 3 characters each from one IP. + ip_limit: 9, character_limit: 3, + // Max concurrent merchant characters per IP and per account. Upstream is + // hard-coded to 1/1; this local build reads these options in login and in + // the backend cron. 3 here means three merchant accounts may share one IP. + merchant_ip_limit: 3, + merchant_owner_limit: 1, // Without an email service, mark new accounts as verified at signup and // keep existing accounts verified on login. Set to false to restore the // upstream verification flow (requires AWS SES). auto_verify_email: true, + // Tutorial/class rewards normally require a platform auth id (Steam/MAS) + // as an anti-alt measure. Web-only accounts have no auth id; set to false + // to let verified web accounts claim them too. Default is true. + tutorial_reward_requires_auth_id: false, fast_sdk: 0, machines: machines, servers: servers,