From 63626db18a7e59ae281938e25597088cb0c6db28 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:11:15 -0600 Subject: [PATCH 01/11] Report bounded capability-negotiated setup health and proxy inventory --- AGENTS.md | 8 + .../backendproxy/BackendProxyHandler.java | 2 + .../BackendProxyTransportManager.java | 5 + .../control/BackendControlConnector.java | 11 +- .../control/ControlInspectionService.java | 130 +++++++++++- .../control/NetworkHealthConfigFacts.java | 54 +++++ .../control/NetworkHealthStorageFacts.java | 37 ++++ .../control/OptionalVotifierDiagnostics.java | 46 +++++ .../votingplugin/proxy/VotingPluginProxy.java | 16 ++ .../proxy/bungee/VotingPluginBungee.java | 14 +- .../proxy/control/ControlConnector.java | 187 ++++++++++++++++++ .../SharedTransportEnvelopeAuthenticator.java | 8 + .../proxy/velocity/VotingPluginVelocity.java | 13 ++ .../BackendControlConnectorProtocolTest.java | 7 + .../control/NetworkHealthConfigFactsTest.java | 53 +++++ .../OptionalVotifierDiagnosticsTest.java | 42 ++++ .../control/ControlNetworkHealthTest.java | 88 +++++++++ ...redTransportEnvelopeAuthenticatorTest.java | 8 + docs/control-agent-contract.md | 20 ++ docs/control-connector.md | 6 +- 20 files changed, 751 insertions(+), 4 deletions(-) create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/control/NetworkHealthConfigFacts.java create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/control/NetworkHealthStorageFacts.java create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/control/OptionalVotifierDiagnostics.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/control/NetworkHealthConfigFactsTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/control/OptionalVotifierDiagnosticsTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlNetworkHealthTest.java diff --git a/AGENTS.md b/AGENTS.md index 79ff954b0b..5654f1981b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -290,6 +290,14 @@ Report readiness for the exact commit that was validated and reviewed. Record th Do not commit server runtime data, credentials, generated JARs, dependency caches, IDE output, or unrelated formatting. +## Network Doctor diagnostics + +`network-health` requires both `data.inspect.v1` and `data.network-health.v1`. It is read-only, bounded, optional, and +compatible with unsupported peers in either deployment order. Omit unobserved facts; never infer runtime initialization +from persisted settings or adapter presence alone. Do not return raw configuration, logs, endpoints, credentials, keys or +certificates. Backend Votifier plus TriggerVotifierEvent is valid synthetic-event compatibility; proxy VotifierPlus socket +forwarding alongside VotingPlugin delivery is a separate duplicate risk. See the connector/agent contract docs. + ## Paired change and PR workflow The server-side peer is `BenCodez/VotingPlugin-Control`. When changing a DTO, endpoint, capability, preset, error code, or diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java index 4b2295dde7..a12ca373e1 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java @@ -1040,6 +1040,8 @@ public MySqlMessenger getBackendMysqlMessenger() { return transportManager.getBackendMysqlMessenger(); } + public String diagnosticSharedKeyFingerprint() { return transportManager.diagnosticSharedKeyFingerprint(); } + public MqttHandler getMqttHandler() { return transportManager.getMqttHandler(); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/BackendProxyTransportManager.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/BackendProxyTransportManager.java index 68ea776ee6..51d2998d30 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/BackendProxyTransportManager.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/BackendProxyTransportManager.java @@ -143,6 +143,11 @@ public synchronized boolean hasEquivalentSharedTransportEncryption( && policy.encryption().hasEquivalentInboundPolicy(encryption); } + public synchronized String diagnosticSharedKeyFingerprint() { + SharedInboundPolicy policy = sharedInboundPolicySnapshot(); + return policy == null || policy.authenticator() == null ? null : policy.authenticator().diagnosticKeyFingerprint(); + } + private synchronized SharedInboundPolicy sharedInboundPolicySnapshot() { if (transport instanceof RedisBackendProxyTransport redis) return redis.sharedInboundPolicySnapshot(); if (transport instanceof MqttBackendProxyTransport mqtt) return mqtt.sharedInboundPolicySnapshot(); 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 554a29cc07..e1705b0587 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java @@ -56,7 +56,9 @@ public final class BackendControlConnector implements AutoCloseable { 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.quick-setup.v2", "config.vote-sites-sync.v1", - "config.proxy-method.v1", "config.proxy-method.v2", "config.reward-files.v1", "data.inspect.v1"); + "config.proxy-method.v1", "config.proxy-method.v2", "config.reward-files.v1", "data.inspect.v1", + "data.network-health.v1"); + private static final String NETWORK_HEALTH_CAPABILITY = "data.network-health.v1"; private static final Set DEPLOYMENT_TASK_FIELDS = Set.of("deploymentId", "artifactId", "sha256", "size", "attemptId"); private final VotingPluginMain plugin; @@ -88,6 +90,7 @@ public final class BackendControlConnector implements AutoCloseable { private volatile boolean proxyMethodV2Accepted; private volatile boolean votePartySetupsAccepted; private volatile boolean voteSitesSyncAccepted; + private volatile boolean networkHealthAccepted; private volatile boolean rewardFilesAccepted; private volatile boolean inspectionsAccepted; private volatile boolean deploymentsAccepted; @@ -559,6 +562,10 @@ private void claimAndInspect() throws Exception { InspectionTaskResult executeInspection(JsonObject query) { try { + if (query != null && query.has("kind") && "network-health".equals(query.get("kind").getAsString()) + && !networkHealthAccepted) { + return InspectionTaskResult.failure("UNAVAILABLE", "Network health was not negotiated"); + } if (ControlInspectionService.rewardFileInventoryQuery(query) && !rewardFilesAccepted) { return InspectionTaskResult.failure("UNAVAILABLE", "Named reward files were not negotiated"); @@ -636,6 +643,7 @@ private void cycle() { voteSitesSyncAccepted = negotiatedCapability(node, "config.vote-sites-sync.v1", voteSitesSyncAccepted); rewardFilesAccepted = configurations.supportsNamedRewardFiles() && negotiatedCapability(node, "config.reward-files.v1", rewardFilesAccepted); + networkHealthAccepted = negotiatedCapability(node, NETWORK_HEALTH_CAPABILITY, networkHealthAccepted); boolean inspectionsWereAccepted = inspectionsAccepted; inspectionsAccepted = negotiatedCapability(node, "data.inspect.v1", inspectionsAccepted); deploymentsAccepted = deployments != null @@ -691,6 +699,7 @@ private JsonObject register() throws Exception { votePartySetupsAccepted = false; voteSitesSyncAccepted = false; rewardFilesAccepted = false; + networkHealthAccepted = false; inspectionsAccepted = false; deploymentsAccepted = false; JsonObject body = sessionBody(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java index 9fe7f5a2a6..7f87f3c727 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java @@ -55,7 +55,7 @@ public final class ControlInspectionService { private static final Pattern SERVICE_NAME = Pattern.compile("[^\\p{Cntrl}]{1,64}"); private static final Set KINDS = Set.of("overview", "vote-site-health", "player", "vote-log-summary", "vote-log-search", "vote-trace", "vote-site-resolution", - "reward-simulation", "reward-file-inventory", "diagnostics"); + "reward-simulation", "reward-file-inventory", "diagnostics", "network-health"); private final VotingPluginMain plugin; @@ -86,6 +86,7 @@ public JsonObject inspect(JsonObject query) { case "reward-simulation" -> rewardSimulation(filters); case "reward-file-inventory" -> rewardFileInventory(filters); case "diagnostics" -> diagnostics(filters); + case "network-health" -> networkHealth(filters); default -> throw new IllegalArgumentException("inspection kind is unsupported"); }; JsonObject envelope = new JsonObject(); @@ -129,6 +130,9 @@ private JsonObject overview(JsonObject filters) { result.addProperty("enabledVoteSites", loadedVoteSites().stream().filter(VoteSite::isEnabled).count()); result.addProperty("autoCreateVoteSites", plugin.getConfigFile().isAutoCreateVoteSites()); result.addProperty("processRewards", plugin.getConfigFile().getData().getBoolean("ProcessRewards", true)); + result.addProperty("allowUnjoined", plugin.getConfigFile().isAllowUnjoined()); + result.addProperty("extraAllSitesCheck", plugin.getConfigFile().isExtraAllSitesCheck()); + result.addProperty("monthDateTotals", plugin.getConfigFile().isStoreMonthTotalsWithDate()); result.addProperty("dataStorage", safe(plugin.getConfigFile().getData().getString("DataStorage", ""), 32)); result.addProperty("voteLoggingEnabled", voteLoggingEnabled); result.addProperty("voteLogAvailable", voteLogAvailable); @@ -492,6 +496,130 @@ private JsonObject diagnostics(JsonObject filters) { return result; } + /** + * Bounded, read-only configuration/runtime facts for Network Doctor. This + * deliberately omits values which are not available through a stable API; + * Control must treat omitted fields as UNKNOWN rather than inferred healthy. + */ + private JsonObject networkHealth(JsonObject filters) { + rejectUnknown(filters, Set.of(), "network health filters"); + JsonObject result = new JsonObject(); + result.addProperty("schemaVersion", 1); + result.addProperty("role", "BACKEND"); + result.addProperty("serverName", safe(plugin.getBungeeSettings().getServer(), 160)); + if (plugin.getBackendProxyHandler() != null && plugin.getBackendProxyHandler().getMethod() != null) + result.addProperty("activeMethod", plugin.getBackendProxyHandler().getMethod().name()); + result.addProperty("configuredMethod", networkMethod(plugin.getBungeeSettings().getBungeeMethod())); + result.addProperty("proxyMode", plugin.getBungeeSettings().isUseBungeecoord()); + result.addProperty("pluginMessageChannel", safe(plugin.getBungeeSettings().getPluginMessagingChannel(), 160)); + result.addProperty("encryption", plugin.getBungeeSettings().isCommunicationEncryption()); + String auth = plugin.getBungeeSettings().getSharedTransportAuthentication(); + result.addProperty("sharedAuthentication", "REQUIRED".equalsIgnoreCase(auth) ? "REQUIRED" : auth == null || auth.isBlank() || "COMPATIBILITY".equalsIgnoreCase(auth) ? "COMPATIBILITY" : "INVALID"); + result.addProperty("triggerVotifierEvent", plugin.getBungeeSettings().isTriggerVotifierEvent()); + result.addProperty("votifierProviderPresent", plugin.isVotifierLoaded()); + OptionalVotifierDiagnostics.add(result, java.util.Arrays.asList(plugin.getServer().getPluginManager().getPlugins())); + result.addProperty("configurationHealthy", !plugin.isYmlError()); + NetworkHealthConfigFacts.addBackend(result, plugin.getConfigFile().getData()); + NetworkHealthConfigFacts.addGlobal(result, plugin.getBungeeSettings().getData()); + result.addProperty("processRewards", plugin.getConfigFile().getData().getBoolean("ProcessRewards", true)); + result.addProperty("autoCreateVoteSites", plugin.getConfigFile().isAutoCreateVoteSites()); + result.addProperty("voteLoggingEnabled", plugin.getConfigFile().isVoteLoggingEnabled()); + result.addProperty("voteLogReadable", plugin.getConfigFile().isVoteLoggingEnabled() + && plugin.getVoteLogMysqlTable() != null && plugin.getVoteLogMysqlTable().isReadable()); + result.addProperty("globalDataEnabled", plugin.getBungeeSettings().isGloblalDataEnabled()); + result.addProperty("globalDataUseMainMysql", plugin.getBungeeSettings().isGloblalDataUseMainMySQL()); + result.addProperty("perServerRewards", plugin.getBungeeSettings().isPerServerRewards()); + result.addProperty("perServerPoints", plugin.getBungeeSettings().isPerServerPoints()); + result.addProperty("giveExtraAllSitesRewards", plugin.getBungeeSettings().isGiveExtraAllSitesRewards()); + result.addProperty("transportProbeState", "UNKNOWN"); + if (plugin.getBackendProxyHandler() != null) { + String fingerprint = plugin.getBackendProxyHandler().diagnosticSharedKeyFingerprint(); + if (fingerprint != null) result.addProperty("sharedKeyFingerprint", fingerprint); + } + if (plugin.getBackendProxyHandler() != null && plugin.getBackendProxyHandler().getMqttHandler() != null) + result.addProperty("transportInitialized", plugin.getBackendProxyHandler().getMqttHandler().isConnected()); + if (plugin.getStorageType() != null) result.addProperty("databaseType", plugin.getStorageType().name()); + if (plugin.getStorageType() == com.bencodez.advancedcore.api.user.UserStorage.MYSQL) + NetworkHealthStorageFacts.add(result, plugin.getMysql() == null || plugin.getMysql().getMysql() == null ? null : plugin.getMysql().getMysql().getConnectionManager()); + if ("REDIS".equals(networkMethod(plugin.getBungeeSettings().getBungeeMethod()))) { + NetworkHealthConfigFacts.addString(result, plugin.getBungeeSettings().getData(), "transportNamespace", "Redis.Prefix", 160); + result.addProperty("redisSsl", plugin.getBungeeSettings().isRedisSsl()); + } + if ("MQTT".equals(networkMethod(plugin.getBungeeSettings().getBungeeMethod()))) { + NetworkHealthConfigFacts.addString(result, plugin.getBungeeSettings().getData(), "transportNamespace", "MQTT.Prefix", 160); + NetworkHealthConfigFacts.addString(result, plugin.getBungeeSettings().getData(), "mqttClientId", "MQTT.ClientID", 160); + } + NetworkHealthConfigFacts.addString(result, plugin.getConfigFile().getData(), "databasePrefix", "Database.Prefix", 160); + JsonArray sites = new JsonArray(); + boolean siteComplete = allConfiguredVoteSiteNames().size() <= MAX_ROWS; + ConfigurationSection root = plugin.getConfigVoteSites().getData().getConfigurationSection("VoteSites"); + if (root != null) { + for (String name : allConfiguredVoteSiteNames().stream().limit(MAX_ROWS).toList()) { + ConfigurationSection site = root.getConfigurationSection(name); + if (site == null) continue; + JsonObject item = new JsonObject(); + if (name.length() > 80 || name.codePoints().anyMatch(Character::isISOControl)) { siteComplete = false; continue; } + item.addProperty("name", name); + String service = site.getString("ServiceSite", ""); + if (service.length() <= 160 && service.codePoints().noneMatch(Character::isISOControl)) item.addProperty("serviceSite", service); + else siteComplete = false; + item.addProperty("enabled", site.getBoolean("Enabled", true)); + item.addProperty("autoCreated", site.getBoolean("AutoCreated", false)); + item.addProperty("hasRewards", hasRewardConfiguration(site)); + Object rawDelay = site.get("VoteDelay"); + try { + boolean typed = rawDelay == null || rawDelay instanceof String || rawDelay instanceof Number; + Object rawMinutes = site.get("VoteDelayMin"); + typed &= rawMinutes == null || rawMinutes instanceof Number; + long millis = plugin.getConfigVoteSites().getVoteDelay(name).getMillis(); + boolean nonnegative = !(rawDelay instanceof Number number) || Double.isFinite(number.doubleValue()) && number.doubleValue() >= 0; + nonnegative &= !(rawMinutes instanceof Number number) || Double.isFinite(number.doubleValue()) && number.doubleValue() >= 0; + item.addProperty("delayValid", typed && nonnegative); + if (typed && nonnegative && millis >= 0) { + long hours = millis == 0 ? 0 : (millis - 1) / 3_600_000L + 1; + if (hours <= 1_000_000L) item.addProperty("delayHours", (int) hours); + } + } catch (IllegalArgumentException failure) { item.addProperty("delayValid", false); } + String url = site.getString("VoteURL", ""); + item.addProperty("voteUrlState", url.isBlank() ? "BLANK" + : url.toLowerCase(Locale.ROOT).contains("example") ? "EXAMPLE" : validVoteUrl(url) ? "VALID" : "INVALID"); + sites.add(item); + } + } + result.add("voteSites", sites); + result.addProperty("voteSitesComplete", siteComplete); + JsonArray detectedServices = new JsonArray(); + java.util.TreeMap observedServices = new java.util.TreeMap<>(String.CASE_INSENSITIVE_ORDER); + for (String observed : plugin.getServerData().getServiceSitesReadOnly()) { + if (observed == null || observed.isBlank() || observed.length() > 80 + || observed.codePoints().anyMatch(Character::isISOControl)) { observedServices.clear(); break; } + observedServices.putIfAbsent(observed, observed); + } + if (observedServices.size() <= MAX_ROWS) { + observedServices.values().forEach(value -> detectedServices.add(value)); + result.add("detectedServices", detectedServices); + } + JsonArray plugins = new JsonArray(); + java.util.Arrays.stream(plugin.getServer().getPluginManager().getPlugins()) + .map(Plugin::getName).filter(name -> name != null && !name.isBlank()).distinct() + .sorted(String.CASE_INSENSITIVE_ORDER.thenComparing(Comparator.naturalOrder())).limit(MAX_ROWS) + .forEach(name -> plugins.add(safe(name, 80))); + result.add("detectedPlugins", plugins); + return result; + } + + private static boolean validVoteUrl(String value) { + try { java.net.URI uri = java.net.URI.create(value); return ("https".equalsIgnoreCase(uri.getScheme()) || "http".equalsIgnoreCase(uri.getScheme())) && uri.getHost() != null; } + catch (IllegalArgumentException failure) { return false; } + } + + private static String networkMethod(String value) { + if (value == null) return "INVALID"; + String normalized = value.trim().toUpperCase(Locale.ROOT); + return Set.of("PLUGINMESSAGING", "HTTP", "REDIS", "MQTT", "SOCKETS", "MYSQL").contains(normalized) + ? normalized : "INVALID"; + } + private List allConfiguredVoteSiteNames() { ConfigurationSection root = plugin.getConfigVoteSites().getData().getConfigurationSection("VoteSites"); if (root == null) return List.of(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/NetworkHealthConfigFacts.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/NetworkHealthConfigFacts.java new file mode 100644 index 0000000000..6e5d9deb62 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/NetworkHealthConfigFacts.java @@ -0,0 +1,54 @@ +package com.bencodez.votingplugin.control; + +import org.bukkit.configuration.file.FileConfiguration; + +import com.google.gson.JsonObject; + +/** Small fixed allow-list of non-secret config facts for Network Doctor. */ +final class NetworkHealthConfigFacts { + private NetworkHealthConfigFacts() { } + + static void addBackend(JsonObject out, FileConfiguration config) { + addBoolean(out, config, "onlineMode", "OnlineMode"); + addString(out, config, "bedrockPlayerPrefix", "BedrockPlayerPrefix", 80); + addInt(out, config, "timeHourOffset", "TimeHourOffSet"); + addBoolean(out, config, "resetMilestonesMonthly", "ResetMilestonesMonthly"); + addBoolean(out, config, "monthDateTotals", "UseMonthDateTotalsAsPrimaryTotal"); + addBoolean(out, config, "extraAllSitesCheck", "ExtraAllSitesCheck"); + addBoolean(out, config, "allowUnjoined", "AllowUnjoined"); + addInt(out, config, "maxVotesPerDay", "MaxAmountOfVotesPerDay"); + addBoolean(out, config, "votePartyEnabled", "VoteParty.Enabled"); + addInt(out, config, "votePartyVotesRequired", "VoteParty.VotesRequired"); + } + + static void addGlobal(JsonObject out, FileConfiguration config) { + addString(out, config, "globalDataPrefix", "GlobalData.Prefix", 80); + addBoolean(out, config, "globalDataUseMainMysql", "GlobalData.UseMainMySQL"); + } + + private static void addBoolean(JsonObject out, FileConfiguration c, String field, String path) { + if (c.isBoolean(path)) out.addProperty(field, c.getBoolean(path)); + else if (c.contains(path)) invalid(out, path); + } + private static void addInt(JsonObject out, FileConfiguration c, String field, String path) { + Object value = c.get(path); + if (value instanceof Integer || value instanceof Long) { + long count = ((Number)value).longValue(); + if (count >= -1_000_000_000L && count <= 1_000_000_000L + && ("timeHourOffset".equals(field) || count >= 0)) out.addProperty(field, count); + else invalid(out, path); + } else if (c.contains(path)) invalid(out, path); + } + static void addString(JsonObject out, FileConfiguration c, String field, String path, int max) { + if (!c.isString(path)) { if (c.contains(path)) invalid(out, path); return; } + String value = c.getString(path); + if (value != null && value.length() <= max && value.chars().noneMatch(Character::isISOControl)) { + out.addProperty(field, value); + } else invalid(out, path); + } + private static void invalid(JsonObject out, String path) { + com.google.gson.JsonArray errors = out.has("invalidConfigurationFields") ? out.getAsJsonArray("invalidConfigurationFields") : new com.google.gson.JsonArray(); + if (errors.size() < 100 && errors.asList().stream().noneMatch(v -> v.getAsString().equals(path))) errors.add(path); + out.add("invalidConfigurationFields", errors); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/NetworkHealthStorageFacts.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/NetworkHealthStorageFacts.java new file mode 100644 index 0000000000..298633d1dc --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/NetworkHealthStorageFacts.java @@ -0,0 +1,37 @@ +package com.bencodez.votingplugin.control; + +import com.bencodez.simpleapi.sql.mysql.ConnectionManager; +import com.google.gson.JsonObject; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; + +/** Existing pool state only: never acquires a connection or initializes storage. */ +public final class NetworkHealthStorageFacts { + private NetworkHealthStorageFacts() { } + public static void add(JsonObject out, ConnectionManager manager) { + if (manager == null) { out.addProperty("databaseInitialized", false); return; } + out.addProperty("databaseInitialized", !manager.isClosed()); + if (manager.getDbType() != null) { + out.addProperty("databaseType", manager.getDbType().name()); + String override = manager.getMysqlDriver(); + boolean present; + if (override != null && !override.isBlank()) present = driver(override); + else present = switch (manager.getDbType()) { + case MYSQL -> driver("com.mysql.cj.jdbc.Driver"); + case MARIADB -> driver("org.mariadb.jdbc.Driver") || manager.isMariadbFallbackToMysqlDriver() && driver("com.mysql.cj.jdbc.Driver"); + case POSTGRESQL -> driver("org.postgresql.Driver"); + }; + out.addProperty("jdbcDriverAvailable", !manager.isClosed() || present); + try { + String identity = "VotingPlugin-storage-v1\0" + (manager.getDbType().isMySqlFamily() ? "MYSQL" : manager.getDbType().name()) + "\0" + manager.getHost() + + "\0" + manager.getPort() + "\0" + manager.getDatabase(); + out.addProperty("storageFingerprint", HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(identity.getBytes(StandardCharsets.UTF_8)))); + } catch (java.security.NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); } + } + } + private static boolean driver(String name) { + try { Class.forName(name, false, NetworkHealthStorageFacts.class.getClassLoader()); return true; } + catch (ClassNotFoundException | LinkageError unavailable) { return false; } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/OptionalVotifierDiagnostics.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/OptionalVotifierDiagnostics.java new file mode 100644 index 0000000000..67d645fc4e --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/OptionalVotifierDiagnostics.java @@ -0,0 +1,46 @@ +package com.bencodez.votingplugin.control; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.util.*; + +/** Optional names-only API: unsupported/partial snapshots never prove empty forwarding. */ +public final class OptionalVotifierDiagnostics { + private OptionalVotifierDiagnostics() { } + public static void add(JsonObject out, Iterable providers) { + int count = 0; + for (Object candidate : providers) { + if (++count > 128) return; + if (candidate == null || !candidate.getClass().getName().toLowerCase(Locale.ROOT).contains("votifier")) continue; + try { + Object snapshot = candidate.getClass().getMethod("getNetworkHealthSnapshot").invoke(candidate); + if (snapshot == null) continue; + JsonObject facts = new JsonObject(); + addBoolean(facts, "votifierProviderPresent", snapshot, "getProviderPresent"); + addBoolean(facts, "votifierListenerInitialized", snapshot, "getListenerInitialized"); + addBoolean(facts, "votifierForwardingKnown", snapshot, "getForwardingKnown"); + Object raw = snapshot.getClass().getMethod("getForwardingDestinations").invoke(snapshot); + JsonArray names = new JsonArray(); boolean complete = raw instanceof Iterable; + if (raw instanceof Iterable values) { + Set seen = new HashSet<>(); int examined = 0; + for (Object value : values) { + if (++examined > 100) { complete = false; break; } + if (!(value instanceof String name) || name.isBlank() || name.length() > 80 + || name.codePoints().anyMatch(Character::isISOControl) || !seen.add(name)) { complete = false; continue; } + names.add(name); + } + } + if (!complete) facts.addProperty("votifierForwardingKnown", false); + facts.add("forwardingDestinations", names); + facts.entrySet().forEach(field -> out.add(field.getKey(), field.getValue())); + return; + } catch (ReflectiveOperationException | RuntimeException ignored) { + // No secrets or exception messages cross the optional boundary. + } + } + } + private static void addBoolean(JsonObject out, String field, Object snapshot, String getter) throws ReflectiveOperationException { + Object value = snapshot.getClass().getMethod(getter).invoke(snapshot); + if (value instanceof Boolean fact) out.addProperty(field, fact); + } +} 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 cf9fd7dca3..3487ec00e5 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java @@ -1683,6 +1683,12 @@ public UUID fetchUUID(String playerName) throws IOException, InterruptedExceptio return parseUUIDFromString(uuidAsString); } + /** Reads the already loaded authenticator; does not initialize a transport. */ + public String diagnosticSharedKeyFingerprint() { + SharedTransportEnvelopeAuthenticator current = sharedTransportAuthenticator; + return current == null ? null : current.diagnosticKeyFingerprint(); + } + public abstract Set getAllAvailableServers(); /** Complete platform server set before whitelist/blocked routing filters. */ @@ -4877,6 +4883,16 @@ private void warnUnsupportedDedicatedVotingProxyMode() { /** Platform name used only for the transport-neutral Control discovery contract. */ public abstract String getProxyPlatform(); + /** Bounded plugin inventory used only by optional Control diagnostics. */ + public Collection getInstalledPluginNames() { + return List.of(); + } + + /** Optional platform plugin objects used for reflective management diagnostics. */ + public Collection getDiagnosticProviders() { + return List.of(); + } + public abstract void runConsoleCommand(String command); /** Completion boundary used before durable HTTP vote-party command progress is advanced. */ diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungee.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungee.java index 321cbe3107..48368793cb 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungee.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungee.java @@ -892,11 +892,23 @@ public VotingPluginProxyConfig getConfig() { return config; } - @Override + @Override public String getProxyPlatform() { return "BUNGEECORD"; } + @Override + public Collection getInstalledPluginNames() { + return getProxy().getPluginManager().getPlugins().stream() + .map(plugin -> plugin.getDescription().getName()) + .filter(name -> name != null && !name.isBlank()).limit(128).toList(); + } + + @Override + public Collection getDiagnosticProviders() { + return new ArrayList<>(getProxy().getPluginManager().getPlugins()); + } + @Override public String getCurrentPlayerServer(String player) { ProxiedPlayer p = getProxy().getPlayer(player); 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 0d8b94da07..c0215e8655 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 @@ -67,6 +67,7 @@ public final class ControlConnector implements AutoCloseable { private static final String PROXY_METHOD_CAPABILITY = "config.proxy-method.v1"; private static final String PROXY_METHOD_HTTP_CAPABILITY = "config.proxy-method.v2"; private static final String PROXY_FILE_CAPABILITY = "config.proxy-files.v1"; + private static final String NETWORK_HEALTH_CAPABILITY = "data.network-health.v1"; private static final String PROXY_METHOD_PRESET = "proxy-method"; private static final String INTERNAL_OPERATION_TYPE = "_controlOperationType"; private static final String INTERNAL_REQUIRED_CAPABILITY = "_controlRequiredCapability"; @@ -76,6 +77,7 @@ public final class ControlConnector implements AutoCloseable { private static final long OPERATION_SHUTDOWN_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(65); private final Settings settings; + private VotingPluginProxy proxy; private final ScheduledExecutorService scheduler; private final Transport transport; private final Supplier> snapshotSource; @@ -100,6 +102,8 @@ public final class ControlConnector implements AutoCloseable { private final Map completedTasks = new LinkedHashMap<>(); private final Object operationLifecycle = new Object(); private final AtomicBoolean inFlight = new AtomicBoolean(); + private final AtomicBoolean inspectionInFlight = new AtomicBoolean(); + private final Object inspectionLifecycle = new Object(); private final AtomicBoolean recoveryCompleted = new AtomicBoolean(); private Runnable deferredReplacement; private volatile boolean closed; @@ -112,6 +116,10 @@ public final class ControlConnector implements AutoCloseable { private volatile ScheduledFuture scheduled; private volatile ScheduledFuture operationPolling; private volatile ScheduledFuture deploymentPolling; + private volatile ScheduledFuture inspectionPolling; + private volatile int inspectionFailures; + private volatile long inspectionRetryAtNanos; + private volatile CompletableFuture activeInspection; private volatile CompletableFuture activeRequest; /* The deployment executor may discard queued AsyncSupply work during shutdown. * Retain its future so close() can complete the dependent operation chain. */ @@ -281,6 +289,7 @@ public static ControlConnector create(VotingPluginProxy proxy) throws IOExceptio server -> proxy.testBackendCommunication(server, 5000L), new ProxyMethodConfigurationService(proxy), () -> proxy.reloadCore(true), new ProxyConfigurationFileService(proxy), deployments, deploymentHttp, credential, directLocalDeploymentEndpoint, allowInsecureHttpPluginDeployment); + connector.proxy = proxy; if (recovered != null) connector.completedTasks.putAll(recovered.results()); return connector; } @@ -297,6 +306,168 @@ public void start() { deploymentPolling = scheduler.scheduleWithFixedDelay(this::pollDeployments, OPERATION_POLL_MILLIS, OPERATION_POLL_MILLIS, TimeUnit.MILLISECONDS); } + inspectionPolling = scheduler.scheduleWithFixedDelay(this::pollNetworkHealth, 5000, 5000, + TimeUnit.MILLISECONDS); + } + + private void pollNetworkHealth() { + if (closed || !registered || status != Status.CONNECTED || !acceptedCapabilities.contains(NETWORK_HEALTH_CAPABILITY) + || !acceptedCapabilities.contains("data.inspect.v1") + || System.nanoTime() < inspectionRetryAtNanos + || !inspectionInFlight.compareAndSet(false, true)) return; + try { + JsonObject claim = new JsonObject(); + claim.addProperty("sessionId", sessionId.toString()); + CompletableFuture inspectionRequest = sendInspectionRequest(new Request("POST", "/api/v1/nodes/" + settings.nodeId() + "/inspections", claim.toString())); + inspectionRequest + .thenCompose(response -> { + if (closed || response.statusCode == 204) return CompletableFuture.completedFuture(null); + requireSuccess(response); + JsonObject task = parseObject(response.body); + if (!task.keySet().equals(Set.of("inspectionId", "attemptId", "query"))) throw new ProtocolException(); + String id = requireString(task, "inspectionId"), attempt = requireString(task, "attemptId"); + if (!UUID.fromString(id).toString().equals(id) || !UUID.fromString(attempt).toString().equals(attempt)) throw new ProtocolException(); + JsonObject query = task.getAsJsonObject("query"); + if (query == null || !query.keySet().equals(Set.of("kind", "filters")) + || !query.get("filters").isJsonObject() || !query.getAsJsonObject("filters").keySet().isEmpty()) throw new ProtocolException(); + String kind = requireString(query, "kind"); + JsonObject result; + if ("network-health".equals(kind)) result = networkHealthResult(); + else { result = new JsonObject(); result.addProperty("success", false); result.addProperty("code", "UNSUPPORTED"); result.addProperty("message", "Proxy inspection kind is unsupported"); } + result.addProperty("sessionId", sessionId.toString()); + result.addProperty("attemptId", task.get("attemptId").getAsString()); + return sendInspectionRequest(new Request("POST", "/api/v1/nodes/" + settings.nodeId() + "/inspections/" + + task.get("inspectionId").getAsString() + "/result", result.toString())) + .thenAccept(ControlConnector::requireSuccess); + }) + .whenComplete((ignored, failure) -> { + if (failure == null) { inspectionFailures = 0; inspectionRetryAtNanos = 0; } + else { inspectionFailures = Math.min(30, inspectionFailures + 1); inspectionRetryAtNanos = System.nanoTime() + + TimeUnit.MILLISECONDS.toNanos(Math.min(MAX_BACKOFF_MILLIS, 1000L << Math.min(inspectionFailures - 1, 8))); } + inspectionInFlight.set(false); + }); + } catch (RuntimeException failure) { + inspectionFailures = Math.min(30, inspectionFailures + 1); + inspectionRetryAtNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(1000L << Math.min(inspectionFailures - 1, 8)); + inspectionInFlight.set(false); + } + } + + private CompletableFuture sendInspectionRequest(Request request) { + synchronized (inspectionLifecycle) { + if (closed) return CompletableFuture.failedFuture(new IllegalStateException("Connector closed")); + CompletableFuture future = transport.send(request); + activeInspection = future; + return future; + } + } + + private JsonObject networkHealthResult() { + JsonObject result = new JsonObject(); + result.addProperty("success", true); + result.addProperty("code", "OK"); + result.addProperty("message", "Network health inspection completed"); + JsonObject envelope = new JsonObject(); + envelope.addProperty("schemaVersion", 1); + envelope.addProperty("kind", "network-health"); + envelope.addProperty("generatedAt", java.time.Instant.now().toString()); + envelope.add("result", proxyNetworkHealth()); + result.add("data", envelope); + return result; + } + + private JsonObject proxyNetworkHealth() { + JsonObject result = new JsonObject(); + result.addProperty("schemaVersion", 1); + result.addProperty("role", "PROXY"); + if (proxy == null) return result; + result.addProperty("configuredMethod", methodValue(settingsMethod())); + if (proxy.getMethod() != null) result.addProperty("activeMethod", proxy.getMethod().name()); + if (proxy.getMqttHandler() != null && proxy.getMethod() == com.bencodez.votingplugin.proxy.BungeeMethod.MQTT) + result.addProperty("transportInitialized", proxy.getMqttHandler().isConnected()); + addSafeString(result, "pluginMessageChannel", proxy.getConfig().getPluginMessageChannel()); + result.addProperty("onlineMode", proxy.getConfig().getOnlineMode()); + result.addProperty("encryption", proxy.getConfig().getCommunicationEncryption()); + String auth = proxy.getConfig().getSharedTransportAuthentication(); + result.addProperty("sharedAuthentication", "REQUIRED".equalsIgnoreCase(auth) ? "REQUIRED" : auth == null || auth.isBlank() || "COMPATIBILITY".equalsIgnoreCase(auth) ? "COMPATIBILITY" : "INVALID"); + com.bencodez.votingplugin.control.NetworkHealthStorageFacts.add(result, proxy.getProxyMySQL() == null || proxy.getProxyMySQL().getMysql() == null ? null : proxy.getProxyMySQL().getMysql().getConnectionManager()); + result.addProperty("bungeeManageTotals", proxy.getConfig().getBungeeManageTotals()); + result.addProperty("dedicatedVotingProxy", proxy.getConfig().getDedicatedVotingProxy()); + result.addProperty("multiProxySupport", proxy.getConfig().getMultiProxySupport()); + String multiMethod = proxy.getConfig().getMultiProxyMethod(); + result.addProperty("multiProxyMethod", "SOCKET".equalsIgnoreCase(multiMethod) ? "SOCKET" : "REDIS".equalsIgnoreCase(multiMethod) ? "REDIS" : "INVALID"); + result.addProperty("primaryServer", proxy.getConfig().getPrimaryServer()); + result.addProperty("timeHourOffset", proxy.getConfig().getTimeHourOffSet()); + result.addProperty("monthDateTotals", proxy.getConfig().getUseMonthDateTotalsAsPrimaryTotal()); + result.addProperty("allowUnjoined", proxy.getConfig().getAllowUnJoined()); + result.addProperty("waitForUserOnline", proxy.getConfig().getWaitForUserOnline()); + result.addProperty("voteCacheMysql", proxy.getConfig().getVoteCacheUseMySQL()); + result.addProperty("voteCacheMainMysql", proxy.getConfig().getVoteCacheUseMainMySQL()); + result.addProperty("nonVotedCacheMysql", proxy.getConfig().getNonVotedCacheUseMySQL()); + result.addProperty("nonVotedCacheMainMysql", proxy.getConfig().getNonVotedCacheUseMainMySQL()); + result.addProperty("globalDataEnabled", proxy.getConfig().getGlobalDataEnabled()); + result.addProperty("globalDataUseMainMysql", proxy.getConfig().getGlobalDataUseMainMySQL()); + result.addProperty("votePartyEnabled", proxy.getConfig().getVotePartyEnabled()); + result.addProperty("votePartyVotesRequired", Math.max(0, proxy.getConfig().getVotePartyVotesRequired())); + if (!proxy.getConfig().getVotePartyBungeeCommands().isEmpty()) result.addProperty("votePartyEffectiveReward", true); + addNames(result, "votePartyServers", proxy.getConfig().getVotePartyServersToSend()); + addSafeString(result, "bedrockPlayerPrefix", proxy.getConfig().getBedrockPlayerPrefix()); + if ("REDIS".equals(methodValue(settingsMethod()))) { addSafeString(result, "transportNamespace", proxy.getConfig().getRedisPrefix()); result.addProperty("redisSsl", proxy.getConfig().getRedisSsl()); } + if ("MQTT".equals(methodValue(settingsMethod()))) { addSafeString(result, "transportNamespace", proxy.getConfig().getMqttPrefix()); addSafeString(result, "mqttClientId", proxy.getConfig().getMqttClientID()); } + if ("HTTP".equals(methodValue(settingsMethod()))) result.addProperty("httpPublicEndpointConfigured", proxy.getConfig().getHttpPublicEndpoint() != null && !proxy.getConfig().getHttpPublicEndpoint().isBlank()); + result.addProperty("multiProxyOneGlobalReward", proxy.getConfig().getMultiProxyOneGlobalReward()); + result.addProperty("sendVotesToAllServers", proxy.getConfig().getSendVotesToAllServers()); + addSafeString(result, "proxyServerName", proxy.getConfig().getProxyServerName()); + addNames(result, "proxyServers", proxy.getConfig().getProxyServers()); + addNames(result, "blockedServers", proxy.getConfig().getBlockedServers()); + addNames(result, "whitelistedServers", proxy.getConfig().getWhiteListedServers()); + addNames(result, "broadcastServers", proxy.getConfig().getProxyBroadcastScopeServers()); + addNames(result, "offlineForwardServers", proxy.getConfig().getProxyBroadcastOfflineForwardServers()); + java.util.Set backendNames = proxy.getAllConfiguredServers(); + addNames(result, "backendNames", new ArrayList<>(backendNames)); + result.addProperty("topologyComplete", result.has("backendNames")); + addVotifierDiagnostics(result); + result.addProperty("transportProbeState", "UNKNOWN"); + String fingerprint = proxy.diagnosticSharedKeyFingerprint(); + if (fingerprint != null && Set.of("REDIS", "MQTT").contains(methodValue(settingsMethod()))) result.addProperty("sharedKeyFingerprint", fingerprint); + if (fingerprint != null && "REDIS".equalsIgnoreCase(proxy.getConfig().getMultiProxyMethod())) result.addProperty("multiProxyKeyFingerprint", fingerprint); + JsonArray plugins = new JsonArray(); + proxyPluginNames().stream().distinct().sorted(String.CASE_INSENSITIVE_ORDER).limit(100) + .forEach(name -> plugins.add(bounded(name, 80))); + result.add("detectedPlugins", plugins); + return result; + } + + private static void addNames(JsonObject result, String field, List values) { + if (values == null || values.size() > 100) return; + JsonArray names = new JsonArray(); + for (String value : values) { + if (value == null || value.isBlank() || value.length() > 80) return; + if (value.codePoints().anyMatch(Character::isISOControl) || names.asList().stream().anyMatch(v -> v.getAsString().equals(value))) return; + names.add(value); + } + result.add(field, names); + } + + private void addVotifierDiagnostics(JsonObject result) { + List names = proxyPluginNames(); + if (names.size() <= 100) result.addProperty("votifierProviderPresent", names.stream() + .anyMatch(name -> Set.of("votifier", "votifierplus", "nuvotifier").contains(name.toLowerCase(java.util.Locale.ROOT)))); + com.bencodez.votingplugin.control.OptionalVotifierDiagnostics.add(result, proxy.getDiagnosticProviders()); + } + private static String methodValue(String value) { + String normalized = value == null ? "" : value.trim().toUpperCase(java.util.Locale.ROOT); + return Set.of("PLUGINMESSAGING", "HTTP", "REDIS", "MQTT", "SOCKETS", "MYSQL").contains(normalized) ? normalized : "INVALID"; + } + private static void addSafeString(JsonObject result, String field, String value) { + if (value != null && value.length() <= 160 && value.codePoints().noneMatch(Character::isISOControl)) result.addProperty(field, value); + } + + private String settingsMethod() { return proxy.getConfig().getBungeeMethod(); } + private static String bounded(String value, int max) { + if (value == null) return ""; + String clean = value.replaceAll("\\p{Cntrl}", "").trim(); + return clean.length() <= max ? clean : clean.substring(0, max); } /** Keeps large artifact transfer and disk staging off proxy event threads and serializes it with operations. */ @@ -676,10 +847,18 @@ private Request registrationRequest() { body.addProperty("displayName", settings.displayName()); body.addProperty("platform", settings.platform()); body.addProperty("pluginVersion", settings.pluginVersion()); + JsonArray detectedPlugins = new JsonArray(); + proxyPluginNames().stream().distinct().sorted(String.CASE_INSENSITIVE_ORDER).limit(128) + .forEach(detectedPlugins::add); + body.add("detectedPlugins", detectedPlugins); addCapabilities(body); return new Request("POST", "/api/v1/nodes/register", body.toString()); } + private List proxyPluginNames() { + return proxy == null ? List.of() : List.copyOf(proxy.getInstalledPluginNames()); + } + private Request heartbeatRequest() { JsonObject body = sessionBody(); addCapabilities(body); @@ -1289,6 +1468,8 @@ static void addCapabilities(JsonObject body, boolean configurationReady, boolean } if (fileReady) advertised.add(PROXY_FILE_CAPABILITY); if (deploymentReady) advertised.add(PluginDeploymentService.CAPABILITY); + advertised.add(NETWORK_HEALTH_CAPABILITY); + advertised.add("data.inspect.v1"); body.add("capabilities", advertised); JsonArray required = new JsonArray(); required.add("presence.snapshot"); @@ -1330,6 +1511,8 @@ public void close() { if (polling != null) polling.cancel(false); ScheduledFuture deployment = deploymentPolling; if (deployment != null) deployment.cancel(false); + ScheduledFuture inspection = inspectionPolling; + if (inspection != null) inspection.cancel(false); if (deployments != null) deployments.cancel(); CompletableFuture deploymentWork = activeDeploymentWork; if (deploymentWork != null) deploymentWork.cancel(true); @@ -1338,6 +1521,10 @@ public void close() { if (request != null) { request.cancel(true); } + synchronized (inspectionLifecycle) { + CompletableFuture inspectionRequest = activeInspection; + if (inspectionRequest != null) inspectionRequest.cancel(true); + } CompletableFuture operation = activeOperation; if (operation != null) { try { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/security/SharedTransportEnvelopeAuthenticator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/security/SharedTransportEnvelopeAuthenticator.java index d8de2e6246..933aa4f8ec 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/security/SharedTransportEnvelopeAuthenticator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/security/SharedTransportEnvelopeAuthenticator.java @@ -114,6 +114,14 @@ private SharedTransportEnvelopeAuthenticator(byte[] masterKey, Mode mode, Clock this.maxReplayEntries = maxReplayEntries; } + /** A diagnostic equality hint for the loaded key; never returns key material. */ + public String diagnosticKeyFingerprint() { + byte[] key = domainKeys.get(Domain.REDIS_PROXY_BACKEND); + if (key == null) return null; + try { return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(key)); } + catch (java.security.NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); } + } + public static SharedTransportEnvelopeAuthenticator load(Path keyFile, Mode mode) throws IOException { if (!Files.isRegularFile(keyFile)) { if (mode == Mode.COMPATIBILITY) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java index b1ea08a776..7b3c4edeb1 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java @@ -977,6 +977,19 @@ public String getProxyPlatform() { return "VELOCITY"; } + @Override + public Collection getInstalledPluginNames() { + return server.getPluginManager().getPlugins().stream() + .map(plugin -> plugin.getDescription().getName().orElse(plugin.getInstance().map(Object::getClass) + .map(Class::getSimpleName).orElse("unknown"))) + .filter(name -> name != null && !name.isBlank()).limit(128).toList(); + } + + @Override + public Collection getDiagnosticProviders() { + return new ArrayList<>(server.getPluginManager().getPlugins()); + } + @Override public String getCurrentPlayerServer(String player) { if (server.getPlayer(player).isPresent()) { 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 04f6d77bd1..ef11d49e2b 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java @@ -274,6 +274,8 @@ private static JsonObject deploymentTask() { .anyMatch(value -> "config.reward-files.v1".equals(value.getAsString()))); assertTrue(advertised.asList().stream() .anyMatch(value -> "data.inspect.v1".equals(value.getAsString()))); + assertTrue(advertised.asList().stream() + .anyMatch(value -> "data.network-health.v1".equals(value.getAsString()))); JsonArray required = registration.getAsJsonArray("requiredCapabilities"); assertTrue(required.asList().stream() .anyMatch(value -> "config.files.v1".equals(value.getAsString()))); @@ -422,6 +424,11 @@ private static JsonObject deploymentTask() { BackendControlConnector.operationFailureMessage("READ", new IOException("/srv/private/Config.yml"))); } + @Test void networkHealthRequiresItsExplicitCapability() { + BackendControlConnector connector = org.mockito.Mockito.mock(BackendControlConnector.class, org.mockito.Mockito.CALLS_REAL_METHODS); + assertEquals("UNAVAILABLE", connector.executeInspection(JsonParser.parseString("{\"kind\":\"network-health\",\"filters\":{}}").getAsJsonObject()).code()); + } + @Test void namedRewardInventoryRequiresItsExplicitCapability() { assertTrue(ControlInspectionService.rewardFileInventoryQuery(JsonParser.parseString( "{\"kind\":\"reward-file-inventory\"}").getAsJsonObject())); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/NetworkHealthConfigFactsTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/NetworkHealthConfigFactsTest.java new file mode 100644 index 0000000000..cb08135343 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/NetworkHealthConfigFactsTest.java @@ -0,0 +1,53 @@ +package com.bencodez.votingplugin.control; + +import static org.junit.jupiter.api.Assertions.*; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; + +import com.google.gson.JsonObject; + +class NetworkHealthConfigFactsTest { + @Test void onlyTypedAllowlistedBackendFactsAreEmitted() { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("OnlineMode", true); + yaml.set("MaxAmountOfVotesPerDay", "not-an-int"); + yaml.set("Database.Password", "secret"); + yaml.set("BedrockPlayerPrefix", "x".repeat(81)); + JsonObject out = new JsonObject(); + NetworkHealthConfigFacts.addBackend(out, yaml); + assertTrue(out.get("onlineMode").getAsBoolean()); + assertFalse(out.has("maxVotesPerDay")); + assertFalse(out.has("Database.Password")); + assertFalse(out.has("bedrockPlayerPrefix")); + } + + @Test void globalFactsRejectWrongTypesAndLongValues() { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("GlobalData.Prefix", "p".repeat(81)); + yaml.set("GlobalData.UseMainMySQL", "yes"); + JsonObject out = new JsonObject(); + NetworkHealthConfigFacts.addGlobal(out, yaml); + assertFalse(out.has("globalDataPrefix")); + assertFalse(out.has("globalDataUseMainMysql")); + } + + @Test void integerFactsRemainBoundedToContractRange() { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("TimeHourOffSet", Integer.MAX_VALUE); + JsonObject out = new JsonObject(); + NetworkHealthConfigFacts.addBackend(out, yaml); + assertFalse(out.has("timeHourOffset")); + yaml.set("TimeHourOffSet", -3); + NetworkHealthConfigFacts.addBackend(out, yaml); + assertEquals(-3, out.get("timeHourOffset").getAsInt()); + } + + @Test void longMinimumDoesNotPassAbsoluteValueBound() { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("TimeHourOffSet", Long.MIN_VALUE); + JsonObject out = new JsonObject(); + NetworkHealthConfigFacts.addBackend(out, yaml); + assertFalse(out.has("timeHourOffset")); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/OptionalVotifierDiagnosticsTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/OptionalVotifierDiagnosticsTest.java new file mode 100644 index 0000000000..57b5434712 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/OptionalVotifierDiagnosticsTest.java @@ -0,0 +1,42 @@ +package com.bencodez.votingplugin.control; +import com.google.gson.JsonObject; +import java.util.*; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +class OptionalVotifierDiagnosticsTest { + public static class VotifierProvider { + private final Object snapshot; + VotifierProvider(Object snapshot) { this.snapshot = snapshot; } + public Object getNetworkHealthSnapshot() { return snapshot; } + } + public static class Snapshot { + private final Iterable names; + Snapshot(Iterable names) { this.names = names; } + public Boolean getProviderPresent() { return true; } + public Boolean getListenerInitialized() { return true; } + public Boolean getForwardingKnown() { return true; } + public Iterable getForwardingDestinations() { return names; } + } + @Test void oldProviderApiIsUnknownAndValidNewApiContainsOnlyAllowlistedFacts() { + JsonObject old = new JsonObject(); OptionalVotifierDiagnostics.add(old, List.of(new Object())); assertEquals(0, old.size()); + JsonObject current = new JsonObject(); OptionalVotifierDiagnostics.add(current, List.of(new VotifierProvider(new Snapshot(List.of("backend-a"))))); + assertEquals(Set.of("votifierProviderPresent", "votifierListenerInitialized", "votifierForwardingKnown", "forwardingDestinations"), current.keySet()); + assertTrue(current.get("votifierForwardingKnown").getAsBoolean()); assertEquals("backend-a", current.getAsJsonArray("forwardingDestinations").get(0).getAsString()); + } + @Test void invalidDuplicateOrOversizedApiEvidenceNeverProvesEmptyForwarding() { + for (Iterable names : List.of(List.of("backend-a", "backend-a"), List.of("a\nb"), java.util.stream.IntStream.range(0, 101).mapToObj(i -> "backend-" + i).toList())) { + JsonObject current = new JsonObject(); OptionalVotifierDiagnostics.add(current, List.of(new VotifierProvider(new Snapshot(names)))); + assertFalse(current.get("votifierForwardingKnown").getAsBoolean()); assertTrue(current.getAsJsonArray("forwardingDestinations").size() <= 100); + } + } + @Test void consumesCandidateSnapshotWithoutACompileTimeDependency() throws Exception { + String candidate = System.getProperty("votifier.health.candidate"); + org.junit.jupiter.api.Assumptions.assumeTrue(candidate != null, "Optional paired artifact supplied by integration validation"); + try (var loader = new java.net.URLClassLoader(new java.net.URL[]{java.nio.file.Path.of(candidate).toUri().toURL()}, getClass().getClassLoader())) { + Class type = loader.loadClass("com.vexsoftware.votifier.diagnostic.VotifierDiagnosticsSnapshot"); + Object snapshot = type.getConstructor(Boolean.class, Boolean.class, Boolean.class, Iterable.class).newInstance(true, true, true, List.of("backend-a")); + JsonObject facts = new JsonObject(); OptionalVotifierDiagnostics.add(facts, List.of(new VotifierProvider(snapshot))); + assertTrue(facts.get("votifierForwardingKnown").getAsBoolean()); assertTrue(facts.get("votifierListenerInitialized").getAsBoolean()); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlNetworkHealthTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlNetworkHealthTest.java new file mode 100644 index 0000000000..8cfba5f720 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlNetworkHealthTest.java @@ -0,0 +1,88 @@ +package com.bencodez.votingplugin.proxy.control; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import com.bencodez.votingplugin.proxy.VotingPluginProxy; +import com.bencodez.votingplugin.proxy.VotingPluginProxyConfig; +import com.google.gson.*; +import java.net.URI; +import java.util.*; +import java.util.concurrent.*; +import java.lang.reflect.Field; +import org.junit.jupiter.api.Test; + +class ControlNetworkHealthTest { + private static final UUID SESSION = UUID.randomUUID(); + private static final String ID = UUID.randomUUID().toString(), ATTEMPT = UUID.randomUUID().toString(); + private static void field(ControlConnector connector, String name, Object value) throws Exception { + Field field = ControlConnector.class.getDeclaredField(name); field.setAccessible(true); field.set(connector, value); + } + private static void poll(ControlConnector connector) throws Exception { + var method = ControlConnector.class.getDeclaredMethod("pollNetworkHealth"); method.setAccessible(true); method.invoke(connector); + } + private ControlConnector connector(String platform, ControlConnector.Transport transport, ScheduledExecutorService executor) throws Exception { + var settings = new ControlConnector.Settings("proxy-a", "a", platform, "1", URI.create("http://localhost:8080"), 10, 1000, 1000); + var connector = new ControlConnector(settings, executor, transport, List::of, ignored -> {}, SESSION, () -> 0); + var proxy = mock(VotingPluginProxy.class); var config = mock(VotingPluginProxyConfig.class); + when(proxy.getConfig()).thenReturn(config); + when(proxy.getInstalledPluginNames()).thenReturn(List.of("VotingPlugin", "VotifierPlus")); + when(proxy.getAllConfiguredServers()).thenReturn(Set.of("backend-a")); + when(config.getBungeeMethod()).thenReturn("PLUGINMESSAGING"); + when(config.getSharedTransportAuthentication()).thenReturn("COMPATIBILITY"); + when(config.getMultiProxyMethod()).thenReturn("SOCKET"); + field(connector, "proxy", proxy); + field(connector, "registered", true); field(connector, "status", ControlConnector.Status.CONNECTED); + field(connector, "acceptedCapabilities", Set.of("data.inspect.v1", "data.network-health.v1")); + return connector; + } + private static String task(String kind, String filters) { + return "{\"inspectionId\":\"" + ID + "\",\"attemptId\":\"" + ATTEMPT + "\",\"query\":{\"kind\":\"" + kind + "\",\"filters\":" + filters + "}}"; + } + @Test void bothProxyPlatformsReportInventoryAndCompleteATypedInspection() throws Exception { + for (String platform : List.of("BUNGEECORD", "VELOCITY")) { + var executor = Executors.newSingleThreadScheduledExecutor(); List requests = new ArrayList<>(); + ControlConnector.Transport transport = request -> { requests.add(request); return CompletableFuture.completedFuture(new ControlConnector.Response(200, requests.size() == 1 ? task("network-health", "{}") : "{}")); }; + try (var connector = connector(platform, transport, executor)) { + var registration = ControlConnector.class.getDeclaredMethod("registrationRequest"); registration.setAccessible(true); + var body = JsonParser.parseString(((ControlConnector.Request)registration.invoke(connector)).body()).getAsJsonObject(); + assertEquals(platform, body.get("platform").getAsString()); + assertEquals(List.of("VotifierPlus", "VotingPlugin"), body.getAsJsonArray("detectedPlugins").asList().stream().map(JsonElement::getAsString).toList()); + poll(connector); + assertEquals(2, requests.size()); + var result = JsonParser.parseString(requests.get(1).body()).getAsJsonObject(); + assertTrue(result.get("success").getAsBoolean()); assertEquals("OK", result.get("code").getAsString()); + assertFalse(result.get("message").getAsString().isBlank()); assertEquals(ATTEMPT, result.get("attemptId").getAsString()); + var evidence = result.getAsJsonObject("data").getAsJsonObject("result"); + assertEquals("PROXY", evidence.get("role").getAsString()); + assertTrue(evidence.get("topologyComplete").getAsBoolean()); + assertEquals("backend-a", evidence.getAsJsonArray("backendNames").get(0).getAsString()); + assertFalse(evidence.has("activeMethod"), "Configured method must not fabricate active runtime evidence"); + assertFalse(result.toString().contains("Password")); + String fixtureDir = System.getProperty("network.health.fixture.dir"); + if (fixtureDir != null) { + java.nio.file.Path dir = java.nio.file.Path.of(fixtureDir); + java.nio.file.Files.createDirectories(dir); + java.nio.file.Files.writeString(dir.resolve(platform + ".json"), evidence.toString()); + } + } finally { executor.shutdownNow(); } + } + } + @Test void oldControlCannotActivateHealthPollingAndFiltersAreRejected() throws Exception { + var executor = Executors.newSingleThreadScheduledExecutor(); List requests = new ArrayList<>(); + try (var connector = connector("VELOCITY", request -> { requests.add(request); return CompletableFuture.completedFuture(new ControlConnector.Response(200, task("network-health", "{\"path\":\"secret\"}"))); }, executor)) { + field(connector, "acceptedCapabilities", Set.of("data.inspect.v1")); poll(connector); assertTrue(requests.isEmpty()); + field(connector, "acceptedCapabilities", Set.of("data.inspect.v1", "data.network-health.v1")); poll(connector); + assertEquals(1, requests.size(), "Invalid filters must never trigger a result snapshot"); + } finally { executor.shutdownNow(); } + } + @Test void shutdownCancelsThePendingClaimAndCannotPostItsLateResult() throws Exception { + var executor = Executors.newSingleThreadScheduledExecutor(); List requests = new ArrayList<>(); + var pending = new CompletableFuture(); + try (var connector = connector("VELOCITY", request -> { requests.add(request); return pending; }, executor)) { + poll(connector); connector.close(); + assertTrue(pending.isCancelled()); + pending.complete(new ControlConnector.Response(200, task("network-health", "{}"))); + assertEquals(1, requests.size()); + } finally { executor.shutdownNow(); } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/security/SharedTransportEnvelopeAuthenticatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/security/SharedTransportEnvelopeAuthenticatorTest.java index 52ea583aae..a3cd992283 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/security/SharedTransportEnvelopeAuthenticatorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/security/SharedTransportEnvelopeAuthenticatorTest.java @@ -27,6 +27,14 @@ class SharedTransportEnvelopeAuthenticatorTest { private static final byte[] KEY = "0123456789abcdef0123456789abcdef".getBytes(java.nio.charset.StandardCharsets.US_ASCII); private static final Clock CLOCK = Clock.fixed(Instant.ofEpochMilli(1_800_000_000_000L), ZoneOffset.UTC); + @Test void diagnosticFingerprintIsBoundedAndDoesNotReturnTheSecret() { + SharedTransportEnvelopeAuthenticator first = SharedTransportEnvelopeAuthenticator.forTesting(KEY, Mode.REQUIRED, CLOCK); + SharedTransportEnvelopeAuthenticator same = SharedTransportEnvelopeAuthenticator.forTesting(KEY, Mode.COMPATIBILITY, CLOCK); + assertTrue(first.diagnosticKeyFingerprint().matches("[a-f0-9]{64}")); + assertEquals(first.diagnosticKeyFingerprint(), same.diagnosticKeyFingerprint()); + org.junit.jupiter.api.Assertions.assertNotEquals(java.util.HexFormat.of().formatHex(KEY), first.diagnosticKeyFingerprint()); + } + @Test void missingSettingDefaultsToUpgradeSafeCompatibility() { assertEquals(Mode.COMPATIBILITY, Mode.parse(null)); diff --git a/docs/control-agent-contract.md b/docs/control-agent-contract.md index 34d00e9950..f30d73eda4 100644 --- a/docs/control-agent-contract.md +++ b/docs/control-agent-contract.md @@ -55,6 +55,16 @@ download, size/hash, invalid artifact, staging/write, cancellation, and lost cap dedicated background worker. Disable/reload marks the worker inactive, interrupts it, and prevents a stale completion from submitting success. Mixed-version nodes that do not negotiate `plugin.deploy.v1` remain connected but are excluded from deployment targets. +## Network-health fact availability + +The current producers expose only facts backed by stable, bounded APIs. Both producers expose role and supported +transport/configuration facts; the backend additionally exposes observational `detectedServices` from persisted +`GottenServiceSites` without filtering configured or disabled sites. The proxy producer exposes `waitForUserOnline` and the +vote-cache/non-voted-cache MySQL mode booleans through its proxy config getters. Backend nodes do not currently expose +those proxy-only cache or wait settings, and neither producer resolves named reward references or reads reward files for +network health; consumers must render omitted fields as `UNKNOWN`. Named reward file names are available only through the +separate bounded `reward-file-inventory` inspection when its capability is accepted. + ## Named reward files (`config.reward-files.v1`) This additive Bukkit capability manages only existing `Rewards/.yml` files directly under VotingPlugin's Rewards @@ -491,6 +501,16 @@ credential, secret, raw payload, SQL metadata, or arbitrary storage key is expos - Capability negotiation is authoritative. An older Control that does not accept `data.inspect.v1` must not receive inspection polling. +## Network health inspection + +Nodes may advertise the additive `data.network-health.v1` capability. A negotiated +`network-health` inspection is read-only and returns bounded typed facts. Unsupported +or unavailable facts are omitted and therefore mean `UNKNOWN` to Control; they are +never represented as healthy defaults. Votifier diagnostics use only the optional +`getNetworkHealthSnapshot` API and never expose keys, tokens, endpoints, raw config, +or logs. Proxy polling is independently failure-isolated from vote and configuration +work, and older peers continue using existing capabilities unchanged. + ## VoteLog interpretation VoteLogging is optional and SQL-backed; a dependent query returns `UNAVAILABLE` when it is disabled, lacks an initialized diff --git a/docs/control-connector.md b/docs/control-connector.md index d5a51ce258..6d17647778 100644 --- a/docs/control-connector.md +++ b/docs/control-connector.md @@ -340,7 +340,11 @@ entries and per-value/content bounds; unavailable storage returns an empty colum serialized payloads, credentials, secrets, and arbitrary keys. See [the exact-player storage schema](control-agent-contract.md#exact-player-storage-fields-schema-version-1) for the static/dynamic names and value-type rules. -Inspection filters are string values on the wire and are parsed by the selected kind's strict schema. The connector runs +Inspection filters are string values on the wire and are parsed by the selected kind's strict schema. The optional +`data.network-health.v1` lane returns bounded typed facts; omitted evidence is UNKNOWN, +never PASS. Proxy polling has independent retry backoff and shutdown cancellation, +and optional VotifierPlus diagnostics are reflective so older providers remain compatible. +The connector runs the handlers, including bounded VoteLog/player storage reads, on the dedicated inspection daemon rather than Bukkit's primary thread or the configuration executor. VoteLog statements use a 10-second JDBC timeout. Reward and vote-site inspections are dry runs: they do not call reward execution or auto-creating resolution paths. From 07ee9367d82e5f6e17fe24cd9f0c9c25b67aa355 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:34:47 -0600 Subject: [PATCH 02/11] Validate diagnostic evidence and scope proxy inventories to actual paths --- .../control/ControlInspectionService.java | 32 ++++--------------- .../control/NetworkHealthConfigFacts.java | 29 ++++++++++++++++- .../proxy/bungee/VotingPluginBungee.java | 9 ++++-- .../proxy/cache/VoteCacheHandler.java | 6 ++++ .../proxy/control/ControlConnector.java | 19 +++++------ .../proxy/velocity/VotingPluginVelocity.java | 15 ++++++--- .../control/NetworkHealthConfigFactsTest.java | 25 +++++++++++++++ .../bungee/NetworkHealthInventoryTest.java | 14 ++++++++ .../velocity/NetworkHealthInventoryTest.java | 24 ++++++++++++++ .../tests/VoteCacheHandlerVoteIdTest.java | 3 ++ docs/control-agent-contract.md | 9 ++++++ 11 files changed, 140 insertions(+), 45 deletions(-) create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/bungee/NetworkHealthInventoryTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/NetworkHealthInventoryTest.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java index 7f87f3c727..2bc60704fc 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java @@ -563,22 +563,13 @@ private JsonObject networkHealth(JsonObject filters) { String service = site.getString("ServiceSite", ""); if (service.length() <= 160 && service.codePoints().noneMatch(Character::isISOControl)) item.addProperty("serviceSite", service); else siteComplete = false; - item.addProperty("enabled", site.getBoolean("Enabled", true)); - item.addProperty("autoCreated", site.getBoolean("AutoCreated", false)); + if (!site.contains("Enabled") || site.isBoolean("Enabled")) item.addProperty("enabled", plugin.getConfigVoteSites().getVoteSiteEnabled(name)); + else { siteComplete = false; NetworkHealthConfigFacts.invalid(result, "VoteSites.Enabled"); } + if (site.isBoolean("AutoCreated")) item.addProperty("autoCreated", site.getBoolean("AutoCreated")); item.addProperty("hasRewards", hasRewardConfiguration(site)); - Object rawDelay = site.get("VoteDelay"); try { - boolean typed = rawDelay == null || rawDelay instanceof String || rawDelay instanceof Number; - Object rawMinutes = site.get("VoteDelayMin"); - typed &= rawMinutes == null || rawMinutes instanceof Number; - long millis = plugin.getConfigVoteSites().getVoteDelay(name).getMillis(); - boolean nonnegative = !(rawDelay instanceof Number number) || Double.isFinite(number.doubleValue()) && number.doubleValue() >= 0; - nonnegative &= !(rawMinutes instanceof Number number) || Double.isFinite(number.doubleValue()) && number.doubleValue() >= 0; - item.addProperty("delayValid", typed && nonnegative); - if (typed && nonnegative && millis >= 0) { - long hours = millis == 0 ? 0 : (millis - 1) / 3_600_000L + 1; - if (hours <= 1_000_000L) item.addProperty("delayHours", (int) hours); - } + NetworkHealthConfigFacts.addDelay(item, site.get("VoteDelay"), site.get("VoteDelayMin"), + plugin.getConfigVoteSites().getVoteDelay(name)); } catch (IllegalArgumentException failure) { item.addProperty("delayValid", false); } String url = site.getString("VoteURL", ""); item.addProperty("voteUrlState", url.isBlank() ? "BLANK" @@ -588,23 +579,14 @@ private JsonObject networkHealth(JsonObject filters) { } result.add("voteSites", sites); result.addProperty("voteSitesComplete", siteComplete); - JsonArray detectedServices = new JsonArray(); - java.util.TreeMap observedServices = new java.util.TreeMap<>(String.CASE_INSENSITIVE_ORDER); - for (String observed : plugin.getServerData().getServiceSitesReadOnly()) { - if (observed == null || observed.isBlank() || observed.length() > 80 - || observed.codePoints().anyMatch(Character::isISOControl)) { observedServices.clear(); break; } - observedServices.putIfAbsent(observed, observed); - } - if (observedServices.size() <= MAX_ROWS) { - observedServices.values().forEach(value -> detectedServices.add(value)); - result.add("detectedServices", detectedServices); - } + NetworkHealthConfigFacts.addObservedServices(result, plugin.getServerData().getServiceSitesReadOnly()); JsonArray plugins = new JsonArray(); java.util.Arrays.stream(plugin.getServer().getPluginManager().getPlugins()) .map(Plugin::getName).filter(name -> name != null && !name.isBlank()).distinct() .sorted(String.CASE_INSENSITIVE_ORDER.thenComparing(Comparator.naturalOrder())).limit(MAX_ROWS) .forEach(name -> plugins.add(safe(name, 80))); result.add("detectedPlugins", plugins); + if (result.has("invalidConfigurationFields")) result.addProperty("configurationHealthy", false); return result; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/NetworkHealthConfigFacts.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/NetworkHealthConfigFacts.java index 6e5d9deb62..e9c06cb8aa 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/NetworkHealthConfigFacts.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/NetworkHealthConfigFacts.java @@ -26,6 +26,33 @@ static void addGlobal(JsonObject out, FileConfiguration config) { addBoolean(out, config, "globalDataUseMainMysql", "GlobalData.UseMainMySQL"); } + static void addDelay(JsonObject out, Object rawDelay, Object rawMinutes, com.bencodez.simpleapi.time.ParsedDuration parsed) { + boolean valid = rawDelay == null || rawDelay instanceof String || rawDelay instanceof Number; + valid &= rawMinutes == null || rawMinutes instanceof Number; + valid &= !(rawDelay instanceof Number n) || Double.isFinite(n.doubleValue()) && n.doubleValue() >= 0; + valid &= !(rawMinutes instanceof Number n) || Double.isFinite(n.doubleValue()) && n.doubleValue() >= 0; + if (rawDelay instanceof String text && (parsed == null || parsed.isEmpty())) + valid &= text.trim().matches("(?i)0+(?:\\.0+)?(?:\\s*(?:ms|s|m|h|d|w))?"); + out.addProperty("delayValid", valid && parsed != null); + if (valid && parsed != null) { + long millis = parsed.getMillis(); + long hours = millis / 3_600_000L + (millis % 3_600_000L == 0 ? 0 : 1); + if (hours >= 0 && hours <= 1_000_000L) out.addProperty("delayHours", hours); + } + } + + static void addObservedServices(JsonObject out, Iterable observations) { + java.util.Set names = new java.util.TreeSet<>(String.CASE_INSENSITIVE_ORDER); + if (observations == null) return; + for (String name : observations) { + if (name == null || name.isBlank() || name.length() > 80 || name.codePoints().anyMatch(Character::isISOControl)) return; + names.add(name); + if (names.size() > 100) return; + } + com.google.gson.JsonArray values = new com.google.gson.JsonArray(); + names.forEach(values::add); out.add("detectedServices", values); + } + private static void addBoolean(JsonObject out, FileConfiguration c, String field, String path) { if (c.isBoolean(path)) out.addProperty(field, c.getBoolean(path)); else if (c.contains(path)) invalid(out, path); @@ -46,7 +73,7 @@ static void addString(JsonObject out, FileConfiguration c, String field, String out.addProperty(field, value); } else invalid(out, path); } - private static void invalid(JsonObject out, String path) { + static void invalid(JsonObject out, String path) { com.google.gson.JsonArray errors = out.has("invalidConfigurationFields") ? out.getAsJsonArray("invalidConfigurationFields") : new com.google.gson.JsonArray(); if (errors.size() < 100 && errors.asList().stream().noneMatch(v -> v.getAsString().equals(path))) errors.add(path); out.add("invalidConfigurationFields", errors); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungee.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungee.java index 48368793cb..12764ca5e1 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungee.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungee.java @@ -66,6 +66,11 @@ * */ public class VotingPluginBungee extends Plugin implements Listener { + + static Collection installedPluginNames(Collection plugins) { + return plugins.stream().map(plugin -> plugin.getDescription().getName()) + .filter(name -> name != null && !name.isBlank()).limit(128).toList(); + } @Getter private BungeeConfig config; @@ -899,9 +904,7 @@ public String getProxyPlatform() { @Override public Collection getInstalledPluginNames() { - return getProxy().getPluginManager().getPlugins().stream() - .map(plugin -> plugin.getDescription().getName()) - .filter(name -> name != null && !name.isBlank()).limit(128).toList(); + return installedPluginNames(getProxy().getPluginManager().getPlugins()); } @Override diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/VoteCacheHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/VoteCacheHandler.java index 415ce70e70..e38ef818df 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/VoteCacheHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/VoteCacheHandler.java @@ -57,6 +57,12 @@ private static int delayValidation(OfflineBungeeVote vote) { private final ConcurrentHashMap> pendingServerVotePersistence = new ConcurrentHashMap<>(); private int pendingPersistenceVoteCount; + + /** Read-only bounded count of votes parked for persistence retry, not all queued deliveries. */ + public synchronized int diagnosticPendingPersistenceVoteCount() { + return pendingPersistenceVoteCount; + } + private boolean jsonStorageQuarantined; /** 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 c0215e8655..b631b20b11 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 @@ -419,22 +419,24 @@ private JsonObject proxyNetworkHealth() { result.addProperty("sendVotesToAllServers", proxy.getConfig().getSendVotesToAllServers()); addSafeString(result, "proxyServerName", proxy.getConfig().getProxyServerName()); addNames(result, "proxyServers", proxy.getConfig().getProxyServers()); + addNames(result, "socketProxyServers", new ArrayList<>(proxy.getConfig().getMultiProxyServers())); addNames(result, "blockedServers", proxy.getConfig().getBlockedServers()); addNames(result, "whitelistedServers", proxy.getConfig().getWhiteListedServers()); - addNames(result, "broadcastServers", proxy.getConfig().getProxyBroadcastScopeServers()); + String scope = proxy.getConfig().getProxyBroadcastScopeMode(); + result.addProperty("broadcastServersApplicable", proxy.getConfig().getProxyBroadcastEnabled() && ("SERVERS".equalsIgnoreCase(scope) || "ALL_EXCEPT".equalsIgnoreCase(scope))); + result.addProperty("offlineForwardServersApplicable", proxy.getConfig().getProxyBroadcastEnabled() && "PLAYER_SERVER".equalsIgnoreCase(scope) && "FORWARD".equalsIgnoreCase(proxy.getConfig().getProxyBroadcastOfflineMode())); + addNames(result, "broadcastServers", proxy.getConfig().getProxyBroadcastScopeServers()); addNames(result, "offlineForwardServers", proxy.getConfig().getProxyBroadcastOfflineForwardServers()); java.util.Set backendNames = proxy.getAllConfiguredServers(); addNames(result, "backendNames", new ArrayList<>(backendNames)); result.addProperty("topologyComplete", result.has("backendNames")); + if (proxy.getVoteCacheHandler() != null) result.addProperty("parkedVotes", proxy.getVoteCacheHandler().diagnosticPendingPersistenceVoteCount()); addVotifierDiagnostics(result); result.addProperty("transportProbeState", "UNKNOWN"); String fingerprint = proxy.diagnosticSharedKeyFingerprint(); if (fingerprint != null && Set.of("REDIS", "MQTT").contains(methodValue(settingsMethod()))) result.addProperty("sharedKeyFingerprint", fingerprint); if (fingerprint != null && "REDIS".equalsIgnoreCase(proxy.getConfig().getMultiProxyMethod())) result.addProperty("multiProxyKeyFingerprint", fingerprint); - JsonArray plugins = new JsonArray(); - proxyPluginNames().stream().distinct().sorted(String.CASE_INSENSITIVE_ORDER).limit(100) - .forEach(name -> plugins.add(bounded(name, 80))); - result.add("detectedPlugins", plugins); + addNames(result, "detectedPlugins", proxyPluginNames()); return result; } @@ -464,11 +466,6 @@ private static void addSafeString(JsonObject result, String field, String value) } private String settingsMethod() { return proxy.getConfig().getBungeeMethod(); } - private static String bounded(String value, int max) { - if (value == null) return ""; - String clean = value.replaceAll("\\p{Cntrl}", "").trim(); - return clean.length() <= max ? clean : clean.substring(0, max); - } /** Keeps large artifact transfer and disk staging off proxy event threads and serializes it with operations. */ private void pollDeployments() { @@ -848,7 +845,7 @@ private Request registrationRequest() { body.addProperty("platform", settings.platform()); body.addProperty("pluginVersion", settings.pluginVersion()); JsonArray detectedPlugins = new JsonArray(); - proxyPluginNames().stream().distinct().sorted(String.CASE_INSENSITIVE_ORDER).limit(128) + proxyPluginNames().stream().filter(name -> name.length() <= 100 && name.codePoints().noneMatch(Character::isISOControl)).distinct().sorted(String.CASE_INSENSITIVE_ORDER).limit(128) .forEach(detectedPlugins::add); body.add("detectedPlugins", detectedPlugins); addCapabilities(body); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java index 7b3c4edeb1..1a1e0580c6 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java @@ -91,6 +91,14 @@ public class VotingPluginVelocity { */ private volatile ChannelIdentifier channel; + static Collection installedPluginNames(Collection plugins) { + return plugins.stream().map(plugin -> plugin.getDescription().getName().orElse(plugin.getDescription().getId())) + .filter(name -> name != null && !name.isBlank()).limit(128).toList(); + } + static Collection diagnosticProviders(Collection plugins) { + return plugins.stream().limit(128).flatMap(container -> container.getInstance().map(instance -> (Object)instance).stream()).toList(); + } + @Getter private VelocityConfig config; @@ -979,15 +987,12 @@ public String getProxyPlatform() { @Override public Collection getInstalledPluginNames() { - return server.getPluginManager().getPlugins().stream() - .map(plugin -> plugin.getDescription().getName().orElse(plugin.getInstance().map(Object::getClass) - .map(Class::getSimpleName).orElse("unknown"))) - .filter(name -> name != null && !name.isBlank()).limit(128).toList(); + return installedPluginNames(server.getPluginManager().getPlugins()); } @Override public Collection getDiagnosticProviders() { - return new ArrayList<>(server.getPluginManager().getPlugins()); + return diagnosticProviders(server.getPluginManager().getPlugins()); } @Override diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/NetworkHealthConfigFactsTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/NetworkHealthConfigFactsTest.java index cb08135343..e90b5a1548 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/NetworkHealthConfigFactsTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/NetworkHealthConfigFactsTest.java @@ -50,4 +50,29 @@ class NetworkHealthConfigFactsTest { NetworkHealthConfigFacts.addBackend(out, yaml); assertFalse(out.has("timeHourOffset")); } + @Test void delayParserEmptyResultCannotProveMalformedTextValid() { + for (String text : java.util.List.of("abc", "-5h", "")) { + JsonObject out = new JsonObject(); + NetworkHealthConfigFacts.addDelay(out, text, null, com.bencodez.simpleapi.time.ParsedDuration.parse(text, java.util.concurrent.TimeUnit.HOURS)); + assertFalse(out.get("delayValid").getAsBoolean()); + } + JsonObject halfHour = new JsonObject(); + NetworkHealthConfigFacts.addDelay(halfHour, "30m", null, com.bencodez.simpleapi.time.ParsedDuration.parse("30m")); + assertTrue(halfHour.get("delayValid").getAsBoolean()); assertEquals(1, halfHour.get("delayHours").getAsInt()); + JsonObject zero = new JsonObject(); + NetworkHealthConfigFacts.addDelay(zero, "0", null, com.bencodez.simpleapi.time.ParsedDuration.empty()); + assertTrue(zero.get("delayValid").getAsBoolean()); assertEquals(0, zero.get("delayHours").getAsInt()); + } + @Test void incompleteServiceObservationsAreOmittedInsteadOfEmpty() { + JsonObject invalid = new JsonObject(); + NetworkHealthConfigFacts.addObservedServices(invalid, java.util.List.of("unmatched", "s".repeat(81))); + assertFalse(invalid.has("detectedServices")); + JsonObject large = new JsonObject(); + NetworkHealthConfigFacts.addObservedServices(large, java.util.stream.IntStream.range(0, 101).mapToObj(i -> "site" + i).toList()); + assertFalse(large.has("detectedServices")); + JsonObject valid = new JsonObject(); + NetworkHealthConfigFacts.addObservedServices(valid, java.util.List.of("site", "SITE", "other")); + assertEquals(2, valid.getAsJsonArray("detectedServices").size()); + } + } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/bungee/NetworkHealthInventoryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/bungee/NetworkHealthInventoryTest.java new file mode 100644 index 0000000000..5144a77dc3 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/bungee/NetworkHealthInventoryTest.java @@ -0,0 +1,14 @@ +package com.bencodez.votingplugin.proxy.bungee; +import java.util.*; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +class NetworkHealthInventoryTest { + @Test void bungeeReportsPluginDescriptionNamesWithInventoryBound() { + net.md_5.bungee.api.plugin.Plugin plugin = mock(net.md_5.bungee.api.plugin.Plugin.class); + var description = mock(net.md_5.bungee.api.plugin.PluginDescription.class); + when(plugin.getDescription()).thenReturn(description); when(description.getName()).thenReturn("VotifierPlus"); + assertEquals(List.of("VotifierPlus"), VotingPluginBungee.installedPluginNames(List.of(plugin))); + assertEquals(128, VotingPluginBungee.installedPluginNames(Collections.nCopies(129, plugin)).size()); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/NetworkHealthInventoryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/NetworkHealthInventoryTest.java new file mode 100644 index 0000000000..c80b57b737 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/NetworkHealthInventoryTest.java @@ -0,0 +1,24 @@ +package com.bencodez.votingplugin.proxy.velocity; +import com.velocitypowered.api.plugin.PluginContainer; +import java.util.*; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +class NetworkHealthInventoryTest { + @Test void optionalProvidersAreActualInstancesRatherThanContainers() { + PluginContainer installed = mock(PluginContainer.class), absent = mock(PluginContainer.class); + Object provider = new Object(); + doReturn(Optional.of(provider)).when(installed).getInstance(); + doReturn(Optional.empty()).when(absent).getInstance(); + assertEquals(List.of(provider), VotingPluginVelocity.diagnosticProviders(List.of(installed, absent))); + } + @Test void velocityReportsDeclaredNamesWithStableIdFallback() { + PluginContainer installed = mock(PluginContainer.class); var description = mock(com.velocitypowered.api.plugin.PluginDescription.class); + when(installed.getDescription()).thenReturn(description); + when(description.getName()).thenReturn(Optional.of("VotifierPlus")); + assertEquals(List.of("VotifierPlus"), VotingPluginVelocity.installedPluginNames(List.of(installed))); + when(description.getName()).thenReturn(Optional.empty()); when(description.getId()).thenReturn("votifierplus"); + assertEquals(List.of("votifierplus"), VotingPluginVelocity.installedPluginNames(List.of(installed))); + } + +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VoteCacheHandlerVoteIdTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VoteCacheHandlerVoteIdTest.java index 1949fdd82e..9698ac0bec 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VoteCacheHandlerVoteIdTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VoteCacheHandlerVoteIdTest.java @@ -231,10 +231,12 @@ public void failedServerInsertIsPublishedOnlyAfterPersistenceRetrySucceeds() { assertFalse(handler.addServerVoteDurably("server", pending)); assertTrue(handler.retainServerVoteForPersistenceRetry("server", pending)); + assertEquals(1, handler.diagnosticPendingPersistenceVoteCount()); assertTrue(handler.getVotes("server").isEmpty()); assertTrue(handler.retryPendingVotePersistence()); assertEquals(List.of(pending), handler.getVotes("server")); + assertEquals(0, handler.diagnosticPendingPersistenceVoteCount()); } @Test @@ -260,6 +262,7 @@ public void failedPersistenceRetryAdmissionIsBoundedAcrossCacheTypes() { assertFalse(handler.retainOnlineVoteForPersistenceRetry("another-player", vote(UUID.randomUUID(), 1025L))); assertTrue(handler.retainServerVoteForPersistenceRetry("server", first)); + assertEquals(1024, handler.diagnosticPendingPersistenceVoteCount()); } @Test diff --git a/docs/control-agent-contract.md b/docs/control-agent-contract.md index f30d73eda4..c3ec784616 100644 --- a/docs/control-agent-contract.md +++ b/docs/control-agent-contract.md @@ -503,6 +503,15 @@ credential, secret, raw payload, SQL metadata, or arbitrary storage key is expos ## Network health inspection +Redis multi-proxy peers use `proxyServers`; socket multi-proxy peers use `socketProxyServers`. Neither list is +inferred from backend topology. `broadcastServersApplicable` and `offlineForwardServersApplicable` describe whether +persisted routing lists participate in the current configured broadcast mode. Shared transport authentication +comparisons apply to Redis/MQTT backend transport and Redis multi-proxy transport, not socket multi-proxy traffic. + +Proxy `parkedVotes` is the existing bounded count of server/online cache entries retained for persistence retry. +It does not count every delivery queue, imply lost votes, or expose players or payloads. Other queue/age counters remain +unreported when no bounded snapshot API exists. + Nodes may advertise the additive `data.network-health.v1` capability. A negotiated `network-health` inspection is read-only and returns bounded typed facts. Unsupported or unavailable facts are omitted and therefore mean `UNKNOWN` to Control; they are From 35d0d5694db575147c8fc708f36951e35d8a181e Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:52:18 -0600 Subject: [PATCH 03/11] Align diagnostics with runtime parsing and accept canonical proxy file tasks --- .../control/ControlInspectionService.java | 3 +- .../proxy/control/ControlConnector.java | 26 ++++++++++++---- .../control/ControlInspectionServiceTest.java | 20 +++++++++++++ .../proxy/control/ControlConnectorTest.java | 30 +++++++++++++++++++ .../control/ControlNetworkHealthTest.java | 24 +++++++++++++++ docs/control-agent-contract.md | 5 +++- 6 files changed, 101 insertions(+), 7 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java index 2bc60704fc..4a465eb7bb 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java @@ -514,7 +514,8 @@ private JsonObject networkHealth(JsonObject filters) { result.addProperty("pluginMessageChannel", safe(plugin.getBungeeSettings().getPluginMessagingChannel(), 160)); result.addProperty("encryption", plugin.getBungeeSettings().isCommunicationEncryption()); String auth = plugin.getBungeeSettings().getSharedTransportAuthentication(); - result.addProperty("sharedAuthentication", "REQUIRED".equalsIgnoreCase(auth) ? "REQUIRED" : auth == null || auth.isBlank() || "COMPATIBILITY".equalsIgnoreCase(auth) ? "COMPATIBILITY" : "INVALID"); + try { result.addProperty("sharedAuthentication", com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode.parse(auth).name()); } + catch (IllegalArgumentException invalid) { result.addProperty("sharedAuthentication", "INVALID"); } result.addProperty("triggerVotifierEvent", plugin.getBungeeSettings().isTriggerVotifierEvent()); result.addProperty("votifierProviderPresent", plugin.isVotifierLoaded()); OptionalVotifierDiagnostics.add(result, java.util.Arrays.asList(plugin.getServer().getPluginManager().getPlugins())); 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 b631b20b11..f77b504efb 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 @@ -389,7 +389,8 @@ private JsonObject proxyNetworkHealth() { result.addProperty("onlineMode", proxy.getConfig().getOnlineMode()); result.addProperty("encryption", proxy.getConfig().getCommunicationEncryption()); String auth = proxy.getConfig().getSharedTransportAuthentication(); - result.addProperty("sharedAuthentication", "REQUIRED".equalsIgnoreCase(auth) ? "REQUIRED" : auth == null || auth.isBlank() || "COMPATIBILITY".equalsIgnoreCase(auth) ? "COMPATIBILITY" : "INVALID"); + try { result.addProperty("sharedAuthentication", com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode.parse(auth).name()); } + catch (IllegalArgumentException invalid) { result.addProperty("sharedAuthentication", "INVALID"); } com.bencodez.votingplugin.control.NetworkHealthStorageFacts.add(result, proxy.getProxyMySQL() == null || proxy.getProxyMySQL().getMysql() == null ? null : proxy.getProxyMySQL().getMysql().getConnectionManager()); result.addProperty("bungeeManageTotals", proxy.getConfig().getBungeeManageTotals()); result.addProperty("dedicatedVotingProxy", proxy.getConfig().getDedicatedVotingProxy()); @@ -410,6 +411,7 @@ private JsonObject proxyNetworkHealth() { result.addProperty("votePartyEnabled", proxy.getConfig().getVotePartyEnabled()); result.addProperty("votePartyVotesRequired", Math.max(0, proxy.getConfig().getVotePartyVotesRequired())); if (!proxy.getConfig().getVotePartyBungeeCommands().isEmpty()) result.addProperty("votePartyEffectiveReward", true); + result.addProperty("votePartyServersApplicable", proxy.getConfig().getVotePartyEnabled() && !proxy.getConfig().getVotePartySendToAllServers()); addNames(result, "votePartyServers", proxy.getConfig().getVotePartyServersToSend()); addSafeString(result, "bedrockPlayerPrefix", proxy.getConfig().getBedrockPlayerPrefix()); if ("REDIS".equals(methodValue(settingsMethod()))) { addSafeString(result, "transportNamespace", proxy.getConfig().getRedisPrefix()); result.addProperty("redisSsl", proxy.getConfig().getRedisSsl()); } @@ -422,9 +424,10 @@ private JsonObject proxyNetworkHealth() { addNames(result, "socketProxyServers", new ArrayList<>(proxy.getConfig().getMultiProxyServers())); addNames(result, "blockedServers", proxy.getConfig().getBlockedServers()); addNames(result, "whitelistedServers", proxy.getConfig().getWhiteListedServers()); - String scope = proxy.getConfig().getProxyBroadcastScopeMode(); - result.addProperty("broadcastServersApplicable", proxy.getConfig().getProxyBroadcastEnabled() && ("SERVERS".equalsIgnoreCase(scope) || "ALL_EXCEPT".equalsIgnoreCase(scope))); - result.addProperty("offlineForwardServersApplicable", proxy.getConfig().getProxyBroadcastEnabled() && "PLAYER_SERVER".equalsIgnoreCase(scope) && "FORWARD".equalsIgnoreCase(proxy.getConfig().getProxyBroadcastOfflineMode())); + var scope = com.bencodez.votingplugin.proxy.broadcast.ScopeMode.parse(proxy.getConfig().getProxyBroadcastScopeMode()); + var offline = com.bencodez.votingplugin.proxy.broadcast.OfflineMode.parse(proxy.getConfig().getProxyBroadcastOfflineMode(), com.bencodez.votingplugin.proxy.broadcast.OfflineMode.QUEUE); + result.addProperty("broadcastServersApplicable", proxy.getConfig().getProxyBroadcastEnabled() && (scope == com.bencodez.votingplugin.proxy.broadcast.ScopeMode.SERVERS || scope == com.bencodez.votingplugin.proxy.broadcast.ScopeMode.ALL_EXCEPT)); + result.addProperty("offlineForwardServersApplicable", proxy.getConfig().getProxyBroadcastEnabled() && scope == com.bencodez.votingplugin.proxy.broadcast.ScopeMode.PLAYER_SERVER && offline == com.bencodez.votingplugin.proxy.broadcast.OfflineMode.FORWARD); addNames(result, "broadcastServers", proxy.getConfig().getProxyBroadcastScopeServers()); addNames(result, "offlineForwardServers", proxy.getConfig().getProxyBroadcastOfflineForwardServers()); java.util.Set backendNames = proxy.getAllConfiguredServers(); @@ -1256,7 +1259,20 @@ private CompletableFuture executeProxyFile(UUID operationId, JsonObj else if ("PREVIEW".equals(type) || "APPLY".equals(type)) allowedFields = Set.of("domain", "fileName", "content"); else return completed(TaskResult.failure("UNSUPPORTED_TASK", "Task type is unsupported")); - if (!allowedFields.equals(requested.keySet())) throw new MalformedResponseException(); + // Control serializes its configuration union with neutral members from other domains. + // Accept that canonical envelope as well as older compact claims, but never + // accept a populated foreign member or an unknown field. + JsonObject fileFields = requested.deepCopy(); + for (String field : List.of("sendVotesToAllServers", "preset")) { + if (fileFields.has(field) && fileFields.get(field).isJsonNull()) fileFields.remove(field); + } + if (fileFields.has("blockedServers") && fileFields.get("blockedServers").isJsonArray() + && fileFields.getAsJsonArray("blockedServers").isEmpty()) fileFields.remove("blockedServers"); + if (fileFields.has("options") && fileFields.get("options").isJsonObject() + && fileFields.getAsJsonObject("options").isEmpty()) fileFields.remove("options"); + if ("READ".equals(type) && fileFields.has("content") && fileFields.get("content").isJsonNull()) + fileFields.remove("content"); + if (!allowedFields.equals(fileFields.keySet())) throw new MalformedResponseException(); String fileName = requireString(requested, "fileName"); if ("READ".equals(type)) { return completed(TaskResult.file(fileConfigurationService.read(fileName), List.of(), false, false)); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java index d52e96ebb3..c987d6926c 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java @@ -39,6 +39,26 @@ class ControlInspectionServiceTest { @TempDir Path directory; + @Test void backendHealthUsesRuntimeAuthenticationNormalization() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + var config = new org.bukkit.configuration.file.YamlConfiguration(); + var sites = new org.bukkit.configuration.file.YamlConfiguration(); + when(plugin.getConfigFile().getData()).thenReturn(config); + when(plugin.getBungeeSettings().getData()).thenReturn(config); + when(plugin.getConfigVoteSites().getData()).thenReturn(sites); + when(plugin.getBungeeSettings().getSharedTransportAuthentication()).thenReturn(" REQUIRED "); + when(plugin.getBungeeSettings().getBungeeMethod()).thenReturn("REDIS"); + when(plugin.getBackendProxyHandler()).thenReturn(null); + when(plugin.getServer().getPluginManager().getPlugins()).thenReturn(new org.bukkit.plugin.Plugin[0]); + JsonObject result = new ControlInspectionService(plugin).inspect(JsonParser.parseString("{\"kind\":\"network-health\",\"filters\":{}}").getAsJsonObject()).getAsJsonObject("result"); + assertEquals("REQUIRED", result.get("sharedAuthentication").getAsString()); + String fixtureDir = System.getProperty("network.health.fixture.dir"); + if (fixtureDir != null) { + Path folder = Path.of(fixtureDir); Files.createDirectories(folder); + Files.writeString(folder.resolve("BUKKIT.json"), result.toString()); + } + } + @Test void rewardFileInventoryReturnsOnlySafeLogicalNames() throws Exception { Path rewards = Files.createDirectories(directory.resolve("Rewards")); Files.writeString(rewards.resolve("Daily.yml"), "Commands: []\n"); 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 3a12d05355..ad67ebf520 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 @@ -476,6 +476,36 @@ class ControlConnectorTest { assertEquals(1, recoveryCalls.get(), "recovery completion must be idempotent"); } + @Test void proxyFilesAcceptCanonicalControlUnionWithoutWeakeningForeignFieldValidation() throws Exception { + Path file = dataDirectory.resolve(ProxyConfigurationFileService.FILE_NAME); + Files.writeString(file, "Debug: false\n"); + connector.close(); + connector = fileConnector(new ProxyConfigurationFileService(file, ControlConnectorTest::atomicMove)); + transport.acceptProxyFiles = true; + for (String type : List.of("READ", "PREVIEW")) { + String content = "READ".equals(type) ? "null" : "\"Debug: true\\n\""; + transport.requests.clear(); + transport.operationClaim = CompletableFuture.completedFuture(new Response(200, + "{\"operationId\":\"" + UUID.randomUUID() + "\",\"attemptId\":\"" + UUID.randomUUID() + + "\",\"type\":\"" + type + "\",\"configuration\":{\"domain\":\"file\"," + + "\"sendVotesToAllServers\":null,\"blockedServers\":[],\"fileName\":\"bungeeconfig.yml\"," + + "\"content\":" + content + ",\"preset\":null,\"options\":{}}}")); + connector.cycle(); + assertTrue(submittedResult().get("success").getAsBoolean(), type); + } + for (String foreign : List.of("\"sendVotesToAllServers\":true", "\"blockedServers\":[\"lobby\"]", + "\"preset\":\"standalone\"", "\"options\":{\"enabled\":\"true\"}", "\"unknown\":null")) { + transport.requests.clear(); + transport.operationClaim = CompletableFuture.completedFuture(new Response(200, + "{\"operationId\":\"" + UUID.randomUUID() + "\",\"attemptId\":\"" + UUID.randomUUID() + + "\",\"type\":\"READ\",\"configuration\":{\"domain\":\"file\"," + + "\"fileName\":\"bungeeconfig.yml\"," + foreign + "}}")); + connector.cycle(); + assertEquals("VALIDATION_ERROR", submittedResult().get("code").getAsString(), foreign); + } + assertEquals("Debug: false\n", Files.readString(file), "read/preview must not write"); + } + @Test void malformedProxyFileFieldsBecomeDurableValidationFailures() throws Exception { Path file = dataDirectory.resolve(ProxyConfigurationFileService.FILE_NAME); Files.writeString(file, "Debug: false\n"); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlNetworkHealthTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlNetworkHealthTest.java index 8cfba5f720..5dbb75605e 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlNetworkHealthTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlNetworkHealthTest.java @@ -67,6 +67,30 @@ private static String task(String kind, String filters) { } finally { executor.shutdownNow(); } } } + @Test void producerUsesRuntimeAliasesAndVotePartyListApplicability() throws Exception { + var executor = Executors.newSingleThreadScheduledExecutor(); + try (var connector = connector("VELOCITY", request -> CompletableFuture.completedFuture(new ControlConnector.Response(204, "")), executor)) { + var proxyField = ControlConnector.class.getDeclaredField("proxy"); proxyField.setAccessible(true); + var config = ((VotingPluginProxy)proxyField.get(connector)).getConfig(); + when(config.getSharedTransportAuthentication()).thenReturn(" REQUIRED "); + when(config.getProxyBroadcastEnabled()).thenReturn(true); + when(config.getProxyBroadcastScopeMode()).thenReturn(" PLAYER "); + when(config.getProxyBroadcastOfflineMode()).thenReturn(" FORWARD "); + when(config.getVotePartyEnabled()).thenReturn(true); when(config.getVotePartySendToAllServers()).thenReturn(true); + var method = ControlConnector.class.getDeclaredMethod("proxyNetworkHealth"); method.setAccessible(true); + var data = (JsonObject)method.invoke(connector); + assertEquals("REQUIRED", data.get("sharedAuthentication").getAsString()); + assertTrue(data.get("offlineForwardServersApplicable").getAsBoolean()); + assertFalse(data.get("broadcastServersApplicable").getAsBoolean()); + assertFalse(data.get("votePartyServersApplicable").getAsBoolean()); + when(config.getProxyBroadcastScopeMode()).thenReturn(" SERVERS "); + when(config.getVotePartySendToAllServers()).thenReturn(false); + data = (JsonObject)method.invoke(connector); + assertTrue(data.get("broadcastServersApplicable").getAsBoolean()); + assertFalse(data.get("offlineForwardServersApplicable").getAsBoolean()); + assertTrue(data.get("votePartyServersApplicable").getAsBoolean()); + } finally { executor.shutdownNow(); } + } @Test void oldControlCannotActivateHealthPollingAndFiltersAreRejected() throws Exception { var executor = Executors.newSingleThreadScheduledExecutor(); List requests = new ArrayList<>(); try (var connector = connector("VELOCITY", request -> { requests.add(request); return CompletableFuture.completedFuture(new ControlConnector.Response(200, task("network-health", "{\"path\":\"secret\"}"))); }, executor)) { diff --git a/docs/control-agent-contract.md b/docs/control-agent-contract.md index c3ec784616..affbd709d1 100644 --- a/docs/control-agent-contract.md +++ b/docs/control-agent-contract.md @@ -121,7 +121,10 @@ The operation is carried in the normal authenticated node operation queue. Contr } ``` -`content` is omitted for READ. The node submits the result through the normal operation-result endpoint; a result has +`content` is omitted or null for READ. The shared Control configuration union may also serialize +`sendVotesToAllServers: null`, `blockedServers: []`, `preset: null`, and `options: {}`. Proxy file tasks accept +those neutral members as well as compact envelopes; populated foreign members and unknown fields remain invalid. +The node submits the result through the normal operation-result endpoint; a result has `success`, `code`, `message`, `revision` (on success), `configuration` (on success), `changes`, `reloaded`, and `rolledBack`, and includes the claimed `attemptId`. A successful configuration object contains `domain`, `fileName`, and masked `content`. `changes` is a deterministic, lexicographically ordered list of at most 20 flattened YAML paths, From 3f920f7e7d62e20354a71a1719905b8ef133bb33 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:54:46 -0600 Subject: [PATCH 04/11] Use real storage enum in backend diagnostic wire fixture --- .../votingplugin/control/ControlInspectionServiceTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java index c987d6926c..92ca1bfa82 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java @@ -48,6 +48,7 @@ class ControlInspectionServiceTest { when(plugin.getConfigVoteSites().getData()).thenReturn(sites); when(plugin.getBungeeSettings().getSharedTransportAuthentication()).thenReturn(" REQUIRED "); when(plugin.getBungeeSettings().getBungeeMethod()).thenReturn("REDIS"); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); when(plugin.getBackendProxyHandler()).thenReturn(null); when(plugin.getServer().getPluginManager().getPlugins()).thenReturn(new org.bukkit.plugin.Plugin[0]); JsonObject result = new ControlInspectionService(plugin).inspect(JsonParser.parseString("{\"kind\":\"network-health\",\"filters\":{}}").getAsJsonObject()).getAsJsonObject("result"); From 8b2db047b627ad7aba668fc476548863b8b4c2e9 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:29:49 -0600 Subject: [PATCH 05/11] Observe forwarding in existing NuVotifier without modifying it --- .../control/OptionalVotifierDiagnostics.java | 142 ++++++++++++++---- .../control/NuVotifierJarDiagnosticsTest.java | 105 +++++++++++++ .../OptionalVotifierDiagnosticsTest.java | 3 +- docs/control-agent-contract.md | 15 ++ docs/control-connector.md | 2 + 5 files changed, 240 insertions(+), 27 deletions(-) create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/control/NuVotifierJarDiagnosticsTest.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/OptionalVotifierDiagnostics.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/OptionalVotifierDiagnostics.java index 67d645fc4e..a5a119d02d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/OptionalVotifierDiagnostics.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/OptionalVotifierDiagnostics.java @@ -3,41 +3,131 @@ import com.google.gson.JsonArray; import com.google.gson.JsonObject; import java.util.*; +import java.lang.reflect.Method; /** Optional names-only API: unsupported/partial snapshots never prove empty forwarding. */ public final class OptionalVotifierDiagnostics { private OptionalVotifierDiagnostics() { } public static void add(JsonObject out, Iterable providers) { - int count = 0; + List observations = new ArrayList<>(); + int count = 0; boolean inventoryComplete = true; for (Object candidate : providers) { - if (++count > 128) return; + if (++count > 128) { inventoryComplete = false; break; } if (candidate == null || !candidate.getClass().getName().toLowerCase(Locale.ROOT).contains("votifier")) continue; - try { - Object snapshot = candidate.getClass().getMethod("getNetworkHealthSnapshot").invoke(candidate); - if (snapshot == null) continue; - JsonObject facts = new JsonObject(); - addBoolean(facts, "votifierProviderPresent", snapshot, "getProviderPresent"); - addBoolean(facts, "votifierListenerInitialized", snapshot, "getListenerInitialized"); - addBoolean(facts, "votifierForwardingKnown", snapshot, "getForwardingKnown"); - Object raw = snapshot.getClass().getMethod("getForwardingDestinations").invoke(snapshot); - JsonArray names = new JsonArray(); boolean complete = raw instanceof Iterable; - if (raw instanceof Iterable values) { - Set seen = new HashSet<>(); int examined = 0; - for (Object value : values) { - if (++examined > 100) { complete = false; break; } - if (!(value instanceof String name) || name.isBlank() || name.length() > 80 - || name.codePoints().anyMatch(Character::isISOControl) || !seen.add(name)) { complete = false; continue; } - names.add(name); - } - } - if (!complete) facts.addProperty("votifierForwardingKnown", false); - facts.add("forwardingDestinations", names); - facts.entrySet().forEach(field -> out.add(field.getKey(), field.getValue())); - return; - } catch (ReflectiveOperationException | RuntimeException ignored) { - // No secrets or exception messages cross the optional boundary. + JsonObject facts = new JsonObject(); + try { read(facts, candidate); } + catch (ReflectiveOperationException | RuntimeException ignored) { + facts = new JsonObject(); // no exception messages or partial facts cross this boundary } + observations.add(facts); } + if (observations.isEmpty()) return; + for (String field : List.of("votifierProviderPresent", "votifierListenerInitialized")) { + boolean positive = observations.stream().anyMatch(f -> f.has(field) && f.get(field).getAsBoolean()); + boolean negative = inventoryComplete && observations.stream().allMatch(f -> f.has(field) && !f.get(field).getAsBoolean()); + if (positive || negative) out.addProperty(field, positive); + } + boolean enabled = false, stateKnown = inventoryComplete, namesKnown = inventoryComplete; + LinkedHashSet names = new LinkedHashSet<>(); + for (JsonObject facts : observations) { + boolean complete = facts.has("votifierForwardingKnown") && facts.get("votifierForwardingKnown").getAsBoolean() + && facts.has("forwardingDestinations"); + namesKnown &= complete; + if (facts.has("votifierForwardingEnabled")) enabled |= facts.get("votifierForwardingEnabled").getAsBoolean(); + else if (complete) enabled |= !facts.getAsJsonArray("forwardingDestinations").isEmpty(); + else stateKnown = false; + if (facts.has("forwardingDestinations")) for (var name : facts.getAsJsonArray("forwardingDestinations")) { + if (names.size() < 100 || names.contains(name.getAsString())) names.add(name.getAsString()); + else namesKnown = false; + } + } + if (enabled || stateKnown) out.addProperty("votifierForwardingEnabled", enabled); + out.addProperty("votifierForwardingKnown", namesKnown); + JsonArray values = new JsonArray(); names.forEach(values::add); out.add("forwardingDestinations", values); + } + private static void read(JsonObject facts, Object candidate) throws ReflectiveOperationException { + if (addNuVotifierForwarding(facts, candidate)) return; + Object snapshot = candidate.getClass().getMethod("getNetworkHealthSnapshot").invoke(candidate); + if (snapshot == null) return; + addBoolean(facts, "votifierProviderPresent", snapshot, "getProviderPresent"); + addBoolean(facts, "votifierListenerInitialized", snapshot, "getListenerInitialized"); + addBoolean(facts, "votifierForwardingKnown", snapshot, "getForwardingKnown"); + Object raw = snapshot.getClass().getMethod("getForwardingDestinations").invoke(snapshot); + JsonArray names = new JsonArray(); boolean complete = raw instanceof Iterable; + if (raw instanceof Iterable values) { + Set seen = new HashSet<>(); int examined = 0; + for (Object value : values) { + if (++examined > 100) { complete = false; break; } + if (!(value instanceof String name) || name.isBlank() || name.length() > 80 + || name.codePoints().anyMatch(Character::isISOControl) || !seen.add(name)) { complete = false; continue; } + names.add(name); + } + } + if (!complete) facts.addProperty("votifierForwardingKnown", false); + facts.add("forwardingDestinations", names); + } + + /** Reads only NuVotifier's observed private field shape; it never invokes forwarding code. */ + private static boolean addNuVotifierForwarding(JsonObject out, Object candidate) + throws ReflectiveOperationException { + String type = candidate.getClass().getName(); + if (!(type.equals("com.vexsoftware.votifier.bungee.NuVotifier") + || type.equals("com.vexsoftware.votifier.velocity.VotifierPlugin"))) return false; + var field = candidate.getClass().getDeclaredField("forwardingMethod"); + field.setAccessible(true); + Object source = field.get(candidate); + if (source == null) return true; // startup failure, pending setup, or disabled: unknown + String sourceType = source.getClass().getName(); + if (sourceType.equals("com.vexsoftware.votifier.bungee.PluginMessagingForwardingSource") + || sourceType.equals("com.vexsoftware.votifier.bungee.OnlineForwardPluginMessagingForwardingSource") + || sourceType.equals("com.vexsoftware.votifier.velocity.PluginMessagingForwardingSource") + || sourceType.equals("com.vexsoftware.votifier.velocity.OnlineForwardPluginMessagingForwardingSource")) { + Class base = source.getClass().getSuperclass(); + if (!base.getName().equals("com.vexsoftware.votifier.support.forwarding.AbstractPluginMessagingForwardingSource")) return true; + var filterField = base.getDeclaredField("serverFilter"); filterField.setAccessible(true); + Object filter = filterField.get(source); + var pluginField = base.getDeclaredField("plugin"); pluginField.setAccessible(true); + Object plugin = pluginField.get(source); + if (filter == null || plugin != candidate || !filter.getClass().getName().equals( + "com.vexsoftware.votifier.support.forwarding.ServerFilter")) return true; + Object rawServers = Class.forName("com.vexsoftware.votifier.platform.ProxyVotifierPlugin", false, + candidate.getClass().getClassLoader()).getMethod("getAllBackendServers").invoke(plugin); + if (!(rawServers instanceof Iterable servers)) return true; + JsonArray names = new JsonArray(); Set seen = new HashSet<>(); int examined = 0; + Method allowed = filter.getClass().getMethod("isAllowed", String.class); + Method serverName = Class.forName("com.vexsoftware.votifier.platform.BackendServer", false, + plugin.getClass().getClassLoader()).getMethod("getName"); + for (Object server : servers) { + if (++examined > 100 || server == null) return true; + Object rawName = serverName.invoke(server); + if (!(rawName instanceof String name) || name.isBlank() || name.length() > 80 + || name.codePoints().anyMatch(Character::isISOControl) || !seen.add(name)) return true; + if (Boolean.TRUE.equals(allowed.invoke(filter, name))) names.add(name); + } + out.addProperty("votifierForwardingEnabled", names.size() > 0); + out.addProperty("votifierForwardingKnown", true); + out.add("forwardingDestinations", names); + return true; + } + if (!sourceType.equals("com.vexsoftware.votifier.support.forwarding.proxy.ProxyForwardingVoteSource")) return true; + var targets = source.getClass().getDeclaredField("backendServers"); + targets.setAccessible(true); + Object raw = targets.get(source); + if (!(raw instanceof Iterable values)) return true; + JsonArray names = new JsonArray(); Set seen = new HashSet<>(); int examined = 0; + for (Object value : values) { + if (++examined > 100 || value == null || !value.getClass().getName().equals( + "com.vexsoftware.votifier.support.forwarding.proxy.ProxyForwardingVoteSource$BackendServer")) return true; + var name = value.getClass().getDeclaredField("name"); name.setAccessible(true); + Object rawName = name.get(value); + if (!(rawName instanceof String text) || text.isBlank() || text.length() > 80 + || text.codePoints().anyMatch(Character::isISOControl) || !seen.add(text)) return true; + names.add(text); + } + out.addProperty("votifierForwardingEnabled", !names.isEmpty()); + out.addProperty("votifierForwardingKnown", true); + out.add("forwardingDestinations", names); + return true; } private static void addBoolean(JsonObject out, String field, Object snapshot, String getter) throws ReflectiveOperationException { Object value = snapshot.getClass().getMethod(getter).invoke(snapshot); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/NuVotifierJarDiagnosticsTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/NuVotifierJarDiagnosticsTest.java new file mode 100644 index 0000000000..a3974349ae --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/NuVotifierJarDiagnosticsTest.java @@ -0,0 +1,105 @@ +package com.bencodez.votingplugin.control; + +import com.google.gson.JsonObject; +import java.lang.reflect.*; +import java.net.*; +import java.nio.file.*; +import java.util.*; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** Exercises actual released NuVotifier classes without starting a plugin or forwarding votes. */ +class NuVotifierJarDiagnosticsTest { + @Test void releasedProxyAdaptersObserveOnlyBoundedForwardingFacts() throws Exception { + String configured = System.getProperty("nuvotifier.health.candidates"); + if (configured == null) configured = Path.of(com.vexsoftware.votifier.support.forwarding.ServerFilter.class + .getProtectionDomain().getCodeSource().getLocation().toURI()).toString(); + for (String file : configured.split(java.util.regex.Pattern.quote(System.getProperty("path.separator")))) { + assertTrue(!file.isBlank() && Files.isRegularFile(Path.of(file)), "Candidate must exist"); + try (URLClassLoader loader = new ChildFirstNuLoader(Path.of(file).toUri().toURL())) { + for (String platform : List.of("bungee.NuVotifier", "velocity.VotifierPlugin")) { + Class plugin = Class.forName("com.vexsoftware.votifier." + platform, false, loader); + Object provider = mock(plugin); // platform constructors/lifecycle never run + Object socket = socketSource(loader, List.of("actual-target")); + setSource(provider, socket); + assertEquals("actual-target", facts(provider).getAsJsonArray("forwardingDestinations").get(0).getAsString()); + assertTrue(facts(provider).get("votifierForwardingEnabled").getAsBoolean()); + Object active = mock(plugin); setSource(active, socket); + Object disabled = mock(plugin); setSource(disabled, socketSource(loader, List.of())); + assertFalse(facts(disabled).get("votifierForwardingEnabled").getAsBoolean()); + for (List order : List.of(List.of(disabled, active), List.of(active, disabled), List.of( + new OptionalVotifierDiagnosticsTest.VotifierProvider(new OptionalVotifierDiagnosticsTest.Snapshot(List.of())), active))) { + assertTrue(facts(order).get("votifierForwardingEnabled").getAsBoolean()); + } + setSource(provider, null); + assertUnknown(provider); + assertUnknown(List.of(new OptionalVotifierDiagnosticsTest.VotifierProvider( + new OptionalVotifierDiagnosticsTest.Snapshot(List.of())), provider)); + setSource(provider, mock(Class.forName("com.vexsoftware.votifier.support.forwarding.ForwardingVoteSource", false, loader))); + assertUnknown(provider); + for (List malformed : List.of(List.of("duplicate", "duplicate"), List.of("bad\nname"), + java.util.stream.IntStream.range(0, 101).mapToObj(i -> "backend-" + i).toList())) { + setSource(provider, socketSource(loader, malformed)); assertUnknown(provider); + } + assertUnknown(Collections.nCopies(129, disabled)); + for (String sourceName : List.of("PluginMessagingForwardingSource", "OnlineForwardPluginMessagingForwardingSource")) { + String packageName = platform.substring(0, platform.indexOf('.')); + Object pm = mock(Class.forName("com.vexsoftware.votifier." + packageName + "." + sourceName, false, loader)); + Class base = pm.getClass().getSuperclass(); + Field owner = base.getDeclaredField("plugin"); owner.setAccessible(true); owner.set(pm, provider); + Field filter = base.getDeclaredField("serverFilter"); filter.setAccessible(true); + Class backendApi = Class.forName("com.vexsoftware.votifier.platform.BackendServer", false, loader); + Object backend = mock(backendApi); + when(backendApi.getMethod("getName").invoke(backend)).thenReturn("backend-a"); + Class proxyApi = Class.forName("com.vexsoftware.votifier.platform.ProxyVotifierPlugin", false, loader); + when(proxyApi.getMethod("getAllBackendServers").invoke(provider)).thenReturn(List.of(backend)); + Constructor filterCtor = Class.forName("com.vexsoftware.votifier.support.forwarding.ServerFilter", false, loader) + .getConstructor(Collection.class, boolean.class); + setSource(provider, pm); + filter.set(pm, filterCtor.newInstance(List.of("backend-a"), true)); + assertTrue(facts(provider).get("votifierForwardingEnabled").getAsBoolean()); + assertEquals("backend-a", facts(provider).getAsJsonArray("forwardingDestinations").get(0).getAsString()); + filter.set(pm, filterCtor.newInstance(List.of(), true)); + assertFalse(facts(provider).get("votifierForwardingEnabled").getAsBoolean()); + filter.set(pm, filterCtor.newInstance(List.of("backend-a"), false)); + assertFalse(facts(provider).get("votifierForwardingEnabled").getAsBoolean()); + filter.set(pm, null); assertUnknown(provider); + } + } + } + } + } + private static Object socketSource(ClassLoader loader, List names) throws Exception { + Class source = Class.forName("com.vexsoftware.votifier.support.forwarding.proxy.ProxyForwardingVoteSource", false, loader); + Constructor backend = Class.forName(source.getName() + "$BackendServer", false, loader) + .getConstructor(String.class, InetSocketAddress.class, java.security.Key.class); + List targets = new ArrayList<>(); + for (String name : names) targets.add(backend.newInstance(name, + InetSocketAddress.createUnresolved("not-exported.invalid", 8192), null)); + Constructor ctor = Arrays.stream(source.getConstructors()).filter(c -> c.getParameterCount() == 4).findFirst().orElseThrow(); + return ctor.newInstance(null, null, targets, null); // stores fields; no start/forward call + } + private static void setSource(Object provider, Object source) throws Exception { + Field field = provider.getClass().getDeclaredField("forwardingMethod"); field.setAccessible(true); field.set(provider, source); + } + private static JsonObject facts(Object provider) { return facts(List.of(provider)); } + private static JsonObject facts(List providers) { + JsonObject facts = new JsonObject(); OptionalVotifierDiagnostics.add(facts, providers); + assertFalse(facts.toString().contains("not-exported.invalid")); + return facts; + } + private static void assertUnknown(Object provider) { assertFalse(facts(provider).has("votifierForwardingEnabled")); } + private static void assertUnknown(List providers) { assertFalse(facts(providers).has("votifierForwardingEnabled")); } + private static final class ChildFirstNuLoader extends URLClassLoader { + ChildFirstNuLoader(URL url) { super(new URL[]{url}, NuVotifierJarDiagnosticsTest.class.getClassLoader()); } + @Override protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name.startsWith("com.vexsoftware.votifier.")) synchronized (getClassLoadingLock(name)) { + Class loaded = findLoadedClass(name); + if (loaded == null) try { loaded = findClass(name); } catch (ClassNotFoundException ignored) { } + if (loaded != null) { if (resolve) resolveClass(loaded); return loaded; } + } + return super.loadClass(name, resolve); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/OptionalVotifierDiagnosticsTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/OptionalVotifierDiagnosticsTest.java index 57b5434712..72789c5117 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/OptionalVotifierDiagnosticsTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/OptionalVotifierDiagnosticsTest.java @@ -20,7 +20,7 @@ public static class Snapshot { @Test void oldProviderApiIsUnknownAndValidNewApiContainsOnlyAllowlistedFacts() { JsonObject old = new JsonObject(); OptionalVotifierDiagnostics.add(old, List.of(new Object())); assertEquals(0, old.size()); JsonObject current = new JsonObject(); OptionalVotifierDiagnostics.add(current, List.of(new VotifierProvider(new Snapshot(List.of("backend-a"))))); - assertEquals(Set.of("votifierProviderPresent", "votifierListenerInitialized", "votifierForwardingKnown", "forwardingDestinations"), current.keySet()); + assertEquals(Set.of("votifierProviderPresent", "votifierListenerInitialized", "votifierForwardingKnown", "forwardingDestinations", "votifierForwardingEnabled"), current.keySet()); assertTrue(current.get("votifierForwardingKnown").getAsBoolean()); assertEquals("backend-a", current.getAsJsonArray("forwardingDestinations").get(0).getAsString()); } @Test void invalidDuplicateOrOversizedApiEvidenceNeverProvesEmptyForwarding() { @@ -39,4 +39,5 @@ public static class Snapshot { assertTrue(facts.get("votifierForwardingKnown").getAsBoolean()); assertTrue(facts.get("votifierListenerInitialized").getAsBoolean()); } } + } diff --git a/docs/control-agent-contract.md b/docs/control-agent-contract.md index affbd709d1..d2b3add0c3 100644 --- a/docs/control-agent-contract.md +++ b/docs/control-agent-contract.md @@ -543,3 +543,18 @@ guarantee. A `voteId` correlates rows written with the same identifier. It is not a complete delivery trace: VoteLog does not promise an entry for every validation rejection, network hop, duplicate decision, reward command, command outcome, or expiry. UI and support output must say “logged events” and must not claim end-to-end delivery proof. + +## Existing NuVotifier forwarding diagnostics + +VotingPlugin can observe forwarding in unmodified NuVotifier BungeeCord/Velocity deployments through a deliberately +narrow, optional reflection adapter. The recognized released runtime shapes are tested against NuVotifier 2.7.2 and 2.7.3. +It reads only the known forwarding handler, socket target entry names, or PluginMessaging filter and proxy-registry names. +The public backend-name API and pure server filter are observational; no lifecycle, forward, vote, scheduler, command, +network, credential, or configuration-write method is invoked. The adapter never reads NuVotifier files, endpoints or keys. + +The optional Boolean `votifierForwardingEnabled` distinguishes an observed eligible forwarding path from unavailable +state; bounded `forwardingDestinations` are included only as names. A null handler can mean disabled, loading or failed +initialization, so remains UNKNOWN. Unrecognized/inaccessible shapes, malformed names and incomplete inventories cannot +produce a healthy disabled result. Positive forwarding evidence cannot be hidden by another disabled provider. +Backend NuVotifier remains valid for synthetic `TriggerVotifierEvent` compatibility; ingress-provider forwarding is a +separate path. NuVotifier needs no changes or upgrade solely for this inspection. diff --git a/docs/control-connector.md b/docs/control-connector.md index 6d17647778..bf838cdaaf 100644 --- a/docs/control-connector.md +++ b/docs/control-connector.md @@ -380,3 +380,5 @@ Arbitrary console commands, direct backup-rollback endpoints, topology persisten signed remote release manifests, and remote support remain later milestones. Automatic release tracking trusts GitHub's authenticated release metadata and published asset digest for the official repository. Administrators who require an independently reviewed trust pin can continue to supply `DownloadUrl` and `Sha256` locally. + +Network Doctor also supports bounded forwarding observations from recognized existing NuVotifier proxy runtime shapes, without changing NuVotifier. See the agent contract for availability, eligibility filtering and UNKNOWN behavior. From 7ca6de5659c6b05afab915ec4c1aa5bbf8bdf53b Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:38:48 -0600 Subject: [PATCH 06/11] Account for NuVotifier online forwarding fallback destinations --- .../control/OptionalVotifierDiagnostics.java | 20 +++++++++++++++++++ .../control/NuVotifierJarDiagnosticsTest.java | 17 ++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/OptionalVotifierDiagnostics.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/OptionalVotifierDiagnostics.java index a5a119d02d..926cdd978b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/OptionalVotifierDiagnostics.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/OptionalVotifierDiagnostics.java @@ -104,6 +104,26 @@ private static boolean addNuVotifierForwarding(JsonObject out, Object candidate) || name.codePoints().anyMatch(Character::isISOControl) || !seen.add(name)) return true; if (Boolean.TRUE.equals(allowed.invoke(filter, name))) names.add(name); } + // Online forwarding deliberately bypasses serverFilter for its fallback path. + if (sourceType.endsWith(".OnlineForwardPluginMessagingForwardingSource")) { + var fallbackField = source.getClass().getDeclaredField("fallbackServer"); + fallbackField.setAccessible(true); + Object rawFallback = fallbackField.get(source); + if (rawFallback != null) { + if (!(rawFallback instanceof String fallback) || fallback.isBlank() || fallback.length() > 80 + || fallback.codePoints().anyMatch(Character::isISOControl)) return true; + Object resolved = Class.forName("com.vexsoftware.votifier.platform.ProxyVotifierPlugin", false, + candidate.getClass().getClassLoader()).getMethod("getServer", String.class).invoke(plugin, fallback); + if (!(resolved instanceof Optional target)) return true; + if (target.isPresent()) { + Object rawName = serverName.invoke(target.get()); + if (!(rawName instanceof String name) || !seen.contains(name)) return true; + boolean listed = false; + for (var value : names) listed |= value.getAsString().equals(name); + if (!listed) names.add(name); + } + } + } out.addProperty("votifierForwardingEnabled", names.size() > 0); out.addProperty("votifierForwardingKnown", true); out.add("forwardingDestinations", names); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/NuVotifierJarDiagnosticsTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/NuVotifierJarDiagnosticsTest.java index a3974349ae..010b295dc4 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/NuVotifierJarDiagnosticsTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/NuVotifierJarDiagnosticsTest.java @@ -64,6 +64,23 @@ class NuVotifierJarDiagnosticsTest { assertFalse(facts(provider).get("votifierForwardingEnabled").getAsBoolean()); filter.set(pm, filterCtor.newInstance(List.of("backend-a"), false)); assertFalse(facts(provider).get("votifierForwardingEnabled").getAsBoolean()); + if (sourceName.startsWith("OnlineForward")) { + Field fallback = pm.getClass().getDeclaredField("fallbackServer"); fallback.setAccessible(true); + when(proxyApi.getMethod("getServer", String.class).invoke(provider, "backend-a")) + .thenReturn(Optional.of(backend)); + fallback.set(pm, "backend-a"); + // Both an empty whitelist and a rejecting blacklist still allow the fallback. + for (boolean whitelist : List.of(true, false)) { + filter.set(pm, filterCtor.newInstance(whitelist ? List.of() : List.of("backend-a"), whitelist)); + assertTrue(facts(provider).get("votifierForwardingEnabled").getAsBoolean()); + assertEquals("backend-a", facts(provider).getAsJsonArray("forwardingDestinations").get(0).getAsString()); + } + when(proxyApi.getMethod("getServer", String.class).invoke(provider, "backend-a")) + .thenReturn(Optional.empty()); + assertFalse(facts(provider).get("votifierForwardingEnabled").getAsBoolean()); + fallback.set(pm, "bad\nname"); assertUnknown(provider); + fallback.set(pm, null); + } filter.set(pm, null); assertUnknown(provider); } } From 32362acfab39549126110f1b9ddcfc27f1779087 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:08:57 -0600 Subject: [PATCH 07/11] Keep optional health diagnostics bounded and recovery safe --- .../control/BackendControlConnector.java | 3 +- .../control/OptionalVotifierDiagnostics.java | 2 +- .../proxy/bungee/BungeeConfig.java | 3 +- .../proxy/control/ControlConnector.java | 22 +++++++++++---- .../proxy/velocity/VotingPluginVelocity.java | 2 +- .../BackendControlConnectorProtocolTest.java | 12 ++++++++ .../OptionalVotifierDiagnosticsTest.java | 25 +++++++++++++++++ .../VotingPluginBungeeInitializationTest.java | 8 ++++++ .../control/ControlNetworkHealthTest.java | 28 +++++++++++++++++++ .../velocity/NetworkHealthInventoryTest.java | 10 +++++++ 10 files changed, 105 insertions(+), 10 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java index e1705b0587..7e7053fd32 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java @@ -562,7 +562,8 @@ private void claimAndInspect() throws Exception { InspectionTaskResult executeInspection(JsonObject query) { try { - if (query != null && query.has("kind") && "network-health".equals(query.get("kind").getAsString()) + if (query != null && query.has("kind") && query.get("kind").isJsonPrimitive() + && "network-health".equals(query.get("kind").getAsString()) && !networkHealthAccepted) { return InspectionTaskResult.failure("UNAVAILABLE", "Network health was not negotiated"); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/OptionalVotifierDiagnostics.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/OptionalVotifierDiagnostics.java index 926cdd978b..ac8097cee4 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/OptionalVotifierDiagnostics.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/OptionalVotifierDiagnostics.java @@ -16,7 +16,7 @@ public static void add(JsonObject out, Iterable providers) { if (candidate == null || !candidate.getClass().getName().toLowerCase(Locale.ROOT).contains("votifier")) continue; JsonObject facts = new JsonObject(); try { read(facts, candidate); } - catch (ReflectiveOperationException | RuntimeException ignored) { + catch (ReflectiveOperationException | RuntimeException | LinkageError ignored) { facts = new JsonObject(); // no exception messages or partial facts cross this boundary } observations.add(facts); 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 cadc73acf5..635f5398f5 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 @@ -310,7 +310,8 @@ public String getMultiProxyRedisUsername() { @Override public Collection getMultiProxyServers() { - return getData().getSection("MultiProxyServers").getKeys(); + var section = getData().getSection("MultiProxyServers"); + return section == null ? List.of() : section.getKeys(); } @Override 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 f77b504efb..7ce0de8f04 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 @@ -311,7 +311,7 @@ public void start() { } private void pollNetworkHealth() { - if (closed || !registered || status != Status.CONNECTED || !acceptedCapabilities.contains(NETWORK_HEALTH_CAPABILITY) + if (closed || recovering || !registered || status != Status.CONNECTED || !acceptedCapabilities.contains(NETWORK_HEALTH_CAPABILITY) || !acceptedCapabilities.contains("data.inspect.v1") || System.nanoTime() < inspectionRetryAtNanos || !inspectionInFlight.compareAndSet(false, true)) return; @@ -332,7 +332,15 @@ private void pollNetworkHealth() { || !query.get("filters").isJsonObject() || !query.getAsJsonObject("filters").keySet().isEmpty()) throw new ProtocolException(); String kind = requireString(query, "kind"); JsonObject result; - if ("network-health".equals(kind)) result = networkHealthResult(); + if ("network-health".equals(kind)) { + try { result = networkHealthResult(); } + catch (RuntimeException failure) { + result = new JsonObject(); + result.addProperty("success", false); + result.addProperty("code", "INSPECTION_FAILED"); + result.addProperty("message", "Network health inspection failed"); + } + } else { result = new JsonObject(); result.addProperty("success", false); result.addProperty("code", "UNSUPPORTED"); result.addProperty("message", "Proxy inspection kind is unsupported"); } result.addProperty("sessionId", sessionId.toString()); result.addProperty("attemptId", task.get("attemptId").getAsString()); @@ -847,10 +855,12 @@ private Request registrationRequest() { body.addProperty("displayName", settings.displayName()); body.addProperty("platform", settings.platform()); body.addProperty("pluginVersion", settings.pluginVersion()); - JsonArray detectedPlugins = new JsonArray(); - proxyPluginNames().stream().filter(name -> name.length() <= 100 && name.codePoints().noneMatch(Character::isISOControl)).distinct().sorted(String.CASE_INSENSITIVE_ORDER).limit(128) - .forEach(detectedPlugins::add); - body.add("detectedPlugins", detectedPlugins); + if (!recovering) { + JsonArray detectedPlugins = new JsonArray(); + proxyPluginNames().stream().filter(name -> name.length() <= 100 && name.codePoints().noneMatch(Character::isISOControl)).distinct().sorted(String.CASE_INSENSITIVE_ORDER).limit(128) + .forEach(detectedPlugins::add); + body.add("detectedPlugins", detectedPlugins); + } addCapabilities(body); return new Request("POST", "/api/v1/nodes/register", body.toString()); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java index 1a1e0580c6..88e9f8b458 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java @@ -96,7 +96,7 @@ static Collection installedPluginNames(Collection name != null && !name.isBlank()).limit(128).toList(); } static Collection diagnosticProviders(Collection plugins) { - return plugins.stream().limit(128).flatMap(container -> container.getInstance().map(instance -> (Object)instance).stream()).toList(); + return plugins.stream().flatMap(container -> container.getInstance().map(instance -> (Object)instance).stream()).limit(129).toList(); } @Getter 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 ef11d49e2b..39a1f814da 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java @@ -440,6 +440,18 @@ private static JsonObject deploymentTask() { "{\"kind\":\"reward-file-inventory\"}").getAsJsonObject()).code()); } + @Test void malformedNetworkHealthKindsReachTypedValidation() throws Exception { + BackendControlConnector connector = org.mockito.Mockito.mock(BackendControlConnector.class, + org.mockito.Mockito.CALLS_REAL_METHODS); + var inspections = BackendControlConnector.class.getDeclaredField("inspections"); + inspections.setAccessible(true); + inspections.set(connector, new ControlInspectionService()); + for (String kind : List.of("null", "{}", "[]")) { + JsonObject query = JsonParser.parseString("{\"kind\":" + kind + ",\"filters\":{}}").getAsJsonObject(); + assertEquals("VALIDATION_ERROR", connector.executeInspection(query).code(), kind); + } + } + @Test void unexpectedInspectionFailureMessagesNeverExposeTheCause() { String message = BackendControlConnector.inspectionFailureMessage(new IllegalStateException( "jdbc:mysql://database.internal/votes user=secret path=/srv/private")); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/OptionalVotifierDiagnosticsTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/OptionalVotifierDiagnosticsTest.java index 72789c5117..560919d40e 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/OptionalVotifierDiagnosticsTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/OptionalVotifierDiagnosticsTest.java @@ -29,6 +29,31 @@ public static class Snapshot { assertFalse(current.get("votifierForwardingKnown").getAsBoolean()); assertTrue(current.getAsJsonArray("forwardingDestinations").size() <= 100); } } + @Test void providerLinkageFailureDoesNotAbortOtherProviderDiagnostics(@org.junit.jupiter.api.io.TempDir java.nio.file.Path dir) throws Exception { + var missing = dir.resolve("Missing.java"); + var provider = dir.resolve("BrokenVotifierProvider.java"); + java.nio.file.Files.writeString(missing, "public class Missing {}"); + java.nio.file.Files.writeString(provider, "public class BrokenVotifierProvider { public Object getNetworkHealthSnapshot() { return null; } public Missing optional() { return null; } }"); + assertEquals(0, javax.tools.ToolProvider.getSystemJavaCompiler().run(null, null, null, + "-d", dir.toString(), missing.toString(), provider.toString())); + try (var loader = new java.net.URLClassLoader(new java.net.URL[]{dir.toUri().toURL()}, getClass().getClassLoader()) { + @Override protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if ("Missing".equals(name)) throw new ClassNotFoundException(name); + return super.loadClass(name, resolve); + } + }) { + Class type = loader.loadClass("BrokenVotifierProvider"); + Object broken = type.getConstructor().newInstance(); + assertThrows(LinkageError.class, () -> type.getMethod("getNetworkHealthSnapshot")); + JsonObject facts = new JsonObject(); + OptionalVotifierDiagnostics.add(facts, List.of(broken, new VotifierProvider(new Snapshot(List.of("backend-a"))))); + assertFalse(facts.get("votifierForwardingKnown").getAsBoolean()); + assertTrue(facts.get("votifierForwardingEnabled").getAsBoolean()); + assertEquals("backend-a", facts.getAsJsonArray("forwardingDestinations").get(0).getAsString()); + JsonObject failed = new JsonObject(); OptionalVotifierDiagnostics.add(failed, List.of(broken)); + assertFalse(failed.has("votifierForwardingEnabled")); + } + } @Test void consumesCandidateSnapshotWithoutACompileTimeDependency() throws Exception { String candidate = System.getProperty("votifier.health.candidate"); org.junit.jupiter.api.Assumptions.assumeTrue(candidate != null, "Optional paired artifact supplied by integration validation"); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungeeInitializationTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungeeInitializationTest.java index 51d8d645c9..81d16ffbfa 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungeeInitializationTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungeeInitializationTest.java @@ -25,6 +25,14 @@ import com.bencodez.votingplugin.proxy.VotingPluginProxy; class VotingPluginBungeeInitializationTest { + @Test + void missingMultiProxySectionIsAnEmptyDiagnosticInventory() { + BungeeConfig config = mock(BungeeConfig.class, CALLS_REAL_METHODS); + net.md_5.bungee.config.Configuration yaml = new net.md_5.bungee.config.Configuration(); + when(config.getData()).thenReturn(yaml); + assertTrue(config.getMultiProxyServers().isEmpty()); + } + @Test void freshInitializationDoesNotCreateADisposableRuntimeBeforeFullLoad() { VotingPluginBungee plugin = mock(VotingPluginBungee.class, CALLS_REAL_METHODS); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlNetworkHealthTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlNetworkHealthTest.java index 5dbb75605e..f3af11ee16 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlNetworkHealthTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlNetworkHealthTest.java @@ -99,6 +99,34 @@ private static String task(String kind, String filters) { assertEquals(1, requests.size(), "Invalid filters must never trigger a result snapshot"); } finally { executor.shutdownNow(); } } + + @Test void recoveryConnectorDoesNotClaimNewDiagnosticsOrExposeInventory() throws Exception { + var executor = Executors.newSingleThreadScheduledExecutor(); List requests = new ArrayList<>(); + try (var connector = connector("VELOCITY", request -> { requests.add(request); return CompletableFuture.completedFuture(new ControlConnector.Response(204, "")); }, executor)) { + field(connector, "recovering", true); + poll(connector); + assertTrue(requests.isEmpty()); + var method = ControlConnector.class.getDeclaredMethod("registrationRequest"); method.setAccessible(true); + var registration = (ControlConnector.Request) method.invoke(connector); + assertFalse(JsonParser.parseString(registration.body()).getAsJsonObject().has("detectedPlugins")); + } finally { executor.shutdownNow(); } + } + + @Test void collectionFailureSettlesClaimWithGenericInspectionFailure() throws Exception { + var executor = Executors.newSingleThreadScheduledExecutor(); List requests = new ArrayList<>(); + try (var connector = connector("VELOCITY", request -> { + requests.add(request); + if (requests.size() == 1) return CompletableFuture.completedFuture(new ControlConnector.Response(200, task("network-health", "{}"))); + return CompletableFuture.completedFuture(new ControlConnector.Response(200, "{}")); + }, executor)) { + var proxyField = ControlConnector.class.getDeclaredField("proxy"); proxyField.setAccessible(true); + when(((VotingPluginProxy) proxyField.get(connector)).getAllConfiguredServers()).thenThrow(new IllegalStateException("secret details")); + poll(connector); + assertEquals(2, requests.size()); + assertTrue(requests.get(1).body().contains("\"code\":\"INSPECTION_FAILED\"")); + assertFalse(requests.get(1).body().contains("secret details")); + } finally { executor.shutdownNow(); } + } @Test void shutdownCancelsThePendingClaimAndCannotPostItsLateResult() throws Exception { var executor = Executors.newSingleThreadScheduledExecutor(); List requests = new ArrayList<>(); var pending = new CompletableFuture(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/NetworkHealthInventoryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/NetworkHealthInventoryTest.java index c80b57b737..a36023191d 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/NetworkHealthInventoryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/NetworkHealthInventoryTest.java @@ -21,4 +21,14 @@ class NetworkHealthInventoryTest { assertEquals(List.of("votifierplus"), VotingPluginVelocity.installedPluginNames(List.of(installed))); } + @Test void diagnosticProvidersRetainProvidersBeyondDisplayInventoryLimit() { + List plugins = new ArrayList<>(); + for (int i = 0; i < 129; i++) { + PluginContainer plugin = mock(PluginContainer.class); + doReturn(Optional.of(new Object())).when(plugin).getInstance(); + plugins.add(plugin); + } + assertEquals(129, VotingPluginVelocity.diagnosticProviders(plugins).size()); + } + } From 4b5b32661e2002e6a40c7c8db3537de31ca16ee2 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:27:24 -0600 Subject: [PATCH 08/11] Echo bounded opaque inspection attempt IDs without UUID parsing --- .../proxy/control/ControlConnector.java | 14 +++++++-- .../control/ControlNetworkHealthTest.java | 29 +++++++++++++++++++ docs/control-agent-contract.md | 3 ++ 3 files changed, 43 insertions(+), 3 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 7ce0de8f04..86b494b5fb 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 @@ -59,6 +59,7 @@ public final class ControlConnector implements AutoCloseable { static final int PROTOCOL_VERSION = 1; static final int MAX_RESPONSE_BYTES = 4 * 1024 * 1024; private static final int MAX_RESULT_TEXT_CHARS = 240; + private static final int MAX_INSPECTION_ATTEMPT_ID_CHARS = 256; private static final Pattern NODE_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); private static final Set BASE_CAPABILITIES = Set.of("presence.snapshot"); private static final String CONFIGURATION_CAPABILITY = "config.proxy-routing.v1"; @@ -325,8 +326,8 @@ private void pollNetworkHealth() { requireSuccess(response); JsonObject task = parseObject(response.body); if (!task.keySet().equals(Set.of("inspectionId", "attemptId", "query"))) throw new ProtocolException(); - String id = requireString(task, "inspectionId"), attempt = requireString(task, "attemptId"); - if (!UUID.fromString(id).toString().equals(id) || !UUID.fromString(attempt).toString().equals(attempt)) throw new ProtocolException(); + String id = requireString(task, "inspectionId"), attempt = requireInspectionAttemptId(task); + if (!UUID.fromString(id).toString().equals(id)) throw new ProtocolException(); JsonObject query = task.getAsJsonObject("query"); if (query == null || !query.keySet().equals(Set.of("kind", "filters")) || !query.get("filters").isJsonObject() || !query.getAsJsonObject("filters").keySet().isEmpty()) throw new ProtocolException(); @@ -343,7 +344,7 @@ private void pollNetworkHealth() { } else { result = new JsonObject(); result.addProperty("success", false); result.addProperty("code", "UNSUPPORTED"); result.addProperty("message", "Proxy inspection kind is unsupported"); } result.addProperty("sessionId", sessionId.toString()); - result.addProperty("attemptId", task.get("attemptId").getAsString()); + result.addProperty("attemptId", attempt); return sendInspectionRequest(new Request("POST", "/api/v1/nodes/" + settings.nodeId() + "/inspections/" + task.get("inspectionId").getAsString() + "/result", result.toString())) .thenAccept(ControlConnector::requireSuccess); @@ -1441,6 +1442,13 @@ private static String requireString(JsonObject body, String name) { return body.get(name).getAsString(); } + private static String requireInspectionAttemptId(JsonObject body) { + String value = requireString(body, "attemptId"); + if (value.isEmpty() || value.length() > MAX_INSPECTION_ATTEMPT_ID_CHARS + || value.codePoints().anyMatch(Character::isISOControl)) throw new MalformedResponseException(); + return value; + } + private JsonObject commonBody() { JsonObject body = sessionBody(); body.addProperty("nodeId", settings.nodeId()); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlNetworkHealthTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlNetworkHealthTest.java index f3af11ee16..ee03e467f8 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlNetworkHealthTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlNetworkHealthTest.java @@ -127,6 +127,35 @@ private static String task(String kind, String filters) { assertFalse(requests.get(1).body().contains("secret details")); } finally { executor.shutdownNow(); } } + + @Test void inspectionEchoesOpaqueAttemptIdsAndRejectsMalformedBounds() throws Exception { + var executor = Executors.newSingleThreadScheduledExecutor(); List requests = new ArrayList<>(); + String opaque = "attempt-from-control-7f3a"; + try (var connector = connector("VELOCITY", request -> { + requests.add(request); + if (requests.size() == 1) return CompletableFuture.completedFuture(new ControlConnector.Response(200, + taskWithAttempt("network-health", "{}", opaque))); + return CompletableFuture.completedFuture(new ControlConnector.Response(200, "{}")); + }, executor)) { + poll(connector); + assertEquals(opaque, JsonParser.parseString(requests.get(1).body()).getAsJsonObject().get("attemptId").getAsString()); + } finally { executor.shutdownNow(); } + + requests.clear(); + try (var connector = connector("VELOCITY", request -> { + requests.add(request); + return CompletableFuture.completedFuture(new ControlConnector.Response(200, + taskWithAttempt("network-health", "{}", "x".repeat(257)))); + }, executor)) { + poll(connector); + assertEquals(1, requests.size()); + } finally { executor.shutdownNow(); } + } + + private static String taskWithAttempt(String kind, String filters, String attempt) { + return "{\"inspectionId\":\"" + ID + "\",\"attemptId\":\"" + attempt + + "\",\"query\":{\"kind\":\"" + kind + "\",\"filters\":" + filters + "}}"; + } @Test void shutdownCancelsThePendingClaimAndCannotPostItsLateResult() throws Exception { var executor = Executors.newSingleThreadScheduledExecutor(); List requests = new ArrayList<>(); var pending = new CompletableFuture(); diff --git a/docs/control-agent-contract.md b/docs/control-agent-contract.md index d2b3add0c3..6aaa01b4fd 100644 --- a/docs/control-agent-contract.md +++ b/docs/control-agent-contract.md @@ -506,6 +506,9 @@ credential, secret, raw payload, SQL metadata, or arbitrary storage key is expos ## Network health inspection +Proxy inspection `attemptId` is opaque and echoed unchanged: a non-empty string of at most 256 characters, +without control characters. Only `inspectionId` is a UUID; attempt IDs must not be interpreted as UUIDs. + Redis multi-proxy peers use `proxyServers`; socket multi-proxy peers use `socketProxyServers`. Neither list is inferred from backend topology. `broadcastServersApplicable` and `offlineForwardServersApplicable` describe whether persisted routing lists participate in the current configured broadcast mode. Shared transport authentication From ed9970bcfa072cba241bc9443590e7d1a2715208 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:41:07 -0600 Subject: [PATCH 09/11] Omit incomplete Bukkit network health plugin inventories --- .../control/ControlInspectionService.java | 18 +++++++---- .../control/ControlInspectionServiceTest.java | 30 +++++++++++++++++++ docs/control-agent-contract.md | 5 +++- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java index 4a465eb7bb..408f69568b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java @@ -581,12 +581,18 @@ private JsonObject networkHealth(JsonObject filters) { result.add("voteSites", sites); result.addProperty("voteSitesComplete", siteComplete); NetworkHealthConfigFacts.addObservedServices(result, plugin.getServerData().getServiceSitesReadOnly()); - JsonArray plugins = new JsonArray(); - java.util.Arrays.stream(plugin.getServer().getPluginManager().getPlugins()) - .map(Plugin::getName).filter(name -> name != null && !name.isBlank()).distinct() - .sorted(String.CASE_INSENSITIVE_ORDER.thenComparing(Comparator.naturalOrder())).limit(MAX_ROWS) - .forEach(name -> plugins.add(safe(name, 80))); - result.add("detectedPlugins", plugins); + // A bounded prefix cannot prove the full inventory; omit it unless every + // discovered name fits the complete bounded representation. + List rawPluginNames = java.util.Arrays.stream(plugin.getServer().getPluginManager().getPlugins()) + .map(Plugin::getName).toList(); + List pluginNames = rawPluginNames.stream().filter(name -> name != null && !name.isBlank()).distinct() + .sorted(String.CASE_INSENSITIVE_ORDER.thenComparing(Comparator.naturalOrder())).limit(MAX_ROWS + 1).toList(); + if (rawPluginNames.size() <= MAX_ROWS && pluginNames.size() == rawPluginNames.size()) { + JsonArray plugins = new JsonArray(); + pluginNames.stream().filter(name -> name.length() <= 80 && name.codePoints().noneMatch(Character::isISOControl)) + .forEach(name -> plugins.add(name)); + if (plugins.size() == pluginNames.size()) result.add("detectedPlugins", plugins); + } if (result.has("invalidConfigurationFields")) result.addProperty("configurationHealthy", false); return result; } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java index 92ca1bfa82..1e6036341e 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java @@ -78,6 +78,36 @@ class ControlInspectionServiceTest { assertTrue(ControlInspectionService.rewardFileInventoryQuery(JsonParser.parseString( "{\"kind\":\"reward-file-inventory\"}").getAsJsonObject())); } + + @Test void networkHealthOmitsIncompleteBukkitPluginInventory() { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + var config = new org.bukkit.configuration.file.YamlConfiguration(); + var sites = new org.bukkit.configuration.file.YamlConfiguration(); + when(plugin.getConfigFile().getData()).thenReturn(config); + when(plugin.getBungeeSettings().getData()).thenReturn(config); + when(plugin.getConfigVoteSites().getData()).thenReturn(sites); + when(plugin.getServer().getPluginManager().getPlugins()).thenReturn(java.util.stream.IntStream.range(0, 101) + .mapToObj(index -> mock(org.bukkit.plugin.Plugin.class, RETURNS_DEEP_STUBS)).toArray(org.bukkit.plugin.Plugin[]::new)); + org.bukkit.plugin.Plugin[] plugins = plugin.getServer().getPluginManager().getPlugins(); + for (int index = 0; index < plugins.length; index++) when(plugins[index].getName()).thenReturn("Plugin" + index); + JsonObject result = new ControlInspectionService(plugin).inspect(JsonParser.parseString( + "{\"kind\":\"network-health\",\"filters\":{}}").getAsJsonObject()).getAsJsonObject("result"); + assertFalse(result.has("detectedPlugins")); + org.bukkit.plugin.Plugin[] complete = new org.bukkit.plugin.Plugin[ControlInspectionService.MAX_ROWS]; + for (int index = 0; index < complete.length; index++) { + complete[index] = mock(org.bukkit.plugin.Plugin.class, RETURNS_DEEP_STUBS); + when(complete[index].getName()).thenReturn("Complete" + index); + } + when(plugin.getServer().getPluginManager().getPlugins()).thenReturn(complete); + result = new ControlInspectionService(plugin).inspect(JsonParser.parseString( + "{\"kind\":\"network-health\",\"filters\":{}}").getAsJsonObject()).getAsJsonObject("result"); + assertEquals(ControlInspectionService.MAX_ROWS, result.getAsJsonArray("detectedPlugins").size()); + complete[ControlInspectionService.MAX_ROWS - 1] = mock(org.bukkit.plugin.Plugin.class, RETURNS_DEEP_STUBS); + when(complete[ControlInspectionService.MAX_ROWS - 1].getName()).thenReturn("bad\nname"); + result = new ControlInspectionService(plugin).inspect(JsonParser.parseString( + "{\"kind\":\"network-health\",\"filters\":{}}").getAsJsonObject()).getAsJsonObject("result"); + assertFalse(result.has("detectedPlugins")); + } @Test void overviewIncludesSafeStorageAndVoteLogReadiness() { VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); org.bukkit.configuration.file.YamlConfiguration config = new org.bukkit.configuration.file.YamlConfiguration(); diff --git a/docs/control-agent-contract.md b/docs/control-agent-contract.md index 6aaa01b4fd..6039ca4d40 100644 --- a/docs/control-agent-contract.md +++ b/docs/control-agent-contract.md @@ -521,7 +521,10 @@ unreported when no bounded snapshot API exists. Nodes may advertise the additive `data.network-health.v1` capability. A negotiated `network-health` inspection is read-only and returns bounded typed facts. Unsupported or unavailable facts are omitted and therefore mean `UNKNOWN` to Control; they are -never represented as healthy defaults. Votifier diagnostics use only the optional +never represented as healthy defaults. Bukkit omits the optional `detectedPlugins` +health inventory if more than 100 plugins are installed or any name cannot be represented +without truncation; missing inventory must not establish that a plugin is absent. +Votifier diagnostics use only the optional `getNetworkHealthSnapshot` API and never expose keys, tokens, endpoints, raw config, or logs. Proxy polling is independently failure-isolated from vote and configuration work, and older peers continue using existing capabilities unchanged. From 7e766d4d15c973417a69ec5e3d7ff9c5871815d2 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:58:46 -0600 Subject: [PATCH 10/11] Settle malformed proxy inspection queries with validation errors --- .../proxy/control/ControlConnector.java | 47 ++++++++++++------- .../control/ControlNetworkHealthTest.java | 38 ++++++++++++++- docs/control-agent-contract.md | 5 +- 3 files changed, 72 insertions(+), 18 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 86b494b5fb..6dbd283523 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 @@ -325,24 +325,10 @@ private void pollNetworkHealth() { if (closed || response.statusCode == 204) return CompletableFuture.completedFuture(null); requireSuccess(response); JsonObject task = parseObject(response.body); - if (!task.keySet().equals(Set.of("inspectionId", "attemptId", "query"))) throw new ProtocolException(); + if (!Set.of("inspectionId", "attemptId", "query").containsAll(task.keySet())) throw new ProtocolException(); String id = requireString(task, "inspectionId"), attempt = requireInspectionAttemptId(task); if (!UUID.fromString(id).toString().equals(id)) throw new ProtocolException(); - JsonObject query = task.getAsJsonObject("query"); - if (query == null || !query.keySet().equals(Set.of("kind", "filters")) - || !query.get("filters").isJsonObject() || !query.getAsJsonObject("filters").keySet().isEmpty()) throw new ProtocolException(); - String kind = requireString(query, "kind"); - JsonObject result; - if ("network-health".equals(kind)) { - try { result = networkHealthResult(); } - catch (RuntimeException failure) { - result = new JsonObject(); - result.addProperty("success", false); - result.addProperty("code", "INSPECTION_FAILED"); - result.addProperty("message", "Network health inspection failed"); - } - } - else { result = new JsonObject(); result.addProperty("success", false); result.addProperty("code", "UNSUPPORTED"); result.addProperty("message", "Proxy inspection kind is unsupported"); } + JsonObject result = executeNetworkHealthQuery(task.get("query")); result.addProperty("sessionId", sessionId.toString()); result.addProperty("attemptId", attempt); return sendInspectionRequest(new Request("POST", "/api/v1/nodes/" + settings.nodeId() + "/inspections/" @@ -362,6 +348,35 @@ private void pollNetworkHealth() { } } + + private JsonObject executeNetworkHealthQuery(JsonElement requested) { + String kind; + try { + if (requested == null || !requested.isJsonObject()) throw new ProtocolException(); + JsonObject query = requested.getAsJsonObject(); + if (!query.keySet().equals(Set.of("kind", "filters")) + || !query.get("filters").isJsonObject() + || !query.getAsJsonObject("filters").keySet().isEmpty()) throw new ProtocolException(); + kind = requireString(query, "kind"); + } catch (ProtocolException | MalformedResponseException invalid) { + return networkHealthFailure("VALIDATION_ERROR", "Proxy inspection query fields are invalid"); + } + if (!"network-health".equals(kind)) + return networkHealthFailure("UNSUPPORTED", "Proxy inspection kind is unsupported"); + try { return networkHealthResult(); } + catch (RuntimeException failure) { + return networkHealthFailure("INSPECTION_FAILED", "Network health inspection failed"); + } + } + + private static JsonObject networkHealthFailure(String code, String message) { + JsonObject result = new JsonObject(); + result.addProperty("success", false); + result.addProperty("code", code); + result.addProperty("message", message); + return result; + } + private CompletableFuture sendInspectionRequest(Request request) { synchronized (inspectionLifecycle) { if (closed) return CompletableFuture.failedFuture(new IllegalStateException("Connector closed")); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlNetworkHealthTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlNetworkHealthTest.java index ee03e467f8..8dad3d4d0a 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlNetworkHealthTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlNetworkHealthTest.java @@ -96,10 +96,46 @@ private static String task(String kind, String filters) { try (var connector = connector("VELOCITY", request -> { requests.add(request); return CompletableFuture.completedFuture(new ControlConnector.Response(200, task("network-health", "{\"path\":\"secret\"}"))); }, executor)) { field(connector, "acceptedCapabilities", Set.of("data.inspect.v1")); poll(connector); assertTrue(requests.isEmpty()); field(connector, "acceptedCapabilities", Set.of("data.inspect.v1", "data.network-health.v1")); poll(connector); - assertEquals(1, requests.size(), "Invalid filters must never trigger a result snapshot"); + assertEquals(2, requests.size(), "Invalid filters must settle the claimed task without a snapshot"); + assertEquals("VALIDATION_ERROR", JsonParser.parseString(requests.get(1).body()).getAsJsonObject().get("code").getAsString()); } finally { executor.shutdownNow(); } } + @Test void malformedQueriesSettleWithoutBlockingTheNextInspection() throws Exception { + for (String platform : List.of("BUNGEECORD", "VELOCITY")) { + for (String query : List.of("null", "[]", "true", "{}", "{\"kind\":\"network-health\"}", + "{\"kind\":\"network-health\",\"filters\":null}", + "{\"kind\":\"network-health\",\"filters\":[]}", + "{\"kind\":17,\"filters\":{}}", + "{\"kind\":\"network-health\",\"filters\":{},\"extra\":true}", + "{\"kind\":\"network-health\",\"filters\":{\"secret\":\"never echo\"}}", "MISSING")) { + var executor = Executors.newSingleThreadScheduledExecutor(); + List requests = new ArrayList<>(); + String malformed = "{\"inspectionId\":\"" + ID + "\",\"attemptId\":\"" + ATTEMPT + "\"" + + ("MISSING".equals(query) ? "}" : ",\"query\":" + query + "}"); + try (var connector = connector(platform, request -> { + requests.add(request); + return CompletableFuture.completedFuture(new ControlConnector.Response(200, + requests.size() == 1 ? malformed : requests.size() == 3 ? task("network-health", "{}") : "{}")); + }, executor)) { + poll(connector); + assertEquals(2, requests.size(), query); + var result = JsonParser.parseString(requests.get(1).body()).getAsJsonObject(); + assertFalse(result.get("success").getAsBoolean()); + assertEquals("VALIDATION_ERROR", result.get("code").getAsString()); + assertEquals(SESSION.toString(), result.get("sessionId").getAsString()); + assertEquals(ATTEMPT, result.get("attemptId").getAsString()); + assertTrue(requests.get(1).path().endsWith("/" + ID + "/result")); + assertFalse(result.has("data")); + assertFalse(result.toString().contains("never echo")); + poll(connector); + assertEquals(4, requests.size()); + assertTrue(JsonParser.parseString(requests.get(3).body()).getAsJsonObject().get("success").getAsBoolean()); + } finally { executor.shutdownNow(); } + } + } + } + @Test void recoveryConnectorDoesNotClaimNewDiagnosticsOrExposeInventory() throws Exception { var executor = Executors.newSingleThreadScheduledExecutor(); List requests = new ArrayList<>(); try (var connector = connector("VELOCITY", request -> { requests.add(request); return CompletableFuture.completedFuture(new ControlConnector.Response(204, "")); }, executor)) { diff --git a/docs/control-agent-contract.md b/docs/control-agent-contract.md index 6039ca4d40..fa36cb9259 100644 --- a/docs/control-agent-contract.md +++ b/docs/control-agent-contract.md @@ -527,7 +527,10 @@ without truncation; missing inventory must not establish that a plugin is absent Votifier diagnostics use only the optional `getNetworkHealthSnapshot` API and never expose keys, tokens, endpoints, raw config, or logs. Proxy polling is independently failure-isolated from vote and configuration -work, and older peers continue using existing capabilities unchanged. +work, and older peers continue using existing capabilities unchanged. Once a task has valid +inspection and attempt identifiers, malformed proxy inspection queries settle with a generic +`VALIDATION_ERROR` result; they do not collect a snapshot or retain the lease for retries. +Untrustworthy task identifiers remain protocol failures and are never used for result routing. ## VoteLog interpretation From 4348ab85369edc0b366607def7ef0a47fa333a24 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 4 Oct 2026 19:54:50 -0600 Subject: [PATCH 11/11] Read backend reward health facts from SpecialRewards config --- .../control/ControlInspectionService.java | 1 + .../control/NetworkHealthConfigFacts.java | 5 ++- .../control/NetworkHealthConfigFactsTest.java | 31 +++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java index 408f69568b..6c6cc2855f 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java @@ -521,6 +521,7 @@ private JsonObject networkHealth(JsonObject filters) { OptionalVotifierDiagnostics.add(result, java.util.Arrays.asList(plugin.getServer().getPluginManager().getPlugins())); result.addProperty("configurationHealthy", !plugin.isYmlError()); NetworkHealthConfigFacts.addBackend(result, plugin.getConfigFile().getData()); + NetworkHealthConfigFacts.addSpecialRewards(result, plugin.getSpecialRewardsConfig().getData()); NetworkHealthConfigFacts.addGlobal(result, plugin.getBungeeSettings().getData()); result.addProperty("processRewards", plugin.getConfigFile().getData().getBoolean("ProcessRewards", true)); result.addProperty("autoCreateVoteSites", plugin.getConfigFile().isAutoCreateVoteSites()); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/NetworkHealthConfigFacts.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/NetworkHealthConfigFacts.java index e9c06cb8aa..3ec2eda0e7 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/NetworkHealthConfigFacts.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/NetworkHealthConfigFacts.java @@ -12,11 +12,14 @@ static void addBackend(JsonObject out, FileConfiguration config) { addBoolean(out, config, "onlineMode", "OnlineMode"); addString(out, config, "bedrockPlayerPrefix", "BedrockPlayerPrefix", 80); addInt(out, config, "timeHourOffset", "TimeHourOffSet"); - addBoolean(out, config, "resetMilestonesMonthly", "ResetMilestonesMonthly"); addBoolean(out, config, "monthDateTotals", "UseMonthDateTotalsAsPrimaryTotal"); addBoolean(out, config, "extraAllSitesCheck", "ExtraAllSitesCheck"); addBoolean(out, config, "allowUnjoined", "AllowUnjoined"); addInt(out, config, "maxVotesPerDay", "MaxAmountOfVotesPerDay"); + } + + static void addSpecialRewards(JsonObject out, FileConfiguration config) { + addBoolean(out, config, "resetMilestonesMonthly", "ResetMilestonesMonthly"); addBoolean(out, config, "votePartyEnabled", "VoteParty.Enabled"); addInt(out, config, "votePartyVotesRequired", "VoteParty.VotesRequired"); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/NetworkHealthConfigFactsTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/NetworkHealthConfigFactsTest.java index e90b5a1548..146a9ad0f8 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/NetworkHealthConfigFactsTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/NetworkHealthConfigFactsTest.java @@ -22,6 +22,23 @@ class NetworkHealthConfigFactsTest { assertFalse(out.has("bedrockPlayerPrefix")); } + @Test void votePartyAndMilestoneFactsComeOnlyFromSpecialRewardsConfig() { + YamlConfiguration main = new YamlConfiguration(); + main.set("VoteParty.Enabled", true); + main.set("VoteParty.VotesRequired", 99); + main.set("ResetMilestonesMonthly", true); + YamlConfiguration special = new YamlConfiguration(); + special.set("VoteParty.Enabled", false); + special.set("VoteParty.VotesRequired", 12); + special.set("ResetMilestonesMonthly", false); + JsonObject out = new JsonObject(); + NetworkHealthConfigFacts.addBackend(out, main); + NetworkHealthConfigFacts.addSpecialRewards(out, special); + assertFalse(out.get("votePartyEnabled").getAsBoolean()); + assertEquals(12, out.get("votePartyVotesRequired").getAsInt()); + assertFalse(out.get("resetMilestonesMonthly").getAsBoolean()); + } + @Test void globalFactsRejectWrongTypesAndLongValues() { YamlConfiguration yaml = new YamlConfiguration(); yaml.set("GlobalData.Prefix", "p".repeat(81)); @@ -43,6 +60,20 @@ class NetworkHealthConfigFactsTest { assertEquals(-3, out.get("timeHourOffset").getAsInt()); } + @Test void malformedSpecialRewardsFactsAreOmittedAndMarkedInvalid() { + YamlConfiguration special = new YamlConfiguration(); + special.set("VoteParty.Enabled", "yes"); + special.set("VoteParty.VotesRequired", "many"); + special.set("ResetMilestonesMonthly", 1); + JsonObject out = new JsonObject(); + NetworkHealthConfigFacts.addSpecialRewards(out, special); + assertFalse(out.has("votePartyEnabled")); + assertFalse(out.has("votePartyVotesRequired")); + assertFalse(out.has("resetMilestonesMonthly")); + assertTrue(out.getAsJsonArray("invalidConfigurationFields").asList().stream() + .map(element -> element.getAsString()).anyMatch("VoteParty.Enabled"::equals)); + } + @Test void longMinimumDoesNotPassAbsoluteValueBound() { YamlConfiguration yaml = new YamlConfiguration(); yaml.set("TimeHourOffSet", Long.MIN_VALUE);