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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ jobs:
- paper
- paper-gamemode
- keycloak
- geyser
- plugin-floodgate
steps:
- name: 📥 Checkout code
uses: actions/checkout@v7
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/docker-build-push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ jobs:
- paper
- paper-gamemode
- keycloak
- geyser
- plugin-floodgate
env:
TAG_TYPE: ${{ startsWith(github.ref, format('refs/tags/{0}@', matrix.container)) && 'release' || 'edge' }}
MATCHES_REF: ${{ github.ref_type != 'tag' || startsWith(github.ref, format('refs/tags/{0}@', matrix.container)) }}
Expand Down
65 changes: 65 additions & 0 deletions geyser/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
FROM alpine:3.24 AS downloader

# Pinned, not "latest": Geyser follows Bedrock's protocol, which changes roughly
# every six weeks, and a floating build would turn any unrelated rebuild into an
# unannounced protocol upgrade. Worse, it would do so silently — a Geyser one
# version behind rejects every client that already auto-updated.
ARG GEYSER_VERSION=2.11.1
ARG GEYSER_BUILD=1219
ARG GEYSER_SHA256=b7f1875bfff183a9f27f8bdfea0d289e030516c8d7b7c37678733a79883a9451

RUN set -eux; \
apk add --no-cache curl; \
[ -n "$GEYSER_VERSION" ] || { echo "Geyser version must be set" >&2; exit 1; }; \
[ -n "$GEYSER_BUILD" ] || { echo "Geyser build must be set" >&2; exit 1; }; \
download_url="https://download.geysermc.org/v2/projects/geyser/versions/${GEYSER_VERSION}/builds/${GEYSER_BUILD}/downloads/standalone"; \
curl -fsSL -o /tmp/geyser.jar "$download_url"; \
[ -s /tmp/geyser.jar ] || { echo "Downloaded Geyser jar is empty (version=${GEYSER_VERSION}, build=${GEYSER_BUILD})" >&2; exit 1; }; \
head -c 4 /tmp/geyser.jar | grep -q '^PK' || { echo "Downloaded Geyser jar is not a valid JAR (version=${GEYSER_VERSION}, build=${GEYSER_BUILD})" >&2; exit 1; }; \
echo "${GEYSER_SHA256} /tmp/geyser.jar" | sha256sum -c -

FROM eclipse-temurin:25-jre-alpine

ARG VCS_REVISION
ARG BUILD_DATE
ARG IMAGE_VERSION
ARG GEYSER_VERSION=2.11.1
ARG GEYSER_BUILD=1219

LABEL org.opencontainers.image.title="geyser" \
org.opencontainers.image.description="Geyser Standalone — Bedrock UDP entry point that translates to the Java-protocol Velocity proxy." \
org.opencontainers.image.vendor="grounds.gg" \
org.opencontainers.image.authors="grounds.gg <hi@grounds.gg>" \
org.opencontainers.image.source="https://github.com/groundsgg/containers" \
org.opencontainers.image.revision="$VCS_REVISION" \
org.opencontainers.image.created="$BUILD_DATE" \
org.opencontainers.image.version="$IMAGE_VERSION" \
gg.grounds.geyser.version="$GEYSER_VERSION" \
gg.grounds.geyser.build="$GEYSER_BUILD"

# Geyser resolves config.yml, its locale cache and its pack directory relative to
# the working directory, so this path is part of the contract with the chart.
#
# The rendered config is mounted at /config, NOT here: Geyser rewrites config.yml
# on startup and cannot do that against a read-only mount. start.sh copies it in.
WORKDIR /opt/geyser

COPY --from=downloader /tmp/geyser.jar /opt/geyser/Geyser.jar
COPY --chmod=755 geyser/scripts/start.sh /opt/geyser/start.sh

RUN set -eux; \
addgroup -S geyser; \
adduser -S -G geyser -h /opt/geyser geyser; \
chown -R geyser:geyser /opt/geyser

USER geyser

EXPOSE 19132/udp

# No HEALTHCHECK. Geyser speaks RakNet over UDP and exposes no HTTP endpoint, and
# a UDP port probe cannot distinguish "listening" from "nothing there" — it would
# report healthy for a dead process. An honest check needs an unconnected-ping
# round trip, which is more machinery than it is worth here; Kubernetes restarts
# the pod when the process exits either way.

