Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1040,6 +1040,8 @@ public MySqlMessenger getBackendMysqlMessenger() {
return transportManager.getBackendMysqlMessenger();
}

public String diagnosticSharedKeyFingerprint() { return transportManager.diagnosticSharedKeyFingerprint(); }

public MqttHandler getMqttHandler() {
return transportManager.getMqttHandler();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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<String> DEPLOYMENT_TASK_FIELDS = Set.of("deploymentId", "artifactId", "sha256", "size", "attemptId");

private final VotingPluginMain plugin;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -559,6 +562,11 @@ private void claimAndInspect() throws Exception {

InspectionTaskResult executeInspection(JsonObject query) {
try {
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");
}
if (ControlInspectionService.rewardFileInventoryQuery(query) && !rewardFilesAccepted) {
return InspectionTaskResult.failure("UNAVAILABLE",
"Named reward files were not negotiated");
Expand Down Expand Up @@ -636,6 +644,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
Expand Down Expand Up @@ -691,6 +700,7 @@ private JsonObject register() throws Exception {
votePartySetupsAccepted = false;
voteSitesSyncAccepted = false;
rewardFilesAccepted = false;
networkHealthAccepted = false;
inspectionsAccepted = false;
deploymentsAccepted = false;
JsonObject body = sessionBody();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ public final class ControlInspectionService {
private static final Pattern SERVICE_NAME = Pattern.compile("[^\\p{Cntrl}]{1,64}");
private static final Set<String> 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;

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -492,6 +496,120 @@ 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();
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()));
result.addProperty("configurationHealthy", !plugin.isYmlError());
NetworkHealthConfigFacts.addBackend(result, plugin.getConfigFile().getData());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Read backend reward facts from SpecialRewards.yml

addBackend looks for VoteParty.Enabled, VoteParty.VotesRequired, and ResetMilestonesMonthly, but this call supplies Config.yml; those settings are stored in SpecialRewards.yml. A normal backend therefore omits the actual values, while any obsolete similarly named keys left in Config.yml could be reported as current, causing Network Doctor to diagnose stale configuration instead of the runtime settings. Read these facts from plugin.getSpecialRewardsConfig().getData() separately.

AGENTS.md reference: AGENTS.md:L295-L297

Useful? React with 👍 / 👎.

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());
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;
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));
try {
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"
: url.toLowerCase(Locale.ROOT).contains("example") ? "EXAMPLE" : validVoteUrl(url) ? "VALID" : "INVALID");
sites.add(item);
}
}
result.add("voteSites", sites);
result.addProperty("voteSitesComplete", siteComplete);
NetworkHealthConfigFacts.addObservedServices(result, plugin.getServerData().getServiceSitesReadOnly());
// A bounded prefix cannot prove the full inventory; omit it unless every
// discovered name fits the complete bounded representation.
List<String> rawPluginNames = java.util.Arrays.stream(plugin.getServer().getPluginManager().getPlugins())
.map(Plugin::getName).toList();
List<String> 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;
}

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<String> allConfiguredVoteSiteNames() {
ConfigurationSection root = plugin.getConfigVoteSites().getData().getConfigurationSection("VoteSites");
if (root == null) return List.of();
Expand Down
Loading
Loading