diff --git a/README.md b/README.md index a3011f0..9b7463f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,14 @@ # VotifierPlus Fork of votifier + +## Optional Network Doctor diagnostics + +VotifierPlus exposes a read-only `getNetworkHealthSnapshot()` method on its +Bukkit, BungeeCord, and Velocity entry points for compatible management +software. The immutable snapshot reports whether the provider is present, the +listener is initialized, whether forwarding state is known, and the names of +enabled forwarding destinations. Destination names are bounded and +deduplicated; hosts, ports, keys, tokens, credentials, and configuration text +are never included. The API is optional and does not participate in vote +receipt, event dispatch, or forwarding, so older VotingPlugin and Control +nodes continue to work without it. diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/VotifierPlus.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/VotifierPlus.java index f201380..fff2497 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/VotifierPlus.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/VotifierPlus.java @@ -55,6 +55,8 @@ import com.vexsoftware.votifier.crypto.RSAIO; import com.vexsoftware.votifier.crypto.RSAKeygen; import com.vexsoftware.votifier.crypto.TokenUtil; +import com.vexsoftware.votifier.diagnostic.VotifierDiagnostics; +import com.vexsoftware.votifier.diagnostic.VotifierDiagnosticsSnapshot; import com.vexsoftware.votifier.model.Vote; import com.vexsoftware.votifier.model.VotifierEvent; import com.vexsoftware.votifier.net.ThrottleConfig; @@ -72,6 +74,11 @@ */ public class VotifierPlus extends JavaPlugin { + /** Optional read-only management-plane snapshot; never participates in vote processing. */ + public VotifierDiagnosticsSnapshot getNetworkHealthSnapshot() { + return VotifierDiagnostics.snapshot(voteReceiver); + } + /** The Votifier instance. */ private static VotifierPlus instance; @@ -83,7 +90,7 @@ public class VotifierPlus extends JavaPlugin { private Updater updater; /** The vote receiver. */ - private VoteReceiver voteReceiver; + private volatile VoteReceiver voteReceiver; /** The RSA key pair. */ @Setter diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/bungee/VotifierPlusBungee.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/bungee/VotifierPlusBungee.java index ef788a3..238d650 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/bungee/VotifierPlusBungee.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/bungee/VotifierPlusBungee.java @@ -16,9 +16,11 @@ import java.util.Set; import com.vexsoftware.votifier.ForwardServer; -import com.vexsoftware.votifier.crypto.RSAIO; -import com.vexsoftware.votifier.crypto.RSAKeygen; -import com.vexsoftware.votifier.crypto.TokenUtil; +import com.vexsoftware.votifier.crypto.RSAIO; +import com.vexsoftware.votifier.crypto.RSAKeygen; +import com.vexsoftware.votifier.crypto.TokenUtil; +import com.vexsoftware.votifier.diagnostic.VotifierDiagnostics; +import com.vexsoftware.votifier.diagnostic.VotifierDiagnosticsSnapshot; import com.vexsoftware.votifier.model.Vote; import com.vexsoftware.votifier.net.ThrottleConfig; import com.vexsoftware.votifier.net.VoteReceiver; @@ -30,10 +32,15 @@ import net.md_5.bungee.config.Configuration; import net.md_5.bungee.config.ConfigurationProvider; -public class VotifierPlusBungee extends Plugin { +public class VotifierPlusBungee extends Plugin { + + /** Optional read-only management-plane snapshot; never participates in vote processing. */ + public VotifierDiagnosticsSnapshot getNetworkHealthSnapshot() { + return VotifierDiagnostics.snapshot(voteReceiver); + } private VotifierPlusBungee instance; @Getter - private VoteReceiver voteReceiver; + private volatile VoteReceiver voteReceiver; @Getter private Config config; @Getter diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/diagnostic/VotifierDiagnostics.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/diagnostic/VotifierDiagnostics.java new file mode 100644 index 0000000..e7cceaf --- /dev/null +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/diagnostic/VotifierDiagnostics.java @@ -0,0 +1,54 @@ +package com.vexsoftware.votifier.diagnostic; + +import java.util.ArrayList; +import java.util.List; + +import com.vexsoftware.votifier.ForwardServer; +import com.vexsoftware.votifier.net.VoteReceiver; + +/** Optional adapter used by management software without a hard dependency. */ +public final class VotifierDiagnostics { + private VotifierDiagnostics() { + } + + /** + * Captures only state already owned by the receiver. A null receiver means + * that listener state is unavailable, rather than falsely healthy. + */ + public static VotifierDiagnosticsSnapshot snapshot(VoteReceiver receiver) { + if (receiver == null) { + return new VotifierDiagnosticsSnapshot(Boolean.TRUE, null, null, null); + } + List destinations = new ArrayList(); + boolean forwardingKnown = true; + try { + for (String name : receiver.getServers()) { + if (name == null || name.trim().isEmpty()) { + // An unrepresentable configured entry means the forwarding inventory is incomplete. + forwardingKnown = false; + continue; + } + ForwardServer server = receiver.getServerData(name); + if (server == null) { + // A configured entry that cannot be classified is incomplete evidence. + forwardingKnown = false; + } else if (server.isEnabled()) { + if (name.length() > VotifierDiagnosticsSnapshot.MAX_NAME_LENGTH + || destinations.size() >= VotifierDiagnosticsSnapshot.MAX_DESTINATIONS) { + // Do not claim complete evidence after the bounded snapshot omitted a risk. + forwardingKnown = false; + continue; + } + // Preserve configured identity; snapshot validation downgrades noncanonical names. + destinations.add(name); + } + } + } catch (RuntimeException unavailable) { + forwardingKnown = false; + destinations.clear(); + } + return new VotifierDiagnosticsSnapshot(Boolean.TRUE, + receiver.getServer() != null && !receiver.getServer().isClosed(), + forwardingKnown, destinations); + } +} diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/diagnostic/VotifierDiagnosticsSnapshot.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/diagnostic/VotifierDiagnosticsSnapshot.java new file mode 100644 index 0000000..b1070f1 --- /dev/null +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/diagnostic/VotifierDiagnosticsSnapshot.java @@ -0,0 +1,88 @@ +package com.vexsoftware.votifier.diagnostic; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Bounded, read-only Votifier state for optional management-plane diagnostics. + * + *

