diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/bungee/Config.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/bungee/Config.java index 4531f53..f5c46ef 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/bungee/Config.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/bungee/Config.java @@ -6,6 +6,8 @@ import java.nio.file.Files; import java.util.Set; +import com.vexsoftware.votifier.net.VoteProtocolPolicy; + import lombok.Getter; import net.md_5.bungee.config.Configuration; import net.md_5.bungee.config.ConfigurationProvider; @@ -36,6 +38,7 @@ public void load() { try { data = ConfigurationProvider.getProvider(YamlConfiguration.class) .load(new File(bungee.getDataFolder(), "bungeeconfig.yml")); + VoteProtocolPolicy.setDisableV1(getDisableV1()); } catch (IOException e) { e.printStackTrace(); } @@ -78,6 +81,10 @@ public boolean getTokenSupport() { return getData().getBoolean("TokenSupport", false); } + public boolean getDisableV1() { + return getData().getBoolean("DisableV1", false); + } + public String getToken(String key) { return getData().getString("tokens." + key, null); } diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/config/Config.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/config/Config.java index d34c329..35f21e3 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/config/Config.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/config/Config.java @@ -14,6 +14,7 @@ import com.bencodez.simpleapi.file.annotation.ConfigDataKeys; import com.bencodez.simpleapi.file.annotation.ConfigDataString; import com.vexsoftware.votifier.VotifierPlus; +import com.vexsoftware.votifier.net.VoteProtocolPolicy; import lombok.Getter; import lombok.Setter; @@ -27,6 +28,13 @@ public Config(VotifierPlus plugin) { public void loadValues() { new AnnotationHandler().load(getData(), this); debug = DebugLevel.getDebug(debugLevelStr); + VoteProtocolPolicy.setDisableV1(disableV1); + } + + @Override + public void reloadData() { + super.reloadData(); + VoteProtocolPolicy.setDisableV1(getData().getBoolean("DisableV1", false)); } @Override @@ -80,6 +88,11 @@ public void onFileCreation() { @Setter private boolean tokenSupport = false; + @ConfigDataBoolean(path = "DisableV1") + @Getter + @Setter + private boolean disableV1 = false; + public ConfigurationSection getForwardingConfiguration(String s) { return getData().getConfigurationSection("Forwarding." + s); } diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/ProxyHeaderProcessor.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/ProxyHeaderProcessor.java index 23cf855..6beed38 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/ProxyHeaderProcessor.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/ProxyHeaderProcessor.java @@ -6,16 +6,29 @@ */ package com.vexsoftware.votifier.net; -import java.io.ByteArrayOutputStream; import java.io.BufferedWriter; +import java.io.ByteArrayOutputStream; import java.io.PushbackInputStream; +import java.net.Socket; +import java.net.SocketException; +import java.net.SocketTimeoutException; import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; import lombok.Getter; import lombok.Setter; public class ProxyHeaderProcessor { + private static final int MAX_PROXY_V1_HEADER_BYTES = 107; + private static final int MAX_CONNECT_LINE_BYTES = 8192; + private static final int MAX_CONNECT_HEADERS = 100; + private static final int MAX_CONNECT_HEADER_BYTES = 32768; + private static final int HEADER_READ_TIMEOUT_MILLIS = 5000; + private static final int DISCARD_BUFFER_BYTES = 1024; + + private static final byte[] PROXY_V1_PREFIX = "PROXY".getBytes(StandardCharsets.US_ASCII); + private static final byte[] CONNECT_PREFIX = "CONNECT".getBytes(StandardCharsets.US_ASCII); private static final byte[] PROXY_V2_SIGNATURE = new byte[] { 0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A }; @@ -27,109 +40,218 @@ public static class ProxyHeaderResult { public ProxyHeaderResult process(PushbackInputStream in, BufferedWriter writer, VoteReceiver receiver) throws Exception { - ProxyHeaderResult result = new ProxyHeaderResult(); + return process(in, writer, receiver, null); + } - byte[] headerPeek = new byte[32]; - int bytesRead = in.read(headerPeek); - if (bytesRead <= 0) { - return result; - } + public ProxyHeaderResult process(PushbackInputStream in, BufferedWriter writer, VoteReceiver receiver, Socket socket) + throws Exception { + int previousTimeout = socket == null ? 0 : socket.getSoTimeout(); + long deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(HEADER_READ_TIMEOUT_MILLIS); - String headerString = new String(headerPeek, 0, bytesRead, StandardCharsets.US_ASCII); + try { + return processWithDeadline(in, writer, receiver, socket, deadlineNanos); + } finally { + if (socket != null && !socket.isClosed()) { + try { + socket.setSoTimeout(previousTimeout); + } catch (SocketException ex) { + receiver.debug(ex); + } + } + } + } - if (headerString.startsWith("PROXY") && !headerString.contains("CONNECT")) { - in.unread(headerPeek, 0, bytesRead); + private ProxyHeaderResult processWithDeadline(PushbackInputStream in, BufferedWriter writer, VoteReceiver receiver, + Socket socket, long deadlineNanos) throws Exception { + ProxyHeaderResult result = new ProxyHeaderResult(); + byte[] prefix = new byte[16]; + int bytesRead = readPrefix(in, prefix, 1, socket, deadlineNanos); + if (bytesRead == 0) { + return result; + } - String proxyHeader = readLine(in); - receiver.debug("Discarded PROXY (v1) header: " + proxyHeader); + if (prefix[0] == PROXY_V1_PREFIX[0]) { + bytesRead = readPrefix(in, prefix, PROXY_V1_PREFIX.length, bytesRead, socket, deadlineNanos); + if (bytesRead == PROXY_V1_PREFIX.length && startsWith(prefix, bytesRead, PROXY_V1_PREFIX)) { + in.unread(prefix, 0, bytesRead); + String proxyHeader = readLine(in, socket, deadlineNanos, MAX_PROXY_V1_HEADER_BYTES, null, + "PROXY protocol v1 header exceeds " + MAX_PROXY_V1_HEADER_BYTES + " bytes"); + receiver.debug("Discarded PROXY (v1) header: " + proxyHeader); - String[] parts = proxyHeader.split("\\s+"); - if (parts.length >= 3) { - String srcIp = parts[2].trim(); - if (!srcIp.isEmpty()) { - result.setRealIp(srcIp); + String[] parts = proxyHeader.split("\\s+"); + if (parts.length >= 3) { + String srcIp = parts[2].trim(); + if (!srcIp.isEmpty()) { + result.setRealIp(srcIp); + } } + return result; } - return result; } - if (bytesRead >= 16 && isProxyV2(headerPeek)) { - int addrLength = ((headerPeek[14] & 0xFF) << 8) | (headerPeek[15] & 0xFF); - int totalLength = 16 + addrLength; - int remaining = totalLength - bytesRead; - - if (remaining > 0) { - byte[] discard = new byte[remaining]; - int read = 0; - while (read < remaining) { - int r = in.read(discard, read, remaining - read); - if (r == -1) { + if (prefix[0] == CONNECT_PREFIX[0]) { + bytesRead = readPrefix(in, prefix, CONNECT_PREFIX.length, bytesRead, socket, deadlineNanos); + if (bytesRead == CONNECT_PREFIX.length && startsWith(prefix, bytesRead, CONNECT_PREFIX)) { + in.unread(prefix, 0, bytesRead); + int[] totalHeaderBytes = new int[1]; + String connectLine = readLine(in, socket, deadlineNanos, MAX_CONNECT_LINE_BYTES, totalHeaderBytes, + "HTTP CONNECT header line exceeds " + MAX_CONNECT_LINE_BYTES + " bytes"); + receiver.debug("Received CONNECT request: " + connectLine); + + int headerCount = 0; + while (true) { + String line = readLine(in, socket, deadlineNanos, MAX_CONNECT_LINE_BYTES, totalHeaderBytes, + "HTTP CONNECT header line exceeds " + MAX_CONNECT_LINE_BYTES + " bytes"); + if (line.isEmpty()) { break; } - read += r; + if (++headerCount > MAX_CONNECT_HEADERS) { + throw new InvalidVoteException("Too many HTTP CONNECT headers"); + } + receiver.debug("Discarding header: " + line); } - if (read != remaining) { - throw new Exception("Incomplete PROXY protocol v2 header"); - } + writer.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + writer.flush(); + return result; } + } - receiver.debug("Discarded PROXY protocol v2 header (" + totalLength + " bytes)"); - return result; + if ((prefix[0] & 0xFF) == (PROXY_V2_SIGNATURE[0] & 0xFF)) { + bytesRead = readPrefix(in, prefix, 16, bytesRead, socket, deadlineNanos); + if (bytesRead < 16 && matchesPrefix(prefix, bytesRead, PROXY_V2_SIGNATURE)) { + throw new InvalidVoteException("Incomplete PROXY protocol v2 header"); + } + if (bytesRead == 16 && startsWith(prefix, bytesRead, PROXY_V2_SIGNATURE)) { + int addressLength = ((prefix[14] & 0xFF) << 8) | (prefix[15] & 0xFF); + discardFully(in, addressLength, socket, deadlineNanos); + receiver.debug("Discarded PROXY protocol v2 header (" + (16 + addressLength) + " bytes)"); + return result; + } } - if (headerString.startsWith("CONNECT")) { - in.unread(headerPeek, 0, bytesRead); + in.unread(prefix, 0, bytesRead); + return result; + } - String connectLine = readLine(in); - receiver.debug("Received CONNECT request: " + connectLine); + private int readPrefix(PushbackInputStream in, byte[] prefix, int targetLength, Socket socket, long deadlineNanos) + throws Exception { + return readPrefix(in, prefix, targetLength, 0, socket, deadlineNanos); + } - String line; - while (!(line = readLine(in)).isEmpty()) { - receiver.debug("Discarding header: " + line); + private int readPrefix(PushbackInputStream in, byte[] prefix, int targetLength, int offset, Socket socket, + long deadlineNanos) throws Exception { + int read = offset; + while (read < targetLength) { + int value = readByteWithDeadline(in, socket, deadlineNanos); + if (value == -1) { + break; } - - writer.write("HTTP/1.1 200 Connection Established\r\n\r\n"); - writer.flush(); - return result; + prefix[read++] = (byte) value; } - - in.unread(headerPeek, 0, bytesRead); - return result; + return read; } - private boolean isProxyV2(byte[] header) { - for (int i = 0; i < PROXY_V2_SIGNATURE.length; i++) { - if (header[i] != PROXY_V2_SIGNATURE[i]) { - return false; + private void discardFully(PushbackInputStream in, int length, Socket socket, long deadlineNanos) throws Exception { + byte[] discard = new byte[Math.min(DISCARD_BUFFER_BYTES, Math.max(1, length))]; + int remaining = length; + while (remaining > 0) { + int read = readWithDeadline(in, discard, 0, Math.min(discard.length, remaining), socket, deadlineNanos); + if (read == -1) { + throw new InvalidVoteException("Incomplete PROXY protocol v2 header"); } + remaining -= read; } - return true; } - private String readLine(PushbackInputStream in) throws Exception { - ByteArrayOutputStream lineBuffer = new ByteArrayOutputStream(); - int b; - boolean seenCR = false; + private String readLine(PushbackInputStream in, Socket socket, long deadlineNanos, int maxLineBytes, + int[] totalBytes, String overflowMessage) throws Exception { + ByteArrayOutputStream lineBuffer = new ByteArrayOutputStream(Math.min(128, maxLineBytes)); + int lineBytes = 0; + + while (true) { + int value = readByteWithDeadline(in, socket, deadlineNanos); + if (value == -1) { + throw new InvalidVoteException("Unexpected end of stream while reading proxy/tunnel headers"); + } - while ((b = in.read()) != -1) { - if (b == '\r') { - seenCR = true; - continue; + lineBytes++; + if (lineBytes > maxLineBytes) { + throw new InvalidVoteException(overflowMessage); } + incrementTotalBytes(totalBytes); - if (b == '\n') { + if (value == '\n') { break; } - if (seenCR) { - in.unread(b); + if (value == '\r') { + int next = readByteWithDeadline(in, socket, deadlineNanos); + if (next == -1) { + throw new InvalidVoteException("Unexpected end of stream while reading proxy/tunnel headers"); + } + + lineBytes++; + if (lineBytes > maxLineBytes) { + throw new InvalidVoteException(overflowMessage); + } + incrementTotalBytes(totalBytes); + + if (next != '\n') { + throw new InvalidVoteException("Invalid line ending in proxy/tunnel headers"); + } break; } - lineBuffer.write(b); + lineBuffer.write(value); + } + + return lineBuffer.toString(StandardCharsets.US_ASCII.name()).trim(); + } + + private void incrementTotalBytes(int[] totalBytes) throws InvalidVoteException { + if (totalBytes != null && ++totalBytes[0] > MAX_CONNECT_HEADER_BYTES) { + throw new InvalidVoteException("HTTP CONNECT headers exceed " + MAX_CONNECT_HEADER_BYTES + " bytes"); } + } - return lineBuffer.toString("ASCII").trim(); + private int readByteWithDeadline(PushbackInputStream in, Socket socket, long deadlineNanos) throws Exception { + setRemainingTimeout(socket, deadlineNanos); + return in.read(); + } + + private int readWithDeadline(PushbackInputStream in, byte[] buffer, int offset, int length, Socket socket, + long deadlineNanos) throws Exception { + setRemainingTimeout(socket, deadlineNanos); + return in.read(buffer, offset, length); + } + + private void setRemainingTimeout(Socket socket, long deadlineNanos) throws Exception { + long remainingNanos = deadlineNanos - System.nanoTime(); + if (remainingNanos <= 0) { + throw new SocketTimeoutException("Timed out reading proxy/tunnel headers"); + } + + if (socket != null) { + long timeoutMillis = TimeUnit.NANOSECONDS.toMillis(remainingNanos); + if (TimeUnit.MILLISECONDS.toNanos(timeoutMillis) < remainingNanos) { + timeoutMillis++; + } + socket.setSoTimeout((int) Math.max(1L, Math.min(Integer.MAX_VALUE, timeoutMillis))); + } + } + + private boolean startsWith(byte[] data, int dataLength, byte[] expected) { + return dataLength >= expected.length && matchesPrefix(data, expected.length, expected); + } + + private boolean matchesPrefix(byte[] data, int dataLength, byte[] expected) { + int length = Math.min(dataLength, expected.length); + for (int i = 0; i < length; i++) { + if (data[i] != expected[i]) { + return false; + } + } + return true; } -} \ No newline at end of file +} diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteConnectionHandler.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteConnectionHandler.java index 118cb41..143cbc3 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteConnectionHandler.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteConnectionHandler.java @@ -68,7 +68,8 @@ public Vote handle(Socket socket) { } String realIp = null; - ProxyHeaderProcessor.ProxyHeaderResult proxyResult = proxyHeaderProcessor.process(in, writer, receiver); + ProxyHeaderProcessor.ProxyHeaderResult proxyResult = proxyHeaderProcessor.process(in, writer, receiver, + accepted); realIp = proxyResult.getRealIp(); realIpKnown = realIp != null && !realIp.isEmpty(); @@ -85,12 +86,8 @@ public Vote handle(Socket socket) { VoteProtocolVersion version = voteParser.detectVersion(in); receiver.debug("Detected vote protocol version: " + version); - if (version == VoteProtocolVersion.V1 && in.available() < 256) { - throttleService.fail(throttleKey, tunnelMode, realIpKnown); - throttleService.logWarning(receiver, "shortv1|" + throttleKey, - "Invalid vote format: Insufficient data for V1 vote block from " - + (realIpKnown ? realIp : remoteIp) + " (expected 256 bytes)"); - return null; + if (receiver.isDisableV1() && version == VoteProtocolVersion.V1) { + throw new VoteAuthenticationException("Votifier V1 votes are disabled by configuration"); } VoteRequest request = voteParser.parse(in, version, receiver, address, challenge); @@ -162,13 +159,11 @@ public Vote handle(Socket socket) { private void sendHandshakeIfNeeded(PushbackInputStream in, BufferedWriter writer, String challenge) throws Exception { - String message = receiver.isUseTokens() ? "VOTIFIER 2" : "VOTIFIER 1"; - if (receiver.isUseTokens()) { - message += " " + challenge; - } + boolean useV2Handshake = receiver.isUseTokens() || receiver.isDisableV1(); + String message = useV2Handshake ? "VOTIFIER 2 " + challenge : "VOTIFIER 1"; int available = in.available(); - if (available >= 256) { + if (available >= 256 && !receiver.isDisableV1()) { receiver.debug("Detected V1 vote payload before handshake (available bytes: " + available + "), skipping handshake."); return; diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteProtocolPolicy.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteProtocolPolicy.java new file mode 100644 index 0000000..ce65a22 --- /dev/null +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteProtocolPolicy.java @@ -0,0 +1,21 @@ +package com.vexsoftware.votifier.net; + +/** + * Process-wide protocol policy for the single VotifierPlus listener owned by a + * plugin class loader. + */ +public final class VoteProtocolPolicy { + + private static volatile boolean disableV1; + + private VoteProtocolPolicy() { + } + + public static boolean isDisableV1() { + return disableV1; + } + + public static void setDisableV1(boolean disableV1) { + VoteProtocolPolicy.disableV1 = disableV1; + } +} diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteReceiver.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteReceiver.java index b4aee65..11d2ef3 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteReceiver.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteReceiver.java @@ -60,6 +60,7 @@ */ package com.vexsoftware.votifier.net; +import java.io.IOException; import java.net.InetSocketAddress; import java.net.ServerSocket; import java.net.Socket; @@ -72,9 +73,11 @@ import java.util.Base64; import java.util.Map; import java.util.Set; +import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import javax.crypto.Cipher; @@ -138,7 +141,7 @@ public void shutdown() { shutdownExecutor(connectionExecutor, "connection"); shutdownExecutor(forwardExecutor, "forward"); } - + private void shutdownExecutor(ExecutorService executor, String name) { if (executor == null) { return; @@ -162,70 +165,116 @@ public int getConnectionWorkerCount() { return 4; } + public int getConnectionQueueCapacity() { + return 64; + } + + public long getConnectionQueueTimeoutMillis() { + return 5000L; + } + public int getForwardWorkerCount() { return 1; } + public int getForwardQueueCapacity() { + return 256; + } + @Override public void run() { throttleService = new VoteThrottleService(getThrottleConfig()); voteForwarder = new VoteForwarder(this); - connectionExecutor = Executors.newFixedThreadPool(getConnectionWorkerCount(), new ThreadFactory() { - private int id = 1; + if (isUseTokens() && !isDisableV1()) { + logWarning("TokenSupport is enabled, but legacy Votifier V1 votes are still accepted. " + + "Set DisableV1: true to require token-authenticated V2 votes."); + } - @Override - public Thread newThread(Runnable r) { - Thread thread = new Thread(r, "Votifier-Connection-" + id++); - thread.setDaemon(true); - return thread; - } - }); - - forwardExecutor = Executors.newFixedThreadPool(getForwardWorkerCount(), new ThreadFactory() { - @Override - public Thread newThread(Runnable r) { - Thread thread = new Thread(r, "Votifier-Forwarder"); - thread.setDaemon(true); - return thread; - } - }); + int connectionWorkerCount = getConnectionWorkerCount(); + connectionExecutor = new ThreadPoolExecutor(connectionWorkerCount, connectionWorkerCount, 0L, + TimeUnit.MILLISECONDS, new ArrayBlockingQueue(getConnectionQueueCapacity()), + new ThreadFactory() { + private int id = 1; + + @Override + public Thread newThread(Runnable r) { + Thread thread = new Thread(r, "Votifier-Connection-" + id++); + thread.setDaemon(true); + return thread; + } + }, new ThreadPoolExecutor.AbortPolicy()); + + int forwardWorkerCount = getForwardWorkerCount(); + forwardExecutor = new ThreadPoolExecutor(forwardWorkerCount, forwardWorkerCount, 0L, TimeUnit.MILLISECONDS, + new ArrayBlockingQueue(getForwardQueueCapacity()), new ThreadFactory() { + private int id = 1; + + @Override + public Thread newThread(Runnable r) { + Thread thread = new Thread(r, "Votifier-Forwarder-" + id++); + thread.setDaemon(true); + return thread; + } + }, new ThreadPoolExecutor.CallerRunsPolicy()); final VoteConnectionHandler handler = new VoteConnectionHandler(this, throttleService); while (running) { try { final Socket socket = server.accept(); + final long acceptedAtNanos = System.nanoTime(); - connectionExecutor.submit(new Runnable() { - @Override - public void run() { - try { - Vote vote = handler.handle(socket); - if (vote != null) { - callEvent(vote); - - final Vote forwardVote = vote; - forwardExecutor.submit(new Runnable() { - @Override - public void run() { - try { - voteForwarder.forwardVote(forwardVote); - } catch (Exception ex) { - logWarning("Error forwarding vote: " - + (ex.getLocalizedMessage() == null ? ex.getClass().getSimpleName() - : ex.getLocalizedMessage())); + try { + socket.setSoTimeout(5000); + } catch (SocketException ex) { + closeConnection(socket); + throw ex; + } + + try { + connectionExecutor.execute(new Runnable() { + @Override + public void run() { + long queueTimeoutMillis = getConnectionQueueTimeoutMillis(); + if (queueTimeoutMillis > 0 && System.nanoTime() - acceptedAtNanos > TimeUnit.MILLISECONDS + .toNanos(queueTimeoutMillis)) { + closeConnection(socket); + debug("Closed stale vote connection after it exceeded the connection queue deadline."); + return; + } + + try { + Vote vote = handler.handle(socket); + if (vote != null) { + callEvent(vote); + + final Vote forwardVote = vote; + forwardExecutor.execute(new Runnable() { + @Override + public void run() { + try { + voteForwarder.forwardVote(forwardVote); + } catch (Exception ex) { + logWarning("Error forwarding vote: " + + (ex.getLocalizedMessage() == null + ? ex.getClass().getSimpleName() + : ex.getLocalizedMessage())); + } } - } - }); + }); + } + } catch (Exception ex) { + logWarning("Error processing vote connection: " + + (ex.getLocalizedMessage() == null ? ex.getClass().getSimpleName() + : ex.getLocalizedMessage())); } - } catch (Exception ex) { - logWarning("Error processing vote connection: " - + (ex.getLocalizedMessage() == null ? ex.getClass().getSimpleName() - : ex.getLocalizedMessage())); } - } - }); + }); + } catch (RejectedExecutionException ex) { + closeConnection(socket); + debug("Rejected vote connection because the connection queue is full."); + } } catch (SocketException ex) { if (running) { logWarning("Connection error while accepting vote socket: " + ex.getLocalizedMessage()); @@ -240,8 +289,25 @@ public void run() { } } + private void closeConnection(Socket socket) { + try { + socket.close(); + } catch (IOException ex) { + debug(ex); + } + } + public abstract boolean isUseTokens(); + /** + * Returns whether legacy Votifier V1 packets must be rejected. + * + * @return true when only V2 votes should be accepted + */ + public boolean isDisableV1() { + return VoteProtocolPolicy.isDisableV1(); + } + public abstract ThrottleConfig getThrottleConfig(); public abstract void logWarning(String warn); @@ -281,4 +347,4 @@ public PublicKey getPublicKey(ForwardServer forwardServer) throws Exception { public String getChallenge() { return com.vexsoftware.votifier.crypto.TokenUtil.newToken(); } -} \ No newline at end of file +} diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/velocity/Config.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/velocity/Config.java index f361713..83dd27f 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/velocity/Config.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/velocity/Config.java @@ -8,11 +8,13 @@ import org.spongepowered.configurate.serialize.SerializationException; import com.bencodez.simpleapi.file.velocity.VelocityYMLFile; +import com.vexsoftware.votifier.net.VoteProtocolPolicy; public class Config extends VelocityYMLFile { public Config(File file) { super(file); + VoteProtocolPolicy.setDisableV1(getDisableV1()); } public String getHost() { @@ -57,6 +59,11 @@ public void setToken(String key, String token) { } public boolean getTokenSupport() { + VoteProtocolPolicy.setDisableV1(getDisableV1()); return getBoolean(getNode("TokenSupport"), false); } + + public boolean getDisableV1() { + return getBoolean(getNode("DisableV1"), false); + } } diff --git a/VotifierPlus/src/main/resources/bungeeconfig.yml b/VotifierPlus/src/main/resources/bungeeconfig.yml index c15a2fc..0dca089 100644 --- a/VotifierPlus/src/main/resources/bungeeconfig.yml +++ b/VotifierPlus/src/main/resources/bungeeconfig.yml @@ -3,8 +3,11 @@ Debug: false host: 0.0.0.0 # The port VotifierPlus will listen on port: 8192 -# This is still new to VotifierPlus, so it's disabled by default. +# Enables Votifier v2 token/HMAC support while retaining V1 compatibility. TokenSupport: false +# Rejects all legacy Votifier v1 RSA packets and forces a V2 handshake. +# Keep this false if any configured voting site only supports V1. +DisableV1: false # ----------------------------------------------------------------------------- # Connection throttling & spam reduction for Votifier diff --git a/VotifierPlus/src/main/resources/config.yml b/VotifierPlus/src/main/resources/config.yml index 7db19fe..187cd3e 100644 --- a/VotifierPlus/src/main/resources/config.yml +++ b/VotifierPlus/src/main/resources/config.yml @@ -7,8 +7,11 @@ DebugLevel: NONE host: 0.0.0.0 # The port VotifierPlus will listen on port: 8192 -# This is still new to VotifierPlus, so it's disabled by default. +# Enables Votifier v2 token/HMAC support while retaining V1 compatibility. TokenSupport: false +# Rejects all legacy Votifier v1 RSA packets and forces a V2 handshake. +# Keep this false if any configured voting site only supports V1. +DisableV1: false # ----------------------------------------------------------------------------- # Connection throttling & spam reduction for Votifier diff --git a/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/ProxyHeaderProcessorSecurityTest.java b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/ProxyHeaderProcessorSecurityTest.java new file mode 100644 index 0000000..72a32f0 --- /dev/null +++ b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/ProxyHeaderProcessorSecurityTest.java @@ -0,0 +1,265 @@ +package com.bencodez.votifierplus.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.BufferedWriter; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.OutputStreamWriter; +import java.io.PushbackInputStream; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.vexsoftware.votifier.ForwardServer; +import com.vexsoftware.votifier.model.Vote; +import com.vexsoftware.votifier.net.InvalidVoteException; +import com.vexsoftware.votifier.net.ProxyHeaderProcessor; +import com.vexsoftware.votifier.net.ThrottleConfig; +import com.vexsoftware.votifier.net.VoteReceiver; + +/** + * Regression tests for bounded PROXY and HTTP CONNECT parsing. + */ +public class ProxyHeaderProcessorSecurityTest { + + private static KeyPair testKeyPair; + + private ProxyHeaderProcessor processor; + private StubVoteReceiver receiver; + + @BeforeAll + public static void setupClass() throws Exception { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + testKeyPair = keyPairGenerator.generateKeyPair(); + } + + @BeforeEach + public void setup() throws Exception { + processor = new ProxyHeaderProcessor(); + receiver = new StubVoteReceiver("127.0.0.1", 0); + } + + @AfterEach + public void tearDown() { + if (receiver != null) { + receiver.shutdown(); + } + } + + @Test + public void testValidProxyV1HeaderPreservesVotePayload() throws Exception { + String header = "PROXY TCP4 192.0.2.10 192.0.2.20 1234 8192\r\n"; + String payload = "VOTE\nsite\nuser\n127.0.0.1\ntimestamp\n"; + PushbackInputStream input = input(header + payload); + + ProxyHeaderProcessor.ProxyHeaderResult result = processor.process(input, writer(), receiver); + + assertEquals("192.0.2.10", result.getRealIp()); + assertEquals(payload, readRemaining(input)); + } + + @Test + public void testProxyV1HeaderOver107BytesIsRejected() throws Exception { + String oversized = "PROXY " + "A".repeat(100) + "\r\n"; + InvalidVoteException exception = assertThrows(InvalidVoteException.class, + () -> processor.process(input(oversized), writer(), receiver)); + + assertTrue(exception.getMessage().contains("exceeds 107 bytes")); + } + + @Test + public void testValidConnectHeadersPreserveVotePayload() throws Exception { + String headers = "CONNECT vote.example:443 HTTP/1.1\r\nHost: vote.example:443\r\n\r\n"; + String payload = "VOTE\nsite\nuser\n127.0.0.1\ntimestamp\n"; + PushbackInputStream input = input(headers + payload); + ByteArrayOutputStream response = new ByteArrayOutputStream(); + BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(response, StandardCharsets.US_ASCII)); + + processor.process(input, writer, receiver); + writer.flush(); + + assertTrue(response.toString(StandardCharsets.US_ASCII).contains("200 Connection Established")); + assertEquals(payload, readRemaining(input)); + } + + @Test + public void testOversizedConnectRequestLineIsRejected() throws Exception { + String oversized = "CONNECT " + "a".repeat(8185) + "\r\n"; + InvalidVoteException exception = assertThrows(InvalidVoteException.class, + () -> processor.process(input(oversized), writer(), receiver)); + + assertTrue(exception.getMessage().contains("line exceeds 8192 bytes")); + } + + @Test + public void testMoreThan100ConnectHeadersAreRejected() throws Exception { + StringBuilder request = new StringBuilder("CONNECT vote.example:443 HTTP/1.1\r\n"); + for (int i = 0; i < 101; i++) { + request.append("X-Test-").append(i).append(": value\r\n"); + } + request.append("\r\n"); + + InvalidVoteException exception = assertThrows(InvalidVoteException.class, + () -> processor.process(input(request.toString()), writer(), receiver)); + + assertTrue(exception.getMessage().contains("Too many HTTP CONNECT headers")); + } + + @Test + public void testConnectHeadersOver32KiBAreRejected() throws Exception { + StringBuilder request = new StringBuilder("CONNECT vote.example:443 HTTP/1.1\r\n"); + for (int i = 0; i < 5; i++) { + request.append("X-Test: ").append("a".repeat(7000)).append("\r\n"); + } + request.append("\r\n"); + + InvalidVoteException exception = assertThrows(InvalidVoteException.class, + () -> processor.process(input(request.toString()), writer(), receiver)); + + assertTrue(exception.getMessage().contains("headers exceed 32768 bytes")); + } + + @Test + public void testProxyV2ReadsUseDecreasingCumulativeTimeout() throws Exception { + byte[] header = new byte[] { 0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A, + 0x21, 0x11, 0x00, 0x03, 0x01, 0x02, 0x03 }; + ByteArrayInputStream fragmented = new ByteArrayInputStream(header) { + @Override + public synchronized int read(byte[] bytes, int offset, int length) { + if (pos >= 16) { + try { + Thread.sleep(25); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + } + return super.read(bytes, offset, Math.min(length, pos < 16 ? 16 : 1)); + } + }; + PushbackInputStream input = new PushbackInputStream(fragmented, 512); + RecordingSocket socket = new RecordingSocket(); + + processor.process(input, writer(), receiver, socket); + + assertTrue(socket.getRecordedTimeouts().stream().anyMatch(timeout -> timeout < 5000)); + assertEquals(5000, socket.getRecordedTimeouts().get(socket.getRecordedTimeouts().size() - 1)); + } + + private PushbackInputStream input(String value) { + return new PushbackInputStream( + new ByteArrayInputStream(value.getBytes(StandardCharsets.US_ASCII)), 512); + } + + private BufferedWriter writer() { + return new BufferedWriter(new OutputStreamWriter(new ByteArrayOutputStream(), StandardCharsets.US_ASCII)); + } + + private String readRemaining(PushbackInputStream input) throws Exception { + ByteArrayOutputStream remaining = new ByteArrayOutputStream(); + input.transferTo(remaining); + return remaining.toString(StandardCharsets.US_ASCII); + } + + private static class RecordingSocket extends Socket { + + private final List recordedTimeouts = new ArrayList<>(); + private int timeout = 5000; + + @Override + public int getSoTimeout() { + return timeout; + } + + @Override + public void setSoTimeout(int timeout) { + this.timeout = timeout; + recordedTimeouts.add(timeout); + } + + List getRecordedTimeouts() { + return recordedTimeouts; + } + } + + private static class StubVoteReceiver extends VoteReceiver { + + StubVoteReceiver(String host, int port) throws Exception { + super(host, port); + } + + @Override + public boolean isUseTokens() { + return false; + } + + @Override + public void logWarning(String warning) { + } + + @Override + public void logSevere(String message) { + } + + @Override + public void log(String message) { + } + + @Override + public void debug(String message) { + } + + @Override + public void debug(Exception exception) { + } + + @Override + public String getVersion() { + return "Test"; + } + + @Override + public Set getServers() { + return Collections.emptySet(); + } + + @Override + public KeyPair getKeyPair() { + return testKeyPair; + } + + @Override + public Map getTokens() { + return Collections.emptyMap(); + } + + @Override + public ForwardServer getServerData(String server) { + return null; + } + + @Override + public void callEvent(Vote vote) { + } + + @Override + public ThrottleConfig getThrottleConfig() { + return null; + } + } +} diff --git a/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteProtocolSecurityTest.java b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteProtocolSecurityTest.java new file mode 100644 index 0000000..59d5896 --- /dev/null +++ b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteProtocolSecurityTest.java @@ -0,0 +1,375 @@ +package com.bencodez.votifierplus.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketException; +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import javax.crypto.Cipher; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.google.gson.JsonObject; +import com.vexsoftware.votifier.ForwardServer; +import com.vexsoftware.votifier.model.Vote; +import com.vexsoftware.votifier.net.ThrottleConfig; +import com.vexsoftware.votifier.net.VoteConnectionHandler; +import com.vexsoftware.votifier.net.VoteProtocolPolicy; +import com.vexsoftware.votifier.net.VoteReceiver; +import com.vexsoftware.votifier.net.VoteThrottleService; + +/** + * Regression tests for the optional V1 security boundary and bounded listener + * queue. + */ +public class VoteProtocolSecurityTest { + + private static KeyPair testKeyPair; + private static Key tokenKey; + + private TestVoteReceiver receiver; + private ExecutorService executor; + + @BeforeAll + public static void setupClass() throws Exception { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + testKeyPair = keyPairGenerator.generateKeyPair(); + tokenKey = new SecretKeySpec("securityTestToken123".getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + } + + @BeforeEach + public void setup() throws Exception { + VoteProtocolPolicy.setDisableV1(false); + receiver = new TestVoteReceiver("127.0.0.1", 0); + executor = Executors.newCachedThreadPool(); + } + + @AfterEach + public void tearDown() { + VoteProtocolPolicy.setDisableV1(false); + if (executor != null) { + executor.shutdownNow(); + } + if (receiver != null) { + receiver.shutdown(); + } + } + + @Test + public void testTokenCompatibilityModeStillAcceptsPresentV1Packet() throws Exception { + receiver.setUseTokens(true); + VoteProtocolPolicy.setDisableV1(false); + VoteConnectionHandler handler = new VoteConnectionHandler(receiver, new VoteThrottleService(null)); + + try (ServerSocket serverSocket = new ServerSocket(0); + Socket client = new Socket("127.0.0.1", serverSocket.getLocalPort()); + Socket accepted = serverSocket.accept()) { + client.getOutputStream().write(createV1Packet("compatibilityUsr")); + client.getOutputStream().flush(); + + Future future = executor.submit(() -> handler.handle(accepted)); + Vote vote = future.get(2, TimeUnit.SECONDS); + + assertNotNull(vote); + assertEquals("compatibilityUsr", vote.getUsername()); + } + } + + @Test + public void testFragmentedV1PacketIsReadCompletely() throws Exception { + receiver.setUseTokens(false); + VoteProtocolPolicy.setDisableV1(false); + VoteConnectionHandler handler = new VoteConnectionHandler(receiver, new VoteThrottleService(null)); + + try (ServerSocket serverSocket = new ServerSocket(0); + Socket client = new Socket("127.0.0.1", serverSocket.getLocalPort()); + Socket accepted = serverSocket.accept()) { + Future future = executor.submit(() -> handler.handle(accepted)); + BufferedReader reader = new BufferedReader( + new InputStreamReader(client.getInputStream(), StandardCharsets.UTF_8)); + OutputStream output = client.getOutputStream(); + + assertEquals("VOTIFIER 1", reader.readLine()); + byte[] packet = createV1Packet("fragmentedUser"); + output.write(packet, 0, 32); + output.flush(); + Thread.sleep(50); + output.write(packet, 32, packet.length - 32); + output.flush(); + + assertTrue(reader.readLine().contains("\"status\":\"ok\"")); + Vote vote = future.get(2, TimeUnit.SECONDS); + assertNotNull(vote); + assertEquals("fragmentedUser", vote.getUsername()); + } + } + + @Test + public void testDisableV1RejectsDelayedV1PacketInTokenMode() throws Exception { + receiver.setUseTokens(true); + VoteProtocolPolicy.setDisableV1(true); + VoteConnectionHandler handler = new VoteConnectionHandler(receiver, new VoteThrottleService(null)); + + try (ServerSocket serverSocket = new ServerSocket(0); + Socket client = new Socket("127.0.0.1", serverSocket.getLocalPort()); + Socket accepted = serverSocket.accept()) { + Future future = executor.submit(() -> handler.handle(accepted)); + BufferedReader reader = new BufferedReader( + new InputStreamReader(client.getInputStream(), StandardCharsets.UTF_8)); + + assertEquals("VOTIFIER 2 testChallenge", reader.readLine()); + client.getOutputStream().write(createV1Packet("rejectedDelayedUser")); + client.getOutputStream().flush(); + + assertNull(future.get(2, TimeUnit.SECONDS)); + assertTrue(receiver.getWarnings().stream() + .anyMatch(message -> message.contains("Votifier V1 votes are disabled by configuration"))); + } + } + + @Test + public void testDisableV1RejectsPresentV1PacketAndStillSendsV2Handshake() throws Exception { + receiver.setUseTokens(true); + VoteProtocolPolicy.setDisableV1(true); + VoteConnectionHandler handler = new VoteConnectionHandler(receiver, new VoteThrottleService(null)); + + try (ServerSocket serverSocket = new ServerSocket(0); + Socket client = new Socket("127.0.0.1", serverSocket.getLocalPort()); + Socket accepted = serverSocket.accept()) { + client.getOutputStream().write(createV1Packet("rejectedPresentUser")); + client.getOutputStream().flush(); + + Future future = executor.submit(() -> handler.handle(accepted)); + BufferedReader reader = new BufferedReader( + new InputStreamReader(client.getInputStream(), StandardCharsets.UTF_8)); + + assertEquals("VOTIFIER 2 testChallenge", reader.readLine()); + assertNull(future.get(2, TimeUnit.SECONDS)); + } + } + + @Test + public void testDisableV1ForcesV2HandshakeAndAcceptsValidV2() throws Exception { + receiver.setUseTokens(false); + VoteProtocolPolicy.setDisableV1(true); + VoteConnectionHandler handler = new VoteConnectionHandler(receiver, new VoteThrottleService(null)); + + try (ServerSocket serverSocket = new ServerSocket(0); + Socket client = new Socket("127.0.0.1", serverSocket.getLocalPort()); + Socket accepted = serverSocket.accept()) { + Future future = executor.submit(() -> handler.handle(accepted)); + BufferedReader reader = new BufferedReader( + new InputStreamReader(client.getInputStream(), StandardCharsets.UTF_8)); + OutputStream output = client.getOutputStream(); + + assertEquals("VOTIFIER 2 testChallenge", reader.readLine()); + output.write(createV2Packet("secureV2User")); + output.flush(); + + assertTrue(reader.readLine().contains("\"status\":\"ok\"")); + Vote vote = future.get(2, TimeUnit.SECONDS); + assertNotNull(vote); + assertEquals("secureV2User", vote.getUsername()); + } + } + + @Test + public void testFullConnectionQueueRejectsAndClosesNewSocket() throws Exception { + receiver.shutdown(); + SaturatedVoteReceiver saturated = new SaturatedVoteReceiver("127.0.0.1", 0); + receiver = saturated; + receiver.start(); + + try (Socket active = new Socket("127.0.0.1", receiver.getServer().getLocalPort())) { + BufferedReader activeReader = new BufferedReader( + new InputStreamReader(active.getInputStream(), StandardCharsets.UTF_8)); + assertEquals("VOTIFIER 1", activeReader.readLine()); + + try (Socket queued = new Socket("127.0.0.1", receiver.getServer().getLocalPort()); + Socket rejected = new Socket("127.0.0.1", receiver.getServer().getLocalPort())) { + assertTrue(saturated.awaitRejection()); + rejected.setSoTimeout(1000); + try { + assertEquals(-1, rejected.getInputStream().read()); + } catch (SocketException expectedReset) { + assertTrue(expectedReset.getMessage() != null || rejected.isClosed() || rejected.isConnected()); + } + } + } + } + + private byte[] createV1Packet(String username) throws Exception { + String voteMessage = "VOTE\nvotifier.bencodez.com\n" + username + "\n127.0.0.1\nNormalTimestamp\n"; + Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); + cipher.init(Cipher.ENCRYPT_MODE, testKeyPair.getPublic()); + return cipher.doFinal(voteMessage.getBytes(StandardCharsets.UTF_8)); + } + + private byte[] createV2Packet(String username) throws Exception { + JsonObject inner = new JsonObject(); + inner.addProperty("serviceName", "votifier.bencodez.com"); + inner.addProperty("username", username); + inner.addProperty("address", "127.0.0.1"); + inner.addProperty("timestamp", "NormalTimestampV2"); + inner.addProperty("challenge", "testChallenge"); + String payload = inner.toString(); + + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(tokenKey); + String signature = Base64.getEncoder() + .encodeToString(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8))); + + JsonObject outer = new JsonObject(); + outer.addProperty("payload", payload); + outer.addProperty("signature", signature); + return (outer.toString() + "\r\n").getBytes(StandardCharsets.UTF_8); + } + + private static class TestVoteReceiver extends VoteReceiver { + + private final List warnings = new CopyOnWriteArrayList<>(); + private volatile boolean useTokens; + + TestVoteReceiver(String host, int port) throws Exception { + super(host, port); + } + + void setUseTokens(boolean useTokens) { + this.useTokens = useTokens; + } + + List getWarnings() { + return warnings; + } + + @Override + public boolean isUseTokens() { + return useTokens; + } + + @Override + public void logWarning(String warning) { + if (warnings != null) { + warnings.add(warning); + } + } + + @Override + public void logSevere(String message) { + } + + @Override + public void log(String message) { + } + + @Override + public void debug(String message) { + } + + @Override + public void debug(Exception exception) { + } + + @Override + public String getVersion() { + return "Test"; + } + + @Override + public Set getServers() { + return Collections.emptySet(); + } + + @Override + public KeyPair getKeyPair() { + return testKeyPair; + } + + @Override + public Map getTokens() { + return Collections.singletonMap("votifier.bencodez.com", tokenKey); + } + + @Override + public ForwardServer getServerData(String server) { + return null; + } + + @Override + public void callEvent(Vote vote) { + } + + @Override + public String getChallenge() { + return "testChallenge"; + } + + @Override + public ThrottleConfig getThrottleConfig() { + return null; + } + } + + private static class SaturatedVoteReceiver extends TestVoteReceiver { + + private final CountDownLatch rejection = new CountDownLatch(1); + + SaturatedVoteReceiver(String host, int port) throws Exception { + super(host, port); + } + + @Override + public int getConnectionWorkerCount() { + return 1; + } + + @Override + public int getConnectionQueueCapacity() { + return 1; + } + + @Override + public long getConnectionQueueTimeoutMillis() { + return 10000L; + } + + @Override + public void debug(String message) { + if (rejection != null && message.startsWith("Rejected vote connection")) { + rejection.countDown(); + } + } + + boolean awaitRejection() throws InterruptedException { + return rejection.await(2, TimeUnit.SECONDS); + } + } +}