CMD ["sh", "/opt/geyser/start.sh"]
49 changes: 49 additions & 0 deletions geyser/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# geyser

[Geyser](https://geysermc.org/) Standalone — the Bedrock entry point. It terminates the RakNet/UDP session a Bedrock client opens, translates it to the Java protocol, and connects to a Velocity proxy as an ordinary Java client. Nothing downstream of the proxy learns the player came from Bedrock.

Deployed by the `grounds-geyser` Helm chart, which mounts the rendered `config.yml` and the Floodgate key into this image.

## Pull

```bash
docker pull ghcr.io/groundsgg/geyser:latest
```

## Build

```bash
docker build -f geyser/Dockerfile -t geyser .
```

To use a specific Geyser build:

```bash
docker build -f geyser/Dockerfile \
--build-arg GEYSER_VERSION=2.11.1 \
--build-arg GEYSER_BUILD=1219 \
--build-arg GEYSER_SHA256=b7f1875bfff183a9f27f8bdfea0d289e030516c8d7b7c37678733a79883a9451 \
-t geyser .
```

All three move together — the checksum is verified against the download, so a version bump without a matching digest fails the build rather than shipping something unverified.

## Paths this image guarantees

Geyser resolves everything relative to its working directory, so these are a contract with the chart, not an implementation detail:

| Path | What puts it there |
|---|---|
| `/opt/geyser/Geyser.jar` | this image |
| `/opt/geyser/config.yml` | `grounds-geyser` ConfigMap, mounted read-only via `subPath` |
| `/opt/geyser/floodgate/key.pem` | the `floodgate-key` Secret |

The config is mounted as a single file rather than a directory, because Geyser also writes caches and logs into this directory at runtime.

## Why it is pinned

Bedrock changes its protocol roughly every six weeks and Geyser follows. A floating tag would turn an unrelated pod restart into an unannounced protocol upgrade — and a Geyser one version behind rejects every client that has already auto-updated. Bump it deliberately.

## Why there is no healthcheck

Geyser speaks RakNet over UDP and exposes no HTTP endpoint. A UDP port probe cannot tell "listening" from "nothing there", so it would report healthy for a dead process. An honest check needs an unconnected-ping round trip; Kubernetes restarts the pod when the process exits, which covers the case that actually happens.
21 changes: 21 additions & 0 deletions geyser/scripts/start.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/bin/sh
set -eu

# Geyser does not just read config.yml, it rewrites it: on startup it loads the
# file, fills in every key it has a default for, and saves the result back
# atomically — temp file, then rename over the original.
#
# Mounting the ConfigMap directly at /opt/geyser/config.yml therefore does not
# work. The rename fails with "Resource busy" against the read-only bind mount
# and Geyser refuses to start, which reads as a config error rather than as a
# mount problem.
#
# So the mount lands at /config and is copied into the working directory, which
# is an ordinary writable layer. Geyser's rewrite goes to the copy and is
# discarded when the pod restarts — the ConfigMap stays the only source of
# truth, which is what we want anyway.
if [ -f /config/config.yml ]; then
cp /config/config.yml /opt/geyser/config.yml
fi

exec java -XX:+AlwaysPreTouch -XX:+ParallelRefProcEnabled -XX:+UnlockExperimentalVMOptions -XX:+UseG1GC -jar /opt/geyser/Geyser.jar
47 changes: 47 additions & 0 deletions plugin-floodgate/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
FROM alpine:3.24 AS downloader

# Floodgate is what lets a single online-mode Velocity accept Bedrock players.
# Geyser authenticates them against Xbox Live and signs the player data with a
# shared key; Floodgate verifies that signature and admits the player with a
# UUID derived from their XUID, shaped 00000000-0000-0000-XXXX-XXXXXXXXXXXX so
# it can never collide with a real Java UUID.
#
# Pinned for the same reason Geyser is: the two speak a shared key format and
# player-data schema, so they move together.
ARG FLOODGATE_VERSION=2.2.5
ARG FLOODGATE_BUILD=140
ARG FLOODGATE_SHA256=f5867ad79b90d38abcc72755a685428fbcf423b52c9830a39ffed5203de6936a

RUN set -eux; \
apk add --no-cache curl; \
[ -n "$FLOODGATE_VERSION" ] || { echo "Floodgate version must be set" >&2; exit 1; }; \
[ -n "$FLOODGATE_BUILD" ] || { echo "Floodgate build must be set" >&2; exit 1; }; \
download_url="https://download.geysermc.org/v2/projects/floodgate/versions/${FLOODGATE_VERSION}/builds/${FLOODGATE_BUILD}/downloads/velocity"; \
curl -fsSL -o /tmp/plugin.jar "$download_url"; \
[ -s /tmp/plugin.jar ] || { echo "Downloaded Floodgate jar is empty (version=${FLOODGATE_VERSION}, build=${FLOODGATE_BUILD})" >&2; exit 1; }; \
head -c 4 /tmp/plugin.jar | grep -q '^PK' || { echo "Downloaded Floodgate jar is not a valid JAR (version=${FLOODGATE_VERSION}, build=${FLOODGATE_BUILD})" >&2; exit 1; }; \
echo "${FLOODGATE_SHA256} /tmp/plugin.jar" | sha256sum -c -

FROM alpine:3.24

ARG VCS_REVISION
ARG BUILD_DATE
ARG IMAGE_VERSION
ARG FLOODGATE_VERSION=2.2.5
ARG FLOODGATE_BUILD=140

LABEL org.opencontainers.image.title="plugin-floodgate" \
org.opencontainers.image.description="Hosts the Floodgate Velocity JAR so the grounds-velocity proxy can fetch it at startup." \
org.opencontainers.image.vendor="grounds.gg" \
org.opencontainers.image.authors="grounds.gg <hi@grounds.gg>" \
org.opencontainers.image.source="https://github.com/groundsgg/containers" \
org.opencontainers.image.revision="$VCS_REVISION" \
org.opencontainers.image.created="$BUILD_DATE" \
org.opencontainers.image.version="$IMAGE_VERSION" \
gg.grounds.floodgate.version="$FLOODGATE_VERSION" \
gg.grounds.floodgate.build="$FLOODGATE_BUILD"

# /jar/plugin.jar is the plugin-velocity-jar chart's contract: its init-container
# overrides the entrypoint and copies this path into the shared volume the httpd
# container serves. Nothing in this image is ever executed as written.
COPY --from=downloader --chmod=644 /tmp/plugin.jar /jar/plugin.jar
40 changes: 40 additions & 0 deletions plugin-floodgate/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# plugin-floodgate

Ships the [Floodgate](https://geysermc.org/wiki/floodgate/) Velocity JAR so a `grounds-velocity` proxy can fetch it at startup.

Nothing in this image is ever executed. The `plugin-velocity-jar` chart's init-container overrides the entrypoint and copies `/jar/plugin.jar` into a shared volume, which a tiny httpd container then serves at `/plugin.jar`; the proxy's own init-containers fetch it from there.

## Pull

```bash
docker pull ghcr.io/groundsgg/plugin-floodgate:latest
```

## Build

```bash
docker build -f plugin-floodgate/Dockerfile -t plugin-floodgate .
```

To use a specific Floodgate build:

```bash
docker build -f plugin-floodgate/Dockerfile \
--build-arg FLOODGATE_VERSION=2.2.5 \
--build-arg FLOODGATE_BUILD=140 \
--build-arg FLOODGATE_SHA256=f5867ad79b90d38abcc72755a685428fbcf423b52c9830a39ffed5203de6936a \
-t plugin-floodgate .
```

## What Floodgate is for

A Bedrock player has an Xbox Live account and no Mojang account, so an online-mode proxy has no Java login to verify. Geyser authenticates the player against Xbox Live and signs the resulting player data with a key shared with this plugin; Floodgate verifies that signature and admits the player.

The UUID it assigns is derived from the player's XUID and shaped `00000000-0000-0000-XXXX-XXXXXXXXXXXX`. The zeroed high bits are the point: the UUID can never collide with a real Java UUID, and any service in the network can recognise a Bedrock player from `uuid.mostSignificantBits == 0` without taking a dependency on Floodgate.

The alternative — an offline-mode proxy — derives UUIDs from the *username*, which means a Bedrock player and a Java player sharing a name share an identity.

## Two things it needs on the proxy

- `force-key-authentication = false`, because Bedrock players have no Mojang profile key and are kicked at login while it is enforced. The `velocity` image reads `VELOCITY_FORCE_KEY_AUTHENTICATION` at boot so only the Bedrock proxy relaxes it.
- The same key file Geyser uses, as the `floodgate-key` Secret. It is provisioned out-of-band, the way `velocity-forwarding-secret` is — a generated key would differ on each side and every login would fail its signature check.
10 changes: 10 additions & 0 deletions release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@
"keycloak": {
"release-type": "simple",
"package-name": "keycloak"
},
"geyser": {
"release-type": "simple",
"package-name": "geyser",
"initial-version": "0.1.0"
},
"plugin-floodgate": {
"release-type": "simple",
"package-name": "plugin-floodgate",
"initial-version": "0.1.0"
}
}
}
Expand Down
44 changes: 44 additions & 0 deletions velocity/scripts/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,32 @@ if [ -n "${VELOCITY_FORWARDING_SECRET:-}" ]; then
printf '%s' "$VELOCITY_FORWARDING_SECRET" > /app/forwarding.secret
fi

