From f195ff28f41695cb50b5743064bf29eeba4d5f19 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:05:15 -0600 Subject: [PATCH 01/39] Add durable automatic Control enrollment state --- .../util/ControlCredentialFile.java | 125 +++++++++++++++++- 1 file changed, 121 insertions(+), 4 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ControlCredentialFile.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ControlCredentialFile.java index 149dc9f661..12d96db9b5 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ControlCredentialFile.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ControlCredentialFile.java @@ -5,31 +5,91 @@ import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; import java.nio.channels.SeekableByteChannel; +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.nio.file.attribute.PosixFileAttributeView; import java.nio.file.attribute.PosixFilePermission; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.HexFormat; import java.util.Set; +import java.util.regex.Pattern; -/** Symlink-safe, bounded credential loading shared by proxy and Bukkit Control connectors. */ +/** Symlink-safe, bounded credential loading and supervised enrollment state. */ public final class ControlCredentialFile { private static final int MAX_BYTES = 512; + private static final int MAX_MARKER_BYTES = 256; + private static final Pattern NODE_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); + private static final Pattern VERIFIER = Pattern.compile("[0-9a-f]{64}"); private static final Set OWNER_ONLY = Set.of( PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE); + private static final SecureRandom RANDOM = new SecureRandom(); private ControlCredentialFile() { } public static String read(Path rootDirectory, String configuredPath) throws IOException { + String credential = readCredential(resolveAndCreate(rootDirectory, configuredPath)); + if (credential == null) throw invalid(); + return credential; + } + + /** + * Generates a credential only when the configured file is empty and retains a + * verifier-only marker until the supervising host acknowledges enrollment. + * Existing manually managed credentials are never replaced. + */ + public static synchronized PendingAutoEnrollment prepareAutoEnrollment(Path rootDirectory, String configuredPath, + String nodeId) throws IOException { + if (nodeId == null || !NODE_ID.matcher(nodeId).matches()) throw invalid(); Path target = resolveAndCreate(rootDirectory, configuredPath); + Path marker = marker(target); + String credential = readCredential(target); + PendingAutoEnrollment pending = readMarker(marker, target, configuredPath); + if (credential != null && pending == null) return null; + if (credential != null && !verifier(credential).equals(pending.verifier())) { + DurableFiles.deleteIfExists(marker); + return null; + } + if (credential != null && nodeId.equals(pending.nodeId())) return pending; + + String generated = token(); + String generatedVerifier = verifier(generated); + PendingAutoEnrollment replacement = new PendingAutoEnrollment(target, configuredPath, nodeId, + generatedVerifier); + writeAtomically(marker, markerContents(replacement)); + writeAtomically(target, generated); + return replacement; + } + + /** Removes the pending marker only when it still describes the installed verifier. */ + public static synchronized void completeAutoEnrollment(PendingAutoEnrollment enrollment) throws IOException { + if (enrollment == null) return; + Path target = enrollment.credentialFile().toAbsolutePath().normalize(); + Path marker = marker(target); + PendingAutoEnrollment current = readMarker(marker, target, enrollment.configuredPath()); + String credential = readCredential(target); + if (current == null || credential == null || !current.nodeId().equals(enrollment.nodeId()) + || !current.verifier().equals(enrollment.verifier()) + || !current.verifier().equals(verifier(credential))) { + throw new IOException("Control automatic enrollment state changed"); + } + DurableFiles.deleteIfExists(marker); + } + private static String readCredential(Path target) throws IOException { ByteBuffer bytes = ByteBuffer.allocate(MAX_BYTES + 1); try (SeekableByteChannel channel = Files.newByteChannel(target, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) { while (bytes.hasRemaining() && channel.read(bytes) > 0) { } } - if (bytes.position() == 0 || bytes.position() > MAX_BYTES) throw invalid(); + if (bytes.position() == 0) return null; + if (bytes.position() > MAX_BYTES) throw invalid(); bytes.flip(); String credential; try { @@ -38,11 +98,50 @@ public static String read(Path rootDirectory, String configuredPath) throws IOEx } catch (java.nio.charset.CharacterCodingException e) { throw invalid(); } - if (credential.isEmpty() || credential.length() > MAX_BYTES || credential.indexOf('\r') >= 0 + if (credential.isEmpty()) return null; + if (credential.length() > MAX_BYTES || credential.indexOf('\r') >= 0 || credential.indexOf('\n') >= 0) throw invalid(); return credential; } + private static PendingAutoEnrollment readMarker(Path marker, Path credentialFile, String configuredPath) + throws IOException { + if (!Files.exists(marker, LinkOption.NOFOLLOW_LINKS)) return null; + if (!Files.isRegularFile(marker, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(marker) + || Files.size(marker) > MAX_MARKER_BYTES) throw invalid(); + String[] lines = Files.readString(marker, StandardCharsets.US_ASCII).split("\\R", -1); + if (lines.length < 3 || !"1".equals(lines[0]) || !NODE_ID.matcher(lines[1]).matches() + || !VERIFIER.matcher(lines[2]).matches()) throw invalid(); + restrictPermissions(marker); + return new PendingAutoEnrollment(credentialFile, configuredPath, lines[1], lines[2]); + } + + private static String markerContents(PendingAutoEnrollment enrollment) { + return "1\n" + enrollment.nodeId() + "\n" + enrollment.verifier() + "\n"; + } + + private static Path marker(Path credentialFile) { + return credentialFile.resolveSibling(credentialFile.getFileName() + ".auto-enroll"); + } + + private static void writeAtomically(Path target, String value) throws IOException { + Path temporary = Files.createTempFile(target.getParent(), target.getFileName().toString(), ".temporary"); + try { + restrictPermissions(temporary); + Files.writeString(temporary, value, StandardCharsets.UTF_8, StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE); + DurableFiles.forceFile(temporary); + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + throw new IOException("Atomic Control credential publication is unsupported", e); + } + DurableFiles.forceMoveDirectories(temporary, target); + } finally { + Files.deleteIfExists(temporary); + } + } + private static Path resolveAndCreate(Path rootDirectory, String configuredPath) throws IOException { if (configuredPath == null || configuredPath.isBlank()) throw invalid(); Path lexicalRoot = rootDirectory.toAbsolutePath().normalize(); @@ -69,7 +168,7 @@ private static Path resolveAndCreate(Path rootDirectory, String configuredPath) if (!Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { try (SeekableByteChannel ignored = Files.newByteChannel(target, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) { - // Publish an empty placeholder for enrollment through the file manager or WebUI. + // Publish an empty placeholder for manual or supervised enrollment. } catch (java.nio.file.FileAlreadyExistsException ignored) { // Revalidate below in case another process created it. } @@ -90,7 +189,25 @@ private static void restrictPermissions(Path target) throws IOException { } } + private static String token() { + byte[] bytes = new byte[32]; + RANDOM.nextBytes(bytes); + return "vpctl_node_" + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private static String verifier(String credential) { + try { + return HexFormat.of().formatHex( + MessageDigest.getInstance("SHA-256").digest(credential.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is unavailable", e); + } + } + private static IOException invalid() { return new IOException("Control credential file is missing or invalid"); } + + /** Non-secret durable request for a supervising Control host to install. */ + public record PendingAutoEnrollment(Path credentialFile, String configuredPath, String nodeId, String verifier) { } } From 1d3a856fc22ce6a081051e79631038fcf0d2bf73 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:05:41 -0600 Subject: [PATCH 02/39] Test durable automatic credential generation --- .../util/ControlCredentialFileTest.java | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/ControlCredentialFileTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/ControlCredentialFileTest.java index 80f707bc65..1b9151c1fc 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/ControlCredentialFileTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/ControlCredentialFileTest.java @@ -1,6 +1,8 @@ package com.bencodez.votingplugin.util; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -16,7 +18,6 @@ class ControlCredentialFileTest { @TempDir Path directory; - @Test void createsBlankContainedCredentialFileBeforeEnrollment() throws Exception { Path root = Files.createDirectory(directory.resolve("plugin")); Path credential = root.resolve("control/control-credential.txt"); @@ -33,6 +34,37 @@ class ControlCredentialFileTest { } } + @Test void automaticEnrollmentSurvivesRestartUntilAcknowledged() throws Exception { + Path root = Files.createDirectory(directory.resolve("auto-plugin")); + String configured = "control/control-credential.txt"; + + ControlCredentialFile.PendingAutoEnrollment first = + ControlCredentialFile.prepareAutoEnrollment(root, configured, "proxy-a"); + String credential = ControlCredentialFile.read(root, configured); + ControlCredentialFile.PendingAutoEnrollment recovered = + ControlCredentialFile.prepareAutoEnrollment(root, configured, "proxy-a"); + + assertTrue(credential.startsWith("vpctl_node_")); + assertEquals(first.verifier(), recovered.verifier()); + assertTrue(Files.isRegularFile(root.resolve(configured + ".auto-enroll"))); + ControlCredentialFile.completeAutoEnrollment(recovered); + assertFalse(Files.exists(root.resolve(configured + ".auto-enroll"))); + assertNull(ControlCredentialFile.prepareAutoEnrollment(root, configured, "proxy-a")); + assertEquals(credential, ControlCredentialFile.read(root, configured)); + } + + @Test void existingManualCredentialIsNeverReplaced() throws Exception { + Path root = Files.createDirectory(directory.resolve("manual-plugin")); + Path control = Files.createDirectories(root.resolve("control")); + Path credential = control.resolve("control-credential.txt"); + Files.writeString(credential, "vpctl_node_manually_enrolled"); + + assertNull(ControlCredentialFile.prepareAutoEnrollment(root, + "control/control-credential.txt", "proxy-a")); + assertEquals("vpctl_node_manually_enrolled", Files.readString(credential)); + assertFalse(Files.exists(control.resolve("control-credential.txt.auto-enroll"))); + } + @Test void readsContainedFileAndRejectsFileDirectoryAndTraversalEscapes() throws Exception { Path root = Files.createDirectory(directory.resolve("plugin")); Path outside = Files.createDirectory(directory.resolve("outside")); From c00222ff8edeb382b741f7ea46be76380ef744c5 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:06:31 -0600 Subject: [PATCH 03/39] Prepare hosted Control for verifier enrollment --- .../proxy/control/HostedControlManager.java | 43 ++++++++++++++++--- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/HostedControlManager.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/HostedControlManager.java index 65ebc395ee..1a211528c1 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/HostedControlManager.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/HostedControlManager.java @@ -25,6 +25,7 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledThreadPoolExecutor; @@ -36,6 +37,8 @@ import com.bencodez.votingplugin.proxy.VotingPluginProxy; import com.bencodez.votingplugin.proxy.VotingPluginProxyConfig; import com.bencodez.votingplugin.util.BoundedHttpBodyHandler; +import com.bencodez.votingplugin.util.ControlCredentialFile; +import com.bencodez.votingplugin.util.ControlCredentialFile.PendingAutoEnrollment; import com.bencodez.votingplugin.util.DurableFiles; import com.google.gson.JsonElement; import com.google.gson.JsonObject; @@ -54,11 +57,13 @@ public final class HostedControlManager implements AutoCloseable { private final ScheduledExecutorService executor; private final ArtifactDownloader downloader; private final ProcessLauncher launcher; + private final VerifierInstaller verifierInstaller; private final HealthProbe healthProbe; private final Sleeper sleeper; private final LongSupplier nanoTime; private final Consumer logger; private final AtomicBoolean workInProgress = new AtomicBoolean(); + private final ConcurrentHashMap> enrollmentTasks = new ConcurrentHashMap<>(); private final Object processLifecycle = new Object(); private final Object preparationLifecycle = new Object(); private volatile boolean closed; @@ -73,26 +78,38 @@ public final class HostedControlManager implements AutoCloseable { private volatile String rollbackCandidateSha256; private volatile boolean rollbackCandidateReachedHealth; private volatile boolean rollbackPending; + private volatile PendingAutoEnrollment localAutoEnrollment; - private HostedControlManager(Settings settings, Consumer logger) { + private HostedControlManager(Settings settings, PendingAutoEnrollment localAutoEnrollment, + Consumer logger) { this(settings, daemonExecutor(), new JdkArtifactDownloader(), new JdkProcessLauncher(), - new JdkHealthProbe(), Thread::sleep, System::nanoTime, logger); + new JdkVerifierInstaller(), new JdkHealthProbe(), Thread::sleep, System::nanoTime, + localAutoEnrollment, logger); } HostedControlManager(Settings settings, ScheduledExecutorService executor, ArtifactDownloader downloader, ProcessLauncher launcher, HealthProbe healthProbe, Sleeper sleeper, LongSupplier nanoTime, Consumer logger) { + this(settings, executor, downloader, launcher, (configured, artifact, nodeId, verifier, timeout) -> { }, + healthProbe, sleeper, nanoTime, null, logger); + } + + HostedControlManager(Settings settings, ScheduledExecutorService executor, ArtifactDownloader downloader, + ProcessLauncher launcher, VerifierInstaller verifierInstaller, HealthProbe healthProbe, Sleeper sleeper, + LongSupplier nanoTime, PendingAutoEnrollment localAutoEnrollment, Consumer logger) { this.settings = Objects.requireNonNull(settings, "settings"); this.executor = Objects.requireNonNull(executor, "executor"); this.downloader = Objects.requireNonNull(downloader, "downloader"); this.launcher = Objects.requireNonNull(launcher, "launcher"); + this.verifierInstaller = Objects.requireNonNull(verifierInstaller, "verifierInstaller"); this.healthProbe = Objects.requireNonNull(healthProbe, "healthProbe"); this.sleeper = Objects.requireNonNull(sleeper, "sleeper"); this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime"); + this.localAutoEnrollment = localAutoEnrollment; this.logger = Objects.requireNonNull(logger, "logger"); } - public static HostedControlManager create(VotingPluginProxy proxy) { + public static HostedControlManager create(VotingPluginProxy proxy) throws IOException { VotingPluginProxyConfig config = proxy.getConfig(); HostConfiguration hosted = new HostConfiguration(config.getControlHostedEnabled(), config.getControlHostedAutoDownload(), config.getControlHostedAutoUpdate(), @@ -100,8 +117,16 @@ public static HostedControlManager create(VotingPluginProxy proxy) { config.getControlHostedJarFile(), config.getControlHostedDataDirectory(), config.getControlHostedHost(), config.getControlHostedPort(), config.getControlHostedStartupTimeoutSeconds(), config.getControlHostedDownloadTimeoutSeconds()); - return create(proxy.getDataFolderPlugin().toPath(), hosted, - message -> proxy.log("[Control Host] " + message)); + Path root = proxy.getDataFolderPlugin().toPath(); + PendingAutoEnrollment enrollment = null; + if (config.getControlEnabled() && isDirectLocalEndpoint(config.getControlEndpoint(), hosted)) { + String configuredNodeId = config.getControlNodeId(); + String nodeId = configuredNodeId == null || configuredNodeId.isBlank() + ? config.getProxyServerName() : configuredNodeId.trim(); + enrollment = ControlCredentialFile.prepareAutoEnrollment(root, + config.getControlCredentialFile(), nodeId); + } + return create(root, hosted, enrollment, message -> proxy.log("[Control Host] " + message)); } /** @@ -111,6 +136,12 @@ public static HostedControlManager create(VotingPluginProxy proxy) { */ public static HostedControlManager create(Path rootDirectory, HostConfiguration config, Consumer logger) { + return create(rootDirectory, config, null, logger); + } + + /** Creates a hosted supervisor with an optional verifier-only local enrollment. */ + public static HostedControlManager create(Path rootDirectory, HostConfiguration config, + PendingAutoEnrollment localAutoEnrollment, Consumer logger) { Objects.requireNonNull(config, "config"); if (!config.enabled()) return null; Path root = Objects.requireNonNull(rootDirectory, "rootDirectory").toAbsolutePath().normalize(); @@ -119,7 +150,7 @@ public static HostedControlManager create(Path rootDirectory, HostConfiguration resolveInside(root, config.dataDirectory(), "Control hosted data directory"), config.autoDownload(), config.autoUpdate(), parseDownloadUri(config.downloadUrl()), config.sha256(), config.host(), config.port(), config.startupTimeoutSeconds(), config.downloadTimeoutSeconds()); - return new HostedControlManager(settings, Objects.requireNonNull(logger, "logger")); + return new HostedControlManager(settings, localAutoEnrollment, Objects.requireNonNull(logger, "logger")); } public void start() { From 8d653745e9f08237f4a9c17bb94864bccb4d1185 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:07:25 -0600 Subject: [PATCH 04/39] Install local and backend Control verifiers --- .../proxy/control/HostedControlManager.java | 123 ++++++++++++++++-- 1 file changed, 115 insertions(+), 8 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/HostedControlManager.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/HostedControlManager.java index 1a211528c1..7b55db444d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/HostedControlManager.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/HostedControlManager.java @@ -228,6 +228,7 @@ void runOnce() { if (closed) { return; } + installLocalAutoEnrollment(); LaunchedProcess launched = launch(settings.jarFile()); ManagedProcess process = launched.process(); if (updated) { @@ -543,6 +544,54 @@ private void clearPersistedQuarantineState() throws IOException { DurableFiles.deleteIfExists(settings.quarantineFile()); } + private void installLocalAutoEnrollment() throws IOException, InterruptedException { + PendingAutoEnrollment enrollment = localAutoEnrollment; + if (enrollment == null) return; + verifierInstaller.install(settings, settings.jarFile(), enrollment.nodeId(), enrollment.verifier(), + Duration.ofSeconds(Math.min(30, settings.startupTimeoutSeconds()))); + ControlCredentialFile.completeAutoEnrollment(enrollment); + localAutoEnrollment = null; + logger.accept("Automatically enrolled local node " + enrollment.nodeId()); + } + + /** + * Installs a verifier generated by a backend connected through the trusted + * proxy plugin-message channel. The raw credential never reaches this process. + */ + public CompletableFuture installNodeVerifier(String nodeId, String verifier) { + if (nodeId == null || !nodeId.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}") + || verifier == null || !verifier.matches("[0-9a-f]{64}")) { + return CompletableFuture.completedFuture(false); + } + String key = nodeId + ':' + verifier; + return enrollmentTasks.computeIfAbsent(key, ignored -> { + CompletableFuture result = new CompletableFuture<>(); + try { + executor.execute(() -> { + try { + if (closed || (status != Status.RUNNING && status != Status.ROLLED_BACK) + || !Files.isRegularFile(settings.jarFile(), LinkOption.NOFOLLOW_LINKS)) { + result.complete(false); + return; + } + verifierInstaller.install(settings, settings.jarFile(), nodeId, verifier, + Duration.ofSeconds(Math.min(30, settings.startupTimeoutSeconds()))); + result.complete(true); + } catch (Exception failure) { + if (failure instanceof InterruptedException) Thread.currentThread().interrupt(); + result.complete(false); + } finally { + enrollmentTasks.remove(key, result); + } + }); + } catch (RuntimeException failure) { + result.complete(false); + enrollmentTasks.remove(key, result); + } + return result; + }); + } + private LaunchedProcess launch(Path artifact) throws IOException { status = Status.STARTING_PROCESS; synchronized (processLifecycle) { @@ -788,6 +837,24 @@ static Path resolveInside(Path root, String configured, String name) { return resolved; } + /** True only for direct loopback HTTP to this hosted listener. */ + public static boolean isDirectLocalEndpoint(String configuredEndpoint, HostConfiguration hosted) { + if (hosted == null || !hosted.enabled() || configuredEndpoint == null) return false; + try { + URI endpoint = URI.create(configuredEndpoint.trim()); + String host = endpoint.getHost(); + int port = endpoint.getPort() < 0 ? 80 : endpoint.getPort(); + String path = endpoint.getPath(); + boolean loopback = "localhost".equalsIgnoreCase(host) || "127.0.0.1".equals(host) + || "::1".equals(host) || "0:0:0:0:0:0:0:1".equals(host); + return "http".equalsIgnoreCase(endpoint.getScheme()) && loopback && port == hosted.port() + && endpoint.getUserInfo() == null && endpoint.getQuery() == null && endpoint.getFragment() == null + && (path == null || path.isEmpty() || "/".equals(path)); + } catch (IllegalArgumentException e) { + return false; + } + } + static URI parseDownloadUri(String configured) { if (configured == null || configured.isBlank()) { return null; @@ -903,6 +970,11 @@ interface ProcessLauncher { ManagedProcess launch(Settings settings, Path artifact, String launchId) throws IOException; } + interface VerifierInstaller { + void install(Settings settings, Path artifact, String nodeId, String verifier, Duration timeout) + throws IOException, InterruptedException; + } + interface HealthProbe { boolean isHealthy(URI endpoint, Duration timeout, String launchId); } @@ -1012,6 +1084,34 @@ private static Thread bodyTimeoutThread(InputStream input, long deadline, Atomic } } + private static final class JdkVerifierInstaller implements VerifierInstaller { + @Override + public void install(Settings settings, Path artifact, String nodeId, String verifier, Duration timeout) + throws IOException, InterruptedException { + Path java = Path.of(System.getProperty("java.home"), "bin", isWindows() ? "java.exe" : "java"); + ProcessBuilder builder = new ProcessBuilder(java.toString(), "-jar", artifact.toString(), + "enroll-verifier", nodeId, verifier, settings.dataDirectory().toString()); + builder.directory(artifact.getParent().toFile()); + builder.redirectOutput(ProcessBuilder.Redirect.DISCARD); + builder.redirectError(ProcessBuilder.Redirect.DISCARD); + copySafeEnvironment(builder); + Process process = builder.start(); + boolean exited; + try { + exited = process.waitFor(Math.max(1L, timeout.toMillis()), TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + process.destroyForcibly(); + throw e; + } + if (!exited) { + process.destroy(); + if (!process.waitFor(2, TimeUnit.SECONDS)) process.destroyForcibly(); + throw new IOException("Control verifier enrollment timed out"); + } + if (process.exitValue() != 0) throw new IOException("Control verifier enrollment failed"); + } + } + private static final class JdkProcessLauncher implements ProcessLauncher { @Override public ManagedProcess launch(Settings settings, Path artifact, String launchId) throws IOException { @@ -1028,14 +1128,8 @@ public ManagedProcess launch(Settings settings, Path artifact, String launchId) builder.directory(artifact.getParent().toFile()); builder.redirectErrorStream(true); builder.redirectOutput(ProcessBuilder.Redirect.appendTo(log.toFile())); + copySafeEnvironment(builder); var environment = builder.environment(); - environment.clear(); - for (String safe : Set.of("LANG", "LC_ALL", "SYSTEMROOT", "WINDIR", "TEMP", "TMP")) { - String value = System.getenv(safe); - if (value != null) { - environment.put(safe, value); - } - } environment.put("CONTROL_HOST", settings.host()); environment.put("CONTROL_PORT", Integer.toString(settings.port())); environment.put("CONTROL_DATA_DIR", settings.dataDirectory().toString()); @@ -1045,10 +1139,23 @@ public ManagedProcess launch(Settings settings, Path artifact, String launchId) } private static boolean isWindows() { - return DurableFiles.isWindowsName(System.getProperty("os.name", "")); + return HostedControlManager.isWindows(); } } + private static void copySafeEnvironment(ProcessBuilder builder) { + var environment = builder.environment(); + environment.clear(); + for (String safe : Set.of("LANG", "LC_ALL", "SYSTEMROOT", "WINDIR", "TEMP", "TMP")) { + String value = System.getenv(safe); + if (value != null) environment.put(safe, value); + } + } + + private static boolean isWindows() { + return DurableFiles.isWindowsName(System.getProperty("os.name", "")); + } + static final class JdkHealthProbe implements HealthProbe { private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)) .followRedirects(HttpClient.Redirect.NEVER).build(); From e09dc1a7def9a8ffefe4fb07323cb895ddcf783d Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:22:40 -0600 Subject: [PATCH 05/39] Automate hosted Control credential enrollment --- .../votingplugin/VotingPluginMain.java | 52 ++++++++++++++++--- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 08e3afff8f..4a8660172f 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -80,6 +80,7 @@ import com.bencodez.votingplugin.config.SpecialRewardsConfig; import com.bencodez.votingplugin.config.VotingPluginConfigHealth; import com.bencodez.votingplugin.cooldown.CoolDownCheck; +import com.bencodez.votingplugin.control.BackendControlAutoEnrollment; import com.bencodez.votingplugin.control.BackendControlConnector; import com.bencodez.votingplugin.data.ServerData; import com.bencodez.votingplugin.discord.DiscordHandler; @@ -96,6 +97,7 @@ import com.bencodez.votingplugin.placeholders.VotingPluginExpansion; import com.bencodez.votingplugin.presets.VoteSitePresetSetupHandler; import com.bencodez.votingplugin.proxy.control.HostedControlManager; +import com.bencodez.votingplugin.util.ControlCredentialFile.PendingAutoEnrollment; import com.bencodez.votingplugin.rewards.VotingPluginRewardRegistrar; import com.bencodez.votingplugin.servicesites.ServiceSiteHandler; import com.bencodez.votingplugin.signs.Signs; @@ -163,6 +165,7 @@ public class VotingPluginMain extends AdvancedCorePlugin { private final ProcessedVoteCache backendProcessedVoteCache = new ProcessedVoteCache(); private final AtomicReference backendPluginMessageTarget = new AtomicReference<>(); private PluginMessageHandler backendPluginMessageRelay; + private volatile BackendControlAutoEnrollment backendControlAutoEnrollment; private volatile BackendControlConnector backendControlConnector; private final ScheduledExecutorService backendControlConnectorLifecycle = Executors.newSingleThreadScheduledExecutor(runnable -> { Thread thread = new Thread(runnable, "votingplugin-control-backend-connector-lifecycle"); @@ -785,12 +788,11 @@ private void startBackendHostedControl() { backendHostedControlActiveConfiguration = configuration; if (!configuration.enabled()) return; try { - backendHostedControlManager = HostedControlManager.create(getDataFolder().toPath(), configuration, - message -> getLogger().info("[Control Host] " + message)); + backendHostedControlManager = createBackendHostedControlManager(configuration); backendHostedControlActiveManager = backendHostedControlManager; if (backendHostedControlActiveManager != null) backendHostedControlManagers.add(backendHostedControlActiveManager); if (backendHostedControlActiveManager != null) backendHostedControlActiveManager.start(); - } catch (IllegalArgumentException e) { + } catch (IOException | IllegalArgumentException e) { backendHostedControlManager = null; backendHostedControlActiveManager = null; getLogger().warning("[Control Host] Bukkit hosting configuration is invalid; VotingPlugin remains active: " @@ -798,6 +800,13 @@ private void startBackendHostedControl() { } } + private HostedControlManager createBackendHostedControlManager( + HostedControlManager.HostConfiguration configuration) throws IOException { + PendingAutoEnrollment enrollment = BackendControlAutoEnrollment.prepareLocal(this, configuration); + return HostedControlManager.create(getDataFolder().toPath(), configuration, enrollment, + message -> getLogger().info("[Control Host] " + message)); + } + private HostedControlManager.HostConfiguration readBackendHostedControlConfiguration() { ConfigurationSection hosted = getConfigFile().getData().getConfigurationSection("Control.Hosted"); if (hosted == null) { @@ -828,9 +837,8 @@ private synchronized void restartBackendHostedControlIfChanged() { } HostedControlManager replacement; try { - replacement = HostedControlManager.create(getDataFolder().toPath(), replacementConfiguration, - message -> getLogger().info("[Control Host] " + message)); - } catch (IllegalArgumentException e) { + replacement = createBackendHostedControlManager(replacementConfiguration); + } catch (IOException | IllegalArgumentException e) { getLogger().warning("[Control Host] Applied Bukkit hosting settings are invalid; the current host is unchanged: " + e.getMessage()); return; @@ -961,8 +969,7 @@ private void restoreBackendHostedControl(HostedControlManager.HostConfiguration } HostedControlManager restored = null; try { - restored = HostedControlManager.create(getDataFolder().toPath(), previousConfiguration, - message -> getLogger().info("[Control Host] " + message)); + restored = createBackendHostedControlManager(previousConfiguration); if (restored != null) backendHostedControlManagers.add(restored); if (restored != null && !restored.startAndWaitForInitialResult()) { closeBackendHostedControlManager(restored, true); @@ -1033,6 +1040,7 @@ private void stopBackendHostedControlLifecycle() { private void startBackendControlConnector() { try { + refreshBackendControlAutoEnrollment(); backendControlConnector = BackendControlConnector.create(this); if (backendControlConnector != null) backendControlConnector.start(); } catch (Exception e) { @@ -1041,6 +1049,16 @@ private void startBackendControlConnector() { } } + private void refreshBackendControlAutoEnrollment() throws IOException { + BackendControlAutoEnrollment previous = backendControlAutoEnrollment; + backendControlAutoEnrollment = null; + if (previous != null) previous.close(); + BackendControlAutoEnrollment replacement = BackendControlAutoEnrollment.create(this, + getActiveBackendHostedControlConfigurationSnapshot()); + backendControlAutoEnrollment = replacement; + if (replacement != null) replacement.start(); + } + /** Refreshes the optional Control services after their Config.yml settings were applied. */ public synchronized void restartBackendControlConnector() { backendControlConnectorReconcileRequested = true; @@ -1092,6 +1110,7 @@ private void reconcileBackendControlConnector() { BackendControlConnector replacement; boolean creationFailed = false; try { + refreshBackendControlAutoEnrollment(); replacement = BackendControlConnector.create(this); } catch (Exception e) { replacement = null; @@ -1135,6 +1154,9 @@ public synchronized boolean hasDeferredBackendControlReconciliation() { private void stopBackendControlConnectorLifecycle() { backendControlConnectorStopping = true; backendControlConnectorLifecycle.shutdownNow(); + BackendControlAutoEnrollment enrollment = backendControlAutoEnrollment; + backendControlAutoEnrollment = null; + if (enrollment != null) enrollment.close(); BackendControlConnector connector = backendControlConnector; backendControlConnector = null; if (connector != null) { @@ -1167,6 +1189,9 @@ public synchronized void restartBackendProxyHandler() { if (!bungeeSettings.isUseBungeecoord()) { backendProxyHandler = null; if (previous != null) previous.close(); + BackendControlAutoEnrollment enrollment = backendControlAutoEnrollment; + backendControlAutoEnrollment = null; + if (enrollment != null) enrollment.close(); return; } BungeeMethod replacementMethod = BungeeMethod.getByName(bungeeSettings.getBungeeMethod()); @@ -1182,6 +1207,11 @@ public synchronized void restartBackendProxyHandler() { } backendProxyHandler = replacement; if (previous != null) previous.close(); + try { + refreshBackendControlAutoEnrollment(); + } catch (IOException e) { + getLogger().warning("[Control] Automatic backend enrollment was not refreshed: " + e.getMessage()); + } } /** Keeps one plugin-message listener for the plugin lifetime and atomically swaps its active backend handler. */ @@ -1203,6 +1233,12 @@ public void deactivateBackendPluginMessageHandler(GlobalMessageHandler target) { backendPluginMessageTarget.compareAndSet(target, null); } + /** Handles the non-secret acknowledgement for a pending proxy-mediated enrollment. */ + public void handleBackendControlEnrollmentResult(com.bencodez.simpleapi.servercomm.codec.JsonEnvelope envelope) { + BackendControlAutoEnrollment enrollment = backendControlAutoEnrollment; + if (enrollment != null) enrollment.handle(envelope); + } + private void migrateVoteBroadcast(Config configFile) { ConfigurationSection cfg = configFile.getData(); // If new section exists, do nothing From cf921ad4d841882d8932be6f37e104df06802670 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:22:42 -0600 Subject: [PATCH 06/39] Automate hosted Control credential enrollment --- .../backendproxy/messaging/BackendProxyMessageRouter.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java index 59563bffbd..40b6cb689a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java @@ -50,6 +50,9 @@ public void register(GlobalMessageHandler messages, BungeeMethod method) { messages.addListener(new GlobalMessageListener(VotingPluginWire.SUB_VOTE_DELAY_REJECTED) { @Override public void onReceive(JsonEnvelope msg) { handleWireVoteDelayRejected(msg); } }); + messages.addListener(new GlobalMessageListener(VotingPluginWire.SUB_CONTROL_ENROLLMENT_RESULT) { + @Override public void onReceive(JsonEnvelope msg) { plugin.handleBackendControlEnrollmentResult(msg); } + }); if (method.supportsBackendPresence()) { messages.addListener(new GlobalMessageListener(VotingPluginWire.SUB_PRESENCE_RESYNC_REQUEST) { From 67c7ea5b5772871dc5c5d2327a53fa81b7159460 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:22:43 -0600 Subject: [PATCH 07/39] Add automatic backend Control enrollment --- .../control/BackendControlAutoEnrollment.java | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java new file mode 100644 index 0000000000..32a97a680a --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java @@ -0,0 +1,127 @@ +package com.bencodez.votingplugin.control; + +import java.io.IOException; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.scheduler.BukkitTask; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.backendproxy.BackendProxyHandler; +import com.bencodez.votingplugin.proxy.BungeeMethod; +import com.bencodez.votingplugin.proxy.VotingPluginWire; +import com.bencodez.votingplugin.proxy.control.HostedControlManager; +import com.bencodez.votingplugin.proxy.control.HostedControlManager.HostConfiguration; +import com.bencodez.votingplugin.util.ControlCredentialFile; +import com.bencodez.votingplugin.util.ControlCredentialFile.PendingAutoEnrollment; + +/** + * Enrolls a Bukkit node through its authenticated proxy server connection while + * keeping the generated bearer credential on the Bukkit node. + */ +public final class BackendControlAutoEnrollment implements AutoCloseable { + private static final long RETRY_TICKS = 20L * 15L; + + private final VotingPluginMain plugin; + private final PendingAutoEnrollment enrollment; + private final UUID requestId = UUID.randomUUID(); + private final AtomicBoolean closed = new AtomicBoolean(); + private volatile BukkitTask retryTask; + + private BackendControlAutoEnrollment(VotingPluginMain plugin, PendingAutoEnrollment enrollment) { + this.plugin = plugin; + this.enrollment = enrollment; + } + + /** Prepares a verifier that this Bukkit process's own hosted Control will install. */ + public static PendingAutoEnrollment prepareLocal(VotingPluginMain plugin, HostConfiguration hosted) + throws IOException { + ConfigurationSection control = control(plugin); + if (control == null || !control.getBoolean("Enabled", false) + || !HostedControlManager.isDirectLocalEndpoint(control.getString("Endpoint", ""), hosted)) { + return null; + } + return prepare(plugin, control); + } + + /** Prepares proxy-mediated enrollment only when source-bound plugin messaging is active. */ + public static BackendControlAutoEnrollment create(VotingPluginMain plugin, HostConfiguration hosted) + throws IOException { + ConfigurationSection control = control(plugin); + if (control == null || !control.getBoolean("Enabled", false) + || HostedControlManager.isDirectLocalEndpoint(control.getString("Endpoint", ""), hosted) + || !plugin.getBungeeSettings().isUseBungeecord() + || BungeeMethod.getByName(plugin.getBungeeSettings().getBungeeMethod()) != BungeeMethod.PLUGINMESSAGING) { + return null; + } + String serverName = plugin.getOptions().getServer(); + String nodeId = nodeId(plugin, control); + if (!nodeId.equals(serverName) || "pleaseset".equalsIgnoreCase(nodeId)) { + return null; + } + PendingAutoEnrollment enrollment = prepare(plugin, control); + return enrollment == null ? null : new BackendControlAutoEnrollment(plugin, enrollment); + } + + public static String configuredNodeId(VotingPluginMain plugin, ConfigurationSection control) { + return nodeId(plugin, control); + } + + private static PendingAutoEnrollment prepare(VotingPluginMain plugin, ConfigurationSection control) + throws IOException { + String credentialFile = control.getString("CredentialFile", "control/control-credential.txt"); + return ControlCredentialFile.prepareAutoEnrollment(plugin.getDataFolder().toPath(), credentialFile, + nodeId(plugin, control)); + } + + private static ConfigurationSection control(VotingPluginMain plugin) { + return plugin.getConfigFile().getData().getConfigurationSection("Control.Backend"); + } + + private static String nodeId(VotingPluginMain plugin, ConfigurationSection control) { + String configured = control.getString("NodeId", ""); + return configured == null || configured.isBlank() ? plugin.getOptions().getServer() : configured.trim(); + } + + public void start() { + if (closed.get() || retryTask != null) return; + try { + retryTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::send, 1L, RETRY_TICKS); + } catch (RuntimeException failure) { + closed.set(true); + plugin.getLogger().warning("[Control] Automatic backend enrollment could not be scheduled"); + } + } + + private void send() { + if (closed.get()) return; + BackendProxyHandler handler = plugin.getBackendProxyHandler(); + if (handler == null || handler.getMethod() != BungeeMethod.PLUGINMESSAGING + || handler.getGlobalMessageHandler() == null) return; + handler.getGlobalMessageHandler().sendMessage(VotingPluginWire.controlEnrollmentRequest( + enrollment.nodeId(), enrollment.verifier(), requestId)); + } + + public void handle(JsonEnvelope envelope) { + VotingPluginWire.ControlEnrollmentResult result = VotingPluginWire.readControlEnrollmentResult(envelope); + if (closed.get() || !result.valid || !result.success || !requestId.equals(result.requestId) + || !enrollment.nodeId().equals(result.nodeId)) return; + try { + ControlCredentialFile.completeAutoEnrollment(enrollment); + plugin.getLogger().info("[Control] Automatic credential enrollment completed for " + enrollment.nodeId()); + close(); + } catch (IOException e) { + plugin.getLogger().warning("[Control] Automatic credential enrollment could not be finalized; it will retry"); + } + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) return; + BukkitTask task = retryTask; + if (task != null) task.cancel(); + retryTask = null; + } +} From dedc30ab41c994a1e778133f1777020b330d8ac6 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:22:44 -0600 Subject: [PATCH 08/39] Automate hosted Control credential enrollment --- .../bencodez/votingplugin/control/BackendControlConnector.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 7337db1cf5..71ec418500 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java @@ -121,7 +121,7 @@ public static BackendControlConnector create(VotingPluginMain plugin) throws IOE } ConfigurationSection control = plugin.getConfigFile().getData().getConfigurationSection("Control.Backend"); if (control == null || !control.getBoolean("Enabled", false)) return null; - String nodeId = control.getString("NodeId", "").trim(); + String nodeId = BackendControlAutoEnrollment.configuredNodeId(plugin, control); if (!NODE_ID.matcher(nodeId).matches()) throw new IllegalArgumentException("Control.Backend.NodeId is invalid"); String endpointValue = control.getString("Endpoint", "").trim(); URI endpoint = URI.create(endpointValue); From e5bf0fdb19071d3a23a91b12693feff50c98d2a7 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:22:46 -0600 Subject: [PATCH 09/39] Automate hosted Control credential enrollment --- .../votingplugin/proxy/VotingPluginProxy.java | 49 ++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java index ab0dab545a..e06d8f0062 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java @@ -31,6 +31,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import javax.net.ssl.SSLParameters; @@ -97,6 +98,7 @@ public abstract class VotingPluginProxy { private static final long PRESENCE_STARTUP_RESYNC_DELAY_SECONDS = 5L; private static final long PRESENCE_MAINTENANCE_INTERVAL_SECONDS = 30L; private static final long PRESENCE_BACKEND_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(90); + private static final long CONTROL_ENROLLMENT_MIN_INTERVAL_NANOS = TimeUnit.SECONDS.toNanos(10); @Getter @Setter @@ -168,6 +170,7 @@ public abstract class VotingPluginProxy { private final BackendPlayerPresenceTracker backendPlayerPresenceTracker = new BackendPlayerPresenceTracker(); private final Map pendingPresenceHandoffs = new HashMap<>(); private final Set pendingBackendRecoverySnapshots = ConcurrentHashMap.newKeySet(); + private final Map controlEnrollmentNextAllowed = new ConcurrentHashMap<>(); private volatile ControlConnector controlConnector; private volatile HostedControlManager hostedControlManager; private final Object controlLifecycleLock = new Object(); @@ -1609,9 +1612,9 @@ private void startControlServicesLocked() { try { hostedControlManager = HostedControlManager.create(this); if (hostedControlManager != null) hostedControlManager.start(); - } catch (IllegalArgumentException e) { + } catch (IOException | IllegalArgumentException e) { hostedControlManager = null; - logSevere("[Control Host] configuration is invalid; VotingPlugin remains unaffected"); + logSevere("[Control Host] configuration or automatic enrollment is invalid; VotingPlugin remains unaffected"); } } try { @@ -2446,6 +2449,11 @@ private void runCleanup(String service, CleanupAction cleanup) { private interface CleanupAction { void run() throws Exception; } public void onPluginMessageReceived(DataInputStream in) { + onPluginMessageReceived(in, null); + } + + /** Receives a plugin message bound to the backend server connection that sent it. */ + public void onPluginMessageReceived(DataInputStream in, String sourceServer) { runAsync(() -> { try { final String headerSub; @@ -2480,6 +2488,11 @@ public void onPluginMessageReceived(DataInputStream in) { return; } + if (VotingPluginWire.SUB_CONTROL_ENROLLMENT_REQUEST.equals(envelope.getSubChannel())) { + handleControlEnrollmentRequest(sourceServer, envelope); + return; + } + globalMessageProxyHandler.onMessage(envelope); } catch (Exception e) { e.printStackTrace(); @@ -2487,6 +2500,38 @@ public void onPluginMessageReceived(DataInputStream in) { }); } + private void handleControlEnrollmentRequest(String sourceServer, JsonEnvelope envelope) { + VotingPluginWire.ControlEnrollmentRequest request = VotingPluginWire.readControlEnrollmentRequest(envelope); + if (!request.valid || sourceServer == null || sourceServer.isBlank()) return; + if (!sourceServer.equals(request.nodeId)) { + sendPluginMessageServer(sourceServer, 0, + VotingPluginWire.controlEnrollmentResult(sourceServer, request.requestId, false)); + return; + } + long now = System.nanoTime(); + AtomicBoolean allowed = new AtomicBoolean(); + controlEnrollmentNextAllowed.compute(sourceServer, (ignored, nextAllowed) -> { + if (nextAllowed == null || now >= nextAllowed) { + allowed.set(true); + return now + CONTROL_ENROLLMENT_MIN_INTERVAL_NANOS; + } + return nextAllowed; + }); + if (!allowed.get()) return; + HostedControlManager manager = hostedControlManager; + if (manager == null) { + sendPluginMessageServer(sourceServer, 0, + VotingPluginWire.controlEnrollmentResult(sourceServer, request.requestId, false)); + return; + } + manager.installNodeVerifier(sourceServer, request.verifier).whenComplete((installed, failure) -> { + boolean success = failure == null && Boolean.TRUE.equals(installed); + sendPluginMessageServer(sourceServer, 0, + VotingPluginWire.controlEnrollmentResult(sourceServer, request.requestId, success)); + if (success) log("[Control] automatically enrolled backend node " + sourceServer); + }); + } + private UUID parseUUIDFromString(String uuidAsString) { String[] parts = { "0x" + uuidAsString.substring(0, 8), "0x" + uuidAsString.substring(8, 12), "0x" + uuidAsString.substring(12, 16), "0x" + uuidAsString.substring(16, 20), From 47379aa4ba3e8175d5a7928423e050b8af52df66 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:22:48 -0600 Subject: [PATCH 10/39] Automate hosted Control credential enrollment --- .../votingplugin/proxy/VotingPluginWire.java | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java index ad1449a57e..89e8c2a67f 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java @@ -59,6 +59,8 @@ private VotingPluginWire() { public static final String SUB_PRESENCE_RESYNC_REQUEST = "PresenceResyncRequest"; public static final String SUB_PRESENCE_SNAPSHOT_REQUEST = "PresenceSnapshotRequest"; public static final String SUB_PRESENCE_SNAPSHOT = "PresenceSnapshot"; + public static final String SUB_CONTROL_ENROLLMENT_REQUEST = "ControlEnrollmentRequest"; + public static final String SUB_CONTROL_ENROLLMENT_RESULT = "ControlEnrollmentResult"; public static final String SUB_VOTEUPDATE_RELAY = "voteupdate"; // ========================= @@ -85,6 +87,9 @@ private VotingPluginWire() { public static final String K_PLAYERS = "players"; public static final String K_CHUNK_INDEX = "chunkIndex"; public static final String K_CHUNK_COUNT = "chunkCount"; + public static final String K_NODE_ID = "nodeId"; + public static final String K_VERIFIER = "verifier"; + public static final String K_SUCCESS = "success"; // Vote/VoteOnline extras public static final String K_WAS_ONLINE = "wasOnline"; @@ -172,6 +177,18 @@ public static JsonEnvelope serverName(String server) { return base(SUB_SERVER_NAME).put(K_SERVER, safe(server)).build(); } + public static JsonEnvelope controlEnrollmentRequest(String nodeId, String verifier, UUID requestId) { + return base(SUB_CONTROL_ENROLLMENT_REQUEST).put(K_NODE_ID, safe(nodeId)) + .put(K_VERIFIER, safe(verifier)) + .put(K_REQUEST_ID, requestId == null ? "" : requestId.toString()).build(); + } + + public static JsonEnvelope controlEnrollmentResult(String nodeId, UUID requestId, boolean success) { + return base(SUB_CONTROL_ENROLLMENT_RESULT).put(K_NODE_ID, safe(nodeId)) + .put(K_REQUEST_ID, requestId == null ? "" : requestId.toString()) + .put(K_SUCCESS, success).build(); + } + public static JsonEnvelope login(String player, String uuid, String server) { return login(player, uuid, server, null); } @@ -461,6 +478,47 @@ public static VoteUpdate readVoteUpdate(JsonEnvelope env) { return new VoteUpdate(uuid, cur, req, service, t); } + public static final class ControlEnrollmentRequest { + public final String nodeId; + public final String verifier; + public final UUID requestId; + public final boolean valid; + + private ControlEnrollmentRequest(String nodeId, String verifier, UUID requestId) { + this.nodeId = nodeId; + this.verifier = verifier; + this.requestId = requestId; + this.valid = nodeId.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}") + && verifier.matches("[0-9a-f]{64}") && requestId != null; + } + } + + public static ControlEnrollmentRequest readControlEnrollmentRequest(JsonEnvelope env) { + Map fields = env.getFields(); + return new ControlEnrollmentRequest(safe(fields.get(K_NODE_ID)), safe(fields.get(K_VERIFIER)), + readUuid(fields, K_REQUEST_ID)); + } + + public static final class ControlEnrollmentResult { + public final String nodeId; + public final UUID requestId; + public final boolean success; + public final boolean valid; + + private ControlEnrollmentResult(String nodeId, UUID requestId, boolean success) { + this.nodeId = nodeId; + this.requestId = requestId; + this.success = success; + this.valid = nodeId.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}") && requestId != null; + } + } + + public static ControlEnrollmentResult readControlEnrollmentResult(JsonEnvelope env) { + Map fields = env.getFields(); + return new ControlEnrollmentResult(safe(fields.get(K_NODE_ID)), readUuid(fields, K_REQUEST_ID), + readBool(fields, K_SUCCESS, false)); + } + public static final class PresencePlayer { public final String player; public final String uuid; From a400d38fbdc156fd7edc5906e2b9953c543d5923 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:22:49 -0600 Subject: [PATCH 11/39] Automate hosted Control credential enrollment --- .../proxy/bungee/VotingPluginBungee.java | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungee.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungee.java index f567245a95..bd644c4743 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungee.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungee.java @@ -89,7 +89,7 @@ public class VotingPluginBungee extends Plugin implements Listener { /** * Plugin messages received during reload are queued and replayed after reload. */ - private final Queue queuedPluginMessages = new ConcurrentLinkedQueue(); + private final Queue queuedPluginMessages = new ConcurrentLinkedQueue<>(); /** * Logs debug output if enabled in config. @@ -279,16 +279,17 @@ public void onPluginMessage(PluginMessageEvent ev) { debug("Ignore plugin message (not from server)"); return; } + String sourceServer = ((Server) ev.getSender()).getInfo().getName(); // During reload, queue and replay later to avoid calling into disposed runtime if (reloading) { byte[] copy = new byte[ev.getData().length]; System.arraycopy(ev.getData(), 0, copy, 0, ev.getData().length); - queuedPluginMessages.add(copy); + queuedPluginMessages.add(new QueuedPluginMessage(sourceServer, copy)); return; } - handlePluginMessageBytes(ev.getData()); + handlePluginMessageBytes(sourceServer, ev.getData()); } /** @@ -896,11 +897,11 @@ private void runAsyncNow(Runnable runnable) { * * @param data payload bytes */ - private void handlePluginMessageBytes(byte[] data) { + private void handlePluginMessageBytes(String sourceServer, byte[] data) { ByteArrayInputStream instream = new ByteArrayInputStream(data); DataInputStream in = new DataInputStream(instream); try { - getVotingPluginProxy().onPluginMessageReceived(in); + getVotingPluginProxy().onPluginMessageReceived(in, sourceServer); } catch (Exception e) { e.printStackTrace(); } @@ -910,13 +911,15 @@ private void handlePluginMessageBytes(byte[] data) { * Replays queued plugin messages after reload completes. */ private void drainQueuedPluginMessages() { - byte[] msg; + QueuedPluginMessage msg; while ((msg = queuedPluginMessages.poll()) != null) { try { - handlePluginMessageBytes(msg); + handlePluginMessageBytes(msg.sourceServer(), msg.data()); } catch (Exception e) { e.printStackTrace(); } } } + + private record QueuedPluginMessage(String sourceServer, byte[] data) { } } From f1aa392961d0b8a0363cee0eeac063bbfcce1d34 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:22:51 -0600 Subject: [PATCH 12/39] Automate hosted Control credential enrollment --- .../proxy/control/HostedControlManager.java | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/HostedControlManager.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/HostedControlManager.java index 7b55db444d..a02100965e 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/HostedControlManager.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/HostedControlManager.java @@ -547,6 +547,7 @@ private void clearPersistedQuarantineState() throws IOException { private void installLocalAutoEnrollment() throws IOException, InterruptedException { PendingAutoEnrollment enrollment = localAutoEnrollment; if (enrollment == null) return; + requireTrustedInstalledArtifact(); verifierInstaller.install(settings, settings.jarFile(), enrollment.nodeId(), enrollment.verifier(), Duration.ofSeconds(Math.min(30, settings.startupTimeoutSeconds()))); ControlCredentialFile.completeAutoEnrollment(enrollment); @@ -574,6 +575,7 @@ public CompletableFuture installNodeVerifier(String nodeId, String veri result.complete(false); return; } + requireTrustedInstalledArtifact(); verifierInstaller.install(settings, settings.jarFile(), nodeId, verifier, Duration.ofSeconds(Math.min(30, settings.startupTimeoutSeconds()))); result.complete(true); @@ -592,6 +594,16 @@ public CompletableFuture installNodeVerifier(String nodeId, String veri }); } + private void requireTrustedInstalledArtifact() throws IOException { + if (!Files.isRegularFile(settings.jarFile(), LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Control artifact is unavailable"); + } + String installed = sha256(settings.jarFile()); + if (!settings.sha256().equals(installed) && !installed.equals(trustedRollbackSha256)) { + throw new IOException("Control artifact no longer matches a trusted digest"); + } + } + private LaunchedProcess launch(Path artifact) throws IOException { status = Status.STARTING_PROCESS; synchronized (processLifecycle) { @@ -736,6 +748,8 @@ private void close(boolean waitForProcess) { } finally { discardPreparedArtifact(); executor.shutdownNow(); + enrollmentTasks.values().forEach(result -> result.complete(false)); + enrollmentTasks.clear(); } if (waitForProcess) { long waitSeconds = (long) settings.downloadTimeoutSeconds() + settings.startupTimeoutSeconds() + 5L; @@ -837,7 +851,7 @@ static Path resolveInside(Path root, String configured, String name) { return resolved; } - /** True only for direct loopback HTTP to this hosted listener. */ + /** True only for direct HTTP to this hosted listener on the same node. */ public static boolean isDirectLocalEndpoint(String configuredEndpoint, HostConfiguration hosted) { if (hosted == null || !hosted.enabled() || configuredEndpoint == null) return false; try { @@ -847,7 +861,15 @@ public static boolean isDirectLocalEndpoint(String configuredEndpoint, HostConfi String path = endpoint.getPath(); boolean loopback = "localhost".equalsIgnoreCase(host) || "127.0.0.1".equals(host) || "::1".equals(host) || "0:0:0:0:0:0:0:1".equals(host); - return "http".equalsIgnoreCase(endpoint.getScheme()) && loopback && port == hosted.port() + String listenerHost = hosted.host() == null ? "" : hosted.host().trim(); + boolean configuredListener = host != null && host.equalsIgnoreCase(listenerHost); + boolean listenerLoopback = "localhost".equalsIgnoreCase(listenerHost) || "127.0.0.1".equals(listenerHost) + || "::1".equals(listenerHost) || "0:0:0:0:0:0:0:1".equals(listenerHost); + boolean listenerWildcard = "0.0.0.0".equals(listenerHost) || "::".equals(listenerHost) + || "0:0:0:0:0:0:0:0".equals(listenerHost); + return "http".equalsIgnoreCase(endpoint.getScheme()) + && (configuredListener || (loopback && (listenerLoopback || listenerWildcard))) + && port == hosted.port() && endpoint.getUserInfo() == null && endpoint.getQuery() == null && endpoint.getFragment() == null && (path == null || path.isEmpty() || "/".equals(path)); } catch (IllegalArgumentException e) { From caeb20c2cc649654b4f53b1947f184d185e42efe Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:22:52 -0600 Subject: [PATCH 13/39] Automate hosted Control credential enrollment --- .../proxy/velocity/VotingPluginVelocity.java | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java index 09d33ed750..176c045c65 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java @@ -124,7 +124,7 @@ public class VotingPluginVelocity { /** * Plugin messages received during reload are queued. */ - private final Queue queuedPluginMessages = new ConcurrentLinkedQueue(); + private final Queue queuedPluginMessages = new ConcurrentLinkedQueue<>(); @Inject public VotingPluginVelocity(ProxyServer server, Logger logger, Metrics.Factory metricsFactory, @@ -265,15 +265,16 @@ public void onPluginMessagingReceived(PluginMessageEvent event) { if (!(event.getSource() instanceof ServerConnection)) { return; } + String sourceServer = ((ServerConnection) event.getSource()).getServerInfo().getName(); if (reloading) { byte[] copy = new byte[event.getData().length]; System.arraycopy(event.getData(), 0, copy, 0, event.getData().length); - queuedPluginMessages.add(copy); + queuedPluginMessages.add(new QueuedPluginMessage(sourceServer, copy)); return; } - handlePluginMessageBytes(event.getData()); + handlePluginMessageBytes(sourceServer, event.getData()); } @Subscribe @@ -986,23 +987,25 @@ private void runAsyncNow(Runnable runnable) { server.getScheduler().buildTask(this, runnable).schedule(); } - private void handlePluginMessageBytes(byte[] data) { + private void handlePluginMessageBytes(String sourceServer, byte[] data) { ByteArrayInputStream instream = new ByteArrayInputStream(data); DataInputStream in = new DataInputStream(instream); try { - getVotingPluginProxy().onPluginMessageReceived(in); + getVotingPluginProxy().onPluginMessageReceived(in, sourceServer); } catch (Exception e) { e.printStackTrace(); } } private void drainQueuedPluginMessages() { - byte[] msg; + QueuedPluginMessage msg; while ((msg = queuedPluginMessages.poll()) != null) { - handlePluginMessageBytes(msg); + handlePluginMessageBytes(msg.sourceServer(), msg.data()); } } + private record QueuedPluginMessage(String sourceServer, byte[] data) { } + /** * Convert old YAML cache files to JSON. */ From 3c900e656472e7cd2e5db77c33e537913d8b8056 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:22:54 -0600 Subject: [PATCH 14/39] Automate hosted Control credential enrollment --- .../util/ControlCredentialFile.java | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ControlCredentialFile.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ControlCredentialFile.java index 12d96db9b5..0e4015194d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ControlCredentialFile.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ControlCredentialFile.java @@ -107,17 +107,31 @@ private static String readCredential(Path target) throws IOException { private static PendingAutoEnrollment readMarker(Path marker, Path credentialFile, String configuredPath) throws IOException { if (!Files.exists(marker, LinkOption.NOFOLLOW_LINKS)) return null; - if (!Files.isRegularFile(marker, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(marker) - || Files.size(marker) > MAX_MARKER_BYTES) throw invalid(); - String[] lines = Files.readString(marker, StandardCharsets.US_ASCII).split("\\R", -1); - if (lines.length < 3 || !"1".equals(lines[0]) || !NODE_ID.matcher(lines[1]).matches() + if (!Files.isRegularFile(marker, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(marker)) throw invalid(); + ByteBuffer bytes = ByteBuffer.allocate(MAX_MARKER_BYTES + 1); + try (SeekableByteChannel channel = Files.newByteChannel(marker, StandardOpenOption.READ, + LinkOption.NOFOLLOW_LINKS)) { + while (bytes.hasRemaining() && channel.read(bytes) > 0) { } + } + if (bytes.position() > MAX_MARKER_BYTES) throw invalid(); + bytes.flip(); + String contents; + try { + contents = StandardCharsets.US_ASCII.newDecoder().onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT).decode(bytes).toString(); + } catch (java.nio.charset.CharacterCodingException e) { + throw invalid(); + } + String[] lines = contents.split("\\n", -1); + if (lines.length != 3 || contents.indexOf('\r') >= 0 || !"1".equals(lines[0]) + || !NODE_ID.matcher(lines[1]).matches() || !VERIFIER.matcher(lines[2]).matches()) throw invalid(); restrictPermissions(marker); return new PendingAutoEnrollment(credentialFile, configuredPath, lines[1], lines[2]); } private static String markerContents(PendingAutoEnrollment enrollment) { - return "1\n" + enrollment.nodeId() + "\n" + enrollment.verifier() + "\n"; + return "1\n" + enrollment.nodeId() + "\n" + enrollment.verifier(); } private static Path marker(Path credentialFile) { From 3bbdf3ad33eec183b2f0d8974cd2103187022462 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:22:55 -0600 Subject: [PATCH 15/39] Automate hosted Control credential enrollment --- VotingPlugin/src/main/resources/Config.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/VotingPlugin/src/main/resources/Config.yml b/VotingPlugin/src/main/resources/Config.yml index f5f3e29be4..19f28cf616 100644 --- a/VotingPlugin/src/main/resources/Config.yml +++ b/VotingPlugin/src/main/resources/Config.yml @@ -1160,8 +1160,8 @@ Webhooks: ########################################### # VotingPlugin Control backend connector # Optional outbound connection for full local WebUI configuration control. -# Create a node credential with Control's `enroll` owner command and place it -# in the VotingPlugin data folder. The credential is never stored in this YAML. +# A locally hosted Control, or a proxy-hosted Control reached with PLUGINMESSAGING, +# generates and enrolls credentials automatically. The credential never enters this YAML. ########################################### Control: @@ -1185,10 +1185,12 @@ Control: DownloadTimeoutSeconds: 60 Backend: Enabled: false - # Must be unique for every Bukkit/Paper backend in the network. + # Blank reuses BungeeSettings.Server, which must be unique for every backend. NodeId: '' Endpoint: 'http://127.0.0.1:8080' - # VotingPlugin creates this blank file automatically; paste only the enrollment credential into it. + # VotingPlugin automatically generates this local credential when it can enroll through + # the hosted Control on this server or through the authenticated proxy plugin-message path. + # Unsupported/external topologies retain the blank file for manual enrollment. CredentialFile: 'control/control-credential.txt' HeartbeatSeconds: 30 ConnectTimeoutMillis: 3000 From e7da7fe7c7ba0eb0b1d50cec2c963ddf2cdbbecb Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:22:57 -0600 Subject: [PATCH 16/39] Automate hosted Control credential enrollment --- VotingPlugin/src/main/resources/bungeeconfig.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/VotingPlugin/src/main/resources/bungeeconfig.yml b/VotingPlugin/src/main/resources/bungeeconfig.yml index 8945a8e1db..0a053a97fe 100644 --- a/VotingPlugin/src/main/resources/bungeeconfig.yml +++ b/VotingPlugin/src/main/resources/bungeeconfig.yml @@ -484,10 +484,11 @@ MultiProxyServers: Control: Enabled: false Endpoint: 'http://127.0.0.1:8080' - # Blank reuses ProxyServerName. This must match the identity enrolled in Control. + # Blank reuses ProxyServerName. NodeId: '' - # Relative to the VotingPlugin proxy data folder. VotingPlugin creates it blank automatically. - # Paste only the enrollment credential into this file. + # Relative to the VotingPlugin proxy data folder. When this connector points to its own + # hosted Control listener, VotingPlugin generates and enrolls this credential automatically. + # External Control installations still create a blank file for manual enrollment. CredentialFile: 'control/control-credential.txt' HeartbeatSeconds: 30 ConnectTimeoutMillis: 3000 From 09179b6ab733053dc2cd25d5b6493b06a8e51653 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:22:58 -0600 Subject: [PATCH 17/39] Automate hosted Control credential enrollment --- .../control/HostedControlManagerTest.java | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/HostedControlManagerTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/HostedControlManagerTest.java index 3bf150f3e2..e73cf7962d 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/HostedControlManagerTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/HostedControlManagerTest.java @@ -28,6 +28,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import com.bencodez.votingplugin.util.ControlCredentialFile; import com.sun.net.httpserver.HttpServer; class HostedControlManagerTest { @@ -61,6 +62,20 @@ void platformNeutralFactoryKeepsHostingOptIn() { () -> HostedControlManager.create(directory, invalidHost, message -> { })); } + @Test + void localEndpointMatchesLoopbackOrTheConfiguredListenerAddress() { + HostedControlManager.HostConfiguration hosted = new HostedControlManager.HostConfiguration(true, false, + false, "", "0".repeat(64), "control/control.jar", "control/data", "10.0.0.5", 2150, 30, 60); + + assertFalse(HostedControlManager.isDirectLocalEndpoint("http://127.0.0.1:2150", hosted)); + assertTrue(HostedControlManager.isDirectLocalEndpoint("http://10.0.0.5:2150", hosted)); + assertFalse(HostedControlManager.isDirectLocalEndpoint("http://10.0.0.6:2150", hosted)); + assertFalse(HostedControlManager.isDirectLocalEndpoint("https://10.0.0.5:2150", hosted)); + HostedControlManager.HostConfiguration wildcard = new HostedControlManager.HostConfiguration(true, false, + false, "", "0".repeat(64), "control/control.jar", "control/data", "0.0.0.0", 2150, 30, 60); + assertTrue(HostedControlManager.isDirectLocalEndpoint("http://127.0.0.1:2150", wildcard)); + } + @Test void hostedArtifactBodyReadCannotOutliveDownloadTimeout() throws Exception { CountDownLatch bodyStarted = new CountDownLatch(1); @@ -179,6 +194,60 @@ void missingPinnedArtifactIsStagedVerifiedAndStarted() throws Exception { assertTrue(launcher.processes.get(0).destroyed); } + @Test + void localEnrollmentInstallsVerifierBeforeTheHostedProcessStarts() throws Exception { + byte[] release = "release-with-enrollment-command".getBytes(StandardCharsets.UTF_8); + Path root = directory.toAbsolutePath().normalize(); + Path jar = root.resolve("control/control.jar"); + Files.createDirectories(jar.getParent()); + Files.write(jar, release); + HostedControlManager.Settings settings = settings(jar, root.resolve("control/data"), false, false, + digest(release), 30); + ControlCredentialFile.PendingAutoEnrollment pending = ControlCredentialFile.prepareAutoEnrollment(root, + "control/control-credential.txt", "proxy-a"); + AtomicInteger installations = new AtomicInteger(); + FakeLauncher launcher = new FakeLauncher(); + ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + HostedControlManager manager = new HostedControlManager(settings, executor, + (source, target, maximum, timeout) -> { throw new AssertionError("download was not expected"); }, + launcher, (configured, artifact, nodeId, verifier, timeout) -> { + assertTrue(launcher.launchedContents.isEmpty()); + assertEquals("proxy-a", nodeId); + assertEquals(pending.verifier(), verifier); + installations.incrementAndGet(); + }, (endpoint, timeout, launchId) -> true, millis -> { }, System::nanoTime, pending, message -> { }); + + manager.runOnce(); + + assertEquals(HostedControlManager.Status.RUNNING, manager.status()); + assertEquals(1, installations.get()); + assertFalse(Files.exists(root.resolve("control/control-credential.txt.auto-enroll"))); + manager.close(); + } + + @Test + void runningHostAcceptsVerifierOnlyBackendEnrollment() throws Exception { + byte[] release = "release-with-enrollment-command".getBytes(StandardCharsets.UTF_8); + Path root = directory.toAbsolutePath().normalize(); + Path jar = root.resolve("control/control.jar"); + Files.createDirectories(jar.getParent()); + Files.write(jar, release); + HostedControlManager.Settings settings = settings(jar, root.resolve("control/data"), false, false, + digest(release), 30); + AtomicInteger installations = new AtomicInteger(); + ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + HostedControlManager manager = new HostedControlManager(settings, executor, + (source, target, maximum, timeout) -> { throw new AssertionError("download was not expected"); }, + new FakeLauncher(), (configured, artifact, nodeId, verifier, timeout) -> installations.incrementAndGet(), + (endpoint, timeout, launchId) -> true, millis -> { }, System::nanoTime, null, message -> { }); + manager.runOnce(); + + assertTrue(manager.installNodeVerifier("survival", "a".repeat(64)).get(5, TimeUnit.SECONDS)); + assertEquals(1, installations.get()); + assertFalse(manager.installNodeVerifier("../invalid", "a".repeat(64)).get(5, TimeUnit.SECONDS)); + manager.close(); + } + @Test void failedUpdateRollsBackAndStartsPreviousRelease() throws Exception { byte[] previous = "previous-release".getBytes(StandardCharsets.UTF_8); From 26fc3a618d6694df7ce45e7ef9ca2cdb1d1d0386 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:22:59 -0600 Subject: [PATCH 18/39] Automate hosted Control credential enrollment --- .../tests/VotingPluginWireTest.java | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java index 4ca9342110..260bde19de 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java @@ -178,4 +178,33 @@ public void chunkedPresenceSnapshotPreservesChunkMetadata() { assertEquals(1000L, snapshot.backendStartedAt); assertEquals(1300L, snapshot.presenceTimestamp); } + + @Test + public void controlEnrollmentRoundTripContainsOnlyVerifierAndIdentity() { + UUID requestId = UUID.randomUUID(); + String verifier = "a".repeat(64); + + VotingPluginWire.ControlEnrollmentRequest request = VotingPluginWire.readControlEnrollmentRequest( + VotingPluginWire.controlEnrollmentRequest("survival", verifier, requestId)); + VotingPluginWire.ControlEnrollmentResult result = VotingPluginWire.readControlEnrollmentResult( + VotingPluginWire.controlEnrollmentResult("survival", requestId, true)); + + assertTrue(request.valid); + assertEquals("survival", request.nodeId); + assertEquals(verifier, request.verifier); + assertEquals(requestId, request.requestId); + assertTrue(result.valid); + assertTrue(result.success); + assertEquals(requestId, result.requestId); + } + + @Test + public void malformedControlEnrollmentIsRejected() { + JsonEnvelope malformed = JsonEnvelope.builder(VotingPluginWire.SUB_CONTROL_ENROLLMENT_REQUEST) + .schema(VotingPluginWire.SCHEMA_VERSION).put(VotingPluginWire.K_NODE_ID, "../proxy") + .put(VotingPluginWire.K_VERIFIER, "not-a-verifier") + .put(VotingPluginWire.K_REQUEST_ID, "not-a-uuid").build(); + + assertFalse(VotingPluginWire.readControlEnrollmentRequest(malformed).valid); + } } From 122d148b091d795d50de660ccf7c663531f6bd58 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:23:01 -0600 Subject: [PATCH 19/39] Automate hosted Control credential enrollment --- .../util/ControlCredentialFileTest.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/ControlCredentialFileTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/ControlCredentialFileTest.java index 1b9151c1fc..3c030728ca 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/ControlCredentialFileTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/ControlCredentialFileTest.java @@ -65,6 +65,21 @@ class ControlCredentialFileTest { assertFalse(Files.exists(control.resolve("control-credential.txt.auto-enroll"))); } + @Test void enrollmentMarkerContainsOnlyBoundedNonSecretState() throws Exception { + Path root = Files.createDirectory(directory.resolve("marker-plugin")); + String configured = "control/control-credential.txt"; + ControlCredentialFile.prepareAutoEnrollment(root, configured, "survival"); + Path credential = root.resolve(configured); + Path marker = root.resolve(configured + ".auto-enroll"); + + String secret = Files.readString(credential); + String state = Files.readString(marker); + assertFalse(state.contains(secret)); + Files.writeString(marker, state + "\nunexpected"); + assertThrows(java.io.IOException.class, + () -> ControlCredentialFile.prepareAutoEnrollment(root, configured, "survival")); + } + @Test void readsContainedFileAndRejectsFileDirectoryAndTraversalEscapes() throws Exception { Path root = Files.createDirectory(directory.resolve("plugin")); Path outside = Files.createDirectory(directory.resolve("outside")); From 5d1c378062a0ccc369cf9fec982f68909d3e37b2 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:23:02 -0600 Subject: [PATCH 20/39] Automate hosted Control credential enrollment --- docs/control-connector.md | 48 +++++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/docs/control-connector.md b/docs/control-connector.md index 7c2a68d56f..6e54e0ac13 100644 --- a/docs/control-connector.md +++ b/docs/control-connector.md @@ -39,13 +39,13 @@ outside HTTPS, and paths outside VotingPlugin's data folder are rejected. Control: Enabled: true Endpoint: 'http://127.0.0.1:8080' - NodeId: 'proxy-a' + NodeId: '' CredentialFile: 'control/control-credential.txt' Hosted: Enabled: true AutoDownload: true AutoUpdate: false - DownloadUrl: 'https://github.com/BenCodez/VotingPlugin-Control/releases/download/v0.1.0/votingplugin-control.jar' + DownloadUrl: 'https://github.com/BenCodez/VotingPlugin-Control/releases/download/vX.Y.Z/votingplugin-control.jar' Sha256: '<64-character SHA-256 from the pinned release>' JarFile: 'control/votingplugin-control.jar' DataDirectory: 'control/data' @@ -64,7 +64,7 @@ Control: Enabled: true AutoDownload: true AutoUpdate: false - DownloadUrl: 'https://github.com/BenCodez/VotingPlugin-Control/releases/download/v0.1.0/votingplugin-control.jar' + DownloadUrl: 'https://github.com/BenCodez/VotingPlugin-Control/releases/download/vX.Y.Z/votingplugin-control.jar' Sha256: '<64-character SHA-256 from the pinned release>' JarFile: 'control/votingplugin-control.jar' DataDirectory: 'control/data' @@ -74,7 +74,7 @@ Control: DownloadTimeoutSeconds: 60 Backend: Enabled: true - NodeId: 'backend-lobby' + NodeId: '' Endpoint: 'http://127.0.0.1:8080' CredentialFile: 'control/control-credential.txt' HeartbeatSeconds: 30 @@ -106,19 +106,18 @@ Manual installation remains supported: put a Control JAR at `JarFile`, configure ## Enrollment and configuration -1. Start Control, complete its one-time WebUI setup, and use the Node enrollment page to enroll the exact stable node ID. +The easiest network setup hosts one Control instance on the proxy. Configure `bungeeconfig.yml` as below, using a Control +release that supports the `enroll-verifier` owner command. On first start VotingPlugin creates a random proxy credential +under `plugins/VotingPlugin/control/`, installs only its SHA-256 verifier into the hosted Control data directory, and starts +the connector. No enrollment command or credential copy is required. -2. On the first connector start, VotingPlugin automatically creates the blank - `plugins/VotingPlugin/control/control-credential.txt` placeholder (or its platform-equivalent data path). Paste only - the generated enrollment credential into that file and restrict filesystem access to the Minecraft service account. - -3. Configure `bungeeconfig.yml`: +Configure `bungeeconfig.yml`: ```yaml Control: Enabled: true Endpoint: 'http://127.0.0.1:8080' - NodeId: 'proxy-a' + NodeId: '' CredentialFile: 'control/control-credential.txt' HeartbeatSeconds: 30 ConnectTimeoutMillis: 3000 @@ -126,10 +125,10 @@ Manual installation remains supported: put a Control JAR at `JarFile`, configure ``` `NodeId` may be blank to reuse the existing `ProxyServerName`, but every simultaneously connected proxy must have a unique, -stable enrolled identity. Explicitly setting it is recommended. `CredentialFile` must resolve inside VotingPlugin's data -directory. Its missing parent directories and blank placeholder are created automatically; credentials in URLs and -parent-directory traversal are rejected. The credential is never logged or placed in -plugin messages. +stable identity. Automatic enrollment is enabled only when the connector endpoint addresses that same hosted listener by +loopback or by its configured bind address and port. `CredentialFile` must resolve inside VotingPlugin's data directory. +Its missing parent directories are created automatically; credentials in URLs and parent-directory traversal are rejected. +The generated credential is never logged or passed to the child process. The endpoint must be an `http` or `https` origin without embedded credentials, query, fragment, or path. Plain HTTP is suitable only for loopback or a trusted private network. Use HTTPS or a private authenticated tunnel when crossing an @@ -146,21 +145,32 @@ Do not raise these by patching around validation; correct the topology or connec ### Bukkit full-configuration enrollment -Each backend is separately opt-in and separately enrolled so one node credential cannot impersonate another. Put its -credential in that backend's VotingPlugin data folder and configure `Config.yml`: +Each backend is separately opt-in and receives its own credential so one node cannot impersonate another. With +`BungeeMethod: PLUGINMESSAGING`, configure each backend's `Config.yml` and point `Endpoint` at the proxy-hosted listener: ```yaml Control: Backend: Enabled: true - NodeId: 'backend-lobby' - Endpoint: 'http://127.0.0.1:8080' + NodeId: '' + Endpoint: 'http://:8080' CredentialFile: 'control/control-credential.txt' HeartbeatSeconds: 30 ConnectTimeoutMillis: 3000 RequestTimeoutMillis: 10000 ``` +Blank `NodeId` reuses `BungeeSettings.Server`. The backend generates the raw credential locally and sends only its SHA-256 +verifier over the plugin-message channel. Velocity/BungeeCord binds the request to the actual backend server connection, +requires that identity to equal `BungeeSettings.Server`, installs the verifier in its hosted Control, and returns a +non-secret acknowledgement. The raw credential never leaves the backend. Requests retry safely after restarts and while no +player is available to carry plugin messages. + +If a Bukkit server hosts its own Control and its endpoint addresses that local listener, the same generation and verifier +installation happen locally. External Control installations, custom backend node IDs that differ from +`BungeeSettings.Server`, and non-plugin-message transports retain manual WebUI/owner-command enrollment; an existing +nonblank credential file is always treated as manually managed and is never replaced. + The Bukkit connector owns one daemon worker and performs no Control I/O on the server thread. It reports a bounded list of installed plugin names for WebUI command suggestions and negotiates `config.files.v1` and `config.quick-setup.v1`, then polls the same outbound operation queue as proxies. File apply schedules From ecddc3ede9e014cca6fbc711695f41311ce27603 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:25:58 -0600 Subject: [PATCH 21/39] Use the backend proxy enable accessor --- .../votingplugin/control/BackendControlAutoEnrollment.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java index 32a97a680a..4a0c04bf91 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java @@ -52,7 +52,7 @@ public static BackendControlAutoEnrollment create(VotingPluginMain plugin, HostC ConfigurationSection control = control(plugin); if (control == null || !control.getBoolean("Enabled", false) || HostedControlManager.isDirectLocalEndpoint(control.getString("Endpoint", ""), hosted) - || !plugin.getBungeeSettings().isUseBungeecord() + || !plugin.getBungeeSettings().isUseBungeecoord() || BungeeMethod.getByName(plugin.getBungeeSettings().getBungeeMethod()) != BungeeMethod.PLUGINMESSAGING) { return null; } From 2f4bce16b6cb780ddacfd001b5cddd36d9555a68 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:27:21 -0600 Subject: [PATCH 22/39] Document command-free WebUI setup --- docs/control-connector.md | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/docs/control-connector.md b/docs/control-connector.md index 6e54e0ac13..ef31265f22 100644 --- a/docs/control-connector.md +++ b/docs/control-connector.md @@ -90,16 +90,13 @@ previous release, retaining the failed candidate as `.failed`. Unexpected exits The child receives only its bind host, port, contained data directory, and a random per-launch ID. Health must echo that ID, preventing an unrelated Control process on the same port from satisfying startup checks. It does not receive -VotingPlugin configuration or credentials in release metadata. Process output is written beside the hosted JAR and rotated at 1 MiB. Configure the -WebUI password directly against the hosted data directory (the prompt is not echoed and the password is not an argument): +VotingPlugin configuration or credentials in release metadata. Process output is written beside the hosted JAR and +rotated at 1 MiB. -```shell -java -jar plugins/VotingPlugin/control/votingplugin-control.jar web-password plugins/VotingPlugin/control/data -``` - -The WebUI is then available at `http://127.0.0.1:8080/`. Password rotation invalidates every existing browser session. -To expose a non-loopback listener, create either the WebUI password or an admin API token in -`Control.Hosted.DataDirectory` first and use HTTPS or a private tunnel; Control otherwise refuses the bind. +On first start, Control creates an owner-readable `web-setup-code.txt` inside `Control.Hosted.DataDirectory`. Open the +WebUI, copy that one-time value using the server file manager, and choose the WebUI password in the browser. The code is +consumed immediately, and no server command is required. Password rotation invalidates every existing browser session. +Use HTTPS or a private authenticated tunnel when exposing the listener outside a trusted private network. Manual installation remains supported: put a Control JAR at `JarFile`, configure its exact SHA-256, set `AutoDownload` and `AutoUpdate` false, and enable hosting. VotingPlugin will verify and supervise it without network access. From 9790a0a05efd368bcce5161200b8527511147a57 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:31:29 -0600 Subject: [PATCH 23/39] Close automatic enrollment lifecycle races --- .../votingplugin/control/BackendControlAutoEnrollment.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java index 4a0c04bf91..b0a0cd9267 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java @@ -85,7 +85,7 @@ private static String nodeId(VotingPluginMain plugin, ConfigurationSection contr return configured == null || configured.isBlank() ? plugin.getOptions().getServer() : configured.trim(); } - public void start() { + public synchronized void start() { if (closed.get() || retryTask != null) return; try { retryTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::send, 1L, RETRY_TICKS); @@ -118,7 +118,7 @@ public void handle(JsonEnvelope envelope) { } @Override - public void close() { + public synchronized void close() { if (!closed.compareAndSet(false, true)) return; BukkitTask task = retryTask; if (task != null) task.cancel(); From 4e0e4e58b6fe29ccd1b5f1478c3bd9ff9c3c9e2d Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:31:30 -0600 Subject: [PATCH 24/39] Close automatic enrollment lifecycle races --- .../main/java/com/bencodez/votingplugin/VotingPluginMain.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 4a8660172f..e2e7209145 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -1049,7 +1049,7 @@ private void startBackendControlConnector() { } } - private void refreshBackendControlAutoEnrollment() throws IOException { + private synchronized void refreshBackendControlAutoEnrollment() throws IOException { BackendControlAutoEnrollment previous = backendControlAutoEnrollment; backendControlAutoEnrollment = null; if (previous != null) previous.close(); From 4fa58cb303ff7f0a5ff5ec5887b4a6d2c9c14170 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:31:32 -0600 Subject: [PATCH 25/39] Close automatic enrollment lifecycle races --- .../com/bencodez/votingplugin/util/ControlCredentialFile.java | 1 + 1 file changed, 1 insertion(+) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ControlCredentialFile.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ControlCredentialFile.java index 0e4015194d..4c8de30cfa 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ControlCredentialFile.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ControlCredentialFile.java @@ -74,6 +74,7 @@ public static synchronized void completeAutoEnrollment(PendingAutoEnrollment enr Path marker = marker(target); PendingAutoEnrollment current = readMarker(marker, target, enrollment.configuredPath()); String credential = readCredential(target); + if (current == null && credential != null && enrollment.verifier().equals(verifier(credential))) return; if (current == null || credential == null || !current.nodeId().equals(enrollment.nodeId()) || !current.verifier().equals(enrollment.verifier()) || !current.verifier().equals(verifier(credential))) { From 58a4263bb7596f9f2502ec35e289166ec44c3980 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:31:34 -0600 Subject: [PATCH 26/39] Close automatic enrollment lifecycle races --- .../bencodez/votingplugin/util/ControlCredentialFileTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/ControlCredentialFileTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/ControlCredentialFileTest.java index 3c030728ca..f44346a726 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/ControlCredentialFileTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/ControlCredentialFileTest.java @@ -49,6 +49,7 @@ class ControlCredentialFileTest { assertTrue(Files.isRegularFile(root.resolve(configured + ".auto-enroll"))); ControlCredentialFile.completeAutoEnrollment(recovered); assertFalse(Files.exists(root.resolve(configured + ".auto-enroll"))); + ControlCredentialFile.completeAutoEnrollment(recovered); assertNull(ControlCredentialFile.prepareAutoEnrollment(root, configured, "proxy-a")); assertEquals(credential, ControlCredentialFile.read(root, configured)); } From 3f1076710843336b412510a78f6d198a33f0a4dd Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:33:31 -0600 Subject: [PATCH 27/39] Confirm backend Control authentication before enrollment --- .../control/BackendControlAutoEnrollment.java | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java index b0a0cd9267..76219285e5 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java @@ -2,6 +2,7 @@ import java.io.IOException; import java.util.UUID; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import org.bukkit.configuration.ConfigurationSection; @@ -23,12 +24,16 @@ */ public final class BackendControlAutoEnrollment implements AutoCloseable { private static final long RETRY_TICKS = 20L * 15L; + private static final long VERIFIER_REFRESH_NANOS = TimeUnit.SECONDS.toNanos(60); private final VotingPluginMain plugin; private final PendingAutoEnrollment enrollment; private final UUID requestId = UUID.randomUUID(); private final AtomicBoolean closed = new AtomicBoolean(); private volatile BukkitTask retryTask; + private boolean verifierInstalled; + private boolean connectorAuthenticated; + private long verifierAcknowledgedAt; private BackendControlAutoEnrollment(VotingPluginMain plugin, PendingAutoEnrollment enrollment) { this.plugin = plugin; @@ -96,7 +101,10 @@ public synchronized void start() { } private void send() { - if (closed.get()) return; + synchronized (this) { + if (closed.get() || (verifierInstalled + && System.nanoTime() - verifierAcknowledgedAt < VERIFIER_REFRESH_NANOS)) return; + } BackendProxyHandler handler = plugin.getBackendProxyHandler(); if (handler == null || handler.getMethod() != BungeeMethod.PLUGINMESSAGING || handler.getGlobalMessageHandler() == null) return; @@ -104,15 +112,30 @@ private void send() { enrollment.nodeId(), enrollment.verifier(), requestId)); } - public void handle(JsonEnvelope envelope) { + public synchronized void handle(JsonEnvelope envelope) { VotingPluginWire.ControlEnrollmentResult result = VotingPluginWire.readControlEnrollmentResult(envelope); if (closed.get() || !result.valid || !result.success || !requestId.equals(result.requestId) || !enrollment.nodeId().equals(result.nodeId)) return; + verifierInstalled = true; + verifierAcknowledgedAt = System.nanoTime(); + completeIfConnected(); + } + + /** Completes durable enrollment only after this credential authenticated to the configured endpoint. */ + public synchronized void connectorAuthenticated() { + if (closed.get()) return; + connectorAuthenticated = true; + completeIfConnected(); + } + + private void completeIfConnected() { + if (!verifierInstalled || !connectorAuthenticated) return; try { ControlCredentialFile.completeAutoEnrollment(enrollment); plugin.getLogger().info("[Control] Automatic credential enrollment completed for " + enrollment.nodeId()); close(); } catch (IOException e) { + verifierInstalled = false; plugin.getLogger().warning("[Control] Automatic credential enrollment could not be finalized; it will retry"); } } From b74b34504a5e9163cb16bffca43b46afa57611c0 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:33:33 -0600 Subject: [PATCH 28/39] Confirm backend Control authentication before enrollment --- .../java/com/bencodez/votingplugin/VotingPluginMain.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index e2e7209145..cc9be78858 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -1239,6 +1239,12 @@ public void handleBackendControlEnrollmentResult(com.bencodez.simpleapi.serverco if (enrollment != null) enrollment.handle(envelope); } + /** Confirms that the generated credential authenticated to the configured Control endpoint. */ + public void backendControlAuthenticated() { + BackendControlAutoEnrollment enrollment = backendControlAutoEnrollment; + if (enrollment != null) enrollment.connectorAuthenticated(); + } + private void migrateVoteBroadcast(Config configFile) { ConfigurationSection cfg = configFile.getData(); // If new section exists, do nothing From 377ec39fc3df8b1384fb631c3d2cf5d2bc8fbdbb Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:33:35 -0600 Subject: [PATCH 29/39] Confirm backend Control authentication before enrollment --- .../bencodez/votingplugin/control/BackendControlConnector.java | 1 + 1 file changed, 1 insertion(+) 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 71ec418500..894a5ff514 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java @@ -180,6 +180,7 @@ private void cycle() { throw incompatible; } registered = true; + plugin.backendControlAuthenticated(); if (closed) return; if (operationsAccepted) { CompletableFuture operation = new CompletableFuture<>(); From 383c7da1a24d8e393f3c1908f9bbf24aa28d3ce9 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:33:37 -0600 Subject: [PATCH 30/39] Confirm backend Control authentication before enrollment --- docs/control-connector.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/control-connector.md b/docs/control-connector.md index ef31265f22..c4b55e53de 100644 --- a/docs/control-connector.md +++ b/docs/control-connector.md @@ -160,8 +160,9 @@ Control: Blank `NodeId` reuses `BungeeSettings.Server`. The backend generates the raw credential locally and sends only its SHA-256 verifier over the plugin-message channel. Velocity/BungeeCord binds the request to the actual backend server connection, requires that identity to equal `BungeeSettings.Server`, installs the verifier in its hosted Control, and returns a -non-secret acknowledgement. The raw credential never leaves the backend. Requests retry safely after restarts and while no -player is available to carry plugin messages. +non-secret acknowledgement. The pending marker is removed only after the backend also authenticates to its configured +Control endpoint, so an incorrect endpoint cannot strand a newly generated credential. The raw credential never leaves the +backend. Requests retry safely after restarts and while no player is available to carry plugin messages. If a Bukkit server hosts its own Control and its endpoint addresses that local listener, the same generation and verifier installation happen locally. External Control installations, custom backend node IDs that differ from From 2eced819c90ba8a4e30373c9c58385e51050b2a9 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:34:40 -0600 Subject: [PATCH 31/39] Bind enrollment completion to connector route --- .../votingplugin/util/ControlCredentialFile.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ControlCredentialFile.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ControlCredentialFile.java index 4c8de30cfa..b92eb3aee5 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ControlCredentialFile.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ControlCredentialFile.java @@ -52,14 +52,14 @@ public static synchronized PendingAutoEnrollment prepareAutoEnrollment(Path root String credential = readCredential(target); PendingAutoEnrollment pending = readMarker(marker, target, configuredPath); if (credential != null && pending == null) return null; - if (credential != null && !verifier(credential).equals(pending.verifier())) { + if (credential != null && !sha256Verifier(credential).equals(pending.verifier())) { DurableFiles.deleteIfExists(marker); return null; } if (credential != null && nodeId.equals(pending.nodeId())) return pending; String generated = token(); - String generatedVerifier = verifier(generated); + String generatedVerifier = sha256Verifier(generated); PendingAutoEnrollment replacement = new PendingAutoEnrollment(target, configuredPath, nodeId, generatedVerifier); writeAtomically(marker, markerContents(replacement)); @@ -74,10 +74,10 @@ public static synchronized void completeAutoEnrollment(PendingAutoEnrollment enr Path marker = marker(target); PendingAutoEnrollment current = readMarker(marker, target, enrollment.configuredPath()); String credential = readCredential(target); - if (current == null && credential != null && enrollment.verifier().equals(verifier(credential))) return; + if (current == null && credential != null && enrollment.verifier().equals(sha256Verifier(credential))) return; if (current == null || credential == null || !current.nodeId().equals(enrollment.nodeId()) || !current.verifier().equals(enrollment.verifier()) - || !current.verifier().equals(verifier(credential))) { + || !current.verifier().equals(sha256Verifier(credential))) { throw new IOException("Control automatic enrollment state changed"); } DurableFiles.deleteIfExists(marker); @@ -210,7 +210,9 @@ private static String token() { return "vpctl_node_" + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); } - private static String verifier(String credential) { + /** Returns the non-secret verifier used to bind connector authentication to pending enrollment. */ + public static String sha256Verifier(String credential) { + if (credential == null) throw new IllegalArgumentException("credential is required"); try { return HexFormat.of().formatHex( MessageDigest.getInstance("SHA-256").digest(credential.getBytes(StandardCharsets.UTF_8))); From ceafcd7afbb56f0f22fec409deb5ba677f49c29b Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:34:41 -0600 Subject: [PATCH 32/39] Bind enrollment completion to connector route --- .../control/BackendControlAutoEnrollment.java | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java index 76219285e5..1d5fc53a35 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java @@ -28,6 +28,7 @@ public final class BackendControlAutoEnrollment implements AutoCloseable { private final VotingPluginMain plugin; private final PendingAutoEnrollment enrollment; + private final String endpoint; private final UUID requestId = UUID.randomUUID(); private final AtomicBoolean closed = new AtomicBoolean(); private volatile BukkitTask retryTask; @@ -35,9 +36,10 @@ public final class BackendControlAutoEnrollment implements AutoCloseable { private boolean connectorAuthenticated; private long verifierAcknowledgedAt; - private BackendControlAutoEnrollment(VotingPluginMain plugin, PendingAutoEnrollment enrollment) { + private BackendControlAutoEnrollment(VotingPluginMain plugin, PendingAutoEnrollment enrollment, String endpoint) { this.plugin = plugin; this.enrollment = enrollment; + this.endpoint = endpoint; } /** Prepares a verifier that this Bukkit process's own hosted Control will install. */ @@ -67,7 +69,9 @@ public static BackendControlAutoEnrollment create(VotingPluginMain plugin, HostC return null; } PendingAutoEnrollment enrollment = prepare(plugin, control); - return enrollment == null ? null : new BackendControlAutoEnrollment(plugin, enrollment); + String endpoint = control.getString("Endpoint", ""); + return enrollment == null ? null : new BackendControlAutoEnrollment(plugin, enrollment, + endpoint == null ? "" : endpoint.trim()); } public static String configuredNodeId(VotingPluginMain plugin, ConfigurationSection control) { @@ -122,8 +126,11 @@ public synchronized void handle(JsonEnvelope envelope) { } /** Completes durable enrollment only after this credential authenticated to the configured endpoint. */ - public synchronized void connectorAuthenticated() { - if (closed.get()) return; + public synchronized void connectorAuthenticated(String nodeId, String credentialFile, String endpoint, + String verifier) { + if (closed.get() || !enrollment.nodeId().equals(nodeId) + || !enrollment.configuredPath().equals(credentialFile) || !this.endpoint.equals(endpoint) + || !enrollment.verifier().equals(verifier)) return; connectorAuthenticated = true; completeIfConnected(); } From 7cae72ff7786643b02ebd8318e00bf779d280067 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:34:43 -0600 Subject: [PATCH 33/39] Bind enrollment completion to connector route --- .../main/java/com/bencodez/votingplugin/VotingPluginMain.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index cc9be78858..16d0ded442 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -1240,9 +1240,9 @@ public void handleBackendControlEnrollmentResult(com.bencodez.simpleapi.serverco } /** Confirms that the generated credential authenticated to the configured Control endpoint. */ - public void backendControlAuthenticated() { + public void backendControlAuthenticated(String nodeId, String credentialFile, String endpoint, String verifier) { BackendControlAutoEnrollment enrollment = backendControlAutoEnrollment; - if (enrollment != null) enrollment.connectorAuthenticated(); + if (enrollment != null) enrollment.connectorAuthenticated(nodeId, credentialFile, endpoint, verifier); } private void migrateVoteBroadcast(Config configFile) { From 77dacfef8e93a89e887b1b2a1df51e6b71cffcd7 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:34:45 -0600 Subject: [PATCH 34/39] Bind enrollment completion to connector route --- .../votingplugin/control/BackendControlConnector.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 894a5ff514..6a7cf384ec 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java @@ -50,6 +50,7 @@ public final class BackendControlConnector implements AutoCloseable { private final Path dataDirectory; private final Settings settings; private final String credential; + private final String credentialVerifier; private volatile HostConfiguration hostedConfiguration; private final ScheduledExecutorService executor; private final HttpClient http; @@ -75,6 +76,7 @@ private BackendControlConnector(VotingPluginMain plugin, Path dataDirectory, Set this.dataDirectory = dataDirectory; this.settings = settings; this.credential = credential; + this.credentialVerifier = ControlCredentialFile.sha256Verifier(credential); this.hostedConfiguration = hostedConfiguration; this.recovering = recovering; ThreadFactory factory = runnable -> { @@ -180,7 +182,8 @@ private void cycle() { throw incompatible; } registered = true; - plugin.backendControlAuthenticated(); + plugin.backendControlAuthenticated(settings.nodeId(), settings.credentialFile(), + settings.endpoint().toString(), credentialVerifier); if (closed) return; if (operationsAccepted) { CompletableFuture operation = new CompletableFuture<>(); From a5a17634ca158fe6bd47ae18ce2c3bd03e21df02 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:46:37 -0600 Subject: [PATCH 35/39] Verify hosted routes before backend auto-enrollment --- .../votingplugin/VotingPluginMain.java | 5 +- .../control/BackendControlAutoEnrollment.java | 80 ++++++++++++++----- .../votingplugin/proxy/VotingPluginProxy.java | 2 +- .../votingplugin/proxy/VotingPluginWire.java | 14 +++- .../proxy/control/HostedControlManager.java | 70 ++++++++++++++-- .../util/ControlCredentialFile.java | 17 ++++ VotingPlugin/src/main/resources/Config.yml | 4 +- .../control/HostedControlManagerTest.java | 43 +++++++++- .../tests/VotingPluginWireTest.java | 15 +++- .../util/ControlCredentialFileTest.java | 24 ++++++ 10 files changed, 235 insertions(+), 39 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 16d0ded442..a408a5d49d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -1041,6 +1041,7 @@ private void stopBackendHostedControlLifecycle() { private void startBackendControlConnector() { try { refreshBackendControlAutoEnrollment(); + if (backendControlAutoEnrollment != null && backendControlAutoEnrollment.isAwaitingCredential()) return; backendControlConnector = BackendControlConnector.create(this); if (backendControlConnector != null) backendControlConnector.start(); } catch (Exception e) { @@ -1111,7 +1112,9 @@ private void reconcileBackendControlConnector() { boolean creationFailed = false; try { refreshBackendControlAutoEnrollment(); - replacement = BackendControlConnector.create(this); + BackendControlAutoEnrollment enrollment = backendControlAutoEnrollment; + replacement = enrollment != null && enrollment.isAwaitingCredential() + ? null : BackendControlConnector.create(this); } catch (Exception e) { replacement = null; creationFailed = true; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java index 1d5fc53a35..34a02dca98 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java @@ -16,6 +16,7 @@ import com.bencodez.votingplugin.proxy.control.HostedControlManager; import com.bencodez.votingplugin.proxy.control.HostedControlManager.HostConfiguration; import com.bencodez.votingplugin.util.ControlCredentialFile; +import com.bencodez.votingplugin.util.ControlCredentialFile.AutoEnrollmentInspection; import com.bencodez.votingplugin.util.ControlCredentialFile.PendingAutoEnrollment; /** @@ -27,19 +28,24 @@ public final class BackendControlAutoEnrollment implements AutoCloseable { private static final long VERIFIER_REFRESH_NANOS = TimeUnit.SECONDS.toNanos(60); private final VotingPluginMain plugin; - private final PendingAutoEnrollment enrollment; + private final String nodeId; + private final String credentialFile; private final String endpoint; private final UUID requestId = UUID.randomUUID(); private final AtomicBoolean closed = new AtomicBoolean(); private volatile BukkitTask retryTask; + private PendingAutoEnrollment enrollment; private boolean verifierInstalled; private boolean connectorAuthenticated; private long verifierAcknowledgedAt; - private BackendControlAutoEnrollment(VotingPluginMain plugin, PendingAutoEnrollment enrollment, String endpoint) { + private BackendControlAutoEnrollment(VotingPluginMain plugin, String nodeId, String credentialFile, + String endpoint, PendingAutoEnrollment enrollment) { this.plugin = plugin; - this.enrollment = enrollment; + this.nodeId = nodeId; + this.credentialFile = credentialFile; this.endpoint = endpoint; + this.enrollment = enrollment; } /** Prepares a verifier that this Bukkit process's own hosted Control will install. */ @@ -68,10 +74,15 @@ public static BackendControlAutoEnrollment create(VotingPluginMain plugin, HostC if (!nodeId.equals(serverName) || "pleaseset".equalsIgnoreCase(nodeId)) { return null; } - PendingAutoEnrollment enrollment = prepare(plugin, control); String endpoint = control.getString("Endpoint", ""); - return enrollment == null ? null : new BackendControlAutoEnrollment(plugin, enrollment, - endpoint == null ? "" : endpoint.trim()); + String credentialFile = control.getString("CredentialFile", "control/control-credential.txt"); + AutoEnrollmentInspection inspection = ControlCredentialFile.inspectAutoEnrollment( + plugin.getDataFolder().toPath(), credentialFile); + PendingAutoEnrollment pending = inspection.pending(); + if (pending == null && inspection.credentialPresent()) return null; + if (pending != null && !nodeId.equals(pending.nodeId())) pending = null; + return new BackendControlAutoEnrollment(plugin, nodeId, credentialFile, + endpoint == null ? "" : endpoint.trim(), pending); } public static String configuredNodeId(VotingPluginMain plugin, ConfigurationSection control) { @@ -81,8 +92,12 @@ public static String configuredNodeId(VotingPluginMain plugin, ConfigurationSect private static PendingAutoEnrollment prepare(VotingPluginMain plugin, ConfigurationSection control) throws IOException { String credentialFile = control.getString("CredentialFile", "control/control-credential.txt"); - return ControlCredentialFile.prepareAutoEnrollment(plugin.getDataFolder().toPath(), credentialFile, - nodeId(plugin, control)); + return prepare(plugin, credentialFile, nodeId(plugin, control)); + } + + private static PendingAutoEnrollment prepare(VotingPluginMain plugin, String credentialFile, String nodeId) + throws IOException { + return ControlCredentialFile.prepareAutoEnrollment(plugin.getDataFolder().toPath(), credentialFile, nodeId); } private static ConfigurationSection control(VotingPluginMain plugin) { @@ -104,33 +119,56 @@ public synchronized void start() { } } + /** True while the proxy must confirm that this route reaches its hosted Control. */ + public synchronized boolean isAwaitingCredential() { + return enrollment == null; + } + private void send() { + PendingAutoEnrollment pending; synchronized (this) { if (closed.get() || (verifierInstalled && System.nanoTime() - verifierAcknowledgedAt < VERIFIER_REFRESH_NANOS)) return; + pending = enrollment; } BackendProxyHandler handler = plugin.getBackendProxyHandler(); if (handler == null || handler.getMethod() != BungeeMethod.PLUGINMESSAGING || handler.getGlobalMessageHandler() == null) return; handler.getGlobalMessageHandler().sendMessage(VotingPluginWire.controlEnrollmentRequest( - enrollment.nodeId(), enrollment.verifier(), requestId)); + nodeId, pending == null ? "" : pending.verifier(), endpoint, requestId)); } - public synchronized void handle(JsonEnvelope envelope) { + public void handle(JsonEnvelope envelope) { VotingPluginWire.ControlEnrollmentResult result = VotingPluginWire.readControlEnrollmentResult(envelope); - if (closed.get() || !result.valid || !result.success || !requestId.equals(result.requestId) - || !enrollment.nodeId().equals(result.nodeId)) return; - verifierInstalled = true; - verifierAcknowledgedAt = System.nanoTime(); - completeIfConnected(); + boolean restartConnector = false; + synchronized (this) { + if (closed.get() || !result.valid || !result.success || !requestId.equals(result.requestId) + || !nodeId.equals(result.nodeId)) return; + if (enrollment == null) { + try { + enrollment = prepare(plugin, credentialFile, nodeId); + if (enrollment == null) close(); + restartConnector = true; + } catch (IOException e) { + plugin.getLogger().warning( + "[Control] Automatic backend credential could not be prepared; it will retry"); + } + } else { + verifierInstalled = true; + verifierAcknowledgedAt = System.nanoTime(); + completeIfConnected(); + } + } + if (restartConnector) plugin.restartBackendControlConnector(); } /** Completes durable enrollment only after this credential authenticated to the configured endpoint. */ public synchronized void connectorAuthenticated(String nodeId, String credentialFile, String endpoint, String verifier) { - if (closed.get() || !enrollment.nodeId().equals(nodeId) - || !enrollment.configuredPath().equals(credentialFile) || !this.endpoint.equals(endpoint) - || !enrollment.verifier().equals(verifier)) return; + PendingAutoEnrollment pending = enrollment; + if (closed.get() || pending == null || !pending.nodeId().equals(nodeId) + || !pending.configuredPath().equals(credentialFile) || !this.endpoint.equals(endpoint) + || !pending.verifier().equals(verifier)) return; connectorAuthenticated = true; completeIfConnected(); } @@ -138,8 +176,10 @@ public synchronized void connectorAuthenticated(String nodeId, String credential private void completeIfConnected() { if (!verifierInstalled || !connectorAuthenticated) return; try { - ControlCredentialFile.completeAutoEnrollment(enrollment); - plugin.getLogger().info("[Control] Automatic credential enrollment completed for " + enrollment.nodeId()); + PendingAutoEnrollment pending = enrollment; + if (pending == null) return; + ControlCredentialFile.completeAutoEnrollment(pending); + plugin.getLogger().info("[Control] Automatic credential enrollment completed for " + pending.nodeId()); close(); } catch (IOException e) { verifierInstalled = false; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java index e06d8f0062..f00a6cf647 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java @@ -2524,7 +2524,7 @@ private void handleControlEnrollmentRequest(String sourceServer, JsonEnvelope en VotingPluginWire.controlEnrollmentResult(sourceServer, request.requestId, false)); return; } - manager.installNodeVerifier(sourceServer, request.verifier).whenComplete((installed, failure) -> { + manager.installNodeVerifier(sourceServer, request.verifier, request.endpoint).whenComplete((installed, failure) -> { boolean success = failure == null && Boolean.TRUE.equals(installed); sendPluginMessageServer(sourceServer, 0, VotingPluginWire.controlEnrollmentResult(sourceServer, request.requestId, success)); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java index 89e8c2a67f..23de6628a6 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java @@ -89,6 +89,7 @@ private VotingPluginWire() { public static final String K_CHUNK_COUNT = "chunkCount"; public static final String K_NODE_ID = "nodeId"; public static final String K_VERIFIER = "verifier"; + public static final String K_ENDPOINT = "endpoint"; public static final String K_SUCCESS = "success"; // Vote/VoteOnline extras @@ -177,9 +178,10 @@ public static JsonEnvelope serverName(String server) { return base(SUB_SERVER_NAME).put(K_SERVER, safe(server)).build(); } - public static JsonEnvelope controlEnrollmentRequest(String nodeId, String verifier, UUID requestId) { + public static JsonEnvelope controlEnrollmentRequest(String nodeId, String verifier, String endpoint, UUID requestId) { return base(SUB_CONTROL_ENROLLMENT_REQUEST).put(K_NODE_ID, safe(nodeId)) .put(K_VERIFIER, safe(verifier)) + .put(K_ENDPOINT, safe(endpoint)) .put(K_REQUEST_ID, requestId == null ? "" : requestId.toString()).build(); } @@ -481,22 +483,26 @@ public static VoteUpdate readVoteUpdate(JsonEnvelope env) { public static final class ControlEnrollmentRequest { public final String nodeId; public final String verifier; + public final String endpoint; public final UUID requestId; public final boolean valid; - private ControlEnrollmentRequest(String nodeId, String verifier, UUID requestId) { + private ControlEnrollmentRequest(String nodeId, String verifier, String endpoint, UUID requestId) { this.nodeId = nodeId; this.verifier = verifier; + this.endpoint = endpoint; this.requestId = requestId; this.valid = nodeId.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}") - && verifier.matches("[0-9a-f]{64}") && requestId != null; + && (verifier.isEmpty() || verifier.matches("[0-9a-f]{64}")) + && !endpoint.isBlank() && endpoint.length() <= 2048 + && requestId != null; } } public static ControlEnrollmentRequest readControlEnrollmentRequest(JsonEnvelope env) { Map fields = env.getFields(); return new ControlEnrollmentRequest(safe(fields.get(K_NODE_ID)), safe(fields.get(K_VERIFIER)), - readUuid(fields, K_REQUEST_ID)); + safe(fields.get(K_ENDPOINT)), readUuid(fields, K_REQUEST_ID)); } public static final class ControlEnrollmentResult { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/HostedControlManager.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/HostedControlManager.java index a02100965e..3e2d952861 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/HostedControlManager.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/HostedControlManager.java @@ -78,6 +78,7 @@ public final class HostedControlManager implements AutoCloseable { private volatile String rollbackCandidateSha256; private volatile boolean rollbackCandidateReachedHealth; private volatile boolean rollbackPending; + private volatile String healthyLaunchId; private volatile PendingAutoEnrollment localAutoEnrollment; private HostedControlManager(Settings settings, PendingAutoEnrollment localAutoEnrollment, @@ -250,6 +251,7 @@ void runOnce() { rollbackCandidateSha256 = null; rollbackCandidateReachedHealth = false; rollbackPending = false; + healthyLaunchId = launched.launchId(); status = Status.RUNNING; logger.accept("WebUI is available at " + settings.endpoint()); monitor(process); @@ -286,6 +288,7 @@ private boolean rollbackAndStartPrevious() throws IOException, InterruptedExcept ManagedProcess previous = previousLaunch.process(); if (awaitHealthy(previousLaunch)) { failures = 0; + healthyLaunchId = previousLaunch.launchId(); status = Status.ROLLED_BACK; logger.accept("The new Control release failed health checks; the previous release is running"); monitor(previous); @@ -556,25 +559,37 @@ private void installLocalAutoEnrollment() throws IOException, InterruptedExcepti } /** - * Installs a verifier generated by a backend connected through the trusted - * proxy plugin-message channel. The raw credential never reaches this process. + * Proves a backend route reaches this hosted launch, then optionally installs + * its verifier. The raw credential never reaches this process. */ - public CompletableFuture installNodeVerifier(String nodeId, String verifier) { + public CompletableFuture installNodeVerifier(String nodeId, String verifier, String endpoint) { if (nodeId == null || !nodeId.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}") - || verifier == null || !verifier.matches("[0-9a-f]{64}")) { + || verifier == null || (!verifier.isEmpty() && !verifier.matches("[0-9a-f]{64}"))) { return CompletableFuture.completedFuture(false); } - String key = nodeId + ':' + verifier; + URI requestedEndpoint = hostedBackendEndpoint(endpoint); + if (requestedEndpoint == null) return CompletableFuture.completedFuture(false); + String key = nodeId + ':' + verifier + ':' + requestedEndpoint; return enrollmentTasks.computeIfAbsent(key, ignored -> { CompletableFuture result = new CompletableFuture<>(); try { executor.execute(() -> { try { + String launchId = healthyLaunchId; if (closed || (status != Status.RUNNING && status != Status.ROLLED_BACK) + || launchId == null || !Files.isRegularFile(settings.jarFile(), LinkOption.NOFOLLOW_LINKS)) { result.complete(false); return; } + if (!healthProbe.isHealthy(requestedEndpoint, Duration.ofSeconds(2), launchId)) { + result.complete(false); + return; + } + if (verifier.isEmpty()) { + result.complete(true); + return; + } requireTrustedInstalledArtifact(); verifierInstaller.install(settings, settings.jarFile(), nodeId, verifier, Duration.ofSeconds(Math.min(30, settings.startupTimeoutSeconds()))); @@ -606,6 +621,7 @@ private void requireTrustedInstalledArtifact() throws IOException { private LaunchedProcess launch(Path artifact) throws IOException { status = Status.STARTING_PROCESS; + healthyLaunchId = null; synchronized (processLifecycle) { if (closed) throw new IOException("Hosted Control manager is closed"); if (managedProcess != null) { @@ -659,6 +675,7 @@ private void monitor(ManagedProcess process) { return; } managedProcess = null; + healthyLaunchId = null; status = Status.FAILED; logger.accept("Control exited unexpectedly; a bounded restart will be attempted"); scheduleRetry(); @@ -666,6 +683,7 @@ private void monitor(ManagedProcess process) { } private void fail() { + healthyLaunchId = null; status = Status.FAILED; ManagedProcess process = managedProcess; if (process != null && process.isAlive()) { @@ -678,6 +696,7 @@ private void fail() { } private void failWhileProcessIsAlive(ManagedProcess process) { + healthyLaunchId = null; status = Status.FAILED; synchronized (processLifecycle) { if (closed || managedProcess != process || exitAwaitedProcess == process) return; @@ -726,6 +745,7 @@ private void close(boolean waitForProcess) { ManagedProcess process; synchronized (processLifecycle) { closed = true; + healthyLaunchId = null; status = Status.STOPPED; process = managedProcess; } @@ -856,12 +876,12 @@ public static boolean isDirectLocalEndpoint(String configuredEndpoint, HostConfi if (hosted == null || !hosted.enabled() || configuredEndpoint == null) return false; try { URI endpoint = URI.create(configuredEndpoint.trim()); - String host = endpoint.getHost(); + String host = normalizeHostLiteral(endpoint.getHost()); int port = endpoint.getPort() < 0 ? 80 : endpoint.getPort(); String path = endpoint.getPath(); boolean loopback = "localhost".equalsIgnoreCase(host) || "127.0.0.1".equals(host) || "::1".equals(host) || "0:0:0:0:0:0:0:1".equals(host); - String listenerHost = hosted.host() == null ? "" : hosted.host().trim(); + String listenerHost = normalizeHostLiteral(hosted.host()); boolean configuredListener = host != null && host.equalsIgnoreCase(listenerHost); boolean listenerLoopback = "localhost".equalsIgnoreCase(listenerHost) || "127.0.0.1".equals(listenerHost) || "::1".equals(listenerHost) || "0:0:0:0:0:0:0:1".equals(listenerHost); @@ -877,6 +897,42 @@ public static boolean isDirectLocalEndpoint(String configuredEndpoint, HostConfi } } + /** + * Accepts a backend route only when it can address this listener. A subsequent + * launch-id health probe proves that the route reaches this exact hosted process. + */ + private URI hostedBackendEndpoint(String configuredEndpoint) { + if (configuredEndpoint == null || configuredEndpoint.isBlank()) return null; + try { + URI endpoint = URI.create(configuredEndpoint.trim()); + String host = normalizeHostLiteral(endpoint.getHost()); + String listenerHost = normalizeHostLiteral(settings.host()); + int port = endpoint.getPort() < 0 ? 80 : endpoint.getPort(); + String path = endpoint.getPath(); + boolean listenerWildcard = "0.0.0.0".equals(listenerHost) || "::".equals(listenerHost) + || "0:0:0:0:0:0:0:0".equals(listenerHost); + if (!"http".equalsIgnoreCase(endpoint.getScheme()) || host.isBlank() + || (!listenerWildcard && !host.equalsIgnoreCase(listenerHost)) || port != settings.port() + || endpoint.getUserInfo() != null || endpoint.getQuery() != null || endpoint.getFragment() != null + || (path != null && !path.isEmpty() && !"/".equals(path))) { + return null; + } + return endpoint; + } catch (IllegalArgumentException e) { + return null; + } + } + + private static String normalizeHostLiteral(String host) { + if (host == null) return ""; + String normalized = host.trim(); + if (normalized.length() >= 2 && normalized.charAt(0) == '[' + && normalized.charAt(normalized.length() - 1) == ']') { + return normalized.substring(1, normalized.length() - 1); + } + return normalized; + } + static URI parseDownloadUri(String configured) { if (configured == null || configured.isBlank()) { return null; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ControlCredentialFile.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ControlCredentialFile.java index b92eb3aee5..c4d6faa5a4 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ControlCredentialFile.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ControlCredentialFile.java @@ -39,6 +39,21 @@ public static String read(Path rootDirectory, String configuredPath) throws IOEx return credential; } + /** Inspects existing enrollment state without generating a new credential. */ + public static synchronized AutoEnrollmentInspection inspectAutoEnrollment(Path rootDirectory, + String configuredPath) throws IOException { + Path target = resolveAndCreate(rootDirectory, configuredPath); + Path marker = marker(target); + String credential = readCredential(target); + PendingAutoEnrollment pending = readMarker(marker, target, configuredPath); + if (credential != null && pending != null && !sha256Verifier(credential).equals(pending.verifier())) { + DurableFiles.deleteIfExists(marker); + pending = null; + } + if (credential == null) pending = null; + return new AutoEnrollmentInspection(credential != null, pending); + } + /** * Generates a credential only when the configured file is empty and retains a * verifier-only marker until the supervising host acknowledges enrollment. @@ -227,4 +242,6 @@ private static IOException invalid() { /** Non-secret durable request for a supervising Control host to install. */ public record PendingAutoEnrollment(Path credentialFile, String configuredPath, String nodeId, String verifier) { } + + public record AutoEnrollmentInspection(boolean credentialPresent, PendingAutoEnrollment pending) { } } diff --git a/VotingPlugin/src/main/resources/Config.yml b/VotingPlugin/src/main/resources/Config.yml index 19f28cf616..80fe7aaa90 100644 --- a/VotingPlugin/src/main/resources/Config.yml +++ b/VotingPlugin/src/main/resources/Config.yml @@ -1188,8 +1188,8 @@ Control: # Blank reuses BungeeSettings.Server, which must be unique for every backend. NodeId: '' Endpoint: 'http://127.0.0.1:8080' - # VotingPlugin automatically generates this local credential when it can enroll through - # the hosted Control on this server or through the authenticated proxy plugin-message path. + # VotingPlugin automatically generates this local credential only after confirming it can enroll through + # the hosted Control on this server or the proxy's authenticated, route-verified plugin-message path. # Unsupported/external topologies retain the blank file for manual enrollment. CredentialFile: 'control/control-credential.txt' HeartbeatSeconds: 30 diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/HostedControlManagerTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/HostedControlManagerTest.java index e73cf7962d..4dd9b3a6ad 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/HostedControlManagerTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/HostedControlManagerTest.java @@ -71,6 +71,9 @@ void localEndpointMatchesLoopbackOrTheConfiguredListenerAddress() { assertTrue(HostedControlManager.isDirectLocalEndpoint("http://10.0.0.5:2150", hosted)); assertFalse(HostedControlManager.isDirectLocalEndpoint("http://10.0.0.6:2150", hosted)); assertFalse(HostedControlManager.isDirectLocalEndpoint("https://10.0.0.5:2150", hosted)); + HostedControlManager.HostConfiguration ipv6 = new HostedControlManager.HostConfiguration(true, false, + false, "", "0".repeat(64), "control/control.jar", "control/data", "::1", 2150, 30, 60); + assertTrue(HostedControlManager.isDirectLocalEndpoint("http://[::1]:2150", ipv6)); HostedControlManager.HostConfiguration wildcard = new HostedControlManager.HostConfiguration(true, false, false, "", "0".repeat(64), "control/control.jar", "control/data", "0.0.0.0", 2150, 30, 60); assertTrue(HostedControlManager.isDirectLocalEndpoint("http://127.0.0.1:2150", wildcard)); @@ -242,9 +245,45 @@ void runningHostAcceptsVerifierOnlyBackendEnrollment() throws Exception { (endpoint, timeout, launchId) -> true, millis -> { }, System::nanoTime, null, message -> { }); manager.runOnce(); - assertTrue(manager.installNodeVerifier("survival", "a".repeat(64)).get(5, TimeUnit.SECONDS)); + assertTrue(manager.installNodeVerifier("survival", "", "http://127.0.0.1:8080") + .get(5, TimeUnit.SECONDS)); + assertEquals(0, installations.get()); + assertTrue(manager.installNodeVerifier("survival", "a".repeat(64), "http://127.0.0.1:8080") + .get(5, TimeUnit.SECONDS)); + assertEquals(1, installations.get()); + assertFalse(manager.installNodeVerifier("../invalid", "a".repeat(64), "http://127.0.0.1:8080") + .get(5, TimeUnit.SECONDS)); + assertFalse(manager.installNodeVerifier("survival", "b".repeat(64), "http://external.example:8080") + .get(5, TimeUnit.SECONDS)); + manager.close(); + } + + @Test + void wildcardHostProvesRequestedRouteReachesTheRunningLaunch() throws Exception { + byte[] release = "release-with-enrollment-command".getBytes(StandardCharsets.UTF_8); + Path root = directory.toAbsolutePath().normalize(); + Path jar = root.resolve("control/wildcard-control.jar"); + Files.createDirectories(jar.getParent()); + Files.write(jar, release); + HostedControlManager.Settings base = settings(jar, root.resolve("control/wildcard-data"), false, false, + digest(release), 30); + HostedControlManager.Settings settings = new HostedControlManager.Settings(base.rootDirectory(), base.jarFile(), + base.dataDirectory(), base.autoDownload(), base.autoUpdate(), base.downloadUri(), base.sha256(), + "0.0.0.0", 2150, base.startupTimeoutSeconds(), base.downloadTimeoutSeconds()); + AtomicInteger installations = new AtomicInteger(); + ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + HostedControlManager manager = new HostedControlManager(settings, executor, + (source, target, maximum, timeout) -> { throw new AssertionError("download was not expected"); }, + new FakeLauncher(), (configured, artifact, nodeId, verifier, timeout) -> installations.incrementAndGet(), + (endpoint, timeout, launchId) -> !"external.example".equals(endpoint.getHost()), + millis -> { }, System::nanoTime, null, message -> { }); + manager.runOnce(); + + assertTrue(manager.installNodeVerifier("survival", "a".repeat(64), "http://10.0.0.5:2150") + .get(5, TimeUnit.SECONDS)); + assertFalse(manager.installNodeVerifier("creative", "b".repeat(64), "http://external.example:2150") + .get(5, TimeUnit.SECONDS)); assertEquals(1, installations.get()); - assertFalse(manager.installNodeVerifier("../invalid", "a".repeat(64)).get(5, TimeUnit.SECONDS)); manager.close(); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java index 260bde19de..e8e59cba4e 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java @@ -180,24 +180,35 @@ public void chunkedPresenceSnapshotPreservesChunkMetadata() { } @Test - public void controlEnrollmentRoundTripContainsOnlyVerifierAndIdentity() { + public void controlEnrollmentRoundTripContainsVerifierIdentityAndRoute() { UUID requestId = UUID.randomUUID(); String verifier = "a".repeat(64); VotingPluginWire.ControlEnrollmentRequest request = VotingPluginWire.readControlEnrollmentRequest( - VotingPluginWire.controlEnrollmentRequest("survival", verifier, requestId)); + VotingPluginWire.controlEnrollmentRequest("survival", verifier, "http://10.0.0.5:2150", requestId)); VotingPluginWire.ControlEnrollmentResult result = VotingPluginWire.readControlEnrollmentResult( VotingPluginWire.controlEnrollmentResult("survival", requestId, true)); assertTrue(request.valid); assertEquals("survival", request.nodeId); assertEquals(verifier, request.verifier); + assertEquals("http://10.0.0.5:2150", request.endpoint); assertEquals(requestId, request.requestId); assertTrue(result.valid); assertTrue(result.success); assertEquals(requestId, result.requestId); } + @Test + public void blankVerifierIsAValidHostedRoutePreflight() { + VotingPluginWire.ControlEnrollmentRequest request = VotingPluginWire.readControlEnrollmentRequest( + VotingPluginWire.controlEnrollmentRequest("survival", "", "http://10.0.0.5:2150", + UUID.randomUUID())); + + assertTrue(request.valid); + assertEquals("", request.verifier); + } + @Test public void malformedControlEnrollmentIsRejected() { JsonEnvelope malformed = JsonEnvelope.builder(VotingPluginWire.SUB_CONTROL_ENROLLMENT_REQUEST) diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/ControlCredentialFileTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/ControlCredentialFileTest.java index f44346a726..834bdf63d2 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/ControlCredentialFileTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/ControlCredentialFileTest.java @@ -54,6 +54,30 @@ class ControlCredentialFileTest { assertEquals(credential, ControlCredentialFile.read(root, configured)); } + @Test void inspectionNeverGeneratesAndRecoversOnlyMatchingPendingState() throws Exception { + Path root = Files.createDirectory(directory.resolve("inspection-plugin")); + String configured = "control/control-credential.txt"; + + ControlCredentialFile.AutoEnrollmentInspection blank = + ControlCredentialFile.inspectAutoEnrollment(root, configured); + assertFalse(blank.credentialPresent()); + assertNull(blank.pending()); + + ControlCredentialFile.PendingAutoEnrollment generated = + ControlCredentialFile.prepareAutoEnrollment(root, configured, "survival"); + ControlCredentialFile.AutoEnrollmentInspection pending = + ControlCredentialFile.inspectAutoEnrollment(root, configured); + assertTrue(pending.credentialPresent()); + assertEquals(generated, pending.pending()); + + Files.writeString(root.resolve(configured), "vpctl_node_manually_replaced"); + ControlCredentialFile.AutoEnrollmentInspection replaced = + ControlCredentialFile.inspectAutoEnrollment(root, configured); + assertTrue(replaced.credentialPresent()); + assertNull(replaced.pending()); + assertFalse(Files.exists(root.resolve(configured + ".auto-enroll"))); + } + @Test void existingManualCredentialIsNeverReplaced() throws Exception { Path root = Files.createDirectory(directory.resolve("manual-plugin")); Path control = Files.createDirectories(root.resolve("control")); From 9b692ab10f8a382324ae865b380f119bd59f0f3e Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:53:33 -0600 Subject: [PATCH 36/39] Reconcile local hosted Control before connector --- .../votingplugin/VotingPluginMain.java | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index a408a5d49d..8ebd653f14 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -940,6 +940,7 @@ private void replaceBackendHostedControl(HostedControlManager replacement, Runnable publication = () -> { backendHostedControlActiveManager = replacement; backendHostedControlActiveConfiguration = replacementConfiguration; + restartBackendControlConnector(); }; BackendControlConnector connector = backendControlConnector; if (connector == null) publication.run(); @@ -1060,6 +1061,21 @@ private synchronized void refreshBackendControlAutoEnrollment() throws IOExcepti if (replacement != null) replacement.start(); } + private synchronized void clearBackendControlAutoEnrollment() { + BackendControlAutoEnrollment previous = backendControlAutoEnrollment; + backendControlAutoEnrollment = null; + if (previous != null) previous.close(); + } + + /** True while a newly configured local host must be published before its connector is created. */ + private boolean backendControlAwaitsHostedPublication() { + HostedControlManager.HostConfiguration configured = readBackendHostedControlConfiguration(); + if (configured.equals(backendHostedControlActiveConfiguration)) return false; + ConfigurationSection control = getConfigFile().getData().getConfigurationSection("Control.Backend"); + return control != null && control.getBoolean("Enabled", false) + && HostedControlManager.isDirectLocalEndpoint(control.getString("Endpoint", ""), configured); + } + /** Refreshes the optional Control services after their Config.yml settings were applied. */ public synchronized void restartBackendControlConnector() { backendControlConnectorReconcileRequested = true; @@ -1111,10 +1127,15 @@ private void reconcileBackendControlConnector() { BackendControlConnector replacement; boolean creationFailed = false; try { - refreshBackendControlAutoEnrollment(); - BackendControlAutoEnrollment enrollment = backendControlAutoEnrollment; - replacement = enrollment != null && enrollment.isAwaitingCredential() - ? null : BackendControlConnector.create(this); + if (backendControlAwaitsHostedPublication()) { + clearBackendControlAutoEnrollment(); + replacement = null; + } else { + refreshBackendControlAutoEnrollment(); + BackendControlAutoEnrollment enrollment = backendControlAutoEnrollment; + replacement = enrollment != null && enrollment.isAwaitingCredential() + ? null : BackendControlConnector.create(this); + } } catch (Exception e) { replacement = null; creationFailed = true; From 755b70a7eeb45525056997ff6414ba6f53866bc9 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:58:46 -0600 Subject: [PATCH 37/39] Keep hosted Control independent from connector enrollment --- .../bencodez/votingplugin/VotingPluginMain.java | 8 +++++++- .../proxy/control/HostedControlManager.java | 14 +++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 8ebd653f14..52e3ec594a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -802,7 +802,13 @@ private void startBackendHostedControl() { private HostedControlManager createBackendHostedControlManager( HostedControlManager.HostConfiguration configuration) throws IOException { - PendingAutoEnrollment enrollment = BackendControlAutoEnrollment.prepareLocal(this, configuration); + PendingAutoEnrollment enrollment = null; + try { + enrollment = BackendControlAutoEnrollment.prepareLocal(this, configuration); + } catch (IOException | IllegalArgumentException enrollmentFailure) { + getLogger().warning( + "[Control] Automatic local credential enrollment was skipped because its connector settings are invalid"); + } return HostedControlManager.create(getDataFolder().toPath(), configuration, enrollment, message -> getLogger().info("[Control Host] " + message)); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/HostedControlManager.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/HostedControlManager.java index 3e2d952861..d1aa492812 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/HostedControlManager.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/HostedControlManager.java @@ -121,11 +121,15 @@ public static HostedControlManager create(VotingPluginProxy proxy) throws IOExce Path root = proxy.getDataFolderPlugin().toPath(); PendingAutoEnrollment enrollment = null; if (config.getControlEnabled() && isDirectLocalEndpoint(config.getControlEndpoint(), hosted)) { - String configuredNodeId = config.getControlNodeId(); - String nodeId = configuredNodeId == null || configuredNodeId.isBlank() - ? config.getProxyServerName() : configuredNodeId.trim(); - enrollment = ControlCredentialFile.prepareAutoEnrollment(root, - config.getControlCredentialFile(), nodeId); + try { + String configuredNodeId = config.getControlNodeId(); + String nodeId = configuredNodeId == null || configuredNodeId.isBlank() + ? config.getProxyServerName() : configuredNodeId.trim(); + enrollment = ControlCredentialFile.prepareAutoEnrollment(root, + config.getControlCredentialFile(), nodeId); + } catch (IOException | IllegalArgumentException enrollmentFailure) { + proxy.log("[Control] Automatic local credential enrollment was skipped because its connector settings are invalid"); + } } return create(root, hosted, enrollment, message -> proxy.log("[Control Host] " + message)); } From dc6388cd6bd62d53b2da4b8122852d6e0796763f Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 13:06:13 -0600 Subject: [PATCH 38/39] Reject loopback routes for proxy backend enrollment --- .../proxy/control/HostedControlManager.java | 13 ++++++++----- .../proxy/control/HostedControlManagerTest.java | 12 +++++++++--- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/HostedControlManager.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/HostedControlManager.java index d1aa492812..2ec1faf8ce 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/HostedControlManager.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/HostedControlManager.java @@ -883,12 +883,10 @@ public static boolean isDirectLocalEndpoint(String configuredEndpoint, HostConfi String host = normalizeHostLiteral(endpoint.getHost()); int port = endpoint.getPort() < 0 ? 80 : endpoint.getPort(); String path = endpoint.getPath(); - boolean loopback = "localhost".equalsIgnoreCase(host) || "127.0.0.1".equals(host) - || "::1".equals(host) || "0:0:0:0:0:0:0:1".equals(host); + boolean loopback = isLoopbackHost(host); String listenerHost = normalizeHostLiteral(hosted.host()); boolean configuredListener = host != null && host.equalsIgnoreCase(listenerHost); - boolean listenerLoopback = "localhost".equalsIgnoreCase(listenerHost) || "127.0.0.1".equals(listenerHost) - || "::1".equals(listenerHost) || "0:0:0:0:0:0:0:1".equals(listenerHost); + boolean listenerLoopback = isLoopbackHost(listenerHost); boolean listenerWildcard = "0.0.0.0".equals(listenerHost) || "::".equals(listenerHost) || "0:0:0:0:0:0:0:0".equals(listenerHost); return "http".equalsIgnoreCase(endpoint.getScheme()) @@ -915,7 +913,7 @@ private URI hostedBackendEndpoint(String configuredEndpoint) { String path = endpoint.getPath(); boolean listenerWildcard = "0.0.0.0".equals(listenerHost) || "::".equals(listenerHost) || "0:0:0:0:0:0:0:0".equals(listenerHost); - if (!"http".equalsIgnoreCase(endpoint.getScheme()) || host.isBlank() + if (!"http".equalsIgnoreCase(endpoint.getScheme()) || host.isBlank() || isLoopbackHost(host) || (!listenerWildcard && !host.equalsIgnoreCase(listenerHost)) || port != settings.port() || endpoint.getUserInfo() != null || endpoint.getQuery() != null || endpoint.getFragment() != null || (path != null && !path.isEmpty() && !"/".equals(path))) { @@ -937,6 +935,11 @@ private static String normalizeHostLiteral(String host) { return normalized; } + private static boolean isLoopbackHost(String host) { + return "localhost".equalsIgnoreCase(host) || host.startsWith("127.") + || "::1".equalsIgnoreCase(host) || "0:0:0:0:0:0:0:1".equalsIgnoreCase(host); + } + static URI parseDownloadUri(String configured) { if (configured == null || configured.isBlank()) { return null; diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/HostedControlManagerTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/HostedControlManagerTest.java index 4dd9b3a6ad..7b1f4ce4cb 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/HostedControlManagerTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/HostedControlManagerTest.java @@ -237,6 +237,10 @@ void runningHostAcceptsVerifierOnlyBackendEnrollment() throws Exception { Files.write(jar, release); HostedControlManager.Settings settings = settings(jar, root.resolve("control/data"), false, false, digest(release), 30); + settings = new HostedControlManager.Settings(settings.rootDirectory(), settings.jarFile(), + settings.dataDirectory(), settings.autoDownload(), settings.autoUpdate(), settings.downloadUri(), + settings.sha256(), "10.0.0.5", 8080, settings.startupTimeoutSeconds(), + settings.downloadTimeoutSeconds()); AtomicInteger installations = new AtomicInteger(); ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); HostedControlManager manager = new HostedControlManager(settings, executor, @@ -245,13 +249,13 @@ void runningHostAcceptsVerifierOnlyBackendEnrollment() throws Exception { (endpoint, timeout, launchId) -> true, millis -> { }, System::nanoTime, null, message -> { }); manager.runOnce(); - assertTrue(manager.installNodeVerifier("survival", "", "http://127.0.0.1:8080") + assertTrue(manager.installNodeVerifier("survival", "", "http://10.0.0.5:8080") .get(5, TimeUnit.SECONDS)); assertEquals(0, installations.get()); - assertTrue(manager.installNodeVerifier("survival", "a".repeat(64), "http://127.0.0.1:8080") + assertTrue(manager.installNodeVerifier("survival", "a".repeat(64), "http://10.0.0.5:8080") .get(5, TimeUnit.SECONDS)); assertEquals(1, installations.get()); - assertFalse(manager.installNodeVerifier("../invalid", "a".repeat(64), "http://127.0.0.1:8080") + assertFalse(manager.installNodeVerifier("../invalid", "a".repeat(64), "http://10.0.0.5:8080") .get(5, TimeUnit.SECONDS)); assertFalse(manager.installNodeVerifier("survival", "b".repeat(64), "http://external.example:8080") .get(5, TimeUnit.SECONDS)); @@ -283,6 +287,8 @@ void wildcardHostProvesRequestedRouteReachesTheRunningLaunch() throws Exception .get(5, TimeUnit.SECONDS)); assertFalse(manager.installNodeVerifier("creative", "b".repeat(64), "http://external.example:2150") .get(5, TimeUnit.SECONDS)); + assertFalse(manager.installNodeVerifier("loopback", "c".repeat(64), "http://127.0.0.1:2150") + .get(5, TimeUnit.SECONDS)); assertEquals(1, installations.get()); manager.close(); } From ec15c72df955cab37406aa8f9b54c16cb32540ac Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 13:08:06 -0600 Subject: [PATCH 39/39] Document proxy backend endpoint requirements --- VotingPlugin/src/main/resources/Config.yml | 1 + docs/control-connector.md | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/VotingPlugin/src/main/resources/Config.yml b/VotingPlugin/src/main/resources/Config.yml index 80fe7aaa90..c566b4070a 100644 --- a/VotingPlugin/src/main/resources/Config.yml +++ b/VotingPlugin/src/main/resources/Config.yml @@ -1187,6 +1187,7 @@ Control: Enabled: false # Blank reuses BungeeSettings.Server, which must be unique for every backend. NodeId: '' + # For a proxy-hosted Control, use the proxy VM/private IP. Loopback is accepted only for Control hosted by this backend. Endpoint: 'http://127.0.0.1:8080' # VotingPlugin automatically generates this local credential only after confirming it can enroll through # the hosted Control on this server or the proxy's authenticated, route-verified plugin-message path. diff --git a/docs/control-connector.md b/docs/control-connector.md index c4b55e53de..5edc994883 100644 --- a/docs/control-connector.md +++ b/docs/control-connector.md @@ -157,6 +157,10 @@ Control: RequestTimeoutMillis: 10000 ``` +Use an address the backend itself can reach, normally the proxy VM/private IP. Proxy-mediated enrollment deliberately +rejects `localhost`, `127.0.0.0/8`, and IPv6 loopback because those addresses resolve to the backend rather than the proxy +when the processes run on different machines. + Blank `NodeId` reuses `BungeeSettings.Server`. The backend generates the raw credential locally and sends only its SHA-256 verifier over the plugin-message channel. Velocity/BungeeCord binds the request to the actual backend server connection, requires that identity to equal `BungeeSettings.Server`, installs the verifier in its hosted Control, and returns a