From 69bb46267fac952fc8f41c9c88d3bb3c643807dc Mon Sep 17 00:00:00 2001 From: Nathan Poirier Date: Fri, 25 Aug 2023 22:25:11 +0200 Subject: [PATCH] Initial version --- .gitignore | 122 ++++++++++++ .prettierignore | 1 + .prettierrc.yaml | 15 ++ .readme/kick.png | Bin 0 -> 3898 bytes .readme/server-list.png | Bin 0 -> 4897 bytes LICENSE | 21 ++ README.md | 43 ++++ package-lock.json | 425 ++++++++++++++++++++++++++++++++++++++++ package.json | 20 ++ src/index.js | 164 ++++++++++++++++ src/mc-protocol.js | 265 +++++++++++++++++++++++++ src/utils.js | 27 +++ 12 files changed, 1103 insertions(+) create mode 100644 .gitignore create mode 120000 .prettierignore create mode 100644 .prettierrc.yaml create mode 100644 .readme/kick.png create mode 100644 .readme/server-list.png create mode 100644 LICENSE create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/index.js create mode 100644 src/mc-protocol.js create mode 100644 src/utils.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..80af415 --- /dev/null +++ b/.gitignore @@ -0,0 +1,122 @@ +### Node +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test +.env*.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +.next-* + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* diff --git a/.prettierignore b/.prettierignore new file mode 120000 index 0000000..3e4e48b --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +.gitignore \ No newline at end of file diff --git a/.prettierrc.yaml b/.prettierrc.yaml new file mode 100644 index 0000000..184bee1 --- /dev/null +++ b/.prettierrc.yaml @@ -0,0 +1,15 @@ +trailingComma: 'es5' +printWidth: 120 +tabWidth: 4 +semi: true +singleQuote: true +jsxSingleQuote: true +bracketSpacing: false +proseWrap: 'always' +overrides: + - files: ['*.md'] + options: {printWidth: 80} + - files: ['*.html', '*.css', '*.less', '*.scss', '*.md', '*.json', '*.yml', '*.yaml', '*.xml'] + options: {tabWidth: 2} + - files: ['*.php'] + options: {phpVersion: '7.1', braceStyle: '1tbs'} diff --git a/.readme/kick.png b/.readme/kick.png new file mode 100644 index 0000000000000000000000000000000000000000..1fe3f51c1497e5fd5e60291d053ef9598e9a2c8a GIT binary patch literal 3898 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 TLg72x00000NkvXXu0mjfL;QeJ literal 0 HcmV?d00001 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..affcb41 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Nathan Poirier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..43a5960 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# Fake Minecraft Server + +This is a fake Minecraft server that only responds to server list requests and +sends a kick message when a player try to join. + +![server list example](./.readme/server-list.png?raw=true) +![kick message example](./.readme/kick.png?raw=true) + +## Usage + +To start this server, simply run: + +```shell +npm ci +npm run start +``` + +## Configuration + +The configuration is defined via environment variables: + +| Variable | Description | Default | +| ------------------ | ----------------------------------------------------------------------------------------------------------- | ----------------- | +| `LISTEN_HOST` | The hostname or IP address to listen on | `::` | +| `LISTEN_PORT` | The port number to listen on | `25565` | +| `LISTEN_BACKLOG` | The maximum number of queued pending connections | - | +| `KICK_MESSAGE` | The message to send when a player try to join | `§cNot available` | +| `MOTD` | The message displayed in the server list | `§eHello World!` | +| `FAVICON` | The favicon displayed in the server list (path to a PNG file, or a string like `data:image/png;base64,XXX`) | - | +| `MAX_PLAYERS` | The number of slots displayed in the server list | `0` | +| `ONLINE_PLAYERS` | The number of online players displayed in the server list | `0` | +| `PROTOCOL_NAME` | The protocol name reported in the server list | (empty) | +| `PROTOCOL_VERSION` | The protocol version reported in the server list | `0` | + +To set these environment variables, you can either export them in your shell or +create a `.env` file in the root of the project with the following format: + +``` +LISTEN_PORT=25565 +KICK_MESSAGE="§c§lSorry.\n§cCome back later." +MOTD="§eWelcome to My Server!\n§dJoin now!" +PROTOCOL_NAME="§cHey!" +``` diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..55b52b8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,425 @@ +{ + "name": "fake-minecraft-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fake-minecraft-server", + "version": "1.0.0", + "dependencies": { + "dotenv": "^16.3.1" + }, + "devDependencies": { + "nodemon": "^3.0.1", + "prettier": "^3.0.2" + } + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/dotenv": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", + "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/motdotla/dotenv?sponsor=1" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nodemon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.0.1.tgz", + "integrity": "sha512-g9AZ7HmkhQkqXkRc20w+ZfQ73cHLbE8hnPbtaFbFtCumZsjyMhKk9LajQ07U5Ux28lvFjZ5X7HvWR1xzU8jHVw==", + "dev": true, + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^3.2.7", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nopt": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", + "integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==", + "dev": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prettier": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.2.tgz", + "integrity": "sha512-o2YR9qtniXvwEZlOKbveKfDQVyqxbEIWn48Z8m3ZJjBjcCmUy3xZGIv+7AkaeuaTr6yPXJjwv07ZWlsWbEy1rQ==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/touch": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", + "integrity": "sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==", + "dev": true, + "dependencies": { + "nopt": "~1.0.10" + }, + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9ab680c --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "fake-minecraft-server", + "private": true, + "version": "1.0.0", + "author": "Nathan Poirier ", + "type": "module", + "scripts": { + "format": "prettier --write .", + "dev": "nodemon --watch src src/index.js", + "start": "node src/index.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "devDependencies": { + "nodemon": "^3.0.1", + "prettier": "^3.0.2" + }, + "dependencies": { + "dotenv": "^16.3.1" + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..0d1accf --- /dev/null +++ b/src/index.js @@ -0,0 +1,164 @@ +import 'dotenv/config'; +import {createServer} from 'node:net'; +import {readFileSync} from 'node:fs'; +import {log, formatAddress} from './utils.js'; +import {ByteBuf, readHandshake, writeStringPacket, PACKET_PONG} from './mc-protocol.js'; + +const HANDSHAKE_TIMEOUT = 2000; // ms + +function main() { + const server = createServer(); + + // Add server lifecycle logging + server.on('listening', () => { + const {address, port} = server.address(); + log(`Listening on ${formatAddress(address)}:${port}`); + }); + server.on('error', (err) => { + log('Server Error:', err); + }); + + // Add socket connection handler + server.on('connection', handleSocket); + + // Start listening + const listenOpts = { + host: process.env.LISTEN_HOST || undefined, + port: parseInt(process.env.LISTEN_PORT) || 25565, + backlog: parseInt(process.env.LISTEN_BACKLOG) || undefined, + }; + const listenErrorHandler = () => process.exit(1); + server.on('error', listenErrorHandler); + server.listen(listenOpts, () => server.off('error', listenErrorHandler)); +} + +/** + * Handles a socket connection. + * + * @param {net.Socket} socket - The client socket. + */ +function handleSocket(socket) { + const name = `[${formatAddress(socket.remoteAddress, false)}]:${socket.remotePort}`; + + // Add socket lifecycle logging + log(`${name} connected`); + let answered = false; + let sockerErr = undefined; + socket.on('error', (err) => { + // log('Socket Error:', err); // debug + if (!sockerErr) { + sockerErr = err; + } + }); + socket.on('close', () => { + if (sockerErr) { + log(`${name} disconnected with error: ${sockerErr.message}`); + } else if (!answered) { + log(`${name} disconnected before receiving response`); + } else { + log(`${name} disconnected successfully`); + } + }); + + // Ensure that the socket is destroyed immediately on error + socket.on('error', () => socket.destroy()); + + // Set a strict timeout for the handshake + const timeoutTask = setTimeout(() => socket.destroy(new Error('Timeout')), HANDSHAKE_TIMEOUT); + socket.on('close', () => clearTimeout(timeoutTask)); + + // Add data handler + const buf = new ByteBuf(); + socket.on('data', (data) => { + if (socket.readyState !== 'open') { + // always skip data after a call to socket.end() + // without this, already received data sometimes continues to be read for a short time + return; + } + if (answered) { + return; // skip (already answered) + } + + // Read the handshake + buf.append(data); + buf.resetOffset(); + const handshake = readHandshake(buf); + if (handshake === undefined) { + return; // skip (missing data) + } + if (handshake === false) { + // fail (illegal handshake) + socket.destroy(new Error('Illegal handshake')); + return; + } + + log(`${name} sent handshake: ${JSON.stringify(handshake)}`); + + // Respond and close the socket + answered = true; + if (handshake.state === 2) { + socket.end(getKickPacket(handshake)); + } else { + socket.write(getServerListPacket(handshake)); + socket.end(PACKET_PONG); + } + }); +} + +function parseChatComponent(str) { + if (str.charAt(0) === '{') { + try { + return JSON.parse(str); + } catch (ignored) {} + } + return {text: str}; +} + +function readFavicon(strOrPath) { + if (!strOrPath) { + return undefined; + } + + if (strOrPath.startsWith('data:')) { + return strOrPath; + } + + try { + const data = readFileSync(strOrPath, {encoding: 'base64'}); + return `data:image/png;base64,${data}`; + } catch (err) { + log(`Cannot read favicon: ${err.message}`); + return undefined; + } +} + +const getKickPacket = (() => { + const packet = writeStringPacket( + 0, + JSON.stringify(parseChatComponent(process.env.KICK_MESSAGE || '§cNot available')) + ); + return (handshake) => packet; +})(); + +const getServerListPacket = (() => { + // TODO: Allow PROTOCOL_VERSION=auto to copy the client's one + const packet = writeStringPacket( + 0, + JSON.stringify({ + version: { + name: process.env.PROTOCOL_NAME || '', + protocol: parseInt(process.env.PROTOCOL_VERSION) || 0, + }, + players: { + max: parseInt(process.env.MAX_PLAYERS) || 0, + online: parseInt(process.env.ONLINE_PLAYERS) || 0, + sample: [], + }, + description: parseChatComponent(process.env.MOTD || '§eHello World!'), + favicon: readFavicon(process.env.FAVICON), + }) + ); + return (handshake) => packet; +})(); + +main(); diff --git a/src/mc-protocol.js b/src/mc-protocol.js new file mode 100644 index 0000000..6822f2f --- /dev/null +++ b/src/mc-protocol.js @@ -0,0 +1,265 @@ +export class ByteBuf { + constructor() { + this.data = undefined; + this.offset = 0; + } + + /** + * Returns the number of bytes this buffer contains. + * + * @returns {number} The length of this buffer. + */ + length() { + return this.data ? this.data.length : 0; + } + + /** + * Returns the current offset of this buffer. + * + * @returns {number} The current offset. + */ + offset() { + return this.offset; + } + + /** + * Resets the offset to 0. + */ + resetOffset() { + this.offset = 0; + } + + /** + * Appends data to this buffer. + * + * @param {Buffer} data - The data to append. + */ + append(data) { + if (!data) { + return; + } + this.data = this.data ? Buffer.concat([this.data, data]) : data; + } + + /** + * Reads an unsigned byte from this buffer and increment offset by 1. + * + * @returns {number|false} The unsigned byte read; or `false` if there is not enough data to read. + */ + readUnsignedByte() { + if (this.offset + 1 > this.length()) { + return false; + } + + const ret = this.data.readUInt8(this.offset); + this.offset += 1; + return ret; + } + + /** + * Reads an unsigned short (16-bit) from this buffer and increment offset by 2. + * + * @returns {number|false} The unsigned short read; or `false` if there is not enough data to read. + */ + readUnsignedShort() { + if (this.offset + 2 > this.length()) { + return false; + } + + const ret = this.data.readUInt16BE(this.offset); + this.offset += 2; + return ret; + } + + /** + * Reads a variable-length integer from this buffer and increment offset. + * + * @param {number} [maxBytes=5] - The maximum number of bytes to read. + * @param {boolean} [skipIncomplete=false] - Whether to skip incomplete values or not. + * @returns {number|boolean|undefined} + * The integer value if successful; + * `false` if the maximum length is exceeded; + * `false` if the value is incomplete and `skipIncomplete` is `false`; + * or `undefined` if the value is incomplete and `skipIncomplete` is `true`. + */ + readVarInt(maxBytes = 5, skipIncomplete = false) { + let ret = 0; + let bytesRead = 0; + + while (true) { + // read byte + const b = this.readUnsignedByte(); + if (b === false) { + return skipIncomplete ? undefined : false; + } + + // compute result + ret = ret | ((b & 0x7f) << (bytesRead * 7)); + ++bytesRead; + + // returns when the end is reached + if ((b & 0x80) === 0) { + return ret; + } + + // fail on max length + if (bytesRead >= maxBytes) { + return false; + } + } + } + + /** + * Reads a string from this buffer and increment offset. + * + * @param {number} maxPrefixBytes - The maximum number of bytes to read for the string's length prefix. + * @param {number} maxUtf8Bytes - The maximum number of bytes to read for the string's UTF-8 encoded characters. + * @returns {string|false} The string read from the buffer; or `false` if the string could not be read. + */ + readString(maxPrefixBytes, maxUtf8Bytes) { + const len = this.readVarInt(maxPrefixBytes); + if (len === false || len < 0 || len > maxUtf8Bytes || this.offset + len > this.length()) { + return false; + } + + const ret = this.data.toString('utf8', this.offset, this.offset + len); + this.offset += len; + return ret; + } + + /** + * Writes a variable-length integer to the buffer. + * + * @param {number} value - The integer value to write. + */ + writeVarInt(value) { + value |= 0; + while (true) { + const b = value & 0x7f; + value >>>= 7; + if (value === 0) { + this.data.writeUInt8(b, this.offset); + ++this.offset; + break; + } else { + this.data.writeUInt8(b | 0x80, this.offset); + ++this.offset; + } + } + } + + /** + * Writes raw data to the buffer. + * + * @param {Buffer} data - The data to write to the buffer. + */ + writeRaw(data) { + data.copy(this.data, this.offset, 0, data.length); + this.offset += data.length; + } +} + +/** + * Returns the number of bytes required to encode a variable-length integer. + * + * @param {number} value - The integer value to encode. + * @returns {number} The number of bytes required to encode the integer value. + */ +export function getVarIntSize(value) { + value |= 0; + if (value < 0) return 5; + if (value < 0x80) return 1; + if (value < 0x4000) return 2; + if (value < 0x200000) return 3; + if (value < 0x10000000) return 4; + return 5; +} + +/** + * The maximum length of a handshake packet in bytes. + * @type {number} + */ +const HANDSHAKE_MAX_LEN = 267; // (packetLen)2 + (packetId)1 + (protocolVersion)4 + (hostname)2+255 + (port)2 + (state)1 + +/** + * Reads a Minecraft handshake packet from a buffer. + * + * @param {ByteBuf} buf - The buffer to read from. + * @returns {Object|boolean|undefined} + * An object containing the protocol version, hostname, port, and state if successful; + * `false` if the packet is invalid; + * or `undefined` if there is missing data to wait. + */ +export function readHandshake(buf) { + // read packet len + const packetLen = buf.readVarInt(2, true); + if (packetLen === undefined) { + return; // skip (missing data) + } + if (packetLen === false || packetLen > HANDSHAKE_MAX_LEN) { + return false; // fail (too long packet) + } + if (packetLen > buf.length) { + return; // skip (missing data) + } + + // read packet id + const packetId = buf.readVarInt(1); + if (packetId === false || packetId !== 0) { + return false; // fail (not an handshake) + } + + // read protocol version + const protocolVersion = buf.readVarInt(4); + if (protocolVersion === false || protocolVersion <= 0) { + return false; // fail (illegal version) + } + + // read hostname + // note: we limit it to 255 utf8 bytes, considering that it contains only ascii characters + let hostname = buf.readString(2, 255); + if (hostname === false) { + return false; // fail (illegal hostname) + } + + // read port + const port = buf.readUnsignedShort(); + if (port === false || port <= 0 || port > 65535) { + return false; // fail (illegal post) + } + + // read (next_)state + const state = buf.readVarInt(1); + if (state !== 1 && state !== 2) { + return false; // fail (illegal state) + } + + // trim clients/mods suffix from host (everything after \0) then returns + const hostnameEnd = hostname.indexOf('\0'); + if (hostnameEnd !== -1) { + hostname = hostname.slice(0, hostnameEnd); + } + return {protocolVersion, hostname, port, state}; +} + +export function writeStringPacket(packetId, str) { + const strBuf = Buffer.from(str, 'utf8'); + const packetLen = getVarIntSize(packetId) + getVarIntSize(strBuf.byteLength) + strBuf.byteLength; + + const buf = new ByteBuf(); + buf.append(Buffer.alloc(getVarIntSize(packetLen) + packetLen)); + buf.writeVarInt(packetLen); + buf.writeVarInt(packetId); + buf.writeVarInt(strBuf.byteLength); + buf.writeRaw(strBuf); + return buf.data; +} + +export const PACKET_PONG = (() => { + const buf = Buffer.alloc(10); + buf.writeUInt8(9, 0); // packet len + buf.writeUInt8(1, 1); // packet id + buf.writeUInt32BE(0, 2); // client time + buf.writeUInt32BE(818, 6); // client time + return buf; +})(); diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..15e7e73 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,27 @@ +/** + * Logs a message to the console. + * + * @param {string} message - The message to log. + * @param {...any} args - Additional arguments to log. + */ +export function log(message, ...args) { + const now = new Date(); + console.log(`[${now.toISOString()}] ${message}`, ...args); +} + +/** + * Formats the given IP address by removing the IPv6 encapsulation or enclosing IPv6 in square brackets. + * + * @param {string} address - The IP address to format. + * @param {boolean} [enclosedIpv6=true] - Whether to enclose IPv6 addresses in square brackets. + * @returns {string} The formatted IP address. + */ +export function formatAddress(address, enclosedIpv6 = true) { + if (address.startsWith('::ffff:')) { + return address.slice(7); + } + if (enclosedIpv6 && address.includes(':')) { + return '[' + address + ']'; + } + return address; +}