From b3cac6f857858721a673f1ec77a43e334e810304 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 17 Sep 2026 18:00:06 -0600 Subject: [PATCH 01/19] Add verified VotingPlugin staging service --- .../control/PluginDeploymentService.java | 432 ++++++++++++++++++ 1 file changed, 432 insertions(+) create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java new file mode 100644 index 000000000..3a7d74923 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java @@ -0,0 +1,432 @@ +package com.bencodez.votingplugin.control; + +import java.io.IOException; +import java.io.InputStream; +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.charset.CodingErrorAction; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.time.Duration; +import java.util.HexFormat; +import java.util.Locale; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +/** + * Bounded, pull-only staging for the optional {@code plugin.deploy.v1} capability. + * It deliberately never reloads a plugin or starts a server. The small on-disk + * marker makes a leased task idempotent after a lost result acknowledgement. + */ +public final class PluginDeploymentService { + public static final String CAPABILITY = "plugin.deploy.v1"; + public static final long MAX_ARTIFACT_BYTES = 64L * 1024L * 1024L; + private static final int BUFFER_BYTES = 32 * 1024; + private static final int MAX_JAR_ENTRIES = 10_000; + private static final long MAX_INSPECTED_UNCOMPRESSED_BYTES = 256L * 1024L * 1024L; + private static final int MAX_PLUGIN_YML_BYTES = 64 * 1024; + private static final String MARKER = ".control-deployment"; + private static final ScheduledExecutorService BODY_DEADLINE_EXECUTOR = Executors.newSingleThreadScheduledExecutor(r -> { + Thread thread = new Thread(r, "VotingPlugin-control-artifact-deadline"); + thread.setDaemon(true); + return thread; + }); + + private final Path target; + private final Path root; + private final Path marker; + private final boolean replaceExisting; + private final AtomicBoolean staging = new AtomicBoolean(); + private final AtomicReference activeResponse = new AtomicReference<>(); + + private PluginDeploymentService(Path target, boolean replaceExisting) throws IOException { + this.target = target.toAbsolutePath().normalize(); + this.replaceExisting = replaceExisting; + Path parent = this.target.getParent(); + if (parent == null) throw new IOException("deployment target has no parent"); + Files.createDirectories(parent); + this.root = parent.toRealPath(LinkOption.NOFOLLOW_LINKS); + if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(root)) { + throw new IOException("deployment staging directory is unsafe"); + } + if (!this.target.startsWith(root) || Files.isSymbolicLink(this.target)) { + throw new IOException("deployment target escapes staging directory"); + } + if (Files.exists(this.target, LinkOption.NOFOLLOW_LINKS) + && !Files.isRegularFile(this.target, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("deployment target is not a regular file"); + } + this.marker = root.resolve(this.target.getFileName() + MARKER); + } + + /** Bukkit's update folder preserves the currently loaded plugin JAR. */ + public static PluginDeploymentService backend(Path updateDirectory) throws IOException { + if (updateDirectory == null) throw new IOException("Bukkit update folder is unavailable"); + return new PluginDeploymentService(updateDirectory.resolve("VotingPlugin.jar"), false); + } + + /** Proxies atomically replace their discovered plugin JAR only after a durable backup. */ + public static PluginDeploymentService proxy(Path currentPluginJar) throws IOException { + if (currentPluginJar == null || !currentPluginJar.getFileName().toString().endsWith(".jar")) { + throw new IOException("proxy plugin JAR is unavailable"); + } + if (!Files.isRegularFile(currentPluginJar, LinkOption.NOFOLLOW_LINKS) + || Files.isSymbolicLink(currentPluginJar)) throw new IOException("proxy plugin JAR is unsafe"); + return new PluginDeploymentService(currentPluginJar, true); + } + + public boolean isStaging() { return staging.get(); } + + /** Unblocks an in-progress response read during connector shutdown. */ + public void cancel() { + InputStream response = activeResponse.getAndSet(null); + if (response != null) { + try { response.close(); } catch (IOException ignored) { /* Shutdown is already in progress. */ } + } + } + + public Result deploy(Task task, URI endpoint, String nodeId, UUID sessionId, String credential, + HttpClient http, Duration timeout, BooleanSupplier active) { + if (!staging.compareAndSet(false, true)) return Result.failure("DEPLOYMENT_FAILED", "Another deployment is still staging"); + try { + validate(task); + if (alreadyStaged(task)) return Result.restartRequired(); + if (!active.getAsBoolean()) return Result.failure("CANCELLED", "Deployment was cancelled before download"); + URI artifact = endpoint.resolve("/api/v1/nodes/" + nodeId + "/deployments/" + task.deploymentId() + + "/artifact"); + HttpRequest request = HttpRequest.newBuilder(artifact).timeout(timeout) + .header("Authorization", "Bearer " + credential) + .header("X-Node-Session", sessionId.toString()) + .header("X-Deployment-Attempt", task.attemptId().toString()).GET().build(); + HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofInputStream()); + InputStream body = response.body(); + activeResponse.set(body); + ScheduledFuture bodyDeadline = BODY_DEADLINE_EXECUTOR.schedule(() -> { + try { body.close(); } catch (IOException ignored) { /* The staging operation reports the timeout. */ } + }, timeout.toMillis(), TimeUnit.MILLISECONDS); + try { + if (response.statusCode() != 200) { + body.close(); + return Result.failure("DOWNLOAD_FAILED", "Artifact download was rejected"); + } + String contentLength = response.headers().firstValue("Content-Length").orElse(null); + if (contentLength != null && (!contentLength.matches("[0-9]{1,9}") + || Long.parseLong(contentLength) != task.size())) { + body.close(); + return Result.failure("SIZE_MISMATCH", "Artifact size did not match the deployment task"); + } + if (!active.getAsBoolean()) { + body.close(); + return Result.failure("CANCELLED", "Deployment was cancelled before download"); + } + try (body) { + return stage(task, body, active); + } catch (IOException failure) { + if (!active.getAsBoolean()) return Result.failure("CANCELLED", "Deployment was cancelled before staging"); + return Result.failure("STAGING_FAILED", "Artifact could not be verified or staged on this node"); + } + } finally { + bodyDeadline.cancel(false); + activeResponse.compareAndSet(body, null); + } + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + return Result.failure("CANCELLED", "Deployment download was interrupted"); + } catch (IOException | RuntimeException failure) { + return Result.failure("DOWNLOAD_FAILED", "Artifact download failed"); + } finally { + staging.set(false); + } + } + + /** Package-visible for deterministic artifact-validation tests. */ + Result stage(Task task, InputStream body, BooleanSupplier active) throws IOException { + validate(task); + if (alreadyStaged(task) || recoverInterruptedActivation(task)) return Result.restartRequired(); + activeResponse.compareAndSet(null, body); + Path temporary = Files.createTempFile(root, target.getFileName().toString() + ".", ".download"); + Activation activation = null; + try { + MessageDigest digest = sha256(); + long written = copyExact(body, temporary, task.size(), digest, active); + force(temporary); + if (written != task.size()) return Result.failure("SIZE_MISMATCH", "Artifact size did not match the deployment task"); + if (!HexFormat.of().formatHex(digest.digest()).equals(task.sha256())) { + return Result.failure("HASH_MISMATCH", "Artifact digest did not match the deployment task"); + } + inspectJar(temporary); + if (!active.getAsBoolean()) return Result.failure("CANCELLED", "Deployment was cancelled before staging"); + activation = new Activation(); + activate(temporary, activation); + writeMarker(task); + activation.discard(); + return Result.restartRequired(); + } catch (CancelledDeploymentException failure) { + return Result.failure("CANCELLED", "Deployment was cancelled before staging"); + } catch (InvalidArtifactException failure) { + return Result.failure("INVALID_ARTIFACT", "Artifact is not a bounded VotingPlugin JAR"); + } catch (IOException failure) { + if (activation != null) { + try { activation.rollback(); } + catch (IOException rollbackFailure) { failure.addSuppressed(rollbackFailure); } + } + if (!active.getAsBoolean()) return Result.failure("CANCELLED", "Deployment was cancelled before staging"); + throw failure; + } finally { + activeResponse.compareAndSet(body, null); + Files.deleteIfExists(temporary); + } + } + + private long copyExact(InputStream input, Path temporary, long expected, MessageDigest digest, + BooleanSupplier active) throws IOException { + long total = 0; + byte[] buffer = new byte[BUFFER_BYTES]; + try (var output = Files.newOutputStream(temporary, StandardOpenOption.TRUNCATE_EXISTING)) { + for (;;) { + if (!active.getAsBoolean()) throw new CancelledDeploymentException(); + int read = input.read(buffer); + if (read == -1) break; + total += read; + if (total > expected || total > MAX_ARTIFACT_BYTES) throw new InvalidArtifactException(); + digest.update(buffer, 0, read); + output.write(buffer, 0, read); + } + } + return total; + } + + private void inspectJar(Path artifact) throws IOException { + try (JarFile jar = new JarFile(artifact.toFile(), false)) { + int entries = 0; + long uncompressed = 0; + JarEntry pluginYml = null; + var iterator = jar.entries(); + while (iterator.hasMoreElements()) { + JarEntry entry = iterator.nextElement(); + if (++entries > MAX_JAR_ENTRIES || unsafeEntry(entry.getName())) throw new InvalidArtifactException(); + long size = entry.getSize(); + long compressed = entry.getCompressedSize(); + if (size < 0 || compressed < 0 || compressed > 0 && size > compressed * 200L + || (uncompressed += size) > MAX_INSPECTED_UNCOMPRESSED_BYTES) { + throw new InvalidArtifactException(); + } + if ("plugin.yml".equals(entry.getName())) { + if (pluginYml != null || size > MAX_PLUGIN_YML_BYTES) throw new InvalidArtifactException(); + pluginYml = entry; + } + } + if (pluginYml == null || !isVotingPluginYml(jar, pluginYml)) throw new InvalidArtifactException(); + } catch (java.util.zip.ZipException failure) { + throw new InvalidArtifactException(); + } + } + + private static boolean unsafeEntry(String name) { + if (name == null || name.isEmpty() || name.length() > 512 || name.startsWith("/") + || name.startsWith("\\") || name.contains("\\") || name.indexOf('\0') >= 0) return true; + for (String component : name.split("/", -1)) { + if (component.equals(".") || component.equals("..")) return true; + } + return false; + } + + private static boolean isVotingPluginYml(JarFile jar, JarEntry entry) throws IOException { + byte[] bytes; + try (InputStream input = jar.getInputStream(entry)) { bytes = input.readNBytes(MAX_PLUGIN_YML_BYTES + 1); } + if (bytes.length > MAX_PLUGIN_YML_BYTES) return false; + String yml; + try { + yml = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT).decode(java.nio.ByteBuffer.wrap(bytes)).toString(); + } catch (java.nio.charset.CharacterCodingException failure) { + return false; + } + boolean found = false; + for (String line : yml.split("\\R")) { + if (!line.startsWith("name:")) continue; + if (found) return false; + found = true; + String value = line.substring(5).trim(); + int comment = value.indexOf('#'); + if (comment >= 0) value = value.substring(0, comment).trim(); + if (!"VotingPlugin".equals(unquote(value))) return false; + } + return found; + } + + private static String unquote(String value) { + return value.length() >= 2 && ((value.startsWith("\"") && value.endsWith("\"")) + || (value.startsWith("'") && value.endsWith("'"))) ? value.substring(1, value.length() - 1) : value; + } + + private void activate(Path temporary, Activation activation) throws IOException { + if (replaceExisting) { + if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) { + throw new IOException("current proxy plugin JAR is unsafe"); + } + Path backup = root.resolve(target.getFileName() + ".control-backup"); + if (Files.exists(backup, LinkOption.NOFOLLOW_LINKS) + && (!Files.isRegularFile(backup, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(backup))) { + throw new IOException("proxy plugin backup is unsafe"); + } + Path backupTemp = Files.createTempFile(root, target.getFileName().toString() + ".", ".backup"); + try { + Files.copy(target, backupTemp, StandardCopyOption.REPLACE_EXISTING); + force(backupTemp); + move(backupTemp, backup); + forceDirectory(root); + } finally { Files.deleteIfExists(backupTemp); } + } + move(temporary, target); + activation.published = true; + force(target); + forceDirectory(root); + } + + /** Holds a private copy until the durable idempotency marker has been published. */ + private final class Activation { + private final Path previous; + private boolean published; + + private Activation() throws IOException { + if (!Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + previous = null; + return; + } + if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) { + throw new IOException("deployment target is unsafe"); + } + previous = Files.createTempFile(root, target.getFileName().toString() + ".", ".rollback"); + Files.copy(target, previous, StandardCopyOption.REPLACE_EXISTING); + force(previous); + } + + private void rollback() throws IOException { + if (!published) { + discard(); + return; + } + if (previous == null) { + if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) { + throw new IOException("deployed target cannot be safely removed"); + } + Files.delete(target); + } else { + move(previous, target); + force(target); + } + forceDirectory(root); + discard(); + } + + private void discard() { + if (previous == null) return; + try { Files.deleteIfExists(previous); } + catch (IOException ignored) { /* A private stale rollback copy is safer than a false deployment result. */ } + } + } + + private void writeMarker(Task task) throws IOException { + Path temporary = Files.createTempFile(root, target.getFileName().toString() + ".", ".marker"); + try { + Files.writeString(temporary, task.deploymentId() + "\n" + task.sha256() + "\n" + task.size() + "\n", + StandardCharsets.US_ASCII, StandardOpenOption.TRUNCATE_EXISTING); + force(temporary); + move(temporary, marker); + forceDirectory(root); + } finally { Files.deleteIfExists(temporary); } + } + + private boolean alreadyStaged(Task task) throws IOException { + if (!Files.isRegularFile(marker, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(marker) + || Files.size(marker) > 256) return false; + String[] fields = Files.readString(marker, StandardCharsets.US_ASCII).split("\\R", -1); + if (fields.length < 3 || !task.deploymentId().toString().equals(fields[0]) + || !task.sha256().equals(fields[1]) || !Long.toString(task.size()).equals(fields[2])) return false; + return targetMatches(task); + } + + /** Complete the durable marker half of an activation interrupted after publish. */ + private boolean recoverInterruptedActivation(Task task) throws IOException { + // Proxy replacement creates a durable backup before publishing the target; + // that transaction shape lets a retry distinguish the activation crash window. + // Backend update folders have no such evidence and must still consume/verify + // the newly supplied body when a different deployment id is requested. + if (!replaceExisting) return false; + if (!targetMatches(task)) return false; + writeMarker(task); + return true; + } + + private boolean targetMatches(Task task) throws IOException { + if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target) + || Files.size(target) != task.size()) return false; + MessageDigest digest = sha256(); + try (InputStream input = Files.newInputStream(target, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) { + byte[] bytes = new byte[BUFFER_BYTES]; + for (int read; (read = input.read(bytes)) != -1;) digest.update(bytes, 0, read); + } + if (!task.sha256().equals(HexFormat.of().formatHex(digest.digest()))) return false; + try { + inspectJar(target); + return true; + } catch (InvalidArtifactException invalid) { + return false; + } + } + + private static void move(Path source, Path destination) throws IOException { + try { Files.move(source, destination, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } + catch (AtomicMoveNotSupportedException ignored) { Files.move(source, destination, StandardCopyOption.REPLACE_EXISTING); } + } + + private static void force(Path file) throws IOException { + try (FileChannel channel = FileChannel.open(file, StandardOpenOption.WRITE)) { channel.force(true); } + } + + private static void forceDirectory(Path directory) throws IOException { + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { channel.force(true); } + catch (UnsupportedOperationException ignored) { /* Some supported filesystems cannot fsync directories. */ } + } + + private static MessageDigest sha256() { + try { return MessageDigest.getInstance("SHA-256"); } + catch (java.security.NoSuchAlgorithmException failure) { throw new IllegalStateException("SHA-256 is unavailable", failure); } + } + + private static void validate(Task task) { + if (task == null || task.deploymentId() == null || task.attemptId() == null || task.artifactId() == null + || !task.artifactId().matches("[A-Za-z0-9][A-Za-z0-9._-]{0,127}") || task.sha256() == null + || !task.sha256().matches("[0-9a-fA-F]{64}") || task.size() < 1 || task.size() > MAX_ARTIFACT_BYTES) { + throw new IllegalArgumentException("deployment task is invalid"); + } + } + + public record Task(UUID deploymentId, String artifactId, String sha256, long size, UUID attemptId) { + public Task { if (sha256 != null) sha256 = sha256.toLowerCase(Locale.ROOT); } + } + public record Result(boolean success, String code, String message) { + static Result restartRequired() { return new Result(true, "RESTART_REQUIRED", "Plugin update staged; restart is required"); } + static Result failure(String code, String message) { return new Result(false, code, message); } + } + private static final class InvalidArtifactException extends IOException { private static final long serialVersionUID = 1L; } + private static final class CancelledDeploymentException extends IOException { private static final long serialVersionUID = 1L; } +} From dec7cd1604d573d0101990a1fdf5097c773bbfd7 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 17 Sep 2026 18:00:09 -0600 Subject: [PATCH 02/19] Test verified VotingPlugin staging --- .../control/PluginDeploymentServiceTest.java | 256 ++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java new file mode 100644 index 000000000..f7b6008a7 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java @@ -0,0 +1,256 @@ +package com.bencodez.votingplugin.control; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.UUID; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class PluginDeploymentServiceTest { + @TempDir Path directory; + + @Test void backendStagesOnlyAnExactVerifiedVotingPluginJarAndIsIdempotent() throws Exception { + byte[] artifact = jar("name: VotingPlugin\nmain: example.Main\n"); + PluginDeploymentService service = PluginDeploymentService.backend(directory.resolve("update")); + PluginDeploymentService.Task task = task(artifact); + + PluginDeploymentService.Result staged = service.stage(task, new ByteArrayInputStream(artifact), () -> true); + + assertTrue(staged.success()); + assertEquals("RESTART_REQUIRED", staged.code()); + assertEquals(artifact.length, Files.size(directory.resolve("update/VotingPlugin.jar"))); + assertTrue(service.stage(task, new ByteArrayInputStream(new byte[0]), () -> true).success()); + } + + @Test void backendIgnoresMatchingMarkerOnlyWhenTargetIsValidThenRestagesWhenCorrupted() throws Exception { + byte[] artifact = jar("name: VotingPlugin\n"); + PluginDeploymentService service = PluginDeploymentService.backend(directory.resolve("update")); + PluginDeploymentService.Task task = task(artifact); + + service.stage(task, new ByteArrayInputStream(artifact), () -> true); + assertArrayEquals(artifact, Files.readAllBytes(directory.resolve("update/VotingPlugin.jar"))); + + byte[] corrupted = new byte[artifact.length]; + Arrays.fill(corrupted, (byte) 0x42); + Files.write(directory.resolve("update/VotingPlugin.jar"), corrupted); + assertArrayEquals(corrupted, Files.readAllBytes(directory.resolve("update/VotingPlugin.jar"))); + + assertTrue(service.stage(task, new ByteArrayInputStream(artifact), () -> true).success()); + assertArrayEquals(artifact, Files.readAllBytes(directory.resolve("update/VotingPlugin.jar"))); + } + + @Test void invalidPluginIdentityAndDigestNeverReachTheUpdateFolder() throws Exception { + byte[] wrongPlugin = jar("name: NotVotingPlugin\n"); + PluginDeploymentService service = PluginDeploymentService.backend(directory.resolve("update")); + PluginDeploymentService.Result invalid = service.stage(task(wrongPlugin), new ByteArrayInputStream(wrongPlugin), () -> true); + assertFalse(invalid.success()); + assertEquals("INVALID_ARTIFACT", invalid.code()); + assertFalse(Files.exists(directory.resolve("update/VotingPlugin.jar"))); + + byte[] good = jar("name: VotingPlugin\n"); + PluginDeploymentService.Task mismatch = new PluginDeploymentService.Task(UUID.randomUUID(), "vp.jar", + "0".repeat(64), good.length, UUID.randomUUID()); + assertEquals("HASH_MISMATCH", service.stage(mismatch, new ByteArrayInputStream(good), () -> true).code()); + } + + @Test void proxyKeepsTheCurrentJarAsABackupBeforeReplacement() throws Exception { + Path current = directory.resolve("VotingPlugin.jar"); + byte[] original = jar("name: VotingPlugin\nversion: 0\n"); + byte[] first = jar("name: VotingPlugin\nversion: 1\n"); + byte[] second = jar("name: VotingPlugin\nversion: 2\n"); + Files.write(current, original); + PluginDeploymentService service = PluginDeploymentService.proxy(current); + + assertEquals("RESTART_REQUIRED", service.stage(task(first), new ByteArrayInputStream(first), () -> true).code()); + assertEquals("RESTART_REQUIRED", service.stage(task(second), new ByteArrayInputStream(second), () -> true).code()); + assertArrayEquals(first, Files.readAllBytes(directory.resolve("VotingPlugin.jar.control-backup"))); + assertArrayEquals(second, Files.readAllBytes(current)); + } + + @Test void proxyRecoversActivationInterruptedBeforeMarkerWithoutReplacingBackup() throws Exception { + Path current = directory.resolve("VotingPlugin.jar"); + Path backup = directory.resolve("VotingPlugin.jar.control-backup"); + Path marker = directory.resolve("VotingPlugin.jar.control-deployment"); + byte[] original = jar("name: VotingPlugin\nversion: original\n"); + byte[] candidate = jar("name: VotingPlugin\nversion: candidate\n"); + PluginDeploymentService.Task task = task(candidate); + Files.write(current, candidate); + Files.write(backup, original); + PluginDeploymentService service = PluginDeploymentService.proxy(current); + + assertEquals("RESTART_REQUIRED", + service.stage(task, new ByteArrayInputStream(new byte[0]), () -> true).code()); + + assertArrayEquals(candidate, Files.readAllBytes(current)); + assertArrayEquals(original, Files.readAllBytes(backup)); + String state = Files.readString(marker, StandardCharsets.US_ASCII); + assertTrue(state.contains(task.deploymentId().toString())); + assertTrue(state.contains(task.sha256())); + assertTrue(state.contains(Long.toString(task.size()))); + } + + @Test void cancellationDuringCopyDoesNotPublish() throws Exception { + byte[] artifact = jar("name: VotingPlugin\n"); + PluginDeploymentService service = PluginDeploymentService.backend(directory.resolve("update")); + PluginDeploymentService.Task task = task(artifact); + + assertEquals("RESTART_REQUIRED", service.stage(task, new ByteArrayInputStream(artifact), () -> true).code()); + String markerBefore = Files.readString(directory.resolve("update/VotingPlugin.jar.control-deployment")); + byte[] targetBefore = Files.readAllBytes(directory.resolve("update/VotingPlugin.jar")); + + AtomicBoolean active = new AtomicBoolean(true); + InputStream body = new CancellingInputStream(new ByteArrayInputStream(artifact), active); + PluginDeploymentService.Task replacement = task(artifact); + assertEquals("CANCELLED", service.stage(replacement, body, active::get).code()); + + assertEquals(markerBefore, Files.readString(directory.resolve("update/VotingPlugin.jar.control-deployment"))); + assertArrayEquals(targetBefore, Files.readAllBytes(directory.resolve("update/VotingPlugin.jar"))); + assertFalse(active.get()); + } + + @Test void markerPublicationFailureRestoresThePreviousArtifact() throws Exception { + Path update = directory.resolve("update"); + Files.createDirectories(update); + byte[] original = jar("name: VotingPlugin\nversion: original\n"); + byte[] candidate = jar("name: VotingPlugin\nversion: candidate\n"); + Path target = update.resolve("VotingPlugin.jar"); + Files.write(target, original); + Path marker = update.resolve("VotingPlugin.jar.control-deployment"); + Files.createDirectory(marker); + Files.writeString(marker.resolve("keep"), "marker publication must fail"); + PluginDeploymentService service = PluginDeploymentService.backend(update); + + assertThrows(IOException.class, () -> service.stage(task(candidate), new ByteArrayInputStream(candidate), () -> true)); + + assertArrayEquals(original, Files.readAllBytes(target)); + } + + @Test void proxyMarkerPublicationFailureRestoresTheCurrentJarAndKeepsItsBackup() throws Exception { + byte[] original = jar("name: VotingPlugin\nversion: original\n"); + byte[] candidate = jar("name: VotingPlugin\nversion: candidate\n"); + Path target = directory.resolve("VotingPlugin.jar"); + Files.write(target, original); + Path marker = directory.resolve("VotingPlugin.jar.control-deployment"); + Files.createDirectory(marker); + Files.writeString(marker.resolve("keep"), "marker publication must fail"); + PluginDeploymentService service = PluginDeploymentService.proxy(target); + + assertThrows(IOException.class, () -> service.stage(task(candidate), new ByteArrayInputStream(candidate), () -> true)); + + assertArrayEquals(original, Files.readAllBytes(target)); + assertArrayEquals(original, Files.readAllBytes(directory.resolve("VotingPlugin.jar.control-backup"))); + } + + @Test void cancellationClosesAStalledArtifactStream() throws Exception { + PluginDeploymentService service = PluginDeploymentService.backend(directory.resolve("update")); + AtomicBoolean active = new AtomicBoolean(true); + BlockingInputStream stalled = new BlockingInputStream(active); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future result = executor.submit(() -> service.stage( + task(new byte[] { 1 }), stalled, active::get)); + assertTrue(stalled.awaitRead()); + service.cancel(); + assertEquals("CANCELLED", result.get(5, TimeUnit.SECONDS).code()); + assertFalse(Files.exists(directory.resolve("update/VotingPlugin.jar"))); + } finally { + executor.shutdownNow(); + } + } + + private static PluginDeploymentService.Task task(byte[] artifact) throws Exception { + String digest = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(artifact)); + return new PluginDeploymentService.Task(UUID.randomUUID(), "votingplugin.jar", digest, artifact.length, + UUID.randomUUID()); + } + + private static byte[] jar(String pluginYml) throws Exception { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (JarOutputStream jar = new JarOutputStream(bytes)) { + jar.putNextEntry(new JarEntry("plugin.yml")); + jar.write(pluginYml.getBytes(StandardCharsets.UTF_8)); + jar.closeEntry(); + jar.putNextEntry(new JarEntry("example/Main.class")); + jar.write(new byte[] { 0, 1, 2 }); + jar.closeEntry(); + } + return bytes.toByteArray(); + } + + private static class CancellingInputStream extends InputStream { + private final InputStream delegate; + private final AtomicBoolean active; + private boolean cancelled; + + private CancellingInputStream(InputStream delegate, AtomicBoolean active) { + this.delegate = delegate; + this.active = active; + } + + @Override + public int read(byte[] bytes, int start, int length) throws IOException { + int read = delegate.read(bytes, start, length); + if (!cancelled && read > 0) { + cancelled = true; + active.set(false); + } + return read; + } + + @Override + public int read() throws IOException { return delegate.read(); } + } + + private static class BlockingInputStream extends InputStream { + private final AtomicBoolean active; + private final CountDownLatch reading = new CountDownLatch(1); + private boolean closed; + + private BlockingInputStream(AtomicBoolean active) { this.active = active; } + + private boolean awaitRead() throws InterruptedException { return reading.await(5, TimeUnit.SECONDS); } + + @Override + public synchronized int read(byte[] bytes, int start, int length) throws IOException { + reading.countDown(); + while (!closed) { + try { wait(); } + catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new IOException(failure); } + } + throw new IOException("stream closed"); + } + + @Override + public int read() throws IOException { return read(new byte[1], 0, 1); } + + @Override + public synchronized void close() { + closed = true; + active.set(false); + notifyAll(); + } + } +} From 157ddde85cb795ec3d96e53ad4b967017362768b Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 17 Sep 2026 18:00:12 -0600 Subject: [PATCH 03/19] Enable verified Control staging on Bukkit nodes --- .../control/BackendControlConnector.java | 92 ++++++++++++++++++- 1 file changed, 90 insertions(+), 2 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java index 58a5a3209..2512b5418 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java @@ -61,6 +61,7 @@ public final class BackendControlConnector implements AutoCloseable { private final HttpClient http; private final BackendConfigurationService configurations; private final ControlInspectionService inspections; + private final PluginDeploymentService deployments; private final UUID sessionId = UUID.randomUUID(); private final Map completed = new LinkedHashMap<>(); private final boolean recovering; @@ -74,12 +75,14 @@ public final class BackendControlConnector implements AutoCloseable { private volatile boolean quickSetupsAccepted; private volatile boolean voteSitesSyncAccepted; private volatile boolean inspectionsAccepted; + private volatile boolean deploymentsAccepted; private volatile int inspectionFailures; private volatile long inspectionRetryAtNanos; private volatile int failures; private volatile ScheduledFuture scheduled; private volatile ScheduledFuture operationPolling; private volatile ScheduledFuture inspectionPolling; + private volatile ScheduledFuture deploymentPolling; private volatile Future activeReload; private volatile CompletableFuture activeOperation; @@ -108,6 +111,13 @@ private BackendControlConnector(VotingPluginMain plugin, Path dataDirectory, Set .followRedirects(HttpClient.Redirect.NEVER).build(); configurations = new BackendConfigurationService(plugin.getDataFolder().toPath(), this::reloadConfiguration); inspections = new ControlInspectionService(plugin); + PluginDeploymentService prepared = null; + try { + prepared = PluginDeploymentService.backend(plugin.getServer().getUpdateFolderFile().toPath()); + } catch (Exception failure) { + plugin.getLogger().warning("[Control] Plugin deployment staging is unavailable; capability not advertised"); + } + deployments = prepared; } private void reloadConfiguration(String fileName) throws Exception { @@ -190,6 +200,73 @@ public void start() { OPERATION_POLL_MILLIS, OPERATION_POLL_MILLIS, TimeUnit.MILLISECONDS); inspectionPolling = inspectionExecutor.scheduleWithFixedDelay(this::pollInspections, OPERATION_POLL_MILLIS, OPERATION_POLL_MILLIS, TimeUnit.MILLISECONDS); + if (deployments != null) { + deploymentPolling = executor.scheduleWithFixedDelay(this::pollDeployments, + OPERATION_POLL_MILLIS, OPERATION_POLL_MILLIS, TimeUnit.MILLISECONDS); + } + } + + /** Deployment is a separate leased lane; its large I/O never runs on Bukkit's primary thread. */ + private void pollDeployments() { + if (closed || deployments == null || !registered || failures != 0 || !deploymentsAccepted + || !running.compareAndSet(false, true)) return; + CompletableFuture operation = new CompletableFuture<>(); + synchronized (operationLifecycle) { + if (closed) { + running.set(false); + return; + } + activeOperation = operation; + } + try { + claimAndDeploy(); + } catch (Exception failure) { + registered = false; + failures = Math.min(30, failures + 1); + if (failures == 1 || failures % 10 == 0) { + plugin.getLogger().warning("[Control] Bukkit deployment polling unavailable; VotingPlugin remains active"); + } + ScheduledFuture heartbeat = scheduled; + if (heartbeat != null) heartbeat.cancel(false); + if (!closed) schedule(Math.min(TimeUnit.MINUTES.toMillis(5), + 1000L << Math.min(failures - 1, 8))); + } finally { + operation.complete(null); + synchronized (operationLifecycle) { + if (activeOperation == operation) activeOperation = null; + } + running.set(false); + } + } + + private void claimAndDeploy() throws Exception { + JsonObject body = new JsonObject(); + body.addProperty("sessionId", sessionId.toString()); + Response response = send("POST", "/api/v1/nodes/" + settings.nodeId() + "/deployments", body); + if (response.status() == 204 || closed) return; + JsonObject claimed = requireObject(response, 200); + PluginDeploymentService.Task task = deploymentTask(claimed); + PluginDeploymentService.Result result = deployments.deploy(task, settings.endpoint(), settings.nodeId(), sessionId, + credential, http, Duration.ofMillis(settings.requestTimeoutMillis()), () -> !closed); + if (closed) return; + JsonObject submitted = new JsonObject(); + submitted.addProperty("sessionId", sessionId.toString()); + submitted.addProperty("success", result.success()); + submitted.addProperty("code", result.code()); + submitted.addProperty("message", boundedResultMessage(result.message())); + submitted.addProperty("attemptId", task.attemptId().toString()); + requireObject(send("POST", "/api/v1/nodes/" + settings.nodeId() + "/deployments/" + task.deploymentId() + + "/result", submitted), 200); + } + + private static PluginDeploymentService.Task deploymentTask(JsonObject task) { + try { + return new PluginDeploymentService.Task(UUID.fromString(string(task, "deploymentId")), + string(task, "artifactId"), string(task, "sha256"), Long.parseLong(string(task, "size")), + UUID.fromString(string(task, "attemptId"))); + } catch (RuntimeException failure) { + throw new IllegalArgumentException("deployment task is invalid"); + } } /** Polls the separately negotiated read-only lane on the connector worker. */ @@ -308,6 +385,8 @@ private void cycle() { voteSitesSyncAccepted = negotiatedCapability(node, "config.vote-sites-sync.v1", voteSitesSyncAccepted); boolean inspectionsWereAccepted = inspectionsAccepted; inspectionsAccepted = negotiatedCapability(node, "data.inspect.v1", inspectionsAccepted); + deploymentsAccepted = deployments != null + && negotiatedCapability(node, PluginDeploymentService.CAPABILITY, deploymentsAccepted); if (inspectionsAccepted && !inspectionsWereAccepted) { inspectionFailures = 0; inspectionRetryAtNanos = 0; @@ -358,6 +437,7 @@ private JsonObject register() throws Exception { quickSetupsAccepted = false; voteSitesSyncAccepted = false; inspectionsAccepted = false; + deploymentsAccepted = false; JsonObject body = sessionBody(); body.addProperty("nodeId", settings.nodeId()); body.addProperty("displayName", settings.nodeId()); @@ -368,13 +448,13 @@ private JsonObject register() throws Exception { .map(installed -> installed.getDescription().getName()).filter(name -> name != null && !name.isBlank()) .distinct().sorted(String.CASE_INSENSITIVE_ORDER).limit(128).forEach(detectedPlugins::add); body.add("detectedPlugins", detectedPlugins); - addCapabilities(body); + addCapabilities(body, deployments != null); return requireObject(send("POST", "/api/v1/nodes/register", body), 200, 201); } private JsonObject heartbeat() throws Exception { JsonObject body = sessionBody(); - addCapabilities(body); + addCapabilities(body, deployments != null); Response response = send("PUT", "/api/v1/nodes/" + settings.nodeId() + "/heartbeat", body); if (response.status() == 404) { registered = false; @@ -762,8 +842,13 @@ private JsonObject sessionBody() { } static void addCapabilities(JsonObject body) { + addCapabilities(body, false); + } + + static void addCapabilities(JsonObject body, boolean deploymentReady) { JsonArray capabilities = new JsonArray(); CAPABILITIES.stream().sorted().forEach(capabilities::add); + if (deploymentReady) capabilities.add(PluginDeploymentService.CAPABILITY); body.add("capabilities", capabilities); JsonArray required = new JsonArray(); required.add("config.files.v1"); @@ -831,6 +916,9 @@ public void close() { if (polling != null) polling.cancel(false); ScheduledFuture inspection = inspectionPolling; if (inspection != null) inspection.cancel(false); + ScheduledFuture deployment = deploymentPolling; + if (deployment != null) deployment.cancel(false); + if (deployments != null) deployments.cancel(); inspectionExecutor.shutdownNow(); if (reload != null && Bukkit.isPrimaryThread()) reload.cancel(false); awaitShutdown(executor, operation); From c3be44076922f66bed6f5e5f82019360ec1bedad Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 17 Sep 2026 18:00:20 -0600 Subject: [PATCH 04/19] Cover deployment capability advertisement --- .../control/BackendControlConnectorProtocolTest.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java index c0452d01d..ffb06480b 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java @@ -95,6 +95,18 @@ class BackendControlConnectorProtocolTest { .anyMatch(value -> "data.inspect.v1".equals(value.getAsString()))); } + @Test void deploymentCapabilityIsOnlyAddedWhenStagingWasPrepared() { + JsonObject unavailable = new JsonObject(); + BackendControlConnector.addCapabilities(unavailable); + assertFalse(unavailable.getAsJsonArray("capabilities").asList().stream() + .anyMatch(value -> PluginDeploymentService.CAPABILITY.equals(value.getAsString()))); + + JsonObject ready = new JsonObject(); + BackendControlConnector.addCapabilities(ready, true); + assertTrue(ready.getAsJsonArray("capabilities").asList().stream() + .anyMatch(value -> PluginDeploymentService.CAPABILITY.equals(value.getAsString()))); + } + @Test void heartbeatRetainsOmittedCapabilitiesAndHonorsExplicitReplacement() { JsonObject omitted = new JsonObject(); assertTrue(BackendControlConnector.negotiatedCapability(omitted, "config.files.v1", true)); From d89bdf51838390727c15a1820401eac09c06e59a Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 17 Sep 2026 18:00:52 -0600 Subject: [PATCH 05/19] Enable verified Control staging on proxy nodes --- .../proxy/control/ControlConnector.java | 141 +++++++++++++++++- 1 file changed, 140 insertions(+), 1 deletion(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java index aa1f4fa40..9d24533fe 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java @@ -2,6 +2,7 @@ import java.io.IOException; import java.net.URI; +import java.net.URISyntaxException; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; @@ -22,6 +23,8 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ThreadLocalRandom; @@ -33,6 +36,7 @@ import java.util.function.Supplier; import java.util.regex.Pattern; +import com.bencodez.votingplugin.control.PluginDeploymentService; import com.bencodez.votingplugin.proxy.VotingPluginProxy; import com.bencodez.votingplugin.proxy.VotingPluginProxyConfig; import com.bencodez.votingplugin.proxy.presence.BackendPresenceStatus; @@ -77,6 +81,10 @@ public final class ControlConnector implements AutoCloseable { private final ProxyRoutingConfigurationService configurationService; private final ProxyMethodConfigurationService methodConfigurationService; private final ProxyConfigurationFileService fileConfigurationService; + private final PluginDeploymentService deployments; + private final HttpClient deploymentHttp; + private final String deploymentCredential; + private final ExecutorService deploymentExecutor; private final Function> communicationTest; private final Runnable runtimeReplacement; private final Path dataDirectory; @@ -91,11 +99,13 @@ public final class ControlConnector implements AutoCloseable { private volatile boolean closed; private volatile boolean registered; private volatile boolean configurationAccepted; + private volatile boolean deploymentsAccepted; private volatile Set acceptedCapabilities = Set.of(); private volatile int failures; private volatile long snapshotSequence; private volatile ScheduledFuture scheduled; private volatile ScheduledFuture operationPolling; + private volatile ScheduledFuture deploymentPolling; private volatile CompletableFuture activeRequest; private volatile CompletableFuture activeOperation; private volatile Status status = Status.STARTING; @@ -130,6 +140,19 @@ private ControlConnector(Settings settings, ScheduledExecutorService scheduler, Function> communicationTest, ProxyMethodConfigurationService methodConfigurationService, Runnable runtimeReplacement, ProxyConfigurationFileService fileConfigurationService) { + this(settings, scheduler, transport, snapshotSource, logger, sessionId, jitterSource, configurationService, + dataDirectory, route, recovering, recoveryComplete, communicationTest, methodConfigurationService, + runtimeReplacement, fileConfigurationService, null, null, null); + } + + private ControlConnector(Settings settings, ScheduledExecutorService scheduler, Transport transport, + Supplier> snapshotSource, Consumer logger, UUID sessionId, + LongSupplier jitterSource, ProxyRoutingConfigurationService configurationService, Path dataDirectory, + Route route, boolean recovering, Runnable recoveryComplete, + Function> communicationTest, + ProxyMethodConfigurationService methodConfigurationService, Runnable runtimeReplacement, + ProxyConfigurationFileService fileConfigurationService, PluginDeploymentService deployments, + HttpClient deploymentHttp, String deploymentCredential) { this.settings = Objects.requireNonNull(settings, "settings"); this.scheduler = Objects.requireNonNull(scheduler, "scheduler"); this.transport = Objects.requireNonNull(transport, "transport"); @@ -142,6 +165,14 @@ private ControlConnector(Settings settings, ScheduledExecutorService scheduler, this.communicationTest = communicationTest; this.runtimeReplacement = runtimeReplacement; this.fileConfigurationService = fileConfigurationService; + this.deployments = deployments; + this.deploymentHttp = deploymentHttp; + this.deploymentCredential = deploymentCredential; + this.deploymentExecutor = deployments == null ? null : Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, "votingplugin-control-proxy-deployment"); + thread.setDaemon(true); + return thread; + }); this.dataDirectory = dataDirectory; this.route = route; this.recovering = recovering; @@ -210,12 +241,17 @@ public static ControlConnector create(VotingPluginProxy proxy) throws IOExceptio backends.sort(Comparator.comparing(ObservedBackend::backendId)); return List.copyOf(backends); }; + PluginDeploymentService deployments = prepareDeployment(proxy); + HttpClient deploymentHttp = deployments == null ? null : HttpClient.newBuilder() + .connectTimeout(Duration.ofMillis(settings.connectTimeoutMillis())) + .followRedirects(HttpClient.Redirect.NEVER).build(); ControlConnector connector = new ControlConnector(settings, proxy.getScheduler(), transport, snapshot, message -> proxy.log("[Control] " + message), UUID.randomUUID(), () -> ThreadLocalRandom.current().nextLong(), new ProxyRoutingConfigurationService(proxy), dataDirectory, route, recovering, proxy::restartControlServicesAfterRecovery, server -> proxy.testBackendCommunication(server, 5000L), new ProxyMethodConfigurationService(proxy), - () -> proxy.reloadCore(true), new ProxyConfigurationFileService(proxy)); + () -> proxy.reloadCore(true), new ProxyConfigurationFileService(proxy), + deployments, deploymentHttp, credential); if (recovered != null) connector.completedTasks.putAll(recovered.results()); return connector; } @@ -228,6 +264,92 @@ public void start() { schedule(0); operationPolling = scheduler.scheduleWithFixedDelay(this::pollOperations, OPERATION_POLL_MILLIS, OPERATION_POLL_MILLIS, TimeUnit.MILLISECONDS); + if (deployments != null) { + deploymentPolling = scheduler.scheduleWithFixedDelay(this::pollDeployments, + OPERATION_POLL_MILLIS, OPERATION_POLL_MILLIS, TimeUnit.MILLISECONDS); + } + } + + /** Keeps large artifact transfer and disk staging off proxy event threads and serializes it with operations. */ + private void pollDeployments() { + synchronized (operationLifecycle) { + if (closed || !registered || status != Status.CONNECTED || !deploymentsAccepted || deployments == null + || !inFlight.compareAndSet(false, true)) return; + } + CompletableFuture done = new CompletableFuture<>(); + activeOperation = done; + CompletableFuture work; + try { + CompletableFuture claim = transport.send(deploymentClaimRequest()); + activeRequest = claim; + work = claim.thenCompose(this::handleDeploymentClaim); + } catch (RuntimeException failure) { + work = CompletableFuture.failedFuture(failure); + } + work.whenComplete((ignored, failure) -> { + Throwable cause = failure == null ? null : unwrap(failure); + activeRequest = null; + if (cause == null) { + done.complete(null); + } else { + registered = false; + done.completeExceptionally(cause); + } + if (activeOperation == done) activeOperation = null; + finishCycle(); + if (cause != null && !closed) onFailure(cause); + }); + } + + private Request deploymentClaimRequest() { + JsonObject body = sessionBody(); + return new Request("POST", "/api/v1/nodes/" + settings.nodeId() + "/deployments", body.toString()); + } + + private CompletableFuture handleDeploymentClaim(Response response) { + if (response.statusCode == 204 || closed) return CompletableFuture.completedFuture(null); + if (response.statusCode == 404) { + registered = false; + throw new RegistryLostException(); + } + requireSuccess(response); + PluginDeploymentService.Task task = deploymentTask(parseObject(response.body)); + return CompletableFuture.supplyAsync(() -> deployments.deploy(task, settings.endpoint(), settings.nodeId(), sessionId, + deploymentCredential, deploymentHttp, Duration.ofMillis(settings.requestTimeoutMillis()), () -> !closed), + deploymentExecutor).thenCompose(result -> { + if (closed) return CompletableFuture.completedFuture(null); + JsonObject body = new JsonObject(); + body.addProperty("sessionId", sessionId.toString()); + body.addProperty("success", result.success()); + body.addProperty("code", result.code()); + body.addProperty("message", boundedResultMessage(result.message())); + body.addProperty("attemptId", task.attemptId().toString()); + CompletableFuture submitted = transport.send(new Request("POST", "/api/v1/nodes/" + + settings.nodeId() + "/deployments/" + task.deploymentId() + "/result", body.toString())); + activeRequest = submitted; + return submitted.thenAccept(ControlConnector::requireSuccess); + }); + } + + private static PluginDeploymentService.Task deploymentTask(JsonObject task) { + try { + return new PluginDeploymentService.Task(UUID.fromString(requireString(task, "deploymentId")), + requireString(task, "artifactId"), requireString(task, "sha256"), + Long.parseLong(requireString(task, "size")), UUID.fromString(requireString(task, "attemptId"))); + } catch (RuntimeException failure) { + throw new MalformedResponseException(); + } + } + + private static PluginDeploymentService prepareDeployment(VotingPluginProxy proxy) { + try { + var source = proxy.getClass().getProtectionDomain().getCodeSource(); + if (source == null || !"file".equalsIgnoreCase(source.getLocation().getProtocol())) return null; + return PluginDeploymentService.proxy(Path.of(source.getLocation().toURI())); + } catch (IOException | URISyntaxException | RuntimeException failure) { + proxy.log("[Control] Plugin deployment staging is unavailable; capability not advertised"); + return null; + } } /** Polls only the operation queue; heartbeat and presence retain their configured cadence. */ @@ -435,6 +557,8 @@ private void handlePrimaryResponse(Response response, boolean registration) { acceptedCapabilities = Set.copyOf(negotiated); configurationAccepted = acceptedCapabilities.stream().anyMatch(Set.of(CONFIGURATION_CAPABILITY, COMMUNICATION_TEST_CAPABILITY, PROXY_METHOD_CAPABILITY, PROXY_FILE_CAPABILITY)::contains); + deploymentsAccepted = deployments != null + && acceptedCapabilities.contains(PluginDeploymentService.CAPABILITY); } } @@ -1094,6 +1218,7 @@ private void addCapabilities(JsonObject body) { if (communicationTest != null) advertised.add(COMMUNICATION_TEST_CAPABILITY); if (methodConfigurationService != null) advertised.add(PROXY_METHOD_CAPABILITY); if (fileConfigurationService != null) advertised.add(PROXY_FILE_CAPABILITY); + if (deployments != null) advertised.add(PluginDeploymentService.CAPABILITY); body.add("capabilities", advertised); JsonArray required = new JsonArray(); required.add("presence.snapshot"); @@ -1133,6 +1258,10 @@ public void close() { } ScheduledFuture polling = operationPolling; if (polling != null) polling.cancel(false); + ScheduledFuture deployment = deploymentPolling; + if (deployment != null) deployment.cancel(false); + if (deployments != null) deployments.cancel(); + if (deploymentExecutor != null) deploymentExecutor.shutdownNow(); CompletableFuture request = activeRequest; if (request != null) { request.cancel(true); @@ -1150,6 +1279,16 @@ public void close() { throw new IllegalStateException("Interrupted while waiting for the Control operation", e); } } + if (deploymentExecutor != null) { + try { + if (!deploymentExecutor.awaitTermination(OPERATION_SHUTDOWN_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)) { + throw new IllegalStateException("Control deployment worker did not stop before connector shutdown"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting for the Control deployment worker", e); + } + } transport.close(); } From f623288b5b33ffec11218779c6fb831f185e6e05 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 17 Sep 2026 18:01:17 -0600 Subject: [PATCH 06/19] Expose deterministic proxy capability advertisement --- .../proxy/control/ControlConnector.java | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java index 9d24533fe..70916c56f 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java @@ -1212,13 +1212,19 @@ static List boundedResultChanges(List changes) { } private void addCapabilities(JsonObject body) { + addCapabilities(body, configurationService != null, communicationTest != null, methodConfigurationService != null, + fileConfigurationService != null, deployments != null); + } + + static void addCapabilities(JsonObject body, boolean configurationReady, boolean communicationReady, + boolean methodReady, boolean fileReady, boolean deploymentReady) { JsonArray advertised = new JsonArray(); BASE_CAPABILITIES.stream().sorted().forEach(advertised::add); - if (configurationService != null) advertised.add(CONFIGURATION_CAPABILITY); - if (communicationTest != null) advertised.add(COMMUNICATION_TEST_CAPABILITY); - if (methodConfigurationService != null) advertised.add(PROXY_METHOD_CAPABILITY); - if (fileConfigurationService != null) advertised.add(PROXY_FILE_CAPABILITY); - if (deployments != null) advertised.add(PluginDeploymentService.CAPABILITY); + if (configurationReady) advertised.add(CONFIGURATION_CAPABILITY); + if (communicationReady) advertised.add(COMMUNICATION_TEST_CAPABILITY); + if (methodReady) advertised.add(PROXY_METHOD_CAPABILITY); + if (fileReady) advertised.add(PROXY_FILE_CAPABILITY); + if (deploymentReady) advertised.add(PluginDeploymentService.CAPABILITY); body.add("capabilities", advertised); JsonArray required = new JsonArray(); required.add("presence.snapshot"); From 607129f31aacb7a6e9620dd3a7a8c673c90efbd9 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 17 Sep 2026 18:01:22 -0600 Subject: [PATCH 07/19] Cover proxy deployment capability advertisement --- .../proxy/control/ControlConnectorTest.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java index caffbbc47..d79a557f0 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java @@ -88,6 +88,18 @@ class ControlConnectorTest { assertEquals(1, secondSnapshot.get("sequence").getAsLong()); } + @Test void deploymentCapabilityIsAdvertisedOnlyWhenProxyStagingIsReady() { + JsonObject unavailable = new JsonObject(); + ControlConnector.addCapabilities(unavailable, true, true, true, true, false); + assertFalse(unavailable.getAsJsonArray("capabilities").asList().stream() + .anyMatch(value -> "plugin.deploy.v1".equals(value.getAsString()))); + + JsonObject ready = new JsonObject(); + ControlConnector.addCapabilities(ready, true, true, true, true, true); + assertTrue(ready.getAsJsonArray("capabilities").asList().stream() + .anyMatch(value -> "plugin.deploy.v1".equals(value.getAsString()))); + } + @Test void unavailableAuthenticationProtocolAndMalformedResponsesOnlyChangeConnectorState() { transport.nextPrimary = new Response(401, "{\"error\":{}}"); connector.cycle(); From 3369cb847f113052e638ba42007fddf56a2a6b69 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 17 Sep 2026 18:13:59 -0600 Subject: [PATCH 08/19] Harden verified deployment transport and idempotency --- .../control/PluginDeploymentService.java | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java index 3a7d74923..bc8e37bc2 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java @@ -30,6 +30,8 @@ import java.util.jar.JarEntry; import java.util.jar.JarFile; +import com.bencodez.votingplugin.util.DurableFiles; + /** * Bounded, pull-only staging for the optional {@code plugin.deploy.v1} capability. * It deliberately never reloads a plugin or starts a server. The small on-disk @@ -102,11 +104,15 @@ public void cancel() { } } - public Result deploy(Task task, URI endpoint, String nodeId, UUID sessionId, String credential, - HttpClient http, Duration timeout, BooleanSupplier active) { + public Result deploy(Task task, URI endpoint, boolean directLocalHosted, String nodeId, UUID sessionId, + String credential, HttpClient http, Duration timeout, BooleanSupplier active) { if (!staging.compareAndSet(false, true)) return Result.failure("DEPLOYMENT_FAILED", "Another deployment is still staging"); try { validate(task); + if (!credentialEndpointAllowed(endpoint, directLocalHosted)) { + return Result.failure("INSECURE_ENDPOINT", + "Verified update staging requires HTTPS unless Control is hosted directly on this node"); + } if (alreadyStaged(task)) return Result.restartRequired(); if (!active.getAsBoolean()) return Result.failure("CANCELLED", "Deployment was cancelled before download"); URI artifact = endpoint.resolve("/api/v1/nodes/" + nodeId + "/deployments/" + task.deploymentId() @@ -361,6 +367,11 @@ private boolean alreadyStaged(Task task) throws IOException { String[] fields = Files.readString(marker, StandardCharsets.US_ASCII).split("\\R", -1); if (fields.length < 3 || !task.deploymentId().toString().equals(fields[0]) || !task.sha256().equals(fields[1]) || !Long.toString(task.size()).equals(fields[2])) return false; + if (!replaceExisting && !Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + // Bukkit removes the staged update JAR after consuming it on restart. The + // durable matching marker still proves this exact deployment was staged. + return true; + } return targetMatches(task); } @@ -403,8 +414,12 @@ private static void force(Path file) throws IOException { } private static void forceDirectory(Path directory) throws IOException { - try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { channel.force(true); } - catch (UnsupportedOperationException ignored) { /* Some supported filesystems cannot fsync directories. */ } + DurableFiles.forceDirectory(directory); + } + + static boolean credentialEndpointAllowed(URI endpoint, boolean directLocalHosted) { + return endpoint != null && ("https".equalsIgnoreCase(endpoint.getScheme()) + || directLocalHosted && "http".equalsIgnoreCase(endpoint.getScheme())); } private static MessageDigest sha256() { From 187db97bf39eddae3608a6eca80b5012d636dffd Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 17 Sep 2026 18:14:11 -0600 Subject: [PATCH 09/19] Restrict backend deployment to active secure routes --- .../control/BackendControlConnector.java | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java index 2512b5418..be75f9873 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java @@ -31,6 +31,7 @@ import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.control.BackendControlResultStore.Route; import com.bencodez.votingplugin.control.BackendControlResultStore.StoredResult; +import com.bencodez.votingplugin.proxy.control.HostedControlManager; import com.bencodez.votingplugin.proxy.control.HostedControlManager.HostConfiguration; import com.bencodez.votingplugin.util.BoundedHttpBodyHandler; import com.bencodez.votingplugin.util.ControlCredentialFile; @@ -62,6 +63,7 @@ public final class BackendControlConnector implements AutoCloseable { private final BackendConfigurationService configurations; private final ControlInspectionService inspections; private final PluginDeploymentService deployments; + private final boolean directLocalDeploymentEndpoint; private final UUID sessionId = UUID.randomUUID(); private final Map completed = new LinkedHashMap<>(); private final boolean recovering; @@ -111,11 +113,19 @@ private BackendControlConnector(VotingPluginMain plugin, Path dataDirectory, Set .followRedirects(HttpClient.Redirect.NEVER).build(); configurations = new BackendConfigurationService(plugin.getDataFolder().toPath(), this::reloadConfiguration); inspections = new ControlInspectionService(plugin); + directLocalDeploymentEndpoint = HostedControlManager.isDirectLocalEndpoint( + settings.endpoint().toString(), hostedConfiguration); PluginDeploymentService prepared = null; - try { - prepared = PluginDeploymentService.backend(plugin.getServer().getUpdateFolderFile().toPath()); - } catch (Exception failure) { - plugin.getLogger().warning("[Control] Plugin deployment staging is unavailable; capability not advertised"); + boolean deploymentEndpointAllowed = PluginDeploymentService.credentialEndpointAllowed( + settings.endpoint(), directLocalDeploymentEndpoint); + if (!recovering && deploymentEndpointAllowed) { + try { + prepared = PluginDeploymentService.backend(plugin.getServer().getUpdateFolderFile().toPath()); + } catch (Exception failure) { + plugin.getLogger().warning("[Control] Plugin deployment staging is unavailable; capability not advertised"); + } + } else if (!recovering && !deploymentEndpointAllowed) { + plugin.getLogger().warning("[Control] Plugin deployment staging requires HTTPS unless Control is hosted directly on this node"); } deployments = prepared; } @@ -246,8 +256,9 @@ private void claimAndDeploy() throws Exception { if (response.status() == 204 || closed) return; JsonObject claimed = requireObject(response, 200); PluginDeploymentService.Task task = deploymentTask(claimed); - PluginDeploymentService.Result result = deployments.deploy(task, settings.endpoint(), settings.nodeId(), sessionId, - credential, http, Duration.ofMillis(settings.requestTimeoutMillis()), () -> !closed); + PluginDeploymentService.Result result = deployments.deploy(task, settings.endpoint(), directLocalDeploymentEndpoint, + settings.nodeId(), sessionId, credential, http, Duration.ofMillis(settings.requestTimeoutMillis()), + () -> !closed); if (closed) return; JsonObject submitted = new JsonObject(); submitted.addProperty("sessionId", sessionId.toString()); From bca3ec4d4c0aee2541d05a36afe7c6bb7429bca4 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 17 Sep 2026 18:14:27 -0600 Subject: [PATCH 10/19] Fence and secure proxy deployment polling --- .../proxy/control/ControlConnector.java | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java index 70916c56f..f5681554d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java @@ -38,6 +38,7 @@ import com.bencodez.votingplugin.control.PluginDeploymentService; import com.bencodez.votingplugin.proxy.VotingPluginProxy; +import com.bencodez.votingplugin.proxy.control.HostedControlManager.HostConfiguration; import com.bencodez.votingplugin.proxy.VotingPluginProxyConfig; import com.bencodez.votingplugin.proxy.presence.BackendPresenceStatus; import com.bencodez.votingplugin.proxy.control.ProxyControlResultStore.Route; @@ -84,6 +85,7 @@ public final class ControlConnector implements AutoCloseable { private final PluginDeploymentService deployments; private final HttpClient deploymentHttp; private final String deploymentCredential; + private final boolean directLocalDeploymentEndpoint; private final ExecutorService deploymentExecutor; private final Function> communicationTest; private final Runnable runtimeReplacement; @@ -142,7 +144,7 @@ private ControlConnector(Settings settings, ScheduledExecutorService scheduler, ProxyConfigurationFileService fileConfigurationService) { this(settings, scheduler, transport, snapshotSource, logger, sessionId, jitterSource, configurationService, dataDirectory, route, recovering, recoveryComplete, communicationTest, methodConfigurationService, - runtimeReplacement, fileConfigurationService, null, null, null); + runtimeReplacement, fileConfigurationService, null, null, null, false); } private ControlConnector(Settings settings, ScheduledExecutorService scheduler, Transport transport, @@ -152,7 +154,7 @@ private ControlConnector(Settings settings, ScheduledExecutorService scheduler, Function> communicationTest, ProxyMethodConfigurationService methodConfigurationService, Runnable runtimeReplacement, ProxyConfigurationFileService fileConfigurationService, PluginDeploymentService deployments, - HttpClient deploymentHttp, String deploymentCredential) { + HttpClient deploymentHttp, String deploymentCredential, boolean directLocalDeploymentEndpoint) { this.settings = Objects.requireNonNull(settings, "settings"); this.scheduler = Objects.requireNonNull(scheduler, "scheduler"); this.transport = Objects.requireNonNull(transport, "transport"); @@ -168,6 +170,7 @@ private ControlConnector(Settings settings, ScheduledExecutorService scheduler, this.deployments = deployments; this.deploymentHttp = deploymentHttp; this.deploymentCredential = deploymentCredential; + this.directLocalDeploymentEndpoint = directLocalDeploymentEndpoint; this.deploymentExecutor = deployments == null ? null : Executors.newSingleThreadExecutor(runnable -> { Thread thread = new Thread(runnable, "votingplugin-control-proxy-deployment"); thread.setDaemon(true); @@ -223,6 +226,7 @@ public static ControlConnector create(VotingPluginProxy proxy) throws IOExceptio } } boolean recovering = recovered != null && recovered.routeRequired(); + boolean deploymentRouteCurrent = config.getControlEnabled() && !recovering; String credential = ControlCredentialFile.read(dataDirectory, credentialName); HttpControlTransport transport = new HttpControlTransport(settings.endpoint(), credential, settings.connectTimeoutMillis(), settings.requestTimeoutMillis()); @@ -241,7 +245,21 @@ public static ControlConnector create(VotingPluginProxy proxy) throws IOExceptio backends.sort(Comparator.comparing(ObservedBackend::backendId)); return List.copyOf(backends); }; - PluginDeploymentService deployments = prepareDeployment(proxy); + HostConfiguration hosted = new HostConfiguration(config.getControlHostedEnabled(), + config.getControlHostedAutoDownload(), config.getControlHostedAutoUpdate(), + config.getControlHostedDownloadUrl(), config.getControlHostedSha256(), + config.getControlHostedJarFile(), config.getControlHostedDataDirectory(), + config.getControlHostedHost(), config.getControlHostedPort(), + config.getControlHostedStartupTimeoutSeconds(), config.getControlHostedDownloadTimeoutSeconds()); + boolean directLocalDeploymentEndpoint = HostedControlManager.isDirectLocalEndpoint( + settings.endpoint().toString(), hosted); + boolean deploymentEndpointAllowed = PluginDeploymentService.credentialEndpointAllowed( + settings.endpoint(), directLocalDeploymentEndpoint); + PluginDeploymentService deployments = deploymentRouteCurrent && deploymentEndpointAllowed + ? prepareDeployment(proxy) : null; + if (deploymentRouteCurrent && !deploymentEndpointAllowed) { + proxy.log("[Control] Plugin deployment staging requires HTTPS unless Control is hosted directly on this node"); + } HttpClient deploymentHttp = deployments == null ? null : HttpClient.newBuilder() .connectTimeout(Duration.ofMillis(settings.connectTimeoutMillis())) .followRedirects(HttpClient.Redirect.NEVER).build(); @@ -251,7 +269,7 @@ public static ControlConnector create(VotingPluginProxy proxy) throws IOExceptio route, recovering, proxy::restartControlServicesAfterRecovery, server -> proxy.testBackendCommunication(server, 5000L), new ProxyMethodConfigurationService(proxy), () -> proxy.reloadCore(true), new ProxyConfigurationFileService(proxy), - deployments, deploymentHttp, credential); + deployments, deploymentHttp, credential, directLocalDeploymentEndpoint); if (recovered != null) connector.completedTasks.putAll(recovered.results()); return connector; } @@ -272,12 +290,12 @@ public void start() { /** Keeps large artifact transfer and disk staging off proxy event threads and serializes it with operations. */ private void pollDeployments() { + CompletableFuture done = new CompletableFuture<>(); synchronized (operationLifecycle) { if (closed || !registered || status != Status.CONNECTED || !deploymentsAccepted || deployments == null || !inFlight.compareAndSet(false, true)) return; + activeOperation = done; } - CompletableFuture done = new CompletableFuture<>(); - activeOperation = done; CompletableFuture work; try { CompletableFuture claim = transport.send(deploymentClaimRequest()); @@ -297,7 +315,11 @@ private void pollDeployments() { } if (activeOperation == done) activeOperation = null; finishCycle(); - if (cause != null && !closed) onFailure(cause); + if (cause != null && !closed) { + ScheduledFuture heartbeat = scheduled; + if (heartbeat != null) heartbeat.cancel(false); + onFailure(cause); + } }); } @@ -314,8 +336,9 @@ private CompletableFuture handleDeploymentClaim(Response response) { } requireSuccess(response); PluginDeploymentService.Task task = deploymentTask(parseObject(response.body)); - return CompletableFuture.supplyAsync(() -> deployments.deploy(task, settings.endpoint(), settings.nodeId(), sessionId, - deploymentCredential, deploymentHttp, Duration.ofMillis(settings.requestTimeoutMillis()), () -> !closed), + return CompletableFuture.supplyAsync(() -> deployments.deploy(task, settings.endpoint(), + directLocalDeploymentEndpoint, settings.nodeId(), sessionId, deploymentCredential, deploymentHttp, + Duration.ofMillis(settings.requestTimeoutMillis()), () -> !closed), deploymentExecutor).thenCompose(result -> { if (closed) return CompletableFuture.completedFuture(null); JsonObject body = new JsonObject(); From ad2c992279286eb2e77fb9963103b32d9cd6f0af Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 17 Sep 2026 18:14:39 -0600 Subject: [PATCH 11/19] Cover secure deployment and consumed Bukkit updates --- .../control/PluginDeploymentServiceTest.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java index f7b6008a7..f8556353e 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java @@ -45,6 +45,31 @@ class PluginDeploymentServiceTest { assertTrue(service.stage(task, new ByteArrayInputStream(new byte[0]), () -> true).success()); } + @Test void backendMatchingMarkerSurvivesBukkitConsumingTheStagedJar() throws Exception { + byte[] artifact = jar("name: VotingPlugin\n"); + Path update = directory.resolve("update"); + PluginDeploymentService service = PluginDeploymentService.backend(update); + PluginDeploymentService.Task task = task(artifact); + + assertTrue(service.stage(task, new ByteArrayInputStream(artifact), () -> true).success()); + Files.delete(update.resolve("VotingPlugin.jar")); + + assertTrue(service.stage(task, new ByteArrayInputStream(new byte[0]), () -> true).success()); + assertFalse(Files.exists(update.resolve("VotingPlugin.jar")), + "a lost result acknowledgement after restart must not stage the consumed update again"); + } + + @Test void credentialedDeploymentRequiresHttpsUnlessSameNodeHostedHttpWasProven() { + assertTrue(PluginDeploymentService.credentialEndpointAllowed( + java.net.URI.create("https://control.example.test"), false)); + assertFalse(PluginDeploymentService.credentialEndpointAllowed( + java.net.URI.create("http://192.0.2.10:8080"), false)); + assertFalse(PluginDeploymentService.credentialEndpointAllowed( + java.net.URI.create("http://127.0.0.1:8080"), false)); + assertTrue(PluginDeploymentService.credentialEndpointAllowed( + java.net.URI.create("http://127.0.0.1:8080"), true)); + } + @Test void backendIgnoresMatchingMarkerOnlyWhenTargetIsValidThenRestagesWhenCorrupted() throws Exception { byte[] artifact = jar("name: VotingPlugin\n"); PluginDeploymentService service = PluginDeploymentService.backend(directory.resolve("update")); From f938c9f88870e06f17ddfe8ec5002f2c78881bf4 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 17 Sep 2026 18:14:58 -0600 Subject: [PATCH 12/19] Document verified deployment protocol --- docs/control-agent-contract.md | 59 ++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/docs/control-agent-contract.md b/docs/control-agent-contract.md index 8375e58f1..77c51edd0 100644 --- a/docs/control-agent-contract.md +++ b/docs/control-agent-contract.md @@ -107,6 +107,65 @@ redacted-editor task; turning `useMainMySQL` off alone does not invent credentia unknown fields. `READ` returns current typed state and its revision, `PREVIEW` computes changes and produces an approval, and only the approved `APPLY` writes atomically, reloads, and rolls back on reload failure, like every other quick setup. +## Verified plugin staging (`plugin.deploy.v1`) + +This optional capability stages one verified VotingPlugin JAR for the node's next process restart. It is never a hot reload +and never restarts a proxy or backend automatically. A node advertises it only when all of the following are true: + +- the connector is the currently enabled Control route, not a recovery-only connector draining an older durable result; +- a safe local staging target was prepared; +- the Control endpoint is HTTPS, or it is the already-proven direct same-node hosted HTTP listener. + +Arbitrary private-network HTTP does not qualify for deployment because the artifact request carries the node bearer +credential. The shared staging service enforces the same transport rule again before sending that credential. + +Control leases deployment work separately from configuration operations: + +```http +POST /api/v1/nodes/{nodeId}/deployments +Content-Type: application/json + +{"sessionId":""} +``` + +A `204` means no work. A `200` task contains `deploymentId`, `artifactId`, lowercase SHA-256 `sha256`, byte +`size`, and `attemptId`. Artifact size is limited to 64 MiB. The node downloads the exact leased artifact with: + +```http +GET /api/v1/nodes/{nodeId}/deployments/{deploymentId}/artifact +Authorization: Bearer +X-Node-Session: +X-Deployment-Attempt: +``` + +The node independently verifies the exact size and SHA-256, bounded ZIP/JAR structure, root `plugin.yml`, and +`name: VotingPlugin` before publication. Bukkit nodes stage to the server update folder; proxy nodes atomically replace +their discovered plugin JAR only after creating a durable `.control-backup`. Every successful deployment writes a small +`.control-deployment` marker so a lost result acknowledgement does not apply the same deployment twice. On Bukkit, that +marker remains authoritative after a restart consumes/removes the staged update JAR. On proxies, the target JAR must still +match the marker's digest; an interrupted publish-before-marker window can be recovered without overwriting the previous +backup. + +The result is posted to: + +```http +POST /api/v1/nodes/{nodeId}/deployments/{deploymentId}/result +Content-Type: application/json + +{ + "sessionId":"", + "attemptId":"", + "success":true, + "code":"RESTART_REQUIRED", + "message":"Plugin update staged; restart is required" +} +``` + +Failure codes include `CANCELLED`, `DOWNLOAD_FAILED`, `SIZE_MISMATCH`, `HASH_MISMATCH`, +`INVALID_ARTIFACT`, `STAGING_FAILED`, `DEPLOYMENT_FAILED`, and `INSECURE_ENDPOINT`. Deployment polling is +serialized with the connector lifecycle, uses the normal bounded failure backoff, and is cancelled during shutdown before +transport teardown. + ## Inspection transport An enrolled Bukkit node advertises `data.inspect.v1`. Once Control includes it in `acceptedCapabilities`, the node polls: From 632058fe3a6e09afc56b698dd3009c147c6a058d Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 17 Sep 2026 18:15:01 -0600 Subject: [PATCH 13/19] Document verified deployment protocol --- docs/control-connector.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/control-connector.md b/docs/control-connector.md index 4e797fef5..14e1851a4 100644 --- a/docs/control-connector.md +++ b/docs/control-connector.md @@ -200,6 +200,29 @@ with the new settings. This prevents the old and new children from racing for th the currently running Control child unchanged. If the backend restarts with a pending result, the durable journal also restores the previous hosted settings until that result is acknowledged, preserving the recovery connector's endpoint. +## Verified VotingPlugin updates + +Nodes that negotiate `plugin.deploy.v1` can stage a VotingPlugin JAR supplied by Control for the next normal process +restart. This capability is deliberately separate from configuration control and from hosted-Control self-updates. +VotingPlugin never hot-reloads itself and never restarts the server or proxy automatically. + +Deployment is available only on the currently enabled Control route. Recovery-only connectors that exist solely to +acknowledge an older durable result never advertise or poll this capability. The node also requires a credential-safe +artifact transport: HTTPS is accepted generally; HTTP is accepted only when the existing hosted-Control checks prove the +endpoint is the direct same-node listener. A LAN/private HTTP endpoint may still be used for ordinary Control operations, +but it is intentionally ineligible for credentialed plugin-JAR staging. + +Control leases deployment work through `POST /api/v1/nodes/{nodeId}/deployments`. The node downloads the artifact through +the matching deployment artifact endpoint with its bearer credential plus exact session and attempt headers, then +independently verifies the 64 MiB size bound, SHA-256, JAR structure, and root `plugin.yml` identity. Bukkit stages the +verified JAR in the configured update folder; BungeeCord/Velocity retain a durable backup before atomically replacing the +running plugin JAR on disk. A durable deployment marker makes lost result acknowledgements idempotent, including the +post-restart Bukkit state where the server has already consumed the staged update JAR. + +Successful staging reports `RESTART_REQUIRED`. The administrator chooses when to restart. See +[the Control agent contract](control-agent-contract.md#verified-plugin-staging-plugindeployv1) for the exact task, download, +result, transport, and failure-code contract. + ## Discovery semantics Both platforms use the same connector implementation and protocol version `1`. Each proxy process creates a new session From a2b1381240bd1f8debd45ea3e63927aa2fb691c5 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 17 Sep 2026 21:39:58 -0600 Subject: [PATCH 14/19] Fix verified deployment hardening build --- .../votingplugin/VotingPluginMain.java | 5 ++ .../control/BackendControlConnector.java | 3 +- .../control/PluginDeploymentService.java | 71 ++++++++++++++----- .../proxy/control/ControlConnector.java | 9 +++ .../control/PluginDeploymentServiceTest.java | 52 +++++++++++--- .../proxy/control/ControlConnectorTest.java | 7 ++ 6 files changed, 121 insertions(+), 26 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 35fb886a8..5ce3788fe 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -152,6 +152,11 @@ public class VotingPluginMain extends AdvancedCorePlugin { @Getter public static VotingPluginMain plugin; + /** Exact backend plugin file name Bukkit uses when consuming its update folder. */ + public File getLoadedPluginJarFile() { + return getFile(); + } + @Getter @Setter private ArrayList adminVoteCommand; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java index be75f9873..499d313ad 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java @@ -120,7 +120,8 @@ private BackendControlConnector(VotingPluginMain plugin, Path dataDirectory, Set settings.endpoint(), directLocalDeploymentEndpoint); if (!recovering && deploymentEndpointAllowed) { try { - prepared = PluginDeploymentService.backend(plugin.getServer().getUpdateFolderFile().toPath()); + prepared = PluginDeploymentService.backend(plugin.getServer().getUpdateFolderFile().toPath(), + plugin.getLoadedPluginJarFile().toPath()); } catch (Exception failure) { plugin.getLogger().warning("[Control] Plugin deployment staging is unavailable; capability not advertised"); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java index bc8e37bc2..4196d4d39 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java @@ -54,12 +54,14 @@ public final class PluginDeploymentService { private final Path target; private final Path root; private final Path marker; + private final Path installedBackendJar; private final boolean replaceExisting; private final AtomicBoolean staging = new AtomicBoolean(); private final AtomicReference activeResponse = new AtomicReference<>(); - private PluginDeploymentService(Path target, boolean replaceExisting) throws IOException { + private PluginDeploymentService(Path target, Path installedBackendJar, boolean replaceExisting) throws IOException { this.target = target.toAbsolutePath().normalize(); + this.installedBackendJar = installedBackendJar == null ? null : installedBackendJar.toAbsolutePath().normalize(); this.replaceExisting = replaceExisting; Path parent = this.target.getParent(); if (parent == null) throw new IOException("deployment target has no parent"); @@ -78,10 +80,15 @@ private PluginDeploymentService(Path target, boolean replaceExisting) throws IOE this.marker = root.resolve(this.target.getFileName() + MARKER); } - /** Bukkit's update folder preserves the currently loaded plugin JAR. */ - public static PluginDeploymentService backend(Path updateDirectory) throws IOException { + /** Bukkit consumes an update only when its file name matches the currently loaded plugin JAR. */ + public static PluginDeploymentService backend(Path updateDirectory, Path currentPluginJar) throws IOException { if (updateDirectory == null) throw new IOException("Bukkit update folder is unavailable"); - return new PluginDeploymentService(updateDirectory.resolve("VotingPlugin.jar"), false); + if (currentPluginJar == null || currentPluginJar.getFileName() == null + || !currentPluginJar.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".jar")) { + throw new IOException("Bukkit plugin JAR is unavailable"); + } + return new PluginDeploymentService(updateDirectory.resolve(currentPluginJar.getFileName().toString()), + currentPluginJar, false); } /** Proxies atomically replace their discovered plugin JAR only after a durable backup. */ @@ -91,7 +98,7 @@ public static PluginDeploymentService proxy(Path currentPluginJar) throws IOExce } if (!Files.isRegularFile(currentPluginJar, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(currentPluginJar)) throw new IOException("proxy plugin JAR is unsafe"); - return new PluginDeploymentService(currentPluginJar, true); + return new PluginDeploymentService(currentPluginJar, null, true); } public boolean isStaging() { return staging.get(); } @@ -181,7 +188,7 @@ Result stage(Task task, InputStream body, BooleanSupplier active) throws IOExcep if (!active.getAsBoolean()) return Result.failure("CANCELLED", "Deployment was cancelled before staging"); activation = new Activation(); activate(temporary, activation); - writeMarker(task); + writeMarker(task, activation); activation.discard(); return Result.restartRequired(); } catch (CancelledDeploymentException failure) { @@ -311,6 +318,7 @@ private void activate(Path temporary, Activation activation) throws IOException private final class Activation { private final Path previous; private boolean published; + private boolean markerPublished; private Activation() throws IOException { if (!Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { @@ -330,6 +338,7 @@ private void rollback() throws IOException { discard(); return; } + if (markerPublished) DurableFiles.deleteIfExists(marker); if (previous == null) { if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) { throw new IOException("deployed target cannot be safely removed"); @@ -351,12 +360,17 @@ private void discard() { } private void writeMarker(Task task) throws IOException { + writeMarker(task, null); + } + + private void writeMarker(Task task, Activation activation) throws IOException { Path temporary = Files.createTempFile(root, target.getFileName().toString() + ".", ".marker"); try { Files.writeString(temporary, task.deploymentId() + "\n" + task.sha256() + "\n" + task.size() + "\n", StandardCharsets.US_ASCII, StandardOpenOption.TRUNCATE_EXISTING); force(temporary); move(temporary, marker); + if (activation != null) activation.markerPublished = true; forceDirectory(root); } finally { Files.deleteIfExists(temporary); } } @@ -368,9 +382,10 @@ private boolean alreadyStaged(Task task) throws IOException { if (fields.length < 3 || !task.deploymentId().toString().equals(fields[0]) || !task.sha256().equals(fields[1]) || !Long.toString(task.size()).equals(fields[2])) return false; if (!replaceExisting && !Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { - // Bukkit removes the staged update JAR after consuming it on restart. The - // durable matching marker still proves this exact deployment was staged. - return true; + // Bukkit removes the staged update JAR after consuming it on restart. A + // missing staging file alone is not proof: it may have been deleted or + // quarantined. Confirm the installed backend JAR is the exact artifact. + return installedBackendJar != null && fileMatches(installedBackendJar, task); } return targetMatches(task); } @@ -388,16 +403,20 @@ private boolean recoverInterruptedActivation(Task task) throws IOException { } private boolean targetMatches(Task task) throws IOException { - if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target) - || Files.size(target) != task.size()) return false; + return fileMatches(target, task); + } + + private boolean fileMatches(Path candidate, Task task) throws IOException { + if (!Files.isRegularFile(candidate, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(candidate) + || Files.size(candidate) != task.size()) return false; MessageDigest digest = sha256(); - try (InputStream input = Files.newInputStream(target, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) { + try (InputStream input = Files.newInputStream(candidate, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) { byte[] bytes = new byte[BUFFER_BYTES]; for (int read; (read = input.read(bytes)) != -1;) digest.update(bytes, 0, read); } if (!task.sha256().equals(HexFormat.of().formatHex(digest.digest()))) return false; try { - inspectJar(target); + inspectJar(candidate); return true; } catch (InvalidArtifactException invalid) { return false; @@ -417,9 +436,29 @@ private static void forceDirectory(Path directory) throws IOException { DurableFiles.forceDirectory(directory); } - static boolean credentialEndpointAllowed(URI endpoint, boolean directLocalHosted) { - return endpoint != null && ("https".equalsIgnoreCase(endpoint.getScheme()) - || directLocalHosted && "http".equalsIgnoreCase(endpoint.getScheme())); + /** True when a deployment bearer credential may be sent to this Control endpoint. */ + public static boolean credentialEndpointAllowed(URI endpoint, boolean directLocalHosted) { + if (endpoint == null) return false; + if ("https".equalsIgnoreCase(endpoint.getScheme())) return true; + return directLocalHosted && "http".equalsIgnoreCase(endpoint.getScheme()) + && isLoopbackHost(endpoint.getHost()); + } + + private static boolean isLoopbackHost(String host) { + if (host == null) return false; + String normalized = host; + if (normalized.length() >= 2 && normalized.charAt(0) == '[' + && normalized.charAt(normalized.length() - 1) == ']') { + normalized = normalized.substring(1, normalized.length() - 1); + } + if ("localhost".equalsIgnoreCase(normalized) || "::1".equalsIgnoreCase(normalized) + || "0:0:0:0:0:0:0:1".equalsIgnoreCase(normalized)) return true; + String[] octets = normalized.split("\\.", -1); + if (octets.length != 4 || !"127".equals(octets[0])) return false; + for (int index = 1; index < octets.length; index++) { + if (!octets[index].matches("[0-9]{1,3}") || Integer.parseInt(octets[index]) > 255) return false; + } + return true; } private static MessageDigest sha256() { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java index f5681554d..c56ff7a01 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java @@ -45,6 +45,7 @@ import com.bencodez.votingplugin.proxy.control.ProxyControlResultStore.StoredResult; import com.bencodez.votingplugin.util.BoundedHttpBodyHandler; import com.bencodez.votingplugin.util.ControlCredentialFile; +import com.bencodez.votingplugin.util.DurableFiles; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; @@ -365,6 +366,10 @@ private static PluginDeploymentService.Task deploymentTask(JsonObject task) { } private static PluginDeploymentService prepareDeployment(VotingPluginProxy proxy) { + if (!proxyDeploymentSupported(System.getProperty("os.name", ""))) { + proxy.log("[Control] Plugin deployment staging is unavailable on Windows proxies; capability not advertised"); + return null; + } try { var source = proxy.getClass().getProtectionDomain().getCodeSource(); if (source == null || !"file".equalsIgnoreCase(source.getLocation().getProtocol())) return null; @@ -375,6 +380,10 @@ private static PluginDeploymentService prepareDeployment(VotingPluginProxy proxy } } + static boolean proxyDeploymentSupported(String osName) { + return !DurableFiles.isWindowsName(osName); + } + /** Polls only the operation queue; heartbeat and presence retain their configured cadence. */ void pollOperations() { CompletableFuture operationDone; diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java index f8556353e..26bd3118c 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java @@ -34,7 +34,7 @@ class PluginDeploymentServiceTest { @Test void backendStagesOnlyAnExactVerifiedVotingPluginJarAndIsIdempotent() throws Exception { byte[] artifact = jar("name: VotingPlugin\nmain: example.Main\n"); - PluginDeploymentService service = PluginDeploymentService.backend(directory.resolve("update")); + PluginDeploymentService service = PluginDeploymentService.backend(directory.resolve("update"), Path.of("VotingPlugin.jar")); PluginDeploymentService.Task task = task(artifact); PluginDeploymentService.Result staged = service.stage(task, new ByteArrayInputStream(artifact), () -> true); @@ -45,18 +45,48 @@ class PluginDeploymentServiceTest { assertTrue(service.stage(task, new ByteArrayInputStream(new byte[0]), () -> true).success()); } - @Test void backendMatchingMarkerSurvivesBukkitConsumingTheStagedJar() throws Exception { + @Test void backendStagesUsingTheInstalledPluginJarFileName() throws Exception { byte[] artifact = jar("name: VotingPlugin\n"); Path update = directory.resolve("update"); - PluginDeploymentService service = PluginDeploymentService.backend(update); + PluginDeploymentService service = PluginDeploymentService.backend(update, + directory.resolve("plugins/VotingPlugin-7.1.2.jar")); + + assertEquals("RESTART_REQUIRED", service.stage(task(artifact), new ByteArrayInputStream(artifact), () -> true).code()); + assertArrayEquals(artifact, Files.readAllBytes(update.resolve("VotingPlugin-7.1.2.jar"))); + assertFalse(Files.exists(update.resolve("VotingPlugin.jar"))); + } + + @Test void backendMatchingMarkerSurvivesBukkitConsumingTheStagedJar() throws Exception { + byte[] artifact = jar("name: VotingPlugin\nversion: candidate\n"); + Path update = directory.resolve("update"); + Path installed = directory.resolve("VotingPlugin.jar"); + Files.write(installed, jar("name: VotingPlugin\nversion: old\n")); + PluginDeploymentService service = PluginDeploymentService.backend(update, installed); PluginDeploymentService.Task task = task(artifact); assertTrue(service.stage(task, new ByteArrayInputStream(artifact), () -> true).success()); + Files.write(installed, artifact); Files.delete(update.resolve("VotingPlugin.jar")); assertTrue(service.stage(task, new ByteArrayInputStream(new byte[0]), () -> true).success()); assertFalse(Files.exists(update.resolve("VotingPlugin.jar")), - "a lost result acknowledgement after restart must not stage the consumed update again"); + "a lost acknowledgement after restart must recognize the artifact Bukkit already consumed"); + } + + @Test void backendRestagesWhenTheUpdateJarDisappearsBeforeBukkitConsumesIt() throws Exception { + byte[] artifact = jar("name: VotingPlugin\nversion: candidate\n"); + Path update = directory.resolve("update"); + Path installed = directory.resolve("VotingPlugin.jar"); + Files.write(installed, jar("name: VotingPlugin\nversion: old\n")); + PluginDeploymentService service = PluginDeploymentService.backend(update, installed); + PluginDeploymentService.Task task = task(artifact); + + assertTrue(service.stage(task, new ByteArrayInputStream(artifact), () -> true).success()); + Files.delete(update.resolve("VotingPlugin.jar")); + + assertTrue(service.stage(task, new ByteArrayInputStream(artifact), () -> true).success()); + assertArrayEquals(artifact, Files.readAllBytes(update.resolve("VotingPlugin.jar")), + "a deleted or quarantined update must be staged again before restart"); } @Test void credentialedDeploymentRequiresHttpsUnlessSameNodeHostedHttpWasProven() { @@ -68,11 +98,15 @@ class PluginDeploymentServiceTest { java.net.URI.create("http://127.0.0.1:8080"), false)); assertTrue(PluginDeploymentService.credentialEndpointAllowed( java.net.URI.create("http://127.0.0.1:8080"), true)); + assertTrue(PluginDeploymentService.credentialEndpointAllowed( + java.net.URI.create("http://[::1]:8080"), true)); + assertFalse(PluginDeploymentService.credentialEndpointAllowed( + java.net.URI.create("http://127.example.com:8080"), true)); } @Test void backendIgnoresMatchingMarkerOnlyWhenTargetIsValidThenRestagesWhenCorrupted() throws Exception { byte[] artifact = jar("name: VotingPlugin\n"); - PluginDeploymentService service = PluginDeploymentService.backend(directory.resolve("update")); + PluginDeploymentService service = PluginDeploymentService.backend(directory.resolve("update"), Path.of("VotingPlugin.jar")); PluginDeploymentService.Task task = task(artifact); service.stage(task, new ByteArrayInputStream(artifact), () -> true); @@ -89,7 +123,7 @@ class PluginDeploymentServiceTest { @Test void invalidPluginIdentityAndDigestNeverReachTheUpdateFolder() throws Exception { byte[] wrongPlugin = jar("name: NotVotingPlugin\n"); - PluginDeploymentService service = PluginDeploymentService.backend(directory.resolve("update")); + PluginDeploymentService service = PluginDeploymentService.backend(directory.resolve("update"), Path.of("VotingPlugin.jar")); PluginDeploymentService.Result invalid = service.stage(task(wrongPlugin), new ByteArrayInputStream(wrongPlugin), () -> true); assertFalse(invalid.success()); assertEquals("INVALID_ARTIFACT", invalid.code()); @@ -139,7 +173,7 @@ class PluginDeploymentServiceTest { @Test void cancellationDuringCopyDoesNotPublish() throws Exception { byte[] artifact = jar("name: VotingPlugin\n"); - PluginDeploymentService service = PluginDeploymentService.backend(directory.resolve("update")); + PluginDeploymentService service = PluginDeploymentService.backend(directory.resolve("update"), Path.of("VotingPlugin.jar")); PluginDeploymentService.Task task = task(artifact); assertEquals("RESTART_REQUIRED", service.stage(task, new ByteArrayInputStream(artifact), () -> true).code()); @@ -166,7 +200,7 @@ class PluginDeploymentServiceTest { Path marker = update.resolve("VotingPlugin.jar.control-deployment"); Files.createDirectory(marker); Files.writeString(marker.resolve("keep"), "marker publication must fail"); - PluginDeploymentService service = PluginDeploymentService.backend(update); + PluginDeploymentService service = PluginDeploymentService.backend(update, Path.of("VotingPlugin.jar")); assertThrows(IOException.class, () -> service.stage(task(candidate), new ByteArrayInputStream(candidate), () -> true)); @@ -190,7 +224,7 @@ class PluginDeploymentServiceTest { } @Test void cancellationClosesAStalledArtifactStream() throws Exception { - PluginDeploymentService service = PluginDeploymentService.backend(directory.resolve("update")); + PluginDeploymentService service = PluginDeploymentService.backend(directory.resolve("update"), Path.of("VotingPlugin.jar")); AtomicBoolean active = new AtomicBoolean(true); BlockingInputStream stalled = new BlockingInputStream(active); ExecutorService executor = Executors.newSingleThreadExecutor(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java index d79a557f0..e77b60165 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java @@ -891,6 +891,13 @@ class ControlConnectorTest { assertEquals(Status.STOPPED, connector.status()); } + @Test void proxyDeploymentIsNotAdvertisedOnWindowsWhereTheLiveJarCannotBeReplaced() { + assertFalse(ControlConnector.proxyDeploymentSupported("Windows 11")); + assertFalse(ControlConnector.proxyDeploymentSupported("Windows Server 2022")); + assertTrue(ControlConnector.proxyDeploymentSupported("Linux")); + assertTrue(ControlConnector.proxyDeploymentSupported("Darwin")); + } + @Test void backoffIsBoundedExponentialAndJitteredWithoutSleeping() { assertEquals(1000, ControlConnector.backoffMillis(1, 0)); assertEquals(2000, ControlConnector.backoffMillis(2, 0)); From b12bf791b1a9326dac00c88aedbd2207f3f2c53d Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:07:33 -0600 Subject: [PATCH 15/19] Complete queued proxy deployment on connector shutdown --- .../proxy/control/ControlConnector.java | 46 +++++++++----- .../proxy/control/ControlConnectorTest.java | 60 +++++++++++++++++++ 2 files changed, 90 insertions(+), 16 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java index c56ff7a01..8fe63463c 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java @@ -110,6 +110,9 @@ public final class ControlConnector implements AutoCloseable { private volatile ScheduledFuture operationPolling; private volatile ScheduledFuture deploymentPolling; private volatile CompletableFuture activeRequest; + /* The deployment executor may discard queued AsyncSupply work during shutdown. + * Retain its future so close() can complete the dependent operation chain. */ + private volatile CompletableFuture activeDeploymentWork; private volatile CompletableFuture activeOperation; private volatile Status status = Status.STARTING; @@ -337,22 +340,31 @@ private CompletableFuture handleDeploymentClaim(Response response) { } requireSuccess(response); PluginDeploymentService.Task task = deploymentTask(parseObject(response.body)); - return CompletableFuture.supplyAsync(() -> deployments.deploy(task, settings.endpoint(), - directLocalDeploymentEndpoint, settings.nodeId(), sessionId, deploymentCredential, deploymentHttp, - Duration.ofMillis(settings.requestTimeoutMillis()), () -> !closed), - deploymentExecutor).thenCompose(result -> { - if (closed) return CompletableFuture.completedFuture(null); - JsonObject body = new JsonObject(); - body.addProperty("sessionId", sessionId.toString()); - body.addProperty("success", result.success()); - body.addProperty("code", result.code()); - body.addProperty("message", boundedResultMessage(result.message())); - body.addProperty("attemptId", task.attemptId().toString()); - CompletableFuture submitted = transport.send(new Request("POST", "/api/v1/nodes/" - + settings.nodeId() + "/deployments/" + task.deploymentId() + "/result", body.toString())); - activeRequest = submitted; - return submitted.thenAccept(ControlConnector::requireSuccess); - }); + final CompletableFuture deploymentWork; + synchronized (operationLifecycle) { + if (closed) return CompletableFuture.completedFuture(null); + deploymentWork = CompletableFuture.supplyAsync(() -> deployments.deploy(task, settings.endpoint(), + directLocalDeploymentEndpoint, settings.nodeId(), sessionId, deploymentCredential, deploymentHttp, + Duration.ofMillis(settings.requestTimeoutMillis()), () -> !closed), deploymentExecutor); + activeDeploymentWork = deploymentWork; + } + return deploymentWork.thenCompose(result -> { + if (closed) return CompletableFuture.completedFuture(null); + JsonObject body = new JsonObject(); + body.addProperty("sessionId", sessionId.toString()); + body.addProperty("success", result.success()); + body.addProperty("code", result.code()); + body.addProperty("message", boundedResultMessage(result.message())); + body.addProperty("attemptId", task.attemptId().toString()); + CompletableFuture submitted = transport.send(new Request("POST", "/api/v1/nodes/" + + settings.nodeId() + "/deployments/" + task.deploymentId() + "/result", body.toString())); + activeRequest = submitted; + return submitted.thenAccept(ControlConnector::requireSuccess); + }).whenComplete((ignored, failure) -> { + synchronized (operationLifecycle) { + if (activeDeploymentWork == deploymentWork) activeDeploymentWork = null; + } + }); } private static PluginDeploymentService.Task deploymentTask(JsonObject task) { @@ -1299,6 +1311,8 @@ public void close() { ScheduledFuture deployment = deploymentPolling; if (deployment != null) deployment.cancel(false); if (deployments != null) deployments.cancel(); + CompletableFuture deploymentWork = activeDeploymentWork; + if (deploymentWork != null) deploymentWork.cancel(true); if (deploymentExecutor != null) deploymentExecutor.shutdownNow(); CompletableFuture request = activeRequest; if (request != null) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java index e77b60165..81e424fe2 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java @@ -1,5 +1,6 @@ package com.bencodez.votingplugin.proxy.control; +import com.bencodez.votingplugin.control.PluginDeploymentService; import com.bencodez.votingplugin.proxy.control.ControlConnector.ObservedBackend; import com.bencodez.votingplugin.proxy.control.ControlConnector.Request; import com.bencodez.votingplugin.proxy.control.ControlConnector.Response; @@ -12,6 +13,7 @@ import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.net.http.HttpClient; import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; @@ -23,6 +25,7 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -891,6 +894,44 @@ class ControlConnectorTest { assertEquals(Status.STOPPED, connector.status()); } + @Test void closeCancelsARealQueuedDeploymentBeforeAwaitingItsOperation() throws Exception { + connector.close(); + Path update = dataDirectory.resolve("update"); + connector = deploymentConnector(PluginDeploymentService.backend(update, dataDirectory.resolve("VotingPlugin.jar"))); + setConnectorField("registered", true); + setConnectorField("deploymentsAccepted", true); + setConnectorField("status", Status.CONNECTED); + Field executorField = ControlConnector.class.getDeclaredField("deploymentExecutor"); + executorField.setAccessible(true); + ExecutorService deploymentExecutor = (ExecutorService) executorField.get(connector); + CountDownLatch workerStarted = new CountDownLatch(1); + deploymentExecutor.submit(() -> { + workerStarted.countDown(); + try { + new CountDownLatch(1).await(); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + }); + assertTrue(workerStarted.await(2, TimeUnit.SECONDS)); + UUID deploymentId = UUID.randomUUID(); + transport.nextPrimary = new Response(200, "{\"deploymentId\":\"" + deploymentId + + "\",\"artifactId\":\"VotingPlugin.jar\",\"sha256\":\"" + "a".repeat(64) + + "\",\"size\":\"1\",\"attemptId\":\"" + UUID.randomUUID() + "\"}"); + Method poll = ControlConnector.class.getDeclaredMethod("pollDeployments"); + poll.setAccessible(true); + poll.invoke(connector); + Field operationField = ControlConnector.class.getDeclaredField("activeOperation"); + operationField.setAccessible(true); + CompletableFuture operation = (CompletableFuture) operationField.get(connector); + assertNotNull(operation, "the actual deployment polling path must own the queued operation"); + + connector.close(); + + assertTrue(operation.isDone(), "shutdown must complete a deployment discarded before its executor starts it"); + assertFalse(Files.exists(update.resolve("VotingPlugin.jar")), "queued deployment work must not stage an artifact"); + } + @Test void proxyDeploymentIsNotAdvertisedOnWindowsWhereTheLiveJarCannotBeReplaced() { assertFalse(ControlConnector.proxyDeploymentSupported("Windows 11")); assertFalse(ControlConnector.proxyDeploymentSupported("Windows Server 2022")); @@ -928,6 +969,25 @@ private Settings settings() { URI.create("http://127.0.0.1:8080"), 30, 3000, 5000); } + private ControlConnector deploymentConnector(PluginDeploymentService deployments) throws Exception { + Constructor constructor = ControlConnector.class.getDeclaredConstructor(Settings.class, + ScheduledExecutorService.class, Transport.class, Supplier.class, Consumer.class, UUID.class, + LongSupplier.class, ProxyRoutingConfigurationService.class, Path.class, ProxyControlResultStore.Route.class, + boolean.class, Runnable.class, Function.class, ProxyMethodConfigurationService.class, Runnable.class, + ProxyConfigurationFileService.class, PluginDeploymentService.class, HttpClient.class, String.class, + boolean.class); + constructor.setAccessible(true); + return constructor.newInstance(settings(), scheduler, transport, (Supplier>) List::of, + (Consumer) logs::add, UUID.randomUUID(), (LongSupplier) () -> 0L, null, null, null, false, + null, null, null, null, null, deployments, HttpClient.newHttpClient(), "credential", false); + } + + private void setConnectorField(String name, Object value) throws Exception { + Field field = ControlConnector.class.getDeclaredField(name); + field.setAccessible(true); + field.set(connector, value); + } + private JsonObject submittedResult() { return JsonParser.parseString(transport.requests.stream().filter(request -> request.path().endsWith("/result")) .findFirst().orElseThrow().body()).getAsJsonObject(); From 5766dbf83c58e643eadd34135b83f4fc2ae7796a Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:50:11 -0600 Subject: [PATCH 16/19] Recognize verified staged artifact across deployment retries --- .../control/PluginDeploymentService.java | 15 +++++++---- .../control/PluginDeploymentServiceTest.java | 27 +++++++++++++++++-- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java index 4196d4d39..54e13619a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java @@ -120,8 +120,8 @@ public Result deploy(Task task, URI endpoint, boolean directLocalHosted, String return Result.failure("INSECURE_ENDPOINT", "Verified update staging requires HTTPS unless Control is hosted directly on this node"); } - if (alreadyStaged(task)) return Result.restartRequired(); if (!active.getAsBoolean()) return Result.failure("CANCELLED", "Deployment was cancelled before download"); + if (alreadyStaged(task)) return Result.restartRequired(); URI artifact = endpoint.resolve("/api/v1/nodes/" + nodeId + "/deployments/" + task.deploymentId() + "/artifact"); HttpRequest request = HttpRequest.newBuilder(artifact).timeout(timeout) @@ -172,6 +172,7 @@ public Result deploy(Task task, URI endpoint, boolean directLocalHosted, String /** Package-visible for deterministic artifact-validation tests. */ Result stage(Task task, InputStream body, BooleanSupplier active) throws IOException { validate(task); + if (!active.getAsBoolean()) return Result.failure("CANCELLED", "Deployment was cancelled before staging"); if (alreadyStaged(task) || recoverInterruptedActivation(task)) return Result.restartRequired(); activeResponse.compareAndSet(null, body); Path temporary = Files.createTempFile(root, target.getFileName().toString() + ".", ".download"); @@ -379,8 +380,12 @@ private boolean alreadyStaged(Task task) throws IOException { if (!Files.isRegularFile(marker, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(marker) || Files.size(marker) > 256) return false; String[] fields = Files.readString(marker, StandardCharsets.US_ASCII).split("\\R", -1); - if (fields.length < 3 || !task.deploymentId().toString().equals(fields[0]) - || !task.sha256().equals(fields[1]) || !Long.toString(task.size()).equals(fields[2])) return false; + // A lost acknowledgement can cause Control to issue a new deployment ID + // for the same verified artifact after reconnect or restart. The marker + // only proves a completed prior stage; the target is checked below by + // exact size, digest and plugin identity before acknowledging the retry. + if (fields.length < 3 || !task.sha256().equals(fields[1]) + || !Long.toString(task.size()).equals(fields[2])) return false; if (!replaceExisting && !Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { // Bukkit removes the staged update JAR after consuming it on restart. A // missing staging file alone is not proof: it may have been deleted or @@ -394,8 +399,8 @@ private boolean alreadyStaged(Task task) throws IOException { private boolean recoverInterruptedActivation(Task task) throws IOException { // Proxy replacement creates a durable backup before publishing the target; // that transaction shape lets a retry distinguish the activation crash window. - // Backend update folders have no such evidence and must still consume/verify - // the newly supplied body when a different deployment id is requested. + // Backend update folders have no such evidence and must consume/verify + // a new body unless a matching durable marker and artifact remain. if (!replaceExisting) return false; if (!targetMatches(task)) return false; writeMarker(task); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java index 26bd3118c..c9f6a5cde 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java @@ -73,6 +73,28 @@ class PluginDeploymentServiceTest { "a lost acknowledgement after restart must recognize the artifact Bukkit already consumed"); } + @Test void retryWithNewDeploymentIdAcknowledgesOnlyTheSameVerifiedArtifact() throws Exception { + byte[] artifact = jar("name: VotingPlugin\nversion: candidate\n"); + Path update = directory.resolve("update"); + Path installed = directory.resolve("VotingPlugin.jar"); + Files.write(installed, jar("name: VotingPlugin\nversion: old\n")); + PluginDeploymentService service = PluginDeploymentService.backend(update, installed); + PluginDeploymentService.Task original = task(artifact); + PluginDeploymentService.Task retry = task(artifact); + assertTrue(service.stage(original, new ByteArrayInputStream(artifact), () -> true).success()); + assertFalse(original.deploymentId().equals(retry.deploymentId())); + + assertEquals("RESTART_REQUIRED", + service.stage(retry, new ByteArrayInputStream(new byte[0]), () -> true).code()); + assertArrayEquals(artifact, Files.readAllBytes(update.resolve("VotingPlugin.jar"))); + + Files.write(installed, artifact); + Files.delete(update.resolve("VotingPlugin.jar")); + assertEquals("RESTART_REQUIRED", + service.stage(retry, new ByteArrayInputStream(new byte[0]), () -> true).code()); + assertFalse(Files.exists(update.resolve("VotingPlugin.jar"))); + } + @Test void backendRestagesWhenTheUpdateJarDisappearsBeforeBukkitConsumesIt() throws Exception { byte[] artifact = jar("name: VotingPlugin\nversion: candidate\n"); Path update = directory.resolve("update"); @@ -181,8 +203,9 @@ class PluginDeploymentServiceTest { byte[] targetBefore = Files.readAllBytes(directory.resolve("update/VotingPlugin.jar")); AtomicBoolean active = new AtomicBoolean(true); - InputStream body = new CancellingInputStream(new ByteArrayInputStream(artifact), active); - PluginDeploymentService.Task replacement = task(artifact); + byte[] replacementArtifact = jar("name: VotingPlugin\nversion: replacement\n"); + InputStream body = new CancellingInputStream(new ByteArrayInputStream(replacementArtifact), active); + PluginDeploymentService.Task replacement = task(replacementArtifact); assertEquals("CANCELLED", service.stage(replacement, body, active::get).code()); assertEquals(markerBefore, Files.readString(directory.resolve("update/VotingPlugin.jar.control-deployment"))); From ef2be5253ccf4d62e03bb66384d960aeff1f822f Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:57:38 -0600 Subject: [PATCH 17/19] Publish deployment marker before proxy activation --- .../control/PluginDeploymentService.java | 28 ++++++++----------- .../control/PluginDeploymentServiceTest.java | 25 +++++++++++++++-- docs/control-agent-contract.md | 10 +++---- 3 files changed, 39 insertions(+), 24 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java index 54e13619a..9a1bff3f4 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java @@ -173,7 +173,7 @@ public Result deploy(Task task, URI endpoint, boolean directLocalHosted, String Result stage(Task task, InputStream body, BooleanSupplier active) throws IOException { validate(task); if (!active.getAsBoolean()) return Result.failure("CANCELLED", "Deployment was cancelled before staging"); - if (alreadyStaged(task) || recoverInterruptedActivation(task)) return Result.restartRequired(); + if (alreadyStaged(task)) return Result.restartRequired(); activeResponse.compareAndSet(null, body); Path temporary = Files.createTempFile(root, target.getFileName().toString() + ".", ".download"); Activation activation = null; @@ -188,8 +188,9 @@ Result stage(Task task, InputStream body, BooleanSupplier active) throws IOExcep inspectJar(temporary); if (!active.getAsBoolean()) return Result.failure("CANCELLED", "Deployment was cancelled before staging"); activation = new Activation(); - activate(temporary, activation); + prepareProxyBackup(); writeMarker(task, activation); + activate(temporary, activation); activation.discard(); return Result.restartRequired(); } catch (CancelledDeploymentException failure) { @@ -291,7 +292,7 @@ private static String unquote(String value) { || (value.startsWith("'") && value.endsWith("'"))) ? value.substring(1, value.length() - 1) : value; } - private void activate(Path temporary, Activation activation) throws IOException { + private void prepareProxyBackup() throws IOException { if (replaceExisting) { if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) { throw new IOException("current proxy plugin JAR is unsafe"); @@ -309,6 +310,9 @@ private void activate(Path temporary, Activation activation) throws IOException forceDirectory(root); } finally { Files.deleteIfExists(backupTemp); } } + } + + private void activate(Path temporary, Activation activation) throws IOException { move(temporary, target); activation.published = true; force(target); @@ -335,11 +339,11 @@ private Activation() throws IOException { } private void rollback() throws IOException { + if (markerPublished) DurableFiles.deleteIfExists(marker); if (!published) { discard(); return; } - if (markerPublished) DurableFiles.deleteIfExists(marker); if (previous == null) { if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) { throw new IOException("deployed target cannot be safely removed"); @@ -379,6 +383,10 @@ private void writeMarker(Task task, Activation activation) throws IOException { private boolean alreadyStaged(Task task) throws IOException { if (!Files.isRegularFile(marker, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(marker) || Files.size(marker) > 256) return false; + if (replaceExisting) { + Path backup = root.resolve(target.getFileName() + ".control-backup"); + if (!Files.isRegularFile(backup, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(backup)) return false; + } String[] fields = Files.readString(marker, StandardCharsets.US_ASCII).split("\\R", -1); // A lost acknowledgement can cause Control to issue a new deployment ID // for the same verified artifact after reconnect or restart. The marker @@ -395,18 +403,6 @@ private boolean alreadyStaged(Task task) throws IOException { return targetMatches(task); } - /** Complete the durable marker half of an activation interrupted after publish. */ - private boolean recoverInterruptedActivation(Task task) throws IOException { - // Proxy replacement creates a durable backup before publishing the target; - // that transaction shape lets a retry distinguish the activation crash window. - // Backend update folders have no such evidence and must consume/verify - // a new body unless a matching durable marker and artifact remain. - if (!replaceExisting) return false; - if (!targetMatches(task)) return false; - writeMarker(task); - return true; - } - private boolean targetMatches(Task task) throws IOException { return fileMatches(target, task); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java index c9f6a5cde..e854316e6 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java @@ -171,7 +171,7 @@ class PluginDeploymentServiceTest { assertArrayEquals(second, Files.readAllBytes(current)); } - @Test void proxyRecoversActivationInterruptedBeforeMarkerWithoutReplacingBackup() throws Exception { + @Test void proxyMissingMarkerRequiresNormalVerifiedStaging() throws Exception { Path current = directory.resolve("VotingPlugin.jar"); Path backup = directory.resolve("VotingPlugin.jar.control-backup"); Path marker = directory.resolve("VotingPlugin.jar.control-deployment"); @@ -182,17 +182,36 @@ class PluginDeploymentServiceTest { Files.write(backup, original); PluginDeploymentService service = PluginDeploymentService.proxy(current); - assertEquals("RESTART_REQUIRED", + assertEquals("SIZE_MISMATCH", service.stage(task, new ByteArrayInputStream(new byte[0]), () -> true).code()); + assertFalse(Files.exists(marker)); + assertArrayEquals(original, Files.readAllBytes(backup)); + assertEquals("RESTART_REQUIRED", + service.stage(task, new ByteArrayInputStream(candidate), () -> true).code()); assertArrayEquals(candidate, Files.readAllBytes(current)); - assertArrayEquals(original, Files.readAllBytes(backup)); + assertArrayEquals(candidate, Files.readAllBytes(backup)); String state = Files.readString(marker, StandardCharsets.US_ASCII); assertTrue(state.contains(task.deploymentId().toString())); assertTrue(state.contains(task.sha256())); assertTrue(state.contains(Long.toString(task.size()))); } + @Test void proxyDoesNotAcknowledgeAStagedArtifactWithoutItsBackup() throws Exception { + Path current = directory.resolve("VotingPlugin.jar"); + Path backup = directory.resolve("VotingPlugin.jar.control-backup"); + byte[] original = jar("name: VotingPlugin\nversion: original\n"); + byte[] candidate = jar("name: VotingPlugin\nversion: candidate\n"); + Files.write(current, original); + PluginDeploymentService service = PluginDeploymentService.proxy(current); + PluginDeploymentService.Task task = task(candidate); + assertEquals("RESTART_REQUIRED", service.stage(task, new ByteArrayInputStream(candidate), () -> true).code()); + Files.delete(backup); + assertEquals("SIZE_MISMATCH", service.stage(task, new ByteArrayInputStream(new byte[0]), () -> true).code()); + assertEquals("RESTART_REQUIRED", service.stage(task, new ByteArrayInputStream(candidate), () -> true).code()); + assertTrue(Files.isRegularFile(backup)); + } + @Test void cancellationDuringCopyDoesNotPublish() throws Exception { byte[] artifact = jar("name: VotingPlugin\n"); PluginDeploymentService service = PluginDeploymentService.backend(directory.resolve("update"), Path.of("VotingPlugin.jar")); diff --git a/docs/control-agent-contract.md b/docs/control-agent-contract.md index d56c37f4e..dc1e5d6ad 100644 --- a/docs/control-agent-contract.md +++ b/docs/control-agent-contract.md @@ -176,11 +176,11 @@ X-Deployment-Attempt: The node independently verifies the exact size and SHA-256, bounded ZIP/JAR structure, root `plugin.yml`, and `name: VotingPlugin` before publication. Bukkit nodes stage to the server update folder; proxy nodes atomically replace -their discovered plugin JAR only after creating a durable `.control-backup`. Every successful deployment writes a small -`.control-deployment` marker so a lost result acknowledgement does not apply the same deployment twice. On Bukkit, that -marker remains authoritative after a restart consumes/removes the staged update JAR. On proxies, the target JAR must still -match the marker's digest; an interrupted publish-before-marker window can be recovered without overwriting the previous -backup. +their discovered plugin JAR only after creating a durable `.control-backup`. A small durable +`.control-deployment` marker is published before the verified target is moved into place, so a lost result +acknowledgement does not apply the same artifact twice. On Bukkit, a consumed staged update must match the installed JAR. +On proxies, both a safe backup and a target JAR matching the marker's digest are required for acknowledgement; a missing +marker requires a fresh verified download and staging attempt. The result is posted to: From 9bd840f8bc0be44dcf54ea05e53bf010de87755f Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:05:56 -0600 Subject: [PATCH 18/19] Reject unsafe Bukkit update targets and unknown deployment fields --- .../control/BackendControlConnector.java | 5 ++++- .../control/PluginDeploymentService.java | 11 +++++++++-- .../proxy/control/ControlConnector.java | 5 ++++- .../BackendControlConnectorProtocolTest.java | 16 ++++++++++++++++ .../control/PluginDeploymentServiceTest.java | 5 +++++ .../proxy/control/ControlConnectorTest.java | 11 +++++++++++ 6 files changed, 49 insertions(+), 4 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java index 4f143bb41..ac5a1bc20 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java @@ -54,6 +54,7 @@ public final class BackendControlConnector implements AutoCloseable { private static final Set CAPABILITIES = Set.of("config.files.v1", "config.file-comments.v1", "config.quick-setup.v1", "config.quick-setup.v2", "config.vote-sites-sync.v1", "config.proxy-method.v1", "config.reward-files.v1", "data.inspect.v1"); + private static final Set DEPLOYMENT_TASK_FIELDS = Set.of("deploymentId", "artifactId", "sha256", "size", "attemptId"); private final VotingPluginMain plugin; private final Path dataDirectory; @@ -349,8 +350,10 @@ private void claimAndDeploy() throws Exception { + "/result", submitted), 200); } - private static PluginDeploymentService.Task deploymentTask(JsonObject task) { + static PluginDeploymentService.Task deploymentTask(JsonObject task) { try { + if (task == null || task.size() != DEPLOYMENT_TASK_FIELDS.size() + || !DEPLOYMENT_TASK_FIELDS.containsAll(task.keySet())) throw new IllegalArgumentException(); return new PluginDeploymentService.Task(UUID.fromString(string(task, "deploymentId")), string(task, "artifactId"), string(task, "sha256"), Long.parseLong(string(task, "size")), UUID.fromString(string(task, "attemptId"))); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java index 9a1bff3f4..cf76174b3 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java @@ -87,8 +87,15 @@ public static PluginDeploymentService backend(Path updateDirectory, Path current || !currentPluginJar.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".jar")) { throw new IOException("Bukkit plugin JAR is unavailable"); } - return new PluginDeploymentService(updateDirectory.resolve(currentPluginJar.getFileName().toString()), - currentPluginJar, false); + Path target = updateDirectory.resolve(currentPluginJar.getFileName().toString()).toAbsolutePath().normalize(); + Path installed = currentPluginJar.toAbsolutePath().normalize(); + // An empty/disabled Bukkit update folder can resolve to the loaded JAR's + // directory. Staging there would overwrite a file that Bukkit currently has open. + if (target.equals(installed) || Files.exists(target, LinkOption.NOFOLLOW_LINKS) + && Files.exists(installed, LinkOption.NOFOLLOW_LINKS) && Files.isSameFile(target, installed)) { + throw new IOException("Bukkit update folder resolves to the loaded plugin JAR"); + } + return new PluginDeploymentService(target, currentPluginJar, false); } /** Proxies atomically replace their discovered plugin JAR only after a durable backup. */ diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java index 8fe63463c..a0576e7be 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java @@ -69,6 +69,7 @@ public final class ControlConnector implements AutoCloseable { private static final String PROXY_METHOD_PRESET = "proxy-method"; private static final String INTERNAL_OPERATION_TYPE = "_controlOperationType"; private static final String INTERNAL_REQUIRED_CAPABILITY = "_controlRequiredCapability"; + private static final Set DEPLOYMENT_TASK_FIELDS = Set.of("deploymentId", "artifactId", "sha256", "size", "attemptId"); private static final long OPERATION_POLL_MILLIS = 1000; private static final long MAX_BACKOFF_MILLIS = TimeUnit.MINUTES.toMillis(5); private static final long OPERATION_SHUTDOWN_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(65); @@ -367,8 +368,10 @@ private CompletableFuture handleDeploymentClaim(Response response) { }); } - private static PluginDeploymentService.Task deploymentTask(JsonObject task) { + static PluginDeploymentService.Task deploymentTask(JsonObject task) { try { + if (task == null || task.size() != DEPLOYMENT_TASK_FIELDS.size() + || !DEPLOYMENT_TASK_FIELDS.containsAll(task.keySet())) throw new IllegalArgumentException(); return new PluginDeploymentService.Task(UUID.fromString(requireString(task, "deploymentId")), requireString(task, "artifactId"), requireString(task, "sha256"), Long.parseLong(requireString(task, "size")), UUID.fromString(requireString(task, "attemptId"))); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java index 8670fa3dc..15e655cec 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java @@ -41,6 +41,22 @@ class BackendControlConnectorProtocolTest { "{\"error\":{\"code\":\"NODE_NOT_FOUND\"}}"))); } + @Test void deploymentTaskRejectsUnknownV1Fields() { + JsonObject task = deploymentTask(); + task.addProperty("unexpected", "value"); + assertThrows(IllegalArgumentException.class, () -> BackendControlConnector.deploymentTask(task)); + } + + private static JsonObject deploymentTask() { + JsonObject task = new JsonObject(); + task.addProperty("deploymentId", "00000000-0000-0000-0000-000000000001"); + task.addProperty("artifactId", "VotingPlugin.jar"); + task.addProperty("sha256", "a".repeat(64)); + task.addProperty("size", "1"); + task.addProperty("attemptId", "00000000-0000-0000-0000-000000000002"); + return task; + } + @Test void abandonedBackendIntentBecomesATerminalRecoveryResult() { JsonObject anticipated = new JsonObject(); anticipated.addProperty("attemptId", "00000000-0000-0000-0000-000000000199"); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java index e854316e6..32afa1b40 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java @@ -56,6 +56,11 @@ class PluginDeploymentServiceTest { assertFalse(Files.exists(update.resolve("VotingPlugin.jar"))); } + @Test void backendRejectsAnEmptyOrDisabledBukkitUpdateFolderThatResolvesToTheLoadedJar() { + assertThrows(IOException.class, + () -> PluginDeploymentService.backend(Path.of(""), Path.of("VotingPlugin.jar"))); + } + @Test void backendMatchingMarkerSurvivesBukkitConsumingTheStagedJar() throws Exception { byte[] artifact = jar("name: VotingPlugin\nversion: candidate\n"); Path update = directory.resolve("update"); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java index 81e424fe2..315447dfe 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java @@ -51,6 +51,17 @@ class ControlConnectorTest { @Test void responseBudgetCanCarryTheLargestEscapedManagedFileTask() { assertTrue(ControlConnector.MAX_RESPONSE_BYTES >= ProxyConfigurationFileService.MAX_BYTES * 6); } + + @Test void deploymentTaskRejectsUnknownV1Fields() { + JsonObject task = new JsonObject(); + task.addProperty("deploymentId", "00000000-0000-0000-0000-000000000001"); + task.addProperty("artifactId", "VotingPlugin.jar"); + task.addProperty("sha256", "a".repeat(64)); + task.addProperty("size", "1"); + task.addProperty("attemptId", "00000000-0000-0000-0000-000000000002"); + task.addProperty("unexpected", "value"); + assertThrows(RuntimeException.class, () -> ControlConnector.deploymentTask(task)); + } private ControlConnector connector; @BeforeEach void setUp() { From 0c5b725b1aa3862cb7577604222a9981985e4a82 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:13:57 -0600 Subject: [PATCH 19/19] Require atomic deployment publication --- .../control/PluginDeploymentService.java | 8 ++++---- .../control/PluginDeploymentServiceTest.java | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java index cf76174b3..2b5256738 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java @@ -9,7 +9,6 @@ import java.nio.charset.StandardCharsets; import java.nio.charset.CodingErrorAction; import java.nio.channels.FileChannel; -import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.Path; @@ -431,9 +430,10 @@ private boolean fileMatches(Path candidate, Task task) throws IOException { } } - private static void move(Path source, Path destination) throws IOException { - try { Files.move(source, destination, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } - catch (AtomicMoveNotSupportedException ignored) { Files.move(source, destination, StandardCopyOption.REPLACE_EXISTING); } + static void move(Path source, Path destination) throws IOException { + // The proxy target is executable on its next startup. A copy/delete fallback + // can leave it missing or partial after a crash, even with a durable backup. + Files.move(source, destination, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } private static void force(Path file) throws IOException { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java index 32afa1b40..423e46db8 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java @@ -19,6 +19,8 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.nio.file.FileSystems; +import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Path; import java.security.MessageDigest; import java.util.HexFormat; @@ -32,6 +34,21 @@ class PluginDeploymentServiceTest { @TempDir Path directory; + @Test void publicationRefusesAProviderWithoutAtomicMoves() throws Exception { + Path source = directory.resolve("VotingPlugin.jar"); + byte[] original = jar("name: VotingPlugin\nversion: old\n"); + Files.write(source, original); + Path archive = directory.resolve("non-atomic.zip"); + try (var zip = FileSystems.newFileSystem(java.net.URI.create("jar:" + archive.toUri()), + java.util.Map.of("create", "true"))) { + Path destination = zip.getPath("/VotingPlugin.jar"); + assertThrows(AtomicMoveNotSupportedException.class, + () -> PluginDeploymentService.move(source, destination)); + assertArrayEquals(original, Files.readAllBytes(source)); + assertFalse(Files.exists(destination)); + } + } + @Test void backendStagesOnlyAnExactVerifiedVotingPluginJarAndIsIdempotent() throws Exception { byte[] artifact = jar("name: VotingPlugin\nmain: example.Main\n"); PluginDeploymentService service = PluginDeploymentService.backend(directory.resolve("update"), Path.of("VotingPlugin.jar"));