This type deliberately contains destination names only. It does not expose + * forwarding endpoints, ports, keys, tokens, or configuration text.

+ */ +public final class VotifierDiagnosticsSnapshot { + public static final int MAX_DESTINATIONS = 100; + public static final int MAX_NAME_LENGTH = 80; + + private final Boolean providerPresent; + private final Boolean listenerInitialized; + private final Boolean forwardingKnown; + private final List forwardingDestinations; + + public VotifierDiagnosticsSnapshot(Boolean providerPresent, Boolean listenerInitialized, + Boolean forwardingKnown, Iterable forwardingDestinations) { + this.providerPresent = providerPresent; + this.listenerInitialized = listenerInitialized; + BoundedNames bounded = boundedNames(forwardingDestinations); + this.forwardingKnown = forwardingKnown == null || !Boolean.TRUE.equals(forwardingKnown) + ? forwardingKnown : bounded.complete ? Boolean.TRUE : Boolean.FALSE; + this.forwardingDestinations = bounded.names; + } + + public Boolean getProviderPresent() { + return providerPresent; + } + + public Boolean getListenerInitialized() { + return listenerInitialized; + } + + public Boolean getForwardingKnown() { + return forwardingKnown; + } + + public List getForwardingDestinations() { + return forwardingDestinations; + } + + private static BoundedNames boundedNames(Iterable values) { + if (values == null) { + return new BoundedNames(Collections.emptyList(), true); + } + Set unique = new LinkedHashSet(); + boolean complete = true; + for (String value : values) { + if (value == null) { + complete = false; + continue; + } + String name = value.trim(); + if (name.isEmpty() || name.length() > MAX_NAME_LENGTH) { + complete = false; + continue; + } + if (!name.equals(value) || name.codePoints().anyMatch(Character::isISOControl)) { + complete = false; + continue; + } + unique.add(name); + if (unique.size() > MAX_DESTINATIONS) { + complete = false; + unique.remove(name); + } + } + return new BoundedNames(Collections.unmodifiableList(new ArrayList(unique)), complete); + } + + private static final class BoundedNames { + private final List names; + private final boolean complete; + + private BoundedNames(List names, boolean complete) { + this.names = names; + this.complete = complete; + } + } +} diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/velocity/VotifierPlusVelocity.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/velocity/VotifierPlusVelocity.java index f1b5889..e4db590 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/velocity/VotifierPlusVelocity.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/velocity/VotifierPlusVelocity.java @@ -31,9 +31,11 @@ import com.velocitypowered.api.plugin.annotation.DataDirectory; import com.velocitypowered.api.proxy.ProxyServer; import com.vexsoftware.votifier.ForwardServer; -import com.vexsoftware.votifier.crypto.RSAIO; -import com.vexsoftware.votifier.crypto.RSAKeygen; -import com.vexsoftware.votifier.crypto.TokenUtil; +import com.vexsoftware.votifier.crypto.RSAIO; +import com.vexsoftware.votifier.crypto.RSAKeygen; +import com.vexsoftware.votifier.crypto.TokenUtil; +import com.vexsoftware.votifier.diagnostic.VotifierDiagnostics; +import com.vexsoftware.votifier.diagnostic.VotifierDiagnosticsSnapshot; import com.vexsoftware.votifier.model.Vote; import com.vexsoftware.votifier.net.ThrottleConfig; import com.vexsoftware.votifier.net.VoteReceiver; @@ -44,9 +46,14 @@ @Plugin(id = "votifierplus", name = "VotifierPlus", version = "1.0", url = "https://www.spigotmc.org/resources/votifierplus.74040", description = "Votifier Velocity Version", authors = { "BenCodez" }) -public class VotifierPlusVelocity { +public class VotifierPlusVelocity { + + /** Optional read-only management-plane snapshot; never participates in vote processing. */ + public VotifierDiagnosticsSnapshot getNetworkHealthSnapshot() { + return VotifierDiagnostics.snapshot(voteReceiver); + } @Getter - private VoteReceiver voteReceiver; + private volatile VoteReceiver voteReceiver; @Getter private Config config; @Getter diff --git a/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VotifierDiagnosticsTest.java b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VotifierDiagnosticsTest.java new file mode 100644 index 0000000..c61122a --- /dev/null +++ b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VotifierDiagnosticsTest.java @@ -0,0 +1,161 @@ +package com.bencodez.votifierplus.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.security.Key; +import java.security.KeyPair; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import com.vexsoftware.votifier.ForwardServer; +import com.vexsoftware.votifier.diagnostic.VotifierDiagnostics; +import com.vexsoftware.votifier.diagnostic.VotifierDiagnosticsSnapshot; +import com.vexsoftware.votifier.model.Vote; +import com.vexsoftware.votifier.net.ThrottleConfig; +import com.vexsoftware.votifier.net.VoteReceiver; + +class VotifierDiagnosticsTest { + @Test + void unavailableReceiverDoesNotBecomeHealthy() { + VotifierDiagnosticsSnapshot snapshot = VotifierDiagnostics.snapshot(null); + + assertEquals(Boolean.TRUE, snapshot.getProviderPresent()); + assertNull(snapshot.getListenerInitialized()); + assertNull(snapshot.getForwardingKnown()); + assertTrue(snapshot.getForwardingDestinations().isEmpty()); + } + + @Test + void snapshotContainsOnlyEnabledBoundedDestinationNames() throws Exception { + TestReceiver receiver = new TestReceiver(); + VotifierDiagnosticsSnapshot snapshot = VotifierDiagnostics.snapshot(receiver); + + assertEquals(Boolean.TRUE, snapshot.getProviderPresent()); + assertEquals(Boolean.TRUE, snapshot.getListenerInitialized()); + assertEquals(Boolean.TRUE, snapshot.getForwardingKnown()); + assertEquals(Collections.singletonList("backend"), snapshot.getForwardingDestinations()); + assertFalse(snapshot.getForwardingDestinations().toString().contains("secret-token")); + receiver.getServer().close(); + assertEquals(Boolean.FALSE, VotifierDiagnostics.snapshot(receiver).getListenerInitialized()); + } + + @Test + void snapshotBoundsAndDeduplicatesNames() { + String longName = "x".repeat(81); + VotifierDiagnosticsSnapshot snapshot = new VotifierDiagnosticsSnapshot(Boolean.TRUE, Boolean.FALSE, + Boolean.TRUE, Arrays.asList("backend", "backend", "", longName, "other")); + + assertEquals(Arrays.asList("backend", "other"), snapshot.getForwardingDestinations()); + assertThrowsUnsupported(snapshot.getForwardingDestinations()); + } + + @Test + void excessEnabledDestinationsMakeForwardingEvidenceUnknown() throws Exception { + TestReceiver receiver = new TestReceiver(101); + receiver.getServer().close(); + VotifierDiagnosticsSnapshot snapshot = VotifierDiagnostics.snapshot(receiver); + + assertEquals(Boolean.FALSE, snapshot.getListenerInitialized()); + assertEquals(Boolean.FALSE, snapshot.getForwardingKnown()); + assertEquals(100, snapshot.getForwardingDestinations().size()); + } + + @Test + void distinctWhitespacePaddedTargetsDoNotBecomeCompleteNormalizedEvidence() throws Exception { + TestReceiver receiver = new TestReceiver(-1); + try { + VotifierDiagnosticsSnapshot snapshot = VotifierDiagnostics.snapshot(receiver); + assertEquals(Boolean.FALSE, snapshot.getForwardingKnown()); + assertEquals(List.of("backend"), snapshot.getForwardingDestinations()); + } finally { + receiver.getServer().close(); + } + } + + @Test + void unclassifiableConfiguredTargetDoesNotBecomeDisabledForwarding() throws Exception { + TestReceiver receiver = new TestReceiver(-2); + try { + VotifierDiagnosticsSnapshot snapshot = VotifierDiagnostics.snapshot(receiver); + assertEquals(Boolean.FALSE, snapshot.getForwardingKnown()); + assertEquals(List.of(), snapshot.getForwardingDestinations()); + } finally { + receiver.getServer().close(); + } + } + + @Test + void receiverReplacementsAreSafelyPublishedOnEveryPlatform() throws Exception { + for (String type : List.of("com.vexsoftware.votifier.VotifierPlus", + "com.vexsoftware.votifier.bungee.VotifierPlusBungee", + "com.vexsoftware.votifier.velocity.VotifierPlusVelocity")) { + assertTrue(java.lang.reflect.Modifier.isVolatile(Class.forName(type) + .getDeclaredField("voteReceiver").getModifiers()), type); + } + } + + @Test + void platformEntryPointsExposeTheOptionalAccessor() throws Exception { + assertTrue(Class.forName("com.vexsoftware.votifier.VotifierPlus") + .getMethod("getNetworkHealthSnapshot") != null); + assertTrue(Class.forName("com.vexsoftware.votifier.bungee.VotifierPlusBungee") + .getMethod("getNetworkHealthSnapshot") != null); + assertTrue(Class.forName("com.vexsoftware.votifier.velocity.VotifierPlusVelocity") + .getMethod("getNetworkHealthSnapshot") != null); + } + + private static void assertThrowsUnsupported(List names) { + try { + names.add("unexpected"); + throw new AssertionError("snapshot list must be immutable"); + } catch (UnsupportedOperationException expected) { + // expected + } + } + + private static final class TestReceiver extends VoteReceiver { + private final int destinationCount; + + TestReceiver() throws Exception { + this(2); + } + + TestReceiver(int destinationCount) throws Exception { + super("127.0.0.1", 0); + this.destinationCount = destinationCount; + } + + @Override public boolean isUseTokens() { return false; } + @Override public ThrottleConfig getThrottleConfig() { return null; } + @Override public void logWarning(String warn) { } + @Override public void logSevere(String msg) { } + @Override public void log(String msg) { } + @Override public void debug(String msg) { } + @Override public void debug(Exception e) { } + @Override public String getVersion() { return "test"; } + @Override public Set getServers() { + if (destinationCount == -1) return Set.of("backend", " backend "); + if (destinationCount == -2) return Set.of("missing"); + if (destinationCount == 2) return Set.of("backend", "disabled"); + java.util.LinkedHashSet names = new java.util.LinkedHashSet(); + for (int i = 0; i < destinationCount; i++) names.add("backend-" + i); + return names; + } + @Override public KeyPair getKeyPair() { return null; } + @Override public Map getTokens() { return Collections.emptyMap(); } + @Override public ForwardServer getServerData(String name) { + if ("missing".equals(name)) return null; + return new ForwardServer(!"disabled".equals(name), "private-host", 8192, "secret-key", + null); + } + @Override public void callEvent(Vote vote) { } + } +}