From b864caaf218341bf62b064b794b971d2e953f018 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:10:33 -0600 Subject: [PATCH 01/15] Add bounded read-only cross-node Network Doctor checks --- AGENTS.md | 10 + README.md | 4 + docs/control-management.md | 7 + docs/network-doctor.md | 102 +++++ .../control/domain/InMemoryNodeRegistry.java | 2 +- .../control/domain/InspectionOperations.java | 38 ++ .../control/domain/NetworkDoctor.java | 422 ++++++++++++++++++ .../control/domain/NetworkHealthEvidence.java | 86 ++++ .../control/http/ControlHttpServer.java | 10 + .../control/protocol/InspectionQuery.java | 6 +- src/main/resources/web/app.js | 66 +-- src/main/resources/web/index.html | 1 + src/main/resources/web/network-doctor.js | 50 +++ .../control/domain/NetworkDoctorTest.java | 371 +++++++++++++++ .../domain/NetworkHealthEvidenceTest.java | 70 +++ .../domain/NetworkHealthInspectionTest.java | 71 +++ .../control/http/ControlHttpServerTest.java | 34 +- .../control/protocol/InspectionQueryTest.java | 4 +- src/test/web/app-workspace.test.cjs | 5 + src/test/web/network-doctor.test.cjs | 19 + 20 files changed, 1339 insertions(+), 39 deletions(-) create mode 100644 docs/network-doctor.md create mode 100644 src/main/java/com/bencodez/votingplugin/control/domain/NetworkDoctor.java create mode 100644 src/main/java/com/bencodez/votingplugin/control/domain/NetworkHealthEvidence.java create mode 100644 src/main/resources/web/network-doctor.js create mode 100644 src/test/java/com/bencodez/votingplugin/control/domain/NetworkDoctorTest.java create mode 100644 src/test/java/com/bencodez/votingplugin/control/domain/NetworkHealthEvidenceTest.java create mode 100644 src/test/java/com/bencodez/votingplugin/control/domain/NetworkHealthInspectionTest.java create mode 100644 src/test/web/network-doctor.test.cjs diff --git a/AGENTS.md b/AGENTS.md index 55dc804..a6a1076 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,6 +113,16 @@ The CI definition is `.github/workflows/maven.yml`. The shaded runnable artifact reload, automatically restart, or include older nodes that did not negotiate the exact capability. A Control restart invalidates in-progress download authority and requires an explicit retry. +## Network Doctor invariants + +Use the pure `domain/NetworkDoctor` evaluator and strict `NetworkHealthEvidence` allow-list. Missing, offline, stale, +unsupported, truncated, or wrong-session evidence is UNKNOWN, never PASS. New facts require additive capability negotiation +and bounded producer/consumer tests. Compare only observed proxy/backend or declared multi-proxy relationships. Keep the +validator read-only and optional: no configuration repair, votes, commands, SQL/scans, raw files/logs or secrets. Backend +Votifier plus TriggerVotifierEvent is a valid synthetic-event path; proxy VotifierPlus socket forwarding alongside +VotingPlugin delivery is a separate duplicate-risk path. Do not infer listener/runtime readiness from plugin presence or +persisted configuration. See `docs/network-doctor.md`. + ## Paired protocol workflow The implementation paired with this repository lives in `BenCodez/VotingPlugin`: diff --git a/README.md b/README.md index 118d288..4d3bfff 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,10 @@ The scope-first WebUI and legacy single-source editing boundary are documented i ## Features +- **Read-only Network Doctor:** grouped FAIL/WARNING/UNKNOWN setup checks across observed proxy/backend networks, + path-aware Votifier forwarding checks, capability-negotiated bounded diagnostics, and a redacted structured JSON download. + Unsupported or missing evidence remains UNKNOWN; [check coverage and limits](docs/network-doctor.md). + VotingPlugin Control is designed to manage and troubleshoot a VotingPlugin network from one place. ### Network management diff --git a/docs/control-management.md b/docs/control-management.md index e7c8142..f99e559 100644 --- a/docs/control-management.md +++ b/docs/control-management.md @@ -585,3 +585,10 @@ includes an explicit list of categories it omitted so a support recipient does n 3. Never persist browser CSRF tokens, approval tokens, node raw credentials, new configuration secrets, or inspection filters/results containing player data. Snapshot content must remain the redacted node read, admin-only, owner-permissioned, and bounded. + +## Capability-negotiated Network Doctor + +The comprehensive validator uses `network-health` with both `data.inspect.v1` and `data.network-health.v1`, then serves +structured checks through administrator-only `GET /api/v1/network-doctor`. Its pure domain evaluator compares only observed +relationships. See [Network Doctor](network-doctor.md) for states, checks, freshness, security bounds, supported mixed-version +behavior, and facts that remain UNKNOWN. Diagnostics never repair configuration or create votes. diff --git a/docs/network-doctor.md b/docs/network-doctor.md new file mode 100644 index 0000000..92f6d78 --- /dev/null +++ b/docs/network-doctor.md @@ -0,0 +1,102 @@ +# Network Doctor + +Network Doctor is a read-only, bounded setup validator. It never sends a vote, changes configuration, executes a command, +scans a network, reads an arbitrary path, or becomes a dependency of vote processing. A successful check proves only the +specific reported observation. It does not certify voting-site uptime or end-to-end reward execution. + +## Results and evidence + +Results contain stable `id`, `category`, `status`, `title`, `explanation`, `affectedNodes`, `evidence`, `nextAction`, and +`restartRequired` fields. Statuses are `PASS`, `WARNING`, `FAIL`, `UNKNOWN`, and `INFO`. The default view groups results by +category and emphasizes FAIL, WARNING, and UNKNOWN; PASS/INFO are collapsed. The download contains the same structured +checks, including collapsed results. Unknown evidence is never replaced by a successful Boolean default. + +A run requests the empty-filter `network-health` inspection from up to 100 online nodes that negotiate **both** +`data.inspect.v1` and `data.network-health.v1`. At most three browser requests are in progress concurrently. Offline, +older, timed-out, incompatible, malformed, or reconnected nodes provide no usable evidence and therefore produce UNKNOWN. +The server uses only validated results from the current node session, with a five-minute maximum age measured from its +own request creation time. A newer pending, rejected or failed observation invalidates an earlier success. Evidence is short-lived in memory, +not added to configuration history. Peer timestamps cannot extend freshness. + +`GET /api/v1/network-doctor` requires the same administrator/browser authentication as node inspection views. It does not +accept user-supplied evidence. The server evaluates its current registry plus eligible inspection results. The report is +limited to 100 nodes and 500 checks; limits are explicitly reported as UNKNOWN/truncated. Missing nodes or omitted checks +have not passed. Re-run checks for the relevant network when evidence expires. + +## Checks + +- **Control & Topology:** offline/stale/unsupported evidence, proxy/backend relationships, reported backend enrollment, + missing presence, bounded/incomplete inventories. Enrollment alone does not establish a shared network. +- **Proxy Setup:** blank/PleaseSet backend identity, duplicate/case-only identity and dash-to-underscore storage collisions, + proxy mode, configured transport validity and mismatches, unsupported BungeeManageTotals:false, dedicated proxy with + plugin messaging, OnlineMode/Bedrock prefix/time offset/milestone and month-total consistency where reported. +- **Transport:** initialization, configured versus active transport drift/restart, channel and encryption mismatch, + authenticated transport mode/key fingerprint mismatch, Redis SSL and Redis/MQTT namespace mismatch, MQTT ClientID + duplication, HTTP endpoint/enrollment/identity/stale connection code, MYSQL recommendation, reported status probes. + Zero carrier players under PLUGINMESSAGING is UNKNOWN/waiting, never a broken-network FAIL. +- **Votifier:** required provider/listener where evidence proves ingress, synthetic-event provider compatibility, + competing implementations, enabled VotifierPlus socket forwarding alongside VotingPlugin delivery, optional-provider + diagnostics and external destination uncertainty. +- **Routing:** unknown backend references, blocked/whitelist overlap, no eligible destinations, intentional exclusions, + allow-unjoined prerequisites and wait-for-online combinations. Invalid references are FAIL only with complete topology. +- **Database & Storage:** unavailable selected storage/driver, enabled but unreadable VoteLog, database-dependent cache + and GlobalData settings, GlobalData/prefix/shared storage identity inconsistencies, nonempty backend isolation prefixes. +- **Vote Sites:** configured/enabled inventory, blank/placeholder/duplicate ServiceSite, case-only conflicts, unfinished + URLs or auto-created sites, invalid/unusual delay, unmatched detected/logged services, auto-create recommendation, + proxy delay service mapping and low daily limits. No reward is INFO: totals-only sites are valid. +- **Rewards:** processing disabled, missing/unparseable named files or safely inferred dependencies when a complete + inventory is reported, per-server identity problems, paired extra-all-sites settings. +- **VoteParty:** nonpositive enabled threshold, absent effective rewards, and server-list routing references. +- **Multi-Proxy:** declared group identities/primary count, method/auth/encryption consistency, incompatible global reward + plus send-to-all settings, missing related peer evidence, and valid secondary ingress omission. +- **Runtime:** reported queue/parked counts, oldest age, authentication counters, and restart state. A retained count alone + does not prove a vote is lost. No arbitrary SQL or transport scan is used to obtain missing evidence. + +Checks are evaluated only within an observed proxy/backend relationship or declared multi-proxy group. Unrelated standalone +servers are not compared. Backend identity mapping differences are recommendations to verify the mapping, because a Control +node ID may be explicitly customized. Duplicate Control identities remain governed by registry/session/credential validation; +Network Doctor does not override session ownership or infer two physical machines from a reconnect. + +## Votifier paths + +A. `Voting site → proxy Votifier → VotingPlugin proxy → backend VotingPlugin` is normal proxy ingress. + +B. `A → synthetic backend VotifierEvent` is valid when `TriggerVotifierEvent:true` has a compatible backend provider. +Having Votifier installed on both sides is not itself a duplicate path. With the trigger disabled, backend Votifier presence +is informational. Enabling the trigger without a compatible provider is FAIL. + +C. Running A alongside `proxy VotifierPlus Forwarding → backend Votifier` creates two possible deliveries and is FAIL. +A forwarding destination name matching an enrolled backend with a provider makes the explanation explicit. Destination +names alone cannot prove the remote host identity. Synthetic event compatibility does not make socket forwarding safe. + +## Compatibility and security + +The registration protocol stays version 1. New diagnostics require the exact additive capability. New Control does not +send this kind to old peers, and old Control leaves the new capability unaccepted. Normal voting continues in either +upgrade order and when Control is absent. Proxy plugin names use the existing bounded `detectedPlugins` registration field. + +The diagnostic result is a strict allow-list: known Boolean facts; supported transport/authentication enums; bounded integer +counts; bounded non-control text; at most 100 unique names per list; and at most 100 typed Vote Site rows. Unknown fields, +wrong types/enums, duplicates, malformed fingerprints, and oversized lists are rejected. Omit an unsupported field rather +than emitting a default. Full inspection envelopes remain capped at 512 KiB. Result data contains no raw YAML, logs, +player records, database addresses/credentials, Votifier keys/tokens, HTTP certificates/private keys, or unrestricted paths. +Key/storage equality, when reported, uses 64-character non-reversible fingerprints rather than secret material. + +VotifierPlus optionally supplies an immutable diagnostic snapshot containing provider presence, listener initialization, +forwarding availability, and enabled forwarding **entry names** only. VotingPlugin consumes it without a mandatory upgrade +or dependency. Older VotifierPlus, NuVotifier, and other providers lacking that API cannot prove forwarding/listener state: +those checks are UNKNOWN. Oversized/incomplete forwarding evidence must not pass as an empty configuration. + +## Evidence that may remain UNKNOWN + +Runtime support varies by platform/transport/version. No listener or connection is inferred solely from installed plugin +names or persisted YAML. Absent runtime initialization/active method/status probe, HTTP client identity state, socket peer +identity/bind state, database driver/pool/schema readiness, cache counts/oldest age/authentication counters, shared storage +identity, reward dependency/inventory completeness, or forwarding APIs remain UNKNOWN. External voting-site destination and +uptime are always UNKNOWN. A diagnostic does not trigger an automatic correlated probe: use the existing non-vote +communication-test workflow when desired (and when a plugin-message carrier is available). + +Intentionally deferred: arbitrary endpoint reachability, remote voting-site checks, raw log inference, command dependency +heuristics that cannot prove a plugin requirement, and reward execution validation. They would require unsafe access, +change behavior, or create false positives. Complete topology is required before declaring missing routing destinations +invalid; a disconnected peer is not treated as proof that a configured destination does not exist. diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java index acc9487..6dbb768 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java @@ -34,7 +34,7 @@ public final class InMemoryNodeRegistry implements NodeRegistry { ConfigurationOperations.VOTE_SITES_SYNC_CAPABILITY, ConfigurationOperations.TRANSPORT_TEST_CAPABILITY, ConfigurationOperations.PROXY_METHOD_CAPABILITY, ConfigurationOperations.PROXY_METHOD_HTTP_CAPABILITY, DeploymentOperations.CAPABILITY, - "config.file-comments.v1", ManagedConfiguration.REWARD_FILE_CAPABILITY, InspectionQuery.CAPABILITY); + "config.file-comments.v1", ManagedConfiguration.REWARD_FILE_CAPABILITY, InspectionQuery.CAPABILITY, NetworkHealthEvidence.CAPABILITY); private static final Pattern ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); private static final Pattern CAPABILITY = Pattern.compile("[a-z][a-z0-9.-]{0,63}"); private static final Set PLATFORMS = Set.of("BUNGEECORD", "VELOCITY", "BUKKIT"); diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java index 163e48f..9033026 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java @@ -72,6 +72,7 @@ public synchronized InspectionView create(String nodeId, InspectionQuery query) NodeStatus node = registry.find(nodeId); if (node == null) throw new ValidationException("NODE_NOT_FOUND", "Node was not found", List.of(nodeId)); if (!node.online() || !node.acceptedCapabilities().contains(InspectionQuery.CAPABILITY) + || query.requiresNetworkHealth() && !node.acceptedCapabilities().contains(NetworkHealthEvidence.CAPABILITY) || query.requiresRewardFiles() && !node.acceptedCapabilities().contains(InspectionQuery.REWARD_FILE_CAPABILITY)) { throw new ValidationException("NODE_UNAVAILABLE", "Node cannot answer inspection queries", List.of(nodeId)); } @@ -92,6 +93,30 @@ public synchronized InspectionView create(String nodeId, InspectionQuery query) return view(stored); } + /** Server receipt time and session are authoritative; peer timestamps do not extend freshness. */ + public synchronized Map networkEvidence(List nodes) { + prune(); + Map result = new LinkedHashMap<>(); + for (NodeStatus node : nodes) { + if (!node.online() || !node.acceptedCapabilities().contains(NetworkHealthEvidence.CAPABILITY) + || !node.acceptedCapabilities().contains(InspectionQuery.CAPABILITY)) continue; + for (StoredInspection stored : inspections.values()) { + if (stored.nodeId.equals(node.nodeId()) && stored.query.requiresNetworkHealth() + && Objects.equals(stored.targetSession, node.sessionId()) + && stored.createdAt.isAfter(clock.instant().minus(Duration.ofMinutes(5)))) { + result.remove(node.nodeId()); + if ("COMPLETE".equals(stored.state) && stored.result != null && stored.result.success() + && Objects.equals(stored.result.sessionId(), node.sessionId())) { + JsonNode evidence = stored.result.data().path("result"); + if (("BUKKIT".equals(node.platform()) ? "BACKEND" : "PROXY").equals(evidence.path("role").asText())) + result.put(node.nodeId(), evidence.deepCopy()); + } + } + } + } + return result; + } + public synchronized InspectionView get(UUID id) { prune(); StoredInspection stored = inspections.get(id); @@ -113,6 +138,7 @@ private InspectionTask claimCurrentSession(String nodeId, NodeStatus node) { for (StoredInspection stored : inspections.values()) { if (!stored.nodeId.equals(nodeId) || "COMPLETE".equals(stored.state)) continue; if (!node.online() || !node.acceptedCapabilities().contains(InspectionQuery.CAPABILITY) + || stored.query.requiresNetworkHealth() && !node.acceptedCapabilities().contains(NetworkHealthEvidence.CAPABILITY) || stored.query.requiresRewardFiles() && !node.acceptedCapabilities().contains(InspectionQuery.REWARD_FILE_CAPABILITY)) { completeUnavailable(stored, node.sessionId()); @@ -168,7 +194,16 @@ private InspectionView completeCurrentSession(UUID id, NodeStatus node, Inspecti if (!Objects.equals(stored.attemptId, result.attemptId())) { throw new ValidationException("TASK_NOT_CLAIMED", "Inspection attempt does not match", List.of()); } + if (stored.query.requiresNetworkHealth() && (!node.online() + || !node.acceptedCapabilities().contains(NetworkHealthEvidence.CAPABILITY) + || !node.acceptedCapabilities().contains(InspectionQuery.CAPABILITY))) { + completeUnavailable(stored, node.sessionId()); + return view(stored); + } validateResult(result, stored.query.kind()); + if (stored.query.requiresNetworkHealth() && result.success() + && !("BUKKIT".equals(node.platform()) ? "BACKEND" : "PROXY").equals(result.data().path("result").path("role").asText())) + throw invalid("network health role does not match the node platform"); InspectionTaskResult previousResult = stored.result; String previousState = stored.state; Instant previousLease = stored.leasedAt; @@ -238,6 +273,9 @@ private static void validateResult(InspectionTaskResult result, String expectedK } catch (java.time.format.DateTimeParseException failure) { throw invalid("inspection data generatedAt is invalid"); } + if ("network-health".equals(expectedKind) && !NetworkHealthEvidence.valid(result.data().path("result"))) { + throw invalid("network health evidence is invalid"); + } if ("player".equals(expectedKind) && !validPlayerResult(result.data().path("result"))) { throw invalid("player inspection data is invalid"); } diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/NetworkDoctor.java b/src/main/java/com/bencodez/votingplugin/control/domain/NetworkDoctor.java new file mode 100644 index 0000000..39b93b2 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/domain/NetworkDoctor.java @@ -0,0 +1,422 @@ +package com.bencodez.votingplugin.control.domain; + +import com.bencodez.votingplugin.control.protocol.NodeStatus; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import java.time.Instant; +import java.util.*; + +/** Pure read-only checks over bounded, validated, current-session evidence and observed topology. */ +public final class NetworkDoctor { + public enum Status { PASS, WARNING, FAIL, UNKNOWN, INFO } + public record Check(String id, String category, Status status, String title, String explanation, + List affectedNodes, String evidence, String nextAction, boolean restartRequired) { } + public record Report(int schemaVersion, Instant generatedAt, List checks, boolean truncated) { } + private static final int MAX_CHECKS = 500; + private final List checks = new ArrayList<>(); + private final List nodes; + private final Map evidence; + private boolean truncated; + private NetworkDoctor(List nodes, Map evidence) { + this.nodes = List.copyOf(nodes); this.evidence = Map.copyOf(evidence); + } + public static Report evaluate(List nodes, Map evidence, Instant now, boolean truncated) { + if (nodes.size() > 100) throw new IllegalArgumentException("Network Doctor is limited to 100 nodes"); + evidence.values().forEach(value -> { if (!NetworkHealthEvidence.valid(value)) throw new IllegalArgumentException("invalid evidence"); }); + var doctor = new NetworkDoctor(nodes, evidence); doctor.truncated = truncated; + for (var node : nodes) { if (doctor.checks.size() >= MAX_CHECKS - 1) { doctor.truncated = true; break; } doctor.node(node); } + for (var proxy : nodes) { if (doctor.checks.size() >= MAX_CHECKS - 1) { doctor.truncated = true; break; } if (proxy(proxy)) doctor.network(proxy); } + doctor.multiProxy(); + if (doctor.truncated) doctor.check("control.evidence.truncated", "Control & Topology", Status.UNKNOWN, + "Evidence limit reached", "The bounded report is incomplete; omitted checks have not passed.", List.of(), "limits", "Inspect smaller networks or individual nodes.", false); + return new Report(1, now, List.copyOf(doctor.checks), doctor.truncated); + } + private JsonNode data(NodeStatus n) { return evidence.getOrDefault(n.nodeId(), JsonNodeFactory.instance.objectNode()); } + private static boolean proxy(NodeStatus n) { return !"BUKKIT".equals(n.platform()); } + private Boolean bool(NodeStatus n, String key) { var v = data(n).get(key); return v == null ? null : v.booleanValue(); } + private String str(NodeStatus n, String key) { var v = data(n).get(key); return v == null ? null : v.asText(); } + private Integer num(NodeStatus n, String key) { var v = data(n).get(key); return v == null ? null : v.intValue(); } + private List list(NodeStatus n, String key) { + var v = data(n).get(key); if (v == null) return null; + List values = new ArrayList<>(); v.forEach(item -> values.add(item.asText())); return values; + } + private static boolean yes(Boolean v) { return Boolean.TRUE.equals(v); } + private static boolean no(Boolean v) { return Boolean.FALSE.equals(v); } + private static boolean invalidName(String s) { return s == null || s.isBlank() || Set.of("pleaseset", "sitename").contains(s.toLowerCase(Locale.ROOT)); } + private static String normalized(String s) { return s.replace('-', '_').toLowerCase(Locale.ROOT); } + private void check(String id, String category, Status status, String title, String text, List affected, + String source, String action, boolean restart) { + if (checks.size() >= MAX_CHECKS - 1 && !"control.evidence.truncated".equals(id)) { truncated = true; return; } + checks.add(new Check(id, category, status, title, text, List.copyOf(affected), source, action, restart)); + } + private void emit(NodeStatus n, String id, String category, Status status, String text, String source, String action) { + check(id, category, status, Character.toUpperCase(id.charAt(0)) + id.substring(1).replace('.', ' ').replace('-', ' '), text, List.of(n.nodeId()), source, action, false); + } + private void requirement(NodeStatus n, String id, String category, String field, String failure) { + Boolean v = bool(n, field); + emit(n, id, category, v == null ? Status.UNKNOWN : v ? Status.PASS : Status.FAIL, + v == null ? "This peer did not report the required evidence." : v ? "The reported requirement is satisfied." : failure, + field, v == null ? "Upgrade or reconnect the peer and run checks again." : v ? "No action required." : "Review the node configuration and initialization state."); + } + private void node(NodeStatus n) { + emit(n, "control.node.online", "Control & Topology", n.online() ? Status.PASS : Status.UNKNOWN, + n.online() ? "Control reports a connected node; this does not prove vote delivery." : "The node is offline; runtime and configuration cannot be verified.", + "registry", n.online() ? "No action required." : "Reconnect the node before checking it."); + if (!n.online() || !evidence.containsKey(n.nodeId())) { + for (String category : List.of("Proxy Setup", "Transport", "Votifier", "Routing", "Database & Storage", "Vote Sites", "Rewards", "VoteParty", "Multi-Proxy", "Runtime")) + emit(n, "evidence." + category.toLowerCase(Locale.ROOT).replaceAll("[^a-z]+", "-"), category, Status.UNKNOWN, + "No fresh capability-negotiated evidence from this node session.", "data.network-health.v1", "Upgrade, reconnect or run the read-only health inspection."); + return; + } + requirement(n, "configuration.parse", "Proxy Setup", "configurationHealthy", "Managed configuration could not be parsed or validated."); + List invalidFields = list(n, "invalidConfigurationFields"); + if (invalidFields != null && !invalidFields.isEmpty()) emit(n, "configuration.type.invalid", "Proxy Setup", Status.FAIL, + "Known configuration fields have invalid types or out-of-range values.", String.join(", ", invalidFields).substring(0, Math.min(160, String.join(", ", invalidFields).length())), + "Correct the reported managed settings; the checker does not repair them."); + boolean managed = proxy(n) || yes(bool(n, "proxyMode")) || nodes.stream().anyMatch(p -> proxy(p) && p.backends().stream().anyMatch(b -> b.backendId().equals(n.nodeId()))); + if (!proxy(n) && managed) { + requirement(n, "backend.proxy-mode.disabled", "Proxy Setup", "proxyMode", "This proxy-managed backend has UseBungeecord disabled."); + String name = str(n, "serverName"); + emit(n, "backend.server-name.unset", "Proxy Setup", name == null ? Status.UNKNOWN : invalidName(name) ? Status.FAIL : Status.PASS, + name == null ? "Backend identity was not reported." : invalidName(name) ? "Server must be nonblank and must not be PleaseSet or SITENAME." : "A backend storage identity is configured.", + "serverName", "Set a unique Server name matching the intended proxy identity."); + boolean linked = nodes.stream().anyMatch(p -> p.online() && proxy(p) && p.backends().stream().anyMatch(b -> b.backendId().equals(n.nodeId()))); + emit(n, "backend.proxy.visible", "Control & Topology", linked ? Status.PASS : Status.UNKNOWN, + linked ? "An online proxy reports this enrolled backend." : "No current proxy relationship is reported; enrollment alone does not establish a network.", + "registry.backends", "Check proxy presence reporting and node identity."); + } + if (managed) transport(n); + if (!proxy(n) && no(bool(n, "proxyMode"))) { + requirement(n, "votifier.standalone.provider", "Votifier", "votifierProviderPresent", "Standalone vote ingress has no compatible Votifier provider."); + requirement(n, "votifier.standalone.listener", "Votifier", "votifierListenerInitialized", "Standalone Votifier ingress failed to initialize."); + } + if (yes(bool(n, "restartRequired"))) check("runtime.restart-required", "Runtime", Status.WARNING, + "Restart required", "The node reports persisted settings requiring a restart.", List.of(n.nodeId()), + "restartRequired", "Restart through the normal administrator workflow, then rerun health checks.", true); + votifier(n, managed); + storage(n); + if (!proxy(n)) sites(n); + rewards(n, managed); + if (yes(bool(n, "votePartyEnabled"))) { + Integer votes = num(n, "votePartyVotesRequired"); + emit(n, "voteparty.votes-required.invalid", "VoteParty", votes == null ? Status.UNKNOWN : votes <= 0 ? Status.FAIL : Status.PASS, + "An enabled VoteParty requires a positive vote threshold.", "votePartyVotesRequired", "Review VoteParty.VotesRequired."); + Boolean effective = bool(n, "votePartyEffectiveReward"); + emit(n, "voteparty.reward.empty", "VoteParty", effective == null ? Status.UNKNOWN : effective ? Status.PASS : Status.WARNING, + "VoteParty effectiveness is based on configured rewards/commands, not execution.", "votePartyEffectiveReward", "Confirm the party has an intended effective reward or command."); + } else if (bool(n, "votePartyEnabled") == null) unknown(n, "voteparty.state", "VoteParty", "votePartyEnabled"); + for (String field : List.of("queuedVotes", "parkedVotes", "oldestPendingSeconds", "authenticationFailures")) { + Integer count = num(n, field); + emit(n, "runtime." + field, "Runtime", count == null ? Status.UNKNOWN : count > 0 ? Status.INFO : Status.PASS, + count == null ? "The runtime does not expose this bounded observation." : "Reported " + field + ": " + count + ". This observation does not prove delivery failure.", + field, "Use existing runtime status tools if investigation is needed."); + } + emit(n, "votifier.external-destination", "Votifier", Status.UNKNOWN, + "Control cannot prove the external voting site's configured destination or uptime.", "not observed", "Verify each voting site's destination independently."); + } + private void unknown(NodeStatus n, String id, String category, String source) { + emit(n, id, category, Status.UNKNOWN, "The peer did not report sufficient evidence for this check.", source, "Obtain supported diagnostic evidence; do not assume success."); + } + private void transport(NodeStatus n) { + String method = str(n, "configuredMethod"), active = str(n, "activeMethod"); + emit(n, "proxy.method.valid", "Transport", method == null ? Status.UNKNOWN : "INVALID".equals(method) ? Status.FAIL : Status.PASS, + "Configured transport must be a supported method.", "configuredMethod", "Review BungeeMethod."); + if (method != null && active != null && !method.equals(active)) { + check("transport.runtime.drift", "Runtime", Status.WARNING, "Transport restart required", + "Persisted and active transport differ. Configuration alone does not prove the new method is running.", + List.of(n.nodeId()), "configuredMethod / activeMethod", "Apply the documented reload or restart and rerun checks.", true); + } else if (active == null || method == null) unknown(n, "transport.runtime.drift", "Runtime", "activeMethod"); + else emit(n, "transport.runtime.drift", "Runtime", Status.PASS, "Configured and active methods agree.", "configuredMethod / activeMethod", "No action required."); + requirement(n, "transport.initialized", "Transport", "transportInitialized", "The required active transport failed to initialize."); + if ("INVALID".equals(str(n, "sharedAuthentication"))) emit(n, "transport.authentication.invalid", "Transport", Status.FAIL, + "SharedTransportAuthentication is not a supported mode.", "sharedAuthentication", "Choose a supported authentication policy."); + String probe = str(n, "transportProbeState"); Integer players = num(n, "carrierPlayers"); + boolean waiting = "PLUGINMESSAGING".equals(method) && (players != null && players == 0 || "WAITING".equals(probe)); + emit(n, "transport.communication", "Transport", waiting || probe == null || "UNKNOWN".equals(probe) ? Status.UNKNOWN + : "SUCCESS".equals(probe) ? Status.PASS : "FAILED".equals(probe) ? Status.WARNING : Status.UNKNOWN, + waiting ? "Plugin messaging is waiting for a carrier player; zero online players is not a broken network." + : "SUCCESS".equals(probe) ? "A reported correlated status probe succeeded; no vote was sent." : "No successful current correlated status probe proves communication.", + "transportProbeState / carrierPlayers", "Use the existing communication test when a carrier is available."); + if (proxy(n)) { + requirement(n, "proxy.manage-totals.unsupported", "Proxy Setup", "bungeeManageTotals", "BungeeManageTotals:false is documented as unsupported."); + if ("PLUGINMESSAGING".equals(method) && yes(bool(n, "dedicatedVotingProxy"))) emit(n, "proxy.dedicated.pluginmessaging", "Proxy Setup", Status.FAIL, + "DedicatedVotingProxy cannot deliver through PLUGINMESSAGING.", "dedicatedVotingProxy / configuredMethod", "Choose a supported dedicated-proxy transport."); + } + if ("HTTP".equals(method)) { + requirement(n, proxy(n) ? "transport.http.endpoint" : "transport.http.enrollment", "Transport", + proxy(n) ? "httpPublicEndpointConfigured" : "httpEnrolled", "HTTP endpoint or enrollment is unavailable."); + if (!proxy(n)) { + requirement(n, "transport.http.identity", "Transport", "httpIdentityValid", "HTTP client identity is invalid."); + if (yes(bool(n, "httpEnrolled")) && yes(bool(n, "httpConnectionCodePresent"))) emit(n, "transport.http.connection-code.stale", "Transport", Status.WARNING, + "A connection code remains after successful enrollment.", "httpConnectionCodePresent", "Remove the stale enrollment code according to the HTTP setup guide."); + } + } + if ("MYSQL".equals(method)) { + requirement(n, "transport.mysql.database", "Transport", "databaseInitialized", "Database proxy transport is unavailable."); + emit(n, "transport.mysql.recommendation", "Transport", Status.INFO, "MYSQL proxy transport is not the preferred network transport.", "documented transport guidance", "Consider HTTP or another supported transport for this network."); + } + if ("SOCKETS".equals(method)) unknown(n, "transport.sockets.peer-identity", "Transport", "peer endpoint / identity not exposed"); + } + private void votifier(NodeStatus n, boolean managed) { + Boolean provider = bool(n, "votifierProviderPresent"); + if (!proxy(n)) { + Boolean trigger = bool(n, "triggerVotifierEvent"); + if (yes(trigger)) emit(n, "votifier.trigger-event.provider-missing", "Votifier", provider == null ? Status.UNKNOWN : provider ? Status.INFO : Status.FAIL, + provider == null ? "Synthetic event compatibility cannot be verified." : provider ? "Backend Votifier is valid here: VotingPlugin emits a synthetic VotifierEvent for proxy-delivered votes. This is separate from socket forwarding." + : "TriggerVotifierEvent is enabled without a compatible backend provider/API.", "triggerVotifierEvent / votifierProviderPresent", "Install a compatible provider or disable the synthetic-event option."); + else emit(n, "votifier.backend.provider", "Votifier", trigger == null || provider == null ? Status.UNKNOWN : Status.INFO, + "A backend Votifier installation does not establish a duplicate forwarding path.", "triggerVotifierEvent / votifierProviderPresent", "Verify intended ingress; no change is required solely for plugin presence."); + return; + } + boolean secondary = yes(bool(n, "multiProxySupport")) && no(bool(n, "primaryServer")); + if (secondary) emit(n, "votifier.secondary.ingress", "Votifier", Status.INFO, "A secondary proxy may use the primary for vote ingress and does not require its own Votifier.", "multiProxySupport / primaryServer", "Verify the primary ingress."); + else { + requirement(n, "votifier.proxy.provider", "Votifier", "votifierProviderPresent", "The expected proxy ingress has no compatible Votifier provider."); + requirement(n, "votifier.proxy.listener", "Votifier", "votifierListenerInitialized", "The expected Votifier listener failed to initialize."); + } + List plugins = list(n, "detectedPlugins"); + if (plugins != null && plugins.stream().filter(p -> Set.of("votifier", "votifierplus", "nuvotifier").contains(p.toLowerCase(Locale.ROOT))).count() > 1) + emit(n, "votifier.providers.competing", "Votifier", Status.WARNING, "Multiple Votifier implementations may compete for proxy ingress.", "detectedPlugins", "Keep one intended listener per ingress role."); + List destinations = list(n, "forwardingDestinations"); + if (!yes(bool(n, "votifierForwardingKnown")) || destinations == null) unknown(n, "votifier.forwarding.duplicate-path", "Votifier", "optional VotifierPlus diagnostics"); + else if (destinations.isEmpty()) emit(n, "votifier.forwarding.duplicate-path", "Votifier", Status.PASS, "The provider reports no enabled socket forwarding destinations.", "forwardingDestinations", "No action required."); + else if (managed) { + boolean proven = nodes.stream().anyMatch(b -> !proxy(b) && yes(bool(b, "votifierProviderPresent")) + && destinations.stream().anyMatch(d -> d.equals(b.nodeId()) || d.equals(str(b, "serverName"))) + && n.backends().stream().anyMatch(r -> r.backendId().equals(b.nodeId()))); + emit(n, "votifier.forwarding.duplicate-path", "Votifier", Status.FAIL, + proven ? "An enabled proxy VotifierPlus destination matches an enrolled VotingPlugin backend with a Votifier provider: socket forwarding and VotingPlugin delivery can process the same vote twice." + : "Proxy VotifierPlus socket forwarding is enabled alongside VotingPlugin proxy delivery, creating a second possible path. Destination names do not prove remote host identity.", + "forwardingDestinations / registry.backends", "Disable unintended VotifierPlus socket forwarding. TriggerVotifierEvent does not make socket forwarding safe."); + } + } + private void storage(NodeStatus n) { + requirement(n, "storage.database.unavailable", "Database & Storage", "databaseInitialized", "The selected storage/database is unavailable."); + requirement(n, "storage.jdbc-driver.missing", "Database & Storage", "jdbcDriverAvailable", "The selected database has no suitable JDBC driver."); + if (yes(bool(n, "voteLoggingEnabled"))) requirement(n, "storage.votelog.unreadable", "Database & Storage", "voteLogReadable", "VoteLogging is enabled but the retained logged-event table is unavailable/unreadable."); + else if (bool(n, "voteLoggingEnabled") == null) unknown(n, "storage.votelog.unreadable", "Database & Storage", "voteLoggingEnabled"); + for (String field : List.of("voteCache", "nonVotedCache")) if (yes(bool(n, field + "Mysql"))) { + Boolean main = bool(n, field + "MainMysql"); + if (main == null) unknown(n, "storage." + field + "Mysql.unavailable", "Database & Storage", field + "MainMysql"); + else requirement(n, "storage." + field + "Mysql.unavailable", "Database & Storage", main ? "databaseInitialized" : field + "DatabaseInitialized", + field + " requests MySQL but its selected database is unavailable."); + } + if (yes(bool(n, "globalDataEnabled")) && yes(bool(n, "globalDataUseMainMysql"))) + requirement(n, "storage.globalDataUseMainMysql.unavailable", "Database & Storage", "databaseInitialized", "Enabled GlobalData requires its selected main database."); + if (!proxy(n) && yes(bool(n, "proxyMode"))) { + String prefix = str(n, "databasePrefix"); + if (prefix == null) unknown(n, "storage.backend-prefix.isolation", "Database & Storage", "databasePrefix"); + else if (!prefix.isEmpty()) emit(n, "storage.backend-prefix.isolation", "Database & Storage", Status.WARNING, + "A per-backend database prefix may isolate data intended to be shared.", "databasePrefix", "Confirm this prefix is intentional for shared/global storage."); + } + } + private void sites(NodeStatus n) { + JsonNode sites = data(n).get("voteSites"); + if (sites == null) { unknown(n, "votesite.inventory", "Vote Sites", "voteSites"); return; } + boolean complete = yes(bool(n, "voteSitesComplete")); + emit(n, "votesite.configured", "Vote Sites", sites.isEmpty() && complete ? Status.WARNING : complete ? Status.PASS : Status.UNKNOWN, + sites.isEmpty() ? "No configured Vote Sites were reported." : "Vote Site inventory is a bounded configuration observation.", "voteSites / voteSitesComplete", "Configure intended sites and complete the inventory."); + long enabled = 0; Map services = new HashMap<>(); + for (JsonNode site : sites) { + if (!site.path("enabled").isBoolean()) { unknown(n, "votesite.enabled", "Vote Sites", "voteSites.enabled"); continue; } + if (!site.path("enabled").booleanValue()) continue; + enabled++; + String service = site.has("serviceSite") ? site.path("serviceSite").asText() : null; + String scope = "Site " + site.path("name").asText() + ": "; + if (service == null) unknown(n, "votesite.service-site.missing", "Vote Sites", "voteSites.serviceSite"); + else if (service.isBlank() || service.equalsIgnoreCase("SERVICESITE")) emit(n, "votesite.service-site.missing", "Vote Sites", Status.FAIL, scope + "ServiceSite is blank or a placeholder.", "voteSites.serviceSite", "Set the service identifier supplied by the voting site."); + else { + String old = services.putIfAbsent(service.toLowerCase(Locale.ROOT), service); + if (old != null) emit(n, "votesite.service-site.duplicate", "Vote Sites", old.equals(service) ? Status.FAIL : Status.WARNING, + scope + "Another enabled site has the same case-normalized ServiceSite.", "voteSites.serviceSite", "Use one unambiguous enabled site per service identifier."); + } + if (site.has("delayValid") && !site.path("delayValid").booleanValue()) emit(n, "votesite.delay.invalid", "Vote Sites", Status.FAIL, scope + "VoteDelay cannot be parsed.", "voteSites.delayValid", "Correct the VoteDelay format."); + else if (!site.has("delayValid")) unknown(n, "votesite.delay.invalid", "Vote Sites", "voteSites.delayValid"); + if (site.has("delayHours") && (site.path("delayHours").asInt() == 0 || site.path("delayHours").asInt() > 8760)) emit(n, "votesite.delay.unusual", "Vote Sites", Status.WARNING, scope + "Vote delay is unusually short or long.", "voteSites.delayHours", "Confirm the voting site's intended delay."); + if (site.has("voteUrlState") && !"VALID".equals(site.path("voteUrlState").asText())) emit(n, "votesite.url.unfinished", "Vote Sites", Status.WARNING, scope + "VoteURL is blank, an example or invalid.", "voteSites.voteUrlState", "Set the intended public voting URL; remote uptime is not observed."); + if (site.path("autoCreated").asBoolean(false)) emit(n, "votesite.auto-created.unfinished", "Vote Sites", Status.WARNING, scope + "Review the automatically created site.", "voteSites.autoCreated", "Complete or remove the auto-created configuration."); + if (site.has("hasRewards") && !site.path("hasRewards").booleanValue()) emit(n, "votesite.rewards.none", "Vote Sites", Status.INFO, scope + "No rewards are configured; totals-only sites can be intentional.", "voteSites.hasRewards", "No action required for an intentional totals-only site."); + } + if (enabled == 0) emit(n, "votesite.enabled.none", "Vote Sites", complete ? Status.WARNING : Status.UNKNOWN, "No enabled sites are present in the reported inventory.", "voteSites", "Check enabled Vote Sites."); + if (yes(bool(n, "autoCreateVoteSites")) && enabled > 0) emit(n, "votesite.auto-create.enabled", "Vote Sites", Status.WARNING, "AutoCreateVoteSites remains enabled after sites have been configured.", "autoCreateVoteSites", "Consider disabling automatic creation after setup."); + for (String field : List.of("detectedServices", "loggedServices")) { + List observed = list(n, field); + if (observed == null || !complete) unknown(n, "votesite." + field + ".unmatched", "Vote Sites", field); + else if (observed.stream().anyMatch(s -> !services.containsKey(s.toLowerCase(Locale.ROOT)))) emit(n, "votesite." + field + ".unmatched", "Vote Sites", Status.WARNING, + "An observed service has no matching enabled Vote Site.", field, "Review observed identifiers without auto-creating sites."); + } + Integer max = num(n, "maxVotesPerDay"); + if (max != null && max > 0 && max < enabled) emit(n, "votesite.daily-limit.conflict", "Vote Sites", Status.WARNING, "MaxAmountOfVotesPerDay is lower than the enabled site count.", "maxVotesPerDay / voteSites", "Confirm the intended daily limit."); + } + private void rewards(NodeStatus n, boolean managed) { + Boolean process = bool(n, "processRewards"); + if (process == null) unknown(n, "rewards.processing.disabled", "Rewards", "processRewards"); + else if (!process) emit(n, "rewards.processing.disabled", "Rewards", Status.WARNING, "ProcessRewards is disabled.", "processRewards", "Confirm this node intentionally processes no rewards."); + for (String field : List.of("missingRewardFiles", "invalidRewardFiles", "missingRewardDependencies")) { + List values = list(n, field); + if (values == null || !yes(bool(n, "rewardsComplete"))) unknown(n, "rewards." + field, "Rewards", field); + else if (!values.isEmpty()) emit(n, "rewards." + field, "Rewards", field.equals("missingRewardDependencies") ? Status.WARNING : Status.FAIL, + "The node reports " + values.size() + " missing/invalid reward references or dependencies.", field, "Review the named reward inventory and required plugins."); + } + if (managed && (yes(bool(n, "perServerRewards")) || yes(bool(n, "perServerPoints")))) { + String name = str(n, "serverName"); + emit(n, "rewards.per-server.identity", "Rewards", name == null ? Status.UNKNOWN : invalidName(name) ? Status.FAIL : Status.INFO, + "PerServerRewards/PerServerPoints require a valid unique storage identity.", "serverName / perServerRewards / perServerPoints", "Correct invalid or colliding server identities."); + } + if (yes(bool(n, "giveExtraAllSitesRewards"))) { + String method = str(n, "configuredMethod"); + if (method == null) unknown(n, "rewards.extra-all-sites.method", "Rewards", "configuredMethod"); + // Applicability is producer/documentation-specific; do not infer an unsupported method from its label. + if (no(bool(n, "extraAllSitesCheck"))) emit(n, "rewards.extra-all-sites.check", "Rewards", Status.WARNING, "GiveExtraAllSitesRewards is enabled without ExtraAllSitesCheck.", "giveExtraAllSitesRewards / extraAllSitesCheck", "Review the documented paired options."); + } + } + private void compare(NodeStatus a, NodeStatus b, String field, String id, String category, Status mismatch) { + JsonNode left = data(a).get(field), right = data(b).get(field); + Status status = left == null || right == null ? Status.UNKNOWN : left.equals(right) ? Status.PASS : mismatch; + check(id, category, status, id, + status == Status.UNKNOWN ? "One or both participating peers did not report this setting." + : status == Status.PASS ? "Reported settings agree for this observed network relationship." : "Reported settings differ within this proxy/backend network.", + List.of(a.nodeId(), b.nodeId()), field, status == Status.PASS ? "No action required." : "Review the intended shared network settings on both nodes.", false); + } + private void network(NodeStatus p) { + if (!p.online() || !evidence.containsKey(p.nodeId())) return; + List members = nodes.stream().filter(b -> !proxy(b) && p.backends().stream().anyMatch(r -> r.backendId().equals(b.nodeId()))).toList(); + boolean complete = yes(bool(p, "topologyComplete")) && !truncated; + List reportedNames = list(p, "backendNames"); + Set known = reportedNames == null ? new HashSet<>() : new HashSet<>(reportedNames); + if (reportedNames == null) p.backends().forEach(b -> known.add(b.backendId())); + for (var backend : p.backends()) { + if (nodes.stream().noneMatch(n -> !proxy(n) && n.nodeId().equals(backend.backendId()))) emit(p, "topology.backend.unenrolled", "Control & Topology", Status.WARNING, + "A proxy-reported backend is not enrolled in this bounded Control view.", "registry.backends", "Enroll the intended backend or verify the reported identity."); + if (!backend.presenceKnown()) emit(p, "routing.presence.unknown", "Routing", Status.UNKNOWN, + "Backend presence is not known; presence-dependent routing cannot be verified.", "registry.backends.presenceKnown", "Check presence reporting without sending a vote."); + } + if (members.isEmpty()) emit(p, "topology.backends.missing", "Control & Topology", complete ? Status.WARNING : Status.UNKNOWN, + "No enrolled backend relationship is available for this proxy.", "registry.backends", "Verify backend enrollment and topology reporting."); + if (!members.isEmpty() && members.stream().allMatch(b -> no(bool(b, "proxyMode")))) emit(p, "proxy.backends.mode-disabled", "Proxy Setup", Status.WARNING, + "All reported enrolled backends have proxy mode disabled.", "proxyMode / registry.backends", "Enable proxy mode on intended VotingPlugin-managed backends."); + Map normalized = new HashMap<>(); + for (NodeStatus b : members) { + if (checks.size() >= MAX_CHECKS - 1) { truncated = true; return; } + if (!b.online() || !evidence.containsKey(b.nodeId())) { unknown(b, "proxy.method.mismatch", "Transport", "current-session evidence"); continue; } + String name = str(b, "serverName"); + if (name != null && !invalidName(name)) { + NodeStatus old = normalized.putIfAbsent(normalized(name), b); + if (old != null) { + String previous = str(old, "serverName"); + String id = previous.equalsIgnoreCase(name) ? "backend.server-name.duplicate" : "backend.server-name.normalization-collision"; + if (yes(bool(old, "perServerRewards")) || yes(bool(old, "perServerPoints")) + || yes(bool(b, "perServerRewards")) || yes(bool(b, "perServerPoints"))) { + check("rewards.per-server.identity", "Rewards", Status.FAIL, "Per-server reward identities collide", + "PerServerRewards/PerServerPoints cannot separate these normalized backend identities.", + List.of(old.nodeId(), b.nodeId()), "serverName / per-server options", "Assign distinct normalized identities.", true); + } + check(id, "Proxy Setup", Status.FAIL, "Backend storage identities collide", + "Two backends in this network have duplicate, case-only or dash-to-underscore normalized identities. Per-server storage may collide.", + List.of(old.nodeId(), b.nodeId()), "serverName / observed topology", "Assign distinct backend identities and review per-server storage before restarting.", true); + } + if (!name.equals(b.nodeId())) emit(b, "backend.server-name.proxy-mismatch", "Proxy Setup", Status.WARNING, + "Configured Server differs from the proxy-reported backend identity. Control node IDs can be customized; verify the intended mapping.", + "serverName / registry.backends.backendId", "Confirm the proxy backend and VotingPlugin Server mapping."); + } + compare(p, b, "configuredMethod", "proxy.method.mismatch", "Transport", Status.FAIL); + for (String field : List.of("onlineMode", "bedrockPlayerPrefix", "timeHourOffset", "resetMilestonesMonthly", "monthDateTotals")) + compare(p, b, field, "network.consistency." + field, "Proxy Setup", Status.WARNING); + String method = str(p, "configuredMethod"); + if ("PLUGINMESSAGING".equals(method)) { + compare(p, b, "pluginMessageChannel", "transport.pluginmessaging.channel-mismatch", "Transport", Status.FAIL); + compare(p, b, "encryption", "transport.encryption.mismatch", "Transport", Status.FAIL); + } + if (Set.of("REDIS", "MQTT", "SOCKETS").contains(method == null ? "" : method)) { + compare(p, b, "sharedAuthentication", "transport.authentication.mode-mismatch", "Transport", Status.FAIL); + if (yesRequired(p) || yesRequired(b)) compare(p, b, "sharedKeyFingerprint", "transport.authentication.key-mismatch", "Transport", Status.FAIL); + if ("COMPATIBILITY".equals(str(p, "sharedAuthentication")) && data(b).has("sharedAuthentication")) emit(p, "transport.authentication.compatibility", "Transport", Status.WARNING, + "Compatibility authentication supports rolling upgrades. Consider REQUIRED only after every intended peer supports authenticated transport.", "sharedAuthentication", "Verify all peers before changing authentication mode."); + } + if (Set.of("REDIS", "MQTT").contains(method == null ? "" : method)) compare(p, b, "transportNamespace", "transport.namespace.mismatch", "Transport", Status.FAIL); + if ("REDIS".equals(method)) compare(p, b, "redisSsl", "transport.redis.ssl-mismatch", "Transport", Status.FAIL); + if ("MQTT".equals(method)) mqttIdentity(p, b); + if (yes(bool(p, "allowUnjoined"))) requirement(b, "routing.allow-unjoined.prerequisite", "Routing", "allowUnjoined", "Proxy accepts unjoined players but backend AllowUnjoined is disabled."); + if (yes(bool(p, "waitForUserOnline")) && no(bool(b, "waitForUserOnline"))) emit(b, "routing.wait-online.combination", "Routing", Status.WARNING, + "Proxy and backend waiting behavior differ; verify intended offline reward routing.", "waitForUserOnline", "Review the documented wait-for-online reward path."); + for (String field : List.of("globalDataEnabled", "globalDataPrefix")) compare(p, b, field, "storage.consistency." + field, "Database & Storage", Status.WARNING); + List waiting = list(p, "waitUntilVoteDelayServices"); + if (waiting != null && yes(bool(b, "voteSitesComplete")) && data(b).has("voteSites")) { + Set services = new HashSet<>(); data(b).path("voteSites").forEach(s -> { if (s.has("serviceSite")) services.add(s.path("serviceSite").asText().toLowerCase(Locale.ROOT)); }); + if (waiting.stream().anyMatch(s -> !services.contains(s.toLowerCase(Locale.ROOT)))) emit(b, "votesite.proxy-delay.unmatched", "Vote Sites", Status.WARNING, + "Proxy WaitUntilVoteDelay references a service not configured on this backend.", "waitUntilVoteDelayServices / voteSites", "Align intended delay service identifiers."); + } + } + for (int i = 0; i < members.size(); i++) for (int j = i + 1; j < members.size(); j++) { + if (checks.size() >= MAX_CHECKS - 1) { truncated = true; return; } + var a = members.get(i); var b = members.get(j); + if (!a.online() || !b.online() || !evidence.containsKey(a.nodeId()) || !evidence.containsKey(b.nodeId())) continue; + compare(a, b, "globalDataEnabled", "storage.consistency.globalDataEnabled", "Database & Storage", Status.WARNING); + if (yes(bool(a, "globalDataEnabled")) || yes(bool(b, "globalDataEnabled"))) { + compare(a, b, "globalDataPrefix", "storage.consistency.globalDataPrefix", "Database & Storage", Status.WARNING); + compare(a, b, "storageFingerprint", "storage.shared-identity.mismatch", "Database & Storage", Status.WARNING); + compare(a, b, "databasePrefix", "storage.prefix.mismatch", "Database & Storage", Status.WARNING); + } + if ("MQTT".equals(str(p, "configuredMethod"))) { + mqttIdentity(a, b); + } + } + routing(p, known, complete); + } + private void mqttIdentity(NodeStatus a, NodeStatus b) { + String left = str(a, "mqttClientId"), right = str(b, "mqttClientId"); + if (left == null || right == null) unknown(a, "transport.mqtt.client-id.duplicate", "Transport", "mqttClientId"); + else if (!left.isBlank() && left.equals(right)) check("transport.mqtt.client-id.duplicate", "Transport", Status.FAIL, + "Duplicate MQTT ClientIDs", "Participating clients can evict one another from the broker.", List.of(a.nodeId(), b.nodeId()), "mqttClientId", "Set distinct MQTT ClientIDs.", true); + } + private boolean yesRequired(NodeStatus n) { return "REQUIRED".equals(str(n, "sharedAuthentication")); } + private void routing(NodeStatus p, Set known, boolean complete) { + for (String field : List.of("blockedServers", "whitelistedServers", "broadcastServers", "offlineForwardServers", "votePartyServers")) { + List entries = list(p, field); + if (entries == null) { unknown(p, "routing." + field + ".unknown", "Routing", field); continue; } + for (String entry : entries) if (!known.contains(entry)) emit(p, "routing.unknown-server", "Routing", complete ? Status.FAIL : Status.UNKNOWN, + complete ? "A routing list references an unknown backend identity." : "Topology is incomplete; the routing destination cannot be verified.", field, "Review routing destinations against the proxy's backend inventory."); + } + List blocked = list(p, "blockedServers"), white = list(p, "whitelistedServers"); + if (blocked == null || white == null || !complete) { unknown(p, "routing.no-eligible-backends", "Routing", "routing / topologyComplete"); return; } + if (white.stream().anyMatch(blocked::contains)) emit(p, "routing.block-whitelist.overlap", "Routing", Status.WARNING, + "BlockedServers and WhiteListedServers overlap; this is confusing even when block precedence is intended.", "blockedServers / whitelistedServers", "Remove unintended overlapping rules."); + long eligible = known.stream().filter(s -> !blocked.contains(s) && (white.isEmpty() || white.contains(s))).count(); + emit(p, "routing.no-eligible-backends", "Routing", eligible == 0 ? Status.FAIL : Status.PASS, + eligible == 0 ? "Routing rules leave no possible backend destination." : "Routing rules leave at least one configured backend destination; runtime delivery is checked separately.", + "blockedServers / whitelistedServers / topology", "Review whitelist and blocked rules."); + if (known.stream().anyMatch(s -> blocked.contains(s) || !white.isEmpty() && !white.contains(s))) emit(p, "routing.backend.excluded", "Routing", Status.WARNING, + "At least one known backend is excluded from normal vote routing.", "blockedServers / whitelistedServers", "Confirm intentional exclusions."); + } + private void multiProxy() { + List proxies = nodes.stream().filter(NetworkDoctor::proxy).filter(p -> p.online() && yes(bool(p, "multiProxySupport"))).toList(); + for (NodeStatus p : proxies) { + if (checks.size() >= MAX_CHECKS - 1) { truncated = true; return; } + String method = str(p, "multiProxyMethod"); + emit(p, "multiproxy.method.valid", "Multi-Proxy", method == null ? Status.UNKNOWN : "INVALID".equals(method) ? Status.FAIL : Status.PASS, + "MultiProxyMethod must be a supported method.", "multiProxyMethod", "Review MultiProxyMethod."); + String name = str(p, "proxyServerName"); + emit(p, "multiproxy.identity.unset", "Multi-Proxy", name == null ? Status.UNKNOWN : invalidName(name) || name.equalsIgnoreCase("proxy") ? Status.FAIL : Status.PASS, + "Multi-proxy requires a nonblank, nondefault unique identity.", "proxyServerName", "Set a unique ProxyServerName."); + if (yes(bool(p, "multiProxyOneGlobalReward")) && yes(bool(p, "sendVotesToAllServers"))) emit(p, "multiproxy.global-reward.send-all", "Multi-Proxy", Status.FAIL, + "MultiProxyOneGlobalReward requires SendVotesToAllServers to be disabled.", "multiProxyOneGlobalReward / sendVotesToAllServers", "Correct the incompatible options."); + List declared = list(p, "proxyServers"); + if (declared == null) { unknown(p, "multiproxy.relationships", "Multi-Proxy", "proxyServers"); continue; } + List related = proxies.stream().filter(other -> other.nodeId().equals(p.nodeId()) || declared.contains(other.nodeId()) || declared.contains(str(other, "proxyServerName"))).toList(); + boolean complete = yes(bool(p, "topologyComplete")) && declared.stream().allMatch(id -> related.stream().anyMatch(other -> id.equals(other.nodeId()) || id.equals(str(other, "proxyServerName")))); + long primary = related.stream().filter(other -> yes(bool(other, "primaryServer"))).count(); + Status state = primary > 1 ? Status.FAIL : !complete || related.stream().anyMatch(other -> bool(other, "primaryServer") == null) ? Status.UNKNOWN : primary == 1 ? Status.PASS : Status.FAIL; + emit(p, "multiproxy.primary.duplicate", "Multi-Proxy", state, + "The declared multi-proxy group requires exactly one primary; incomplete or offline evidence cannot prove that count.", "primaryServer / proxyServers", "Verify all intended peers and configure one primary."); + for (String reference : declared) if (related.stream().noneMatch(other -> reference.equals(other.nodeId()) || reference.equals(str(other, "proxyServerName")))) unknown(p, "multiproxy.peer.missing", "Multi-Proxy", "proxyServers"); + for (NodeStatus other : related) if (!other.nodeId().equals(p.nodeId())) { + if (name != null && name.equalsIgnoreCase(str(other, "proxyServerName"))) check("multiproxy.identity.duplicate", "Multi-Proxy", Status.FAIL, + "Duplicate proxy identity", "Related proxies share a case-normalized ProxyServerName.", List.of(p.nodeId(), other.nodeId()), "proxyServerName", "Assign distinct proxy identities.", true); + compare(p, other, "multiProxyMethod", "multiproxy.method.mismatch", "Multi-Proxy", Status.FAIL); + compare(p, other, "sharedAuthentication", "multiproxy.authentication.mismatch", "Multi-Proxy", Status.FAIL); + if (yesRequired(p) || yesRequired(other)) compare(p, other, "multiProxyKeyFingerprint", "multiproxy.key.mismatch", "Multi-Proxy", Status.FAIL); + compare(p, other, "encryption", "multiproxy.encryption.mismatch", "Multi-Proxy", Status.FAIL); + } + } + } +} diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/NetworkHealthEvidence.java b/src/main/java/com/bencodez/votingplugin/control/domain/NetworkHealthEvidence.java new file mode 100644 index 0000000..d4b3412 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/domain/NetworkHealthEvidence.java @@ -0,0 +1,86 @@ +package com.bencodez.votingplugin.control.domain; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.*; + +/** Strict allow-list for untrusted read-only evidence. Missing is not false. */ +public final class NetworkHealthEvidence { + private NetworkHealthEvidence() { } + public static final String CAPABILITY = "data.network-health.v1"; + private static final Set BOOLEANS = Set.of("proxyMode", "configurationHealthy", "onlineMode", + "resetMilestonesMonthly", "monthDateTotals", "automaticTimeChanges", "encryption", "transportInitialized", + "restartRequired", "triggerVotifierEvent", "votifierProviderPresent", "votifierListenerInitialized", + "votifierForwardingKnown", "bungeeManageTotals", "dedicatedVotingProxy", "httpPublicEndpointConfigured", + "httpEnrolled", "httpIdentityValid", "httpConnectionCodePresent", "redisSsl", "databaseInitialized", + "jdbcDriverAvailable", "voteLoggingEnabled", "voteLogReadable", "voteCacheMysql", "nonVotedCacheMysql", + "voteCacheMainMysql", "nonVotedCacheMainMysql", "voteCacheDatabaseInitialized", "nonVotedCacheDatabaseInitialized", + "globalDataEnabled", "globalDataUseMainMysql", "processRewards", "perServerRewards", "perServerPoints", + "giveExtraAllSitesRewards", "extraAllSitesCheck", "autoCreateVoteSites", "allowUnjoined", "waitForUserOnline", + "votePartyEnabled", "votePartyEffectiveReward", "multiProxySupport", "primaryServer", + "multiProxyOneGlobalReward", "sendVotesToAllServers", "topologyComplete", "voteSitesComplete", "rewardsComplete"); + private static final Set STRINGS = Set.of("serverName", "pluginMessageChannel", "bedrockPlayerPrefix", + "transportNamespace", "mqttClientId", "globalDataPrefix", "databasePrefix", "proxyServerName", "databaseType"); + private static final Set FINGERPRINTS = Set.of("sharedKeyFingerprint", "multiProxyKeyFingerprint", "storageFingerprint"); + private static final Set INTEGERS = Set.of("timeHourOffset", "maxVotesPerDay", "votePartyVotesRequired", + "carrierPlayers", "queuedVotes", "parkedVotes", "oldestPendingSeconds", "authenticationFailures"); + private static final Set ARRAYS = Set.of("detectedPlugins", "backendNames", "forwardingDestinations", "blockedServers", + "whitelistedServers", "broadcastServers", "offlineForwardServers", "votePartyServers", "proxyServers", + "waitUntilVoteDelayServices", "detectedServices", "loggedServices", "missingRewardFiles", "invalidRewardFiles", + "missingRewardDependencies", "invalidConfigurationFields"); + private static final Set METHODS = Set.of("PLUGINMESSAGING", "HTTP", "REDIS", "MQTT", "SOCKETS", "MYSQL", "INVALID"); + private static final Map> ENUMS = Map.of("role", Set.of("BACKEND", "PROXY"), + "configuredMethod", METHODS, "activeMethod", METHODS, "multiProxyMethod", Set.of("SOCKET", "SOCKETS", "REDIS", "INVALID"), + "sharedAuthentication", Set.of("REQUIRED", "COMPATIBILITY", "DISABLED", "INVALID"), + "transportProbeState", Set.of("SUCCESS", "WAITING", "FAILED", "UNKNOWN")); + + public static boolean valid(JsonNode data) { + if (data == null || !data.isObject() || !data.path("schemaVersion").isIntegralNumber() + || data.path("schemaVersion").intValue() != 1 || !ENUMS.get("role").contains(data.path("role").asText())) return false; + Iterator> fields = data.fields(); + while (fields.hasNext()) { + var field = fields.next(); String name = field.getKey(); JsonNode v = field.getValue(); + if ("schemaVersion".equals(name)) { if (!v.canConvertToInt()) return false; } + else if (BOOLEANS.contains(name)) { if (!v.isBoolean()) return false; } + else if (STRINGS.contains(name)) { if (!text(v, 160)) return false; } + else if (FINGERPRINTS.contains(name)) { if (!v.isTextual() || !v.asText().matches("[a-f0-9]{64}")) return false; } + else if (INTEGERS.contains(name)) { if (!v.isIntegralNumber() || !v.canConvertToInt() + || Math.abs(v.longValue()) > 1_000_000_000L + || !"timeHourOffset".equals(name) && v.longValue() < 0) return false; } + else if (ENUMS.containsKey(name)) { if (!v.isTextual() || !ENUMS.get(name).contains(v.asText())) return false; } + else if (ARRAYS.contains(name)) { if (!strings(v)) return false; } + else if ("voteSites".equals(name)) { if (!sites(v)) return false; } + else return false; + } + return true; + } + private static boolean text(JsonNode v, int max) { + return v.isTextual() && v.asText().length() <= max && v.asText().codePoints().noneMatch(Character::isISOControl); + } + private static boolean strings(JsonNode v) { + if (!v.isArray() || v.size() > 100) return false; + Set seen = new HashSet<>(); + for (JsonNode value : v) if (!text(value, 80) || !seen.add(value.asText())) return false; + return true; + } + private static boolean sites(JsonNode v) { + if (!v.isArray() || v.size() > 100) return false; + Set names = new HashSet<>(); + for (JsonNode site : v) { + if (!site.isObject() || !text(site.path("name"), 80) || !names.add(site.path("name").asText())) return false; + var fields = site.fields(); + while (fields.hasNext()) { + var f = fields.next(); JsonNode value = f.getValue(); + switch (f.getKey()) { + case "name" -> { } + case "serviceSite" -> { if (!text(value, 160)) return false; } + case "enabled", "delayValid", "autoCreated", "hasRewards" -> { if (!value.isBoolean()) return false; } + case "delayHours" -> { if (!value.isIntegralNumber() || !value.canConvertToInt() + || value.intValue() < 0 || value.intValue() > 1_000_000) return false; } + case "voteUrlState" -> { if (!value.isTextual() || !Set.of("VALID", "BLANK", "EXAMPLE", "INVALID").contains(value.asText())) return false; } + default -> { return false; } + } + } + } + return true; + } +} diff --git a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java index 0c97217..2c183d5 100644 --- a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java +++ b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java @@ -93,6 +93,7 @@ public final class ControlHttpServer implements AutoCloseable { private static final Map WEB_RESOURCES = Map.ofEntries( Map.entry("/", new WebResource("/web/index.html", "text/html; charset=utf-8")), Map.entry("/index.html", new WebResource("/web/index.html", "text/html; charset=utf-8")), + Map.entry("/network-doctor.js", new WebResource("/web/network-doctor.js", "text/javascript; charset=utf-8")), Map.entry("/app.js", new WebResource("/web/app.js", "text/javascript; charset=utf-8")), Map.entry("/workspace.js", new WebResource("/web/workspace.js", "text/javascript; charset=utf-8")), Map.entry("/configuration-state.js", new WebResource("/web/configuration-state.js", "text/javascript; charset=utf-8")), @@ -498,6 +499,15 @@ private void route(HttpExchange exchange) throws IOException { return; } } + if ("/api/v1/network-doctor".equals(path)) { + requireMethod(exchange, "GET"); + authenticateAdmin(exchange, false); + NodeRegistry.RegistryPage page = registry.page(0, 100, null); + var evidence = inspectionOperations.networkEvidence(page.items()); + send(exchange, 200, com.bencodez.votingplugin.control.domain.NetworkDoctor.evaluate( + page.items(), evidence, Instant.now(), page.total() > page.items().size())); + return; + } if (NODES.equals(path)) { requireMethod(exchange, "GET"); authenticateAdmin(exchange, false); diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/InspectionQuery.java b/src/main/java/com/bencodez/votingplugin/control/protocol/InspectionQuery.java index 672ffe2..b0d6a7f 100644 --- a/src/main/java/com/bencodez/votingplugin/control/protocol/InspectionQuery.java +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/InspectionQuery.java @@ -12,12 +12,12 @@ public record InspectionQuery(String kind, Map filters) { public static final int MAX_REWARD_PROPOSAL = 64 * 1024; public static final Set KINDS = Set.of("overview", "player", "vote-site-health", "vote-log-summary", "vote-log-search", "vote-trace", "vote-site-resolution", "reward-simulation", "diagnostics", - "reward-file-inventory"); + "reward-file-inventory", "network-health"); public InspectionQuery { filters = filters == null ? Map.of() : Map.copyOf(new LinkedHashMap<>(filters)); if (!KINDS.contains(kind)) throw new IllegalArgumentException("inspection kind is unsupported"); - if ("reward-file-inventory".equals(kind) && !filters.isEmpty()) + if (("reward-file-inventory".equals(kind) || "network-health".equals(kind)) && !filters.isEmpty()) throw new IllegalArgumentException("reward file inventory takes no filters"); if (filters.size() > 12 || filters.entrySet().stream().anyMatch(entry -> entry.getKey() == null || !entry.getKey().matches("[a-z][A-Za-z0-9]{0,39}") || entry.getValue() == null @@ -26,6 +26,8 @@ public record InspectionQuery(String kind, Map filters) { } } + public boolean requiresNetworkHealth() { return "network-health".equals(kind); } + public boolean requiresRewardFiles() { return "reward-file-inventory".equals(kind); } private static boolean invalidValue(String kind, String key, String value) { diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index fcff6d4..b41fdde 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -3198,7 +3198,7 @@ function updateExtendedButtons() { const fileTargets = comparisonTargetIds(); const driftReady = authenticated && fileTargets.length >= 2 && configurationOperationsInFlight === 0; refreshDashboardButton.disabled = !authenticated || inspectionInFlight || dashboardLoading; - runNetworkDoctor.disabled = !inspectionReady; + runNetworkDoctor.disabled = !authenticated || inspectionInFlight || !allNodeItems.length; downloadNetworkDiagnostics.disabled = !lastDiagnostics; refreshSetupChecklist.disabled = !inspectionReady; refreshDataOverview.disabled = !inspectionReady; @@ -5432,41 +5432,45 @@ refreshSetupChecklist.addEventListener('click', async () => { refreshDataOverview.addEventListener('click', () => refreshOverview(dataOverview)); runNetworkDoctor.addEventListener('click', async () => { + if (inspectionInFlight) return; downloadNetworkDiagnostics.disabled = true; lastDiagnostics = null; + const generation = authenticationGeneration; + const selected = selectedServerId; + const contextCurrent = () => generation === authenticationGeneration && selected === selectedServerId; + inspectionInFlight = true; + updateExtendedButtons(); + text(networkDoctorResults, 'Collecting bounded read-only evidence across enrolled nodes…'); try { - const [diagnostics] = await Promise.all([runInspection('diagnostics', {}, networkDoctorResults), - refreshConfigurationHealth()]); - lastOverview = diagnostics.result; - invalidateDashboardInspection(); - const node = nodeIndex.get(selectedServerId); - const voteLog = diagnostics.result.voteLoggingEnabled !== true - ? {state: 'DISABLED', message: 'Vote logging is disabled; no retained logged-event history is expected.'} - : diagnostics.result.voteLogReadable === true - ? {state: 'READABLE', message: 'Retained logged-event history is readable. It is not a guaranteed record of every internal vote-delivery hop.'} - : {state: 'UNREADABLE', message: 'Vote logging is enabled, but retained logged-event history is not currently readable.'}; - const configuredVoteSites = finiteCount(diagnostics.result.configuredVoteSites); - const checks = { - controlConnected: Boolean(node?.online), - configurationHealthy: diagnostics.result.configurationHealthy, - votifierDetected: diagnostics.result.votifierDetected, - voteSitesConfigured: configuredVoteSites == null ? null : configuredVoteSites > 0, - voteSitesConfiguredKnown: configuredVoteSites != null, - processRewards: diagnostics.result.processRewards, - voteLogging: voteLog, - topologyReported: isBackend(node) ? proxyReportsFor(node.nodeId).length > 0 || !diagnostics.result.proxyMode : true - }; - lastDiagnostics = { - schemaVersion: 1, generatedAt: new Date().toISOString(), selectedNodeId: selectedServerId, - checks, configurationChecks: configurationHealthChecks(), voteLog, node: diagnostics.result, - control: {application: 'VotingPlugin Control', registeredNodes: allNodeItems.length, - nodes: allNodeItems.slice(0, 100).map(item => ({nodeId: item.nodeId, displayName: item.displayName, - role: roleLabel(item), online: item.online, pluginVersion: item.pluginVersion}))} + const candidates = allNodeItems.filter(node => node.online + && node.acceptedCapabilities.includes('data.inspect.v1') + && node.acceptedCapabilities.includes('data.network-health.v1')).slice(0, 100); + const deadlineAt = Date.now() + 175_000; + const controller = new AbortController(); + const deadlineTimer = window.setTimeout(() => controller.abort(), 175_000); + // Bound both concurrency and total request time; unavailable peers remain UNKNOWN in the report. + let index = 0; + const worker = async () => { + while (index < candidates.length && contextCurrent() && Date.now() < deadlineAt) { + const node = candidates[index++]; + try { await runInspectionOnNode(node, 'network-health', {}, {manageBusy: false, contextCurrent, deadlineAt, signal: controller.signal}); } + catch (_) { /* Missing or failed inspection evidence is represented by the server as UNKNOWN. */ } + } }; - renderJsonResult(networkDoctorResults, lastDiagnostics); - updateSetupChecklist(diagnostics.result); + try { await Promise.all([refreshConfigurationHealth(), ...Array.from({length: Math.min(3, candidates.length)}, worker)]); } + finally { window.clearTimeout(deadlineTimer); controller.abort(); } + if (!contextCurrent()) return; + const report = await authorized('/api/v1/network-doctor'); + if (!contextCurrent()) return; + lastDiagnostics = NetworkDoctorView.withConfigurationChecks(report, configurationHealthChecks()); + NetworkDoctorView.render(networkDoctorResults, lastDiagnostics, document); downloadNetworkDiagnostics.disabled = false; - } catch (error) { text(networkDoctorResults, error.message); } + } catch (error) { + if (contextCurrent()) text(networkDoctorResults, error.message); + } finally { + inspectionInFlight = false; + updateExtendedButtons(); + } }); downloadNetworkDiagnostics.addEventListener('click', () => { diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index 065fd13..6790fba 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -752,6 +752,7 @@

