Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
6c646e9
Add Control transport diagnostics and method switching
BenCodez Aug 29, 2026
836f8e7
Initialize transport in plugin messaging diagnostic test
BenCodez Aug 29, 2026
1e2343c
Allow identity-free plugin messaging preflight
BenCodez Aug 29, 2026
9df6143
Cover proxy method preflight and persistence
BenCodez Aug 29, 2026
7e141bf
Complete Control operation before runtime replacement
BenCodez Aug 29, 2026
263d703
Align transport stack with prerequisite fixes
BenCodez Aug 29, 2026
29d26fa
Merge updated VoteSites prerequisite
BenCodez Aug 29, 2026
94ed7be
Fix VoteSites configuration test imports
BenCodez Aug 29, 2026
6e8e674
Merge second VoteSites review fixes
BenCodez Aug 29, 2026
cac2657
Merge third VoteSites review fixes
BenCodez Aug 29, 2026
2cbb387
Restrict proxy replacement to successful apply
BenCodez Aug 29, 2026
e39c3ac
Honor plugin messaging channel default
BenCodez Aug 29, 2026
1b72508
Honor backend transport identity and defaults
BenCodez Aug 29, 2026
c5a9a59
Harden proxy method reads and defaults
BenCodez Aug 29, 2026
99be239
Refresh proxy transport stack base
BenCodez Aug 29, 2026
ae8a30f
Carry reviewed configuration fixes into transport diagnostics
BenCodez Aug 29, 2026
a3f04df
Match qualified webhook labels
BenCodez Aug 29, 2026
edddcf7
Align proxy diagnostics with runtime identity
BenCodez Aug 29, 2026
24ab420
Rebuild diagnostics head with reviewed fixes
BenCodez Aug 29, 2026
1c9ae8c
Carry reviewed config fixes into diagnostics
BenCodez Aug 29, 2026
e6d0133
Carry hardened comment redaction into diagnostics
BenCodez Aug 29, 2026
5366339
Carry reviewed configuration fixes into diagnostics
BenCodez Aug 29, 2026
aa3d40c
Merge comment credential preservation fix
BenCodez Aug 30, 2026
53882fd
Validate persisted transport settings atomically
BenCodez Aug 30, 2026
894f318
Merge VoteSites sync fixes into transport diagnostics
BenCodez Aug 30, 2026
078eb67
Merge VoteSites sync test correction into transport diagnostics
BenCodez Aug 30, 2026
9cb5d8d
Use fresh transport settings for apply validation
BenCodez Aug 30, 2026
da8c0fa
Merge VoteSites sync safety fixes into transport diagnostics
BenCodez Aug 30, 2026
8d6cdc2
Validate every persisted socket route
BenCodez Aug 30, 2026
a655706
Merge VoteSites revision fixes into transport diagnostics
BenCodez Aug 30, 2026
be4e9a9
Merge casing policy default fix into transport diagnostics
BenCodez Aug 30, 2026
52bc7df
Report unavailable diagnostic transports immediately
BenCodez Aug 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> out = new HashMap<>();
out.put("server", nvl(msg.getFields().get("server")));
sendSubChannel(messages, "statusokay", out);
out.put(VotingPluginWire.K_SERVER, nvl(plugin.getOptions().getServer()));
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") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,8 @@ private String quickSetupRevision(String preset, String current) throws IOExcept
}

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";
Expand Down Expand Up @@ -258,6 +259,13 @@ private QuickProposal quickProposal(String preset, Map<String, String> 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;
Expand Down Expand Up @@ -322,6 +330,65 @@ private boolean caseInsensitiveYmlFiles() throws IOException {
return key != null && settings.getBoolean(key, false);
}

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", "vp:vp").isBlank()) {
throw new IllegalArgumentException("PluginMessageChannel must be set");
}
break;
case REDIS:
configuredHostAndPort(settings, "Redis.Host", "Redis.Port", 6379, "Redis");
break;
case MQTT:
validBroker(settings.getString("MQTT.BrokerURL", "tcp://localhost:1883"));
String clientId = settings.getString("MQTT.ClientID", server);
Comment on lines +347 to +349

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the runtime MQTT broker default during preflight

When a legacy or minimal BungeeSettings.yml omits MQTT.BrokerURL, the running BungeeSettings supplies tcp://localhost:1883, but this raw-YAML preflight substitutes an empty string and rejects switching to MQTT. Use the same broker default as the runtime so an otherwise valid configuration can be previewed and applied.

Useful? React with 👍 / 👎.

