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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions docs/control-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
113 changes: 113 additions & 0 deletions docs/network-doctor.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> PLATFORMS = Set.of("BUNGEECORD", "VELOCITY", "BUKKIT");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand All @@ -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<String, JsonNode> networkEvidence(List<NodeStatus> nodes) {
prune();
Map<String, JsonNode> 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<String, JsonNode> 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);
Expand All @@ -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());
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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");
}
Expand Down
Loading
Loading