# velocity.toml is baked into the image, so a release that needs a different
# value for one key would otherwise need its own image. This is the one key that
# genuinely differs per proxy: Floodgate requires force-key-authentication off,
# because a Bedrock player has no Mojang profile key and is kicked at login while
# it is enforced.
#
# Only the Bedrock proxy sets this. Leaving it unset keeps the image default, so
# the Java proxies keep enforcing chat signatures — which is the whole reason
# Bedrock gets a proxy of its own rather than this being relaxed everywhere.
#
# Deliberately BEFORE the offline-mode block: that one forces this key off too,
# and it has to be able to override an explicit `true` here. An offline player
# carries no Mojang-signed key either, so enforcing it there would reject them
# one step later, looking like an unrelated failure.
if [ -n "${VELOCITY_FORCE_KEY_AUTHENTICATION:-}" ]; then
case "$VELOCITY_FORCE_KEY_AUTHENTICATION" in
true|false) ;;
*) echo "VELOCITY_FORCE_KEY_AUTHENTICATION must be 'true' or 'false', got '${VELOCITY_FORCE_KEY_AUTHENTICATION}'" >&2; exit 1 ;;
esac
sed -i "s/^force-key-authentication = .*/force-key-authentication = ${VELOCITY_FORCE_KEY_AUTHENTICATION}/" /app/velocity.toml
grep -q "^force-key-authentication = ${VELOCITY_FORCE_KEY_AUTHENTICATION}$" /app/velocity.toml || {
echo "Failed to set force-key-authentication in /app/velocity.toml" >&2
exit 1
}
fi