if (clientId == null || clientId.isBlank()) throw new IllegalArgumentException("MQTT.ClientID must be set");
break;
case SOCKETS:
configuredHostAndPort(settings, "BungeeServer.Host", "BungeeServer.Port", 1297, "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,
int defaultPort, String label) {
String host = yaml.getString(hostPath, "");
int port = yaml.getInt(portPath, defaultPort);
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, boolean ignoreCase) {
List<String> targetSecretValues = commentSecretReplacements(target);
String sourceRootKey = matchingKey(source, "VoteSites", true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -171,6 +172,7 @@ public abstract class VotingPluginProxy {
private final Map<UUID, PendingPresenceHandoff> pendingPresenceHandoffs = new HashMap<>();
private final Set<String> pendingBackendRecoverySnapshots = ConcurrentHashMap.newKeySet();
private final Map<String, Long> controlEnrollmentNextAllowed = new ConcurrentHashMap<>();
private final Map<UUID, PendingCommunicationTest> pendingCommunicationTests = new ConcurrentHashMap<>();
private volatile ControlConnector controlConnector;
private volatile HostedControlManager hostedControlManager;
private final Object controlLifecycleLock = new Object();
Expand Down Expand Up @@ -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);
}
});

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -2891,6 +2893,100 @@ public void status() {
}
}

/** Runs a correlated, non-vote round trip over the active backend transport. */
public CompletableFuture<CommunicationTestResult> 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"));
Comment on lines +2905 to +2907

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check the active transport before starting diagnostics

When MQTT initialization fails, load() catches the exception and continues with mqttHandler == null, while globalMessageProxyHandler is still constructed. This check therefore treats the transport as available; sendMqttEnvelopeServer() silently returns false, and the diagnostic eventually reports TIMEOUT rather than the intended TRANSPORT_UNAVAILABLE. The same issue occurs for a missing socket client, so check the method-specific transport state or propagate the send method's acceptance result before scheduling the timeout.

Useful? React with 👍 / 👎.

}
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<CommunicationTestResult> 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 {
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);
} catch (RuntimeException failure) {
result.complete(CommunicationTestResult.failure(server, activeMethod, "SEND_FAILED",
"The proxy could not send the communication test"));
}
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, "");
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<CommunicationTestResult> result) { }

private void sendVoteDelayRejected(String player, String uuid, String service, boolean playerOnline,
String playerServer) {
if (!playerOnline || playerServer == null || !getAllAvailableServers().contains(playerServer)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,22 @@ default void persistControlProxyRouting(boolean sendVotesToAllServers, List<Stri
persistControlProxyRouting(sendVotesToAllServers, blockedServers);
}

/** Atomically persists only the proxy communication method. */
default void persistControlProxyMethod(String method, String expectedRevision) throws IOException {
persistControlProxyMethod(method, expectedRevision, ignored -> { });
}

/** 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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,10 +170,20 @@ public static JsonEnvelope status(String server) {
return base(SUB_STATUS).put(K_SERVER, safe(server)).build();
}

public static JsonEnvelope status(String server, UUID requestId) {
return base(SUB_STATUS).put(K_SERVER, safe(server))
.put(K_REQUEST_ID, requestId == null ? "" : requestId.toString()).build();
}

public static JsonEnvelope statusOkay(String server) {
return base(SUB_STATUS_OKAY).put(K_SERVER, safe(server)).build();
}

public static JsonEnvelope statusOkay(String server, UUID requestId) {
return base(SUB_STATUS_OKAY).put(K_SERVER, safe(server))
.put(K_REQUEST_ID, requestId == null ? "" : requestId.toString()).build();
}

public static JsonEnvelope serverName(String server) {
return base(SUB_SERVER_NAME).put(K_SERVER, safe(server)).build();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
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;
import com.bencodez.votingplugin.util.DurableFiles;

Expand Down Expand Up @@ -579,6 +581,49 @@ private static ProxyRoutingConfiguration routing(Configuration configuration) {
configuration.getStringList("BlockedServers"));
}

@Override
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;
Path backup = target.resolveSibling(target.getFileName() + ".control-backup");
try {
backupStage = Files.createTempFile(target.getParent(), target.getFileName().toString(), ".control-backup-stage");
byte[] sourceSnapshot = Files.readAllBytes(target);
Configuration latest = ConfigurationProvider.getProvider(YamlConfiguration.class).load(target.toFile());
ProxyMethodConfiguration current = new ProxyMethodConfiguration(
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);
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;
}
data = latest;
} finally {
Files.deleteIfExists(stage);
if (backupStage != null) Files.deleteIfExists(backupStage);
}
}

@Override
public synchronized void rollbackControlProxyRouting() throws IOException {
Path target = new File(bungee.getDataFolder(), "bungeeconfig.yml").toPath();
Expand Down Expand Up @@ -739,14 +784,14 @@ public Configuration sectionOrNull(Configuration root, String key) {
@Override
public boolean hasDatabaseConfigured() {
// New-style: Database: { Host: ..., ... }
Configuration db = sectionOrNull(data, "Database");
if (db != null) {
return db.getString("Host", "") != null && !db.getString("Host", "").isEmpty();
}
Configuration db = sectionOrNull(data, "Database");
if (db != null) {
return !db.getString("Host", "").isEmpty() && !db.getString("Database", "").isEmpty();
}

// Legacy-style: Host/Port/... at root
String host = data.getString("Host", "");
return host != null && !host.isEmpty();
return host != null && !host.isEmpty() && !data.getString("Database", "").isEmpty();
}

}
Loading