From cac0bbdd977e4adc8b9307f81ac0ebae213d1cd8 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Sat, 31 Oct 2026 20:35:25 +0100 Subject: [PATCH 1/2] fix(velocity): verify transfer cookies before the shared IP login limit --- .github/workflows/ci.yml | 2 + scripts/test-transfer-admission-build.py | 55 +++ scripts/test-transfer-admission.py | 315 ++++++++++++++++++ scripts/transfer-admission/PatchRuntime.java | 99 ++++++ scripts/transfer-admission/README.md | 70 ++++ .../transfer-admission/TransferAdmission.java | 182 ++++++++++ scripts/transfer-admission/build.sh | 8 + velocity/Dockerfile | 5 +- 8 files changed, 735 insertions(+), 1 deletion(-) create mode 100644 scripts/test-transfer-admission-build.py create mode 100644 scripts/test-transfer-admission.py create mode 100644 scripts/transfer-admission/PatchRuntime.java create mode 100644 scripts/transfer-admission/README.md create mode 100644 scripts/transfer-admission/TransferAdmission.java create mode 100644 scripts/transfer-admission/build.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62a563f..a9b0b33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,7 @@ jobs: distribution: temurin java-version: '25' - run: python3 scripts/test-player-access-privacy.py + - run: python3 scripts/test-transfer-admission-build.py startup-contract-tests: name: Container startup contracts @@ -81,6 +82,7 @@ jobs: ${{ matrix.container }}/** ${{ matrix.container == 'buildserver' && 'scripts/test-buildserver-scene-editor-pin.sh' || '' }} ${{ (matrix.container == 'velocity' || matrix.container == 'geyser') && 'scripts/player-access-privacy/**' || '' }} + ${{ matrix.container == 'velocity' && 'scripts/transfer-admission/**' || '' }} - name: 🐳 Set up Docker Buildx if: steps.changed-files.outputs.any_changed == 'true' uses: docker/setup-buildx-action@v4 diff --git a/scripts/test-transfer-admission-build.py b/scripts/test-transfer-admission-build.py new file mode 100644 index 0000000..a2aa139 --- /dev/null +++ b/scripts/test-transfer-admission-build.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Upgrade gate: pinned artifact, exact patch anchors, real LOGIN packets, default off.""" +import importlib.util +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import urllib.request +import zipfile + +sys.dont_write_bytecode = True +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location('privacy', ROOT / 'scripts/test-player-access-privacy.py') +privacy = importlib.util.module_from_spec(spec) +spec.loader.exec_module(privacy) + +with tempfile.TemporaryDirectory(prefix='grounds-admission-build-') as temporary: + work = Path(temporary) + original = work / 'original.jar' + cache = Path(sys.argv[1]) if len(sys.argv) > 1 else None + if cache: + shutil.copyfile(cache, original) + else: + version = privacy.setting('velocity', 'VELOCITY_VERSION') + build = int(privacy.setting('velocity', 'VELOCITY_BUILD')) + request = urllib.request.Request(f'https://fill.papermc.io/v3/projects/velocity/versions/{version}/builds', headers={'User-Agent': privacy.USER_AGENT}) + with urllib.request.urlopen(request, timeout=30) as response: + builds = json.load(response) + selected = next(item for item in builds if item.get('id', item.get('number')) == build) + privacy.fetch(selected['downloads']['server:default']['url'], original) + expected = privacy.setting('velocity', 'VELOCITY_SHA256') + private = work / 'private.jar' + patched = work / 'admission.jar' + privacy.run('sh', str(ROOT / 'scripts/player-access-privacy/build.sh'), 'velocity', str(original), str(private), expected, cwd=ROOT) + patch = ['sh', str(ROOT / 'scripts/transfer-admission/build.sh')] + privacy.run(*patch, str(private), str(patched), privacy.digest(private), cwd=ROOT) + repeated = work / 'repeated.jar' + privacy.run(*patch, str(private), str(repeated), privacy.digest(private), cwd=ROOT) + assert privacy.digest(patched) == privacy.digest(repeated), 'patch must be reproducible' + privacy.run(*patch, str(private), str(work / 'bad-hash.jar'), '0' * 64, cwd=ROOT, expected=1) + privacy.run(*patch, str(patched), str(work / 'double.jar'), privacy.digest(patched), cwd=ROOT, expected=1) + malformed = work / 'drift.jar' + with zipfile.ZipFile(private) as source, zipfile.ZipFile(malformed, 'w') as out: + for item in source.infolist(): + data = source.read(item) + if item.filename.endswith('/HandshakeSessionHandler.class'): + data = privacy.rename_utf8(data, b'handleLogin', b'handleL0gin') + out.writestr(item, data) + privacy.run(*patch, str(malformed), str(work / 'drift-output.jar'), privacy.digest(malformed), cwd=ROOT, expected=1) + subprocess.run([sys.executable, str(ROOT / 'scripts/test-transfer-admission.py'), str(patched)], cwd=ROOT, check=True, timeout=60) + subprocess.run([sys.executable, str(ROOT / 'scripts/test-transfer-admission.py'), str(patched)], env=dict(os.environ, GROUNDS_WIRE_GATE='false'), cwd=ROOT, check=True, timeout=60) +print('Transfer patch upgrade guards and LOGIN wire verified') diff --git a/scripts/test-transfer-admission.py b/scripts/test-transfer-admission.py new file mode 100644 index 0000000..4e9d6bb --- /dev/null +++ b/scripts/test-transfer-admission.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +"""Blackbox the actual Velocity LOGIN wire, with only a loopback admission authority.""" +import http.server +import json +import os +from pathlib import Path +import shutil +import socket +import struct +import subprocess +import sys +import tempfile +import threading +import time +import unittest +import uuid + +JAR = str(Path(sys.argv.pop(1)).resolve()) +KEY = b'grounds:player-session-handoff' +ENABLED = os.environ.get('GROUNDS_WIRE_GATE', 'true') == 'true' + +def vi(n): + out = bytearray() + while n > 127: + out.append((n & 127) | 128) + n >>= 7 + return bytes(out + bytes([n])) + +def string(value): + return vi(len(value)) + value + +def frame(data): + return vi(len(data)) + data + +def exact(s, size): + out = b'' + while len(out) < size: + part = s.recv(size - len(out)) + if not part: + raise EOFError('connection closed') + out += part + return out + +def read_vi(s): + n = 0 + for shift in range(0, 35, 7): + b = exact(s, 1)[0] + n |= (b & 127) << shift + if b < 128: + return n + raise ValueError('oversized varint') + +def packet(s): + return exact(s, read_vi(s)) + +class Authority(http.server.BaseHTTPRequestHandler): + requests = [] + lock = threading.Lock() + reservations = {} + delay = 0 + def log_message(self, *args): + pass + def do_POST(self): + body = json.loads(self.rfile.read(int(self.headers['Content-Length']))) + if self.path.endswith('/abort'): + self.send_response(204) + self.send_header('Content-Length', '0') + self.end_headers() + return + self.requests.append((self.path, self.headers.get('Authorization'), body)) + time.sleep(self.delay) + with self.lock: + profile = body.get('playerId') + owner = self.reservations.get(profile) + binding = (body.get('connectionId'), body.get('destinationProxyId')) + status = 201 if body.get('handoffToken') == 'v' * 43 and owner in (None, binding) else 409 + if status == 201: + self.reservations[profile] = binding + payload = json.dumps(dict(body, expiresAt='2099-01-01T00:00:00Z')).encode() + self.send_response(status) + self.send_header('Content-Type', 'application/json') + self.send_header('Content-Length', str(len(payload))) + self.end_headers() + try: + self.wfile.write(payload) + except (BrokenPipeError, ConnectionResetError): + pass + +class AdmissionWireTest(unittest.TestCase): + counter = 0 + @classmethod + def setUpClass(cls): + cls.private = Path(tempfile.mkdtemp(prefix='grounds-admission-wire-')) + cls.authority = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Authority) + threading.Thread(target=cls.authority.serve_forever, daemon=True).start() + with socket.socket() as s: + s.bind(('127.0.0.1', 0)) + cls.port = s.getsockname()[1] + (cls.private / 'velocity.toml').write_text(f'''config-version = "2.8" +bind = "127.0.0.1:{cls.port}" +online-mode = true +force-key-authentication = false +player-info-forwarding-mode = "none" +forwarding-secret-file = "forwarding.secret" +[servers] +lobby = "127.0.0.1:1" +factions = "127.0.0.1:1" +minigames = "127.0.0.1:1" +try = ["lobby"] +[advanced] +login-ratelimit = 3000 +accepts-transfers = true +[query] +enabled = false +''') + token = cls.private / 'workload-token' + token.write_text('isolated-test-workload') + env = dict(os.environ, GROUNDS_TRANSFER_ADMISSION_ENABLED=str(ENABLED).lower(), + PLAYER_SERVICE_URL=f'http://127.0.0.1:{cls.authority.server_port}', + GROUNDS_TOKEN_FILE=str(token), PROXY_ID='wire-target') + cls.env = env + cls.log = (cls.private / 'proxy.log').open('w') + cls.process = subprocess.Popen([os.environ.get('JAVA', 'java'), '-XX:ActiveProcessorCount=2', '-Xmx256m', '-Dterminal.jline=false', '-Dterminal.ansi=false', '--enable-native-access=ALL-UNNAMED', '-jar', JAR], + cwd=cls.private, env=env, stdin=subprocess.DEVNULL, stdout=cls.log, stderr=subprocess.STDOUT) + deadline = time.monotonic() + 30 + while 'Done (' not in (cls.private / 'proxy.log').read_text(): + if cls.process.poll() is not None or time.monotonic() > deadline: + raise RuntimeError((cls.private / 'proxy.log').read_text()) + time.sleep(.1) + + @classmethod + def tearDownClass(cls): + cls.process.terminate() + try: + cls.process.wait(timeout=10) + except subprocess.TimeoutExpired: + cls.process.kill() + cls.process.wait() + cls.log.close() + cls.authority.shutdown() + cls.authority.server_close() + print((cls.private / 'proxy.log').read_text()[-1500:]) + shutil.rmtree(cls.private) + + def setUp(self): + type(self).counter += 1 + self.ip = '127.0.0.' + str(10 + self.counter) + self.sockets = [] + self.profile = uuid.uuid4() + Authority.requests.clear() + Authority.reservations.clear() + Authority.delay = 0 + + def tearDown(self): + for s in self.sockets: + s.close() + + def connect(self, transfer=True, start=True, port=None): + s = socket.socket() + s.settimeout(5) + s.bind((self.ip, 0)) + s.connect(('127.0.0.1', port or self.port)) + self.sockets.append(s) + s.sendall(frame(b'\0' + vi(769) + string(b'probe.invalid') + struct.pack('>H', self.port) + + vi(3 if transfer else 2))) + if start: + s.sendall(frame(b'\0' + string(b'AdmissionProbe') + self.profile.bytes)) + return s + + def cookie(self, s, token='v' * 43): + p = packet(s) + self.assertEqual(p, b'\x05' + string(KEY), 'transfer must request its handoff before authentication') + data = json.dumps({'playerId': str(self.profile), 'token': token}).encode() + s.sendall(frame(b'\x04' + string(KEY) + b'\x01' + string(data))) + + @unittest.skipUnless(ENABLED, 'gate disabled') + def test_normal_login_then_valid_transfer_same_ip_requires_authentication(self): + self.connect(transfer=False, start=False) + time.sleep(.15) + s = self.connect() + self.cookie(s) + self.assertEqual(packet(s)[0], 1, 'valid handoff must continue to the encryption/authentication request') + self.assertEqual(len(Authority.requests), 1) + path, authorization, body = Authority.requests[0] + self.assertEqual(path, '/v2/players/sessions/transfer/admission') + self.assertEqual(authorization, 'Bearer isolated-test-workload') + self.assertEqual(body['playerId'], str(self.profile)) + self.assertEqual(body['destinationProxyId'], 'wire-target') + self.assertEqual(uuid.UUID(body['connectionId']).version, 4) + + def test_normal_logins_keep_the_existing_ip_limit(self): + self.connect(transfer=False, start=False) + time.sleep(.15) + s = self.connect(transfer=False, start=False) + self.assertIn(b'You are logging in too fast', packet(s)) + self.assertEqual(Authority.requests, []) + + @unittest.skipUnless(ENABLED, 'gate disabled') + def test_bare_transfer_flag_has_no_admission(self): + s = self.connect() + self.assertEqual(packet(s), b'\x05' + string(KEY)) + s.sendall(frame(b'\x04' + string(KEY) + b'\0')) + self.assertEqual(packet(s)[0], 0) + self.assertEqual(Authority.requests, []) + + @unittest.skipUnless(ENABLED, 'gate disabled') + def test_rejected_ticket_never_reaches_authentication(self): + s = self.connect() + self.cookie(s, token='r' * 43) + self.assertEqual(packet(s)[0], 0) + self.assertEqual(len(Authority.requests), 1) + + @unittest.skipUnless(ENABLED, 'gate disabled') + def test_cookie_wait_is_bounded(self): + start = time.monotonic() + s = self.connect() + self.assertEqual(packet(s), b'\x05' + string(KEY)) + self.assertEqual(packet(s)[0], 0) + self.assertLess(time.monotonic() - start, 4.5) + + @unittest.skipUnless(ENABLED, 'gate disabled') + def test_distinct_profiles_share_one_nat_without_colliding(self): + a = self.connect() + self.cookie(a) + self.profile = uuid.uuid4() + b = self.connect() + self.cookie(b) + self.assertEqual(packet(a)[0], 1) + self.assertEqual(packet(b)[0], 1) + self.assertEqual(len(Authority.requests), 2) + + @unittest.skipUnless(ENABLED, 'gate disabled') + def test_two_actual_proxies_cannot_admit_the_same_ticket_twice(self): + with tempfile.TemporaryDirectory(prefix='grounds-second-admission-proxy-') as directory: + work = Path(directory) + with socket.socket() as reserved: + reserved.bind(('127.0.0.1', 0)) + port = reserved.getsockname()[1] + (work / 'velocity.toml').write_text((self.private / 'velocity.toml').read_text().replace(f'127.0.0.1:{self.port}', f'127.0.0.1:{port}')) + with (work / 'proxy.log').open('w') as log: + process = subprocess.Popen([os.environ.get('JAVA', 'java'), '-XX:ActiveProcessorCount=2', '-Xmx256m', '-Dterminal.jline=false', '-Dterminal.ansi=false', '--enable-native-access=ALL-UNNAMED', '-jar', JAR], + cwd=work, env=dict(self.env, PROXY_ID='wire-target-b'), stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT) + try: + deadline = time.monotonic() + 20 + while 'Done (' not in (work / 'proxy.log').read_text(): + if process.poll() is not None or time.monotonic() > deadline: + raise RuntimeError((work / 'proxy.log').read_text()[-2000:]) + time.sleep(.1) + a, b = self.connect(), self.connect(port=port) + self.cookie(a) + self.cookie(b) + self.assertEqual(sorted([packet(a)[0], packet(b)[0]]), [0, 1]) + self.assertEqual({body['destinationProxyId'] for _, _, body in Authority.requests}, {'wire-target', 'wire-target-b'}) + finally: + process.terminate() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill(); process.wait() + + @unittest.skipIf(ENABLED, 'gate enabled') + def test_disabled_gate_preserves_transfer_ip_limit(self): + self.connect(transfer=False, start=False) + time.sleep(.15) + self.assertIn(b'You are logging in too fast', packet(self.connect(start=False))) + self.assertEqual(Authority.requests, []) + + @unittest.skipUnless(ENABLED, 'gate disabled') + def test_forged_profile_is_rejected_before_http_and_authentication(self): + s = self.connect() + self.assertEqual(packet(s), b'\x05' + string(KEY)) + data = json.dumps({'playerId': str(uuid.uuid4()), 'token': 'v' * 43}).encode() + s.sendall(frame(b'\x04' + string(KEY) + b'\x01' + string(data))) + self.assertEqual(packet(s)[0], 0) + self.assertEqual(Authority.requests, []) + + @unittest.skipUnless(ENABLED, 'gate disabled') + def test_cookie_payload_is_limited(self): + s = self.connect() + self.assertEqual(packet(s), b'\x05' + string(KEY)) + s.sendall(frame(b'\x04' + string(KEY) + b'\x01' + string(b'x' * 4097))) + self.assertEqual(packet(s)[0], 0) + self.assertEqual(Authority.requests, []) + + @unittest.skipUnless(ENABLED, 'gate disabled') + def test_parallel_connections_cannot_reuse_an_admission(self): + a, b = self.connect(), self.connect() + self.cookie(a) + self.cookie(b) + self.assertEqual(sorted([packet(a)[0], packet(b)[0]]), [0, 1]) + + @unittest.skipUnless(ENABLED, 'gate disabled') + def test_unanswered_transfer_burst_is_bounded_and_close_releases_budget(self): + waiting = [self.connect() for _ in range(32)] + for s in waiting: + self.assertEqual(packet(s), b'\x05' + string(KEY)) + overflow = self.connect() + self.assertEqual(packet(overflow)[0], 0) + for s in waiting: + s.close() + time.sleep(.2) + valid = self.connect() + self.cookie(valid) + self.assertEqual(packet(valid)[0], 1) + + @unittest.skipUnless(ENABLED, 'gate disabled') + def test_slow_authority_never_opens_authentication_after_timeout(self): + Authority.delay = 4 + s = self.connect() + self.cookie(s) + self.assertEqual(packet(s)[0], 0) + self.assertEqual(s.recv(1), b'') + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/transfer-admission/PatchRuntime.java b/scripts/transfer-admission/PatchRuntime.java new file mode 100644 index 0000000..7c20c2c --- /dev/null +++ b/scripts/transfer-admission/PatchRuntime.java @@ -0,0 +1,99 @@ +package gg.grounds.build.transfer; + +import java.lang.classfile.*; +import java.lang.classfile.instruction.InvokeInstruction; +import java.lang.constant.*; +import java.nio.file.*; +import java.security.MessageDigest; +import java.util.*; +import java.util.zip.*; + +/** Exact pinned bytecode anchors; fails the build on upstream drift or partial application. */ +public final class PatchRuntime { + private static final String BASE = "com/velocitypowered/proxy/"; + private static final ClassDesc CONNECTION = ClassDesc.ofInternalName(BASE + "connection/MinecraftConnection"); + private static final ClassDesc HELPER = ClassDesc.of("gg.grounds.runtime.transfer.TransferAdmission"); + private static final ClassDesc HANDLER = ClassDesc.ofInternalName(BASE + "connection/client/InitialLoginSessionHandler"); + private static final ClassDesc LOGIN = ClassDesc.ofInternalName(BASE + "protocol/packet/ServerLoginPacket"); + private static final ClassDesc COOKIE = ClassDesc.ofInternalName(BASE + "protocol/packet/ServerboundCookieResponsePacket"); + private static final String MARKER = "groundsTransferAdmissionVersion"; + private static String internal(ClassDesc desc) { return desc.descriptorString().substring(1, desc.descriptorString().length() - 1); } + public static void main(String[] args) throws Exception { + Path input = Path.of(args[0]), output = Path.of(args[1]), helpers = Path.of(args[3]); + if (input.toAbsolutePath().equals(output.toAbsolutePath())) throw new IllegalArgumentException("Distinct input/output required"); + if (!HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(input))).equals(args[2])) + throw new IllegalArgumentException("Runtime checksum mismatch"); + ClassFile cf = ClassFile.of(); + Map replacements = new HashMap<>(); + try (ZipFile jar = new ZipFile(input.toFile())) { + if (jar.stream().anyMatch(e -> e.getName().matches("META-INF/.*\\.(SF|RSA|DSA)") || e.getName().startsWith("gg/grounds/runtime/transfer/"))) + throw new IllegalArgumentException("Signed or already patched runtime"); + String handshake = BASE + "connection/client/HandshakeSessionHandler"; + for (String name : List.of(handshake, internal(HANDLER))) { + ClassModel model = cf.parse(jar.getInputStream(jar.getEntry(name + ".class")).readAllBytes()); + if (model.methods().stream().anyMatch(m -> m.methodName().equalsString(MARKER))) throw new IllegalArgumentException("Already patched"); + int[] matched = new int[2]; + byte[] patched = cf.transformClass(model, (builder, element) -> { + if (element instanceof MethodModel method && name.equals(handshake) && method.methodName().equalsString("handleLogin") + && method.methodType().equalsString("(L" + BASE + "protocol/packet/HandshakePacket;L" + BASE + "connection/client/InitialInboundConnection;)V")) { + matched[0]++; + builder.transformMethod(method, MethodTransform.transformingCode((code, instruction) -> { + if (instruction instanceof InvokeInstruction invoke && invoke.owner().asInternalName().equals(BASE + "util/ratelimit/Ratelimiter") + && invoke.name().equalsString("attempt") && invoke.type().equalsString("(Ljava/lang/Object;)Z")) { + matched[1]++; + code.aload(0).getfield(ClassDesc.ofInternalName(name), "connection", CONNECTION).aload(1) + .invokestatic(HELPER, "attempt", MethodTypeDesc.of(ConstantDescs.CD_boolean, + ClassDesc.ofInternalName(BASE + "util/ratelimit/Ratelimiter"), ConstantDescs.CD_Object, + CONNECTION, ClassDesc.ofInternalName(BASE + "protocol/packet/HandshakePacket"))); + } else code.with(instruction); + })); + } else if (element instanceof MethodModel method && name.equals(internal(HANDLER)) && method.methodName().equalsString("handle") + && method.methodType().equalsString("(L" + internal(LOGIN) + ";)Z")) { + matched[0]++; + builder.transformMethod(method, MethodTransform.transformingCode(new CodeTransform() { + public void accept(CodeBuilder code, CodeElement instruction) { code.with(instruction); } + public void atStart(CodeBuilder code) { + matched[1]++; + Label original = code.newLabel(); + code.aload(0).getfield(HANDLER, "mcConnection", CONNECTION).aload(0).aload(1) + .invokestatic(HELPER, "begin", MethodTypeDesc.of(ConstantDescs.CD_boolean, CONNECTION, HANDLER, LOGIN)) + .ifeq(original).iconst_1().ireturn().labelBinding(original); + } + })); + } else builder.with(element); + }); + if (!Arrays.equals(matched, new int[]{1, 1})) throw new IllegalArgumentException("Unexpected anchor shape " + name + Arrays.toString(matched)); + ClassModel intermediate = cf.parse(patched); + if (name.equals(internal(HANDLER)) && intermediate.methods().stream().anyMatch(m -> m.methodName().equalsString("handle") && m.methodType().equalsString("(L" + internal(COOKIE) + ";)Z"))) + throw new IllegalArgumentException("Upstream cookie handler changed"); + patched = cf.transformClass(intermediate, ClassTransform.endHandler(builder -> { + builder.withMethodBody(MARKER, MethodTypeDesc.of(ConstantDescs.CD_int), ClassFile.ACC_PUBLIC | ClassFile.ACC_STATIC, code -> code.iconst_1().ireturn()); + if (name.equals(internal(HANDLER))) builder.withMethodBody("handle", MethodTypeDesc.of(ConstantDescs.CD_boolean, COOKIE), ClassFile.ACC_PUBLIC, + code -> code.aload(0).getfield(HANDLER, "mcConnection", CONNECTION).aload(1) + .invokestatic(HELPER, "cookie", MethodTypeDesc.of(ConstantDescs.CD_boolean, CONNECTION, COOKIE)).ireturn()); + })); + if (!cf.verify(patched).isEmpty()) throw new IllegalArgumentException("Invalid patched class: " + cf.verify(patched)); + replacements.put(name + ".class", patched); + } + Files.createDirectories(output.toAbsolutePath().getParent()); + Path staged = Files.createTempFile(output.toAbsolutePath().getParent(), "transfer-", ".jar"); + try { + try (ZipOutputStream out = new ZipOutputStream(Files.newOutputStream(staged))) { + for (var entry : Collections.list(jar.entries())) { + ZipEntry copy = new ZipEntry(entry.getName()); copy.setTime(entry.getTime()); out.putNextEntry(copy); + if (!entry.isDirectory()) out.write(replacements.containsKey(entry.getName()) ? replacements.get(entry.getName()) : jar.getInputStream(entry).readAllBytes()); + out.closeEntry(); + } + try (var files = Files.list(helpers)) { + for (Path file : files.filter(p -> p.toString().endsWith(".class")).sorted().toList()) { + ZipEntry entry = new ZipEntry("gg/grounds/runtime/transfer/" + file.getFileName()); entry.setTime(0); + out.putNextEntry(entry); out.write(Files.readAllBytes(file)); out.closeEntry(); + } + } + } + Files.move(staged, output, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } finally { Files.deleteIfExists(staged); } + } + System.out.println("Early transfer admission: two exact runtime anchors verified"); + } +} diff --git a/scripts/transfer-admission/README.md b/scripts/transfer-admission/README.md new file mode 100644 index 0000000..cc89581 --- /dev/null +++ b/scripts/transfer-admission/README.md @@ -0,0 +1,70 @@ +# Verified transfer admission + +This build-time patch fixes Velocity's shared-IP login limiter rejecting an automatic +Grounds transfer before plugin-player can validate its handoff. It is **off by default**. +Normal LOGIN uses Velocity's unchanged limiter. TRANSFER can only reach online +authentication after a bounded cookie check and service-player reservation. + +The input first passes the existing privacy patch. Both transformations validate the +pinned upstream checksum and exact method anchors. An upstream change fails the build; +there is no instrumentation agent, plugin-event exemption, or separate Velocity fork. + +## Contract and limits + +- Set `GROUNDS_TRANSFER_ADMISSION_ENABLED=true` only with the matching plugin-player + bridge and the admission/abort API on every service-player replica. Required existing + settings: `PLAYER_SERVICE_URL`, `PROXY_ID`, rotating `GROUNDS_TOKEN_FILE`. +- Cookie: `grounds:player-session-handoff`, maximum 4096 bytes, original 15-second ticket. +- A destination-created random connection UUID and proxy ID bind the early reservation. + That UUID is also the final exact session ID. The authenticated profile must match. +- Cookie + HTTP verification: maximum 3 seconds, HTTP request 2 seconds. Maximum 32 + pending/admitted connections per process. Online authentication is never skipped. +- Service-player shares a 32-outstanding-ticket issuance budget across source proxies + and service replicas; each destination also has a database admission budget of 32. + plugin-agones retries a full budget before its drain deadline and keeps protecting + running rounds. Minting happens at the actual send attempt, never on queue insertion. +- Disconnect/timeout closes the pending socket and sends a bounded best-effort abort. + Abort and final consume lock the same source row. Abort fences a future consume or + deletes only the already committed destination UUID. Lost consume responses also + trigger this exact abort/cleanup in plugin-player. Failure never rebinds an existing + reservation or extends its expiry. Expired tickets cannot authenticate. +- No player IDs, IPs or tokens are metric labels. The service publishes admission result + counters and wait/duration timers. Do not log admission payloads or cookie contents. + +## Rollout and rollback gate + +1. Publish and roll service-player's additive schema/API first; verify **all** serving + replicas implement admission and abort. Old unreserved consumes remain compatible; + reserved tickets refuse legacy unbound consumption. +2. Publish matching plugin-player and plugin-proxy, and bake the updated plugin-agones + into the Velocity image. The gate remains off during this preparation. +3. Test a separate Stage target with the gate enabled: valid handoff, shared NAT, + concurrent source drains, two destination proxies, restart twice, replay, timeout, + missing service, close during consume, and rollback. Keep Bedrock's flag off. +4. Require authenticated Java-player smoke evidence before activating the public Stage + receiver group. Local tests stop at the real online encryption request; their HTTP + authority is a loopback fixture, **not** evidence of a live Mojang-authenticated move. +5. Before rollback, stop new sends to the canary, finish or close outstanding transfers + and wait out the original ticket lifetime. Roll the gate/runtime/plugin group back + together; retain the admission API until no gated proxy/reservation remains. + +No public ingress or PROXY framing is changed by this patch. The separate IP-preservation +phase is gated: current Cilium audit mode does not enforce a trusted-router boundary. +Never enable `haproxy-protocol` against untrusted direct connections or switch a global +mixed Java/Geyser router to PROXY output. `externalTrafficPolicy: Local` also requires +continuous router coverage on every published node, including DNS-TTL overlap on removal. + +## Current route inventory + +| Route | Existing receiver framing | Admission/IP treatment | +| --- | --- | --- | +| Java public NodePort → mc-router → Java Velocity | raw Minecraft TCP | Cluster/SNAT + router shared IP; verified gate handles NAT transfers | +| Same-region, cross-proxy and cross-region host transfers | normal client TRANSFER through regional entry | same authoritative service reservation; `/proxy` also uses session handoff | +| Geyser UDP entry → Geyser → Bedrock Velocity | raw Java/Floodgate connector | gate off; preserve current wait-only Bedrock drain | +| Velocity → Java backend | modern player-info forwarding | separate backend protocol, no ingress PROXY toggle | +| Internal direct proxy connections and TCP readiness | raw TCP | no new trusted-header path; ordinary limiter unchanged | +| HTTP metrics/readiness | HTTP on existing management port | unaffected by Minecraft framing | + +Run `python3 scripts/test-transfer-admission-build.py` with JDK 25. It fetches the pinned +upstream, checks reproducibility/reapplication/checksum/anchor drift, then runs real LOGIN +wire tests with the flag on/off, two Velocity processes and a bounded loopback authority. diff --git a/scripts/transfer-admission/TransferAdmission.java b/scripts/transfer-admission/TransferAdmission.java new file mode 100644 index 0000000..cfc5942 --- /dev/null +++ b/scripts/transfer-admission/TransferAdmission.java @@ -0,0 +1,182 @@ +package gg.grounds.runtime.transfer; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.velocitypowered.api.network.HandshakeIntent; +import com.velocitypowered.api.proxy.Player; +import com.velocitypowered.proxy.connection.MinecraftConnection; +import com.velocitypowered.proxy.connection.client.ConnectedPlayer; +import com.velocitypowered.proxy.connection.client.InitialLoginSessionHandler; +import com.velocitypowered.proxy.protocol.StateRegistry; +import com.velocitypowered.proxy.protocol.packet.*; +import com.velocitypowered.proxy.util.ratelimit.Ratelimiter; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.Component; + +/** Early cookie gate only. The original online authentication still runs after admission. */ +public final class TransferAdmission { + private static final boolean ENABLED = "true".equals(System.getenv("GROUNDS_TRANSFER_ADMISSION_ENABLED")); + private static final Key COOKIE = Key.key("grounds:player-session-handoff"); + // Bounds all waiting sockets, HTTP checks, AND admitted authentications, not just active HTTP. + private static final Semaphore BUDGET = new Semaphore(32); + private static final Semaphore ABORT_BUDGET = new Semaphore(32); + private static final ConcurrentHashMap PENDING = new ConcurrentHashMap<>(); + private static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(1)) + .followRedirects(HttpClient.Redirect.NEVER).build(); + private static final Component FAILED = Component.text("This transfer could not be verified. Please reconnect."); + + private static final class Pending { + final String connectionId = UUID.randomUUID().toString(); + final String proxyId = System.getenv("PROXY_ID"); + ServerLoginPacket login; + InitialLoginSessionHandler handler; + UUID playerId; + String token; + boolean checking; + boolean admitted; + } + + private TransferAdmission() {} + + public static boolean attempt(Ratelimiter limiter, Object address, + MinecraftConnection connection, HandshakePacket handshake) { + if (!ENABLED || handshake.getIntent() != HandshakeIntent.TRANSFER) return limiter.attempt(address); + if (!BUDGET.tryAcquire()) return false; + Pending pending = new Pending(); + if (PENDING.putIfAbsent(connection, pending) != null) { BUDGET.release(); return false; } + connection.getChannel().closeFuture().addListener(f -> { if (remove(connection, pending)) abort(pending); }); + connection.eventLoop().schedule(() -> { + if (PENDING.get(connection) == pending && !pending.admitted) reject(connection, pending); + }, 3, TimeUnit.SECONDS); + // Even admitted sockets cannot keep the finite admission budget forever during auth. + connection.eventLoop().schedule(() -> { + if (PENDING.get(connection) == pending) reject(connection, pending); + }, 15, TimeUnit.SECONDS); + return true; + } + + /** Called before ANY original LoginStart state change, plugin event or encryption request. */ + public static boolean begin(MinecraftConnection connection, InitialLoginSessionHandler handler, + ServerLoginPacket login) { + Pending pending = PENDING.get(connection); + if (pending == null) return false; + if (pending.admitted) return false; // replay by our verified continuation + if (pending.login != null) { reject(connection, pending); return true; } + pending.login = login; + pending.handler = handler; + connection.write(new ClientboundCookieRequestPacket(COOKIE)); + return true; + } + + public static boolean cookie(MinecraftConnection connection, ServerboundCookieResponsePacket packet) { + Pending pending = PENDING.get(connection); + if (pending == null) { connection.close(); return true; } + if (pending.checking || pending.admitted || pending.login == null || !COOKIE.equals(packet.getKey())) { + reject(connection, pending); return true; + } + byte[] payload = packet.getPayload(); + if (payload == null || payload.length > 4096) { reject(connection, pending); return true; } + try { + JsonObject cookie = JsonParser.parseString(new String(payload, StandardCharsets.UTF_8)).getAsJsonObject(); + pending.playerId = UUID.fromString(cookie.get("playerId").getAsString()); + pending.token = cookie.get("token").getAsString(); + if (!pending.token.matches("[A-Za-z0-9_-]{43}") || !pending.playerId.equals(pending.login.getHolderUuid())) { + reject(connection, pending); return true; + } + pending.checking = true; + HttpRequest request = request(pending, ""); + HTTP.sendAsync(request, HttpResponse.BodyHandlers.limiting(HttpResponse.BodyHandlers.ofString(), 4096)) + .orTimeout(2500, TimeUnit.MILLISECONDS).whenComplete((response, error) -> connection.eventLoop().execute(() -> { + if (connection.isClosed() || PENDING.get(connection) != pending) { abort(pending); return; } + try { + if (error != null || response.statusCode() != 201) throw new IllegalStateException(); + JsonObject result = JsonParser.parseString(response.body()).getAsJsonObject(); + if (!pending.playerId.toString().equals(result.get("playerId").getAsString()) + || !pending.connectionId.equals(result.get("connectionId").getAsString()) + || !pending.proxyId.equals(result.get("destinationProxyId").getAsString()) + || !Instant.parse(result.get("expiresAt").getAsString()).isAfter(Instant.now())) throw new IllegalStateException(); + pending.admitted = true; + handlerReplay(connection, pending); + } catch (Exception failure) { reject(connection, pending); } + })); + } catch (Exception failure) { reject(connection, pending); } + return true; + } + + private static HttpRequest request(Pending pending, String suffix) throws Exception { + JsonObject body = new JsonObject(); + body.addProperty("playerId", pending.playerId.toString()); + body.addProperty("handoffToken", pending.token); + body.addProperty("connectionId", pending.connectionId); + body.addProperty("destinationProxyId", pending.proxyId); + String service = System.getenv("PLAYER_SERVICE_URL"); + if (service == null || pending.proxyId == null || pending.proxyId.isBlank()) throw new IllegalStateException(); + String path = System.getenv().getOrDefault("GROUNDS_TOKEN_FILE", "/var/run/secrets/grounds/token"); + // The rotating Workload token is read on each request; never log it or the cookie. + String bearer = Files.readString(Path.of(path)).trim(); + if (bearer.isEmpty() || bearer.length() > 16384) throw new IllegalStateException(); + return HttpRequest.newBuilder(URI.create(service.replaceAll("/$", "") + "/v2/players/sessions/transfer/admission" + suffix)) + .timeout(Duration.ofSeconds(2)).header("Authorization", "Bearer " + bearer) + .header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(body.toString())).build(); + } + + private static void abort(Pending pending) { + if (pending.playerId == null || pending.token == null || !pending.checking || !ABORT_BUDGET.tryAcquire()) return; + try { + HTTP.sendAsync(request(pending, "/abort"), HttpResponse.BodyHandlers.discarding()) + .orTimeout(2500, TimeUnit.MILLISECONDS).whenComplete((response, error) -> ABORT_BUDGET.release()); + } catch (Exception failure) { ABORT_BUDGET.release(); } + // Failure leaves the immutable reservation closed to replacement connections until + // its original short TTL. No exception authorizes a login or extends that TTL. + } + + private static void handlerReplay(MinecraftConnection connection, Pending pending) { + if (connection.getActiveSessionHandler() != pending.handler) { reject(connection, pending); return; } + pending.handler.handle(pending.login); + } + + /** Narrow bridge for plugin-player: final authenticated UUID must match this admission. */ + public static Map admission(Player player) { + if (!(player instanceof ConnectedPlayer connected)) return Map.of(); + Pending pending = PENDING.get(connected.getConnection()); + if (pending == null || !pending.admitted || !player.getUniqueId().equals(pending.playerId)) return Map.of(); + return Map.of("playerId", pending.playerId.toString(), "handoffToken", pending.token, + "connectionId", pending.connectionId, "destinationProxyId", pending.proxyId); + } + + public static void complete(Player player) { + if (player instanceof ConnectedPlayer connected) { + Pending pending = PENDING.get(connected.getConnection()); + if (pending != null) remove(connected.getConnection(), pending); + } + } + + private static void reject(MinecraftConnection connection, Pending pending) { + if (!remove(connection, pending)) return; + abort(pending); + if (connection.isClosed()) return; + if (connection.getState() == StateRegistry.LOGIN) { + connection.closeWith(DisconnectPacket.create(FAILED, connection.getProtocolVersion(), StateRegistry.LOGIN)); + } else connection.close(); + } + + private static boolean remove(MinecraftConnection connection, Pending pending) { + if (!PENDING.remove(connection, pending)) return false; + BUDGET.release(); + return true; + } +} diff --git a/scripts/transfer-admission/build.sh b/scripts/transfer-admission/build.sh new file mode 100644 index 0000000..a109a2d --- /dev/null +++ b/scripts/transfer-admission/build.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu +[ "$#" -eq 3 ] || { echo 'Usage: build.sh input.jar output.jar expected-sha256' >&2; exit 2; } +source_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +classes=$(mktemp -d) +trap 'rm -rf "$classes"' EXIT HUP INT TERM +javac -cp "$1" -d "$classes" "$source_dir/TransferAdmission.java" "$source_dir/PatchRuntime.java" +java -cp "$classes:$1" gg.grounds.build.transfer.PatchRuntime "$1" "$2" "$3" "$classes/gg/grounds/runtime/transfer" diff --git a/velocity/Dockerfile b/velocity/Dockerfile index 236de8b..87be183 100644 --- a/velocity/Dockerfile +++ b/velocity/Dockerfile @@ -6,6 +6,7 @@ ARG VELOCITY_BUILD=30 ARG VELOCITY_SHA256=35a5596a5468a035d8a32c8de5ebb0dc6b8d8f0cc3ff5169d514aca762af8aa8 ARG VELOCITY_DOWNLOAD_USER_AGENT="grounds-containers/1.0 (https://github.com/groundsgg/containers)" COPY scripts/player-access-privacy /privacy +COPY scripts/transfer-admission /transfer-admission RUN set -eu; \ apk add --no-cache curl jq; \ builds_api_url="https://fill.papermc.io/v3/projects/velocity/versions/${VELOCITY_VERSION}/builds"; \ @@ -13,7 +14,9 @@ RUN set -eu; \ download_url="$(jq -r --argjson build "${VELOCITY_BUILD}" 'first(.[] | select(.id == $build or .number == $build) | .downloads["server:default"].url) // empty' /tmp/builds.json)"; \ [ -n "$download_url" ] || { echo "Pinned Velocity build was not found" >&2; exit 1; }; \ curl -fsSL -H "User-Agent: ${VELOCITY_DOWNLOAD_USER_AGENT}" "$download_url" -o /tmp/velocity-original.jar; \ - sh /privacy/build.sh velocity /tmp/velocity-original.jar /out/velocity.jar "$VELOCITY_SHA256" + sh /privacy/build.sh velocity /tmp/velocity-original.jar /tmp/velocity-private.jar "$VELOCITY_SHA256"; \ + privacy_sha256="$(sha256sum /tmp/velocity-private.jar | cut -d' ' -f1)"; \ + sh /transfer-admission/build.sh /tmp/velocity-private.jar /out/velocity.jar "$privacy_sha256" FROM eclipse-temurin:25-jre-alpine From 92feb7dd895a7c2572ce45d81b6c7b36040ea7a3 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Sat, 31 Oct 2026 20:44:33 +0100 Subject: [PATCH 2/2] fix: address transfer admission review regressions --- scripts/test-transfer-admission.py | 18 ++++++++++++ scripts/transfer-admission/README.md | 18 ++++++++++-- .../transfer-admission/TransferAdmission.java | 29 ++++++++++++++++--- 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/scripts/test-transfer-admission.py b/scripts/test-transfer-admission.py index 4e9d6bb..e70a0d2 100644 --- a/scripts/test-transfer-admission.py +++ b/scripts/test-transfer-admission.py @@ -258,6 +258,24 @@ def test_two_actual_proxies_cannot_admit_the_same_ticket_twice(self): except subprocess.TimeoutExpired: process.kill(); process.wait() + @unittest.skipUnless(ENABLED, 'gate disabled') + def test_z_fast_failures_have_a_global_attempt_rate_bound(self): + admitted_to_cookie = 0 + start = time.monotonic() + for _ in range(200): + s = self.connect() + response = packet(s) + if response[0] == 5: + admitted_to_cookie += 1 + s.sendall(frame(b'\x04' + string(KEY) + b'\0')) + self.assertEqual(packet(s)[0], 0) + else: + self.assertEqual(response[0], 0) + s.close() + elapsed = time.monotonic() - start + self.assertLessEqual(admitted_to_cookie, 64 + int(elapsed * 64) + 2, + 'fast invalid attempts must not replenish their own admission budget') + @unittest.skipIf(ENABLED, 'gate enabled') def test_disabled_gate_preserves_transfer_ip_limit(self): self.connect(transfer=False, start=False) diff --git a/scripts/transfer-admission/README.md b/scripts/transfer-admission/README.md index cc89581..1dc7500 100644 --- a/scripts/transfer-admission/README.md +++ b/scripts/transfer-admission/README.md @@ -18,8 +18,12 @@ there is no instrumentation agent, plugin-event exemption, or separate Velocity - A destination-created random connection UUID and proxy ID bind the early reservation. That UUID is also the final exact session ID. The authenticated profile must match. - Cookie + HTTP verification: maximum 3 seconds, HTTP request 2 seconds. Maximum 32 - pending/admitted connections per process. Online authentication is never skipped. -- Service-player shares a 32-outstanding-ticket issuance budget across source proxies + pending/admitted connections per process. A global 64-attempt/second token bucket + (burst 64) bounds fast invalid transfers as well, including direct connections. Closed + connections cancel their timers. Online authentication is never skipped. +- Enabled source proxies request `boundedAdmission=true` when issuing a ticket. + Legacy/off sources retain their existing issuance behavior during API-first rollout. + Service-player shares a 32-outstanding-ticket issuance budget across opted-in source proxies and service replicas; each destination also has a database admission budget of 32. plugin-agones retries a full budget before its drain deadline and keeps protecting running rounds. Minting happens at the actual send attempt, never on queue insertion. @@ -68,3 +72,13 @@ continuous router coverage on every published node, including DNS-TTL overlap on Run `python3 scripts/test-transfer-admission-build.py` with JDK 25. It fetches the pinned upstream, checks reproducibility/reapplication/checksum/anchor drift, then runs real LOGIN wire tests with the flag on/off, two Velocity processes and a bounded loopback authority. + +## Activation requirements still open + +The code does not yet coordinate separate planned restart operations using total backlog +plus authentication time, or discover admission/API capability through arbitrary shared +entry hosts. Retrying until a local drain deadline supplies backpressure, not that broader +rollout guarantee. Until those controls are implemented, use only an isolated, verified +compatible receiver pool for canary testing and do not activate general mixed-version +public routing. Public Stage activation remains blocked on that coordination/routing work +and authenticated Stage evidence; this PR does not mark those plan tasks complete. diff --git a/scripts/transfer-admission/TransferAdmission.java b/scripts/transfer-admission/TransferAdmission.java index cfc5942..ec5dc4b 100644 --- a/scripts/transfer-admission/TransferAdmission.java +++ b/scripts/transfer-admission/TransferAdmission.java @@ -23,6 +23,7 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Semaphore; +import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import net.kyori.adventure.key.Key; import net.kyori.adventure.text.Component; @@ -32,6 +33,7 @@ public final class TransferAdmission { private static final boolean ENABLED = "true".equals(System.getenv("GROUNDS_TRANSFER_ADMISSION_ENABLED")); private static final Key COOKIE = Key.key("grounds:player-session-handoff"); // Bounds all waiting sockets, HTTP checks, AND admitted authentications, not just active HTTP. + private static final AttemptBudget ATTEMPTS = new AttemptBudget(); private static final Semaphore BUDGET = new Semaphore(32); private static final Semaphore ABORT_BUDGET = new Semaphore(32); private static final ConcurrentHashMap PENDING = new ConcurrentHashMap<>(); @@ -39,6 +41,20 @@ public final class TransferAdmission { .followRedirects(HttpClient.Redirect.NEVER).build(); private static final Component FAILED = Component.text("This transfer could not be verified. Please reconnect."); + /** Global, never per-IP: a legitimate shared-NAT burst has the full initial 64 slots. */ + private static final class AttemptBudget { + private double tokens = 64; + private long refilledAt = System.nanoTime(); + synchronized boolean acquire() { + long now = System.nanoTime(); + tokens = Math.min(64, tokens + Math.max(0, now - refilledAt) * (64.0 / 1_000_000_000)); + refilledAt = now; + if (tokens < 1) return false; + tokens -= 1; + return true; + } + } + private static final class Pending { final String connectionId = UUID.randomUUID().toString(); final String proxyId = System.getenv("PROXY_ID"); @@ -47,7 +63,9 @@ private static final class Pending { UUID playerId; String token; boolean checking; - boolean admitted; + volatile boolean admitted; + volatile ScheduledFuture cookieTimeout; + volatile ScheduledFuture authTimeout; } private TransferAdmission() {} @@ -55,15 +73,16 @@ private TransferAdmission() {} public static boolean attempt(Ratelimiter limiter, Object address, MinecraftConnection connection, HandshakePacket handshake) { if (!ENABLED || handshake.getIntent() != HandshakeIntent.TRANSFER) return limiter.attempt(address); - if (!BUDGET.tryAcquire()) return false; + if (!ATTEMPTS.acquire() || !BUDGET.tryAcquire()) return false; Pending pending = new Pending(); if (PENDING.putIfAbsent(connection, pending) != null) { BUDGET.release(); return false; } connection.getChannel().closeFuture().addListener(f -> { if (remove(connection, pending)) abort(pending); }); - connection.eventLoop().schedule(() -> { + if (PENDING.get(connection) != pending) return false; + pending.cookieTimeout = connection.eventLoop().schedule(() -> { if (PENDING.get(connection) == pending && !pending.admitted) reject(connection, pending); }, 3, TimeUnit.SECONDS); // Even admitted sockets cannot keep the finite admission budget forever during auth. - connection.eventLoop().schedule(() -> { + pending.authTimeout = connection.eventLoop().schedule(() -> { if (PENDING.get(connection) == pending) reject(connection, pending); }, 15, TimeUnit.SECONDS); return true; @@ -177,6 +196,8 @@ private static void reject(MinecraftConnection connection, Pending pending) { private static boolean remove(MinecraftConnection connection, Pending pending) { if (!PENDING.remove(connection, pending)) return false; BUDGET.release(); + if (pending.cookieTimeout != null) pending.cookieTimeout.cancel(false); + if (pending.authTimeout != null) pending.authTimeout.cancel(false); return true; } }