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..fdbbca2 100644 --- a/docs/control-management.md +++ b/docs/control-management.md @@ -585,3 +585,12 @@ 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`. The report and node-page response +include an additive opaque `registrySnapshot` identifier. Configuration checks are included only when the collected first +page and evaluated report match; unsupported or changed snapshots produce `UNKNOWN`. 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..725d51f --- /dev/null +++ b/docs/network-doctor.md @@ -0,0 +1,113 @@ +# 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. The report also carries an opaque `registrySnapshot` fingerprint for the exact bounded node page used by +the evaluation. The WebUI compares it with the token from its first `/api/v1/nodes` page and discards configuration +evidence as UNKNOWN when they differ or either token is unavailable. The fingerprint covers only bounded public node +identity/session/platform/online/capability and backend identity metadata; heartbeat timestamps and player counts do not +invalidate it. 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 + and evidence that the routing list participates in the configured feature. Inactive broadcast/VoteParty examples do not fail. +- **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:** transport-specific 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. Offline proxy snapshots cannot force an online standalone backend into proxy-mode checks. +Socket multi-proxy peers come from `socketProxyServers`; Redis peers come from `proxyServers`. Redis authentication settings +are compared for Redis multi-proxy traffic, never borrowed from unrelated socket-connected backend networks. +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. VotingPlugin also observes existing NuVotifier proxy forwarding through a narrow read-only adapter for recognized +runtime class/field shapes; no NuVotifier changes are required. Active forwarding can be reported through the optional +Boolean `votifierForwardingEnabled`, independently of destination-name availability. Unsupported shapes, null runtime +handlers, and unavailable listener observations remain UNKNOWN. Older VotifierPlus and other providers lacking supported +diagnostics also remain 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..3a068df 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,43 @@ 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 snapshot : nodes) { + try { + // Keep evidence selection under the same per-node session fence as claim/completion. + registry.withSession(snapshot.nodeId(), snapshot.sessionId(), current -> { + collectNetworkEvidence(current, result); + return null; + }); + } catch (ValidationException changed) { + if (!Set.of("SESSION_MISMATCH", "NODE_NOT_FOUND").contains(changed.code())) throw changed; + // A page can outlive registration replacement; absence means UNKNOWN to the doctor. + } + } + return result; + } + + private void collectNetworkEvidence(NodeStatus node, Map result) { + if (!node.online() || !node.acceptedCapabilities().contains(NetworkHealthEvidence.CAPABILITY) + || !node.acceptedCapabilities().contains(InspectionQuery.CAPABILITY)) return; + 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()); + } + } + } + } + public synchronized InspectionView get(UUID id) { prune(); StoredInspection stored = inspections.get(id); @@ -113,6 +151,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 +207,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 +286,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..68abe6c --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/domain/NetworkDoctor.java @@ -0,0 +1,448 @@ +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, + String registrySnapshot) { + public Report(int schemaVersion, Instant generatedAt, List checks, boolean truncated) { + this(schemaVersion, generatedAt, checks, 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 -> p.online() && 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 ((Set.of("REDIS", "MQTT").contains(method == null ? "" : method) + || proxy(n) && yes(bool(n, "multiProxySupport")) && "REDIS".equals(str(n, "multiProxyMethod"))) + && "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"); + Boolean forwarding = bool(n, "votifierForwardingEnabled"); + if (yes(bool(n, "votifierForwardingKnown")) && destinations != null && !destinations.isEmpty()) forwarding = true; + else if (forwarding == null && yes(bool(n, "votifierForwardingKnown")) && destinations != null) forwarding = false; + if (forwarding == null) unknown(n, "votifier.forwarding.duplicate-path", "Votifier", "optional provider forwarding diagnostics"); + else if (!forwarding) emit(n, "votifier.forwarding.duplicate-path", "Votifier", Status.PASS, + "The provider reports no enabled forwarding source/destinations.", "provider forwarding diagnostics", "No action required."); + else if (managed) { + boolean proven = destinations != null && 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 Votifier forwarding destination matches an enrolled VotingPlugin backend with a Votifier provider: provider forwarding and VotingPlugin delivery can process the same vote twice." + : "Proxy Votifier forwarding is enabled alongside VotingPlugin proxy delivery, creating a second possible path. Destination names do not prove remote host identity.", + "votifierForwardingEnabled / forwardingDestinations / registry.backends", "Disable unintended Votifier provider forwarding. TriggerVotifierEvent does not make a second forwarding path 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", truncated ? Status.UNKNOWN : Status.WARNING, + truncated ? "A proxy-reported backend is outside this truncated Control view; enrollment cannot be verified." + : "A proxy-reported backend is not enrolled in Control.", "registry.backends", + truncated ? "Inspect the backend in the complete registry before changing enrollment." + : "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").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")) { + Boolean applicable = switch (field) { + case "broadcastServers" -> bool(p, "broadcastServersApplicable"); + case "offlineForwardServers" -> bool(p, "offlineForwardServersApplicable"); + case "votePartyServers" -> bool(p, "votePartyServersApplicable"); + default -> true; + }; + if (no(applicable)) continue; + if (applicable == null) { unknown(p, "routing." + field + ".unknown", "Routing", field + " applicability"); continue; } + 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; a nonempty whitelist takes precedence for normal vote routing.", "blockedServers / whitelistedServers", "Remove unintended overlapping rules."); + long eligible = known.stream().filter(s -> white.isEmpty() ? !blocked.contains(s) : 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 -> white.isEmpty() ? blocked.contains(s) : !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."); + String peersField = Set.of("SOCKET", "SOCKETS").contains(method == null ? "" : method) ? "socketProxyServers" : "REDIS".equals(method) ? "proxyServers" : null; + List declared = peersField == null ? null : list(p, peersField); + if (declared == null) { unknown(p, "multiproxy.relationships", "Multi-Proxy", "transport-specific proxy peers"); continue; } + List related = proxies.stream().filter(other -> other.nodeId().equals(p.nodeId()) || declared.contains(other.nodeId()) || declared.contains(str(other, "proxyServerName"))).toList(); + boolean complete = 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); + if ("REDIS".equals(method)) { + 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..247e8c0 --- /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", "votePartyServersApplicable", "broadcastServersApplicable", "offlineForwardServersApplicable", "configurationHealthy", "onlineMode", + "resetMilestonesMonthly", "monthDateTotals", "automaticTimeChanges", "encryption", "transportInitialized", + "restartRequired", "triggerVotifierEvent", "votifierProviderPresent", "votifierListenerInitialized", + "votifierForwardingKnown", "votifierForwardingEnabled", "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", "socketProxyServers", + "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/domain/RegistrySnapshot.java b/src/main/java/com/bencodez/votingplugin/control/domain/RegistrySnapshot.java new file mode 100644 index 0000000..d5a014b --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/domain/RegistrySnapshot.java @@ -0,0 +1,51 @@ +package com.bencodez.votingplugin.control.domain; + +import com.bencodez.votingplugin.control.protocol.BackendServerIdentity; +import com.bencodez.votingplugin.control.protocol.NodeStatus; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Comparator; +import java.util.HexFormat; + +/** Stable fingerprint of the bounded public registry page used by a read-only report. */ +public final class RegistrySnapshot { + private RegistrySnapshot() { } + + public static String sha256(NodeRegistry.RegistryPage page) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + field(digest, Long.toString(page.revision())); + field(digest, Integer.toString(page.total())); + field(digest, Integer.toString(page.items().size())); + page.items().stream().sorted(Comparator.comparing(NodeStatus::nodeId)).forEach(node -> { + field(digest, node.nodeId()); + field(digest, node.sessionId() == null ? "" : node.sessionId().toString()); + field(digest, node.platform()); + field(digest, Boolean.toString(node.online())); + field(digest, Integer.toString(node.acceptedCapabilities().size())); + node.acceptedCapabilities().stream().sorted().forEach(capability -> field(digest, capability)); + var backends = node.backends().stream().sorted(Comparator.comparing(BackendServerIdentity::backendId) + .thenComparing(BackendServerIdentity::displayName)).toList(); + field(digest, Integer.toString(backends.size())); + backends.forEach(backend -> { + field(digest, backend.backendId()); + field(digest, backend.displayName()); + }); + }); + return HexFormat.of().formatHex(digest.digest()); + } catch (NoSuchAlgorithmException impossible) { + throw new AssertionError(impossible); + } + } + + private static void field(MessageDigest digest, String value) { + String safe = value == null ? "" : value; + byte[] bytes = safe.getBytes(StandardCharsets.UTF_8); + byte[] length = Integer.toString(bytes.length).getBytes(StandardCharsets.US_ASCII); + digest.update(length); + digest.update((byte) ':'); + digest.update(bytes); + digest.update((byte) '|'); + } +} 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..e3b936f 100644 --- a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java +++ b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java @@ -6,6 +6,7 @@ import com.bencodez.votingplugin.control.artifact.JenkinsVotingPluginSource; import com.bencodez.votingplugin.control.artifact.ArtifactStore.ArtifactException; import com.bencodez.votingplugin.control.domain.NodeRegistry; +import com.bencodez.votingplugin.control.domain.RegistrySnapshot; import com.bencodez.votingplugin.control.domain.ConfigurationOperations; import com.bencodez.votingplugin.control.domain.ConfigurationSnapshots; import com.bencodez.votingplugin.control.domain.InspectionOperations; @@ -93,6 +94,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 +500,18 @@ 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()); + var report = com.bencodez.votingplugin.control.domain.NetworkDoctor.evaluate( + page.items(), evidence, Instant.now(), page.total() > page.items().size()); + send(exchange, 200, new com.bencodez.votingplugin.control.domain.NetworkDoctor.Report( + report.schemaVersion(), report.generatedAt(), report.checks(), report.truncated(), + RegistrySnapshot.sha256(page))); + return; + } if (NODES.equals(path)) { requireMethod(exchange, "GET"); authenticateAdmin(exchange, false); @@ -509,6 +523,7 @@ private void route(HttpExchange exchange) throws IOException { NodeRegistry.RegistryPage registryPage = registry.page(offset, limit, expectedRevision); BackendPage page = boundedNodePage(registryPage.items()); send(exchange, 200, Map.of("items", page.items(), "offset", offset, "limit", limit, + "registrySnapshot", RegistrySnapshot.sha256(registryPage), "registryRevision", registryPage.revision(), "total", registryPage.total(), "backendItemsReturned", page.backendItemsReturned(), "backendItemsTruncatedNodeIds", page.backendItemsTruncatedNodeIds(), 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.css b/src/main/resources/web/app.css index 05e0564..9c9e4f7 100644 --- a/src/main/resources/web/app.css +++ b/src/main/resources/web/app.css @@ -216,6 +216,7 @@ label span { color: var(--muted); font-weight: 400; } .method-buttons button.active { box-shadow: 0 0 0 3px rgba(42, 122, 226, .22); } #operation-status, #file-operation-status, #quick-operation-status, #transport-test-status, #proxy-method-status, #auto-sites-status, #vote-logging-status { min-height: 84px; margin: 18px 0 0; padding: 14px; overflow: auto; border: 1px solid var(--border); border-radius: 9px; background: #050c16; color: #c6d5e8; white-space: pre-wrap; } +#deployment-status { max-width: 100%; overflow: auto; overflow-wrap: anywhere; white-space: pre-wrap; } #file-operation-status { margin: 0; border: 0; border-top: 1px solid var(--border); border-radius: 0; } .form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; margin: 16px 0; } .form-grid label { color: var(--muted); } diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index fcff6d4..fd8b859 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -505,7 +505,21 @@ function renderJsonResult(element, value, emptyMessage = 'No data returned.') { function formatEpoch(value) { const epoch = Number(value); - return Number.isFinite(epoch) && epoch > 0 ? new Date(epoch).toLocaleString() : 'Unknown'; + return formatDateTime(Number.isFinite(epoch) && epoch > 0 ? (epoch < 1e12 ? epoch * 1000 : epoch) : null); +} + +function formatDateTime(value) { + if (value == null || value === '') return 'Unknown'; + const numeric = typeof value === 'number' || (typeof value === 'string' && /^\d+(?:\.\d+)?$/.test(value)) + ? Number(value) : NaN; + if (Number.isFinite(numeric)) { + if (numeric <= 0) return 'Unknown'; + const date = new Date(numeric < 1e12 ? numeric * 1000 : numeric); + return Number.isNaN(date.getTime()) ? 'Unknown' : date.toLocaleString(); + } + if (typeof value !== 'string') return 'Unknown'; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? 'Unknown' : date.toLocaleString(); } function plainObject(value) { @@ -885,7 +899,7 @@ function renderVoteTrace(trace) { const event = item.event; const eventRow = document.createElement('tr'); const time = Number(event.voteTime); - eventRow.append(text(document.createElement('td'), Number.isFinite(time) && time > 0 ? new Date(time).toLocaleString() : 'Unknown')); + eventRow.append(text(document.createElement('td'), formatEpoch(time))); eventRow.append(text(document.createElement('td'), event.event || 'Unknown')); eventRow.append(text(document.createElement('td'), event.status || 'Unknown')); eventRow.append(text(document.createElement('td'), event.playerName || event.playerUuid || 'Unknown')); @@ -1133,7 +1147,7 @@ function renderOperationHistory() { heading.className = 'section-title'; const identity = document.createElement('div'); identity.append(text(document.createElement('strong'), `Plugin deployment · ${deployment.state}`)); - identity.append(text(document.createElement('small'), `${deployment.deploymentId} · ${new Date(deployment.createdAt).toLocaleString()}`)); + identity.append(text(document.createElement('small'), `${deployment.deploymentId} · ${formatDateTime(deployment.createdAt)}`)); heading.append(identity); if ((deployment.nodes || []).some(node => node.state === 'FAILED')) { const retry = text(document.createElement('button'), 'Retry failed targets'); @@ -1569,14 +1583,14 @@ function nodeCard(node) { ? `Reported by ${proxies.map(proxy => proxy.displayName).join(', ')}.` : 'No connected proxy reports this backend ID.')); } - const seen = text(document.createElement('p'), `Last seen: ${node.lastSeen ? new Date(node.lastSeen).toLocaleString() : 'Unknown'} · ${nodePresence(node)}`); + const seen = text(document.createElement('p'), `Last seen: ${formatDateTime(node.lastSeen)} · ${nodePresence(node)}`); seen.className = 'node-detail'; article.append(header, meta, detail, seen, list, selector); if (!managedCapabilities(node).length) { article.append(text(document.createElement('p'), 'No supported management capability reported. Configuration tools are unavailable.')); } - if (isBackend(node)) { - const inspect = text(document.createElement('button'), 'Inspect server overview'); + if (isBackend(node) || isProxy(node)) { + const inspect = text(document.createElement('button'), isProxy(node) ? 'Manage proxy settings' : 'Inspect server overview'); inspect.type = 'button'; inspect.className = 'secondary compact'; inspect.addEventListener('click', () => inspectWorkspaceServer(node.nodeId)); @@ -1695,15 +1709,24 @@ function changeWorkspaceTargets(change) { return true; } +function workspaceTargetNeedsReplacement(id) { + const proxyTarget = workspace.selectedTargetIds.size === 1 + && isProxy(nodeIndex.get([...workspace.selectedTargetIds][0])); + return !workspace.managementScope || (isProxy(nodeIndex.get(id)) + ? workspace.selectedTargetIds.size !== 1 || !workspace.selectedTargetIds.has(id) : proxyTarget); +} + function inspectWorkspaceServer(id) { - if (!isBackend(nodeIndex.get(id))) return; + if (!isBackend(nodeIndex.get(id)) && !isProxy(nodeIndex.get(id))) return; if (workspace.managementScope === 'GLOBAL') { - if (!confirmDiscardUnsavedConfiguration('returning to the server workspace')) return; + if (!confirmDiscardWorkspaceDrafts('returning to the server workspace')) return; resetServerContextValues('Returning to server tools.'); workspace.returnToServers(); clearApprovals(); } - if (!workspace.managementScope) workspace.setTargets([id]); + if (workspaceTargetNeedsReplacement(id)) { + if (!changeWorkspaceTargets(() => workspace.setTargets([id]))) return; + } if (selectPrimaryServer(id) === false) return; workspace.inspect(id); setActiveTab('overview', true); @@ -1748,11 +1771,13 @@ function applyNavigationRoute() { return setActiveTab('home'); } if (route.inspectedServerId) { - if (!isBackend(nodeIndex.get(route.inspectedServerId))) { + const destination = nodeIndex.get(route.inspectedServerId); + if (!isBackend(destination) && !isProxy(destination)) { window.history.replaceState(null, '', '#home'); return setActiveTab('home'); } - if (!workspace.managementScope) workspace.setTargets([route.inspectedServerId]); + if (workspaceTargetNeedsReplacement(route.inspectedServerId) + && !changeWorkspaceTargets(() => workspace.setTargets([route.inspectedServerId]))) return cancelNavigation(); if (selectPrimaryServer(route.inspectedServerId) === false) return cancelNavigation(); workspace.inspect(route.inspectedServerId); } else if (route.page === 'overview') workspace.inspect(''); @@ -1814,7 +1839,7 @@ function renderScopeOverview() { text(document.querySelector('#metric-presence-detail'), 'Known only when a connected proxy reports it'); const recent = overviewOperations()[0]; text(document.querySelector('#metric-last-operation'), recent ? operationPhase(recent) : '—'); - text(document.querySelector('#metric-last-operation-detail'), recent ? `${operationLabel(recent)} · ${recent.createdAt ? new Date(recent.createdAt).toLocaleString() : 'Time unknown'}` : 'No retained operation for this scope'); + text(document.querySelector('#metric-last-operation-detail'), recent ? `${operationLabel(recent)} · ${formatDateTime(recent.createdAt)}` : 'No retained operation for this scope'); const health = document.querySelector('#overview-site-health'); health.replaceChildren(); const sites = dashboardLoadedContext === dashboardContext() ? dashboardVoteSiteHealth?.sites : null; @@ -2200,7 +2225,7 @@ function renderSelectedServer() { const relationshipText = relationships.length ? ` Reported by ${relationships.map(proxy => proxy.displayName).join(', ')}.` : ''; text(selectedServerSummary, - `${roleLabel(selected)} · ${platformLabel(selected.platform)} · VotingPlugin ${selected.pluginVersion || 'version unknown'}.${relationshipText} Last seen: ${selected.lastSeen ? new Date(selected.lastSeen).toLocaleString() : 'unknown'}. ${nodePresence(selected)}.`); + `${roleLabel(selected)} · ${platformLabel(selected.platform)} · VotingPlugin ${selected.pluginVersion || 'version unknown'}.${relationshipText} Last seen: ${formatDateTime(selected.lastSeen)}. ${nodePresence(selected)}.`); const capabilities = managedCapabilities(selected); if (capabilities.length === 0) capabilities.push('Discovery only'); capabilities.forEach(value => { @@ -2249,6 +2274,13 @@ function renderTopology() { proxyIdentity.append(text(document.createElement('strong'), proxy.displayName)); proxyIdentity.append(text(document.createElement('small'), `${platformLabel(proxy.platform)} proxy · Control ${proxy.online ? 'connected' : 'disconnected'}`)); + if (proxy.online) { + const manage = text(document.createElement('button'), 'Manage proxy settings'); + manage.type = 'button'; + manage.className = 'secondary compact'; + manage.addEventListener('click', () => inspectWorkspaceServer(proxy.nodeId)); + proxyIdentity.append(manage); + } const backendList = document.createElement('div'); backendList.className = 'topology-backends'; const backends = Array.isArray(proxy.backends) ? proxy.backends : []; @@ -2868,7 +2900,7 @@ function renderOverviewActivity() { const targetCount = Object.keys(operation.nodeStates || {}).length || results.length; const successful = results.filter(result => result?.success).length; item.append(text(document.createElement('strong'), operationLabel(operation))); - const when = operation.createdAt ? new Date(operation.createdAt).toLocaleString() : 'Time unavailable'; + const when = formatDateTime(operation.createdAt); item.append(text(document.createElement('small'), `${operationPhase(operation)} · ${successful}/${targetCount} targets successful · ${when}`)); overviewActivity.append(item); }); @@ -3198,7 +3230,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; @@ -4016,12 +4048,28 @@ function invalidateConfigurationReads() { updateExtendedButtons(); } -async function waitForOperation(operation, statusElement = operationStatus, context = operationContext()) { +async function waitForOperation(operation, statusElement = operationStatus, context = operationContext(), options = {}) { + const checkDeadline = () => { + if (options.signal?.aborted || options.deadlineAt && Date.now() >= options.deadlineAt + || options.contextCurrent && !options.contextCurrent()) throw new Error('Configuration health read ended; evidence is unavailable.'); + }; + checkDeadline(); if (operationContextCurrent(context)) text(statusElement, operationSummary(operation)); rememberOperation(operation); while (operation.state === 'RUNNING') { - await new Promise(resolve => window.setTimeout(resolve, 1500)); - operation = await authorized(`/api/v1/operations/${operation.operationId}`); + await new Promise((resolve, reject) => { + const finish = () => { options.signal?.removeEventListener('abort', abort); resolve(); }; + const timer = window.setTimeout(finish, 1500); + const abort = () => { + window.clearTimeout(timer); options.signal?.removeEventListener('abort', abort); + reject(new Error('Configuration health read ended; evidence is unavailable.')); + }; + options.signal?.addEventListener('abort', abort, {once: true}); + if (options.signal?.aborted) abort(); + }); + checkDeadline(); + operation = await authorized(`/api/v1/operations/${operation.operationId}`, {signal: options.signal}); + checkDeadline(); if (operationContextCurrent(context)) text(statusElement, operationSummary(operation)); rememberOperation(operation); } @@ -4044,7 +4092,7 @@ async function waitForOperation(operation, statusElement = operationStatus, cont return operation; } -async function startConfigurationOperation(path, body, statusElement = operationStatus) { +async function startConfigurationOperation(path, body, statusElement = operationStatus, options = {}) { // Legacy forms remain source-only; coordinated workflows keep explicit targets. if (path.endsWith('/preview') && path !== '/api/v1/configuration/general-settings/preview' && path !== '/api/v1/configuration/vote-sites/preview' @@ -4072,8 +4120,8 @@ async function startConfigurationOperation(path, body, statusElement = operation updateExtendedButtons(); try { return await waitForOperation(await authorized(path, { - method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) - }), statusElement, context); + method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body), signal: options.signal + }), statusElement, context, options); } finally { configurationOperationsInFlight--; updateConfigurationButtons(); @@ -4183,6 +4231,7 @@ async function loadAllNodes() { truncated ||= Boolean(page.backendItemsTruncated); (page.backendItemsTruncatedNodeIds || []).forEach(nodeId => truncatedNodeIds.add(nodeId)); pageMetadata.set(offset, { + registrySnapshot: page.registrySnapshot, backendItemsReturned: page.backendItemsReturned, backendItemsTruncated: Boolean(page.backendItemsTruncated) }); @@ -5432,41 +5481,52 @@ 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 registrySnapshot = nodePageMetadata.get(0)?.registrySnapshot; + const doctorNodes = allNodeItems.slice(0, 100); + const candidates = doctorNodes.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); + let configurationReader; + try { [configurationReader] = await Promise.all([refreshConfigurationHealth({nodeIds: new Set(doctorNodes.map(node => node.nodeId)), + contextCurrent, deadlineAt, signal: controller.signal}), ...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, configurationReader + && NetworkDoctorView.registrySnapshotMatches(registrySnapshot, report) + ? ControlGeneralSettings.healthChecks(configurationReader.model) : [{path: 'configuration', title: 'Configuration health is not verified', + status: 'UNKNOWN', nodeIds: [], message: 'Configuration evidence changed or was unavailable during this run.'}]); + 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', () => { @@ -5552,7 +5612,7 @@ async function loadSnapshots() { item.className = 'result-item'; const detail = document.createElement('div'); detail.append(text(document.createElement('strong'), snapshot.name)); - detail.append(text(document.createElement('small'), `${new Date(snapshot.createdAt).toLocaleString()} · ${snapshot.documents.length} document(s)`)); + detail.append(text(document.createElement('small'), `${formatDateTime(snapshot.createdAt)} · ${snapshot.documents.length} document(s)`)); const restore = text(document.createElement('button'), 'Load for restore preview'); restore.type = 'button'; restore.className = 'secondary compact'; @@ -6621,9 +6681,9 @@ function generalSettingsContext() { target.supported, target.managedByProxy, target.reportingProxyIds, target.networkIncomplete, target.networkOnly])]); } -function generalSettingsTargets(health = false) { +function generalSettingsTargets(health = false, seedIds) { if ((!health && workspace.managementScope === 'GLOBAL') || !authenticated) return []; - const ids = new Set(health && workspace.managementScope === 'GLOBAL' + const ids = new Set(seedIds !== undefined ? seedIds : health && workspace.managementScope === 'GLOBAL' ? allNodeItems.map(node => node.nodeId) : [...workspace.selectedTargetIds]); const directlyManagedIds = new Set(ids); const backendReporters = new Map(); @@ -6693,11 +6753,38 @@ function configurationHealthChecks() { } let settingsHealthContext = ''; -async function refreshConfigurationHealth() { +async function refreshConfigurationHealth(options = {}) { if (!authenticated || !settingsHealthReader) return; const captured = configurationHealthContext(); - await settingsHealthReader.read(false); - if (captured !== configurationHealthContext()) return; + // A doctor run owns a separate bounded reader, so an older dashboard flight cannot extend its deadline. + const reader = options.nodeIds ? createConfigurationHealthReader( + () => { + const allTargets = generalSettingsTargets(true, options.nodeIds); + // Retain topology-only placeholders in the model so missing unenrolled + // backends remain UNKNOWN, while only requested targets can schedule reads. + const requestedAll = allTargets.filter(target => options.nodeIds.has(target.id)); + const requested = requestedAll.slice(0, 100); + const omitted = new Set(requested.map(target => target.id)); + const related = new Set(requested.map(target => target.id)); + let expanded = true; + while (expanded) { + expanded = false; + allTargets.forEach(target => { + const links = [target.id, ...(target.reportingProxyIds || [])]; + if (!links.some(id => related.has(id))) return; + links.forEach(id => { if (!related.has(id)) { related.add(id); expanded = true; } }); + }); + } + const placeholders = allTargets.filter(target => (!omitted.has(target.id) + && (options.nodeIds.has(target.id) || related.has(target.id)))) + .map(target => ({...target, online: false, supported: false, networkIncomplete: true})); + return [...requested, ...placeholders]; + }, options) + : settingsHealthReader; + await reader.read(false); + if (captured !== configurationHealthContext() || options.contextCurrent && !options.contextCurrent()) return; + if (options.nodeIds) return reader; + if (reader !== settingsHealthReader) return; settingsHealthContext = captured; renderMetrics(); } @@ -6877,18 +6964,21 @@ settingsEditor = ControlGeneralSettings.create({ changed: renderGeneralSettings }); // Independent read-only state: health refreshes must not invalidate an editor draft/approval. -settingsHealthReader = ControlGeneralSettings.create({ - targets: () => generalSettingsTargets(true), - context: configurationHealthContext, - operation: (path, body) => { - if (path !== '/api/v1/configuration/read') throw new Error('Health checks are read-only'); - return startConfigurationOperation(path, body, document.createElement('span')); - }, - request: (path, body) => { - if (path !== '/api/v1/configuration/general-settings/state') throw new Error('Health checks are read-only'); - return authorized(path, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)}); - } -}); +function createConfigurationHealthReader(targets, options = {}) { + return ControlGeneralSettings.create({ + targets, + context: configurationHealthContext, + operation: (path, body) => { + if (path !== '/api/v1/configuration/read') throw new Error('Health checks are read-only'); + return startConfigurationOperation(path, body, document.createElement('span'), options); + }, + request: (path, body) => { + if (path !== '/api/v1/configuration/general-settings/state') throw new Error('Health checks are read-only'); + return authorized(path, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body), signal: options.signal}); + } + }); +} +settingsHealthReader = createConfigurationHealthReader(() => generalSettingsTargets(true)); document.querySelector('#general-settings-read').addEventListener('click', () => void settingsEditor.read(true)); document.querySelector('#general-settings-retry').addEventListener('click', () => void settingsEditor.read(true, true)); document.querySelector('#general-settings-preview').addEventListener('click', () => { document.querySelector('#general-settings-ack').checked = false; void settingsEditor.preview(); }); 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..89a2f03 --- /dev/null +++ b/src/main/resources/web/network-doctor.js @@ -0,0 +1,68 @@ +/* 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 registrySnapshotMatches(expected, report) { + return typeof expected === 'string' && /^[a-f0-9]{64}$/.test(expected) + && expected === report?.registrySnapshot; + } + function withConfigurationChecks(report, checks) { + const serverChecks = Array.isArray(report?.checks) ? report.checks : []; + const incomingConfigurationChecks = Array.isArray(checks) ? checks : []; + const serverLimit = 500; + const serverChecksKept = serverChecks.slice(0, serverLimit); + const configurationLimit = Math.min(100, Math.max(0, serverLimit - serverChecksKept.length)); + const configurationChecks = incomingConfigurationChecks.slice(0, configurationLimit); + const affectedNodesFor = check => [...new Set([...(check.nodeIds || []), ...(check.unknownNodeIds || [])])]; + const truncated = Boolean(report?.truncated) || serverChecks.length > serverChecksKept.length + || incomingConfigurationChecks.length > configurationChecks.length + || configurationChecks.some(check => affectedNodesFor(check).length > 100); + return {...report, truncated, configurationChecks, checks: [...serverChecksKept, ...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: affectedNodesFor(check).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.')); + if (report?.truncated) { + const notice = label('p', 'UNKNOWN: This report is incomplete. Some nodes or checks were omitted by the reporting limits; omitted evidence is not verified. Inspect a smaller workspace for the missing evidence.'); + notice.className = 'doctor-check'; + container.append(notice); + } + 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, registrySnapshotMatches}; + if (typeof module !== 'undefined' && module.exports) module.exports = api; + else root.NetworkDoctorView = api; +})(typeof globalThis !== 'undefined' ? globalThis : this); diff --git a/src/main/resources/web/workspace.js b/src/main/resources/web/workspace.js index 8667f60..7d824d4 100644 --- a/src/main/resources/web/workspace.js +++ b/src/main/resources/web/workspace.js @@ -241,7 +241,14 @@ } reconcile(nodes) { - const validIds = new Set((Array.isArray(nodes) ? nodes : []).filter(isBukkit).map(node => node.nodeId)); + const proxyCandidates = this.managementScope === 'GLOBAL' ? this.previousTargetIds : this.selectedTargetIds; + const proxyOnly = proxyCandidates.size === 1 + && [...proxyCandidates].some(id => (Array.isArray(nodes) ? nodes : []) + .some(node => node && node.nodeId === id + && ['VELOCITY', 'BUNGEECORD'].includes(String(node.platform || '').toUpperCase()))); + const validIds = new Set((Array.isArray(nodes) ? nodes : []) + .filter(node => isBukkit(node) || proxyOnly && node && node.nodeId === [...proxyCandidates][0]) + .map(node => node.nodeId)); const keep = ids => new Set([...ids].filter(id => validIds.has(id))); this.selectedTargetIds = keep(this.selectedTargetIds); this.previousTargetIds = keep(this.previousTargetIds); 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..185bb70 --- /dev/null +++ b/src/test/java/com/bencodez/votingplugin/control/domain/NetworkDoctorTest.java @@ -0,0 +1,469 @@ +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 truncatedRegistryDoesNotAssertBackendNeedsEnrollment() { + NodeStatus proxy = node("proxy", "VELOCITY", true, + List.of(new BackendServerIdentity("outside-page", "outside-page", true, true, 1))); + assertEquals(NetworkDoctor.Status.UNKNOWN, status(NetworkDoctor.evaluate(List.of(proxy), + Map.of("proxy", proxyEvidence()), Instant.EPOCH, true), "topology.backend.unenrolled")); + assertEquals(NetworkDoctor.Status.WARNING, status(NetworkDoctor.evaluate(List.of(proxy), + Map.of("proxy", proxyEvidence()), Instant.EPOCH, false), "topology.backend.unenrolled")); + } + + @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 nuVotifierActiveForwardingFailsWithoutInventingDestinationNames() { + NodeStatus proxy = node("proxy", "VELOCITY", true, List.of()); + ObjectNode evidence = proxyEvidence().put("votifierForwardingEnabled", true); + evidence.remove("forwardingDestinations"); evidence.remove("votifierForwardingKnown"); + assertEquals(NetworkDoctor.Status.FAIL, status(evaluate(List.of(proxy), evidence), "votifier.forwarding.duplicate-path")); + evidence.put("votifierForwardingEnabled", false); + assertEquals(NetworkDoctor.Status.PASS, status(evaluate(List.of(proxy), evidence), "votifier.forwarding.duplicate-path")); + evidence.remove("votifierForwardingEnabled"); + assertEquals(NetworkDoctor.Status.UNKNOWN, status(evaluate(List.of(proxy), evidence), "votifier.forwarding.duplicate-path")); + } + + @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")); + assertTrue(report.checks().stream().filter(c -> c.id().equals("votifier.forwarding.duplicate-path")) + .anyMatch(c -> c.explanation().contains("matches an enrolled VotingPlugin backend with a Votifier provider"))); + b.put("votifierProviderPresent", false); + assertEquals(NetworkDoctor.Status.FAIL, status(NetworkDoctor.evaluate(List.of(proxy, backend), + Map.of("proxy", p, "backend-a", b), Instant.EPOCH, false), "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() <= 500); + assertTrue(report.truncated()); + assertEquals(NetworkDoctor.Status.UNKNOWN, status(report, "control.evidence.truncated")); + } + + @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")); + e.withArray("voteSites").addObject().put("name", "three").put("enabled", true).put("serviceSite", ""); + assertEquals(NetworkDoctor.Status.FAIL, status(evaluate(List.of(backend), e), "votesite.service-site.missing")); + } + + @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("multiProxyMethod", "REDIS").put("primaryServer", true).put("proxyServerName", "pa").put("topologyComplete", true); + ea.putArray("proxyServers").add("pb"); + ObjectNode eb = proxyEvidence().put("multiProxySupport", true).put("multiProxyMethod", "REDIS").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 whitelistOverridesBlockedEntriesButBlockedOnlyCanExcludeAllDestinations() { + 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.PASS, status(report, "routing.no-eligible-backends")); + assertFalse(report.checks().stream().anyMatch(c -> c.id().equals("routing.backend.excluded"))); + p.putArray("whitelistedServers"); + report = evaluate(List.of(proxy), p); + assertEquals(NetworkDoctor.Status.FAIL, status(report, "routing.no-eligible-backends")); + assertEquals(NetworkDoctor.Status.WARNING, status(report, "routing.backend.excluded")); + } + + @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("multiProxyMethod", "REDIS").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")); + } + + @Test void socketMultiProxyUsesSocketPeersAndDoesNotCompareRedisPolicies() { + var primary = node("one", "VELOCITY", true, List.of()); var secondary = node("two", "VELOCITY", true, List.of()); + var a = proxyEvidence().put("multiProxySupport", true).put("multiProxyMethod", "SOCKET").put("primaryServer", true) + .put("proxyServerName", "one").put("sharedAuthentication", "REQUIRED"); + var b = proxyEvidence().put("multiProxySupport", true).put("multiProxyMethod", "SOCKET").put("primaryServer", false) + .put("proxyServerName", "two").put("sharedAuthentication", "COMPATIBILITY"); + a.putArray("proxyServers"); b.putArray("proxyServers"); + a.putArray("socketProxyServers").add("two"); b.putArray("socketProxyServers").add("one"); + var report = NetworkDoctor.evaluate(List.of(primary, secondary), Map.of("one", a, "two", b), Instant.EPOCH, false); + assertTrue(report.checks().stream().filter(c -> c.id().equals("multiproxy.primary.duplicate")).allMatch(c -> c.status() == NetworkDoctor.Status.PASS)); + assertFalse(report.checks().stream().anyMatch(c -> c.id().equals("multiproxy.authentication.mismatch") || c.id().equals("multiproxy.key.mismatch"))); + b.remove("socketProxyServers"); + assertTrue(NetworkDoctor.evaluate(List.of(primary, secondary), Map.of("one", a, "two", b), Instant.EPOCH, false).checks().stream() + .anyMatch(c -> c.id().equals("multiproxy.relationships") && c.status() == NetworkDoctor.Status.UNKNOWN)); + } + @Test void inactiveRoutingListsCannotFailHealthyTopology() { + var proxy = node("proxy", "VELOCITY", true, List.of()); + var p = proxyEvidence().put("broadcastServersApplicable", false).put("offlineForwardServersApplicable", false).put("votePartyEnabled", false); + p.putArray("backendNames").add("backend-a"); p.putArray("blockedServers"); p.putArray("whitelistedServers"); + p.putArray("broadcastServers").add("unused"); p.putArray("offlineForwardServers").add("lobby"); p.putArray("votePartyServers").add("old"); + assertFalse(evaluate(List.of(proxy), p).checks().stream().anyMatch(c -> c.id().equals("routing.unknown-server"))); + p.put("broadcastServersApplicable", true); + assertEquals(NetworkDoctor.Status.FAIL, status(evaluate(List.of(proxy), p), "routing.unknown-server")); + } + @Test void offlineProxyTopologyCannotForceOnlineStandaloneIntoProxyMode() { + var stale = node("proxy", "VELOCITY", false, List.of(new BackendServerIdentity("backend-a", "backend-a", true, true, 1))); + var standalone = node("backend-a", "BUKKIT", true, List.of()); + var b = backendHealthy("backend-a").put("proxyMode", false); + var report = NetworkDoctor.evaluate(List.of(stale, standalone), Map.of("backend-a", b), Instant.EPOCH, false); + assertFalse(report.checks().stream().anyMatch(c -> c.id().equals("backend.proxy-mode.disabled"))); + } + + @Test void caseOnlyBackendNamesCollideInOneNetwork() { + var proxy = node("proxy", "VELOCITY", true, List.of(new BackendServerIdentity("a", "a", true, true, 1), new BackendServerIdentity("b", "b", true, true, 1))); + var a = node("a", "BUKKIT", true, List.of()); var b = node("b", "BUKKIT", true, List.of()); + var report = NetworkDoctor.evaluate(List.of(proxy, a, b), Map.of("proxy", proxyEvidence(), "a", backendHealthy("Lobby"), "b", backendHealthy("lobby")), Instant.EPOCH, false); + assertEquals(NetworkDoctor.Status.FAIL, status(report, "backend.server-name.duplicate")); + } + @Test void completeRewardFailureEvidenceAndInvalidSiteDelayFailButIncompleteRewardEvidenceDoesNot() { + var node = node("backend", "BUKKIT", true, List.of()); + var data = backendHealthy("backend").put("rewardsComplete", true); + data.putArray("missingRewardFiles").add("daily.yml"); data.putArray("invalidRewardFiles").add("broken.yml"); + data.putArray("missingRewardDependencies").add("PlaceholderAPI"); + data.putArray("voteSites").addObject().put("name", "site").put("enabled", true).put("serviceSite", "svc").put("delayValid", false); + var report = evaluate(List.of(node), data); + assertEquals(NetworkDoctor.Status.FAIL, status(report, "rewards.missingRewardFiles")); + assertEquals(NetworkDoctor.Status.FAIL, status(report, "rewards.invalidRewardFiles")); + assertEquals(NetworkDoctor.Status.WARNING, status(report, "rewards.missingRewardDependencies")); + assertEquals(NetworkDoctor.Status.FAIL, status(report, "votesite.delay.invalid")); + data.put("rewardsComplete", false); + assertEquals(NetworkDoctor.Status.UNKNOWN, status(evaluate(List.of(node), data), "rewards.missingRewardFiles")); + } + + @Test void votePartySendAllMakesItsExplicitListInactive() { + var proxy = node("proxy", "VELOCITY", true, List.of()); + var data = proxyEvidence().put("votePartyEnabled", true).put("votePartyServersApplicable", false).put("votePartyVotesRequired", 100); + data.putArray("backendNames").add("backend-a"); data.putArray("votePartyServers").add("retired"); + assertFalse(evaluate(List.of(proxy), data).checks().stream().anyMatch(c -> c.id().equals("routing.unknown-server"))); + data.put("votePartyServersApplicable", true); + assertEquals(NetworkDoctor.Status.FAIL, status(evaluate(List.of(proxy), data), "routing.unknown-server")); + data.remove("votePartyServersApplicable"); + assertEquals(NetworkDoctor.Status.UNKNOWN, status(evaluate(List.of(proxy), data), "routing.votePartyServers.unknown")); + } + + 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..9eed518 --- /dev/null +++ b/src/test/java/com/bencodez/votingplugin/control/domain/NetworkHealthEvidenceTest.java @@ -0,0 +1,72 @@ +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("votifierForwardingEnabled", "true"))); + assertTrue(NetworkHealthEvidence.valid(base().put("votifierForwardingEnabled", 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..0c07bf5 --- /dev/null +++ b/src/test/java/com/bencodez/votingplugin/control/domain/NetworkHealthInspectionTest.java @@ -0,0 +1,113 @@ +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 staleRegistryPageCannotReuseEvidenceAfterSessionReplacement() { + 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())); + var page = registry.page(0, 100, null); + assertEquals(1, ops.networkEvidence(page.items()).size()); + register(UUID.randomUUID(), caps()); + assertEquals(page.revision(), registry.page(0, 100, null).revision()); + assertTrue(ops.networkEvidence(page.items()).isEmpty()); + } + + @Test void staleRegistryCapabilitiesCannotAuthorizeRetainedEvidence() { + 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())); + var page = registry.page(0, 100, null); + register(session, Set.of(InspectionQuery.CAPABILITY)); + assertEquals(page.revision(), registry.page(0, 100, null).revision()); + assertTrue(ops.networkEvidence(page.items()).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()); + } + + @Test void peerTimestampCannotExtendServerMeasuredFreshness() { + var time = new java.util.concurrent.atomic.AtomicReference<>(clock.instant()); + Clock mutable = new Clock() { + public ZoneId getZone() { return ZoneOffset.UTC; } + public Clock withZone(ZoneId zone) { return this; } + public Instant instant() { return time.get(); } + }; + var localRegistry = new InMemoryNodeRegistry(mutable, Duration.ofHours(1)); + localRegistry.register(new NodeRegistration("backend-a", session, "a", "BUKKIT", "1", 1, caps(), Set.of())); + var localOps = new InspectionOperations(localRegistry, mutable); + var first = localOps.create("backend-a", new InspectionQuery("network-health", Map.of())); + var claim = localOps.claim("backend-a", session); + var data = envelope(); data.put("generatedAt", "2099-01-01T00:00:00Z"); + localOps.complete(first.inspectionId(), "backend-a", new InspectionTaskResult(session, true, "OK", "ok", data, claim.attemptId())); + assertEquals(1, localOps.networkEvidence(localRegistry.list(0, 100)).size()); + time.set(time.get().plusSeconds(301)); + assertTrue(localOps.networkEvidence(localRegistry.list(0, 100)).isEmpty()); + } + +} diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/RegistrySnapshotTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/RegistrySnapshotTest.java new file mode 100644 index 0000000..c707806 --- /dev/null +++ b/src/test/java/com/bencodez/votingplugin/control/domain/RegistrySnapshotTest.java @@ -0,0 +1,52 @@ +package com.bencodez.votingplugin.control.domain; + +import com.bencodez.votingplugin.control.protocol.BackendServerIdentity; +import com.bencodez.votingplugin.control.protocol.NodeStatus; +import java.time.Instant; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +class RegistrySnapshotTest { + private static final UUID SESSION = UUID.fromString("00000000-0000-0000-0000-000000000001"); + + @Test void samePageIgnoresHeartbeatTimesAndOrdering() { + NodeStatus first = node(SESSION, Set.of("b", "a"), List.of(new BackendServerIdentity("b", "B", true, true, 2))); + NodeStatus second = new NodeStatus(first.nodeId(), first.sessionId(), first.displayName(), first.platform(), + first.pluginVersion(), first.protocolVersion(), first.advertisedCapabilities(), first.acceptedCapabilities(), + first.detectedPlugins(), List.of(new BackendServerIdentity("b", "B", false, false, 99)), + first.snapshotSequence() + 1, Instant.ofEpochSecond(99), + Instant.ofEpochSecond(100), first.online()); + assertEquals(RegistrySnapshot.sha256(page(first)), RegistrySnapshot.sha256(page(second))); + } + + @Test void identityCapabilitiesAndTopologyChangesInvalidateEvenWithSameRevision() { + NodeStatus base = node(SESSION, Set.of("a"), List.of(new BackendServerIdentity("b", "B", true, true, 1))); + String fingerprint = RegistrySnapshot.sha256(page(base)); + assertNotEquals(fingerprint, RegistrySnapshot.sha256(new NodeRegistry.RegistryPage(List.of(base), 8, 1))); + assertNotEquals(fingerprint, RegistrySnapshot.sha256(new NodeRegistry.RegistryPage(List.of(base), 7, 2))); + assertNotEquals(fingerprint, RegistrySnapshot.sha256(page(node(SESSION, Set.of("a"), + List.of(new BackendServerIdentity("new-backend", "B", true, true, 1)))))); + assertNotEquals(fingerprint, RegistrySnapshot.sha256(page(node(UUID.randomUUID(), Set.of("a"), base.backends())))); + assertNotEquals(fingerprint, RegistrySnapshot.sha256(page(node(SESSION, Set.of("changed"), base.backends())))); + assertNotEquals(fingerprint, RegistrySnapshot.sha256(page(node(SESSION, Set.of("a"), + List.of(new BackendServerIdentity("b", "Renamed", true, true, 1)))))); + assertNotEquals(fingerprint, RegistrySnapshot.sha256(page(node("other", SESSION, Set.of("a"), base.backends())))); + } + + private static NodeRegistry.RegistryPage page(NodeStatus node) { + return new NodeRegistry.RegistryPage(List.of(node), 7, 1); + } + + private static NodeStatus node(UUID session, Set capabilities, List backends) { + return node("node", session, capabilities, backends); + } + + private static NodeStatus node(String id, UUID session, Set capabilities, List backends) { + return new NodeStatus(id, session, "Node", "BUKKIT", "test", 1, capabilities, capabilities, + Set.of(), backends, 4, Instant.EPOCH, Instant.EPOCH, true); + } +} 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..bf2c391 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,9 @@ 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, configurationReader")); + assertTrue(script.body().contains("ControlGeneralSettings.healthChecks(configurationReader.model)"), + "Doctor checks must use the bounded reader from this run rather than cached dashboard state."); 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 +1000,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); @@ -1128,6 +1132,7 @@ private static void terminateProcess(Process process) { assertTrue(listed.at("/items/0/online").asBoolean()); assertEquals(1, listed.get("total").asInt()); assertTrue(listed.has("registryRevision")); + assertTrue(listed.get("registrySnapshot").asText().matches("[0-9a-f]{64}")); } @Test void nodePaginationRejectsAStaleRegistryRevision() throws Exception { @@ -1307,6 +1312,35 @@ 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.get("registrySnapshot").asText().matches("[0-9a-f]{64}")); + JsonNode page = json.readTree(get("/api/v1/nodes?offset=0&limit=100", adminToken).body()); + assertEquals(page.get("registrySnapshot").asText(), report.get("registrySnapshot").asText()); + 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-date-format.test.cjs b/src/test/web/app-date-format.test.cjs new file mode 100644 index 0000000..c6abfa8 --- /dev/null +++ b/src/test/web/app-date-format.test.cjs @@ -0,0 +1,36 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); + +const source = fs.readFileSync(path.join(__dirname, '../../main/resources/web/app.js'), 'utf8'); +const styles = fs.readFileSync(path.join(__dirname, '../../main/resources/web/app.css'), 'utf8'); + +function declaration(name) { + const start = source.indexOf(`function ${name}(`); + assert.notEqual(start, -1); + const open = source.indexOf('{', start); + let depth = 0; + for (let index = open; index < source.length; index++) { + if (source[index] === '{') depth++; + if (source[index] === '}' && --depth === 0) return source.slice(start, index + 1); + } + throw new Error(`Could not extract ${name}`); +} + +test('date rendering accepts API epoch seconds and ISO instants', () => { + const context = {}; + vm.createContext(context); + vm.runInContext(`${declaration('formatDateTime')}\n${declaration('formatEpoch')}`, context); + assert.match(context.formatEpoch(1770000000), /2026/); + assert.match(context.formatDateTime('2026-01-21T00:00:00Z'), /2026/); + assert.equal(context.formatEpoch(0), 'Unknown'); + assert.equal(context.formatDateTime(-1), 'Unknown'); + assert.equal(context.formatDateTime(true), 'Unknown'); + assert.equal(context.formatDateTime('not-a-date'), 'Unknown'); +}); + +test('deployment status wraps long staging messages', () => { + assert.match(styles, /#deployment-status \{[^}]*overflow-wrap: anywhere;[^}]*white-space: pre-wrap;/s); +}); diff --git a/src/test/web/app-network-doctor-deadline.test.cjs b/src/test/web/app-network-doctor-deadline.test.cjs new file mode 100644 index 0000000..2661e74 --- /dev/null +++ b/src/test/web/app-network-doctor-deadline.test.cjs @@ -0,0 +1,177 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); +const ControlGeneralSettings = require('../../main/resources/web/general-settings.js'); +const source = fs.readFileSync(path.join(__dirname, '../../main/resources/web/app.js'), 'utf8'); +function section(start, end) { + const offset = source.indexOf(start); + assert.notEqual(offset, -1); + return source.slice(offset, source.indexOf(end, offset)); +} +function context(extra = {}) { + const ctx = {ControlGeneralSettings, authenticated: true, settingsHealthReader: {read: () => { throw new Error('old dashboard read'); }}, + configurationHealthContext: () => 'same-context', document: {createElement: () => ({})}, + renderMetrics: () => {}, window: {setTimeout, clearTimeout}, ...extra}; + vm.createContext(ctx); + vm.runInContext(section('function createConfigurationHealthReader(', 'settingsHealthReader = createConfigurationHealthReader(') + + section('async function refreshConfigurationHealth(', 'function settingValueLabel('), ctx); + return ctx; +} +test('doctor configuration reads cover at most its bounded registry page and leave dashboard flight independent', async () => { + const targets = Array.from({length: 250}, (_, i) => ({id: `node-${i}`, sessionId: `session-${i}`, + online: true, supported: true, fileName: 'Config.yml'})); + const calls = []; + const ctx = context({generalSettingsTargets: () => targets, + startConfigurationOperation: async (_path, body, _status, options) => { + calls.push({body, options}); + return {operationId: 'read', results: Object.fromEntries(body.nodeIds.map(id => [id, + {success: true, sessionId: targets.find(t => t.id === id).sessionId, revision: 'r'}]))}; + }, authorized: async (_path, request) => { + const {nodeId} = JSON.parse(request.body); + return {nodeId, sessionId: targets.find(t => t.id === nodeId).sessionId, revision: 'r', fields: {}}; + }}); + const original = ctx.settingsHealthReader; + const controller = new AbortController(); + const reader = await ctx.refreshConfigurationHealth({nodeIds: new Set(targets.slice(0, 100).map(t => t.id)), signal: controller.signal}); + assert.equal(reader.model.targets.size, 100); + assert.equal(calls.length, 1); + assert.equal(calls[0].body.nodeIds.length, 100); + assert.equal(calls[0].options.signal, controller.signal); + assert.equal(ctx.settingsHealthReader, original); +}); + +test('abort releases a claimed configuration read immediately without another operation poll', async () => { + let timer; + let polls = 0; + const ctx = {operationStatus: {}, operationContext: () => ({}), operationContextCurrent: () => false, + rememberOperation: () => {}, authorized: async () => { polls++; }, + window: {setTimeout: callback => { timer = callback; return 1; }, clearTimeout: () => { timer = null; }}}; + vm.createContext(ctx); + vm.runInContext(section('async function waitForOperation(', 'async function startConfigurationOperation('), ctx); + const controller = new AbortController(); + const pending = ctx.waitForOperation({operationId: 'stalled', state: 'RUNNING'}, {}, {}, {signal: controller.signal}); + assert.equal(typeof timer, 'function'); + controller.abort(); + await assert.rejects(pending, /evidence is unavailable/); + assert.equal(timer, null); + assert.equal(polls, 0); +}); + +test('deadline-expired configuration evidence cannot reuse previous healthy dashboard values', async () => { + const controller = new AbortController(); controller.abort(); + const ctx = context({generalSettingsTargets: () => [{id: 'node', sessionId: 's', online: true, supported: true}], + startConfigurationOperation: async (_path, _body, _status, options) => { + assert.equal(options.signal.aborted, true); + throw new Error('aborted'); + }, authorized: async () => { throw new Error('must not read typed state'); }}); + const reader = await ctx.refreshConfigurationHealth({nodeIds: new Set(['node']), signal: controller.signal}); + assert.equal(reader.model.targets.get('node').status, 'ERROR'); + assert.equal(ControlGeneralSettings.healthChecks(reader.model).some(check => check.status === 'PASS'), false); +}); +test('doctor keeps unenrolled topology placeholders unknown without reading them', async () => { + const targets = [{id: 'proxy', sessionId: 'p', online: true, supported: true, networkOnly: false}, + {id: 'missing-backend', sessionId: '', online: false, supported: false, networkOnly: false, reportingProxyIds: ['proxy']}]; + const calls = []; + const ctx = context({generalSettingsTargets: () => targets, + startConfigurationOperation: async (_path, body) => { + calls.push(body.nodeIds); return {operationId: 'read', results: {proxy: {success: true, sessionId: 'p', revision: 'r'}}}; + }, authorized: async (_path, request) => { + const body = JSON.parse(request.body); return {nodeId: body.nodeId, sessionId: 'p', revision: 'r', fields: { + OnlineMode: {status: 'AVAILABLE', value: true}, BedrockPlayerPrefix: {status: 'AVAILABLE', value: '.'} + }}; + }}); + const reader = await ctx.refreshConfigurationHealth({nodeIds: new Set(['proxy'])}); + assert.deepEqual(calls, [['proxy']]); + assert.equal(reader.model.targets.get('missing-backend').status, 'UNSUPPORTED'); + const checks = ControlGeneralSettings.healthChecks(reader.model); + assert.equal(checks.find(check => check.path === 'OnlineMode').status, 'UNKNOWN'); + assert.equal(checks.find(check => check.path === 'BedrockPlayerPrefix').status, 'UNKNOWN'); +}); +test('doctor keeps the read page bounded when related enrolled peers exceed it', async () => { + const targets = Array.from({length: 105}, (_, i) => ({id: `backend-${i}`, sessionId: `s-${i}`, + online: true, supported: true, networkOnly: true, role: 'BACKEND'})); + const calls = []; + const ctx = context({generalSettingsTargets: () => targets, + startConfigurationOperation: async (_path, body) => { + calls.push(body.nodeIds); return {operationId: 'read', results: Object.fromEntries(body.nodeIds.map(id => [id, + {success: true, sessionId: `s-${id.slice(8)}`, revision: 'r'}]))}; + }, authorized: async (_path, request) => { + const body = JSON.parse(request.body); return {nodeId: body.nodeId, sessionId: `s-${body.nodeId.slice(8)}`, revision: 'r', fields: {}}; + }}); + const reader = await ctx.refreshConfigurationHealth({nodeIds: new Set(targets.map(target => target.id))}); + assert.equal(calls.flat().length, 100); + assert.equal(reader.model.targets.size, 105); + assert.equal(ControlGeneralSettings.healthChecks(reader.model).find(check => check.path === 'OnlineMode').status, 'UNKNOWN'); +}); +test('doctor reports matching enrolled network values as verified', async () => { + const targets = [{id: 'proxy', sessionId: 'proxy', online: true, supported: true, role: 'PROXY'}, + {id: 'backend', sessionId: 'backend', online: true, supported: true, role: 'BACKEND', reportingProxyIds: ['proxy']}]; + const ctx = context({generalSettingsTargets: () => targets, + startConfigurationOperation: async (_path, body) => ({operationId: 'read', results: Object.fromEntries(body.nodeIds.map(id => [id, {success: true, sessionId: id, revision: 'r'}]))}), + authorized: async (_path, request) => ({nodeId: JSON.parse(request.body).nodeId, sessionId: JSON.parse(request.body).nodeId, revision: 'r', fields: { + OnlineMode: {status: 'AVAILABLE', value: true}, BedrockPlayerPrefix: {status: 'AVAILABLE', value: '.'} + }})}); + const reader = await ctx.refreshConfigurationHealth({nodeIds: new Set(['proxy', 'backend'])}); + const checks = ControlGeneralSettings.healthChecks(reader.model); + assert.equal(checks.find(check => check.path === 'OnlineMode').status, 'PASS'); + assert.equal(checks.find(check => check.path === 'BedrockPlayerPrefix').status, 'PASS'); +}); +test('doctor retains enrolled related peers outside the page but excludes standalone nodes', async () => { + const targets = [ + {id: 'proxy', sessionId: 'proxy', online: true, supported: true, role: 'PROXY'}, + {id: 'backend-outside-page', sessionId: 'backend', online: true, supported: true, role: 'BACKEND', networkOnly: false, reportingProxyIds: ['proxy']}, + {id: 'standalone', sessionId: 'standalone', online: true, supported: true, role: 'BACKEND', networkOnly: false} + ]; + const ctx = context({generalSettingsTargets: () => targets, + startConfigurationOperation: async (_path, body) => ({operationId: 'read', results: {proxy: {success: true, sessionId: 'proxy', revision: 'r'}}}), + authorized: async () => ({nodeId: 'proxy', sessionId: 'proxy', revision: 'r', fields: { + OnlineMode: {status: 'AVAILABLE', value: true}, BedrockPlayerPrefix: {status: 'AVAILABLE', value: '.'} + }})}); + const reader = await ctx.refreshConfigurationHealth({nodeIds: new Set(['proxy'])}); + assert.equal(reader.model.targets.has('backend-outside-page'), true); + assert.equal(reader.model.targets.has('standalone'), false); + assert.equal(ControlGeneralSettings.healthChecks(reader.model).find(check => check.path === 'OnlineMode').status, 'UNKNOWN'); +}); + +test('doctor configuration targets start from the full doctor page in server and multi-server scopes', async () => { + for (const scope of ['SERVER', 'MULTI_SERVER']) { + const nodes = [ + {nodeId: 'selected', sessionId: 'selected', online: true, platform: 'BUKKIT', acceptedCapabilities: ['config.files.v1']}, + {nodeId: 'second-selected', sessionId: 'second-selected', online: true, platform: 'BUKKIT', acceptedCapabilities: ['config.files.v1']}, + {nodeId: 'other-proxy', sessionId: 'other-proxy', online: true, platform: 'VELOCITY', acceptedCapabilities: ['config.proxy-files.v1'], + backends: [{backendId: 'other-backend'}]}, + {nodeId: 'other-backend', sessionId: 'other-backend', online: true, platform: 'BUKKIT', acceptedCapabilities: ['config.files.v1']} + ]; + const selected = scope === 'SERVER' ? ['selected'] : ['selected', 'second-selected']; + const calls = []; + const ctx = context({workspace: {managementScope: scope, selectedTargetIds: new Set(selected)}, + allNodeItems: nodes, nodeIndex: new Map(nodes.map(node => [node.nodeId, node])), backendTopologyTruncatedNodeIds: new Set(), + isProxy: node => ['VELOCITY', 'BUNGEECORD'].includes(node?.platform), isBackend: node => node?.platform === 'BUKKIT', + startConfigurationOperation: async (_path, body) => { + calls.push(...body.nodeIds); + return {operationId: 'read', results: Object.fromEntries(body.nodeIds.map(id => [id, {success: true, sessionId: id, revision: 'r'}]))}; + }, authorized: async (_path, request) => { + const id = JSON.parse(request.body).nodeId; + return {nodeId: id, sessionId: id, revision: 'r', fields: { + OnlineMode: {status: 'AVAILABLE', value: true}, BedrockPlayerPrefix: {status: 'AVAILABLE', value: '.'} + }}; + }}); + // Execute the real target builder, including its workspace seeding and topology expansion. + vm.runInContext(section('function generalSettingsTargets(', 'function configurationHealthContext('), ctx); + const reader = await ctx.refreshConfigurationHealth({nodeIds: new Set(nodes.map(node => node.nodeId))}); + assert.deepEqual(calls.sort(), nodes.map(node => node.nodeId).sort(), scope); + assert.equal(reader.model.targets.size, 4, scope); + assert.equal(ControlGeneralSettings.healthChecks(reader.model).find(check => check.path === 'OnlineMode').status, 'PASS'); + assert.deepEqual([...ctx.workspace.selectedTargetIds], selected, 'Doctor must not alter the selected workspace'); + nodes[3].online = false; + calls.length = 0; + const unavailable = await ctx.refreshConfigurationHealth({nodeIds: new Set(nodes.map(node => node.nodeId))}); + assert.equal(unavailable.model.targets.size, 4); + assert.equal(calls.includes('other-backend'), false, 'An offline peer must remain evidence-only'); + const checks = ControlGeneralSettings.healthChecks(unavailable.model); + assert.equal(checks.find(check => check.path === 'OnlineMode').status, 'UNKNOWN'); + assert.equal(checks.find(check => check.path === 'BedrockPlayerPrefix').status, 'UNKNOWN'); + } +}); diff --git a/src/test/web/app-workspace.test.cjs b/src/test/web/app-workspace.test.cjs index 177267f..5f7f3e6 100644 --- a/src/test/web/app-workspace.test.cjs +++ b/src/test/web/app-workspace.test.cjs @@ -21,7 +21,7 @@ function declaration(name) { const match = new RegExp(`(?:^|\\n)(?:async )?function ${name}\\(`, 'm').exec(appSource); const start = match ? match.index + (match[0].startsWith('\n') ? 1 : 0) : -1; assert.notEqual(start, -1, `app.js declares ${name}`); - const open = appSource.indexOf('{', start); + const open = appSource.indexOf(') {', start) + 2; let depth = 0; let quote = ''; for (let index = open; index < appSource.length; index++) { @@ -54,6 +54,7 @@ function harness() { this.value = ''; this.textContent = ''; this.className = ''; + this.classList = {toggle: () => {}, add: () => {}, remove: () => {}}; } append(...items) { this.children.push(...items); } prepend(...items) { this.children.unshift(...items); } @@ -92,6 +93,7 @@ function harness() { nodeIndex: new Map(), nodeCapabilities: new Map(), backendTopologyTruncatedNodeIds: new Set(), + backendTopologyTruncated: false, selectedNodes: new Set(['stale']), voteSitesTargetIds: new Set(['stale']), fileReadCache: new Map([['stale', {}]]), @@ -101,7 +103,7 @@ function harness() { autoLoadPending: new Set(['stale']), configurationContent: element(), configurationFile: element(), logout: element(), sidebarToggle: element(), globalSearch: element(), headerAction: element(), authCard: element(), welcome: element(), appShell: element(), - serverPickerLabel: element(), enrollmentCard: element(), quickPreset: element(), + serverPickerLabel: element(), enrollmentCard: element(), quickPreset: element(), topology: element(), authenticated: false, csrfToken: 'old', approvedPreview: {}, approvedFilePreview: {}, approvedQuickPreview: {}, loadedQuickSetup: {}, quickSetupDirty: true, voteSitesSourceId: 'stale', voteSitesTargetsInitialized: true, transportTestProxyId: 'stale', transportTestBackendId: 'stale', proxyMethodProxyId: 'stale', @@ -141,10 +143,10 @@ function harness() { calls }; vm.createContext(context); - const names = ['text', 'applyAuthenticatedSession', 'isProxy', 'isBackend', 'roleLabel', 'platformLabel', + const names = ['text', 'formatDateTime', 'applyAuthenticatedSession', 'isProxy', 'isBackend', 'roleLabel', 'platformLabel', 'friendlyCapability', 'managedCapabilities', 'proxyReportsFor', 'backendCard', 'nodePresence', 'nodeCard', 'ordinaryTargetIds', 'comparisonTargetIds', 'confirmDiscardWorkspaceDrafts', 'changeWorkspaceTargets', - 'inspectWorkspaceServer', 'openScopeOverview', 'enterGlobalWorkspace', 'applyNavigationRoute', 'startConfigurationOperation', + 'workspaceTargetNeedsReplacement', 'inspectWorkspaceServer', 'renderTopology', 'openScopeOverview', 'enterGlobalWorkspace', 'applyNavigationRoute', 'startConfigurationOperation', 'generalSettingsTargets', 'settingValueLabel']; vm.runInContext(names.map(declaration).join('\n'), context, {filename: 'app-workspace-helpers.js'}); return context; @@ -369,6 +371,69 @@ test('nodeCard keeps offline Bukkit nodes selectable, but disables proxy and unk } }); +test('proxy card opens a proxy workspace for settings without making it a backend target', () => { + const context = harness(); + const proxyNode = {nodeId: 'proxy', displayName: 'Velocity', platform: 'VELOCITY', online: true, + sessionId: 'proxy-session', acceptedCapabilities: ['config.proxy-files.v1'], backends: []}; + context.nodeIndex.set('proxy', proxyNode); + context.proxyNode = proxyNode; + context.nodeIndex.set('backend-a', backend('backend-a')); + context.nodeIndex.set('backend-b', backend('backend-b')); + context.workspace.setTargets(['backend-a', 'backend-b']); + context.selectedServerId = 'backend-a'; + const card = run(context, 'nodeCard(proxyNode)'); + const action = card.find(node => node.tagName === 'button' && node.textContent === 'Manage proxy settings'); + assert.ok(action); + action.listeners.get('click')(); + assert.deepEqual([...context.workspace.selectedTargetIds], ['proxy']); + assert.equal(context.workspace.inspectedServerId, 'proxy'); + assert.equal(context.selectedServerId, 'proxy'); + assert.equal(context.calls.at(-1)[1], 'overview'); +}); + +test('topology overview exposes the same proxy settings action', () => { + const context = harness(); + context.authenticated = true; + const node = {...proxy('proxy'), displayName: 'Velocity', sessionId: 'p', acceptedCapabilities: ['config.proxy-files.v1'], backends: []}; + context.nodeIndex.set('proxy', node); + context.allNodeItems.push(node); + run(context, 'renderTopology()'); + const action = context.topology.find(item => item.tagName === 'button' && item.textContent === 'Manage proxy settings'); + assert.ok(action); + action.listeners.get('click')(); + assert.deepEqual([...context.workspace.selectedTargetIds], ['proxy']); +}); + +test('proxy settings cancellation preserves backend targets and selected source', () => { + const context = harness(); + context.authenticated = true; + context.workspace.setTargets(['backend-a', 'backend-b']); + context.selectedServerId = 'backend-a'; + context.nodeIndex.set('proxy', proxy('proxy')); + context.settingsEditor = {model: {dirty: new Set(['Server'])}}; + context.window.confirm = () => false; + run(context, "inspectWorkspaceServer('proxy')"); + assert.deepEqual([...context.workspace.selectedTargetIds], ['backend-a', 'backend-b']); + assert.equal(context.selectedServerId, 'backend-a'); + assert.equal(context.calls.length, 0); +}); + +test('backend inspection preserves a multi-server workspace', () => { + const context = harness(); + context.authenticated = true; + context.nodeIndex.set('backend-a', backend('backend-a')); + context.nodeIndex.set('backend-b', backend('backend-b')); + context.workspace.setTargets(['backend-a', 'backend-b']); + context.selectedServerId = 'backend-a'; + context.backendB = {...backend('backend-b'), displayName: 'Backend B', acceptedCapabilities: [], backends: []}; + context.nodeIndex.set('backend-b', context.backendB); + const card = run(context, 'nodeCard(backendB)'); + const action = card.find(node => node.tagName === 'button' && node.textContent === 'Inspect server overview'); + action.listeners.get('click')(); + assert.deepEqual([...context.workspace.selectedTargetIds], ['backend-a', 'backend-b']); + assert.equal(context.selectedServerId, 'backend-b'); +}); + test('back and forward scope navigation retains targets, maps presets, and restores a cancelled hash', () => { const context = harness(); context.authenticated = true; @@ -538,10 +603,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: [], nodePageMetadata: new Map([[0, {registrySnapshot: 'a'.repeat(64)}]]), updateExtendedButtons: () => {}, + AbortController, window: {setTimeout: () => 1, clearTimeout: () => {}}, + NetworkDoctorView: require('../../main/resources/web/network-doctor.js'), + authorized: async () => ({checks: [], registrySnapshot: 'a'.repeat(64)}), runInspection: async () => ({result: {configuredVoteSites: 1}}), lastOverview: null, invalidateDashboardInspection: () => {}, finiteCount: value => value, - configurationHealthChecks: () => [{path: 'OnlineMode', status: 'WARNING'}], - refreshConfigurationHealth: async () => {reads++;}, renderJsonResult: () => {}, updateSetupChecklist: () => {}}); + ControlGeneralSettings: {healthChecks: () => [{path: 'OnlineMode', status: 'WARNING'}]}, + refreshConfigurationHealth: async () => {reads++; return {model: {}};}, 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); @@ -550,6 +620,35 @@ test('Network Doctor reads configuration health and includes typed checks on its assert.equal(context.lastDiagnostics.configurationChecks[0].status, 'WARNING'); }); +test('Network Doctor drops stale or unsupported registry configuration conclusions', async () => { + for (const reportSnapshot of ['b'.repeat(64), undefined]) { + const context = harness(); + let click; + let conclusions = 0; + Object.assign(context, {runNetworkDoctor: {addEventListener: (_event, handler) => {click = handler;}}, + downloadNetworkDiagnostics: {}, lastDiagnostics: null, networkDoctorResults: {}, + inspectionInFlight: false, authenticationGeneration: 1, allNodeItems: [], + nodePageMetadata: new Map([[0, {registrySnapshot: 'a'.repeat(64)}]]), updateExtendedButtons: () => {}, + AbortController, window: {setTimeout: () => 1, clearTimeout: () => {}}, + NetworkDoctorView: {...require('../../main/resources/web/network-doctor.js'), render: () => {}}, + authorized: async () => ({checks: [], registrySnapshot: reportSnapshot}), + ControlGeneralSettings: {healthChecks: () => {conclusions++; return [{path: 'OnlineMode', status: 'PASS'}];}}, + refreshConfigurationHealth: async () => { + // A refresh during collection must not replace the originally collected snapshot. + context.nodePageMetadata.set(0, {registrySnapshot: reportSnapshot}); + return {model: {}}; + }}); + const start = appSource.indexOf("runNetworkDoctor.addEventListener('click'"); + const end = appSource.indexOf("downloadNetworkDiagnostics.addEventListener", start); + vm.runInContext(appSource.slice(start, end), context); + await click(); + assert.equal(conclusions, 0); + assert.equal(context.lastDiagnostics.configurationChecks[0].status, 'UNKNOWN'); + assert.equal(context.lastDiagnostics.checks.some(check => check.status === 'PASS'), false); + assert.equal(context.downloadNetworkDiagnostics.disabled, false); + } +}); + test('configuration mutation fences an in-flight health read and forces a new context', () => { const context = harness(); Object.assign(context, {authenticated: true, settingsHealthGeneration: 1, authenticationGeneration: 1, @@ -561,3 +660,72 @@ test('configuration mutation fences an in-flight health read and forces a new co context.settingsHealthGeneration++; assert.equal(run(context, 'configurationHealthChecks()')[0].status, 'UNKNOWN'); }); + + +test('proxy route restoration creates a proxy workspace instead of resetting Home', () => { + const context = harness(); context.authenticated = true; + context.nodeIndex.set('velocity', {...proxy('velocity'), acceptedCapabilities: ['config.proxy-files.v1']}); + context.window.location.hash = '#servers/velocity/overview'; + run(context, 'applyNavigationRoute()'); + assert.deepEqual([...context.workspace.selectedTargetIds], ['velocity']); + assert.equal(context.workspace.inspectedServerId, 'velocity'); + assert.equal(context.selectedServerId, 'velocity'); + assert.equal(context.calls.some(call => call[0] === 'setActiveTab' && call[1] === 'home'), false); +}); + +test('opening a backend after proxy management replaces the proxy target and file identity', () => { + const context = harness(); context.authenticated = true; + context.nodeIndex.set('velocity', {...proxy('velocity'), acceptedCapabilities: ['config.proxy-files.v1']}); + context.nodeIndex.set('lobby', {...backend('lobby'), acceptedCapabilities: ['config.files.v1']}); + context.workspace.setTargets(['velocity']).inspect('velocity'); context.selectedServerId = 'velocity'; + run(context, "inspectWorkspaceServer('lobby')"); + assert.deepEqual([...context.workspace.selectedTargetIds], ['lobby']); + assert.equal(context.workspace.inspectedServerId, 'lobby'); + assert.equal(context.selectedServerId, 'lobby'); + assert.equal(run(context, 'generalSettingsTargets()[0].fileName'), 'Config.yml'); + assert.equal(context.calls.some(call => call[0] === 'clearApprovals'), true); +}); + +test('history transitions between proxy and backend routes replace only incompatible target sets', () => { + const context = harness(); context.authenticated = true; + context.nodeIndex.set('velocity', proxy('velocity')); + context.nodeIndex.set('lobby', backend('lobby')); context.nodeIndex.set('server', backend('server')); + context.workspace.setTargets(['lobby', 'server']); context.selectedServerId = 'lobby'; + context.window.location.hash = '#servers/velocity/overview'; run(context, 'applyNavigationRoute()'); + assert.deepEqual([...context.workspace.selectedTargetIds], ['velocity']); + context.window.location.hash = '#servers/lobby/overview'; run(context, 'applyNavigationRoute()'); + assert.deepEqual([...context.workspace.selectedTargetIds], ['lobby']); + assert.equal(context.selectedServerId, 'lobby'); + context.workspace.setTargets(['lobby', 'server']); + context.window.location.hash = '#servers/server/overview'; run(context, 'applyNavigationRoute()'); + assert.deepEqual([...context.workspace.selectedTargetIds], ['lobby', 'server']); +}); + +test('cancelling a proxy-to-backend history transition retains the proxy target and route', () => { + const context = harness(); context.authenticated = true; + context.nodeIndex.set('velocity', proxy('velocity')); context.nodeIndex.set('lobby', backend('lobby')); + context.workspace.setTargets(['velocity']).inspect('velocity'); context.selectedServerId = 'velocity'; + context.activeNavigationHash = '#servers/velocity/overview'; + context.settingsEditor = {model: {dirty: new Set(['Server'])}}; context.window.confirm = () => false; + context.window.location.hash = '#servers/lobby/overview'; run(context, 'applyNavigationRoute()'); + assert.deepEqual([...context.workspace.selectedTargetIds], ['velocity']); + assert.equal(context.selectedServerId, 'velocity'); + assert.equal(context.window.location.hash, '#servers/velocity/overview'); +}); + +test('node pagination retains the first doctor page snapshot independently of later pages', async () => { + const context = harness(); + let pageNumber = 0; + Object.assign(context, {PAGE_SIZE: 100, MAX_REGISTRY_SCAN_ATTEMPTS: 1, + authorized: async () => { + const page = pageNumber++; + return {items: Array.from({length: 100}, (_, i) => ({nodeId: `node-${page * 100 + i}`})), + registryRevision: 7, total: 200, registrySnapshot: (page ? 'b' : 'a').repeat(64), + backendItemsReturned: 0, backendItemsTruncated: false, backendItemsTruncatedNodeIds: []}; + }}); + vm.runInContext(declaration('loadAllNodes'), context); + const registry = await context.loadAllNodes(); + assert.equal(registry.items.length, 200); + assert.equal(registry.pageMetadata.get(0).registrySnapshot, 'a'.repeat(64)); + assert.equal(registry.pageMetadata.get(100).registrySnapshot, 'b'.repeat(64)); +}); diff --git a/src/test/web/network-doctor.test.cjs b/src/test/web/network-doctor.test.cjs new file mode 100644 index 0000000..711c584 --- /dev/null +++ b/src/test/web/network-doctor.test.cjs @@ -0,0 +1,82 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const {groups, render, withConfigurationChecks} = 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:'