From 6c646e990917ed5795a550c1ab2d30184c0a5bb3 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 14:41:15 -0600 Subject: [PATCH 01/11] Add Control transport diagnostics and method switching --- .../messaging/BackendProxyMessageRouter.java | 8 +- .../control/BackendConfigurationService.java | 68 ++++++- .../control/BackendControlConnector.java | 2 +- .../votingplugin/proxy/VotingPluginProxy.java | 93 ++++++++- .../proxy/VotingPluginProxyConfig.java | 5 + .../votingplugin/proxy/VotingPluginWire.java | 10 + .../proxy/bungee/BungeeConfig.java | 38 ++++ .../proxy/control/ControlConnector.java | 190 +++++++++++++++--- .../control/ProxyMethodConfiguration.java | 30 +++ .../ProxyMethodConfigurationService.java | 132 ++++++++++++ .../proxy/velocity/VelocityConfig.java | 38 ++++ .../BackendConfigurationServiceTest.java | 25 +++ .../tests/VotingPluginProxyTest.java | 43 ++++ .../tests/VotingPluginProxyTestImpl.java | 4 + .../tests/VotingPluginWireTest.java | 14 ++ 15 files changed, 661 insertions(+), 39 deletions(-) create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfiguration.java create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationService.java 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 40b6cb689a..5cc4aaee28 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 @@ -72,11 +72,13 @@ public void register(GlobalMessageHandler messages, BungeeMethod method) { messages.addListener(new GlobalMessageListener(VotingPluginWire.SUB_VOTE_BROADCAST) { @Override public void onReceive(JsonEnvelope msg) { handleVoteBroadcast(msg); } }); - messages.addListener(new GlobalMessageListener("Status") { + messages.addListener(new GlobalMessageListener(VotingPluginWire.SUB_STATUS) { @Override public void onReceive(JsonEnvelope msg) { HashMap out = new HashMap<>(); - out.put("server", nvl(msg.getFields().get("server"))); - sendSubChannel(messages, "statusokay", out); + out.put(VotingPluginWire.K_SERVER, nvl(msg.getFields().get(VotingPluginWire.K_SERVER))); + String requestId = nvl(msg.getFields().get(VotingPluginWire.K_REQUEST_ID)); + if (!requestId.isEmpty()) out.put(VotingPluginWire.K_REQUEST_ID, requestId); + sendSubChannel(messages, VotingPluginWire.SUB_STATUS_OKAY, out); } }); messages.addListener(new GlobalMessageListener("ServerName") { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java index 891e465ba1..fa8886063d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java @@ -204,7 +204,8 @@ public ApplyResult applyQuickSetup(String preset, Map options, S } private static String quickSetupFile(String preset) { - if ("standalone".equals(preset) || "proxy-backend".equals(preset)) return "BungeeSettings.yml"; + if ("standalone".equals(preset) || "proxy-backend".equals(preset) + || "proxy-method".equals(preset)) return "BungeeSettings.yml"; if ("vote-site".equals(preset) || "easy-reward".equals(preset) || "sync-vote-sites".equals(preset)) return "VoteSites.yml"; if ("common-settings".equals(preset)) return "Config.yml"; @@ -231,6 +232,13 @@ private QuickProposal quickProposal(String preset, Map options, } return new QuickProposal(fileName, yaml.saveToString()); } + if ("proxy-method".equals(preset)) { + BungeeMethod method = BungeeMethod.valueOf(bungeeMethodOption(options)); + validateProxyMethod(method, yaml); + yaml.set("UseBungeecord", true); + yaml.set("BungeeMethod", method.name()); + return new QuickProposal(fileName, yaml.saveToString()); + } if ("vote-site".equals(preset)) { String name = option(options, "name", "[A-Za-z0-9_-]{1,64}"); String root = "VoteSites." + name; @@ -287,6 +295,64 @@ private QuickProposal quickProposal(String preset, Map options, throw new IllegalArgumentException("quick setup preset is unsupported"); } + private void validateProxyMethod(BungeeMethod method, YamlConfiguration settings) { + String server = settings.getString("Server", "").trim(); + if (server.isEmpty() || "PleaseSet".equalsIgnoreCase(server)) { + throw new IllegalArgumentException("Server must be set to this backend's unique proxy server name"); + } + switch (method) { + case PLUGINMESSAGING: + if (settings.getString("PluginMessageChannel", "").isBlank()) { + throw new IllegalArgumentException("PluginMessageChannel must be set"); + } + break; + case REDIS: + configuredHostAndPort(settings, "Redis.Host", "Redis.Port", "Redis"); + break; + case MQTT: + validBroker(settings.getString("MQTT.BrokerURL", "")); + String clientId = settings.getString("MQTT.ClientID", server); + if (clientId == null || clientId.isBlank()) throw new IllegalArgumentException("MQTT.ClientID must be set"); + break; + case SOCKETS: + configuredHostAndPort(settings, "BungeeServer.Host", "BungeeServer.Port", "BungeeServer"); + break; + case MYSQL: + try { + YamlConfiguration main = parse(readRaw(resolve("Config.yml"), false)); + if (!"MYSQL".equalsIgnoreCase(main.getString("DataStorage", "")) + || main.getString("Database.Host", "").isBlank() + || main.getString("Database.Database", "").isBlank()) { + throw new IllegalArgumentException("Config.yml DataStorage must be MYSQL with Database.Host and Database.Database set"); + } + } catch (IOException e) { + throw new IllegalArgumentException("Config.yml could not be checked for MYSQL settings"); + } + break; + default: + throw new IllegalArgumentException("BungeeMethod is unsupported"); + } + } + + private static void configuredHostAndPort(YamlConfiguration yaml, String hostPath, String portPath, String label) { + String host = yaml.getString(hostPath, ""); + int port = yaml.getInt(portPath, 0); + if (host == null || host.isBlank() || port < 1 || port > 65535) { + throw new IllegalArgumentException(label + " host and port must be set"); + } + } + + private static void validBroker(String value) { + try { + java.net.URI broker = java.net.URI.create(value == null ? "" : value.trim()); + if (!Set.of("tcp", "ssl", "ws", "wss").contains(broker.getScheme()) || broker.getHost() == null) { + throw new IllegalArgumentException(); + } + } catch (RuntimeException e) { + throw new IllegalArgumentException("MQTT.BrokerURL must be a valid tcp, ssl, ws, or wss endpoint"); + } + } + private static void mergeVoteSites(YamlConfiguration source, YamlConfiguration target) { ConfigurationSection sourceSites = source.getConfigurationSection("VoteSites"); if (sourceSites == null) throw new IllegalArgumentException("source VoteSites.yml has no VoteSites section"); 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 c9fa91f2c3..0a2b9353cf 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java @@ -45,7 +45,7 @@ public final class BackendControlConnector implements AutoCloseable { private static final long SHUTDOWN_TIMEOUT_SECONDS = 65; private static final Pattern NODE_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); private static final Set CAPABILITIES = Set.of("config.files.v1", "config.file-comments.v1", - "config.quick-setup.v1", "config.vote-sites-sync.v1"); + "config.quick-setup.v1", "config.vote-sites-sync.v1", "config.proxy-method.v1"); private final VotingPluginMain plugin; private final Path dataDirectory; 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 f00a6cf647..0286afe7be 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java @@ -26,6 +26,7 @@ import java.util.Map; import java.util.Set; import java.util.UUID; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -171,6 +172,7 @@ public abstract class VotingPluginProxy { private final Map pendingPresenceHandoffs = new HashMap<>(); private final Set pendingBackendRecoverySnapshots = ConcurrentHashMap.newKeySet(); private final Map controlEnrollmentNextAllowed = new ConcurrentHashMap<>(); + private final Map pendingCommunicationTests = new ConcurrentHashMap<>(); private volatile ControlConnector controlConnector; private volatile HostedControlManager hostedControlManager; private final Object controlLifecycleLock = new Object(); @@ -1521,11 +1523,10 @@ && isPresenceGenerationValid(snapshot.backendIncarnationId, snapshot.backendStar } }); - globalMessageProxyHandler.addListener(new GlobalMessageListener("statusokay") { + globalMessageProxyHandler.addListener(new GlobalMessageListener(VotingPluginWire.SUB_STATUS_OKAY) { @Override public void onReceive(JsonEnvelope message) { - String server = message.getFields().getOrDefault("server", ""); - log("Status okay for " + server); + handleStatusOkay(message); } }); @@ -2402,6 +2403,7 @@ public void prepareForRuntimeReplacement() { /** Best-effort remainder of runtime teardown after the Control overlap gate has succeeded. */ public void completeRuntimeReplacementShutdown() { + cancelCommunicationTests("Proxy runtime stopped before the backend replied"); runCleanup("vote cache", () -> getVoteCacheHandler().saveVoteCache()); runCleanup("proxy MySQL messenger", () -> { if (getProxyMysqlMessenger() != null) getProxyMysqlMessenger().shutdown(); @@ -2891,6 +2893,91 @@ public void status() { } } + /** Runs a correlated, non-vote round trip over the active backend transport. */ + public CompletableFuture testBackendCommunication(String requestedServer, + long timeoutMillis) { + String server = requestedServer == null ? "" : requestedServer.trim(); + BungeeMethod activeMethod = method; + if (server.isEmpty() || !getAllAvailableServers().contains(server)) { + return CompletableFuture.completedFuture(CommunicationTestResult.failure(server, activeMethod, + "UNKNOWN_BACKEND", "The backend is not configured on this proxy")); + } + if (activeMethod == null || globalMessageProxyHandler == null) { + return CompletableFuture.completedFuture(CommunicationTestResult.failure(server, activeMethod, + "TRANSPORT_UNAVAILABLE", "The proxy communication transport is not running")); + } + if (activeMethod == BungeeMethod.PLUGINMESSAGING && !isSomeoneOnlineServerForVoteRouting(server)) { + return CompletableFuture.completedFuture(CommunicationTestResult.failure(server, activeMethod, + "PLAYER_REQUIRED", "Plugin messaging requires an online player on the selected backend")); + } + ScheduledExecutorService scheduler = getScheduler(); + if (scheduler == null) { + return CompletableFuture.completedFuture(CommunicationTestResult.failure(server, activeMethod, + "TRANSPORT_UNAVAILABLE", "The proxy scheduler is not running")); + } + long boundedTimeout = Math.max(500L, Math.min(timeoutMillis, 30000L)); + UUID requestId = UUID.randomUUID(); + CompletableFuture result = new CompletableFuture<>(); + PendingCommunicationTest pending = new PendingCommunicationTest(server, activeMethod, System.nanoTime(), result); + pendingCommunicationTests.put(requestId, pending); + result.whenComplete((ignored, failure) -> pendingCommunicationTests.remove(requestId, pending)); + try { + scheduler.schedule(() -> result.complete(CommunicationTestResult.failure(server, activeMethod, + "TIMEOUT", "No correlated reply arrived before the timeout")), boundedTimeout, TimeUnit.MILLISECONDS); + globalMessageProxyHandler.sendMessage(server, 1, VotingPluginWire.status(server, requestId)); + } catch (RuntimeException failure) { + result.complete(CommunicationTestResult.failure(server, activeMethod, "SEND_FAILED", + "The proxy could not send the communication test")); + } + return result; + } + + protected void handleStatusOkay(JsonEnvelope message) { + String server = message.getFields().getOrDefault(VotingPluginWire.K_SERVER, ""); + String request = message.getFields().getOrDefault(VotingPluginWire.K_REQUEST_ID, ""); + if (request.isEmpty()) { + log("Status okay for " + server); + return; + } + UUID requestId; + try { + requestId = UUID.fromString(request); + } catch (IllegalArgumentException ignored) { + debug("Ignored status reply with an invalid request ID from " + server); + return; + } + PendingCommunicationTest pending = pendingCommunicationTests.get(requestId); + if (pending == null || !pending.server().equals(server)) { + debug("Ignored unexpected status reply from " + server); + return; + } + long roundTripMillis = Math.max(0L, + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - pending.startedAtNanos())); + pending.result().complete(CommunicationTestResult.success(server, pending.method(), roundTripMillis)); + } + + private void cancelCommunicationTests(String message) { + pendingCommunicationTests.forEach((requestId, pending) -> pending.result().complete( + CommunicationTestResult.failure(pending.server(), pending.method(), "TRANSPORT_STOPPED", message))); + pendingCommunicationTests.clear(); + } + + public record CommunicationTestResult(boolean success, String code, String message, String server, + String method, long roundTripMillis) { + private static CommunicationTestResult success(String server, BungeeMethod method, long roundTripMillis) { + return new CommunicationTestResult(true, "OK", "Backend replied over the active transport", server, + method == null ? "" : method.name(), roundTripMillis); + } + + private static CommunicationTestResult failure(String server, BungeeMethod method, String code, String message) { + return new CommunicationTestResult(false, code, message, server, + method == null ? "" : method.name(), -1L); + } + } + + private record PendingCommunicationTest(String server, BungeeMethod method, long startedAtNanos, + CompletableFuture result) { } + private void sendVoteDelayRejected(String player, String uuid, String service, boolean playerOnline, String playerServer) { if (!playerOnline || playerServer == null || !getAllAvailableServers().contains(playerServer)) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyConfig.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyConfig.java index b6ca6aec52..e481a3b65c 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyConfig.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyConfig.java @@ -22,6 +22,11 @@ default void persistControlProxyRouting(boolean sendVotesToAllServers, List BASE_CAPABILITIES = Set.of("presence.snapshot"); private static final String CONFIGURATION_CAPABILITY = "config.proxy-routing.v1"; + private static final String COMMUNICATION_TEST_CAPABILITY = "config.transport-test.v1"; + private static final String COMMUNICATION_TEST_PRESET = "communication-test"; + private static final String PROXY_METHOD_CAPABILITY = "config.proxy-method.v1"; + private static final String PROXY_METHOD_PRESET = "proxy-method"; private static final long MAX_BACKOFF_MILLIS = TimeUnit.MINUTES.toMillis(5); private static final long OPERATION_SHUTDOWN_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(65); @@ -64,6 +69,9 @@ public final class ControlConnector implements AutoCloseable { private final UUID sessionId; private final LongSupplier jitterSource; private final ProxyRoutingConfigurationService configurationService; + private final ProxyMethodConfigurationService methodConfigurationService; + private final Function> communicationTest; + private final Runnable runtimeReplacement; private final Path dataDirectory; private final Route route; private final boolean recovering; @@ -86,14 +94,14 @@ public ControlConnector(Settings settings, ScheduledExecutorService scheduler, T Supplier> snapshotSource, Consumer logger, UUID sessionId, LongSupplier jitterSource) { this(settings, scheduler, transport, snapshotSource, logger, sessionId, jitterSource, null, - null, null, false, null); + null, null, false, null, null, null, null); } ControlConnector(Settings settings, ScheduledExecutorService scheduler, Transport transport, Supplier> snapshotSource, Consumer logger, UUID sessionId, LongSupplier jitterSource, ProxyRoutingConfigurationService configurationService) { this(settings, scheduler, transport, snapshotSource, logger, sessionId, jitterSource, configurationService, - null, null, false, null); + null, null, false, null, null, null, null); } ControlConnector(Settings settings, ScheduledExecutorService scheduler, Transport transport, @@ -101,14 +109,16 @@ public ControlConnector(Settings settings, ScheduledExecutorService scheduler, T LongSupplier jitterSource, ProxyRoutingConfigurationService configurationService, Map recoveredTasks) { this(settings, scheduler, transport, snapshotSource, logger, sessionId, jitterSource, configurationService, - null, null, false, null); + null, null, false, null, null, null, null); completedTasks.putAll(recoveredTasks); } private ControlConnector(Settings settings, ScheduledExecutorService scheduler, Transport transport, Supplier> snapshotSource, Consumer logger, UUID sessionId, LongSupplier jitterSource, ProxyRoutingConfigurationService configurationService, Path dataDirectory, - Route route, boolean recovering, Runnable recoveryComplete) { + Route route, boolean recovering, Runnable recoveryComplete, + Function> communicationTest, + ProxyMethodConfigurationService methodConfigurationService, Runnable runtimeReplacement) { this.settings = Objects.requireNonNull(settings, "settings"); this.scheduler = Objects.requireNonNull(scheduler, "scheduler"); this.transport = Objects.requireNonNull(transport, "transport"); @@ -117,6 +127,9 @@ private ControlConnector(Settings settings, ScheduledExecutorService scheduler, this.sessionId = Objects.requireNonNull(sessionId, "sessionId"); this.jitterSource = Objects.requireNonNull(jitterSource, "jitterSource"); this.configurationService = configurationService; + this.methodConfigurationService = methodConfigurationService; + this.communicationTest = communicationTest; + this.runtimeReplacement = runtimeReplacement; this.dataDirectory = dataDirectory; this.route = route; this.recovering = recovering; @@ -175,7 +188,9 @@ public static ControlConnector create(VotingPluginProxy proxy) throws IOExceptio ControlConnector connector = new ControlConnector(settings, proxy.getScheduler(), transport, snapshot, message -> proxy.log("[Control] " + message), UUID.randomUUID(), () -> ThreadLocalRandom.current().nextLong(), new ProxyRoutingConfigurationService(proxy), dataDirectory, - route, recovering, proxy::restartControlServicesAfterRecovery); + route, recovering, proxy::restartControlServicesAfterRecovery, + server -> proxy.testBackendCommunication(server, 5000L), new ProxyMethodConfigurationService(proxy), + () -> proxy.reloadCore(true)); if (recovered != null) connector.completedTasks.putAll(recovered.results()); return connector; } @@ -322,7 +337,9 @@ private void handlePrimaryResponse(Response response, boolean registration) { if (!contains(accepted, "presence.snapshot")) { throw new ProtocolException(); } - configurationAccepted = contains(accepted, CONFIGURATION_CAPABILITY); + configurationAccepted = contains(accepted, CONFIGURATION_CAPABILITY) + || contains(accepted, COMMUNICATION_TEST_CAPABILITY) + || contains(accepted, PROXY_METHOD_CAPABILITY); } } @@ -446,14 +463,14 @@ private CompletableFuture handleClaimResponse(Response response) { } } if (result == null) { - TaskResult executed = executeTask(operationId, task); - JsonObject resultJson = executed.json(); - resultJson.addProperty("attemptId", requireString(task, "attemptId")); - result = new StoredResult(resultJson, true, false); - synchronized (operationLifecycle) { - completedTasks.put(operationId, result); - } - persistCompleted(); + return executeTask(operationId, task).thenCompose(executed -> { + JsonObject resultJson = executed.json(); + resultJson.addProperty("attemptId", requireString(task, "attemptId")); + StoredResult completed = new StoredResult(resultJson, true, false); + synchronized (operationLifecycle) { completedTasks.put(operationId, completed); } + persistCompleted(); + return submitCompletedResult(operationId, completed); + }); } return submitCompletedResult(operationId, result); } @@ -505,8 +522,20 @@ private void acknowledgeCompletedResult(UUID operationId, StoredResult result) { throw failure; } boolean drained; - synchronized (operationLifecycle) { drained = completedTasks.isEmpty(); } - if (recovering && drained && recoveryComplete != null) recoveryComplete.run(); + boolean replaceRuntime = requiresRuntimeReplacement(result) && runtimeReplacement != null; + synchronized (operationLifecycle) { + drained = completedTasks.isEmpty(); + if (replaceRuntime) deferredReplacement = runtimeReplacement; + } + if (recovering && drained && recoveryComplete != null && !replaceRuntime) recoveryComplete.run(); + } + + private static boolean requiresRuntimeReplacement(StoredResult result) { + JsonObject body = result.result(); + if (!body.has("success") || !body.get("success").getAsBoolean() || !body.has("configuration")) return false; + JsonObject configuration = body.getAsJsonObject("configuration"); + return configuration != null && configuration.has("preset") + && PROXY_METHOD_PRESET.equals(configuration.get("preset").getAsString()); } private static boolean taskLeaseExpired(Response response) { @@ -576,7 +605,13 @@ private static StoredResult abortedIntent(StoredResult pending) { private boolean anticipatedResultIsInstalled(StoredResult pending) { JsonObject result = pending.result(); - return result.has("revision") && result.get("revision").getAsString().equals(configurationService.read().revision()); + if (!result.has("revision")) return false; + JsonObject configuration = result.getAsJsonObject("configuration"); + if (configuration != null && isProxyMethod(configuration) && methodConfigurationService != null) { + return result.get("revision").getAsString().equals(methodConfigurationService.read().revision()); + } + return configurationService != null + && result.get("revision").getAsString().equals(configurationService.read().revision()); } private static StoredResult committedForAttempt(StoredResult pending, String attemptId) { @@ -585,34 +620,121 @@ private static StoredResult committedForAttempt(StoredResult pending, String att return new StoredResult(result, true, false); } - private TaskResult executeTask(UUID operationId, JsonObject task) { - if (configurationService == null) return TaskResult.failure("UNSUPPORTED", "Configuration control is unavailable"); + private CompletableFuture executeTask(UUID operationId, JsonObject task) { + JsonObject requested = task.getAsJsonObject("configuration"); + if (isCommunicationTest(requested)) return executeCommunicationTest(task, requested); + if (isProxyMethod(requested)) return executeProxyMethod(operationId, task, requested); + if (configurationService == null) return completed(TaskResult.failure("UNSUPPORTED", "Configuration control is unavailable")); String type = requireString(task, "type"); try { ProxyRoutingConfiguration current = configurationService.read(); - if ("READ".equals(type)) return TaskResult.success(current.revision(), current, List.of(), false); + if ("READ".equals(type)) return completed(TaskResult.success(current.revision(), current, List.of(), false)); ProxyRoutingConfiguration proposal = parseConfiguration(task.getAsJsonObject("configuration")); configurationService.validate(proposal); List changes = proposal.changesFrom(current); - if ("PREVIEW".equals(type)) return TaskResult.success(current.revision(), current, changes, false); - if (!"APPLY".equals(type)) return TaskResult.failure("UNSUPPORTED_TASK", "Task type is unsupported"); + if ("PREVIEW".equals(type)) return completed(TaskResult.success(current.revision(), current, changes, false)); + if (!"APPLY".equals(type)) return completed(TaskResult.failure("UNSUPPORTED_TASK", "Task type is unsupported")); persistIntent(operationId, TaskResult.success(proposal.revision(), proposal, changes, true), requireString(task, "attemptId")); configurationService.apply(proposal, requireString(task, "expectedRevision")); ProxyRoutingConfiguration applied = configurationService.read(); - return TaskResult.success(applied.revision(), applied, changes, true); + return completed(TaskResult.success(applied.revision(), applied, changes, true)); } catch (ProxyRoutingConfigurationService.StaleRevisionException e) { - return TaskResult.failure("STALE_REVISION", "Configuration changed after preview"); + return completed(TaskResult.failure("STALE_REVISION", "Configuration changed after preview")); } catch (ProxyRoutingConfigurationService.ApplyFailureException e) { - return new TaskResult(false, "RELOAD_FAILED", "Reload failed after persistence", null, null, List.of(), - false, e.rolledBack()); + return completed(new TaskResult(false, "RELOAD_FAILED", "Reload failed after persistence", null, null, + List.of(), false, e.rolledBack())); + } catch (IllegalArgumentException e) { + return completed(TaskResult.failure("VALIDATION_ERROR", e.getMessage())); + } catch (IOException | RuntimeException e) { + return completed(TaskResult.failure("APPLY_FAILED", "Configuration operation failed")); + } + } + + private CompletableFuture executeProxyMethod(UUID operationId, JsonObject task, JsonObject requested) { + if (methodConfigurationService == null) { + return completed(TaskResult.failure("UNSUPPORTED", "Proxy method control is unavailable")); + } + String type = requireString(task, "type"); + try { + JsonObject options = requested.getAsJsonObject("options"); + ProxyMethodConfiguration proposal = new ProxyMethodConfiguration( + ProxyMethodConfigurationService.canonical(requireString(options, "method"))); + ProxyMethodConfiguration current = methodConfigurationService.read(); + methodConfigurationService.validate(proposal); + List changes = proposal.changesFrom(current); + if ("READ".equals(type)) { + JsonObject response = requested.deepCopy(); + response.getAsJsonObject("options").addProperty("method", current.method().name()); + return completed(TaskResult.success(current.revision(), response, List.of(), false, + "Current proxy method is " + current.method().name())); + } + if ("PREVIEW".equals(type)) return completed(TaskResult.success(current.revision(), requested.deepCopy(), + changes, false, "Required settings are present; runtime restart will follow apply")); + if (!"APPLY".equals(type)) return completed(TaskResult.failure("UNSUPPORTED_TASK", "Task type is unsupported")); + persistIntent(operationId, TaskResult.success(proposal.revision(), requested.deepCopy(), changes, false, + "Proxy method persisted; runtime restart is waiting for result acknowledgement"), + requireString(task, "attemptId")); + methodConfigurationService.apply(proposal, requireString(task, "expectedRevision")); + return completed(TaskResult.success(proposal.revision(), requested.deepCopy(), changes, false, + "Proxy method persisted; runtime restart follows acknowledgement")); + } catch (ProxyMethodConfigurationService.StaleRevisionException e) { + return completed(TaskResult.failure("STALE_REVISION", "Proxy method changed after preview")); + } catch (ProxyMethodConfigurationService.ApplyFailureException e) { + return completed(new TaskResult(false, "APPLY_FAILED", "Proxy method persistence failed", null, null, + List.of(), false, e.rolledBack())); } catch (IllegalArgumentException e) { - return TaskResult.failure("VALIDATION_ERROR", e.getMessage()); + return completed(TaskResult.failure("VALIDATION_ERROR", e.getMessage())); } catch (IOException | RuntimeException e) { - return TaskResult.failure("APPLY_FAILED", "Configuration operation failed"); + return completed(TaskResult.failure("APPLY_FAILED", "Proxy method operation failed")); } } + private CompletableFuture executeCommunicationTest(JsonObject task, JsonObject requested) { + if (!"READ".equals(requireString(task, "type"))) { + return completed(TaskResult.failure("UNSUPPORTED_TASK", "Communication tests are read-only")); + } + if (communicationTest == null || configurationService == null) { + return completed(TaskResult.failure("UNSUPPORTED", "Communication testing is unavailable")); + } + JsonObject options = requested == null ? null : requested.getAsJsonObject("options"); + String server = options == null ? "" : requireString(options, "server").trim(); + if (server.isEmpty() || server.length() > 100) { + return completed(TaskResult.failure("VALIDATION_ERROR", "A valid backend server is required")); + } + String revision; + try { + revision = configurationService.read().revision(); + } catch (RuntimeException failure) { + return completed(TaskResult.failure("READ_FAILED", "Could not read the active proxy configuration")); + } + return communicationTest.apply(server).handle((result, failure) -> { + if (failure != null || result == null) { + return TaskResult.failure("TEST_FAILED", "The communication test could not complete"); + } + if (!result.success()) return TaskResult.failure(result.code(), result.message()); + String summary = result.server() + " replied via " + result.method() + " in " + + result.roundTripMillis() + " ms"; + return TaskResult.success(revision, requested.deepCopy(), List.of(summary), false, result.message()); + }); + } + + private static boolean isCommunicationTest(JsonObject requested) { + return requested != null && requested.has("domain") && requested.has("preset") + && "quick-setup".equals(requested.get("domain").getAsString()) + && COMMUNICATION_TEST_PRESET.equals(requested.get("preset").getAsString()); + } + + private static boolean isProxyMethod(JsonObject requested) { + return requested != null && requested.has("domain") && requested.has("preset") + && "quick-setup".equals(requested.get("domain").getAsString()) + && PROXY_METHOD_PRESET.equals(requested.get("preset").getAsString()); + } + + private static CompletableFuture completed(TaskResult result) { + return CompletableFuture.completedFuture(result); + } + private static ProxyRoutingConfiguration parseConfiguration(JsonObject body) { if (body == null || !body.has("sendVotesToAllServers") || !body.has("blockedServers")) { throw new IllegalArgumentException("configuration is incomplete"); @@ -675,6 +797,8 @@ private void addCapabilities(JsonObject body) { JsonArray advertised = new JsonArray(); BASE_CAPABILITIES.stream().sorted().forEach(advertised::add); if (configurationService != null) advertised.add(CONFIGURATION_CAPABILITY); + if (communicationTest != null) advertised.add(COMMUNICATION_TEST_CAPABILITY); + if (methodConfigurationService != null) advertised.add(PROXY_METHOD_CAPABILITY); body.add("capabilities", advertised); JsonArray required = new JsonArray(); required.add("presence.snapshot"); @@ -818,14 +942,14 @@ public record Request(String method, String path, String body) { } public record Response(int statusCode, String body) { } private record TaskResult(boolean success, String code, String message, String revision, - ProxyRoutingConfiguration configuration, List changes, boolean reloaded, boolean rolledBack) { + JsonObject configuration, List changes, boolean reloaded, boolean rolledBack) { private JsonObject json() { JsonObject body = new JsonObject(); body.addProperty("success", success); body.addProperty("code", code); body.addProperty("message", message); if (revision != null) body.addProperty("revision", revision); - if (configuration != null) body.add("configuration", configurationJson(configuration)); + if (configuration != null) body.add("configuration", configuration); JsonArray listed = new JsonArray(); changes.forEach(listed::add); body.add("changes", listed); @@ -836,7 +960,11 @@ private JsonObject json() { private static TaskResult success(String revision, ProxyRoutingConfiguration configuration, List changes, boolean reloaded) { - return new TaskResult(true, "OK", "Operation completed", revision, configuration, changes, reloaded, false); + return success(revision, configurationJson(configuration), changes, reloaded, "Operation completed"); + } + private static TaskResult success(String revision, JsonObject configuration, List changes, + boolean reloaded, String message) { + return new TaskResult(true, "OK", message, revision, configuration, changes, reloaded, false); } private static TaskResult failure(String code, String message) { return new TaskResult(false, code, message == null ? "Operation failed" : message, null, null, List.of(), false, false); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfiguration.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfiguration.java new file mode 100644 index 0000000000..d7fe1c6e12 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfiguration.java @@ -0,0 +1,30 @@ +package com.bencodez.votingplugin.proxy.control; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.List; + +import com.bencodez.votingplugin.proxy.BungeeMethod; + +/** Small, revisioned proxy transport selection exposed to Control. */ +public record ProxyMethodConfiguration(BungeeMethod method) { + public ProxyMethodConfiguration { + if (method == null) throw new IllegalArgumentException("proxy method is required"); + } + + public String revision() { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(method.name().getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is unavailable", e); + } + } + + public List changesFrom(ProxyMethodConfiguration current) { + return method == current.method ? List.of() : List.of("change proxy method from " + + current.method.name() + " to " + method.name()); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationService.java new file mode 100644 index 0000000000..780d31b25b --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationService.java @@ -0,0 +1,132 @@ +package com.bencodez.votingplugin.proxy.control; + +import java.io.IOException; +import java.net.URI; +import java.util.Map; +import java.util.Set; + +import com.bencodez.votingplugin.proxy.BungeeMethod; +import com.bencodez.votingplugin.proxy.VotingPluginProxy; +import com.bencodez.votingplugin.proxy.VotingPluginProxyConfig; +import com.bencodez.votingplugin.util.DurableFiles; + +/** Preflights and atomically persists proxy transport-method changes. */ +public final class ProxyMethodConfigurationService { + private final VotingPluginProxy proxy; + + public ProxyMethodConfigurationService(VotingPluginProxy proxy) { + this.proxy = proxy; + } + + public ProxyMethodConfiguration read() { + return new ProxyMethodConfiguration(canonical(proxy.getConfig().getBungeeMethod())); + } + + public void validate(ProxyMethodConfiguration proposal) { + VotingPluginProxyConfig config = proxy.getConfig(); + switch (proposal.method()) { + case PLUGINMESSAGING: + if (blank(config.getPluginMessageChannel())) { + throw new IllegalArgumentException("PluginMessageChannel must be set"); + } + break; + case REDIS: + validHostPort(config.getRedisHost(), config.getRedisPort(), "Redis"); + break; + case MQTT: + validBroker(config.getMqttBrokerURL()); + if (blank(config.getMqttClientID())) throw new IllegalArgumentException("MQTT.ClientID must be set"); + break; + case SOCKETS: + if (config.getBungeePort() < 1 || config.getBungeePort() > 65535) { + throw new IllegalArgumentException("BungeeServer.Port must be set"); + } + for (String server : proxy.getAllConfiguredServers()) { + Map backend = config.getSpigotServerConfiguration(server); + Object host = backend.get("Host"); + Object port = backend.get("Port"); + if (host == null || blank(host.toString()) || !(port instanceof Number) + || ((Number) port).intValue() < 1 || ((Number) port).intValue() > 65535) { + throw new IllegalArgumentException("SpigotServers." + server + " Host and Port must be set for SOCKETS"); + } + } + break; + case MYSQL: + if (!config.hasDatabaseConfigured()) { + throw new IllegalArgumentException("The proxy database Host must be configured for MYSQL"); + } + break; + default: + throw new IllegalArgumentException("proxy method is unsupported"); + } + } + + public void apply(ProxyMethodConfiguration proposal, String expectedRevision) throws IOException { + validate(proposal); + if (expectedRevision == null || !read().revision().equals(expectedRevision)) { + throw new StaleRevisionException(); + } + try { + proxy.getConfig().persistControlProxyMethod(proposal.method().name(), expectedRevision); + proxy.getConfig().verifyControlProxyRoutingInstalled(); + } catch (VotingPluginProxyConfig.StaleControlRevisionException e) { + throw new StaleRevisionException(); + } catch (DurableFiles.PublishedException published) { + throw rollbackAfterFailure(published); + } catch (IOException failure) { + throw failure; + } catch (RuntimeException failure) { + throw rollbackAfterFailure(failure); + } + } + + private ApplyFailureException rollbackAfterFailure(Exception failure) { + boolean rolledBack = false; + try { + proxy.getConfig().rollbackControlProxyRouting(); + rolledBack = true; + } catch (Exception rollbackFailure) { + failure.addSuppressed(rollbackFailure); + } + return new ApplyFailureException(rolledBack, failure); + } + + public static BungeeMethod canonical(String value) { + if (value == null) throw new IllegalArgumentException("proxy method is required"); + try { + return BungeeMethod.valueOf(value.trim().toUpperCase(java.util.Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("proxy method is unsupported"); + } + } + + private static void validHostPort(String host, int port, String label) { + if (blank(host) || port < 1 || port > 65535) { + throw new IllegalArgumentException(label + " host and port must be set"); + } + } + + private static void validBroker(String value) { + try { + URI broker = URI.create(value == null ? "" : value.trim()); + if (!Set.of("tcp", "ssl", "ws", "wss").contains(broker.getScheme()) || broker.getHost() == null) { + throw new IllegalArgumentException(); + } + } catch (RuntimeException e) { + throw new IllegalArgumentException("MQTT.BrokerURL must be a valid tcp, ssl, ws, or wss endpoint"); + } + } + + private static boolean blank(String value) { + return value == null || value.isBlank(); + } + + @SuppressWarnings("serial") + public static final class StaleRevisionException extends RuntimeException { } + @SuppressWarnings("serial") + public static final class ApplyFailureException extends RuntimeException { + private final boolean rolledBack; + private ApplyFailureException(boolean rolledBack, Throwable cause) { super(cause); this.rolledBack = rolledBack; } + public boolean rolledBack() { return rolledBack; } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfig.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfig.java index 1e60a509ec..295a6b468d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfig.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfig.java @@ -21,6 +21,8 @@ import com.bencodez.simpleapi.file.velocity.VelocityYMLFile; import com.bencodez.votingplugin.proxy.VotingPluginProxyConfig; +import com.bencodez.votingplugin.proxy.control.ProxyMethodConfiguration; +import com.bencodez.votingplugin.proxy.control.ProxyMethodConfigurationService; import com.bencodez.votingplugin.proxy.control.ProxyRoutingConfiguration; import com.bencodez.votingplugin.util.DurableFiles; @@ -92,6 +94,42 @@ private static ProxyRoutingConfiguration routing(ConfigurationNode configuration configuration.node("BlockedServers").getList(String.class, List.of())); } + @Override + public synchronized void persistControlProxyMethod(String method, String expectedRevision) throws IOException { + Path target = configurationFile.toPath(); + Path stage = Files.createTempFile(target.getParent(), target.getFileName().toString(), ".control-stage"); + Path backupStage = null; + Path backup = target.resolveSibling(target.getFileName() + ".control-backup"); + YamlConfigurationLoader sourceLoader = YamlConfigurationLoader.builder().path(target).build(); + try { + backupStage = Files.createTempFile(target.getParent(), target.getFileName().toString(), ".control-backup-stage"); + byte[] sourceSnapshot = Files.readAllBytes(target); + ConfigurationNode latest = sourceLoader.load(); + ProxyMethodConfiguration current = new ProxyMethodConfiguration(ProxyMethodConfigurationService.canonical( + latest.node("BungeeMethod").getString("PLUGINMESSAGING"))); + if (!java.util.Arrays.equals(sourceSnapshot, Files.readAllBytes(target)) + || !current.revision().equals(expectedRevision)) throw new StaleControlRevisionException(); + latest.node("BungeeMethod").set(method); + YamlConfigurationLoader.builder().path(stage).build().save(latest); + byte[] installedSnapshot = Files.readAllBytes(stage); + if (!java.util.Arrays.equals(sourceSnapshot, Files.readAllBytes(target))) throw new StaleControlRevisionException(); + Files.write(backupStage, sourceSnapshot); + if (!java.util.Arrays.equals(sourceSnapshot, Files.readAllBytes(target))) throw new StaleControlRevisionException(); + atomicReplace(backupStage, backup); + if (!java.util.Arrays.equals(sourceSnapshot, Files.readAllBytes(target))) throw new StaleControlRevisionException(); + controlInstalledSnapshot = installedSnapshot; + try { + atomicReplace(stage, target); + } catch (IOException failure) { + if (!(failure instanceof DurableFiles.PublishedException)) controlInstalledSnapshot = null; + throw failure; + } + } finally { + Files.deleteIfExists(stage); + if (backupStage != null) Files.deleteIfExists(backupStage); + } + } + @Override public synchronized void rollbackControlProxyRouting() throws IOException { Path target = configurationFile.toPath(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java index 350d15827e..fec3730657 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java @@ -272,6 +272,31 @@ class BackendConfigurationServiceTest { assertTrue(mqtt.proposal().content().contains("BungeeMethod: MQTT")); } + @Test void proxyMethodSwitchPreflightsRequiredBackendSettings() throws Exception { + Path settings = directory.resolve("BungeeSettings.yml"); + Files.writeString(settings, "UseBungeecord: true\nServer: lobby\nBungeeMethod: PLUGINMESSAGING\n" + + "PluginMessageChannel: vp:vp\nRedis:\n Host: ''\n Port: 6379\n" + + "MQTT:\n ClientID: lobby\n BrokerURL: bad\nBungeeServer:\n Host: ''\n Port: 1297\n"); + Files.writeString(directory.resolve("Config.yml"), + "DataStorage: SQLITE\nDatabase:\n Host: ''\n Database: ''\n"); + BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); + + assertThrows(IllegalArgumentException.class, + () -> service.previewQuickSetup("proxy-method", Map.of("method", "REDIS"))); + assertThrows(IllegalArgumentException.class, + () -> service.previewQuickSetup("proxy-method", Map.of("method", "MQTT"))); + assertThrows(IllegalArgumentException.class, + () -> service.previewQuickSetup("proxy-method", Map.of("method", "SOCKETS"))); + assertThrows(IllegalArgumentException.class, + () -> service.previewQuickSetup("proxy-method", Map.of("method", "MYSQL"))); + + Files.writeString(settings, Files.readString(settings).replace("Host: ''", "Host: localhost") + .replace("BrokerURL: bad", "BrokerURL: tcp://localhost:1883")); + BackendConfigurationService.QuickPreview redis = service.previewQuickSetup("proxy-method", + Map.of("method", "REDIS")); + assertTrue(redis.proposal().content().contains("BungeeMethod: REDIS")); + } + @Test void voteSitesSyncAddsAndUpdatesDefinitionsWithoutTouchingRewardsOrTargetOnlySites() throws Exception { Path voteSites = directory.resolve("VoteSites.yml"); Files.writeString(voteSites, "VoteSites:\n" diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java index f507d910e2..acbd218ffa 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java @@ -124,6 +124,49 @@ void immediatePluginMessageReportsActualDeliveryResult() { VotingPluginWire.voteBroadcast("uuid", "Player", "Service", 100L, "", false))); } + @Test + void communicationTestCompletesOnlyForTheCorrelatedBackendReply() throws Exception { + votingPluginProxy.setMethod(BungeeMethod.MQTT); + java.util.concurrent.ScheduledExecutorService scheduler = Mockito + .mock(java.util.concurrent.ScheduledExecutorService.class); + com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler messageHandler = Mockito + .mock(com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler.class); + votingPluginProxy.setSchedulerForTest(scheduler); + votingPluginProxy.setGlobalMessageProxyHandlerForTest(messageHandler); + org.mockito.ArgumentCaptor sent = org.mockito.ArgumentCaptor + .forClass(com.bencodez.simpleapi.servercomm.codec.JsonEnvelope.class); + + java.util.concurrent.CompletableFuture result = votingPluginProxy + .testBackendCommunication("Server1", 5000L); + verify(messageHandler).sendMessage(Mockito.eq("Server1"), Mockito.eq(1), sent.capture()); + String requestId = sent.getValue().getFields().get(VotingPluginWire.K_REQUEST_ID); + assertFalse(result.isDone()); + + votingPluginProxy.handleStatusOkayForTest(VotingPluginWire.statusOkay("Server2", + java.util.UUID.fromString(requestId))); + assertFalse(result.isDone()); + votingPluginProxy.handleStatusOkayForTest(VotingPluginWire.statusOkay("Server1", + java.util.UUID.fromString(requestId))); + + VotingPluginProxy.CommunicationTestResult completed = result.get(); + assertTrue(completed.success()); + assertEquals("Server1", completed.server()); + assertEquals("MQTT", completed.method()); + assertTrue(completed.roundTripMillis() >= 0L); + } + + @Test + void pluginMessagingCommunicationTestExplainsOnlinePlayerRequirement() throws Exception { + votingPluginProxy.setMethod(BungeeMethod.PLUGINMESSAGING); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(false).when(spyProxy).isSomeoneOnlineServer(Mockito.eq("Server1")); + + VotingPluginProxy.CommunicationTestResult result = spyProxy.testBackendCommunication("Server1", 5000L).get(); + + assertFalse(result.success()); + assertEquals("PLAYER_REQUIRED", result.code()); + } + @Test void pluginMessagingLegacyLoginUsesTheProxyCurrentServerAndUuid() { String uuid = java.util.UUID.randomUUID().toString(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTestImpl.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTestImpl.java index 2bfbe1e7c6..67f62b061c 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTestImpl.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTestImpl.java @@ -214,6 +214,10 @@ public void handleLoginMessageForTest(JsonEnvelope envelope) { handleLoginMessage(envelope); } + public void handleStatusOkayForTest(JsonEnvelope envelope) { + handleStatusOkay(envelope); + } + public void setGlobalMessageProxyHandlerForTest(GlobalMessageProxyHandler handler) { try { java.lang.reflect.Field field = VotingPluginProxy.class.getDeclaredField("globalMessageProxyHandler"); 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 e8e59cba4e..7aacf8ccae 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java @@ -209,6 +209,20 @@ public void blankVerifierIsAValidHostedRoutePreflight() { assertEquals("", request.verifier); } + @Test + public void statusRoundTripCarriesCorrelationId() { + UUID requestId = UUID.randomUUID(); + + JsonEnvelope request = VotingPluginWire.status("survival", requestId); + JsonEnvelope response = VotingPluginWire.statusOkay("survival", requestId); + + assertEquals(VotingPluginWire.SUB_STATUS, request.getSubChannel()); + assertEquals("survival", request.getFields().get(VotingPluginWire.K_SERVER)); + assertEquals(requestId.toString(), request.getFields().get(VotingPluginWire.K_REQUEST_ID)); + assertEquals(VotingPluginWire.SUB_STATUS_OKAY, response.getSubChannel()); + assertEquals(requestId.toString(), response.getFields().get(VotingPluginWire.K_REQUEST_ID)); + } + @Test public void malformedControlEnrollmentIsRejected() { JsonEnvelope malformed = JsonEnvelope.builder(VotingPluginWire.SUB_CONTROL_ENROLLMENT_REQUEST) From 836f8e7f4c29af570071e82f545fac3fd434dd18 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 14:43:17 -0600 Subject: [PATCH 02/11] Initialize transport in plugin messaging diagnostic test --- .../com/bencodez/votingplugin/tests/VotingPluginProxyTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java index acbd218ffa..dfaa1449dc 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java @@ -158,6 +158,8 @@ void communicationTestCompletesOnlyForTheCorrelatedBackendReply() throws Excepti @Test void pluginMessagingCommunicationTestExplainsOnlinePlayerRequirement() throws Exception { votingPluginProxy.setMethod(BungeeMethod.PLUGINMESSAGING); + votingPluginProxy.setGlobalMessageProxyHandlerForTest(Mockito + .mock(com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler.class)); VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); Mockito.doReturn(false).when(spyProxy).isSomeoneOnlineServer(Mockito.eq("Server1")); From 1e2343c50d510def7d891ce0b88b2bfd225a3827 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 14:44:56 -0600 Subject: [PATCH 03/11] Allow identity-free plugin messaging preflight --- .../control/BackendConfigurationService.java | 3 ++- .../control/BackendConfigurationServiceTest.java | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java index fa8886063d..6e2df88efa 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java @@ -297,7 +297,8 @@ private QuickProposal quickProposal(String preset, Map options, private void validateProxyMethod(BungeeMethod method, YamlConfiguration settings) { String server = settings.getString("Server", "").trim(); - if (server.isEmpty() || "PleaseSet".equalsIgnoreCase(server)) { + if (method != BungeeMethod.PLUGINMESSAGING + && (server.isEmpty() || "PleaseSet".equalsIgnoreCase(server))) { throw new IllegalArgumentException("Server must be set to this backend's unique proxy server name"); } switch (method) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java index fec3730657..e04b207e00 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java @@ -297,6 +297,17 @@ class BackendConfigurationServiceTest { assertTrue(redis.proposal().content().contains("BungeeMethod: REDIS")); } + @Test void pluginMessagingMethodDoesNotRequireStandaloneBackendIdentity() throws Exception { + Files.writeString(directory.resolve("BungeeSettings.yml"), + "UseBungeecord: true\nServer: PleaseSet\nBungeeMethod: REDIS\nPluginMessageChannel: vp:vp\n"); + BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); + + BackendConfigurationService.QuickPreview preview = service.previewQuickSetup("proxy-method", + Map.of("method", "PLUGINMESSAGING")); + + assertTrue(preview.proposal().content().contains("BungeeMethod: PLUGINMESSAGING")); + } + @Test void voteSitesSyncAddsAndUpdatesDefinitionsWithoutTouchingRewardsOrTargetOnlySites() throws Exception { Path voteSites = directory.resolve("VoteSites.yml"); Files.writeString(voteSites, "VoteSites:\n" From 9df6143f2d1a4dfe6b1f38f28322610db2c0e275 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 14:45:51 -0600 Subject: [PATCH 04/11] Cover proxy method preflight and persistence --- .../ProxyMethodConfigurationServiceTest.java | 66 +++++++++++++++++++ .../velocity/VelocityConfigControlTest.java | 19 ++++++ 2 files changed, 85 insertions(+) create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationServiceTest.java diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationServiceTest.java new file mode 100644 index 0000000000..89df34666c --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationServiceTest.java @@ -0,0 +1,66 @@ +package com.bencodez.votingplugin.proxy.control; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.bencodez.votingplugin.proxy.BungeeMethod; +import com.bencodez.votingplugin.proxy.VotingPluginProxy; +import com.bencodez.votingplugin.proxy.VotingPluginProxyConfig; + +class ProxyMethodConfigurationServiceTest { + private VotingPluginProxy proxy; + private VotingPluginProxyConfig config; + private ProxyMethodConfigurationService service; + + @BeforeEach + void setUp() { + proxy = mock(VotingPluginProxy.class); + config = mock(VotingPluginProxyConfig.class); + when(proxy.getConfig()).thenReturn(config); + when(proxy.getAllConfiguredServers()).thenReturn(Set.of("lobby")); + service = new ProxyMethodConfigurationService(proxy); + } + + @Test + void validatesRequiredSettingsForEveryTransport() { + assertThrows(IllegalArgumentException.class, + () -> service.validate(new ProxyMethodConfiguration(BungeeMethod.PLUGINMESSAGING))); + when(config.getPluginMessageChannel()).thenReturn("vp:vp"); + assertDoesNotThrow(() -> service.validate(new ProxyMethodConfiguration(BungeeMethod.PLUGINMESSAGING))); + + assertThrows(IllegalArgumentException.class, + () -> service.validate(new ProxyMethodConfiguration(BungeeMethod.REDIS))); + when(config.getRedisHost()).thenReturn("localhost"); + when(config.getRedisPort()).thenReturn(6379); + assertDoesNotThrow(() -> service.validate(new ProxyMethodConfiguration(BungeeMethod.REDIS))); + + when(config.getMqttBrokerURL()).thenReturn("not-a-broker"); + assertThrows(IllegalArgumentException.class, + () -> service.validate(new ProxyMethodConfiguration(BungeeMethod.MQTT))); + when(config.getMqttBrokerURL()).thenReturn("tcp://localhost:1883"); + when(config.getMqttClientID()).thenReturn("proxy"); + assertDoesNotThrow(() -> service.validate(new ProxyMethodConfiguration(BungeeMethod.MQTT))); + + when(config.getBungeePort()).thenReturn(1297); + when(config.getSpigotServerConfiguration("lobby")).thenReturn(Map.of("Host", "localhost", "Port", 1298)); + assertDoesNotThrow(() -> service.validate(new ProxyMethodConfiguration(BungeeMethod.SOCKETS))); + + assertThrows(IllegalArgumentException.class, + () -> service.validate(new ProxyMethodConfiguration(BungeeMethod.MYSQL))); + when(config.hasDatabaseConfigured()).thenReturn(true); + assertDoesNotThrow(() -> service.validate(new ProxyMethodConfiguration(BungeeMethod.MYSQL))); + } + + @Test + void rejectsUnknownMethodNamesInsteadOfFallingBack() { + assertThrows(IllegalArgumentException.class, () -> ProxyMethodConfigurationService.canonical("unknown")); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfigControlTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfigControlTest.java index 13660a25ab..29862adab0 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfigControlTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfigControlTest.java @@ -1,5 +1,6 @@ package com.bencodez.votingplugin.proxy.velocity; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -12,6 +13,8 @@ import org.junit.jupiter.api.io.TempDir; import com.bencodez.votingplugin.proxy.control.ProxyRoutingConfiguration; +import com.bencodez.votingplugin.proxy.control.ProxyMethodConfiguration; +import com.bencodez.votingplugin.proxy.control.ProxyMethodConfigurationService; class VelocityConfigControlTest { @TempDir Path directory; @@ -52,4 +55,20 @@ void rollbackRejectsASymbolicBackupSidecar() throws Exception { assertTrue(Files.readString(file).contains("lobby")); assertTrue(Files.readString(external).contains("preserved")); } + + @Test + void controlPersistsOnlyTheRevisionedProxyMethod() throws Exception { + Path file = directory.resolve("velocity.yml"); + Files.writeString(file, "BungeeMethod: PLUGINMESSAGING\nRedis:\n Host: localhost\n Port: 6379\n"); + VelocityConfig config = new VelocityConfig(file.toFile()); + config.loadControlConfiguration(); + ProxyMethodConfiguration current = new ProxyMethodConfiguration( + ProxyMethodConfigurationService.canonical(config.getBungeeMethod())); + + config.persistControlProxyMethod("REDIS", current.revision()); + config.loadControlConfiguration(); + + assertEquals("REDIS", config.getBungeeMethod()); + assertTrue(Files.readString(file).contains("Host: localhost")); + } } From 7e141bf7b77dc5b547d8f137a62656372c903df7 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 15:16:52 -0600 Subject: [PATCH 05/11] Complete Control operation before runtime replacement --- .../votingplugin/proxy/control/ControlConnector.java | 6 +++++- .../votingplugin/proxy/control/ControlConnectorTest.java | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java index 37a26b6a5d..57b3393d7e 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java @@ -284,7 +284,6 @@ void cycle() { operation.whenComplete((ignored, failure) -> { try { activeRequest = null; - finishCycle(); if (!closed) { if (failure == null) { onSuccess(); @@ -299,10 +298,15 @@ void cycle() { operationDone.completeExceptionally(failure); } if (activeOperation == operationDone) activeOperation = null; + finishCycle(); } }); } + boolean hasActiveOperation() { + return activeOperation != null; + } + private void finishCycle() { Runnable replacement = null; synchronized (operationLifecycle) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java index 8fe3dfdc05..6f13f14634 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java @@ -217,7 +217,10 @@ class ControlConnectorTest { connector.cycle(); AtomicBoolean replacementStarted = new AtomicBoolean(); - assertTrue(connector.deferReplacementUntilSafe(() -> replacementStarted.set(true))); + assertTrue(connector.deferReplacementUntilSafe(() -> { + assertFalse(connector.hasActiveOperation()); + replacementStarted.set(true); + })); assertFalse(replacementStarted.get()); transport.resultSubmission.complete(new Response(200, "{}")); From 263d7038c469bd28dac0ec33c76284de598f33de Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 15:17:43 -0600 Subject: [PATCH 06/11] Align transport stack with prerequisite fixes --- .../control/BackendConfigurationService.java | 43 ++++++++++++++++++- .../control/BackendControlConnector.java | 11 +++++ .../BackendConfigurationServiceTest.java | 16 +++++++ .../BackendControlConnectorProtocolTest.java | 7 +++ 4 files changed, 75 insertions(+), 2 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java index 6e2df88efa..e3f79f26e2 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java @@ -21,6 +21,8 @@ import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.InvalidConfigurationException; @@ -35,6 +37,8 @@ public final class BackendConfigurationService { public static final int MAX_CONTENT_BYTES = 512 * 1024; private static final Set TOP_LEVEL = Set.of("Config.yml", "VoteSites.yml", "SpecialRewards.yml", "GUI.yml", "Shop.yml", "BungeeSettings.yml"); + private static final Pattern COMMENT_SECRET = Pattern.compile( + "(?i)(\\b(?:password|token|secret|credential|api[ _-]?key)\\b\\s*[:=]\\s*)([^\\s#]+)"); private final Path dataDirectory; private final ApplyAction reload; @@ -461,10 +465,45 @@ private static void ensureBounded(String content) { } private static String mask(YamlConfiguration yaml) { + Set secretValues = new HashSet<>(); for (String path : new ArrayList<>(yaml.getKeys(true))) { - if (!(yaml.get(path) instanceof ConfigurationSection) && secret(path)) yaml.set(path, REDACTED); + if (!(yaml.get(path) instanceof ConfigurationSection) && secret(path)) { + Object value = yaml.get(path); + if (value != null && !String.valueOf(value).isBlank()) secretValues.add(String.valueOf(value)); + yaml.set(path, REDACTED); + } + } + return sanitizeComments(yaml.saveToString(), secretValues); + } + + private static String sanitizeComments(String content, Set secretValues) { + StringBuilder sanitized = new StringBuilder(content.length()); + for (String line : content.split("\\n", -1)) { + int commentStart = commentStart(line); + if (commentStart >= 0) { + String comment = line.substring(commentStart); + for (String value : secretValues) comment = comment.replace(value, REDACTED); + Matcher matcher = COMMENT_SECRET.matcher(comment); + comment = matcher.replaceAll("$1" + REDACTED); + line = line.substring(0, commentStart) + comment; + } + sanitized.append(line).append('\n'); + } + return sanitized.substring(0, sanitized.length() - 1); + } + + private static int commentStart(String line) { + boolean singleQuoted = false; + boolean doubleQuoted = false; + for (int index = 0; index < line.length(); index++) { + char current = line.charAt(index); + if (current == '\'' && !doubleQuoted) singleQuoted = !singleQuoted; + else if (current == '"' && !singleQuoted && (index == 0 || line.charAt(index - 1) != '\\')) { + doubleQuoted = !doubleQuoted; + } else if (current == '#' && !singleQuoted && !doubleQuoted + && (index == 0 || Character.isWhitespace(line.charAt(index - 1)))) return index; } - return yaml.saveToString(); + return -1; } private static YamlConfiguration resolveSecrets(YamlConfiguration proposal, YamlConfiguration current) { 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 0a2b9353cf..583dda0a82 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java @@ -66,6 +66,7 @@ public final class BackendControlConnector implements AutoCloseable { private volatile boolean registered; private volatile boolean operationsAccepted; private volatile boolean quickSetupsAccepted; + private volatile boolean voteSitesSyncAccepted; private volatile int failures; private volatile ScheduledFuture scheduled; private volatile Future activeReload; @@ -176,6 +177,7 @@ private void cycle() { } operationsAccepted = negotiatedCapability(node, "config.files.v1", operationsAccepted); quickSetupsAccepted = negotiatedCapability(node, "config.quick-setup.v1", quickSetupsAccepted); + voteSitesSyncAccepted = negotiatedCapability(node, "config.vote-sites-sync.v1", voteSitesSyncAccepted); try { requireFileCapability(operationsAccepted); } catch (ConnectorException incompatible) { @@ -220,6 +222,7 @@ private JsonObject register() throws Exception { // A registration must explicitly establish required capabilities. Heartbeats may omit the unchanged set. operationsAccepted = false; quickSetupsAccepted = false; + voteSitesSyncAccepted = false; JsonObject body = sessionBody(); body.addProperty("nodeId", settings.nodeId()); body.addProperty("displayName", settings.nodeId()); @@ -486,6 +489,9 @@ private TaskResult executeQuick(UUID operationId, String type, JsonObject config throws IOException { if ("READ".equals(type)) return TaskResult.failure("UNSUPPORTED_TASK", "Quick setups cannot be read"); String preset = string(configuration, "preset"); + if (!quickSetupCapabilityAccepted(preset, quickSetupsAccepted, voteSitesSyncAccepted)) { + return TaskResult.failure("UNSUPPORTED_TASK", "VoteSites sync was not negotiated"); + } Map options = options(configuration.getAsJsonObject("options")); if ("PREVIEW".equals(type)) { BackendConfigurationService.QuickPreview preview = configurations.previewQuickSetup(preset, options); @@ -504,6 +510,11 @@ private TaskResult executeQuick(UUID operationId, String type, JsonObject config return TaskResult.failure("UNSUPPORTED_TASK", "Task type is unsupported"); } + static boolean quickSetupCapabilityAccepted(String preset, boolean quickSetupsAccepted, + boolean voteSitesSyncAccepted) { + return quickSetupsAccepted && (!"sync-vote-sites".equals(preset) || voteSitesSyncAccepted); + } + private Response send(String method, String path, JsonObject body) throws Exception { HttpRequest request = HttpRequest.newBuilder(settings.endpoint().resolve(path)) .timeout(Duration.ofMillis(settings.requestTimeoutMillis())).header("Content-Type", "application/json") diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java index e04b207e00..4a1c276acf 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java @@ -81,6 +81,22 @@ class BackendConfigurationServiceTest { assertTrue(applied.contains("# End of owner notes")); } + @Test void redactsSecretsRepeatedOrDefinedInsideComments() throws Exception { + Path config = directory.resolve("Config.yml"); + Files.writeString(config, "# Password: commented-secret\n" + + "Database:\n" + + " Password: active-secret # rotate active-secret soon\n" + + "Feature: true # Token=comment-token\n"); + BackendConfigurationService.Document read = new BackendConfigurationService(directory, () -> { }) + .read("Config.yml"); + + assertFalse(read.content().contains("commented-secret")); + assertFalse(read.content().contains("active-secret")); + assertFalse(read.content().contains("comment-token")); + assertTrue(read.content().contains("# Password: " + BackendConfigurationService.REDACTED)); + assertTrue(read.content().contains("# rotate " + BackendConfigurationService.REDACTED + " soon")); + } + @Test void rejectsStaleInvalidAndUnmanagedWrites() throws Exception { Files.writeString(directory.resolve("Config.yml"), "Feature: false\n"); BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java index d4ad6d0f1b..f05c655448 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java @@ -76,6 +76,13 @@ class BackendControlConnectorProtocolTest { assertTrue(BackendControlConnector.negotiatedCapability(explicit, "config.quick-setup.v1", false)); } + @Test void voteSitesSyncRequiresBothNegotiatedCapabilities() { + assertFalse(BackendControlConnector.quickSetupCapabilityAccepted("sync-vote-sites", true, false)); + assertFalse(BackendControlConnector.quickSetupCapabilityAccepted("sync-vote-sites", false, true)); + assertTrue(BackendControlConnector.quickSetupCapabilityAccepted("sync-vote-sites", true, true)); + assertTrue(BackendControlConnector.quickSetupCapabilityAccepted("common-settings", true, false)); + } + @Test void shutdownWaitsForTheClaimedBackendOperation() throws Exception { var executor = Executors.newSingleThreadScheduledExecutor(); CompletableFuture operation = new CompletableFuture<>(); From edddcf7b0e6a03cdc6731f5f5025e9e6bbc2e916 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 16:39:29 -0600 Subject: [PATCH 07/11] Align proxy diagnostics with runtime identity --- .../control/BackendConfigurationService.java | 3 +- .../ProxyMethodConfigurationService.java | 132 ++++++++++++++++ .../BackendConfigurationServiceTest.java | 143 ++++++++++++++++++ .../ProxyMethodConfigurationServiceTest.java | 73 +++++++++ 4 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationService.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationServiceTest.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java index 42d95802e9..e01b4d0ec3 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java @@ -331,8 +331,7 @@ private boolean caseInsensitiveYmlFiles() throws IOException { private void validateProxyMethod(BungeeMethod method, YamlConfiguration settings) { String server = settings.getString("Server", "").trim(); - if (method != BungeeMethod.PLUGINMESSAGING - && (server.isEmpty() || "PleaseSet".equalsIgnoreCase(server))) { + if (server.isEmpty() || "PleaseSet".equalsIgnoreCase(server)) { throw new IllegalArgumentException("Server must be set to this backend's unique proxy server name"); } switch (method) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationService.java new file mode 100644 index 0000000000..0142c819d4 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationService.java @@ -0,0 +1,132 @@ +package com.bencodez.votingplugin.proxy.control; + +import java.io.IOException; +import java.net.URI; +import java.util.Map; +import java.util.Set; + +import com.bencodez.votingplugin.proxy.BungeeMethod; +import com.bencodez.votingplugin.proxy.VotingPluginProxy; +import com.bencodez.votingplugin.proxy.VotingPluginProxyConfig; +import com.bencodez.votingplugin.util.DurableFiles; + +/** Preflights and atomically persists proxy transport-method changes. */ +public final class ProxyMethodConfigurationService { + private final VotingPluginProxy proxy; + + public ProxyMethodConfigurationService(VotingPluginProxy proxy) { + this.proxy = proxy; + } + + public ProxyMethodConfiguration read() { + return new ProxyMethodConfiguration(BungeeMethod.getByName(proxy.getConfig().getBungeeMethod())); + } + + public void validate(ProxyMethodConfiguration proposal) { + VotingPluginProxyConfig config = proxy.getConfig(); + switch (proposal.method()) { + case PLUGINMESSAGING: + if (blank(config.getPluginMessageChannel())) { + throw new IllegalArgumentException("PluginMessageChannel must be set"); + } + break; + case REDIS: + validHostPort(config.getRedisHost(), config.getRedisPort(), "Redis"); + break; + case MQTT: + validBroker(config.getMqttBrokerURL()); + if (blank(config.getMqttClientID())) throw new IllegalArgumentException("MQTT.ClientID must be set"); + break; + case SOCKETS: + if (config.getBungeePort() < 1 || config.getBungeePort() > 65535) { + throw new IllegalArgumentException("BungeeServer.Port must be set"); + } + for (String server : proxy.getAllConfiguredServers()) { + Map backend = config.getSpigotServerConfiguration(server); + Object host = backend.get("Host"); + Object port = backend.get("Port"); + if (host == null || blank(host.toString()) || !(port instanceof Number) + || ((Number) port).intValue() < 1 || ((Number) port).intValue() > 65535) { + throw new IllegalArgumentException("SpigotServers." + server + " Host and Port must be set for SOCKETS"); + } + } + break; + case MYSQL: + if (!config.hasDatabaseConfigured()) { + throw new IllegalArgumentException("The proxy database Host must be configured for MYSQL"); + } + break; + default: + throw new IllegalArgumentException("proxy method is unsupported"); + } + } + + public void apply(ProxyMethodConfiguration proposal, String expectedRevision) throws IOException { + validate(proposal); + if (expectedRevision == null || !read().revision().equals(expectedRevision)) { + throw new StaleRevisionException(); + } + try { + proxy.getConfig().persistControlProxyMethod(proposal.method().name(), expectedRevision); + proxy.getConfig().verifyControlProxyRoutingInstalled(); + } catch (VotingPluginProxyConfig.StaleControlRevisionException e) { + throw new StaleRevisionException(); + } catch (DurableFiles.PublishedException published) { + throw rollbackAfterFailure(published); + } catch (IOException failure) { + throw failure; + } catch (RuntimeException failure) { + throw rollbackAfterFailure(failure); + } + } + + private ApplyFailureException rollbackAfterFailure(Exception failure) { + boolean rolledBack = false; + try { + proxy.getConfig().rollbackControlProxyRouting(); + rolledBack = true; + } catch (Exception rollbackFailure) { + failure.addSuppressed(rollbackFailure); + } + return new ApplyFailureException(rolledBack, failure); + } + + public static BungeeMethod canonical(String value) { + if (value == null) throw new IllegalArgumentException("proxy method is required"); + try { + return BungeeMethod.valueOf(value.trim().toUpperCase(java.util.Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("proxy method is unsupported"); + } + } + + private static void validHostPort(String host, int port, String label) { + if (blank(host) || port < 1 || port > 65535) { + throw new IllegalArgumentException(label + " host and port must be set"); + } + } + + private static void validBroker(String value) { + try { + URI broker = URI.create(value == null ? "" : value.trim()); + if (!Set.of("tcp", "ssl", "ws", "wss").contains(broker.getScheme()) || broker.getHost() == null) { + throw new IllegalArgumentException(); + } + } catch (RuntimeException e) { + throw new IllegalArgumentException("MQTT.BrokerURL must be a valid tcp, ssl, ws, or wss endpoint"); + } + } + + private static boolean blank(String value) { + return value == null || value.isBlank(); + } + + @SuppressWarnings("serial") + public static final class StaleRevisionException extends RuntimeException { } + @SuppressWarnings("serial") + public static final class ApplyFailureException extends RuntimeException { + private final boolean rolledBack; + private ApplyFailureException(boolean rolledBack, Throwable cause) { super(cause); this.rolledBack = rolledBack; } + public boolean rolledBack() { return rolledBack; } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java index 068db8b8d9..ddf8d3988f 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java @@ -9,6 +9,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; @@ -325,6 +326,148 @@ class BackendConfigurationServiceTest { assertTrue(mqtt.proposal().content().contains("BungeeMethod: MQTT")); } + @Test void proxyMethodSwitchPreflightsRequiredBackendSettings() throws Exception { + Path settings = directory.resolve("BungeeSettings.yml"); + Files.writeString(settings, "UseBungeecord: true\nServer: lobby\nBungeeMethod: PLUGINMESSAGING\n" + + "PluginMessageChannel: vp:vp\nRedis:\n Host: ''\n Port: 6379\n" + + "MQTT:\n ClientID: lobby\n BrokerURL: bad\nBungeeServer:\n Host: ''\n Port: 1297\n"); + Files.writeString(directory.resolve("Config.yml"), + "DataStorage: SQLITE\nDatabase:\n Host: ''\n Database: ''\n"); + BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); + + assertThrows(IllegalArgumentException.class, + () -> service.previewQuickSetup("proxy-method", Map.of("method", "REDIS"))); + assertThrows(IllegalArgumentException.class, + () -> service.previewQuickSetup("proxy-method", Map.of("method", "MQTT"))); + assertThrows(IllegalArgumentException.class, + () -> service.previewQuickSetup("proxy-method", Map.of("method", "SOCKETS"))); + assertThrows(IllegalArgumentException.class, + () -> service.previewQuickSetup("proxy-method", Map.of("method", "MYSQL"))); + + Files.writeString(settings, Files.readString(settings).replace("Host: ''", "Host: localhost") + .replace("BrokerURL: bad", "BrokerURL: tcp://localhost:1883")); + BackendConfigurationService.QuickPreview redis = service.previewQuickSetup("proxy-method", + Map.of("method", "REDIS")); + assertTrue(redis.proposal().content().contains("BungeeMethod: REDIS")); + } + + @Test void pluginMessagingMethodRequiresBackendIdentityForDiagnostics() throws Exception { + Files.writeString(directory.resolve("BungeeSettings.yml"), + "UseBungeecord: true\nServer: PleaseSet\nBungeeMethod: REDIS\n"); + BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); + + assertThrows(IllegalArgumentException.class, () -> service.previewQuickSetup("proxy-method", + Map.of("method", "PLUGINMESSAGING"))); + } + + @Test void voteSitesSyncAddsAndUpdatesDefinitionsWithoutTouchingRewardsOrTargetOnlySites() throws Exception { + Path voteSites = directory.resolve("VoteSites.yml"); + Files.writeString(voteSites, "VoteSites:\n" + + " PMC:\n" + + " Name: Target name\n" + + " VoteURL: https://target.example/vote\n" + + " Rewards:\n" + + " Commands: ['target reward']\n" + + " WaitUntilVoteDelayRewards:\n" + + " Commands: ['target rejected reward']\n" + + " TargetOnly:\n" + + " Name: Keep me\n" + + "EverySiteReward:\n" + + " Commands: ['target every-site reward']\n"); + String source = "# Source network sites\n" + + "VoteSites:\n" + + " # Planet Minecraft settings\n" + + " PMC:\n" + + " Name: Source name # synchronized field\n" + + " VoteURL: https://source.example/vote\n" + + " DisplayItem:\n" + + " Material: EMERALD\n" + + " Rewards:\n" + + " Commands: ['source reward']\n" + + " WaitUntilVoteDelayRewards:\n" + + " Commands: ['source rejected reward']\n" + + " NewSite:\n" + + " Name: Added site\n" + + " VoteURL: https://new.example/vote\n" + + " Rewards:\n" + + " Commands: ['source new reward']\n" + + "EverySiteReward:\n" + + " Commands: ['source every-site reward']\n"; + BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); + BackendConfigurationService.Document before = service.read("VoteSites.yml"); + + BackendConfigurationService.QuickPreview preview = service.previewQuickSetup("sync-vote-sites", + Map.of("sourceContent", source)); + String recoveryRevision = service.proposedQuickSetupRevision("sync-vote-sites", preview); + YamlConfiguration proposal = new YamlConfiguration(); + proposal.options().parseComments(true); + proposal.loadFromString(preview.proposal().content()); + assertEquals("Source name", proposal.getString("VoteSites.PMC.Name")); + assertEquals("EMERALD", proposal.getString("VoteSites.PMC.DisplayItem.Material")); + assertEquals(List.of("target reward"), proposal.getStringList("VoteSites.PMC.Rewards.Commands")); + assertEquals(List.of("target rejected reward"), + proposal.getStringList("VoteSites.PMC.WaitUntilVoteDelayRewards.Commands")); + assertEquals("Keep me", proposal.getString("VoteSites.TargetOnly.Name")); + assertEquals("Added site", proposal.getString("VoteSites.NewSite.Name")); + assertFalse(proposal.contains("VoteSites.NewSite.Rewards")); + assertEquals(List.of("target every-site reward"), proposal.getStringList("EverySiteReward.Commands")); + assertTrue(preview.proposal().content().contains("# Planet Minecraft settings")); + assertTrue(preview.proposal().content().contains("# synchronized field")); + assertTrue(preview.changes().stream().noneMatch(change -> change.toLowerCase().contains("reward"))); + + BackendConfigurationService.ApplyResult result = service.applyQuickSetup("sync-vote-sites", + Map.of("sourceContent", source), preview.revision()); + assertEquals(recoveryRevision, result.document().revision()); + String applied = Files.readString(voteSites); + assertTrue(applied.contains("target reward")); + assertFalse(applied.contains("source reward")); + assertTrue(applied.contains("TargetOnly")); + assertTrue(applied.contains("NewSite")); + assertTrue(applied.contains("# Planet Minecraft settings")); + } + + @Test void voteSitesSyncMatchesRootSitesAndFieldsCaseInsensitively() throws Exception { + Files.writeString(directory.resolve("Config.yml"), "CaseInsensitiveYMLFiles: true\n"); + Files.writeString(directory.resolve("VoteSites.yml"), "votesites:\n pmc:\n name: Target\n Rewards:\n Commands: ['keep']\n"); + BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); + + BackendConfigurationService.QuickPreview preview = service.previewQuickSetup("sync-vote-sites", Map.of( + "sourceContent", "VOTESITES:\n PMC:\n Name: Source\n VoteURL: https://example.invalid/vote\n")); + YamlConfiguration proposal = new YamlConfiguration(); + proposal.loadFromString(preview.proposal().content()); + + assertEquals("Source", proposal.getString("votesites.pmc.name")); + assertEquals("https://example.invalid/vote", proposal.getString("votesites.pmc.VoteURL")); + assertEquals(List.of("keep"), proposal.getStringList("votesites.pmc.Rewards.Commands")); + assertEquals(1, proposal.getKeys(false).size()); + assertEquals(1, proposal.getConfigurationSection("votesites").getKeys(false).size()); + } + + @Test void voteSitesSyncKeepsDistinctKeysWhenCaseInsensitiveFilesAreDisabled() throws Exception { + Files.writeString(directory.resolve("Config.yml"), "caseinsensitiveymlfiles: false\n"); + Files.writeString(directory.resolve("VoteSites.yml"), "VoteSites:\n PMC:\n Name: Upper target\n" + + " pmc:\n Name: Lower target\n"); + BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); + + BackendConfigurationService.QuickPreview preview = service.previewQuickSetup("sync-vote-sites", Map.of( + "sourceContent", "votesites:\n PMC:\n name: Updated upper\n")); + YamlConfiguration proposal = new YamlConfiguration(); + proposal.loadFromString(preview.proposal().content()); + + assertEquals("Updated upper", proposal.getString("VoteSites.PMC.Name")); + assertEquals("Lower target", proposal.getString("VoteSites.pmc.Name")); + } + + @Test void voteSitesSyncRejectsMalformedOrMissingSourceSections() throws Exception { + Files.writeString(directory.resolve("VoteSites.yml"), "VoteSites: {}\n"); + BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); + + assertThrows(IllegalArgumentException.class, () -> service.previewQuickSetup("sync-vote-sites", + Map.of("sourceContent", "not: [yaml"))); + assertThrows(IllegalArgumentException.class, () -> service.previewQuickSetup("sync-vote-sites", + Map.of("sourceContent", "Other: value\n"))); + } + @Test void fullBungeeSettingsRejectsUnknownTransportMethods() throws Exception { Files.writeString(directory.resolve("BungeeSettings.yml"), "UseBungeecord: true\nServer: lobby\nBungeeMethod: PLUGINMESSAGING\n"); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationServiceTest.java new file mode 100644 index 0000000000..02f7b4e971 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationServiceTest.java @@ -0,0 +1,73 @@ +package com.bencodez.votingplugin.proxy.control; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.bencodez.votingplugin.proxy.BungeeMethod; +import com.bencodez.votingplugin.proxy.VotingPluginProxy; +import com.bencodez.votingplugin.proxy.VotingPluginProxyConfig; + +class ProxyMethodConfigurationServiceTest { + private VotingPluginProxy proxy; + private VotingPluginProxyConfig config; + private ProxyMethodConfigurationService service; + + @BeforeEach + void setUp() { + proxy = mock(VotingPluginProxy.class); + config = mock(VotingPluginProxyConfig.class); + when(proxy.getConfig()).thenReturn(config); + when(proxy.getAllConfiguredServers()).thenReturn(Set.of("lobby")); + service = new ProxyMethodConfigurationService(proxy); + } + + @Test + void validatesRequiredSettingsForEveryTransport() { + assertThrows(IllegalArgumentException.class, + () -> service.validate(new ProxyMethodConfiguration(BungeeMethod.PLUGINMESSAGING))); + when(config.getPluginMessageChannel()).thenReturn("vp:vp"); + assertDoesNotThrow(() -> service.validate(new ProxyMethodConfiguration(BungeeMethod.PLUGINMESSAGING))); + + assertThrows(IllegalArgumentException.class, + () -> service.validate(new ProxyMethodConfiguration(BungeeMethod.REDIS))); + when(config.getRedisHost()).thenReturn("localhost"); + when(config.getRedisPort()).thenReturn(6379); + assertDoesNotThrow(() -> service.validate(new ProxyMethodConfiguration(BungeeMethod.REDIS))); + + when(config.getMqttBrokerURL()).thenReturn("not-a-broker"); + assertThrows(IllegalArgumentException.class, + () -> service.validate(new ProxyMethodConfiguration(BungeeMethod.MQTT))); + when(config.getMqttBrokerURL()).thenReturn("tcp://localhost:1883"); + when(config.getMqttClientID()).thenReturn("proxy"); + assertDoesNotThrow(() -> service.validate(new ProxyMethodConfiguration(BungeeMethod.MQTT))); + + when(config.getBungeePort()).thenReturn(1297); + when(config.getSpigotServerConfiguration("lobby")).thenReturn(Map.of("Host", "localhost", "Port", 1298)); + assertDoesNotThrow(() -> service.validate(new ProxyMethodConfiguration(BungeeMethod.SOCKETS))); + + assertThrows(IllegalArgumentException.class, + () -> service.validate(new ProxyMethodConfiguration(BungeeMethod.MYSQL))); + when(config.hasDatabaseConfigured()).thenReturn(true); + assertDoesNotThrow(() -> service.validate(new ProxyMethodConfiguration(BungeeMethod.MYSQL))); + } + + @Test + void rejectsUnknownMethodNamesInsteadOfFallingBack() { + assertThrows(IllegalArgumentException.class, () -> ProxyMethodConfigurationService.canonical("unknown")); + } + + @Test + void readsUnknownPersistedMethodsUsingTheRuntimeFallback() { + when(config.getBungeeMethod()).thenReturn("unknown"); + assertEquals(BungeeMethod.PLUGINMESSAGING, service.read().method()); + } +} From 53882fd85855378357b4de42e8493875096bbced Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 18:23:37 -0600 Subject: [PATCH 08/11] Validate persisted transport settings atomically --- .../proxy/VotingPluginProxyConfig.java | 11 +++++++ .../proxy/bungee/BungeeConfig.java | 13 ++++++-- .../ProxyMethodConfigurationService.java | 17 +++++++--- .../proxy/velocity/VelocityConfig.java | 13 ++++++-- .../ProxyMethodConfigurationServiceTest.java | 23 ++++++++++++- .../velocity/VelocityConfigControlTest.java | 33 +++++++++++++++++++ 6 files changed, 99 insertions(+), 11 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyConfig.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyConfig.java index e481a3b65c..3c5eeff944 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyConfig.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyConfig.java @@ -24,9 +24,20 @@ default void persistControlProxyRouting(boolean sendVotesToAllServers, List { }); + } + + /** Atomically validates the freshly loaded file before persisting the proxy communication method. */ + default void persistControlProxyMethod(String method, String expectedRevision, + ControlProxyMethodValidator validator) throws IOException { throw new UnsupportedOperationException("Control proxy method writes are unavailable"); } + @FunctionalInterface + interface ControlProxyMethodValidator { + void validate(VotingPluginProxyConfig current); + } + /** Restores the most recent Control-created backup. */ default void rollbackControlProxyRouting() throws IOException { throw new UnsupportedOperationException("Control configuration rollback is unavailable"); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeConfig.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeConfig.java index b6a328732d..ee1ff540a4 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeConfig.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeConfig.java @@ -17,6 +17,7 @@ import java.util.Map; import java.util.Set; +import com.bencodez.votingplugin.proxy.BungeeMethod; import com.bencodez.votingplugin.proxy.VotingPluginProxyConfig; import com.bencodez.votingplugin.proxy.control.ProxyMethodConfiguration; import com.bencodez.votingplugin.proxy.control.ProxyRoutingConfiguration; @@ -581,7 +582,8 @@ private static ProxyRoutingConfiguration routing(Configuration configuration) { } @Override - public synchronized void persistControlProxyMethod(String method, String expectedRevision) throws IOException { + public synchronized void persistControlProxyMethod(String method, String expectedRevision, + ControlProxyMethodValidator validator) throws IOException { Path target = new File(bungee.getDataFolder(), "bungeeconfig.yml").toPath(); Path stage = Files.createTempFile(target.getParent(), target.getFileName().toString(), ".control-stage"); Path backupStage = null; @@ -591,10 +593,15 @@ public synchronized void persistControlProxyMethod(String method, String expecte byte[] sourceSnapshot = Files.readAllBytes(target); Configuration latest = ConfigurationProvider.getProvider(YamlConfiguration.class).load(target.toFile()); ProxyMethodConfiguration current = new ProxyMethodConfiguration( - com.bencodez.votingplugin.proxy.control.ProxyMethodConfigurationService.canonical( - latest.getString("BungeeMethod", "SOCKETS"))); + BungeeMethod.getByName(latest.getString("BungeeMethod", "SOCKETS"))); if (!java.util.Arrays.equals(sourceSnapshot, Files.readAllBytes(target)) || !current.revision().equals(expectedRevision)) throw new StaleControlRevisionException(); + BungeeConfig fresh = new BungeeConfig(bungee); + fresh.loadControlConfiguration(); + validator.validate(fresh); + if (!java.util.Arrays.equals(sourceSnapshot, Files.readAllBytes(target))) { + throw new StaleControlRevisionException(); + } latest.set("BungeeMethod", method); ConfigurationProvider.getProvider(YamlConfiguration.class).save(latest, stage.toFile()); byte[] installedSnapshot = Files.readAllBytes(stage); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationService.java index 0142c819d4..f1ee5e3498 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationService.java @@ -23,7 +23,10 @@ public ProxyMethodConfiguration read() { } public void validate(ProxyMethodConfiguration proposal) { - VotingPluginProxyConfig config = proxy.getConfig(); + validate(proposal, proxy.getConfig()); + } + + private void validate(ProxyMethodConfiguration proposal, VotingPluginProxyConfig config) { switch (proposal.method()) { case PLUGINMESSAGING: if (blank(config.getPluginMessageChannel())) { @@ -41,12 +44,15 @@ public void validate(ProxyMethodConfiguration proposal) { if (config.getBungeePort() < 1 || config.getBungeePort() > 65535) { throw new IllegalArgumentException("BungeeServer.Port must be set"); } + List blockedServers = config.getBlockedServers(); for (String server : proxy.getAllConfiguredServers()) { + if (blockedServers != null && blockedServers.contains(server)) continue; Map backend = config.getSpigotServerConfiguration(server); Object host = backend.get("Host"); Object port = backend.get("Port"); - if (host == null || blank(host.toString()) || !(port instanceof Number) - || ((Number) port).intValue() < 1 || ((Number) port).intValue() > 65535) { + int socketPort = port == null ? 1298 : port instanceof Number ? ((Number) port).intValue() : 0; + if (!(host instanceof String) || blank((String) host) + || socketPort < 1 || socketPort > 65535) { throw new IllegalArgumentException("SpigotServers." + server + " Host and Port must be set for SOCKETS"); } } @@ -67,10 +73,13 @@ public void apply(ProxyMethodConfiguration proposal, String expectedRevision) th throw new StaleRevisionException(); } try { - proxy.getConfig().persistControlProxyMethod(proposal.method().name(), expectedRevision); + proxy.getConfig().persistControlProxyMethod(proposal.method().name(), expectedRevision, + latest -> validate(proposal, latest)); proxy.getConfig().verifyControlProxyRoutingInstalled(); } catch (VotingPluginProxyConfig.StaleControlRevisionException e) { throw new StaleRevisionException(); + } catch (IllegalArgumentException validation) { + throw validation; } catch (DurableFiles.PublishedException published) { throw rollbackAfterFailure(published); } catch (IOException failure) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfig.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfig.java index a98eefb470..008aa7716d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfig.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfig.java @@ -20,9 +20,9 @@ import org.checkerframework.checker.nullness.qual.NonNull; import com.bencodez.simpleapi.file.velocity.VelocityYMLFile; +import com.bencodez.votingplugin.proxy.BungeeMethod; import com.bencodez.votingplugin.proxy.VotingPluginProxyConfig; import com.bencodez.votingplugin.proxy.control.ProxyMethodConfiguration; -import com.bencodez.votingplugin.proxy.control.ProxyMethodConfigurationService; import com.bencodez.votingplugin.proxy.control.ProxyRoutingConfiguration; import com.bencodez.votingplugin.util.DurableFiles; @@ -95,7 +95,8 @@ private static ProxyRoutingConfiguration routing(ConfigurationNode configuration } @Override - public synchronized void persistControlProxyMethod(String method, String expectedRevision) throws IOException { + public synchronized void persistControlProxyMethod(String method, String expectedRevision, + ControlProxyMethodValidator validator) throws IOException { Path target = configurationFile.toPath(); Path stage = Files.createTempFile(target.getParent(), target.getFileName().toString(), ".control-stage"); Path backupStage = null; @@ -105,10 +106,16 @@ public synchronized void persistControlProxyMethod(String method, String expecte backupStage = Files.createTempFile(target.getParent(), target.getFileName().toString(), ".control-backup-stage"); byte[] sourceSnapshot = Files.readAllBytes(target); ConfigurationNode latest = sourceLoader.load(); - ProxyMethodConfiguration current = new ProxyMethodConfiguration(ProxyMethodConfigurationService.canonical( + ProxyMethodConfiguration current = new ProxyMethodConfiguration(BungeeMethod.getByName( latest.node("BungeeMethod").getString("PLUGINMESSAGING"))); if (!java.util.Arrays.equals(sourceSnapshot, Files.readAllBytes(target)) || !current.revision().equals(expectedRevision)) throw new StaleControlRevisionException(); + VelocityConfig fresh = new VelocityConfig(target.toFile()); + fresh.loadControlConfiguration(); + validator.validate(fresh); + if (!java.util.Arrays.equals(sourceSnapshot, Files.readAllBytes(target))) { + throw new StaleControlRevisionException(); + } latest.node("BungeeMethod").set(method); YamlConfigurationLoader.builder().path(stage).build().save(latest); byte[] installedSnapshot = Files.readAllBytes(stage); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationServiceTest.java index 02f7b4e971..435c5199fa 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationServiceTest.java @@ -3,9 +3,11 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import java.util.List; import java.util.Map; import java.util.Set; @@ -51,7 +53,9 @@ void validatesRequiredSettingsForEveryTransport() { assertDoesNotThrow(() -> service.validate(new ProxyMethodConfiguration(BungeeMethod.MQTT))); when(config.getBungeePort()).thenReturn(1297); - when(config.getSpigotServerConfiguration("lobby")).thenReturn(Map.of("Host", "localhost", "Port", 1298)); + when(proxy.getAllConfiguredServers()).thenReturn(Set.of("lobby", "blocked")); + when(config.getBlockedServers()).thenReturn(List.of("blocked")); + when(config.getSpigotServerConfiguration("lobby")).thenReturn(Map.of("Host", "localhost")); assertDoesNotThrow(() -> service.validate(new ProxyMethodConfiguration(BungeeMethod.SOCKETS))); assertThrows(IllegalArgumentException.class, @@ -70,4 +74,21 @@ void readsUnknownPersistedMethodsUsingTheRuntimeFallback() { when(config.getBungeeMethod()).thenReturn("unknown"); assertEquals(BungeeMethod.PLUGINMESSAGING, service.read().method()); } + + @Test + void applyValidatesTheFreshlyLoadedPersistedSnapshot() throws Exception { + when(config.getBungeeMethod()).thenReturn("PLUGINMESSAGING"); + when(config.getRedisHost()).thenReturn("localhost"); + when(config.getRedisPort()).thenReturn(6379); + VotingPluginProxyConfig fresh = mock(VotingPluginProxyConfig.class); + doAnswer(invocation -> { + VotingPluginProxyConfig.ControlProxyMethodValidator validator = invocation.getArgument(2); + validator.validate(fresh); + return null; + }).when(config).persistControlProxyMethod(org.mockito.ArgumentMatchers.eq("REDIS"), + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()); + + ProxyMethodConfiguration proposal = new ProxyMethodConfiguration(BungeeMethod.REDIS); + assertThrows(IllegalArgumentException.class, () -> service.apply(proposal, service.read().revision())); + } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfigControlTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfigControlTest.java index 29862adab0..d4edbd3281 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfigControlTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfigControlTest.java @@ -71,4 +71,37 @@ void controlPersistsOnlyTheRevisionedProxyMethod() throws Exception { assertEquals("REDIS", config.getBungeeMethod()); assertTrue(Files.readString(file).contains("Host: localhost")); } + + @Test + void controlCanRepairAnUnknownPersistedProxyMethod() throws Exception { + Path file = directory.resolve("velocity.yml"); + Files.writeString(file, "BungeeMethod: RETIRED_METHOD\nRedis:\n Host: localhost\n Port: 6379\n"); + VelocityConfig config = new VelocityConfig(file.toFile()); + config.loadControlConfiguration(); + ProxyMethodConfiguration current = new ProxyMethodConfiguration( + com.bencodez.votingplugin.proxy.BungeeMethod.getByName(config.getBungeeMethod())); + + config.persistControlProxyMethod("REDIS", current.revision()); + config.loadControlConfiguration(); + + assertEquals("REDIS", config.getBungeeMethod()); + } + + @Test + void controlValidatesTheFreshOnDiskSnapshotBeforeChangingMethods() throws Exception { + Path file = directory.resolve("velocity.yml"); + Files.writeString(file, "BungeeMethod: PLUGINMESSAGING\nRedis:\n Host: localhost\n Port: 6379\n"); + VelocityConfig config = new VelocityConfig(file.toFile()); + config.loadControlConfiguration(); + ProxyMethodConfiguration current = new ProxyMethodConfiguration( + com.bencodez.votingplugin.proxy.BungeeMethod.getByName(config.getBungeeMethod())); + Files.writeString(file, "BungeeMethod: PLUGINMESSAGING\nRedis:\n Host: ''\n Port: 6379\n"); + + assertThrows(IllegalArgumentException.class, () -> config.persistControlProxyMethod("REDIS", + current.revision(), fresh -> { + if (fresh.getRedisHost().isBlank()) throw new IllegalArgumentException("Redis host must be set"); + })); + + assertTrue(Files.readString(file).contains("BungeeMethod: PLUGINMESSAGING")); + } } From 9cb5d8dd8294348e28c73c3de0c5de6c09655b60 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 18:48:20 -0600 Subject: [PATCH 09/11] Use fresh transport settings for apply validation --- .../ProxyMethodConfigurationService.java | 5 +---- .../ProxyMethodConfigurationServiceTest.java | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationService.java index f1ee5e3498..1ff85f0a3f 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationService.java @@ -68,10 +68,7 @@ private void validate(ProxyMethodConfiguration proposal, VotingPluginProxyConfig } public void apply(ProxyMethodConfiguration proposal, String expectedRevision) throws IOException { - validate(proposal); - if (expectedRevision == null || !read().revision().equals(expectedRevision)) { - throw new StaleRevisionException(); - } + if (expectedRevision == null) throw new StaleRevisionException(); try { proxy.getConfig().persistControlProxyMethod(proposal.method().name(), expectedRevision, latest -> validate(proposal, latest)); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationServiceTest.java index 435c5199fa..1481a8155f 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationServiceTest.java @@ -91,4 +91,21 @@ void applyValidatesTheFreshlyLoadedPersistedSnapshot() throws Exception { ProxyMethodConfiguration proposal = new ProxyMethodConfiguration(BungeeMethod.REDIS); assertThrows(IllegalArgumentException.class, () -> service.apply(proposal, service.read().revision())); } + + @Test + void applyDoesNotRejectAValidFreshSnapshotUsingStaleCachedSettings() throws Exception { + when(config.getBungeeMethod()).thenReturn("PLUGINMESSAGING"); + VotingPluginProxyConfig fresh = mock(VotingPluginProxyConfig.class); + when(fresh.getRedisHost()).thenReturn("localhost"); + when(fresh.getRedisPort()).thenReturn(6379); + doAnswer(invocation -> { + VotingPluginProxyConfig.ControlProxyMethodValidator validator = invocation.getArgument(2); + validator.validate(fresh); + return null; + }).when(config).persistControlProxyMethod(org.mockito.ArgumentMatchers.eq("REDIS"), + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()); + + ProxyMethodConfiguration proposal = new ProxyMethodConfiguration(BungeeMethod.REDIS); + assertDoesNotThrow(() -> service.apply(proposal, service.read().revision())); + } } From 8d6cdc2231a5251774d4e2c88bf5017efbb7e52b Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 18:54:02 -0600 Subject: [PATCH 10/11] Validate every persisted socket route --- .../control/ProxyMethodConfigurationService.java | 2 +- .../ProxyMethodConfigurationServiceTest.java | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationService.java index 1ff85f0a3f..1f76ca4dd7 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationService.java @@ -45,7 +45,7 @@ private void validate(ProxyMethodConfiguration proposal, VotingPluginProxyConfig throw new IllegalArgumentException("BungeeServer.Port must be set"); } List blockedServers = config.getBlockedServers(); - for (String server : proxy.getAllConfiguredServers()) { + for (String server : config.getSpigotServers()) { if (blockedServers != null && blockedServers.contains(server)) continue; Map backend = config.getSpigotServerConfiguration(server); Object host = backend.get("Host"); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationServiceTest.java index 1481a8155f..4e6a25da79 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationServiceTest.java @@ -54,6 +54,7 @@ void validatesRequiredSettingsForEveryTransport() { when(config.getBungeePort()).thenReturn(1297); when(proxy.getAllConfiguredServers()).thenReturn(Set.of("lobby", "blocked")); + when(config.getSpigotServers()).thenReturn(List.of("lobby", "blocked")); when(config.getBlockedServers()).thenReturn(List.of("blocked")); when(config.getSpigotServerConfiguration("lobby")).thenReturn(Map.of("Host", "localhost")); assertDoesNotThrow(() -> service.validate(new ProxyMethodConfiguration(BungeeMethod.SOCKETS))); @@ -64,6 +65,18 @@ void validatesRequiredSettingsForEveryTransport() { assertDoesNotThrow(() -> service.validate(new ProxyMethodConfiguration(BungeeMethod.MYSQL))); } + @Test + void validatesEveryPersistedSocketRouteEvenWhenTheProxyNoLongerRegistersIt() { + when(config.getBungeePort()).thenReturn(1297); + when(config.getBlockedServers()).thenReturn(List.of()); + when(config.getSpigotServers()).thenReturn(List.of("stale-route")); + when(config.getSpigotServerConfiguration("stale-route")) + .thenReturn(Map.of("Host", "localhost", "Port", "bad")); + + assertThrows(IllegalArgumentException.class, + () -> service.validate(new ProxyMethodConfiguration(BungeeMethod.SOCKETS))); + } + @Test void rejectsUnknownMethodNamesInsteadOfFallingBack() { assertThrows(IllegalArgumentException.class, () -> ProxyMethodConfigurationService.canonical("unknown")); From 52bc7df31b5fc23dd38412ebc25d885a5d43a64a Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 19:14:16 -0600 Subject: [PATCH 11/11] Report unavailable diagnostic transports immediately --- .../votingplugin/proxy/VotingPluginProxy.java | 11 +++++++- .../tests/VotingPluginProxyTest.java | 27 ++++++++++++++++--- .../tests/VotingPluginProxyTestImpl.java | 16 +++++++++++ 3 files changed, 49 insertions(+), 5 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 0286afe7be..841ed2eba7 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java @@ -2922,9 +2922,13 @@ public CompletableFuture testBackendCommunication(Strin pendingCommunicationTests.put(requestId, pending); result.whenComplete((ignored, failure) -> pendingCommunicationTests.remove(requestId, pending)); try { + if (!sendCommunicationTestEnvelopeNow(server, VotingPluginWire.status(server, requestId))) { + result.complete(CommunicationTestResult.failure(server, activeMethod, "TRANSPORT_UNAVAILABLE", + "The active transport could not accept the communication test")); + return result; + } scheduler.schedule(() -> result.complete(CommunicationTestResult.failure(server, activeMethod, "TIMEOUT", "No correlated reply arrived before the timeout")), boundedTimeout, TimeUnit.MILLISECONDS); - globalMessageProxyHandler.sendMessage(server, 1, VotingPluginWire.status(server, requestId)); } catch (RuntimeException failure) { result.complete(CommunicationTestResult.failure(server, activeMethod, "SEND_FAILED", "The proxy could not send the communication test")); @@ -2932,6 +2936,11 @@ public CompletableFuture testBackendCommunication(Strin return result; } + /** Sends a diagnostic immediately and reports whether the active transport accepted it. */ + protected boolean sendCommunicationTestEnvelopeNow(String server, JsonEnvelope envelope) { + return sendProxyBroadcastEnvelopeNow(server, envelope); + } + protected void handleStatusOkay(JsonEnvelope message) { String server = message.getFields().getOrDefault(VotingPluginWire.K_SERVER, ""); String request = message.getFields().getOrDefault(VotingPluginWire.K_REQUEST_ID, ""); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java index dfaa1449dc..b6c587a5a1 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java @@ -133,13 +133,11 @@ void communicationTestCompletesOnlyForTheCorrelatedBackendReply() throws Excepti .mock(com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler.class); votingPluginProxy.setSchedulerForTest(scheduler); votingPluginProxy.setGlobalMessageProxyHandlerForTest(messageHandler); - org.mockito.ArgumentCaptor sent = org.mockito.ArgumentCaptor - .forClass(com.bencodez.simpleapi.servercomm.codec.JsonEnvelope.class); java.util.concurrent.CompletableFuture result = votingPluginProxy .testBackendCommunication("Server1", 5000L); - verify(messageHandler).sendMessage(Mockito.eq("Server1"), Mockito.eq(1), sent.capture()); - String requestId = sent.getValue().getFields().get(VotingPluginWire.K_REQUEST_ID); + String requestId = votingPluginProxy.getLastCommunicationTestEnvelope().getFields() + .get(VotingPluginWire.K_REQUEST_ID); assertFalse(result.isDone()); votingPluginProxy.handleStatusOkayForTest(VotingPluginWire.statusOkay("Server2", @@ -155,6 +153,27 @@ void communicationTestCompletesOnlyForTheCorrelatedBackendReply() throws Excepti assertTrue(completed.roundTripMillis() >= 0L); } + @Test + void communicationTestReportsUnavailableWhenTheActiveTransportCannotSend() throws Exception { + java.util.concurrent.ScheduledExecutorService scheduler = Mockito + .mock(java.util.concurrent.ScheduledExecutorService.class); + votingPluginProxy.setSchedulerForTest(scheduler); + votingPluginProxy.setGlobalMessageProxyHandlerForTest(Mockito + .mock(com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler.class)); + votingPluginProxy.setCommunicationTestDeliveryResult(false); + + for (BungeeMethod unavailable : new BungeeMethod[] { BungeeMethod.MQTT, BungeeMethod.SOCKETS }) { + votingPluginProxy.setMethod(unavailable); + VotingPluginProxy.CommunicationTestResult result = votingPluginProxy + .testBackendCommunication("Server1", 5000L).get(); + + assertFalse(result.success()); + assertEquals("TRANSPORT_UNAVAILABLE", result.code()); + assertEquals(unavailable.name(), result.method()); + } + Mockito.verifyNoInteractions(scheduler); + } + @Test void pluginMessagingCommunicationTestExplainsOnlinePlayerRequirement() throws Exception { votingPluginProxy.setMethod(BungeeMethod.PLUGINMESSAGING); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTestImpl.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTestImpl.java index 67f62b061c..ecdd7e6d19 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTestImpl.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTestImpl.java @@ -23,6 +23,8 @@ public class VotingPluginProxyTestImpl extends VotingPluginProxy { private final List warnings = new ArrayList<>(); private VotingPluginProxyConfig config; private boolean pluginMessageDeliveryResult = true; + private boolean communicationTestDeliveryResult = true; + private JsonEnvelope lastCommunicationTestEnvelope; private boolean playerOnline = true; private ScheduledExecutorService scheduler; @@ -210,6 +212,20 @@ public boolean sendProxyBroadcastImmediately(String server, JsonEnvelope envelop return sendProxyBroadcastEnvelopeNow(server, envelope); } + @Override + protected boolean sendCommunicationTestEnvelopeNow(String server, JsonEnvelope envelope) { + lastCommunicationTestEnvelope = envelope; + return communicationTestDeliveryResult; + } + + public void setCommunicationTestDeliveryResult(boolean communicationTestDeliveryResult) { + this.communicationTestDeliveryResult = communicationTestDeliveryResult; + } + + public JsonEnvelope getLastCommunicationTestEnvelope() { + return lastCommunicationTestEnvelope; + } + public void handleLoginMessageForTest(JsonEnvelope envelope) { handleLoginMessage(envelope); }