Enrolled node IDs

+ diff --git a/src/main/resources/web/network-doctor.js b/src/main/resources/web/network-doctor.js new file mode 100644 index 0000000..fd273c4 --- /dev/null +++ b/src/main/resources/web/network-doctor.js @@ -0,0 +1,50 @@ +/* Read-only presentation of server-produced checks; never interpret raw configuration here. */ +(function (root) { + 'use strict'; + const rank = {FAIL: 0, WARNING: 1, UNKNOWN: 2, INFO: 3, PASS: 4}; + function groups(report) { + const result = new Map(); + for (const check of Array.isArray(report?.checks) ? report.checks : []) { + if (!Object.hasOwn(rank, check.status)) continue; + const category = String(check.category || 'Other'); + if (!result.has(category)) result.set(category, []); + result.get(category).push(check); + } + return [...result].map(([category, checks]) => ({category, + checks: checks.slice().sort((a, b) => rank[a.status] - rank[b.status])})); + } + function withConfigurationChecks(report, checks) { + const configurationChecks = (Array.isArray(checks) ? checks : []).slice(0, 100); + return {...report, configurationChecks, checks: [...(report.checks || []), ...configurationChecks.map(check => ({ + id: `configuration.${check.path}`, category: 'Proxy Setup', + status: Object.hasOwn(rank, check.status) ? check.status : 'UNKNOWN', title: check.title || check.path, + explanation: check.message || 'Managed configuration evidence is unavailable.', + affectedNodes: [...new Set([...(check.nodeIds || []), ...(check.unknownNodeIds || [])])].slice(0, 100), + evidence: 'Current revision-bound managed configuration read', + nextAction: 'Review applicable settings through the normal preview/apply workflow.', restartRequired: false + }))]}; + } + function render(container, report, document) { + container.replaceChildren(); + const label = (tag, value) => { const element = document.createElement(tag); element.textContent = String(value); return element; }; + container.append(label('p', 'Read-only reported evidence. UNKNOWN means unverified; no votes or configuration changes were made.')); + for (const group of groups(report)) { + const section = document.createElement('section'); + section.append(label('h4', group.category)); + const extra = document.createElement('details'); + extra.append(label('summary', 'Show PASS / INFO')); + for (const check of group.checks) { + const item = document.createElement('div'); + item.className = 'doctor-check'; + item.append(label('strong', `${check.status}: ${check.title}`), label('p', check.explanation), + label('p', `Nodes: ${(check.affectedNodes || []).join(', ') || 'network'} · Evidence: ${check.evidence || 'unavailable'}`), + label('p', `Next: ${check.nextAction}${check.restartRequired ? ' (restart required)' : ''}`)); + if (check.status === 'PASS' || check.status === 'INFO') extra.append(item); else section.append(item); + } + section.append(extra); container.append(section); + } + } + const api = {groups, render, withConfigurationChecks}; + if (typeof module !== 'undefined' && module.exports) module.exports = api; + else root.NetworkDoctorView = api; +})(typeof globalThis !== 'undefined' ? globalThis : this); diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/NetworkDoctorTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/NetworkDoctorTest.java new file mode 100644 index 0000000..aca4634 --- /dev/null +++ b/src/test/java/com/bencodez/votingplugin/control/domain/NetworkDoctorTest.java @@ -0,0 +1,371 @@ +package com.bencodez.votingplugin.control.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.bencodez.votingplugin.control.protocol.BackendServerIdentity; +import com.bencodez.votingplugin.control.protocol.NodeStatus; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class NetworkDoctorTest { + private static final ObjectMapper JSON = new ObjectMapper(); + + @Test + void offlineOrMissingEvidenceProducesUnknownAndStandaloneDoesNotCompare() { + NodeStatus standalone = node("standalone", "BUKKIT", false, List.of()); + NetworkDoctor.Report report = NetworkDoctor.evaluate(List.of(standalone), Map.of(), Instant.EPOCH, false); + assertEquals(NetworkDoctor.Status.UNKNOWN, status(report, "evidence.proxy-setup")); + assertFalse(report.checks().stream().anyMatch(c -> c.id().equals("proxy.method.mismatch"))); + } + + @Test + void validBackendVotifierTriggerIsInfoAndNoCarrierIsUnknown() { + NodeStatus backend = node("backend-a", "BUKKIT", true, List.of()); + ObjectNode evidence = base().put("proxyMode", false).put("configurationHealthy", true) + .put("triggerVotifierEvent", true).put("votifierProviderPresent", true) + .put("voteSitesComplete", true).put("rewardsComplete", true) + .put("transportProbeState", "WAITING").put("carrierPlayers", 0) + .put("processRewards", true); + NetworkDoctor.Report report = evaluate(List.of(backend), evidence); + assertEquals(NetworkDoctor.Status.INFO, status(report, "votifier.trigger-event.provider-missing")); + assertFalse(report.checks().stream().anyMatch(c -> c.status() == NetworkDoctor.Status.FAIL + && c.id().startsWith("votifier."))); + assertEquals(NetworkDoctor.Status.UNKNOWN, status(report, "votifier.external-destination")); + } + + @Test + void proxyForwardingToKnownBackendWithVotifierIsFail() { + BackendServerIdentity relation = new BackendServerIdentity("backend-a", "backend-a", true, true, 1); + NodeStatus proxy = node("proxy", "BUNGEE", true, List.of(relation)); + NodeStatus backend = node("backend-a", "BUKKIT", true, List.of()); + ObjectNode p = proxyEvidence().put("votifierProviderPresent", true).put("votifierListenerInitialized", true) + .put("votifierForwardingKnown", true); + p.putArray("forwardingDestinations").add("backend-a"); + ObjectNode b = base().put("proxyMode", true).put("votifierProviderPresent", true) + .put("triggerVotifierEvent", true).put("configurationHealthy", true); + NetworkDoctor.Report report = NetworkDoctor.evaluate(List.of(proxy, backend), + Map.of("proxy", p, "backend-a", b), Instant.EPOCH, false); + assertEquals(NetworkDoctor.Status.FAIL, status(report, "votifier.forwarding.duplicate-path")); + } + + @Test + void unknownForwardingEvidenceDoesNotBecomeDuplicatePathFail() { + NodeStatus proxy = node("proxy", "VELOCITY", true, List.of()); + ObjectNode p = proxyEvidence().put("votifierProviderPresent", true).put("votifierListenerInitialized", true); + NetworkDoctor.Report report = evaluate(List.of(proxy), p); + assertEquals(NetworkDoctor.Status.UNKNOWN, status(report, "votifier.forwarding.duplicate-path")); + } + + @Test + void triggerWithoutProviderFailsAndBackendTriggerFalseIsNotFalsePositive() { + NodeStatus missing = node("missing", "BUKKIT", true, List.of()); + ObjectNode fail = base().put("proxyMode", false).put("triggerVotifierEvent", true) + .put("votifierProviderPresent", false); + assertEquals(NetworkDoctor.Status.FAIL, status(evaluate(List.of(missing), fail), "votifier.trigger-event.provider-missing")); + + NodeStatus ordinary = node("ordinary", "BUKKIT", true, List.of()); + ObjectNode info = base().put("proxyMode", false).put("triggerVotifierEvent", false) + .put("votifierProviderPresent", true); + NetworkDoctor.Report report = evaluate(List.of(ordinary), info); + assertFalse(report.checks().stream().anyMatch(c -> c.id().equals("votifier.trigger-event.provider-missing") + && c.status() == NetworkDoctor.Status.FAIL)); + } + + @Test + void healthyStandaloneAndProxyNetworksHaveNoFailChecks() { + NodeStatus standalone = node("standalone", "BUKKIT", true, List.of()); + ObjectNode standaloneEvidence = base().put("proxyMode", false).put("configurationHealthy", true) + .put("votifierProviderPresent", true).put("triggerVotifierEvent", false) + .put("databaseInitialized", true).put("jdbcDriverAvailable", true).put("processRewards", true) + .put("voteSitesComplete", true).put("rewardsComplete", true); + standaloneEvidence.putArray("voteSites"); + assertFalse(evaluate(List.of(standalone), standaloneEvidence).checks().stream() + .anyMatch(c -> c.status() == NetworkDoctor.Status.FAIL)); + + BackendServerIdentity relation = new BackendServerIdentity("backend-a", "backend-a", true, true, 1); + NodeStatus proxy = node("proxy", "BUNGEE", true, List.of(relation)); + NodeStatus backend = node("backend-a", "BUKKIT", true, List.of()); + ObjectNode p = proxyEvidence().put("votifierProviderPresent", true).put("votifierListenerInitialized", true) + .put("votifierForwardingKnown", true).put("databaseInitialized", true).put("jdbcDriverAvailable", true) + .put("proxyMode", true).put("topologyComplete", true); + p.putArray("forwardingDestinations"); + ObjectNode b = base().put("proxyMode", true).put("configurationHealthy", true) + .put("configuredMethod", "PLUGINMESSAGING").put("activeMethod", "PLUGINMESSAGING") + .put("transportInitialized", true).put("pluginMessageChannel", "VotingPlugin") + .put("encryption", false).put("votifierProviderPresent", true).put("triggerVotifierEvent", true) + .put("databaseInitialized", true).put("jdbcDriverAvailable", true).put("processRewards", true) + .put("voteSitesComplete", true).put("rewardsComplete", true); + NetworkDoctor.Report report = NetworkDoctor.evaluate(List.of(proxy, backend), Map.of("proxy", p, "backend-a", b), Instant.EPOCH, false); + assertFalse(report.checks().stream().anyMatch(c -> c.status() == NetworkDoctor.Status.FAIL)); + } + + @Test + void duplicateCaseAndDashUnderscoreBackendIdentitiesFail() { + BackendServerIdentity a = new BackendServerIdentity("a", "a", true, true, 0); + BackendServerIdentity b = new BackendServerIdentity("b", "b", true, true, 0); + NodeStatus proxy = node("proxy", "BUNGEE", true, List.of(a, b)); + ObjectNode p = proxyEvidence().put("topologyComplete", true); + ObjectNode ea = base().put("proxyMode", true).put("serverName", "Server-A"); + ObjectNode eb = base().put("proxyMode", true).put("serverName", "server_a"); + NetworkDoctor.Report report = NetworkDoctor.evaluate(List.of(proxy, node("a", "BUKKIT", true, List.of()), node("b", "BUKKIT", true, List.of())), + Map.of("proxy", p, "a", ea, "b", eb), Instant.EPOCH, false); + assertTrue(report.checks().stream().anyMatch(c -> c.id().equals("backend.server-name.normalization-collision") + || c.id().equals("backend.server-name.duplicate"))); + } + + @Test + void boundedHundredNodeReportDoesNotGrowWithoutLimit() { + List nodes = new java.util.ArrayList<>(); + for (int i = 0; i < 100; i++) nodes.add(node("n" + i, "BUKKIT", false, List.of())); + NetworkDoctor.Report report = NetworkDoctor.evaluate(nodes, Map.of(), Instant.EPOCH, false); + assertTrue(report.checks().size() <= 1000); + } + + @Test + void healthyProxyWithTwoBackendsHasNoFailChecks() { + BackendServerIdentity a = new BackendServerIdentity("a", "a", true, true, 1); + BackendServerIdentity b = new BackendServerIdentity("b", "b", true, true, 1); + NodeStatus proxy = node("proxy2", "BUNGEECORD", true, List.of(a, b)); + NodeStatus na = node("a", "BUKKIT", true, List.of()); + NodeStatus nb = node("b", "BUKKIT", true, List.of()); + ObjectNode p = proxyEvidence().put("votifierProviderPresent", true).put("votifierListenerInitialized", true) + .put("votifierForwardingKnown", true).put("topologyComplete", true).put("databaseInitialized", true) + .put("jdbcDriverAvailable", true); + p.putArray("forwardingDestinations"); + ObjectNode ea = backendHealthy("a"); + ObjectNode eb = backendHealthy("b"); + NetworkDoctor.Report report = NetworkDoctor.evaluate(List.of(proxy, na, nb), Map.of("proxy2", p, "a", ea, "b", eb), Instant.EPOCH, false); + assertFalse(report.checks().stream().anyMatch(c -> c.status() == NetworkDoctor.Status.FAIL)); + } + + @Test + void reportedBackendWithProxyModeDisabledFails() { + BackendServerIdentity relation = new BackendServerIdentity("backend", "backend", true, true, 0); + NodeStatus proxy = node("proxy", "BUNGEECORD", true, List.of(relation)); + ObjectNode p = proxyEvidence().put("topologyComplete", true); + ObjectNode b = base().put("proxyMode", false).put("serverName", "backend"); + NetworkDoctor.Report report = NetworkDoctor.evaluate(List.of(proxy, node("backend", "BUKKIT", true, List.of())), Map.of("proxy", p, "backend", b), Instant.EPOCH, false); + assertEquals(NetworkDoctor.Status.FAIL, status(report, "backend.proxy-mode.disabled")); + } + + @Test + void noCarrierPluginMessagingIsUnknownInsteadOfFail() { + NodeStatus proxy = node("proxy", "BUNGEECORD", true, List.of()); + ObjectNode p = proxyEvidence().put("carrierPlayers", 0).put("transportProbeState", "WAITING"); + NetworkDoctor.Report report = evaluate(List.of(proxy), p); + assertEquals(NetworkDoctor.Status.UNKNOWN, status(report, "transport.communication")); + } + + @Test + void requiredSharedAuthMismatchedFingerprintsFails() { + BackendServerIdentity relation = new BackendServerIdentity("backend", "backend", true, true, 0); + NodeStatus proxy = node("proxy", "BUNGEECORD", true, List.of(relation)); + ObjectNode p = proxyEvidence().put("configuredMethod", "REDIS").put("activeMethod", "REDIS") + .put("sharedAuthentication", "REQUIRED").put("sharedKeyFingerprint", "a".repeat(64)); + ObjectNode b = base().put("proxyMode", true).put("configuredMethod", "REDIS").put("activeMethod", "REDIS") + .put("sharedAuthentication", "REQUIRED").put("sharedKeyFingerprint", "b".repeat(64)); + NetworkDoctor.Report report = NetworkDoctor.evaluate(List.of(proxy, node("backend", "BUKKIT", true, List.of())), Map.of("proxy", p, "backend", b), Instant.EPOCH, false); + assertEquals(NetworkDoctor.Status.FAIL, status(report, "transport.authentication.key-mismatch")); + } + + @Test + void missingJdbcDriverFails() { + NodeStatus backend = node("db", "BUKKIT", true, List.of()); + ObjectNode e = base().put("proxyMode", false).put("jdbcDriverAvailable", false).put("databaseInitialized", true); + assertEquals(NetworkDoctor.Status.FAIL, status(evaluate(List.of(backend), e), "storage.jdbc-driver.missing")); + } + + @Test + void enabledVoteLogWithoutReadableLogFails() { + NodeStatus backend = node("log", "BUKKIT", true, List.of()); + ObjectNode e = base().put("proxyMode", false).put("voteLoggingEnabled", true).put("voteLogReadable", false); + assertEquals(NetworkDoctor.Status.FAIL, status(evaluate(List.of(backend), e), "storage.votelog.unreadable")); + } + + @Test + void duplicateAndMissingVoteSiteServiceIdsAreReported() { + NodeStatus backend = node("sites", "BUKKIT", true, List.of()); + ObjectNode e = base().put("proxyMode", false).put("voteSitesComplete", true); + e.putArray("voteSites").addObject().put("name", "one").put("serviceSite", "Service") + .put("enabled", true).put("delayValid", true).put("voteUrlState", "VALID"); + e.withArray("voteSites").addObject().put("name", "two").put("serviceSite", "Service") + .put("enabled", true).put("delayValid", true).put("voteUrlState", "VALID"); + NetworkDoctor.Report report = evaluate(List.of(backend), e); + assertEquals(NetworkDoctor.Status.FAIL, status(report, "votesite.service-site.duplicate")); + } + + @Test + void autoCreateVoteSitesIsWarning() { + NodeStatus backend = node("auto", "BUKKIT", true, List.of()); + ObjectNode e = base().put("proxyMode", false).put("voteSitesComplete", true).put("autoCreateVoteSites", true); + e.putArray("voteSites").addObject().put("name", "site").put("serviceSite", "svc") + .put("enabled", true).put("delayValid", true).put("voteUrlState", "VALID"); + assertEquals(NetworkDoctor.Status.WARNING, status(evaluate(List.of(backend), e), "votesite.auto-create.enabled")); + } + + @Test + void unknownWhitelistedServerFailsWithCompleteTopology() { + NodeStatus proxy = node("whitelist", "BUNGEECORD", true, List.of()); + ObjectNode e = proxyEvidence().put("topologyComplete", true); + e.putArray("blockedServers"); + e.putArray("whitelistedServers").add("missing"); + assertEquals(NetworkDoctor.Status.FAIL, status(evaluate(List.of(proxy), e), "routing.unknown-server")); + } + + @Test + void duplicateMultiProxyPrimaryFails() { + NodeStatus a = node("pa", "BUNGEECORD", true, List.of()); + NodeStatus b = node("pb", "BUNGEECORD", true, List.of()); + ObjectNode ea = proxyEvidence().put("multiProxySupport", true).put("primaryServer", true).put("proxyServerName", "pa").put("topologyComplete", true); + ea.putArray("proxyServers").add("pb"); + ObjectNode eb = proxyEvidence().put("multiProxySupport", true).put("primaryServer", true).put("proxyServerName", "pb").put("topologyComplete", true); + eb.putArray("proxyServers").add("pa"); + NetworkDoctor.Report report = NetworkDoctor.evaluate(List.of(a, b), Map.of("pa", ea, "pb", eb), Instant.EPOCH, false); + assertEquals(NetworkDoctor.Status.FAIL, status(report, "multiproxy.primary.duplicate")); + } + + @Test + void backendIdentityAndPerServerChecksCatchPlaceholders() { + NodeStatus backend = node("backend-a", "BUKKIT", true, List.of()); + ObjectNode evidence = base().put("proxyMode", true).put("configurationHealthy", true) + .put("serverName", "PleaseSet").put("perServerRewards", true).put("processRewards", false) + .put("voteSitesComplete", true).put("rewardsComplete", true); + NetworkDoctor.Report report = evaluate(List.of(backend), evidence); + assertEquals(NetworkDoctor.Status.FAIL, status(report, "backend.server-name.unset")); + assertEquals(NetworkDoctor.Status.WARNING, status(report, "rewards.processing.disabled")); + assertEquals(NetworkDoctor.Status.FAIL, status(report, "rewards.per-server.identity")); + } + + @Test + void proxyBackendCatchesMethodChannelEncryptionAndRoutingErrors() { + BackendServerIdentity relation = new BackendServerIdentity("backend-a", "backend-a", true, true, 1); + NodeStatus proxy = node("proxy", "BUNGEE", true, List.of(relation)); + NodeStatus backend = node("backend-a", "BUKKIT", true, List.of()); + ObjectNode p = base().put("configuredMethod", "PLUGINMESSAGING").put("activeMethod", "PLUGINMESSAGING") + .put("transportInitialized", true).put("bungeeManageTotals", true).put("topologyComplete", true) + .put("pluginMessageChannel", "A").put("encryption", true).put("proxyMode", true); + p.putArray("blockedServers").add("unknown"); + ObjectNode b = base().put("configuredMethod", "HTTP").put("activeMethod", "HTTP") + .put("transportInitialized", true).put("pluginMessageChannel", "B").put("encryption", false) + .put("proxyMode", true).put("serverName", "backend-a").put("configurationHealthy", true); + NetworkDoctor.Report report = NetworkDoctor.evaluate(List.of(proxy, backend), Map.of("proxy", p, "backend-a", b), Instant.EPOCH, false); + assertEquals(NetworkDoctor.Status.FAIL, status(report, "proxy.method.mismatch")); + assertEquals(NetworkDoctor.Status.FAIL, status(report, "transport.pluginmessaging.channel-mismatch")); + assertEquals(NetworkDoctor.Status.FAIL, status(report, "transport.encryption.mismatch")); + assertEquals(NetworkDoctor.Status.FAIL, status(report, "routing.unknown-server")); + } + + @Test + void routingOverlapAndNoEligibleBackendAreDistinctWarningsAndFailures() { + BackendServerIdentity relation = new BackendServerIdentity("backend-a", "backend-a", true, true, 0); + NodeStatus proxy = node("proxy", "VELOCITY", true, List.of(relation)); + ObjectNode p = base().put("configuredMethod", "HTTP").put("activeMethod", "HTTP") + .put("transportInitialized", true).put("bungeeManageTotals", true).put("topologyComplete", true); + p.putArray("blockedServers").add("backend-a"); + p.putArray("whitelistedServers").add("backend-a"); + NetworkDoctor.Report report = evaluate(List.of(proxy), p); + assertEquals(NetworkDoctor.Status.WARNING, status(report, "routing.block-whitelist.overlap")); + assertEquals(NetworkDoctor.Status.FAIL, status(report, "routing.no-eligible-backends")); + } + + @Test + void voteSitesRewardsVotePartyAndMultiProxyFailuresAreSpecific() { + NodeStatus proxy = node("proxy", "BUNGEE", true, List.of()); + ObjectNode p = base().put("configuredMethod", "HTTP").put("activeMethod", "HTTP") + .put("transportInitialized", true).put("bungeeManageTotals", true).put("topologyComplete", true) + .put("multiProxySupport", true).put("proxyServerName", "proxy") + .put("multiProxyOneGlobalReward", true).put("sendVotesToAllServers", true) + .put("votePartyEnabled", true).put("votePartyVotesRequired", 0); + p.putArray("proxyServers"); + NetworkDoctor.Report report = evaluate(List.of(proxy), p); + assertEquals(NetworkDoctor.Status.FAIL, status(report, "multiproxy.identity.unset")); + assertEquals(NetworkDoctor.Status.FAIL, status(report, "multiproxy.global-reward.send-all")); + assertEquals(NetworkDoctor.Status.FAIL, status(report, "voteparty.votes-required.invalid")); + } + + @Test + void runtimeDriftAndTruncationAreVisible() { + NodeStatus proxy = node("proxy", "BUNGEE", true, List.of()); + ObjectNode p = base().put("configuredMethod", "HTTP").put("activeMethod", "PLUGINMESSAGING") + .put("transportInitialized", true).put("bungeeManageTotals", true); + NetworkDoctor.Report report = evaluate(List.of(proxy), p, true); + assertEquals(NetworkDoctor.Status.WARNING, status(report, "transport.runtime.drift")); + assertTrue(report.truncated()); + assertEquals(NetworkDoctor.Status.UNKNOWN, status(report, "control.evidence.truncated")); + } + + @Test void mqttProxyClientIdentityAndBackendGlobalPrefixAreComparedWithinNetwork() { + NodeStatus proxy = node("proxy", "VELOCITY", true, List.of( + new BackendServerIdentity("a", "a", true, true, 1), + new BackendServerIdentity("b", "b", true, true, 1))); + ObjectNode p = proxyEvidence().put("configuredMethod", "MQTT").put("mqttClientId", "same"); + ObjectNode a = backendHealthy("a").put("configuredMethod", "MQTT").put("mqttClientId", "same") + .put("globalDataEnabled", true).put("globalDataPrefix", "one"); + ObjectNode b = backendHealthy("b").put("configuredMethod", "MQTT").put("mqttClientId", "other") + .put("globalDataEnabled", true).put("globalDataPrefix", "two"); + var report = NetworkDoctor.evaluate(List.of(proxy, node("a", "BUKKIT", true, List.of()), node("b", "BUKKIT", true, List.of())), + Map.of("proxy", p, "a", a, "b", b), Instant.EPOCH, false); + assertTrue(report.checks().stream().anyMatch(c -> c.id().equals("transport.mqtt.client-id.duplicate") && c.status() == NetworkDoctor.Status.FAIL)); + assertTrue(report.checks().stream().anyMatch(c -> c.id().equals("storage.consistency.globalDataPrefix") && c.status() == NetworkDoctor.Status.WARNING)); + } + + @Test void separateCacheDatabaseDoesNotBorrowMainPoolReadiness() { + NodeStatus proxy = node("proxy", "VELOCITY", true, List.of()); + ObjectNode p = proxyEvidence().put("databaseInitialized", true).put("voteCacheMysql", true).put("voteCacheMainMysql", false); + assertEquals(NetworkDoctor.Status.UNKNOWN, status(evaluate(List.of(proxy), p), "storage.voteCacheMysql.unavailable")); + p.put("voteCacheDatabaseInitialized", false); + assertEquals(NetworkDoctor.Status.FAIL, status(evaluate(List.of(proxy), p), "storage.voteCacheMysql.unavailable")); + p.put("voteCacheMainMysql", true); + assertEquals(NetworkDoctor.Status.PASS, status(evaluate(List.of(proxy), p), "storage.voteCacheMysql.unavailable")); + } + + private static NetworkDoctor.Report evaluate(List nodes, ObjectNode evidence) { + return NetworkDoctor.evaluate(nodes, Map.of(nodes.get(0).nodeId(), evidence), Instant.EPOCH, false); + } + + private static NetworkDoctor.Report evaluate(List nodes, ObjectNode evidence, boolean truncated) { + return NetworkDoctor.evaluate(nodes, Map.of(nodes.get(0).nodeId(), evidence), Instant.EPOCH, truncated); + } + + private static ObjectNode base() { + return JSON.createObjectNode().put("schemaVersion", 1).put("role", "BACKEND"); + } + + private static ObjectNode proxyEvidence() { + return base().put("proxyMode", true).put("configurationHealthy", true) + .put("configuredMethod", "PLUGINMESSAGING").put("activeMethod", "PLUGINMESSAGING") + .put("transportInitialized", true).put("bungeeManageTotals", true) + .put("topologyComplete", true).put("pluginMessageChannel", "VotingPlugin") + .put("encryption", false).put("carrierPlayers", 1) + .put("transportProbeState", "SUCCESS"); + } + + private static ObjectNode backendHealthy(String name) { + return base().put("proxyMode", true).put("serverName", name).put("configurationHealthy", true) + .put("configuredMethod", "PLUGINMESSAGING").put("activeMethod", "PLUGINMESSAGING") + .put("transportInitialized", true).put("pluginMessageChannel", "VotingPlugin") + .put("encryption", false).put("votifierProviderPresent", true).put("triggerVotifierEvent", true) + .put("databaseInitialized", true).put("jdbcDriverAvailable", true).put("processRewards", true) + .put("voteSitesComplete", true).put("rewardsComplete", true); + } + + private static NodeStatus node(String id, String platform, boolean online, List backends) { + return new NodeStatus(id, UUID.nameUUIDFromBytes(id.getBytes()), id, platform, "test", 1, + Set.of(NetworkHealthEvidence.CAPABILITY), Set.of(NetworkHealthEvidence.CAPABILITY), backends, + 1, Instant.EPOCH, Instant.EPOCH, online); + } + + private static NetworkDoctor.Status status(NetworkDoctor.Report report, String id) { + return report.checks().stream().filter(check -> check.id().equals(id)).findFirst() + .orElseThrow(() -> new AssertionError("missing check " + id)).status(); + } +} diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/NetworkHealthEvidenceTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/NetworkHealthEvidenceTest.java new file mode 100644 index 0000000..91ca639 --- /dev/null +++ b/src/test/java/com/bencodez/votingplugin/control/domain/NetworkHealthEvidenceTest.java @@ -0,0 +1,70 @@ +package com.bencodez.votingplugin.control.domain; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.Test; + +class NetworkHealthEvidenceTest { + private static final ObjectMapper JSON = new ObjectMapper(); + + @Test + void acceptsMinimalRoleAndKnownTypedFields() { + ObjectNode value = JSON.createObjectNode().put("schemaVersion", 1).put("role", "BACKEND") + .put("serverName", "backend-a").put("triggerVotifierEvent", true); + value.putArray("detectedPlugins").add("VotifierPlus"); + assertTrue(NetworkHealthEvidence.valid(value)); + } + + @Test + void rejectsUnknownFieldsSecretsWrongTypesAndEnums() { + ObjectNode unknown = base().put("password", "secret"); + assertFalse(NetworkHealthEvidence.valid(unknown)); + assertFalse(NetworkHealthEvidence.valid(base().put("onlineMode", "true"))); + assertFalse(NetworkHealthEvidence.valid(base().put("configuredMethod", "TCP"))); + assertFalse(NetworkHealthEvidence.valid(base().put("sharedKeyFingerprint", "secret-key"))); + } + + @Test + void enforcesArrayStringAndFingerprintBounds() { + ObjectNode tooLong = base(); + tooLong.putArray("detectedPlugins").add("x".repeat(81)); + assertFalse(NetworkHealthEvidence.valid(tooLong)); + + ObjectNode duplicate = base(); + duplicate.putArray("blockedServers").add("backend-a").add("backend-a"); + assertFalse(NetworkHealthEvidence.valid(duplicate)); + + ObjectNode tooMany = base(); + for (int i = 0; i < 101; i++) tooMany.withArray("proxyServers").add("proxy-" + i); + assertFalse(NetworkHealthEvidence.valid(tooMany)); + + assertFalse(NetworkHealthEvidence.valid(base().put("storageFingerprint", "z".repeat(64)))); + assertTrue(NetworkHealthEvidence.valid(base().put("storageFingerprint", "a".repeat(64)) + .put("timeHourOffset", 1_000_000_000))); + assertFalse(NetworkHealthEvidence.valid(base().put("timeHourOffset", 1_000_000_001))); + } + + @Test + void validatesVoteSiteObjectsAndBounds() { + ObjectNode valid = base(); + valid.putArray("voteSites").addObject().put("name", "Site A").put("serviceSite", "service") + .put("voteUrlState", "BLANK").put("enabled", true).put("delayValid", true) + .put("delayHours", 24).put("autoCreated", false).put("hasRewards", true); + assertTrue(NetworkHealthEvidence.valid(valid)); + + ObjectNode badDelay = base(); + badDelay.putArray("voteSites").addObject().put("name", "Site A").put("delayHours", -1); + assertFalse(NetworkHealthEvidence.valid(badDelay)); + ObjectNode duplicateNames = base(); + duplicateNames.putArray("voteSites").addObject().put("name", "Site A"); + duplicateNames.withArray("voteSites").addObject().put("name", "Site A"); + assertFalse(NetworkHealthEvidence.valid(duplicateNames)); + } + + private static ObjectNode base() { + return JSON.createObjectNode().put("schemaVersion", 1).put("role", "BACKEND"); + } +} diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/NetworkHealthInspectionTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/NetworkHealthInspectionTest.java new file mode 100644 index 0000000..adf9b9a --- /dev/null +++ b/src/test/java/com/bencodez/votingplugin/control/domain/NetworkHealthInspectionTest.java @@ -0,0 +1,71 @@ +package com.bencodez.votingplugin.control.domain; +import com.bencodez.votingplugin.control.protocol.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.time.*; +import java.util.*; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +class NetworkHealthInspectionTest { + private final Clock clock = Clock.fixed(Instant.parse("2026-10-04T00:00:00Z"), ZoneOffset.UTC); + private final InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofHours(1)); + private final UUID session = UUID.randomUUID(); + private final InspectionOperations ops = new InspectionOperations(registry, clock); + private void register(UUID id, Set caps) { + registry.register(new NodeRegistration("backend-a", id, "a", "BUKKIT", "1", 1, caps, Set.of())); + } + private ObjectNode envelope() { + ObjectNode data = new ObjectMapper().createObjectNode(); + data.put("schemaVersion", 1).put("kind", "network-health").put("generatedAt", clock.instant().toString()); + data.putObject("result").put("schemaVersion", 1).put("role", "BACKEND").put("proxyMode", false); + return data; + } + private Set caps() { return Set.of(InspectionQuery.CAPABILITY, NetworkHealthEvidence.CAPABILITY); } + @Test void oldPeerCannotReceiveNewKindAndFiltersAreRejected() { + register(session, Set.of(InspectionQuery.CAPABILITY)); + assertThrows(ValidationException.class, () -> ops.create("backend-a", new InspectionQuery("network-health", Map.of()))); + assertThrows(IllegalArgumentException.class, () -> new InspectionQuery("network-health", Map.of("path", "secret"))); + } + @Test void validatesEvidenceAndReconnectionInvalidatesOldCompletion() { + register(session, caps()); + var created = ops.create("backend-a", new InspectionQuery("network-health", Map.of())); + var task = ops.claim("backend-a", session); + var data = envelope(); data.withObject("result").put("password", "must-not-leave-peer"); + assertThrows(ValidationException.class, () -> ops.complete(created.inspectionId(), "backend-a", new InspectionTaskResult(session, true, "OK", "ok", data, task.attemptId()))); + ops.complete(created.inspectionId(), "backend-a", new InspectionTaskResult(session, true, "OK", "ok", envelope(), task.attemptId())); + assertEquals(1, ops.networkEvidence(registry.list(0, 100)).size()); + register(UUID.randomUUID(), caps()); + assertTrue(ops.networkEvidence(registry.list(0, 100)).isEmpty()); + } + @Test void capabilityLossCancelsClaimAndDoesNotAcceptLateResult() { + register(session, caps()); + var created = ops.create("backend-a", new InspectionQuery("network-health", Map.of())); + register(session, Set.of(InspectionQuery.CAPABILITY)); + assertNull(ops.claim("backend-a", session)); + assertEquals("FAILED", ops.get(created.inspectionId()).state()); + } + @Test void latestFailedObservationInvalidatesEarlierSuccess() { + register(session, caps()); + var created = ops.create("backend-a", new InspectionQuery("network-health", Map.of())); + var task = ops.claim("backend-a", session); + ops.complete(created.inspectionId(), "backend-a", new InspectionTaskResult(session, true, "OK", "ok", envelope(), task.attemptId())); + var next = ops.create("backend-a", new InspectionQuery("network-health", Map.of())); + var nextTask = ops.claim("backend-a", session); + ops.complete(next.inspectionId(), "backend-a", new InspectionTaskResult(session, false, "UNAVAILABLE", "unavailable", null, nextTask.attemptId())); + assertTrue(ops.networkEvidence(registry.list(0, 100)).isEmpty()); + } + @Test void pendingOrRejectedNewObservationInvalidatesEarlierSuccess() { + register(session, caps()); + var first = ops.create("backend-a", new InspectionQuery("network-health", Map.of())); + var claim = ops.claim("backend-a", session); + ops.complete(first.inspectionId(), "backend-a", new InspectionTaskResult(session, true, "OK", "ok", envelope(), claim.attemptId())); + assertEquals(1, ops.networkEvidence(registry.list(0, 100)).size()); + var next = ops.create("backend-a", new InspectionQuery("network-health", Map.of())); + assertTrue(ops.networkEvidence(registry.list(0, 100)).isEmpty()); + var nextClaim = ops.claim("backend-a", session); + var wrongRole = envelope(); wrongRole.withObject("result").put("role", "PROXY"); + assertThrows(ValidationException.class, () -> ops.complete(next.inspectionId(), "backend-a", new InspectionTaskResult(session, true, "OK", "ok", wrongRole, nextClaim.attemptId()))); + assertTrue(ops.networkEvidence(registry.list(0, 100)).isEmpty()); + } + +} diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index f92bbee..83cd466 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -879,7 +879,7 @@ private static void terminateProcess(Process process) { assertTrue(script.body().contains("const identity = name.value.toLowerCase();")); assertTrue(script.body().contains("if (identities.has(identity)) return null;")); assertTrue(script.body().contains("function invalidateDashboardInspection()")); - assertTrue(script.body().contains("lastOverview = diagnostics.result;\n invalidateDashboardInspection();")); + assertTrue(script.body().contains("lastDiagnostics = NetworkDoctorView.withConfigurationChecks(report, configurationHealthChecks());")); assertTrue(script.body().contains("lastOverview = envelope.result;\n invalidateDashboardInspection();"), "Setup diagnostics must invalidate any cached dashboard evidence."); assertTrue(script.body().contains("function invalidVoteLoggingState(value)")); @@ -998,8 +998,10 @@ private static void terminateProcess(Process process) { "['FAILED', 'COMPLETED_WITH_ERRORS'].includes(operation.state)).slice(0, 5)"), "All bounded failed operations must contribute to the dashboard issue total."); assertTrue(script.body().contains("runDriftCheck.addEventListener('click', async () => {\n setConfigView('compare');")); - assertTrue(script.body().contains("voteSitesConfigured: configuredVoteSites == null ? null : configuredVoteSites > 0")); - assertTrue(script.body().contains("voteSitesConfiguredKnown: configuredVoteSites != null")); + assertTrue(script.body().contains("NetworkDoctorView.withConfigurationChecks")); + HttpResponse doctorView = get("/network-doctor.js", null); + assertEquals(200, doctorView.statusCode()); + assertTrue(doctorView.body().contains("UNKNOWN means unverified")); int exactShortcut = script.body().indexOf("const exactShortcut = GLOBAL_PAGE_SHORTCUTS.get(normalized);"); int fuzzySetting = script.body().indexOf("const setting = GENERAL_SETTING_FIELDS.find"); assertTrue(exactShortcut >= 0 && exactShortcut < fuzzySetting); @@ -1307,6 +1309,32 @@ private static void terminateProcess(Process process) { assertEquals(applyId, applyTask.get("operationId").asText()); } + @Test void networkDoctorRequiresAdminAndRetainsOnlyValidatedEvidence() throws Exception { + assertEquals(401, get("/api/v1/network-doctor", null).statusCode()); + assertEquals(401, get("/api/v1/network-doctor", nodeToken).statusCode()); + assertEquals(405, send("POST", "/api/v1/network-doctor", "{}", adminToken).statusCode()); + String capable = registration().replace("\"presence.snapshot\"]", + "\"presence.snapshot\",\"data.inspect.v1\",\"data.network-health.v1\"]"); + assertEquals(201, send("POST", "/api/v1/nodes/register", capable, nodeToken).statusCode()); + JsonNode queued = json.readTree(send("POST", "/api/v1/inspections", + "{\"nodeId\":\"proxy-a\",\"query\":{\"kind\":\"network-health\",\"filters\":{}}}", adminToken).body()); + JsonNode task = json.readTree(send("POST", "/api/v1/nodes/proxy-a/inspections", + "{\"sessionId\":\"" + SESSION + "\"}", nodeToken).body()); + var result = json.createObjectNode().put("sessionId", SESSION).put("success", true).put("message", "current"); + result.put("attemptId", task.path("attemptId").asText()); + var data = result.putObject("data").put("schemaVersion", 1).put("kind", "network-health") + .put("generatedAt", java.time.Instant.now().toString()); + data.putObject("result").put("schemaVersion", 1).put("role", "PROXY").put("votifierProviderPresent", false); + String path = "/api/v1/nodes/proxy-a/inspections/" + queued.path("inspectionId").asText() + "/result"; + assertEquals(200, send("POST", path, result.toString(), nodeToken).statusCode()); + JsonNode report = json.readTree(get("/api/v1/network-doctor", adminToken).body()); + assertTrue(report.path("checks").isArray()); + assertTrue(java.util.stream.StreamSupport.stream(report.path("checks").spliterator(), false) + .anyMatch(check -> "votifier.proxy.provider".equals(check.path("id").asText()) && "FAIL".equals(check.path("status").asText()))); + assertFalse(report.toString().contains("rawConfig")); + assertEquals(200, get("/network-doctor.js", null).statusCode()); + } + @Test void inspectionRetryAndSnapshotRoutesAreEndToEnd() throws Exception { String capableRegistration = registration().replace("\"presence.snapshot\"]", "\"presence.snapshot\",\"data.inspect.v1\",\"config.files.v1\"]") diff --git a/src/test/java/com/bencodez/votingplugin/control/protocol/InspectionQueryTest.java b/src/test/java/com/bencodez/votingplugin/control/protocol/InspectionQueryTest.java index 21d97ee..a4fe908 100644 --- a/src/test/java/com/bencodez/votingplugin/control/protocol/InspectionQueryTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/protocol/InspectionQueryTest.java @@ -12,9 +12,9 @@ class InspectionQueryTest { @Test void supportsOnlyTheBoundedReadOnlyInspectionCatalog() { for (String kind : InspectionQuery.KINDS) { InspectionQuery query = new InspectionQuery(kind, - "reward-file-inventory".equals(kind) ? Map.of() : Map.of("player", "Example")); + ("reward-file-inventory".equals(kind) || "network-health".equals(kind)) ? Map.of() : Map.of("player", "Example")); assertEquals(kind, query.kind()); - if (!query.requiresRewardFiles()) assertEquals("Example", query.filters().get("player")); + if (!query.requiresRewardFiles() && !query.requiresNetworkHealth()) assertEquals("Example", query.filters().get("player")); } assertEquals("data.inspect.v1", InspectionQuery.CAPABILITY); assertThrows(IllegalArgumentException.class, () -> new InspectionQuery("raw-sql", Map.of())); diff --git a/src/test/web/app-workspace.test.cjs b/src/test/web/app-workspace.test.cjs index 177267f..50e2dcd 100644 --- a/src/test/web/app-workspace.test.cjs +++ b/src/test/web/app-workspace.test.cjs @@ -538,10 +538,15 @@ test('Network Doctor reads configuration health and includes typed checks on its let reads = 0; Object.assign(context, {runNetworkDoctor: {addEventListener: (_event, handler) => {click = handler;}}, downloadNetworkDiagnostics: {}, lastDiagnostics: null, networkDoctorResults: {}, + inspectionInFlight: false, authenticationGeneration: 1, allNodeItems: [], updateExtendedButtons: () => {}, + AbortController, window: {setTimeout: () => 1, clearTimeout: () => {}}, + NetworkDoctorView: require('../../main/resources/web/network-doctor.js'), + authorized: async () => ({checks: []}), runInspection: async () => ({result: {configuredVoteSites: 1}}), lastOverview: null, invalidateDashboardInspection: () => {}, finiteCount: value => value, configurationHealthChecks: () => [{path: 'OnlineMode', status: 'WARNING'}], refreshConfigurationHealth: async () => {reads++;}, renderJsonResult: () => {}, updateSetupChecklist: () => {}}); + context.NetworkDoctorView = {...context.NetworkDoctorView, render: () => {}}; const start = appSource.indexOf("runNetworkDoctor.addEventListener('click'"); const end = appSource.indexOf("downloadNetworkDiagnostics.addEventListener", start); vm.runInContext(appSource.slice(start, end), context); diff --git a/src/test/web/network-doctor.test.cjs b/src/test/web/network-doctor.test.cjs new file mode 100644 index 0000000..d15c3ec --- /dev/null +++ b/src/test/web/network-doctor.test.cjs @@ -0,0 +1,19 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const {groups, render} = require('../../main/resources/web/network-doctor.js'); +test('groups stable statuses by severity and preserves UNKNOWN', () => { + const result = groups({checks: ['PASS', 'UNKNOWN', 'FAIL', 'INFO', 'WARNING'].map(status => ({status, category: 'Transport'}))}); + assert.deepEqual(result[0].checks.map(check => check.status), ['FAIL', 'WARNING', 'UNKNOWN', 'INFO', 'PASS']); +}); +test('untrusted explanations remain text and PASS/INFO stay collapsed', () => { + const create = tag => ({tag, children: [], append(...items) {this.children.push(...items);}, replaceChildren() {this.children=[];}}); + const container = create('div'); + render(container, {checks: [{category:'', status:'FAIL', title:'