# Offline mode, for load testing only. Bot swarms (azalea, mineflayer) have no
# Mojang account, so an online-mode proxy rejects them during login and no load
# ever reaches the backends.
Expand Down Expand Up @@ -70,4 +96,22 @@ if [ -n "${VELOCITY_LOGIN_RATELIMIT:-}" ]; then
}
fi

# Floodgate reads its key from its own plugin data directory, and it also
# CREATES a config.yml there on first start. Mounting the Secret straight onto
# plugins/floodgate would make that directory read-only and the plugin would
# fail to initialise — the same trap Geyser has with its config.
#
# So the Secret is mounted somewhere neutral and copied in, leaving the
# directory writable. Geyser and Floodgate have to see byte-identical keys:
# the key is what Geyser signs Bedrock player data with, and Floodgate rejects
# every login whose signature it cannot verify.
if [ -n "${FLOODGATE_KEY_FILE:-}" ]; then
if [ ! -f "$FLOODGATE_KEY_FILE" ]; then
echo "FLOODGATE_KEY_FILE is set to '${FLOODGATE_KEY_FILE}' but no such file exists" >&2
exit 1
fi
mkdir -p /app/plugins/floodgate
cp "$FLOODGATE_KEY_FILE" /app/plugins/floodgate/key.pem
fi

exec java -XX:+AlwaysPreTouch -XX:+ParallelRefProcEnabled -XX:+UnlockExperimentalVMOptions -XX:+UseG1GC -XX:G1HeapRegionSize=4M -XX:MaxInlineLevel=15 -jar /app/velocity.jar