├── README.md ├── java ├── entrypoint.sh ├── Dockerfile ├── README.md └── prompt.py ├── .github └── workflows │ └── images.yml └── LICENSE.md /README.md: -------------------------------------------------------------------------------- 1 | # images 2 | 3 | [![Docker Repository on Quay](https://quay.io/repository/wisp/images/status "Docker Repository on Quay")](https://quay.io/repository/wisp/images) 4 | 5 | Docker images aimed to be used in WISP/Pterodactyl. -------------------------------------------------------------------------------- /java/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd /home/container 4 | 5 | export INTERNAL_IP=`ip route get 1 | awk '{print $NF;exit}'` 6 | 7 | [ ! -f server.properties ] && cat > server.properties << EOF 8 | server-ip=0.0.0.0 9 | server-port=${SERVER_PORT} 10 | query.port=${SERVER_PORT} 11 | EOF 12 | 13 | export MODIFIED_STARTUP=`eval echo $(echo ${STARTUP} | sed -e 's/{{/${/g' -e 's/}}/}/g')` 14 | python3 /prompt.py --mode=echo 15 | export MODIFIED_STARTUP=`echo $(python3 /prompt.py --mode=env)` 16 | echo ":/home/container$ ${MODIFIED_STARTUP}" 17 | 18 | eval ${MODIFIED_STARTUP} 19 | -------------------------------------------------------------------------------- /.github/workflows/images.yml: -------------------------------------------------------------------------------- 1 | name: images 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | 8 | jobs: 9 | docker: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | - name: Set up QEMU 15 | uses: docker/setup-qemu-action@v3 16 | - name: Set up Docker Buildx 17 | uses: docker/setup-buildx-action@v3 18 | - name: Login to quay.io 19 | uses: docker/login-action@v3 20 | with: 21 | registry: quay.io 22 | username: ${{ secrets.QUAY_USERNAME }} 23 | password: ${{ secrets.QUAY_ROBOT_TOKEN }} 24 | - name: Build and push 25 | uses: docker/build-push-action@v5 26 | with: 27 | context: java 28 | platforms: linux/amd64,linux/arm64 29 | push: true 30 | tags: quay.io/wisp/images:java 31 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Stepan Fedotov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /java/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:20.04 2 | 3 | LABEL author="Stepan Fedotov " maintainer="William Venner " 4 | ARG DEBIAN_FRONTEND=noninteractive 5 | ENV LANG='en_US.UTF-8' LANGUAGE='en_US:en' LC_ALL='en_US.UTF-8' 6 | 7 | RUN apt-get update -y && \ 8 | apt-get install -y tzdata curl wget ca-certificates software-properties-common apt-transport-https fontconfig locales openssl git tar sqlite iproute2 python3 && \ 9 | echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen && \ 10 | locale-gen en_US.UTF-8 && \ 11 | add-apt-repository -y ppa:openjdk-r/ppa && \ 12 | apt-get update -y && \ 13 | apt-get install -y openjdk-8-jre openjdk-11-jre openjdk-16-jre openjdk-17-jre openjdk-21-jdk && \ 14 | rm -rf /var/lib/apt/lists/* 15 | 16 | RUN ln -s /usr/lib/jvm/java-8-openjdk-$(dpkg --print-architecture)/jre/bin/java /usr/local/bin/java8 && \ 17 | ln -s /usr/lib/jvm/java-11-openjdk-$(dpkg --print-architecture)/bin/java /usr/local/bin/java11 && \ 18 | ln -s /usr/lib/jvm/java-16-openjdk-$(dpkg --print-architecture)/bin/java /usr/local/bin/java16 && \ 19 | ln -s /usr/lib/jvm/java-17-openjdk-$(dpkg --print-architecture)/bin/java /usr/local/bin/java17 && \ 20 | ln -s /usr/lib/jvm/java-19-openjdk-$(dpkg --print-architecture)/bin/java /usr/local/bin/java19 && \ 21 | ln -s /usr/lib/jvm/java-21-openjdk-$(dpkg --print-architecture)/bin/java /usr/local/bin/java21 && \ 22 | useradd -d /home/container -m container 23 | 24 | USER container 25 | ENV USER=container HOME=/home/container 26 | 27 | WORKDIR /home/container 28 | 29 | COPY ./prompt.py /prompt.py 30 | COPY ./entrypoint.sh /entrypoint.sh 31 | CMD [ "/bin/bash", "/entrypoint.sh" ] 32 | -------------------------------------------------------------------------------- /java/README.md: -------------------------------------------------------------------------------- 1 | # java 2 | 3 | **EXPERIMENTAL - this has been tested with bunch of different jars, but it's possible some plugins may have incompatibilities.** 4 | 5 | This Docker image aims to eliminate having to swap between java versions by automatically detecting the jar's recommended (or fallback to target) version. If it's unable to find the jar that should be executed, it'll default to Java 11. 6 | 7 | Currently, the following java versions are included: 8 | 9 | * Java 8 10 | * Java 11 11 | * Java 16 12 | * Java 17 13 | * Java 21 14 | 15 | Each time the image detects a new startup jar, the user will be prompted to choose the version the jar should be ran with: 16 | ![example startup](https://i.imgur.com/Uim0Ese.png) 17 | 18 | If the user doesn't select their version inside 30 seconds, it'll default to the automatically detected version. After the initial run, the prompt won't ask any questions from the user. If at any point the choice should be changed, the user can delete the `disable_prompt_for_java_version` file in the root of the server to retrigger the prompt and choose the new desired java version. 19 | 20 | ## How to use this with WISP/Pterodactyl? 21 | 22 | This should work with any existing egg, as long as you update the Docker image for the server, or the egg. 23 | 24 | You can switch an individual game server to this image by navigating to admin area > Servers > your server > Startup and changing the image field in the Docker Container Configuration section to `quay.io/wisp/images:java`. 25 | 26 | If you want to default all new servers to use this image (or if you're on WISP, it'll also update all existing servers to use that image), all that you need to do is navigate to admin area > Nests > your nest > your egg and change the Docker Image field to `quay.io/wisp/images:java`. 27 | 28 | You may also need to rebuild the server container (in admin area of the server, manage tab) to apply the changes for both of these changes. 29 | -------------------------------------------------------------------------------- /java/prompt.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import signal 4 | import struct 5 | import zipfile 6 | import hashlib 7 | import json 8 | 9 | def getFlag(name, default): 10 | search = "--%s=" % name 11 | for x in sys.argv: 12 | if x.startswith(search): 13 | return x[len(search):] 14 | 15 | return default 16 | 17 | def getHeader(data, header, separator = ":"): 18 | for x in data.split("\n"): 19 | splitted = x.strip().split(separator) 20 | if len(splitted) != 2: 21 | continue 22 | 23 | key, value = splitted 24 | if key.lower() == header.lower(): 25 | return value.strip() 26 | 27 | raise Exception("Couldn't find header " + header) 28 | 29 | def readClassHeader(zip, path): 30 | with zip.open(path) as class_data: 31 | header = class_data.read(4) 32 | if header != b"\xca\xfe\xba\xbe": 33 | raise Exception("Magic header of java class is not CAFEBABE?") 34 | 35 | class_data.read(2) # minor_version, we don't care about this 36 | major_version, = struct.unpack('>H', class_data.read(2)) 37 | 38 | return major_version 39 | 40 | def getJavaVersion(zip): 41 | # Some jars are already pre-built and their build tools just merge the mojang jar into it, 42 | # leading to some false positives. This tries to mitigate this by detecting the mojang jar inside. 43 | # TODO: Though for some reason, some of them could be built with Java 8, others with e.g. Java 16??? 44 | max_version = 0 45 | for x in zip.namelist(): 46 | if (x.startswith("net/minecraft/") or x.startswith("io/")) and x.endswith(".class"): 47 | max_version = max(max_version, readClassHeader(zip, x)) 48 | 49 | if max_version != 0: 50 | return max_version 51 | 52 | # Otherwise, fall back to the main class 53 | manifest_data = zip.read("META-INF/MANIFEST.MF").decode() 54 | main_class_path = getHeader(manifest_data, "Main-Class") 55 | 56 | return readClassHeader(zip, main_class_path.replace(".", "/") + ".class") 57 | 58 | def getVersionFromPaperclip(zip): 59 | try: 60 | with zip.open("patch.properties") as patch_properties: 61 | return getHeader(patch_properties.read().decode(), "version", "=") 62 | except: 63 | pass 64 | 65 | try: 66 | with zip.open("patch.json") as patch_json: 67 | patch_contents = patch_json.read().decode() 68 | return json.loads(patch_contents)["version"] 69 | except: 70 | pass 71 | 72 | try: 73 | # the version.json manifest also has the java_version field but this is a bit inaccurate as we aren't guaranteed to have that version, so prefer our logic instead 74 | with zip.open("version.json") as version_json: 75 | version_contents = version_json.read().decode() 76 | return json.loads(version_contents)["id"] 77 | except: 78 | pass 79 | 80 | def getPaperRecommendedVersion(zip): 81 | version = getVersionFromPaperclip(zip) 82 | if not version: 83 | return 84 | 85 | splitted = list(map(int, version.split("."))) 86 | major, minor = [splitted[0], splitted[1]] 87 | if major >= 1 and minor >= 20: 88 | return "Java 21" 89 | if major >= 1 and minor >= 17: 90 | return "Java 17" 91 | if major >= 1 and minor >= 16: 92 | return "Java 16" 93 | elif major >= 1 and minor >= 12: 94 | return "Java 11" 95 | elif major >= 1 and minor >= 8: 96 | return "Java 8" 97 | 98 | def getJavaName(zip): 99 | # If we're able to detect that the server uses paper (or fork of it), prefer their recommended versions over target. 100 | try: 101 | paper_recommended = getPaperRecommendedVersion(zip) 102 | if paper_recommended: 103 | return paper_recommended 104 | except Exception as e: 105 | pass 106 | 107 | # Otherwise, just fallback to checking which version of java the files were built with. 108 | try: 109 | major_version = getJavaVersion(zip) 110 | if major_version >= 65: 111 | return "Java 21" 112 | if major_version >= 61: 113 | return "Java 17" 114 | if major_version >= 60: 115 | return "Java 16" 116 | elif major_version >= 55: 117 | return "Java 11" 118 | else: 119 | return "Java 8" 120 | except Exception as e: 121 | pass 122 | 123 | startup = os.getenv("MODIFIED_STARTUP", os.getenv("STARTUP", "")) 124 | def getJarFromStartup(): 125 | splitted = startup.split(" ") 126 | for x in splitted: 127 | if x.strip().endswith(".jar"): 128 | return x.strip() 129 | 130 | def replaceStartupWith(entrypoint): 131 | splitted = startup.split(" ") 132 | splitted[0] = entrypoint 133 | 134 | return " ".join(splitted) 135 | 136 | def interrupt(signum, frame): 137 | raise Exception("") 138 | 139 | def inputWithTimeout(timeout): 140 | signal.signal(signal.SIGALRM, interrupt) 141 | signal.alarm(timeout) 142 | try: 143 | res = input() 144 | signal.alarm(0) 145 | 146 | return res 147 | except: 148 | signal.alarm(0) 149 | 150 | return None 151 | 152 | def getFileChecksum(path): 153 | with open(path, "rb") as f: 154 | file_hash = hashlib.md5() 155 | while chunk := f.read(8192): 156 | file_hash.update(chunk) 157 | 158 | return file_hash.digest() 159 | 160 | def readFile(path): 161 | with open(path, "rb") as f: 162 | return f.read() 163 | 164 | def writeFile(path, data): 165 | with open(path, "wb") as f: 166 | f.write(data) 167 | 168 | def deleteFile(path): 169 | if os.path.exists(path): 170 | os.remove(path) 171 | 172 | mode = getFlag("mode", "echo") 173 | if mode not in ["echo", "env"]: 174 | raise Exception("Unknown mode '%s' passed." % mode) 175 | 176 | is_echo = mode == "echo" 177 | is_env = mode == "env" 178 | 179 | default = "Java 11" 180 | entrypointMappings = { 181 | "Java 8": "java8", 182 | "Java 11": "java11", 183 | "Java 16": "java16", 184 | "Java 17": "java17", 185 | "Java 21": "java21", 186 | } 187 | state_file = "disable_prompt_for_java_version" 188 | save_file = ".docker_overwrite" 189 | def main(): 190 | jar = getJarFromStartup() 191 | if not jar: 192 | if is_echo: 193 | print("No jar detected in startup arguments - not enforcing java.") 194 | elif is_env: 195 | print(startup) 196 | 197 | return 198 | 199 | # TODO: quilt's startup jars have nothing in them and is handled instead by MANIFEST.MF pointing to libraries - we'd need more advanced logic for this 200 | # but for now just assume server.jar will exist. 201 | if jar == "quilt-server-launch.jar": 202 | jar = "server.jar" 203 | 204 | try: 205 | checksum = getFileChecksum(jar) 206 | 207 | with zipfile.ZipFile(jar, "r") as zip: 208 | name = getJavaName(zip) 209 | if not name: 210 | name = default 211 | 212 | if not os.path.exists(state_file) or readFile(state_file) != checksum: 213 | if not is_echo: 214 | raise Exception("Something went really wrong - prompt should be displayed in echo mode but we're not using that mode???") 215 | 216 | print("Detected initial boot with this jar.") 217 | initial = True 218 | timedOut = False 219 | answer = "" 220 | while True: 221 | if initial: 222 | initial = False 223 | print("Which java version do you want to use?") 224 | else: 225 | print("Invalid option '%s' - the only valid options are the following:" % str(answer)) 226 | 227 | print("1) Automatically detected version: '%s'" % name) 228 | print("2) Java 8") 229 | print("3) Java 11") 230 | print("4) Java 16") 231 | print("5) Java 17") 232 | print("6) Java 21") 233 | print("NOTE: this prompt will automatically expire in 30 seconds from inactivity and default to option 1) if nothing is chosen.") 234 | 235 | answer = inputWithTimeout(30) 236 | if answer is None: 237 | answer = "1" 238 | # timedOut = True # Technically, this should be set to true but we want to default to automatic always if possible 239 | 240 | if answer.endswith(")"): 241 | answer = answer[:-1] 242 | 243 | if answer.isdigit(): 244 | answer = int(answer) 245 | if answer >= 1 and answer <= 6: 246 | break 247 | 248 | name = { 249 | 1: name, 250 | 2: "Java 8", 251 | 3: "Java 11", 252 | 4: "Java 16", 253 | 5: "Java 17", 254 | 6: "Java 21", 255 | }[answer] 256 | 257 | if answer > 1: 258 | writeFile(save_file, name.encode()) 259 | else: 260 | deleteFile(save_file) 261 | 262 | if not timedOut: 263 | writeFile(state_file, checksum) 264 | 265 | if os.path.exists(save_file): 266 | name = readFile(save_file).decode().strip() 267 | 268 | # users can overwrite the file - if they do and its not java* may as well just default to something else 269 | if name not in entrypointMappings: 270 | if is_echo: 271 | print("Detected invalid java version '%s', defaulting back to '%s'..." % name % default) 272 | print("This choice can be reset by deleting the '%s' file." % state_file) 273 | elif is_env: 274 | print(replaceStartupWith(entrypointMappings[default])) 275 | 276 | return 277 | 278 | if is_echo: 279 | print("Detected java version being overwritten, using '%s'..." % name) 280 | print("This choice can be reset by deleting the '%s' file." % state_file) 281 | elif is_env: 282 | print(replaceStartupWith(entrypointMappings[name])) 283 | 284 | return 285 | 286 | if is_echo: 287 | print("Detected java version as '%s' automatically." % name) 288 | print("This choice can be reset by deleting the '%s' file." % state_file) 289 | elif is_env: 290 | print(replaceStartupWith(entrypointMappings[name])) 291 | except Exception as e: 292 | if is_echo: 293 | print("Couldn't detect jar version - defaulting to '%s'." % default) 294 | elif is_env: 295 | print(replaceStartupWith(entrypointMappings[default])) 296 | 297 | main() 298 | --------------------------------------------------------------------------------