diff --git a/README.md b/README.md index 1a5130738a..45a3ae94a8 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Plugin on SpigotMC https://www.spigotmc.org/resources/votingplugin.15358/ +Development documentation: [optional VotingPlugin Control discovery connector](docs/control-connector.md). + ### Maven: diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 60c2fdaebe..59cc62654d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -14,6 +14,9 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; import org.bukkit.Bukkit; import org.bukkit.Material; @@ -46,14 +49,19 @@ import com.bencodez.advancedcore.api.user.AdvancedCoreUser; import com.bencodez.advancedcore.api.user.UserStartup; import com.bencodez.simpleapi.file.YMLConfig; +import com.bencodez.simpleapi.servercomm.global.GlobalMessageHandler; +import com.bencodez.simpleapi.servercomm.pluginmessage.PluginMessageHandler; +import com.bencodez.simpleapi.skull.SkullCache; import com.bencodez.simpleapi.sql.mysql.config.MysqlConfigSpigot; import com.bencodez.simpleapi.time.ParsedDuration; import com.bencodez.simpleapi.updater.Updater; import com.bencodez.votingplugin.broadcast.BroadcastHandler; import com.bencodez.votingplugin.backendproxy.BackendProxyHandler; +import com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache; import com.bencodez.votingplugin.backgroundtask.VotingPluginBackgroundTask; import com.bencodez.votingplugin.backendproxy.BackendProxyRewardRegistrar; import com.bencodez.votingplugin.broadcast.BroadcastSettings; +import com.bencodez.votingplugin.proxy.BungeeMethod; import com.bencodez.votingplugin.commands.CommandLoader; import com.bencodez.votingplugin.commands.executers.CommandAdminVote; import com.bencodez.votingplugin.commands.executers.CommandVote; @@ -69,6 +77,7 @@ import com.bencodez.votingplugin.config.SpecialRewardsConfig; import com.bencodez.votingplugin.config.VotingPluginConfigHealth; import com.bencodez.votingplugin.cooldown.CoolDownCheck; +import com.bencodez.votingplugin.control.BackendControlConnector; import com.bencodez.votingplugin.data.ServerData; import com.bencodez.votingplugin.discord.DiscordHandler; import com.bencodez.votingplugin.listeners.BlockBreak; @@ -147,6 +156,10 @@ public class VotingPluginMain extends AdvancedCorePlugin { @Getter private BackendProxyHandler backendProxyHandler; + private final ProcessedVoteCache backendProcessedVoteCache = new ProcessedVoteCache(); + private final AtomicReference backendPluginMessageTarget = new AtomicReference<>(); + private PluginMessageHandler backendPluginMessageRelay; + private BackendControlConnector backendControlConnector; @Getter private BungeeSettings bungeeSettings; @@ -429,8 +442,20 @@ public boolean isVoteSite(String voteSite) { } private void loadBungeeHandler() { - backendProxyHandler = new BackendProxyHandler(this); - backendProxyHandler.load(); + BackendProxyHandler candidate = new BackendProxyHandler(this, backendProcessedVoteCache); + try { + candidate.load(); + backendProxyHandler = candidate; + } catch (RuntimeException failure) { + try { + candidate.close(); + } catch (RuntimeException closeFailure) { + failure.addSuppressed(closeFailure); + } + backendProxyHandler = null; + getLogger().warning("Backend proxy transport was not started; it will be retried on reload: " + + failure.getMessage()); + } if (getOptions().getServer().equalsIgnoreCase("PleaseSet")) { getLogger().warning("Bungeecoord is true and server name is not set, bungeecoord features may not work"); @@ -715,6 +740,79 @@ public void run() { }, 10); } + startBackendControlConnector(); + + } + + private void startBackendControlConnector() { + try { + backendControlConnector = BackendControlConnector.create(this); + if (backendControlConnector != null) backendControlConnector.start(); + } catch (Exception e) { + backendControlConnector = null; + getLogger().warning("[Control] Bukkit configuration connector was not started: " + e.getMessage()); + } + } + + /** Refreshes the optional Control connector after its Config.yml settings were applied. */ + public synchronized void restartBackendControlConnector() { + BackendControlConnector connector = backendControlConnector; + if (connector != null && connector.hasPendingResults()) { + getLogger().warning("[Control] Keeping the previous connector until its applied result is acknowledged"); + return; + } + BackendControlConnector replacement; + try { + replacement = BackendControlConnector.create(this); + } catch (Exception e) { + replacement = null; + getLogger().warning("[Control] Bukkit configuration connector was not restarted: " + e.getMessage()); + } + if (connector != null) connector.close(); + if (backendControlConnector == connector) backendControlConnector = replacement; + if (replacement != null) replacement.start(); + } + + /** Recreates proxy transports after Control applies BungeeSettings.yml. */ + public synchronized void restartBackendProxyHandler() { + BackendProxyHandler previous = backendProxyHandler; + if (!bungeeSettings.isUseBungeecoord()) { + backendProxyHandler = null; + if (previous != null) previous.close(); + return; + } + BungeeMethod replacementMethod = BungeeMethod.getByName(bungeeSettings.getBungeeMethod()); + if (previous != null) previous.prepareForReplacement(replacementMethod); + BackendProxyHandler replacement = new BackendProxyHandler(this, backendProcessedVoteCache); + try { + replacement.load(); + replacement.validateTransport(); + if (previous != null) previous.completeRedisHandoff(replacement); + } catch (RuntimeException failure) { + replacement.close(); + throw failure; + } + backendProxyHandler = replacement; + if (previous != null) previous.close(); + } + + /** Keeps one plugin-message listener for the plugin lifetime and atomically swaps its active backend handler. */ + public synchronized void activateBackendPluginMessageHandler(GlobalMessageHandler target) { + backendPluginMessageTarget.set(target); + if (backendPluginMessageRelay == null) { + backendPluginMessageRelay = new PluginMessageHandler() { + @Override + public void onReceive(com.bencodez.simpleapi.servercomm.codec.JsonEnvelope envelope) { + GlobalMessageHandler current = backendPluginMessageTarget.get(); + if (current != null) current.onMessage(envelope); + } + }; + getPluginMessaging().add(backendPluginMessageRelay); + } + } + + public void deactivateBackendPluginMessageHandler(GlobalMessageHandler target) { + backendPluginMessageTarget.compareAndSet(target, null); } private void migrateVoteBroadcast(Config configFile) { @@ -808,6 +906,17 @@ public void onFinish() { @Override public void onUnLoad() { + BackendControlConnector connector = backendControlConnector; + if (connector != null) { + try { + connector.close(); + } catch (RuntimeException e) { + getLogger().warning("[Control] Bukkit connector did not stop cleanly; normal plugin cleanup will continue"); + debug(e); + } finally { + if (backendControlConnector == connector) backendControlConnector = null; + } + } if (getBackendProxyHandler() != null) { try { getBackendProxyHandler().close(); @@ -953,13 +1062,15 @@ private void reloadPlugin(boolean userStorage) { reloadAdvancedCore(userStorage); if (bungeeSettings.isUseBungeecoord()) { - if (getBackendProxyHandler() == null) { + BackendProxyHandler handler = getBackendProxyHandler(); + if (handler == null) { loadBungeeHandler(); + handler = getBackendProxyHandler(); } else { - getBackendProxyHandler().reloadPresenceReporting(); + handler.reloadPresenceReporting(); } - if (userStorage) { - getBackendProxyHandler().loadGlobalMysql(); + if (userStorage && handler != null) { + handler.loadGlobalMysql(); } } else if (getBackendProxyHandler() != null) { getBackendProxyHandler().disablePresenceReporting(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java index 671b6812d0..8395c38b4c 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java @@ -34,7 +34,7 @@ public class BackendProxyHandler implements Listener { private final VotingPluginMain plugin; - private final ProcessedVoteCache processedVoteCache = new ProcessedVoteCache(); + private final ProcessedVoteCache processedVoteCache; private final BackendProxyTransportManager transportManager; private final BackendGlobalDataSync globalDataSync; @@ -48,8 +48,13 @@ public class BackendProxyHandler implements Listener { private GlobalMessageHandler globalMessageHandler; public BackendProxyHandler(VotingPluginMain plugin) { + this(plugin, new ProcessedVoteCache()); + } + + public BackendProxyHandler(VotingPluginMain plugin, ProcessedVoteCache processedVoteCache) { this.plugin = plugin; - transportManager = new BackendProxyTransportManager(plugin); + this.processedVoteCache = java.util.Objects.requireNonNull(processedVoteCache, "processedVoteCache"); + transportManager = new BackendProxyTransportManager(plugin, processedVoteCache); globalDataSync = new BackendGlobalDataSync(plugin, this::sendEnvelope); } @@ -96,6 +101,28 @@ public void close() { globalDataSync.close(); } + /** Releases a same-method subscriber/listener before its replacement starts. */ + public void prepareForReplacement(BungeeMethod replacementMethod) { + if (method == replacementMethod && method != BungeeMethod.PLUGINMESSAGING && method != BungeeMethod.REDIS) { + transportManager.prepareForReplacement(); + } + } + + /** Fails a configuration apply when its selected transport did not initialize. */ + public void validateTransport() { + if (method == null || globalMessageHandler == null || presenceManager == null) { + throw new IllegalStateException("Backend proxy handler initialization failed"); + } + transportManager.validate(); + } + + /** Completes the no-loss/no-duplicate same-Redis subscriber handoff after validation. */ + public void completeRedisHandoff(BackendProxyHandler replacement) { + if (method != BungeeMethod.REDIS || replacement.method != BungeeMethod.REDIS) return; + transportManager.closeRedisForHandoff(); + replacement.transportManager.activateRedisAfterHandoff(); + } + public void playerOnline(String playerName, String uuid) { if (presenceManager != null) { presenceManager.playerOnline(playerName, uuid); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/cache/ProcessedVoteCache.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/cache/ProcessedVoteCache.java index 0fae384443..919fb8077d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/cache/ProcessedVoteCache.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/cache/ProcessedVoteCache.java @@ -1,5 +1,8 @@ package com.bencodez.votingplugin.backendproxy.cache; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; @@ -12,10 +15,18 @@ public class ProcessedVoteCache { private static final long DEFAULT_TTL_MILLIS = TimeUnit.MINUTES.toMillis(30); + private static final int MAX_REDIS_DELIVERIES = 4096; + public static final int MAX_LEGACY_REDIS_DELIVERY_BYTES = 256 * 1024; + public static final int MAX_LEGACY_REDIS_TOTAL_BYTES = 4 * 1024 * 1024; @Getter private final ConcurrentHashMap processedVotes = new ConcurrentHashMap<>(); private final long ttlMillis; + private final LinkedHashMap processedRedisDeliveries = new LinkedHashMap<>(); + private final LinkedHashMap legacyRedisDeliveries = new LinkedHashMap<>(); + private long legacyRedisDeliveryBytes; + private Object activeRedisSubscriber; + private Object standbyRedisSubscriber; public ProcessedVoteCache() { this(DEFAULT_TTL_MILLIS); @@ -54,7 +65,106 @@ public boolean reserve(UUID voteId) { } } + /** Deduplicates one Redis envelope across overlapping subscribers during a validated handoff. */ + public synchronized boolean reserveRedisDelivery(String deliveryId) { + if (deliveryId == null || !deliveryId.matches("[0-9a-fA-F-]{36}")) return true; + long now = System.currentTimeMillis(); + processedRedisDeliveries.entrySet().removeIf(entry -> entry.getValue() <= now); + Long current = processedRedisDeliveries.get(deliveryId); + if (current != null && current > now) return false; + processedRedisDeliveries.put(deliveryId, now + ttlMillis); + while (processedRedisDeliveries.size() > MAX_REDIS_DELIVERIES) { + Iterator> oldest = processedRedisDeliveries.entrySet().iterator(); + if (!oldest.hasNext()) break; + oldest.next(); + oldest.remove(); + } + return true; + } + + public synchronized void registerRedisSubscriber(Object subscriber) { + if (activeRedisSubscriber == null) { + activeRedisSubscriber = subscriber; + } else if (activeRedisSubscriber != subscriber) { + standbyRedisSubscriber = subscriber; + legacyRedisDeliveries.clear(); + legacyRedisDeliveryBytes = 0; + } + } + + /** Returns true only for the active subscriber and counts its legacy delivery during overlap. */ + public synchronized boolean reserveLegacyRedisDelivery(Object subscriber, String signature) { + if (activeRedisSubscriber != subscriber) return false; + if (standbyRedisSubscriber != null) { + Integer count = legacyRedisDeliveries.get(signature); + if (count != null) { + legacyRedisDeliveries.put(signature, + count == Integer.MAX_VALUE ? Integer.MAX_VALUE : count + 1); + } else { + int bytes = legacyRedisDeliveryBytes(signature); + if (bytes <= MAX_LEGACY_REDIS_DELIVERY_BYTES + && legacyRedisDeliveries.size() < MAX_REDIS_DELIVERIES + && legacyRedisDeliveryBytes <= MAX_LEGACY_REDIS_TOTAL_BYTES - bytes) { + legacyRedisDeliveries.put(signature, 1); + legacyRedisDeliveryBytes += bytes; + } + } + } + return true; + } + + public synchronized void activateRedisSubscriber(Object subscriber) { + if (standbyRedisSubscriber != subscriber) { + throw new IllegalStateException("Redis replacement subscriber is not registered"); + } + activeRedisSubscriber = subscriber; + standbyRedisSubscriber = null; + } + + /** Consumes one matching delivery processed by the previous active subscriber. */ + public synchronized boolean consumeLegacyRedisDelivery(String signature) { + Integer count = legacyRedisDeliveries.get(signature); + if (count == null) return false; + if (count <= 1) { + legacyRedisDeliveries.remove(signature); + legacyRedisDeliveryBytes -= legacyRedisDeliveryBytes(signature); + } else legacyRedisDeliveries.put(signature, count - 1); + return true; + } + + public synchronized void finishRedisHandoff() { + legacyRedisDeliveries.clear(); + legacyRedisDeliveryBytes = 0; + } + + public synchronized void unregisterRedisSubscriber(Object subscriber) { + if (standbyRedisSubscriber == subscriber) { + standbyRedisSubscriber = null; + legacyRedisDeliveries.clear(); + legacyRedisDeliveryBytes = 0; + } + if (activeRedisSubscriber == subscriber) activeRedisSubscriber = null; + } + private void cleanup(long now) { processedVotes.entrySet().removeIf(entry -> entry.getValue() <= now); } + + /** Returns an exact UTF-8 length up to the per-delivery cap, then cap + 1. */ + public static int legacyRedisDeliveryBytes(String signature) { + if (signature == null) return 0; + int bytes = 0; + for (int index = 0; index < signature.length(); index++) { + char character = signature.charAt(index); + if (character <= 0x7f) bytes++; + else if (character <= 0x7ff) bytes += 2; + else if (Character.isHighSurrogate(character) && index + 1 < signature.length() + && Character.isLowSurrogate(signature.charAt(index + 1))) { + bytes += 4; + index++; + } else bytes += 3; + if (bytes > MAX_LEGACY_REDIS_DELIVERY_BYTES) return MAX_LEGACY_REDIS_DELIVERY_BYTES + 1; + } + return bytes; + } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSync.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSync.java index adaaee969a..29c24a7db3 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSync.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSync.java @@ -151,6 +151,7 @@ public void load() { } public void close() { + shutdownTimer(); closeGlobalMysql(); } @@ -171,5 +172,6 @@ private void shutdownTimer() { Thread.currentThread().interrupt(); } timer.shutdownNow(); + timer = null; } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/BackendProxyTransport.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/BackendProxyTransport.java index 5e7b40b49b..30a0baa003 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/BackendProxyTransport.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/BackendProxyTransport.java @@ -12,5 +12,12 @@ public interface BackendProxyTransport { void send(JsonEnvelope envelope); + default void validate() { + } + + default void prepareForReplacement() { + close(); + } + void close(); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/BackendProxyTransportManager.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/BackendProxyTransportManager.java index e8377f7538..4d7b208721 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/BackendProxyTransportManager.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/BackendProxyTransportManager.java @@ -8,6 +8,7 @@ import com.bencodez.simpleapi.servercomm.sockets.ClientHandler; import com.bencodez.simpleapi.servercomm.sockets.SocketHandler; import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache; import com.bencodez.votingplugin.proxy.BungeeMethod; /** @@ -16,10 +17,16 @@ public class BackendProxyTransportManager { private final VotingPluginMain plugin; + private final ProcessedVoteCache processedVoteCache; private BackendProxyTransport transport; public BackendProxyTransportManager(VotingPluginMain plugin) { + this(plugin, new ProcessedVoteCache()); + } + + public BackendProxyTransportManager(VotingPluginMain plugin, ProcessedVoteCache processedVoteCache) { this.plugin = plugin; + this.processedVoteCache = processedVoteCache; } public void start(BungeeMethod method, GlobalMessageHandler messageHandler) { @@ -35,7 +42,7 @@ public void start(BungeeMethod method, GlobalMessageHandler messageHandler) { transport = new SocketBackendProxyTransport(plugin); break; case REDIS: - transport = new RedisBackendProxyTransport(plugin); + transport = new RedisBackendProxyTransport(plugin, processedVoteCache); break; case MQTT: transport = new MqttBackendProxyTransport(plugin); @@ -59,6 +66,33 @@ public void close() { } } + public void validate() { + if (transport == null) throw new IllegalStateException("Backend proxy transport was not initialized"); + transport.validate(); + } + + public void prepareForReplacement() { + if (transport != null) { + transport.prepareForReplacement(); + transport = null; + } + } + + public void closeRedisForHandoff() { + if (!(transport instanceof RedisBackendProxyTransport)) { + throw new IllegalStateException("Redis backend proxy transport is unavailable"); + } + ((RedisBackendProxyTransport) transport).closeForHandoff(); + transport = null; + } + + public void activateRedisAfterHandoff() { + if (!(transport instanceof RedisBackendProxyTransport)) { + throw new IllegalStateException("Redis replacement transport is unavailable"); + } + ((RedisBackendProxyTransport) transport).activateAfterHandoff(); + } + public ClientHandler getClientHandler() { return transport instanceof SocketBackendProxyTransport ? ((SocketBackendProxyTransport) transport).getClientHandler() : null; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/MqttBackendProxyTransport.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/MqttBackendProxyTransport.java index f092e77da2..5b9bb44a0a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/MqttBackendProxyTransport.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/MqttBackendProxyTransport.java @@ -32,12 +32,17 @@ public void start(GlobalMessageHandler messageHandler) { mqttHandler.subscribeEnvelopes(plugin.getBungeeSettings().getMqttPrefix() + "votingplugin/servers/" + plugin.getOptions().getServer(), (topic, envelope) -> messageHandler.onMessage(envelope)); } catch (MqttException e) { - e.printStackTrace(); + throw new IllegalStateException("MQTT backend proxy transport initialization failed", e); } catch (Exception e) { - e.printStackTrace(); + throw new IllegalStateException("MQTT backend proxy transport initialization failed", e); } } + @Override + public void validate() { + if (mqttHandler == null) throw new IllegalStateException("MQTT backend proxy transport initialization failed"); + } + @Override public void send(JsonEnvelope envelope) { if (mqttHandler == null) { @@ -51,8 +56,28 @@ public void send(JsonEnvelope envelope) { } } + @Override + public void prepareForReplacement() { + if (mqttHandler != null) { + try { + mqttHandler.disconnect(); + } catch (Exception e) { + throw new IllegalStateException("Unable to disconnect the MQTT backend transport", e); + } + mqttHandler = null; + } + } + @Override public void close() { - // Preserve current MQTT lifecycle behavior. + if (mqttHandler != null) { + try { + mqttHandler.disconnect(); + } catch (Exception e) { + plugin.getLogger().warning("Unable to disconnect the replaced MQTT backend transport"); + } finally { + mqttHandler = null; + } + } } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/MysqlBackendProxyTransport.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/MysqlBackendProxyTransport.java index d55b2e88f8..dc61e9f70c 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/MysqlBackendProxyTransport.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/MysqlBackendProxyTransport.java @@ -33,10 +33,15 @@ public void start(GlobalMessageHandler messageHandler) { messageHandler.onMessage(msg.envelope); }); } catch (SQLException e) { - e.printStackTrace(); + throw new IllegalStateException("MySQL backend proxy transport initialization failed", e); } } + @Override + public void validate() { + if (messenger == null) throw new IllegalStateException("MySQL backend proxy transport initialization failed"); + } + @Override public void send(JsonEnvelope envelope) { if (messenger == null) { @@ -53,6 +58,7 @@ public void send(JsonEnvelope envelope) { public void close() { if (messenger != null) { messenger.shutdown(); + messenger = null; } } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/PluginMessagingBackendProxyTransport.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/PluginMessagingBackendProxyTransport.java index 6616ff748d..d6a24ff5cb 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/PluginMessagingBackendProxyTransport.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/PluginMessagingBackendProxyTransport.java @@ -5,12 +5,12 @@ import com.bencodez.simpleapi.encryption.EncryptionHandler; import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; import com.bencodez.simpleapi.servercomm.global.GlobalMessageHandler; -import com.bencodez.simpleapi.servercomm.pluginmessage.PluginMessageHandler; import com.bencodez.votingplugin.VotingPluginMain; public class PluginMessagingBackendProxyTransport implements BackendProxyTransport { private final VotingPluginMain plugin; + private GlobalMessageHandler messageHandler; public PluginMessagingBackendProxyTransport(VotingPluginMain plugin) { this.plugin = plugin; @@ -18,19 +18,16 @@ public PluginMessagingBackendProxyTransport(VotingPluginMain plugin) { @Override public void start(GlobalMessageHandler messageHandler) { + this.messageHandler = messageHandler; plugin.registerBungeeChannels(plugin.getBungeeSettings().getPluginMessagingChannel()); + EncryptionHandler encryptionHandler = null; if (plugin.getBungeeSettings().isPluginMessageEncryption()) { - EncryptionHandler encryptionHandler = new EncryptionHandler(plugin.getName(), + encryptionHandler = new EncryptionHandler(plugin.getName(), new File(plugin.getDataFolder(), "secretkey.key")); - plugin.getPluginMessaging().setEncryptionHandler(encryptionHandler); } + plugin.getPluginMessaging().setEncryptionHandler(encryptionHandler); plugin.getPluginMessaging().setDebug(plugin.getBungeeSettings().isBungeeDebug()); - plugin.getPluginMessaging().add(new PluginMessageHandler() { - @Override - public void onReceive(JsonEnvelope envelope) { - messageHandler.onMessage(envelope); - } - }); + plugin.activateBackendPluginMessageHandler(messageHandler); } @Override @@ -40,6 +37,9 @@ public void send(JsonEnvelope envelope) { @Override public void close() { - // Plugin messaging is owned by the plugin lifecycle. + if (messageHandler != null) { + plugin.deactivateBackendPluginMessageHandler(messageHandler); + messageHandler = null; + } } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/RedisBackendProxyTransport.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/RedisBackendProxyTransport.java index de8d68817e..eb2ce25391 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/RedisBackendProxyTransport.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/RedisBackendProxyTransport.java @@ -1,26 +1,55 @@ package com.bencodez.votingplugin.backendproxy.transport; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import javax.net.ssl.SSLParameters; + import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; import com.bencodez.simpleapi.servercomm.global.GlobalMessageHandler; import com.bencodez.simpleapi.servercomm.redis.RedisHandler; import com.bencodez.simpleapi.servercomm.redis.RedisListener; import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache; +import com.bencodez.votingplugin.proxy.VotingPluginWire; + +import redis.clients.jedis.DefaultJedisClientConfig; +import redis.clients.jedis.HostAndPort; +import redis.clients.jedis.Jedis; import lombok.Getter; public class RedisBackendProxyTransport implements BackendProxyTransport { + static final int MAX_LEGACY_HANDOFF_DELIVERIES = 4096; private final VotingPluginMain plugin; + private final ProcessedVoteCache processedVoteCache; @Getter private RedisHandler redisHandler; + private CountDownLatch subscriptionReady; private Thread listenerThread; + private final Object subscriberIdentity = new Object(); + private final Object legacyLifecycle = new Object(); + private final List bufferedLegacyDeliveries = new ArrayList<>(); + private long bufferedLegacyDeliveryBytes; + private boolean legacyHandoffDegraded; + private GlobalMessageHandler messageHandler; public RedisBackendProxyTransport(VotingPluginMain plugin) { + this(plugin, new ProcessedVoteCache()); + } + + public RedisBackendProxyTransport(VotingPluginMain plugin, ProcessedVoteCache processedVoteCache) { this.plugin = plugin; + this.processedVoteCache = processedVoteCache; } @Override public void start(GlobalMessageHandler messageHandler) { + this.messageHandler = messageHandler; + processedVoteCache.registerRedisSubscriber(subscriberIdentity); redisHandler = new RedisHandler(plugin.getBungeeSettings().getRedisHost(), plugin.getBungeeSettings().getRedisPort(), plugin.getBungeeSettings().getRedisUsername(), plugin.getBungeeSettings().getRedisPassword(), plugin.getBungeeSettings().getRedisdbindex(), @@ -32,27 +61,146 @@ public void debug(String message) { } } }; - - listenerThread = new Thread(() -> { - if (plugin.isEnabled()) { - RedisListener listener = redisHandler.createEnvelopeListener( - plugin.getBungeeSettings().getRedisPrefix() + "VotingPlugin_" + plugin.getBungeeSettings().getServer(), - (ch, envelope) -> messageHandler.onMessage(envelope)); - redisHandler.loadListener(listener); + RedisHandler handler = redisHandler; + CountDownLatch ready = new CountDownLatch(1); + subscriptionReady = ready; + RedisListener listener = new RedisListener(handler, + plugin.getBungeeSettings().getRedisPrefix() + "VotingPlugin_" + plugin.getBungeeSettings().getServer(), + (ch, payload) -> { + try { + JsonEnvelope envelope = com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec.decode(payload); + String deliveryId = envelope.getFields().get(VotingPluginWire.K_REDIS_DELIVERY_ID); + if (deliveryId != null) { + if (processedVoteCache.reserveRedisDelivery(deliveryId)) messageHandler.onMessage(envelope); + } else { + dispatchLegacy(envelope); + } + } catch (Exception e) { + plugin.debug("Redis decode failed: " + e.getMessage()); + } + }) { + @Override + public void onSubscribe(String channel, int subscribedChannels) { + ready.countDown(); } - }, "VotingPlugin-Redis-Backend"); + }; + listenerThread = new Thread(() -> handler.loadListener(listener), "VotingPlugin-Redis-Backend"); + listenerThread.setDaemon(true); listenerThread.start(); } + void dispatchLegacy(JsonEnvelope envelope) { + String signature = com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec.encode(envelope); + int encodedBytes = ProcessedVoteCache.legacyRedisDeliveryBytes(signature); + synchronized (legacyLifecycle) { + if (processedVoteCache.reserveLegacyRedisDelivery(subscriberIdentity, signature)) { + messageHandler.onMessage(envelope); + } else if (encodedBytes <= ProcessedVoteCache.MAX_LEGACY_REDIS_DELIVERY_BYTES + && bufferedLegacyDeliveries.size() < MAX_LEGACY_HANDOFF_DELIVERIES + && bufferedLegacyDeliveryBytes <= ProcessedVoteCache.MAX_LEGACY_REDIS_TOTAL_BYTES - encodedBytes) { + bufferedLegacyDeliveries.add(envelope); + bufferedLegacyDeliveryBytes += encodedBytes; + } else { + if (!legacyHandoffDegraded && plugin != null) { + plugin.getLogger().warning("Redis legacy handoff exceeded its " + MAX_LEGACY_HANDOFF_DELIVERIES + + " delivery / " + ProcessedVoteCache.MAX_LEGACY_REDIS_TOTAL_BYTES + + " byte buffer; temporarily degrading duplicate suppression"); + } + legacyHandoffDegraded = true; + messageHandler.onMessage(envelope); + } + } + } + + /** Promotes a validated standby after the previous listener has completely stopped. */ + public void activateAfterHandoff() { + synchronized (legacyLifecycle) { + processedVoteCache.activateRedisSubscriber(subscriberIdentity); + for (JsonEnvelope envelope : bufferedLegacyDeliveries) { + String signature = com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec.encode(envelope); + if (!processedVoteCache.consumeLegacyRedisDelivery(signature)) messageHandler.onMessage(envelope); + } + bufferedLegacyDeliveries.clear(); + bufferedLegacyDeliveryBytes = 0; + legacyHandoffDegraded = false; + processedVoteCache.finishRedisHandoff(); + } + } + + /** Stops the old listener while retaining its overlap accounting for standby promotion. */ + public void closeForHandoff() { + closeListener(false); + } + @Override public void send(JsonEnvelope envelope) { if (redisHandler != null) { - redisHandler.publishEnvelope(plugin.getBungeeSettings().getRedisPrefix() + "VotingPlugin", envelope); + redisHandler.publishEnvelope(plugin.getBungeeSettings().getRedisPrefix() + "VotingPlugin", + VotingPluginWire.withRedisDeliveryId(envelope)); } } + @Override + public void validate() { + if (redisHandler == null) throw new IllegalStateException("Redis backend proxy transport initialization failed"); + try (Jedis jedis = new Jedis(new HostAndPort(plugin.getBungeeSettings().getRedisHost(), + plugin.getBungeeSettings().getRedisPort()), buildValidationClientConfig( + plugin.getBungeeSettings().getRedisdbindex(), plugin.getBungeeSettings().getRedisUsername(), + plugin.getBungeeSettings().getRedisPassword(), plugin.getBungeeSettings().isRedisSsl()))) { + if (!"PONG".equalsIgnoreCase(jedis.ping())) { + throw new IllegalStateException("Redis backend proxy transport did not answer PING"); + } + } catch (RuntimeException failure) { + throw new IllegalStateException("Redis backend proxy transport connection failed", failure); + } + try { + if (subscriptionReady == null || !subscriptionReady.await(3, TimeUnit.SECONDS)) { + throw new IllegalStateException("Redis backend proxy subscription did not become ready"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting for Redis backend proxy subscription", e); + } + } + + static DefaultJedisClientConfig buildValidationClientConfig(int database, String username, String password, + boolean ssl) { + DefaultJedisClientConfig.Builder config = DefaultJedisClientConfig.builder().database(database).ssl(ssl) + .connectionTimeoutMillis(2000).socketTimeoutMillis(2000); + if (ssl) { + SSLParameters sslParameters = new SSLParameters(); + sslParameters.setEndpointIdentificationAlgorithm("HTTPS"); + config.sslParameters(sslParameters); + } + if (username != null && !username.isEmpty()) config.user(username); + if (password != null && !password.isEmpty()) config.password(password); + return config.build(); + } + @Override public void close() { - // Preserve current Redis lifecycle behavior; the listener is owned by RedisHandler. + closeListener(true); + } + + private void closeListener(boolean unregister) { + Thread thread = listenerThread; + if (redisHandler != null) { + redisHandler.close(); + redisHandler = null; + } + if (thread != null) { + thread.interrupt(); + try { + thread.join(TimeUnit.SECONDS.toMillis(3)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while stopping Redis backend subscription", e); + } + if (thread.isAlive()) throw new IllegalStateException("Redis backend subscription did not stop"); + } + listenerThread = null; + subscriptionReady = null; + messageHandler = null; + if (unregister) processedVoteCache.unregisterRedisSubscriber(subscriberIdentity); } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/SocketBackendProxyTransport.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/SocketBackendProxyTransport.java index c4342ca58a..a660c88741 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/SocketBackendProxyTransport.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/SocketBackendProxyTransport.java @@ -54,13 +54,22 @@ public void send(JsonEnvelope envelope) { } } + @Override + public void validate() { + if (clientHandler == null || socketHandler == null) { + throw new IllegalStateException("Socket backend proxy transport initialization failed"); + } + } + @Override public void close() { if (socketHandler != null) { socketHandler.closeConnection(); + socketHandler = null; } if (clientHandler != null) { clientHandler.stopConnection(); + clientHandler = null; } } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/ShopFile.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/ShopFile.java index 0a6c7c4182..bb7e55743c 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/ShopFile.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/ShopFile.java @@ -82,7 +82,7 @@ public ShopFile(VotingPluginMain plugin) { public void convertFromGUIFile() { setValue("VoteShop.Enabled", plugin.getGui().getData().getBoolean("CHEST.VoteShopEnabled")); setValue("VoteShop.BackButton", plugin.getGui().getData().getBoolean("CHEST.VoteShopBackButton")); - setValue("VoteShop.HideLimitedReached", plugin.getGui().getData().getBoolean("CHEST.VoteShopHideLimitedReached")); + setValue("VoteShop.HideLimitedReached", plugin.getGui().getData().getBoolean("CHEST.VoteShopHideLimitedReached")); setValue("VoteShop.RequireConfirmation", plugin.getGui().getData().getBoolean("CHEST.VoteShopRequireConfirmation")); setValue("VoteShop.ReopenGUIOnPurchase", @@ -392,4 +392,4 @@ public void removeShop(String value) { getData().set("Shop." + value, null); saveData(); } -} \ No newline at end of file +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java new file mode 100644 index 0000000000..b43256a21c --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java @@ -0,0 +1,454 @@ +package com.bencodez.votingplugin.control; + +import java.io.IOException; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.channels.Channels; +import java.nio.channels.SeekableByteChannel; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.YamlConfiguration; + +import com.bencodez.votingplugin.proxy.BungeeMethod; +import com.bencodez.votingplugin.util.DurableFiles; + +/** Safe, revisioned access to every user-facing VotingPlugin YAML configuration file. */ +public final class BackendConfigurationService { + public static final String REDACTED = "__VOTINGPLUGIN_CONTROL_REDACTED__"; + public static final int MAX_CONTENT_BYTES = 512 * 1024; + private static final Set TOP_LEVEL = Set.of("Config.yml", "VoteSites.yml", "SpecialRewards.yml", + "GUI.yml", "Shop.yml", "BungeeSettings.yml"); + + private final Path dataDirectory; + private final ApplyAction reload; + private final MoveAction mover; + + public BackendConfigurationService(Path dataDirectory, ReloadAction reload) { + this(dataDirectory, ignored -> reload.run()); + } + + public BackendConfigurationService(Path dataDirectory, ApplyAction reload) { + this(dataDirectory, reload, BackendConfigurationService::move); + } + + BackendConfigurationService(Path dataDirectory, ApplyAction reload, MoveAction mover) { + this.dataDirectory = dataDirectory.toAbsolutePath().normalize(); + this.reload = reload; + this.mover = mover; + } + + public Document read(String fileName) throws IOException { + Path path = resolve(fileName); + String raw = readRaw(path, false); + YamlConfiguration yaml = parse(raw); + return new Document(fileName, mask(yaml), revision(raw)); + } + + public Preview preview(String fileName, String proposedContent) throws IOException { + Path path = resolve(fileName); + String current = readRaw(path, false); + return preview(fileName, proposedContent, current); + } + + Document proposedDocument(Preview preview) { + return new Document(preview.fileName(), mask(parse(preview.resolvedContent())), + revision(preview.resolvedContent())); + } + + public ApplyResult apply(String fileName, String proposedContent, String expectedRevision) throws IOException { + Path target = resolve(fileName); + String current = readRaw(target, false); + if (expectedRevision == null || !revision(current).equals(expectedRevision)) throw new StaleRevisionException(); + Preview preview = preview(fileName, proposedContent, current); + Path backup = target.resolveSibling(target.getFileName() + ".control-backup"); + Path staging = Files.createTempFile(target.getParent(), ".control-", ".yml"); + Path backupStaging = Files.createTempFile(target.getParent(), ".control-backup-", ".yml"); + boolean installed = false; + try { + Files.writeString(staging, preview.resolvedContent(), StandardCharsets.UTF_8, + StandardOpenOption.TRUNCATE_EXISTING); + rejectSymbolicBackup(backup); + Files.writeString(backupStaging, current, StandardCharsets.UTF_8, + StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); + if (!revision(readRaw(target, false)).equals(expectedRevision)) throw new StaleRevisionException(); + mover.move(backupStaging, backup); + if (!revision(readRaw(target, false)).equals(expectedRevision)) throw new StaleRevisionException(); + try { + mover.move(staging, target); + installed = true; + } catch (DurableFiles.PublishedException published) { + installed = true; + throw published; + } + reload.run(fileName); + String applied = readRaw(target, false); + String installedRevision = revision(preview.resolvedContent()); + if (!revision(applied).equals(installedRevision)) { + reconcileConcurrentEdit(fileName, target); + throw new StaleRevisionException(); + } + return new ApplyResult(new Document(fileName, mask(parse(applied)), revision(applied)), + preview.changes(), false); + } catch (StaleRevisionException stale) { + throw stale; + } catch (Exception failure) { + boolean rolledBack = false; + if (installed) { + try { + String installedRevision = revision(preview.resolvedContent()); + if (!revision(readRaw(target, false)).equals(installedRevision)) { + throw new IOException("Managed configuration changed while reload failed; backup was not restored"); + } + try { + copyBackupNoFollow(backup, target, installedRevision); + } catch (DurableFiles.PublishedException published) { + // The backup is already active. Preserve the durability warning, but + // still reload it so runtime and disk cannot diverge. + failure.addSuppressed(published); + } + reload.run(fileName); + rolledBack = true; + } catch (Exception rollbackFailure) { + failure.addSuppressed(rollbackFailure); + } + } + throw new ApplyFailureException(rolledBack, failure); + } finally { + Files.deleteIfExists(staging); + Files.deleteIfExists(backupStaging); + } + } + + private void reconcileConcurrentEdit(String fileName, Path target) throws Exception { + for (int attempt = 0; attempt < 3; attempt++) { + String snapshotRevision = revision(readRaw(target, false)); + reload.run(fileName); + if (revision(readRaw(target, false)).equals(snapshotRevision)) return; + } + throw new StaleRevisionException(); + } + + private static void rejectSymbolicBackup(Path backup) throws IOException { + if (Files.isSymbolicLink(backup)) throw new IOException("Symbolic configuration backups are not allowed"); + } + + private void copyBackupNoFollow(Path backup, Path target, String expectedTargetRevision) throws IOException { + if (!Files.isRegularFile(backup, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Control backup is unavailable or unsafe"); + } + Path staging = Files.createTempFile(target.getParent(), ".control-rollback-", ".yml"); + try { + try (SeekableByteChannel source = Files.newByteChannel(backup, + Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS))) { + Files.copy(Channels.newInputStream(source), staging, StandardCopyOption.REPLACE_EXISTING); + } + if (!revision(readRaw(target, false)).equals(expectedTargetRevision)) { + throw new IOException("Managed configuration changed while rollback was staged"); + } + mover.move(staging, target); + } finally { + Files.deleteIfExists(staging); + } + } + + private Preview preview(String fileName, String proposedContent, String current) { + YamlConfiguration currentYaml = parse(current); + YamlConfiguration resolvedYaml = resolveSecrets(parse(proposedContent), currentYaml); + if ("BungeeSettings.yml".equals(fileName) && resolvedYaml.contains("BungeeMethod")) { + resolvedYaml.set("BungeeMethod", canonicalBungeeMethod(resolvedYaml.getString("BungeeMethod"))); + } + String resolved = resolvedYaml.saveToString(); + ensureBounded(resolved); + return new Preview(fileName, resolved, revision(current), changes(currentYaml, parse(resolved))); + } + + public QuickPreview previewQuickSetup(String preset, Map options) throws IOException { + String fileName = quickSetupFile(preset); + String current = readRaw(resolve(fileName), false); + QuickProposal proposal = quickProposal(preset, options, fileName, current); + return new QuickPreview(proposal, revision(current), changes(parse(current), parse(proposal.content()))); + } + + String proposedQuickSetupRevision(QuickPreview preview) { + return revision(preview.proposal().content()); + } + + String currentQuickSetupRevision(String preset) throws IOException { + return read(quickSetupFile(preset)).revision(); + } + + public ApplyResult applyQuickSetup(String preset, Map options, String expectedRevision) + throws IOException { + String fileName = quickSetupFile(preset); + String current = readRaw(resolve(fileName), false); + if (expectedRevision == null || !revision(current).equals(expectedRevision)) throw new StaleRevisionException(); + QuickProposal proposal = quickProposal(preset, options, fileName, current); + return apply(proposal.fileName(), proposal.content(), expectedRevision); + } + + private static String quickSetupFile(String preset) { + if ("standalone".equals(preset) || "proxy-backend".equals(preset)) return "BungeeSettings.yml"; + if ("vote-site".equals(preset) || "easy-reward".equals(preset)) return "VoteSites.yml"; + if ("common-settings".equals(preset)) return "Config.yml"; + if ("vote-party".equals(preset)) return "SpecialRewards.yml"; + throw new IllegalArgumentException("quick setup preset is unsupported"); + } + + private QuickProposal quickProposal(String preset, Map options, String fileName, + String current) { + YamlConfiguration yaml = parse(current); + if ("standalone".equals(preset) || "proxy-backend".equals(preset)) { + boolean proxy = "proxy-backend".equals(preset); + yaml.set("UseBungeecord", proxy); + if (proxy) { + String server = option(options, "server", "[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); + yaml.set("Server", server); + yaml.set("BungeeMethod", bungeeMethodOption(options)); + } + return new QuickProposal(fileName, yaml.saveToString()); + } + if ("vote-site".equals(preset)) { + String name = option(options, "name", "[A-Za-z0-9_-]{1,64}"); + String root = "VoteSites." + name; + yaml.set(root + ".Enabled", true); + yaml.set(root + ".Name", options.getOrDefault("displayName", name)); + yaml.set(root + ".Priority", boundedInteger(options.getOrDefault("priority", "5"), 0, 100)); + yaml.set(root + ".Hidden", false); + yaml.set(root + ".ServiceSite", option(options, "serviceSite", ".{1,200}")); + yaml.set(root + ".VoteURL", option(options, "voteUrl", ".{1,500}")); + yaml.set(root + ".VoteDelay", options.getOrDefault("voteDelay", "24h")); + yaml.set(root + ".DisplayItem.Material", options.getOrDefault("material", "DIAMOND")); + yaml.set(root + ".DisplayItem.Amount", 1); + return new QuickProposal(fileName, yaml.saveToString()); + } + if ("easy-reward".equals(preset)) { + String scope = options == null ? "site" : options.getOrDefault("scope", "site"); + String root; + if ("every-site".equals(scope)) { + root = "EverySiteReward"; + } else if ("site".equals(scope)) { + root = "VoteSites." + option(options, "name", "[A-Za-z0-9_-]{1,64}") + ".Rewards"; + } else { + throw new IllegalArgumentException("quick setup option scope is invalid"); + } + String command = optional(options, "command", 500); + String message = optional(options, "message", 500); + if (command.isBlank() && message.isBlank()) { + throw new IllegalArgumentException("easy reward requires a command or player message"); + } + if (!command.isBlank()) yaml.set(root + ".Commands", List.of(command)); + if (!message.isBlank()) yaml.set(root + ".Messages.Player", message); + return new QuickProposal(fileName, yaml.saveToString()); + } + if ("common-settings".equals(preset)) { + yaml.set("ProcessRewards", booleanOption(options, "processRewards")); + yaml.set("AutoCreateVoteSites", booleanOption(options, "autoCreateVoteSites")); + yaml.set("ExtraAllSitesCheck", booleanOption(options, "extraAllSitesCheck")); + yaml.set("CountFakeVotes", booleanOption(options, "countFakeVotes")); + yaml.set("DisableNoServiceSiteMessage", booleanOption(options, "disableNoServiceSiteMessage")); + yaml.set("DisableUpdateChecking", booleanOption(options, "disableUpdateChecking")); + return new QuickProposal(fileName, yaml.saveToString()); + } + if ("vote-party".equals(preset)) { + yaml.set("VoteParty.Enabled", true); + yaml.set("VoteParty.VotesRequired", boundedInteger(option(options, "votesRequired", "[0-9]{1,6}"), 1, 100000)); + yaml.set("VoteParty.GiveAllPlayers", booleanOption(options, "giveAllPlayers")); + yaml.set("VoteParty.GiveOnlinePlayersOnly", booleanOption(options, "onlineOnly")); + String command = optional(options, "command", 500); + String broadcast = optional(options, "broadcast", 500); + if (!command.isBlank()) yaml.set("VoteParty.Rewards.Commands", List.of(command)); + if (!broadcast.isBlank()) yaml.set("VoteParty.Broadcast", broadcast); + return new QuickProposal(fileName, yaml.saveToString()); + } + throw new IllegalArgumentException("quick setup preset is unsupported"); + } + + private Path resolve(String fileName) throws IOException { + if (fileName == null || (!TOP_LEVEL.contains(fileName) + && !fileName.matches("VoteSites/[A-Za-z0-9._-]{1,100}\\.yml"))) { + throw new IllegalArgumentException("configuration file is not managed"); + } + Path requested = dataDirectory.resolve(fileName).normalize(); + if (!requested.startsWith(dataDirectory)) { + throw new IllegalArgumentException("configuration path escapes data folder"); + } + Path root = dataDirectory.toRealPath(); + Path parent = requested.getParent(); + if (parent == null) throw new IOException("configuration parent is unavailable"); + Path realParent = parent.toRealPath(); + if (!realParent.startsWith(root)) throw new IOException("configuration path escapes data folder"); + Path target = realParent.resolve(requested.getFileName()).normalize(); + if (!target.startsWith(root) || Files.isSymbolicLink(target)) { + throw new IOException("symbolic configuration paths are not allowed"); + } + return target; + } + + private static String readRaw(Path path, boolean allowMissing) throws IOException { + if (!Files.exists(path) && allowMissing) return ""; + if (!Files.isRegularFile(path, java.nio.file.LinkOption.NOFOLLOW_LINKS) + || Files.size(path) > MAX_CONTENT_BYTES) { + throw new IOException("configuration file is missing or too large"); + } + byte[] bytes = Files.readAllBytes(path); + try { + return StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT).decode(java.nio.ByteBuffer.wrap(bytes)).toString(); + } catch (CharacterCodingException e) { + throw new IOException("configuration file is not valid UTF-8", e); + } + } + + private static YamlConfiguration parse(String content) { + if (content == null) throw new IllegalArgumentException("configuration content is required"); + ensureBounded(content); + YamlConfiguration yaml = new YamlConfiguration(); + try { + yaml.loadFromString(content); + } catch (InvalidConfigurationException e) { + throw new IllegalArgumentException("configuration is not valid YAML"); + } + return yaml; + } + + private static void ensureBounded(String content) { + if (content == null || content.indexOf('\0') >= 0 + || content.getBytes(StandardCharsets.UTF_8).length > MAX_CONTENT_BYTES) { + throw new IllegalArgumentException("configuration content exceeds the 512 KiB limit"); + } + } + + private static String mask(YamlConfiguration yaml) { + for (String path : new ArrayList<>(yaml.getKeys(true))) { + if (!(yaml.get(path) instanceof ConfigurationSection) && secret(path)) yaml.set(path, REDACTED); + } + return yaml.saveToString(); + } + + private static YamlConfiguration resolveSecrets(YamlConfiguration proposal, YamlConfiguration current) { + for (String path : new ArrayList<>(proposal.getKeys(true))) { + if (secret(path) && REDACTED.equals(proposal.getString(path))) proposal.set(path, current.get(path)); + } + return proposal; + } + + private static boolean secret(String path) { + String key = path.substring(path.lastIndexOf('.') + 1).replace("-", "").replace("_", "") + .toLowerCase(Locale.ROOT); + return key.contains("password") || key.contains("secret") || key.equals("token") + || key.equals("apikey") || key.equals("authorization") || key.equals("webhookurl") || key.equals("url") + && path.toLowerCase(Locale.ROOT).contains("webhook"); + } + + private static List changes(YamlConfiguration current, YamlConfiguration proposal) { + Set paths = new LinkedHashSet<>(current.getKeys(true)); + paths.addAll(proposal.getKeys(true)); + List changes = new ArrayList<>(); + for (String path : paths) { + Object before = current.get(path); + Object after = proposal.get(path); + if (before instanceof ConfigurationSection || after instanceof ConfigurationSection) continue; + if (!java.util.Objects.equals(before, after)) { + changes.add((secret(path) ? "changed secret " : "changed ") + path); + if (changes.size() == 19) { + changes.add("additional changes omitted"); + break; + } + } + } + return List.copyOf(changes); + } + + private static String revision(String content) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(content.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is unavailable", e); + } + } + + private static void move(Path source, Path target) throws IOException { + DurableFiles.forceFile(source); + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + DurableFiles.forceMoveDirectories(source, target); + } + + private static String bungeeMethodOption(Map options) { + String value = options == null ? "PLUGINMESSAGING" : options.getOrDefault("method", "PLUGINMESSAGING"); + return canonicalBungeeMethod(value); + } + + private static String canonicalBungeeMethod(String value) { + for (BungeeMethod method : BungeeMethod.values()) { + if (method.name().equalsIgnoreCase(value)) return method.name(); + } + throw new IllegalArgumentException("BungeeMethod is invalid"); + } + + private static String option(Map options, String name, String pattern) { + String value = options == null ? null : options.get(name); + if (value == null || !value.matches(pattern)) throw new IllegalArgumentException("quick setup option " + name + " is invalid"); + return value; + } + + private static String optional(Map options, String name, int maximum) { + String value = options == null ? "" : options.getOrDefault(name, "").trim(); + if (value.length() > maximum || value.indexOf('\0') >= 0 || value.indexOf('\r') >= 0 + || value.indexOf('\n') >= 0) { + throw new IllegalArgumentException("quick setup option " + name + " is invalid"); + } + return value; + } + + private static boolean booleanOption(Map options, String name) { + String value = options == null ? null : options.get(name); + if (!"true".equals(value) && !"false".equals(value)) { + throw new IllegalArgumentException("quick setup option " + name + " is invalid"); + } + return Boolean.parseBoolean(value); + } + + private static int boundedInteger(String value, int minimum, int maximum) { + try { + int parsed = Integer.parseInt(value); + if (parsed < minimum || parsed > maximum) throw new NumberFormatException(); + return parsed; + } catch (NumberFormatException e) { + throw new IllegalArgumentException("quick setup number is invalid"); + } + } + + public record Document(String fileName, String content, String revision) { } + public record Preview(String fileName, String resolvedContent, String revision, List changes) { } + public record ApplyResult(Document document, List changes, boolean rolledBack) { } + public record QuickProposal(String fileName, String content) { } + public record QuickPreview(QuickProposal proposal, String revision, List changes) { } + + @FunctionalInterface public interface ReloadAction { void run() throws Exception; } + @FunctionalInterface public interface ApplyAction { void run(String fileName) throws Exception; } + @FunctionalInterface interface MoveAction { void move(Path source, Path target) throws IOException; } + @SuppressWarnings("serial") public static final class StaleRevisionException extends RuntimeException { } + @SuppressWarnings("serial") public static final class ApplyFailureException extends IOException { + private final boolean rolledBack; + private ApplyFailureException(boolean rolledBack, Throwable cause) { super(cause); this.rolledBack = rolledBack; } + public boolean rolledBack() { return rolledBack; } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java new file mode 100644 index 0000000000..77dd93662e --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java @@ -0,0 +1,663 @@ +package com.bencodez.votingplugin.control; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.regex.Pattern; + +import org.bukkit.Bukkit; +import org.bukkit.configuration.ConfigurationSection; + +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.control.BackendControlResultStore.Route; +import com.bencodez.votingplugin.control.BackendControlResultStore.StoredResult; +import com.bencodez.votingplugin.util.BoundedHttpBodyHandler; +import com.bencodez.votingplugin.util.ControlCredentialFile; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +/** Optional outbound Bukkit node connector for full configuration-file and quick-setup control. */ +public final class BackendControlConnector implements AutoCloseable { + private static final int PROTOCOL_VERSION = 1; + private static final int MAX_RESPONSE_BYTES = 4 * 1024 * 1024; + private static final long SHUTDOWN_TIMEOUT_SECONDS = 65; + private static final Pattern NODE_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); + private static final Set CAPABILITIES = Set.of("config.files.v1", "config.quick-setup.v1"); + + private final VotingPluginMain plugin; + private final Path dataDirectory; + private final Settings settings; + private final String credential; + private final ScheduledExecutorService executor; + private final HttpClient http; + private final BackendConfigurationService configurations; + private final UUID sessionId = UUID.randomUUID(); + private final Map completed = new LinkedHashMap<>(); + private final boolean recovering; + private final AtomicBoolean running = new AtomicBoolean(); + private final Object operationLifecycle = new Object(); + private volatile boolean closed; + private volatile boolean registered; + private volatile boolean operationsAccepted; + private volatile boolean quickSetupsAccepted; + private volatile int failures; + private volatile ScheduledFuture scheduled; + private volatile Future activeReload; + private volatile CompletableFuture activeOperation; + + private BackendControlConnector(VotingPluginMain plugin, Path dataDirectory, Settings settings, String credential, + boolean recovering) { + this.plugin = plugin; + this.dataDirectory = dataDirectory; + this.settings = settings; + this.credential = credential; + this.recovering = recovering; + ThreadFactory factory = runnable -> { + Thread thread = new Thread(runnable, "votingplugin-control-backend"); + thread.setDaemon(true); + return thread; + }; + executor = Executors.newSingleThreadScheduledExecutor(factory); + http = HttpClient.newBuilder().connectTimeout(Duration.ofMillis(settings.connectTimeoutMillis())) + .followRedirects(HttpClient.Redirect.NEVER).build(); + configurations = new BackendConfigurationService(plugin.getDataFolder().toPath(), this::reloadConfiguration); + } + + private void reloadConfiguration(String fileName) throws Exception { + Future reload; + synchronized (operationLifecycle) { + if (closed) throw new IllegalStateException("Bukkit Control connector is stopping"); + reload = plugin.getServer().getScheduler().callSyncMethod(plugin, () -> { + plugin.reload(); + if ("BungeeSettings.yml".equals(fileName)) plugin.restartBackendProxyHandler(); + return null; + }); + activeReload = reload; + } + try { + reload.get(30, TimeUnit.SECONDS); + } finally { + synchronized (operationLifecycle) { + if (activeReload == reload) activeReload = null; + } + } + } + + public static BackendControlConnector create(VotingPluginMain plugin) throws IOException { + Path root = plugin.getDataFolder().toPath().toAbsolutePath().normalize(); + BackendControlResultStore.State recovered = BackendControlResultStore.load(root); + if (recovered != null) { + Settings settings = Settings.from(recovered.route()); + String credential = ControlCredentialFile.read(root, settings.credentialFile()); + BackendControlConnector connector = new BackendControlConnector(plugin, root, settings, credential, true); + connector.completed.putAll(recovered.results()); + return connector; + } + ConfigurationSection control = plugin.getConfigFile().getData().getConfigurationSection("Control.Backend"); + if (control == null || !control.getBoolean("Enabled", false)) return null; + String nodeId = control.getString("NodeId", "").trim(); + if (!NODE_ID.matcher(nodeId).matches()) throw new IllegalArgumentException("Control.Backend.NodeId is invalid"); + String endpointValue = control.getString("Endpoint", "").trim(); + URI endpoint = URI.create(endpointValue); + if (!Set.of("http", "https").contains(endpoint.getScheme()) || endpoint.getHost() == null + || endpoint.getUserInfo() != null || endpoint.getQuery() != null || endpoint.getFragment() != null + || (endpoint.getPath() != null && !endpoint.getPath().isEmpty() && !"/".equals(endpoint.getPath()))) { + throw new IllegalArgumentException("Control.Backend.Endpoint must be an HTTP(S) origin"); + } + String credentialFile = control.getString("CredentialFile", "control-credential.txt"); + String credential = ControlCredentialFile.read(root, credentialFile); + Settings settings = new Settings(nodeId, endpoint, credentialFile, + bounded(control.getInt("HeartbeatSeconds", 30), 10, 300, "HeartbeatSeconds"), + bounded(control.getInt("ConnectTimeoutMillis", 3000), 500, 30000, "ConnectTimeoutMillis"), + bounded(control.getInt("RequestTimeoutMillis", 10000), 500, 30000, "RequestTimeoutMillis")); + return new BackendControlConnector(plugin, root, settings, credential, false); + } + + public boolean hasPendingResults() { + synchronized (completed) { return !completed.isEmpty(); } + } + + public void start() { schedule(0); } + + private void schedule(long delayMillis) { + if (!closed) scheduled = executor.schedule(this::cycle, delayMillis, TimeUnit.MILLISECONDS); + } + + private void cycle() { + if (closed || !running.compareAndSet(false, true)) return; + try { + JsonObject primary = registered ? heartbeat() : register(); + JsonObject node = primary.has("node") ? primary.getAsJsonObject("node") : null; + if (!registered) { + JsonObject identity = primary.has("identity") ? primary.getAsJsonObject("identity") : null; + if (identity == null || identity.get("protocolVersion").getAsInt() != PROTOCOL_VERSION) { + throw new ConnectorException("protocol mismatch"); + } + } + operationsAccepted = negotiatedCapability(node, "config.files.v1", operationsAccepted); + quickSetupsAccepted = negotiatedCapability(node, "config.quick-setup.v1", quickSetupsAccepted); + try { + requireFileCapability(operationsAccepted); + } catch (ConnectorException incompatible) { + registered = false; + throw incompatible; + } + registered = true; + if (closed) return; + if (operationsAccepted) { + CompletableFuture operation = new CompletableFuture<>(); + synchronized (operationLifecycle) { + if (closed) return; + activeOperation = operation; + } + try { + claimAndExecute(); + } finally { + operation.complete(null); + synchronized (operationLifecycle) { + if (activeOperation == operation) activeOperation = null; + } + } + } + if (failures > 0) plugin.getLogger().info("[Control] Bukkit configuration connector recovered"); + failures = 0; + } catch (Exception failure) { + failures = Math.min(30, failures + 1); + if (failures == 1 || failures % 10 == 0) { + plugin.getLogger().warning("[Control] Bukkit configuration connector unavailable; VotingPlugin remains active"); + } + } finally { + running.set(false); + long delay = failures == 0 ? TimeUnit.SECONDS.toMillis(settings.heartbeatSeconds()) + : Math.min(TimeUnit.MINUTES.toMillis(5), 1000L << Math.min(failures - 1, 8)); + schedule(delay); + } + } + + private JsonObject register() throws Exception { + // A registration must explicitly establish required capabilities. Heartbeats may omit the unchanged set. + operationsAccepted = false; + quickSetupsAccepted = false; + JsonObject body = sessionBody(); + body.addProperty("nodeId", settings.nodeId()); + body.addProperty("displayName", settings.nodeId()); + body.addProperty("platform", "BUKKIT"); + body.addProperty("pluginVersion", plugin.getDescription().getVersion()); + JsonArray detectedPlugins = new JsonArray(); + java.util.Arrays.stream(plugin.getServer().getPluginManager().getPlugins()) + .map(installed -> installed.getDescription().getName()).filter(name -> name != null && !name.isBlank()) + .distinct().sorted(String.CASE_INSENSITIVE_ORDER).limit(128).forEach(detectedPlugins::add); + body.add("detectedPlugins", detectedPlugins); + addCapabilities(body); + return requireObject(send("POST", "/api/v1/nodes/register", body), 200, 201); + } + + private JsonObject heartbeat() throws Exception { + JsonObject body = sessionBody(); + addCapabilities(body); + Response response = send("PUT", "/api/v1/nodes/" + settings.nodeId() + "/heartbeat", body); + if (response.status() == 404) { + registered = false; + return register(); + } + return requireObject(response, 200); + } + + private void claimAndExecute() throws Exception { + if (submitCompletedResult()) return; + JsonObject body = new JsonObject(); + body.addProperty("sessionId", sessionId.toString()); + Response response = send("POST", "/api/v1/nodes/" + settings.nodeId() + "/operations", body); + if (response.status() == 204) return; + JsonObject task = requireObject(response, 200); + if (closed) return; + UUID operationId = UUID.fromString(string(task, "operationId")); + StoredResult result; + synchronized (completed) { + result = completed.get(operationId); + } + if (result != null) { + if (!result.committed() && !result.claimRequired() && !anticipatedResultIsInstalled(result)) { + result = null; + } else { + result = committedForAttempt(result, string(task, "attemptId")); + synchronized (completed) { completed.put(operationId, result); } + persistCompleted(); + } + } + if (result == null) { + TaskResult executed = execute(operationId, task); + JsonObject resultJson = executed.json(); + resultJson.addProperty("attemptId", string(task, "attemptId")); + result = new StoredResult(resultJson, executed.restartConnector(), true, false); + synchronized (completed) { + completed.put(operationId, result); + } + persistCompleted(); + } + submitCompletedResult(operationId, result); + } + + private boolean submitCompletedResult() throws Exception { + prepareWriteAheadIntents(); + Map.Entry pending; + synchronized (completed) { + pending = completed.entrySet().stream().filter(entry -> entry.getValue().committed()).findFirst().orElse(null); + } + if (pending == null) return false; + persistCompleted(); + return submitCompletedResult(pending.getKey(), pending.getValue()); + } + + private boolean submitCompletedResult(UUID operationId, StoredResult submitted) throws Exception { + JsonObject resultBody = submitted.result().deepCopy(); + resultBody.addProperty("sessionId", sessionId.toString()); + Response response = send("POST", "/api/v1/nodes/" + settings.nodeId() + "/operations/" + operationId + + "/result", resultBody); + if (taskLeaseExpired(response)) { + synchronized (completed) { + completed.put(operationId, + new StoredResult(submitted.result(), submitted.restartConnector(), false, true)); + } + persistCompleted(); + return false; + } + if (operationNotFound(response)) { + acknowledgeCompletedResult(operationId, submitted); + return true; + } + afterResultAcknowledged( + () -> requireObject(response, 200), + () -> acknowledgeCompletedResult(operationId, submitted)); + return true; + } + + private void acknowledgeCompletedResult(UUID operationId, StoredResult submitted) throws Exception { + synchronized (completed) { completed.remove(operationId); } + try { + persistCompleted(); + } catch (Exception failure) { + synchronized (completed) { completed.put(operationId, submitted); } + throw failure; + } + boolean drained; + synchronized (completed) { drained = completed.isEmpty(); } + if (drained && (submitted.restartConnector() || recovering)) { + plugin.getServer().getScheduler().runTask(plugin, plugin::restartBackendControlConnector); + } + } + + static boolean taskLeaseExpired(Response response) { + return responseCode(response, 409, "TASK_LEASE_EXPIRED"); + } + + static boolean operationNotFound(Response response) { + return responseCode(response, 404, "OPERATION_NOT_FOUND"); + } + + private static boolean responseCode(Response response, int status, String code) { + if (response.status() != status) return false; + try { + JsonObject error = JsonParser.parseString(response.body()).getAsJsonObject().getAsJsonObject("error"); + return error != null && error.has("code") && code.equals(error.get("code").getAsString()); + } catch (RuntimeException ignored) { + return false; + } + } + + private void persistCompleted() throws IOException { + Map snapshot; + synchronized (completed) { snapshot = new LinkedHashMap<>(completed); } + BackendControlResultStore.save(dataDirectory, settings.route(), snapshot); + } + + private void persistIntent(UUID operationId, TaskResult anticipated, String attemptId) throws IOException { + JsonObject result = anticipated.json(); + result.addProperty("attemptId", attemptId); + synchronized (completed) { + completed.put(operationId, new StoredResult(result, anticipated.restartConnector(), false, false)); + } + persistCompleted(); + } + + private void prepareWriteAheadIntents() throws IOException { + Map snapshot; + synchronized (completed) { snapshot = new LinkedHashMap<>(completed); } + boolean changed = false; + for (Map.Entry entry : snapshot.entrySet()) { + StoredResult pending = entry.getValue(); + if (pending.committed() || pending.claimRequired()) continue; + StoredResult recovered = anticipatedResultIsInstalled(pending) + ? committedForAttempt(pending, string(pending.result(), "attemptId")) + : abortedIntent(pending); + synchronized (completed) { + if (completed.get(entry.getKey()) == pending) { + completed.put(entry.getKey(), recovered); + changed = true; + } + } + } + if (changed) persistCompleted(); + } + + static StoredResult abortedIntent(StoredResult pending) { + JsonObject result = TaskResult.failure("RECOVERY_ABORTED", + "Configuration apply did not finish before node recovery").json(); + result.addProperty("attemptId", string(pending.result(), "attemptId")); + return new StoredResult(result, false, true, false); + } + + private boolean anticipatedResultIsInstalled(StoredResult pending) throws IOException { + JsonObject result = pending.result(); + if (!result.has("revision") || !result.has("configuration")) return false; + String revision = result.get("revision").getAsString(); + JsonObject configuration = result.getAsJsonObject("configuration"); + String domain = string(configuration, "domain"); + if ("file".equals(domain)) { + return revision.equals(configurations.read(string(configuration, "fileName")).revision()); + } + if ("quick-setup".equals(domain)) { + return revision.equals(configurations.currentQuickSetupRevision(string(configuration, "preset"))); + } + return false; + } + + private static StoredResult committedForAttempt(StoredResult pending, String attemptId) { + JsonObject result = pending.result().deepCopy(); + result.addProperty("attemptId", attemptId); + return new StoredResult(result, pending.restartConnector(), true, false); + } + + static void afterResultAcknowledged(ResultSubmission submission, ResultAcknowledgement acknowledged) + throws Exception { + submission.submit(); + acknowledged.acknowledge(); + } + + private TaskResult execute(UUID operationId, JsonObject task) { + try { + String type = string(task, "type"); + JsonObject configuration = task.getAsJsonObject("configuration"); + String domain = string(configuration, "domain"); + if ("file".equals(domain)) return executeFile(operationId, type, configuration, task); + if ("quick-setup".equals(domain)) { + if (!quickSetupsAccepted) return TaskResult.failure("UNSUPPORTED_TASK", "Quick setups were not negotiated"); + return executeQuick(operationId, type, configuration, task); + } + return TaskResult.failure("UNSUPPORTED_TASK", "Configuration domain is unsupported"); + } catch (BackendConfigurationService.StaleRevisionException e) { + return TaskResult.failure("STALE_REVISION", "Configuration changed after preview"); + } catch (BackendConfigurationService.ApplyFailureException e) { + return TaskResult.failure("RELOAD_FAILED", "Reload failed after persistence", e.rolledBack()); + } catch (IllegalArgumentException e) { + return TaskResult.failure("VALIDATION_ERROR", e.getMessage()); + } catch (Exception e) { + return TaskResult.failure("APPLY_FAILED", "Configuration operation failed"); + } + } + + private TaskResult executeFile(UUID operationId, String type, JsonObject configuration, JsonObject task) + throws IOException { + String fileName = string(configuration, "fileName"); + if ("READ".equals(type)) { + BackendConfigurationService.Document document = configurations.read(fileName); + return TaskResult.file(document, List.of(), false, false, false); + } + String content = string(configuration, "content"); + if ("PREVIEW".equals(type)) { + BackendConfigurationService.Preview preview = configurations.preview(fileName, content); + BackendConfigurationService.Document current = configurations.read(fileName); + if (!preview.revision().equals(current.revision())) throw new BackendConfigurationService.StaleRevisionException(); + return TaskResult.file(current, preview.changes(), false, false, false); + } + if ("APPLY".equals(type)) { + BackendConfigurationService.Preview preview = configurations.preview(fileName, content); + persistIntent(operationId, + TaskResult.file(configurations.proposedDocument(preview), preview.changes(), true, false, + "Config.yml".equals(fileName)), string(task, "attemptId")); + BackendConfigurationService.ApplyResult applied = configurations.apply(fileName, content, + string(task, "expectedRevision")); + return TaskResult.file(applied.document(), applied.changes(), true, applied.rolledBack(), + "Config.yml".equals(fileName)); + } + return TaskResult.failure("UNSUPPORTED_TASK", "Task type is unsupported"); + } + + private TaskResult executeQuick(UUID operationId, String type, JsonObject configuration, JsonObject task) + throws IOException { + if ("READ".equals(type)) return TaskResult.failure("UNSUPPORTED_TASK", "Quick setups cannot be read"); + String preset = string(configuration, "preset"); + Map options = options(configuration.getAsJsonObject("options")); + if ("PREVIEW".equals(type)) { + BackendConfigurationService.QuickPreview preview = configurations.previewQuickSetup(preset, options); + return TaskResult.quick(preset, options, preview.revision(), preview.changes(), false); + } + if ("APPLY".equals(type)) { + BackendConfigurationService.QuickPreview preview = configurations.previewQuickSetup(preset, options); + persistIntent(operationId, + TaskResult.quick(preset, options, configurations.proposedQuickSetupRevision(preview), preview.changes(), + true, "Config.yml".equals(preview.proposal().fileName())), string(task, "attemptId")); + BackendConfigurationService.ApplyResult applied = configurations.applyQuickSetup(preset, options, + string(task, "expectedRevision")); + return TaskResult.quick(preset, options, applied.document().revision(), applied.changes(), true, + "Config.yml".equals(applied.document().fileName())); + } + return TaskResult.failure("UNSUPPORTED_TASK", "Task type is unsupported"); + } + + private Response send(String method, String path, JsonObject body) throws Exception { + HttpRequest request = HttpRequest.newBuilder(settings.endpoint().resolve(path)) + .timeout(Duration.ofMillis(settings.requestTimeoutMillis())).header("Content-Type", "application/json") + .header("Authorization", "Bearer " + credential) + .method(method, HttpRequest.BodyPublishers.ofString(body.toString(), StandardCharsets.UTF_8)).build(); + HttpResponse response = http.send(request, + new BoundedHttpBodyHandler(MAX_RESPONSE_BYTES, + Duration.ofMillis(settings.requestTimeoutMillis()))); + return new Response(response.statusCode(), new String(response.body(), StandardCharsets.UTF_8)); + } + + private static JsonObject requireObject(Response response, int... statuses) { + boolean accepted = false; + for (int status : statuses) if (response.status() == status) accepted = true; + if (!accepted) throw new ConnectorException("unexpected response"); + JsonElement element = JsonParser.parseString(response.body()); + if (!element.isJsonObject()) throw new ConnectorException("malformed response"); + return element.getAsJsonObject(); + } + + private JsonObject sessionBody() { + JsonObject body = new JsonObject(); + body.addProperty("sessionId", sessionId.toString()); + body.addProperty("protocolVersion", PROTOCOL_VERSION); + return body; + } + + private static void addCapabilities(JsonObject body) { + JsonArray capabilities = new JsonArray(); + CAPABILITIES.stream().sorted().forEach(capabilities::add); + body.add("capabilities", capabilities); + JsonArray required = new JsonArray(); + required.add("config.files.v1"); + body.add("requiredCapabilities", required); + } + + private static boolean contains(JsonArray accepted, String expected) { + if (accepted == null) return false; + for (JsonElement value : accepted) if (value.isJsonPrimitive() && expected.equals(value.getAsString())) return true; + return false; + } + + static boolean negotiatedCapability(JsonObject node, String capability, boolean current) { + if (node == null || !node.has("acceptedCapabilities")) return current; + return contains(node.getAsJsonArray("acceptedCapabilities"), capability); + } + + static void requireFileCapability(boolean accepted) { + if (!accepted) throw new ConnectorException("required config.files.v1 capability was not accepted"); + } + + private static String string(JsonObject object, String name) { + if (object == null || !object.has(name) || object.get(name).isJsonNull() + || !object.get(name).isJsonPrimitive()) throw new IllegalArgumentException(name + " is required"); + return object.get(name).getAsString(); + } + + private static Map options(JsonObject object) { + Map values = new LinkedHashMap<>(); + if (object != null) object.entrySet().forEach(entry -> values.put(entry.getKey(), entry.getValue().getAsString())); + return Map.copyOf(values); + } + + private static int bounded(int value, int min, int max, String name) { + if (value < min || value > max) throw new IllegalArgumentException("Control.Backend." + name + " is invalid"); + return value; + } + + @Override + public void close() { + Future reload; + CompletableFuture operation; + synchronized (operationLifecycle) { + closed = true; + reload = activeReload; + operation = activeOperation; + } + ScheduledFuture current = scheduled; + if (current != null) current.cancel(false); + if (reload != null && Bukkit.isPrimaryThread()) reload.cancel(false); + awaitShutdown(executor, operation); + } + + static void awaitShutdown(ScheduledExecutorService executor, CompletableFuture operation) { + executor.shutdown(); + if (operation != null) { + try { + operation.get(SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (java.util.concurrent.CancellationException | java.util.concurrent.ExecutionException ignored) { + // A completed failed operation is no longer able to outlive shutdown. + } catch (java.util.concurrent.TimeoutException e) { + throw new IllegalStateException("Bukkit Control operation did not stop before connector shutdown", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting for the Bukkit Control operation", e); + } + } + try { + if (!executor.awaitTermination(SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + throw new IllegalStateException("Bukkit Control operation did not stop before connector shutdown"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting for the Bukkit Control operation", e); + } + } + + private record Settings(String nodeId, URI endpoint, String credentialFile, int heartbeatSeconds, + int connectTimeoutMillis, int requestTimeoutMillis) { + private Settings { + if (!NODE_ID.matcher(nodeId).matches()) throw new IllegalArgumentException("Control.Backend.NodeId is invalid"); + if (!Set.of("http", "https").contains(endpoint.getScheme()) || endpoint.getHost() == null + || endpoint.getUserInfo() != null || endpoint.getQuery() != null || endpoint.getFragment() != null + || (endpoint.getPath() != null && !endpoint.getPath().isEmpty() && !"/".equals(endpoint.getPath()))) { + throw new IllegalArgumentException("Control.Backend.Endpoint must be an HTTP(S) origin"); + } + if (credentialFile == null || credentialFile.isBlank() || credentialFile.length() > 2048) { + throw new IllegalArgumentException("Control.Backend.CredentialFile is invalid"); + } + bounded(heartbeatSeconds, 10, 300, "HeartbeatSeconds"); + bounded(connectTimeoutMillis, 500, 30000, "ConnectTimeoutMillis"); + bounded(requestTimeoutMillis, 500, 30000, "RequestTimeoutMillis"); + } + + private static Settings from(Route route) { + return new Settings(route.nodeId(), route.endpoint(), route.credentialFile(), route.heartbeatSeconds(), + route.connectTimeoutMillis(), route.requestTimeoutMillis()); + } + + private Route route() { + return new Route(nodeId, endpoint, credentialFile, heartbeatSeconds, connectTimeoutMillis, + requestTimeoutMillis); + } + } + record Response(int status, String body) { } + @FunctionalInterface + interface ResultSubmission { void submit() throws Exception; } + @FunctionalInterface + interface ResultAcknowledgement { void acknowledge() throws Exception; } + + private record TaskResult(boolean success, String code, String message, String revision, + JsonObject configuration, List changes, boolean reloaded, boolean rolledBack, + boolean restartConnector) { + private JsonObject json() { + JsonObject body = new JsonObject(); + body.addProperty("success", success); + body.addProperty("code", code); + body.addProperty("message", message); + if (revision != null) body.addProperty("revision", revision); + if (configuration != null) body.add("configuration", configuration); + JsonArray listed = new JsonArray(); + changes.forEach(listed::add); + body.add("changes", listed); + body.addProperty("reloaded", reloaded); + body.addProperty("rolledBack", rolledBack); + return body; + } + + private static TaskResult file(BackendConfigurationService.Document document, List changes, + boolean reloaded, boolean rolledBack, boolean restartConnector) { + JsonObject config = new JsonObject(); + config.addProperty("domain", "file"); + config.addProperty("fileName", document.fileName()); + config.addProperty("content", document.content()); + return new TaskResult(true, "OK", "Operation completed", document.revision(), config, + List.copyOf(changes), reloaded, rolledBack, restartConnector); + } + + private static TaskResult quick(String preset, Map options, String revision, + List changes, boolean reloaded) { + return quick(preset, options, revision, changes, reloaded, false); + } + private static TaskResult quick(String preset, Map options, String revision, + List changes, boolean reloaded, boolean restartConnector) { + JsonObject config = new JsonObject(); + config.addProperty("domain", "quick-setup"); + config.addProperty("preset", preset); + JsonObject values = new JsonObject(); + options.forEach(values::addProperty); + config.add("options", values); + return new TaskResult(true, "OK", "Operation completed", revision, config, List.copyOf(changes), + reloaded, false, restartConnector); + } + + private static TaskResult failure(String code, String message) { return failure(code, message, false); } + private static TaskResult failure(String code, String message, boolean rolledBack) { + return new TaskResult(false, code, message == null ? "Operation failed" : message, null, null, + List.of(), false, rolledBack, false); + } + } + + @SuppressWarnings("serial") private static final class ConnectorException extends RuntimeException { + private ConnectorException(String message) { super(message); } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlResultStore.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlResultStore.java new file mode 100644 index 0000000000..7d9ce39dc5 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlResultStore.java @@ -0,0 +1,172 @@ +package com.bencodez.votingplugin.control; + +import java.io.IOException; +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.channels.SeekableByteChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import com.bencodez.votingplugin.util.DurableFiles; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +/** Durable result journal used to make locally applied Control operations restart-safe. */ +final class BackendControlResultStore { + private static final int VERSION = 2; + // A valid 512 KiB YAML result may expand up to sixfold when characters require JSON unicode escaping. + private static final int MAX_BYTES = 4 * 1024 * 1024; + private static final int MAX_RESULTS = 128; + private static final String FILE_NAME = ".control-pending-results.json"; + + private BackendControlResultStore() { } + + static State load(Path dataDirectory) throws IOException { + Path target = target(dataDirectory); + if (!Files.exists(target, LinkOption.NOFOLLOW_LINKS)) return null; + if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target) + || Files.size(target) > MAX_BYTES) { + throw new IOException("Control pending-result journal is unsafe or too large"); + } + byte[] bytes; + try (SeekableByteChannel channel = Files.newByteChannel(target, + Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS))) { + ByteBuffer buffer = ByteBuffer.allocate((int) Files.size(target) + 1); + while (channel.read(buffer) >= 0 && buffer.hasRemaining()) { } + if (!buffer.hasRemaining()) throw new IOException("Control pending-result journal is too large"); + buffer.flip(); + bytes = new byte[buffer.remaining()]; + buffer.get(bytes); + } + try { + JsonElement parsed = JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8)); + if (!parsed.isJsonObject()) throw invalid(); + JsonObject root = parsed.getAsJsonObject(); + if (integer(root, "version") != VERSION) throw invalid(); + JsonObject routeJson = object(root, "route"); + Route route = new Route(string(routeJson, "nodeId"), URI.create(string(routeJson, "endpoint")), + string(routeJson, "credentialFile"), integer(routeJson, "heartbeatSeconds"), + integer(routeJson, "connectTimeoutMillis"), integer(routeJson, "requestTimeoutMillis")); + JsonArray listed = array(root, "results"); + if (listed.size() == 0 || listed.size() > MAX_RESULTS) throw invalid(); + Map results = new LinkedHashMap<>(); + for (JsonElement element : listed) { + if (!element.isJsonObject()) throw invalid(); + JsonObject item = element.getAsJsonObject(); + UUID operationId = UUID.fromString(string(item, "operationId")); + JsonObject result = object(item, "result").deepCopy(); + if (!item.has("restartConnector") || !item.get("restartConnector").isJsonPrimitive() + || !item.has("committed") || !item.get("committed").isJsonPrimitive() + || !item.has("claimRequired") || !item.get("claimRequired").isJsonPrimitive()) throw invalid(); + StoredResult previous = results.put(operationId, + new StoredResult(result, item.get("restartConnector").getAsBoolean(), + item.get("committed").getAsBoolean(), item.get("claimRequired").getAsBoolean())); + if (previous != null) throw invalid(); + } + return new State(route, Map.copyOf(results)); + } catch (RuntimeException e) { + throw new IOException("Control pending-result journal is malformed", e); + } + } + + static void save(Path dataDirectory, Route route, Map results) throws IOException { + Path target = target(dataDirectory); + if (results.isEmpty()) { + if (Files.isSymbolicLink(target)) throw new IOException("Control pending-result journal is unsafe"); + DurableFiles.deleteIfExists(target); + return; + } + if (results.size() > MAX_RESULTS) throw new IOException("Too many pending Control results"); + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS) + && (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target))) { + throw new IOException("Control pending-result journal is unsafe"); + } + JsonObject root = new JsonObject(); + root.addProperty("version", VERSION); + JsonObject routeJson = new JsonObject(); + routeJson.addProperty("nodeId", route.nodeId()); + routeJson.addProperty("endpoint", route.endpoint().toString()); + routeJson.addProperty("credentialFile", route.credentialFile()); + routeJson.addProperty("heartbeatSeconds", route.heartbeatSeconds()); + routeJson.addProperty("connectTimeoutMillis", route.connectTimeoutMillis()); + routeJson.addProperty("requestTimeoutMillis", route.requestTimeoutMillis()); + root.add("route", routeJson); + JsonArray listed = new JsonArray(); + results.forEach((operationId, result) -> { + JsonObject item = new JsonObject(); + item.addProperty("operationId", operationId.toString()); + item.add("result", result.result().deepCopy()); + item.addProperty("restartConnector", result.restartConnector()); + item.addProperty("committed", result.committed()); + item.addProperty("claimRequired", result.claimRequired()); + listed.add(item); + }); + root.add("results", listed); + byte[] bytes = root.toString().getBytes(StandardCharsets.UTF_8); + if (bytes.length > MAX_BYTES) throw new IOException("Control pending-result journal is too large"); + Files.createDirectories(dataDirectory); + Path staging = Files.createTempFile(dataDirectory, ".control-results-", ".json"); + try { + try (FileChannel channel = FileChannel.open(staging, StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE)) { + ByteBuffer buffer = ByteBuffer.wrap(bytes); + while (buffer.hasRemaining()) channel.write(buffer); + channel.force(true); + } + Files.move(staging, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + DurableFiles.forceDirectory(dataDirectory); + } finally { + Files.deleteIfExists(staging); + } + } + + private static Path target(Path dataDirectory) { + return dataDirectory.toAbsolutePath().normalize().resolve(FILE_NAME); + } + + private static JsonObject object(JsonObject object, String name) { + if (!object.has(name) || !object.get(name).isJsonObject()) throw invalid(); + return object.getAsJsonObject(name); + } + + private static JsonArray array(JsonObject object, String name) { + if (!object.has(name) || !object.get(name).isJsonArray()) throw invalid(); + return object.getAsJsonArray(name); + } + + private static String string(JsonObject object, String name) { + if (!object.has(name) || !object.get(name).isJsonPrimitive()) throw invalid(); + String value = object.get(name).getAsString(); + if (value == null || value.isBlank() || value.length() > 2048) throw invalid(); + return value; + } + + private static int integer(JsonObject object, String name) { + if (!object.has(name) || !object.get(name).isJsonPrimitive()) throw invalid(); + return object.get(name).getAsInt(); + } + + private static IllegalArgumentException invalid() { + return new IllegalArgumentException("invalid pending-result journal"); + } + + record Route(String nodeId, URI endpoint, String credentialFile, int heartbeatSeconds, + int connectTimeoutMillis, int requestTimeoutMillis) { } + record StoredResult(JsonObject result, boolean restartConnector, boolean committed, boolean claimRequired) { + StoredResult { + if (committed && claimRequired) throw new IllegalArgumentException("committed result cannot require a claim"); + } + } + record State(Route route, Map results) { } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java index 5dd4a14099..ab0dab545a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java @@ -27,8 +27,11 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; import javax.net.ssl.SSLParameters; @@ -64,6 +67,8 @@ import com.bencodez.votingplugin.proxy.cache.VoteCacheHandler; import com.bencodez.votingplugin.proxy.cache.nonvoted.INonVotedPlayersStorage; import com.bencodez.votingplugin.proxy.cache.nonvoted.NonVotedPlayersCache; +import com.bencodez.votingplugin.proxy.control.ControlConnector; +import com.bencodez.votingplugin.proxy.control.HostedControlManager; import com.bencodez.votingplugin.proxy.multiproxy.MultiProxyHandler; import com.bencodez.votingplugin.proxy.multiproxy.MultiProxyMethod; import com.bencodez.votingplugin.proxy.multiproxy.MultiProxyServerSocketConfiguration; @@ -163,6 +168,15 @@ public abstract class VotingPluginProxy { private final BackendPlayerPresenceTracker backendPlayerPresenceTracker = new BackendPlayerPresenceTracker(); private final Map pendingPresenceHandoffs = new HashMap<>(); private final Set pendingBackendRecoverySnapshots = ConcurrentHashMap.newKeySet(); + private volatile ControlConnector controlConnector; + private volatile HostedControlManager hostedControlManager; + private final Object controlLifecycleLock = new Object(); + private final AtomicLong controlServicesGeneration = new AtomicLong(); + private final ExecutorService controlLifecycleExecutor = Executors.newSingleThreadExecutor(task -> { + Thread thread = new Thread(task, "votingplugin-control-lifecycle"); + thread.setDaemon(true); + return thread; + }); public VotingPluginProxy() { enabled = true; @@ -546,17 +560,7 @@ protected boolean sendProxyBroadcastEnvelopeNow(String server, JsonEnvelope enve // Standalone broadcasts use the same initialized client as normal // envelopes. This preserves the socket connection and its delivery // acknowledgement instead of creating a second short-lived socket. - ClientHandler socketClient = clientHandles == null ? null : clientHandles.get(server); - if (socketClient == null) { - return false; - } - try { - socketClient.sendEnvelope(envelope); - return true; - } catch (RuntimeException e) { - debug(e.getMessage()); - return false; - } + return sendSocketEnvelope(server, envelope); default: return false; } @@ -995,6 +999,9 @@ public UUID fetchUUID(String playerName) throws IOException, InterruptedExceptio public abstract Set getAllAvailableServers(); + /** Complete platform server set before whitelist/blocked routing filters. */ + public abstract Set getAllConfiguredServers(); + public abstract VotingPluginProxyConfig getConfig(); public abstract String getCurrentPlayerServer(String player); @@ -1315,22 +1322,7 @@ public void onReceiveEnvelope(JsonEnvelope envelope) { } }); - clientHandles = new HashMap<>(); - List l = getConfig().getBlockedServers(); - for (String s : getConfig().getSpigotServers()) { - if (!l.contains(s)) { - Map d = getConfig().getSpigotServerConfiguration(s); - String host = ""; - if (d.containsKey("Host")) { - host = (String) d.get("Host"); - } - int port = 1298; - if (d.containsKey("Port")) { - port = (int) d.get("Port"); - } - clientHandles.put(s, new ClientHandler(host, port, encryptionHandler, getConfig().getDebug())); - } - } + rebuildSocketClients(); } else if (method.equals(BungeeMethod.REDIS)) { redisHandler = new RedisHandler(getConfig().getRedisHost(), getConfig().getRedisPort(), getConfig().getRedisUsername(), getConfig().getRedisPassword(), getConfig().getRedisDbIndex(), @@ -1392,10 +1384,7 @@ public void sendMessage(String server, int delay, JsonEnvelope envelope) { sendRedisEnvelopeServer(server, envelope); break; case SOCKETS: - ClientHandler socketClient = clientHandles == null ? null : clientHandles.get(server); - if (socketClient != null) { - socketClient.sendEnvelope(envelope); - } + sendSocketEnvelope(server, envelope); break; default: break; @@ -1559,10 +1548,120 @@ public void onReceive(JsonEnvelope message) { loadTaskTimer(this::maintainBackendPresence, PRESENCE_MAINTENANCE_INTERVAL_SECONDS, PRESENCE_MAINTENANCE_INTERVAL_SECONDS); } + startControlServices(); debug("VotingPluginProxy loaded, ONLINEMODE: " + getConfig().getOnlineMode()); } + private void startControlServices() { + synchronized (controlLifecycleLock) { + ControlConnector predecessor = controlConnector; + if (predecessor != null && predecessor.deferReplacementUntilSafe(this::restartControlServicesAsync)) { + log("[Control] service restart deferred until the current result is acknowledged"); + return; + } + stopControlServicesLocked(true); + startControlServicesLocked(); + } + } + + /** Keeps potentially long hosted-Control handoffs off proxy command/event threads. */ + private void restartControlServicesAsync() { + final long generation = controlServicesGeneration.incrementAndGet(); + try { + controlLifecycleExecutor.execute(() -> { + try { + synchronized (controlLifecycleLock) { + if (!enabled || generation != controlServicesGeneration.get()) return; + ControlConnector predecessor = controlConnector; + if (predecessor != null + && predecessor.deferReplacementUntilSafe(this::restartControlServicesAsync)) { + log("[Control] service restart deferred until the current result is acknowledged"); + return; + } + stopControlServicesLocked(true); + startControlServicesLocked(); + } + } catch (RuntimeException failure) { + if (generation == controlServicesGeneration.get()) { + logSevere("[Control] asynchronous service restart failed: " + failure.getMessage()); + } + } + }); + } catch (RuntimeException failure) { + logSevere("[Control] services were not restarted because async scheduling failed"); + } + } + + /** Rebuilds a recovery connector from current settings after its durable result is acknowledged. */ + public final void restartControlServicesAfterRecovery() { + restartControlServicesAsync(); + } + + private void stopControlServices(boolean waitForHosted) { + synchronized (controlLifecycleLock) { + stopControlServicesLocked(waitForHosted); + } + } + + private void startControlServicesLocked() { + if (getConfig().getControlHostedEnabled()) { + try { + hostedControlManager = HostedControlManager.create(this); + if (hostedControlManager != null) hostedControlManager.start(); + } catch (IllegalArgumentException e) { + hostedControlManager = null; + logSevere("[Control Host] configuration is invalid; VotingPlugin remains unaffected"); + } + } + try { + controlConnector = ControlConnector.create(this); + if (controlConnector != null) controlConnector.start(); + } catch (IOException | IllegalArgumentException e) { + controlConnector = null; + logSevere("[Control] connector configuration or credential is invalid; voting remains unaffected"); + } + } + + private void stopControlServicesLocked(boolean waitForHosted) { + ControlConnector connector = controlConnector; + if (connector != null) { + try { + connector.close(); + if (controlConnector == connector) controlConnector = null; + } catch (RuntimeException failure) { + if (waitForHosted) throw failure; + if (controlConnector == connector) controlConnector = null; + logSevere("[Control] connector did not stop cleanly; proxy cleanup will continue"); + } + } + HostedControlManager manager = hostedControlManager; + if (manager != null) { + try { + if (waitForHosted) { + manager.closeAndWait(); + } else { + manager.close(); + } + if (hostedControlManager == manager) hostedControlManager = null; + } catch (RuntimeException failure) { + if (waitForHosted) throw failure; + if (hostedControlManager == manager) hostedControlManager = null; + logSevere("[Control Host] manager did not stop cleanly; proxy cleanup will continue"); + } + } + } + + public String getControlConnectorStatus() { + ControlConnector connector = controlConnector; + return connector == null ? "DISABLED" : connector.status().name(); + } + + public String getHostedControlStatus() { + HostedControlManager manager = hostedControlManager; + return manager == null ? "DISABLED" : manager.status().name(); + } + /** * Handles both the original login notification and extended presence logins. * Kept protected so transport-policy behavior can be regression tested without @@ -2270,40 +2369,82 @@ private void logInfo(String msg) { public abstract void logSevere(String message); public void onDisable() { - getVoteCacheHandler().saveVoteCache(); - - if (getProxyMysqlMessenger() != null) { - getProxyMysqlMessenger().shutdown(); - } - - if (getProxyMySQL() != null) { - getProxyMySQL().shutdown(); - } - if (multiProxyHandler != null) { - multiProxyHandler.close(); - } + onDisable(false); + } - if (socketHandler != null) { - socketHandler.closeConnection(); + /** Full runtime replacement waits for hosted workers; final proxy stop remains non-blocking. */ + public void onDisable(boolean waitForHosted) { + if (waitForHosted) { + prepareForRuntimeReplacement(); + } else { + controlServicesGeneration.incrementAndGet(); + controlLifecycleExecutor.shutdownNow(); + stopControlServices(false); } + completeRuntimeReplacementShutdown(); + } - if (redisHandler != null) { - redisHandler.close(); - } - if (redisPublisherPool != null) { - redisPublisherPool.close(); - redisPublisherPool = null; + /** Fail-closed gate that must complete before a replacement proxy runtime is created. */ + public void prepareForRuntimeReplacement() { + controlServicesGeneration.incrementAndGet(); + synchronized (controlLifecycleLock) { + ControlConnector connector = controlConnector; + if (connector != null && !connector.reserveRuntimeReplacement()) { + throw new IllegalStateException("Control result must be acknowledged before proxy runtime replacement"); + } + controlLifecycleExecutor.shutdown(); + stopControlServicesLocked(true); } + } - bungeeTimeChecker.shutdown(); + /** Best-effort remainder of runtime teardown after the Control overlap gate has succeeded. */ + public void completeRuntimeReplacementShutdown() { + runCleanup("vote cache", () -> getVoteCacheHandler().saveVoteCache()); + runCleanup("proxy MySQL messenger", () -> { + if (getProxyMysqlMessenger() != null) getProxyMysqlMessenger().shutdown(); + }); + runCleanup("proxy MySQL", () -> { + if (getProxyMySQL() != null) getProxyMySQL().shutdown(); + }); + runCleanup("multi-proxy handler", () -> { + if (multiProxyHandler != null) multiProxyHandler.close(); + }); + runCleanup("socket listener", () -> { + if (socketHandler != null) socketHandler.closeConnection(); + }); + runCleanup("socket clients", this::closeSocketClients); + runCleanup("Redis subscriber", () -> { + if (redisHandler != null) redisHandler.close(); + }); + runCleanup("Redis publisher", () -> { + JedisPool pool = redisPublisherPool; + try { + if (pool != null) pool.close(); + } finally { + if (redisPublisherPool == pool) redisPublisherPool = null; + } + }); + runCleanup("MQTT transport", () -> { + if (mqttHandler != null) mqttHandler.disconnect(); + }); + runCleanup("time checker", () -> bungeeTimeChecker.shutdown()); + runCleanup("global data", () -> { + if (getGlobalDataHandler() != null) getGlobalDataHandler().shutdown(); + }); + enabled = false; + } - if (getGlobalDataHandler() != null) { - getGlobalDataHandler().shutdown(); + private void runCleanup(String service, CleanupAction cleanup) { + try { + cleanup.run(); + } catch (Exception failure) { + logSevere("Unable to stop " + service + "; remaining proxy cleanup will continue"); } - - enabled = false; } + @FunctionalInterface + private interface CleanupAction { void run() throws Exception; } + public void onPluginMessageReceived(DataInputStream in) { runAsync(() -> { try { @@ -2407,15 +2548,80 @@ private void scheduleTimeVoteRetry() { } public void reload() { + reloadRuntime(true); + } + + /** Applies a Control-originated configuration reload without stopping its connector or hosted service. */ + public void reloadFromControl() { + reloadRuntime(false); + } + + private void reloadRuntime(boolean restartControlServices) { method = BungeeMethod.getByName(getConfig().getBungeeMethod()); if (getMethod() == null) { method = BungeeMethod.PLUGINMESSAGING; } warnUnsupportedDedicatedVotingProxyMode(); + if (!restartControlServices && method == BungeeMethod.SOCKETS) { + rebuildSocketClients(); + } setCurrentVotePartyVotesRequired( getConfig().getVotePartyVotesRequired() + getVoteCacheVotePartyIncreaseVotesRequired()); - loadMultiProxySupport(); + if (restartControlServices) { + loadMultiProxySupport(); + restartControlServicesAsync(); + } + } + + private synchronized void rebuildSocketClients() { + HashMap rebuilt = new HashMap<>(); + try { + List blocked = getConfig().getBlockedServers(); + for (String server : getConfig().getSpigotServers()) { + if (blocked.contains(server)) continue; + Map data = getConfig().getSpigotServerConfiguration(server); + String host = data.containsKey("Host") ? (String) data.get("Host") : ""; + int port = data.containsKey("Port") ? (int) data.get("Port") : 1298; + rebuilt.put(server, new ClientHandler(host, port, encryptionHandler, getConfig().getDebug())); + } + } catch (RuntimeException failure) { + stopSocketClients(rebuilt); + throw failure; + } + HashMap previous = clientHandles; + clientHandles = rebuilt; + stopSocketClients(previous); + } + + private synchronized boolean sendSocketEnvelope(String server, JsonEnvelope envelope) { + ClientHandler socketClient = clientHandles == null ? null : clientHandles.get(server); + if (socketClient == null) return false; + try { + socketClient.sendEnvelope(envelope); + return true; + } catch (RuntimeException e) { + debug(e.getMessage()); + return false; + } + } + + private synchronized void closeSocketClients() { + HashMap clients = clientHandles; + clientHandles = null; + stopSocketClients(clients); + } + + static void stopSocketClients(Map clients) { + if (clients == null) return; + for (ClientHandler client : clients.values()) { + if (client == null) continue; + try { + client.stopConnection(); + } catch (RuntimeException ignored) { + // Best effort: one broken client must not prevent the remaining sockets from closing. + } + } } private void warnUnsupportedDedicatedVotingProxyMode() { @@ -2427,12 +2633,18 @@ private void warnUnsupportedDedicatedVotingProxyMode() { public abstract void runAsync(Runnable run); + /** Platform name used only for the transport-neutral Control discovery contract. */ + public abstract String getProxyPlatform(); + public abstract void runConsoleCommand(String command); public abstract void saveVoteCacheFile(); public abstract void reloadCore(boolean mysql); + /** Strict Control reload path; failures propagate so the caller can restore its backup. */ + public abstract void reloadControlConfiguration() throws Exception; + public abstract boolean sendPluginMessageData(String server, String channel, byte[] data, boolean queue); private static final int PLUGIN_MESSAGE_HARD_LIMIT = 32767; @@ -2536,7 +2748,8 @@ private boolean sendRedisEnvelopeServer(String server, JsonEnvelope envelope, bo try (Jedis jedis = publisherPool.getResource()) { String channel = getConfig().getRedisPrefix() + "VotingPlugin_" + server; - long subscribers = jedis.publish(channel, JsonEnvelopeCodec.encode(envelope)); + long subscribers = jedis.publish(channel, + JsonEnvelopeCodec.encode(VotingPluginWire.withRedisDeliveryId(envelope))); redisPublisherRetryAfter = 0L; return subscribers > 0; } catch (Exception e) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyConfig.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyConfig.java index c2d2bcad4a..9ecf34e63d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyConfig.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyConfig.java @@ -1,5 +1,6 @@ package com.bencodez.votingplugin.proxy; +import java.io.IOException; import java.util.Collection; import java.util.Collections; import java.util.List; @@ -9,6 +10,119 @@ * Configuration interface for proxy server integration. */ public interface VotingPluginProxyConfig { + /** Atomically persists the small non-secret configuration domain exposed by Control. */ + default void persistControlProxyRouting(boolean sendVotesToAllServers, List blockedServers) + throws IOException { + throw new UnsupportedOperationException("Control configuration writes are unavailable"); + } + + /** Persists only if the same routing snapshot is still active immediately before replacement. */ + default void persistControlProxyRouting(boolean sendVotesToAllServers, List blockedServers, + String expectedRevision) throws IOException { + persistControlProxyRouting(sendVotesToAllServers, blockedServers); + } + + /** Restores the most recent Control-created backup. */ + default void rollbackControlProxyRouting() throws IOException { + throw new UnsupportedOperationException("Control configuration rollback is unavailable"); + } + + /** Verifies that the exact Control-installed routing snapshot is still on disk. */ + default void verifyControlProxyRoutingInstalled() throws IOException { + } + + /** Captures the exact proxy configuration file that the next strict reload should consume. */ + default byte[] captureControlProxyRoutingSnapshot() throws IOException { + throw new UnsupportedOperationException("Control configuration snapshot verification is unavailable"); + } + + /** Verifies that the proxy configuration file still equals a previously captured snapshot. */ + default void verifyControlProxyRoutingSnapshot(byte[] snapshot) throws IOException { + throw new UnsupportedOperationException("Control configuration snapshot verification is unavailable"); + } + + @SuppressWarnings("serial") + final class StaleControlRevisionException extends IOException { + public StaleControlRevisionException() { super("Control proxy routing revision is stale"); } + } + /** Whether the optional local VotingPlugin Control connector is enabled. */ + default boolean getControlEnabled() { + return false; + } + + /** Base HTTP(S) endpoint for VotingPlugin Control. */ + default String getControlEndpoint() { + return "http://127.0.0.1:8080"; + } + + /** Stable enrolled identity; blank reuses ProxyServerName. */ + default String getControlNodeId() { + return ""; + } + + /** Relative file in the plugin data folder containing the one-time enrollment credential. */ + default String getControlCredentialFile() { + return "control-credential.txt"; + } + + default int getControlHeartbeatSeconds() { + return 30; + } + + default int getControlConnectTimeoutMillis() { + return 3000; + } + + default int getControlRequestTimeoutMillis() { + return 5000; + } + + /** Whether this proxy should provision and supervise a separate Control JVM. */ + default boolean getControlHostedEnabled() { + return false; + } + + /** Download the pinned artifact when it is not installed. */ + default boolean getControlHostedAutoDownload() { + return true; + } + + /** Replace an installed artifact when its digest differs from the configured pin. */ + default boolean getControlHostedAutoUpdate() { + return false; + } + + default String getControlHostedDownloadUrl() { + return ""; + } + + default String getControlHostedSha256() { + return ""; + } + + default String getControlHostedJarFile() { + return "control/votingplugin-control.jar"; + } + + default String getControlHostedDataDirectory() { + return "control/data"; + } + + default String getControlHostedHost() { + return "127.0.0.1"; + } + + default int getControlHostedPort() { + return 8080; + } + + default int getControlHostedStartupTimeoutSeconds() { + return 30; + } + + default int getControlHostedDownloadTimeoutSeconds() { + return 60; + } /** * Gets whether proxy broadcast is enabled. diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java index f1837a41c1..ad1449a57e 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java @@ -27,6 +27,7 @@ * reference them directly. */ public final class VotingPluginWire { + public static final String K_REDIS_DELIVERY_ID = "_vpRedisDeliveryId"; private static final int MAX_PRESENCE_SNAPSHOT_JSON_LENGTH = 65536; private VotingPluginWire() { @@ -636,6 +637,12 @@ private static JsonEnvelope.Builder base(String subChannel) { return JsonEnvelope.builder(subChannel).schema(ver); } + /** Adds a per-publication identity so overlapping Redis subscribers can suppress one duplicate delivery. */ + public static JsonEnvelope withRedisDeliveryId(JsonEnvelope envelope) { + if (envelope.getFields().containsKey(K_REDIS_DELIVERY_ID)) return envelope; + return envelope.toBuilder().put(K_REDIS_DELIVERY_ID, UUID.randomUUID().toString()).build(); + } + private static String safe(String s) { return s == null ? "" : s; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeConfig.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeConfig.java index ab98b05bdf..2022295379 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeConfig.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeConfig.java @@ -2,15 +2,24 @@ import java.io.File; import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; +import java.io.InputStream; +import java.nio.channels.Channels; +import java.nio.channels.SeekableByteChannel; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.List; -import java.util.Map; +import java.util.Map; +import java.util.Set; -import com.bencodez.votingplugin.proxy.VotingPluginProxyConfig; +import com.bencodez.votingplugin.proxy.VotingPluginProxyConfig; +import com.bencodez.votingplugin.proxy.control.ProxyRoutingConfiguration; +import com.bencodez.votingplugin.util.DurableFiles; import lombok.Getter; import net.md_5.bungee.config.Configuration; @@ -20,8 +29,9 @@ /** * Configuration implementation for Bungee proxy server. */ -public class BungeeConfig implements VotingPluginProxyConfig { - private VotingPluginBungee bungee; +public class BungeeConfig implements VotingPluginProxyConfig { + private VotingPluginBungee bungee; + private byte[] controlInstalledSnapshot; @Getter private Configuration data; @@ -34,6 +44,96 @@ public BungeeConfig(VotingPluginBungee bungee) { this.bungee = bungee; } + @Override + public boolean getControlEnabled() { + return getData().getBoolean("Control.Enabled", false); + } + + @Override + public String getControlEndpoint() { + return getData().getString("Control.Endpoint", "http://127.0.0.1:8080"); + } + + @Override + public String getControlNodeId() { + return getData().getString("Control.NodeId", ""); + } + + @Override + public String getControlCredentialFile() { + return getData().getString("Control.CredentialFile", "control-credential.txt"); + } + + @Override + public int getControlHeartbeatSeconds() { + return getData().getInt("Control.HeartbeatSeconds", 30); + } + + @Override + public int getControlConnectTimeoutMillis() { + return getData().getInt("Control.ConnectTimeoutMillis", 3000); + } + + @Override + public int getControlRequestTimeoutMillis() { + return getData().getInt("Control.RequestTimeoutMillis", 5000); + } + + @Override + public boolean getControlHostedEnabled() { + return getData().getBoolean("Control.Hosted.Enabled", false); + } + + @Override + public boolean getControlHostedAutoDownload() { + return getData().getBoolean("Control.Hosted.AutoDownload", true); + } + + @Override + public boolean getControlHostedAutoUpdate() { + return getData().getBoolean("Control.Hosted.AutoUpdate", false); + } + + @Override + public String getControlHostedDownloadUrl() { + return getData().getString("Control.Hosted.DownloadUrl", ""); + } + + @Override + public String getControlHostedSha256() { + return getData().getString("Control.Hosted.Sha256", ""); + } + + @Override + public String getControlHostedJarFile() { + return getData().getString("Control.Hosted.JarFile", "control/votingplugin-control.jar"); + } + + @Override + public String getControlHostedDataDirectory() { + return getData().getString("Control.Hosted.DataDirectory", "control/data"); + } + + @Override + public String getControlHostedHost() { + return getData().getString("Control.Hosted.Host", "127.0.0.1"); + } + + @Override + public int getControlHostedPort() { + return getData().getInt("Control.Hosted.Port", 8080); + } + + @Override + public int getControlHostedStartupTimeoutSeconds() { + return getData().getInt("Control.Hosted.StartupTimeoutSeconds", 30); + } + + @Override + public int getControlHostedDownloadTimeoutSeconds() { + return getData().getInt("Control.Hosted.DownloadTimeoutSeconds", 60); + } + /** * Converts a Configuration object to a Map. * @@ -396,36 +496,153 @@ public String getMqttPrefix() { return getData().getString("MQTT.Prefix", ""); } - public void load() { - if (!bungee.getDataFolder().exists()) { - bungee.getDataFolder().mkdir(); + public void load() { + try { + loadControlConfiguration(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + /** Loads the configuration while propagating failures to Control's rollback path. */ + public void loadControlConfiguration() throws IOException { + if (!bungee.getDataFolder().exists()) { + bungee.getDataFolder().mkdir(); } - File file = new File(bungee.getDataFolder(), "bungeeconfig.yml"); - - if (!file.exists()) { - try (InputStream in = bungee.getResourceAsStream("bungeeconfig.yml")) { - Files.copy(in, file.toPath()); - } catch (IOException e) { - e.printStackTrace(); - } - } - try { - data = ConfigurationProvider.getProvider(YamlConfiguration.class) - .load(new File(bungee.getDataFolder(), "bungeeconfig.yml")); - } catch (IOException e) { - e.printStackTrace(); - } - } + File file = new File(bungee.getDataFolder(), "bungeeconfig.yml"); + + if (!file.exists()) { + try (InputStream in = bungee.getResourceAsStream("bungeeconfig.yml")) { + Files.copy(in, file.toPath()); + } + } + data = ConfigurationProvider.getProvider(YamlConfiguration.class) + .load(new File(bungee.getDataFolder(), "bungeeconfig.yml")); + } - public void save() { + public void save() { try { ConfigurationProvider.getProvider(YamlConfiguration.class).save(data, new File(bungee.getDataFolder(), "bungeeconfig.yml")); } catch (IOException e) { e.printStackTrace(); } - } + } + + @Override + public synchronized void persistControlProxyRouting(boolean sendVotesToAllServers, List blockedServers, + String expectedRevision) throws IOException { + Path target = new File(bungee.getDataFolder(), "bungeeconfig.yml").toPath(); + Path stage = Files.createTempFile(target.getParent(), target.getFileName().toString(), ".control-stage"); + Path backupStage = null; + Path backup = target.resolveSibling(target.getFileName() + ".control-backup"); + try { + backupStage = Files.createTempFile(target.getParent(), target.getFileName().toString(), ".control-backup-stage"); + byte[] sourceSnapshot = Files.readAllBytes(target); + Configuration latest = ConfigurationProvider.getProvider(YamlConfiguration.class).load(target.toFile()); + if (!java.util.Arrays.equals(sourceSnapshot, Files.readAllBytes(target)) + || !routing(latest).revision().equals(expectedRevision)) { + throw new StaleControlRevisionException(); + } + latest.set("SendVotesToAllServers", sendVotesToAllServers); + latest.set("BlockedServers", List.copyOf(blockedServers)); + ConfigurationProvider.getProvider(YamlConfiguration.class).save(latest, stage.toFile()); + byte[] installedSnapshot = Files.readAllBytes(stage); + if (!java.util.Arrays.equals(sourceSnapshot, Files.readAllBytes(target))) { + throw new StaleControlRevisionException(); + } + Files.write(backupStage, sourceSnapshot); + if (!java.util.Arrays.equals(sourceSnapshot, Files.readAllBytes(target))) { + throw new StaleControlRevisionException(); + } + atomicReplace(backupStage, backup); + if (!java.util.Arrays.equals(sourceSnapshot, Files.readAllBytes(target))) { + throw new StaleControlRevisionException(); + } + controlInstalledSnapshot = installedSnapshot; + try { + atomicReplace(stage, target); + } catch (IOException failure) { + if (!(failure instanceof DurableFiles.PublishedException)) controlInstalledSnapshot = null; + throw failure; + } + data = latest; + } finally { + Files.deleteIfExists(stage); + if (backupStage != null) Files.deleteIfExists(backupStage); + } + } + + private static ProxyRoutingConfiguration routing(Configuration configuration) { + return new ProxyRoutingConfiguration(configuration.getBoolean("SendVotesToAllServers", false), + configuration.getStringList("BlockedServers")); + } + + @Override + public synchronized void rollbackControlProxyRouting() throws IOException { + Path target = new File(bungee.getDataFolder(), "bungeeconfig.yml").toPath(); + Path backup = target.resolveSibling(target.getFileName() + ".control-backup"); + if (!Files.isRegularFile(backup, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Control backup is unavailable or unsafe"); + } + if (controlInstalledSnapshot == null + || !java.util.Arrays.equals(controlInstalledSnapshot, Files.readAllBytes(target))) { + throw new StaleControlRevisionException(); + } + Path stage = Files.createTempFile(target.getParent(), target.getFileName().toString(), ".control-rollback"); + try { + try (SeekableByteChannel source = Files.newByteChannel(backup, + Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS))) { + Files.copy(Channels.newInputStream(source), stage, StandardCopyOption.REPLACE_EXISTING); + } + if (!java.util.Arrays.equals(controlInstalledSnapshot, Files.readAllBytes(target))) { + throw new StaleControlRevisionException(); + } + try { + atomicReplace(stage, target); + } catch (DurableFiles.PublishedException published) { + controlInstalledSnapshot = null; + throw published; + } + } finally { + Files.deleteIfExists(stage); + } + controlInstalledSnapshot = null; + data = ConfigurationProvider.getProvider(YamlConfiguration.class).load(target.toFile()); + } + + @Override + public synchronized void verifyControlProxyRoutingInstalled() throws IOException { + Path target = new File(bungee.getDataFolder(), "bungeeconfig.yml").toPath(); + if (controlInstalledSnapshot == null + || !java.util.Arrays.equals(controlInstalledSnapshot, Files.readAllBytes(target))) { + throw new StaleControlRevisionException(); + } + } + + @Override + public synchronized byte[] captureControlProxyRoutingSnapshot() throws IOException { + return Files.readAllBytes(new File(bungee.getDataFolder(), "bungeeconfig.yml").toPath()); + } + + @Override + public synchronized void verifyControlProxyRoutingSnapshot(byte[] snapshot) throws IOException { + Path target = new File(bungee.getDataFolder(), "bungeeconfig.yml").toPath(); + if (!java.util.Arrays.equals(snapshot, Files.readAllBytes(target))) { + throw new StaleControlRevisionException(); + } + } + + private static void atomicReplace(Path source, Path target) throws IOException { + try { + DurableFiles.forceFile(source); + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + DurableFiles.forceMoveDirectories(source, target); + } catch (java.nio.file.AtomicMoveNotSupportedException e) { + throw new IOException("Atomic Control configuration activation is unsupported", e); + } + } @Override public String getPluginMessageChannel() { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungee.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungee.java index 40a229c5eb..f567245a95 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungee.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungee.java @@ -218,8 +218,8 @@ public void onEnable() { } } - @Override - public void onDisable() { + @Override + public void onDisable() { synchronized (reloadLock) { reloading = true; @@ -227,7 +227,7 @@ public void onDisable() { try { if (votingPluginProxy != null) { - votingPluginProxy.onDisable(); + votingPluginProxy.onDisable(); } } catch (Exception ignored) { } @@ -393,16 +393,33 @@ public void reloadPlugin(boolean loadMysql) { } catch (Exception ignored) { } - // Tear down old runtime (this WILL shutdown MySQL because proxy owns it) - try { - if (votingPluginProxy != null) { - votingPluginProxy.onDisable(); - } - } catch (Exception ignored) { - } - - // Recreate runtime - votingPluginProxy = createProxyRuntime(); + // Stop Control first; replacement must not overlap a retained hosted child/connector. + try { + if (votingPluginProxy != null) { + votingPluginProxy.prepareForRuntimeReplacement(); + } + } catch (Exception shutdownFailure) { + getLogger().severe("Reload aborted because hosted Control did not stop safely"); + shutdownFailure.printStackTrace(); + try { + schedulePlatformTasks(); + } catch (Exception taskFailure) { + shutdownFailure.addSuppressed(taskFailure); + } + reloading = false; + return; + } + // Later transport/cache failures must not leave the old runtime partially disabled. + try { + if (votingPluginProxy != null) votingPluginProxy.completeRuntimeReplacementShutdown(); + } catch (Exception cleanupFailure) { + getLogger().severe("Old proxy runtime cleanup was incomplete; replacement will continue"); + cleanupFailure.printStackTrace(); + } + + // Recreate the runtime only after the old connector has drained its result. + VotingPluginProxy replacementRuntime = createProxyRuntime(); + votingPluginProxy = replacementRuntime; // Initialize MySQL BEFORE calling proxy.load(...) try { @@ -489,9 +506,14 @@ public void debug(String str) { } @Override - public Set getAllAvailableServers() { - return getAvailableAllServers(); - } + public Set getAllAvailableServers() { + return getAvailableAllServers(); + } + + @Override + public Set getAllConfiguredServers() { + return new HashSet<>(getProxy().getServers().keySet()); + } @Override public VotingPluginProxyConfig getConfig() { @@ -499,6 +521,11 @@ public VotingPluginProxyConfig getConfig() { } @Override + public String getProxyPlatform() { + return "BUNGEECORD"; + } + + @Override public String getCurrentPlayerServer(String player) { ProxiedPlayer p = getProxy().getPlayer(player); if (p != null && p.getServer() != null) { @@ -697,10 +724,16 @@ public void warn(String message) { } @Override - public void reloadCore(boolean mysql) { + public void reloadCore(boolean mysql) { // mysql==true should do full reloadall behavior on the platform - reloadPlugin(mysql); - } + reloadPlugin(mysql); + } + + @Override + public void reloadControlConfiguration() throws Exception { + config.loadControlConfiguration(); + reloadFromControl(); + } @Override public ScheduledExecutorService getScheduler() { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java new file mode 100644 index 0000000000..8ee78d1778 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java @@ -0,0 +1,893 @@ +package com.bencodez.votingplugin.proxy.control; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.function.LongSupplier; +import java.util.function.Supplier; +import java.util.regex.Pattern; + +import com.bencodez.votingplugin.proxy.VotingPluginProxy; +import com.bencodez.votingplugin.proxy.VotingPluginProxyConfig; +import com.bencodez.votingplugin.proxy.presence.BackendPresenceStatus; +import com.bencodez.votingplugin.proxy.control.ProxyControlResultStore.Route; +import com.bencodez.votingplugin.proxy.control.ProxyControlResultStore.StoredResult; +import com.bencodez.votingplugin.util.BoundedHttpBodyHandler; +import com.bencodez.votingplugin.util.ControlCredentialFile; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +/** + * Optional, failure-isolated Control discovery connector shared by BungeeCord and Velocity. + * It owns no vote-processing callbacks and never performs blocking I/O on a proxy event thread. + */ +public final class ControlConnector implements AutoCloseable { + static final int PROTOCOL_VERSION = 1; + static final int MAX_RESPONSE_BYTES = 64 * 1024; + private static final Pattern NODE_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); + private static final Set BASE_CAPABILITIES = Set.of("presence.snapshot"); + private static final String CONFIGURATION_CAPABILITY = "config.proxy-routing.v1"; + private static final long MAX_BACKOFF_MILLIS = TimeUnit.MINUTES.toMillis(5); + private static final long OPERATION_SHUTDOWN_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(65); + + private final Settings settings; + private final ScheduledExecutorService scheduler; + private final Transport transport; + private final Supplier> snapshotSource; + private final Consumer logger; + private final UUID sessionId; + private final LongSupplier jitterSource; + private final ProxyRoutingConfigurationService configurationService; + private final Path dataDirectory; + private final Route route; + private final boolean recovering; + private final Runnable recoveryComplete; + private final Map completedTasks = new LinkedHashMap<>(); + private final Object operationLifecycle = new Object(); + private final AtomicBoolean inFlight = new AtomicBoolean(); + private Runnable deferredReplacement; + private volatile boolean closed; + private volatile boolean registered; + private volatile boolean configurationAccepted; + private volatile int failures; + private volatile long snapshotSequence; + private volatile ScheduledFuture scheduled; + private volatile CompletableFuture activeRequest; + private volatile CompletableFuture activeOperation; + private volatile Status status = Status.STARTING; + + public ControlConnector(Settings settings, ScheduledExecutorService scheduler, Transport transport, + Supplier> snapshotSource, Consumer logger, UUID sessionId, + LongSupplier jitterSource) { + this(settings, scheduler, transport, snapshotSource, logger, sessionId, jitterSource, null, + null, null, false, null); + } + + ControlConnector(Settings settings, ScheduledExecutorService scheduler, Transport transport, + Supplier> snapshotSource, Consumer logger, UUID sessionId, + LongSupplier jitterSource, ProxyRoutingConfigurationService configurationService) { + this(settings, scheduler, transport, snapshotSource, logger, sessionId, jitterSource, configurationService, + null, null, false, null); + } + + ControlConnector(Settings settings, ScheduledExecutorService scheduler, Transport transport, + Supplier> snapshotSource, Consumer logger, UUID sessionId, + LongSupplier jitterSource, ProxyRoutingConfigurationService configurationService, + Map recoveredTasks) { + this(settings, scheduler, transport, snapshotSource, logger, sessionId, jitterSource, configurationService, + null, null, false, null); + completedTasks.putAll(recoveredTasks); + } + + private ControlConnector(Settings settings, ScheduledExecutorService scheduler, Transport transport, + Supplier> snapshotSource, Consumer logger, UUID sessionId, + LongSupplier jitterSource, ProxyRoutingConfigurationService configurationService, Path dataDirectory, + Route route, boolean recovering, Runnable recoveryComplete) { + this.settings = Objects.requireNonNull(settings, "settings"); + this.scheduler = Objects.requireNonNull(scheduler, "scheduler"); + this.transport = Objects.requireNonNull(transport, "transport"); + this.snapshotSource = Objects.requireNonNull(snapshotSource, "snapshotSource"); + this.logger = Objects.requireNonNull(logger, "logger"); + this.sessionId = Objects.requireNonNull(sessionId, "sessionId"); + this.jitterSource = Objects.requireNonNull(jitterSource, "jitterSource"); + this.configurationService = configurationService; + this.dataDirectory = dataDirectory; + this.route = route; + this.recovering = recovering; + this.recoveryComplete = recoveryComplete; + } + + /** Creates the production connector without reading a credential when the feature is disabled. */ + public static ControlConnector create(VotingPluginProxy proxy) throws IOException { + Path dataDirectory = proxy.getDataFolderPlugin().toPath().toAbsolutePath().normalize(); + ProxyControlResultStore.State recovered = ProxyControlResultStore.load(dataDirectory); + VotingPluginProxyConfig config = proxy.getConfig(); + Settings settings; + Route route; + String credentialName; + boolean recovering = recovered != null; + if (recovered != null) { + route = recovered.route(); + settings = settings(route); + credentialName = route.credentialFile(); + } else { + if (!config.getControlEnabled()) return null; + String configuredNodeId = config.getControlNodeId(); + String nodeId = configuredNodeId == null || configuredNodeId.isBlank() + ? config.getProxyServerName() : configuredNodeId.trim(); + credentialName = config.getControlCredentialFile(); + if (credentialName == null || credentialName.isBlank()) { + throw new IllegalArgumentException("Control.CredentialFile must be set"); + } + String endpointValue = config.getControlEndpoint(); + if (endpointValue == null || endpointValue.isBlank()) { + throw new IllegalArgumentException("Control.Endpoint must be set"); + } + settings = new Settings(nodeId, nodeId, proxy.getProxyPlatform(), proxy.getPluginVersion(), + URI.create(endpointValue.trim()), config.getControlHeartbeatSeconds(), + config.getControlConnectTimeoutMillis(), config.getControlRequestTimeoutMillis()); + route = route(settings, credentialName); + } + String credential = ControlCredentialFile.read(dataDirectory, credentialName); + HttpControlTransport transport = new HttpControlTransport(settings.endpoint(), credential, + settings.connectTimeoutMillis(), settings.requestTimeoutMillis()); + Supplier> snapshot = () -> { + List backends = new ArrayList<>(); + for (String server : proxy.getAllAvailableServers()) { + if (server == null || server.isBlank()) { + continue; + } + BackendPresenceStatus presence = proxy.getBackendPlayerPresenceTracker().getBackendStatus(server); + String displayName = server.trim(); + backends.add(new ObservedBackend(stableBackendId(displayName), + displayName.substring(0, Math.min(displayName.length(), 100)), presence != null, + presence != null && presence.isAvailable(), presence == null ? 0 : presence.getPlayerCount())); + } + backends.sort(Comparator.comparing(ObservedBackend::backendId)); + return List.copyOf(backends); + }; + ControlConnector connector = new ControlConnector(settings, proxy.getScheduler(), transport, snapshot, + message -> proxy.log("[Control] " + message), UUID.randomUUID(), + () -> ThreadLocalRandom.current().nextLong(), new ProxyRoutingConfigurationService(proxy), dataDirectory, + route, recovering, proxy::restartControlServicesAfterRecovery); + if (recovered != null) connector.completedTasks.putAll(recovered.results()); + return connector; + } + + public void start() { + if (closed) { + return; + } + status = Status.STARTING; + schedule(0); + } + + public Status status() { + return status; + } + + /** Defers a replacement while a cycle or unacknowledged result still belongs to this connector. */ + public boolean deferReplacementUntilSafe(Runnable replacement) { + Objects.requireNonNull(replacement, "replacement"); + synchronized (operationLifecycle) { + if (inFlight.get() || !completedTasks.isEmpty()) { + deferredReplacement = replacement; + return true; + } + closed = true; + status = Status.STOPPED; + return false; + } + } + + /** Reserves a quiescent connector for a full runtime replacement without losing a claimed result. */ + public boolean reserveRuntimeReplacement() { + synchronized (operationLifecycle) { + if (inFlight.get() || !completedTasks.isEmpty()) return false; + closed = true; + status = Status.STOPPED; + return true; + } + } + + private void schedule(long delayMillis) { + if (closed) { + return; + } + try { + scheduled = scheduler.schedule(this::cycle, delayMillis, TimeUnit.MILLISECONDS); + } catch (java.util.concurrent.RejectedExecutionException ignored) { + if (!closed) { + status = Status.UNAVAILABLE; + } + } + } + + void cycle() { + synchronized (operationLifecycle) { + if (closed || !inFlight.compareAndSet(false, true)) return; + } + Request first = registered ? heartbeatRequest() : registrationRequest(); + CompletableFuture primary; + try { + primary = transport.send(first); + } catch (RuntimeException failure) { + finishCycle(); + onFailure(failure); + return; + } + CompletableFuture operationDone = new CompletableFuture<>(); + activeOperation = operationDone; + if (closed) { + primary.cancel(true); + operationDone.complete(null); + if (activeOperation == operationDone) activeOperation = null; + finishCycle(); + return; + } + activeRequest = primary; + CompletableFuture operation = primary.thenCompose(response -> { + handlePrimaryResponse(response, !registered); + registered = true; + CompletableFuture presence = transport.send(presenceRequest()); + activeRequest = presence; + return presence.thenCompose(responseBody -> { + handlePresenceResponse(responseBody); + if (!configurationAccepted) return CompletableFuture.completedFuture(null); + if (hasCompletedTask()) return submitCompletedResult(); + CompletableFuture claim = transport.send(claimRequest()); + activeRequest = claim; + return claim.thenCompose(this::handleClaimResponse); + }); + }); + operation.whenComplete((ignored, failure) -> { + try { + activeRequest = null; + finishCycle(); + if (!closed) { + if (failure == null) { + onSuccess(); + } else { + onFailure(unwrap(failure)); + } + } + } finally { + if (failure == null) { + operationDone.complete(null); + } else { + operationDone.completeExceptionally(failure); + } + if (activeOperation == operationDone) activeOperation = null; + } + }); + } + + private void finishCycle() { + Runnable replacement = null; + synchronized (operationLifecycle) { + inFlight.set(false); + if (completedTasks.isEmpty() && deferredReplacement != null) { + replacement = deferredReplacement; + deferredReplacement = null; + } + } + if (replacement != null) replacement.run(); + } + + private void handlePrimaryResponse(Response response, boolean registration) { + if (response.statusCode == 404 && !registration) { + registered = false; + throw new RegistryLostException(); + } + requireSuccess(response); + JsonObject body = parseObject(response.body); + JsonObject node = body.getAsJsonObject("node"); + JsonArray accepted = node == null ? null : node.getAsJsonArray("acceptedCapabilities"); + if (registration) { + JsonElement protocol = body.getAsJsonObject("identity").get("protocolVersion"); + if (protocol == null || protocol.getAsInt() != PROTOCOL_VERSION) { + throw new ProtocolException(); + } + if (accepted == null || !contains(accepted, "presence.snapshot")) { + throw new ProtocolException(); + } + } + if (accepted != null) { + if (!contains(accepted, "presence.snapshot")) { + throw new ProtocolException(); + } + configurationAccepted = contains(accepted, CONFIGURATION_CAPABILITY); + } + } + + private void handlePresenceResponse(Response response) { + if (response.statusCode == 404) { + registered = false; + throw new RegistryLostException(); + } + requireSuccess(response); + JsonObject body = parseObject(response.body); + if (!body.has("applied") || !body.has("node")) { + throw new MalformedResponseException(); + } + } + + private static void requireSuccess(Response response) { + if (response.statusCode == 401 || response.statusCode == 403) { + throw new AuthenticationException(); + } + if (response.statusCode == 409) { + throw new ProtocolException(); + } + if (response.statusCode < 200 || response.statusCode >= 300) { + throw new UnavailableException(); + } + } + + private void onSuccess() { + boolean wasConnected = status == Status.CONNECTED; + failures = 0; + status = Status.CONNECTED; + if (!wasConnected) { + logger.accept("authenticated discovery connected"); + } + schedule(TimeUnit.SECONDS.toMillis(settings.heartbeatSeconds())); + } + + private void onFailure(Throwable failure) { + failures = Math.min(failures + 1, 30); + Status next; + String message; + if (failure instanceof AuthenticationException) { + next = Status.AUTHENTICATION_FAILED; + message = "authentication failed; re-enroll or rotate the configured credential"; + } else if (failure instanceof ProtocolException) { + next = Status.INCOMPATIBLE; + message = "protocol or required capabilities are incompatible"; + } else { + next = Status.UNAVAILABLE; + message = "unavailable; voting remains unaffected"; + } + if (status != next || failures == 1 || failures % 10 == 0) { + logger.accept(message); + } + status = next; + schedule(backoffMillis(failures, jitterSource.getAsLong())); + } + + private Request registrationRequest() { + JsonObject body = commonBody(); + body.addProperty("displayName", settings.displayName()); + body.addProperty("platform", settings.platform()); + body.addProperty("pluginVersion", settings.pluginVersion()); + addCapabilities(body); + return new Request("POST", "/api/v1/nodes/register", body.toString()); + } + + private Request heartbeatRequest() { + JsonObject body = sessionBody(); + addCapabilities(body); + return new Request("PUT", "/api/v1/nodes/" + settings.nodeId() + "/heartbeat", body.toString()); + } + + private Request presenceRequest() { + JsonObject body = sessionBody(); + body.addProperty("sequence", snapshotSequence++); + JsonArray backends = new JsonArray(); + List observed = snapshotSource.get(); + if (observed.size() > 4096) { + throw new IllegalStateException("Control backend snapshot exceeds 4096 entries"); + } + for (ObservedBackend backend : observed) { + JsonObject item = new JsonObject(); + item.addProperty("backendId", backend.backendId()); + item.addProperty("displayName", backend.displayName()); + item.addProperty("presenceKnown", backend.presenceKnown()); + item.addProperty("available", backend.available()); + item.addProperty("playerCount", backend.playerCount()); + backends.add(item); + } + body.add("backends", backends); + return new Request("PUT", "/api/v1/nodes/" + settings.nodeId() + "/presence", body.toString()); + } + + private Request claimRequest() { + JsonObject body = new JsonObject(); + body.addProperty("sessionId", sessionId.toString()); + return new Request("POST", "/api/v1/nodes/" + settings.nodeId() + "/operations", body.toString()); + } + + private CompletableFuture handleClaimResponse(Response response) { + if (response.statusCode == 204) return CompletableFuture.completedFuture(null); + if (response.statusCode == 404) { + registered = false; + throw new RegistryLostException(); + } + requireSuccess(response); + JsonObject task = parseObject(response.body); + UUID operationId = UUID.fromString(requireString(task, "operationId")); + StoredResult result; + synchronized (operationLifecycle) { + result = completedTasks.get(operationId); + } + if (result != null) { + if (!result.committed() && !result.claimRequired() && !anticipatedResultIsInstalled(result)) { + result = null; + } else { + result = committedForAttempt(result, requireString(task, "attemptId")); + synchronized (operationLifecycle) { completedTasks.put(operationId, result); } + persistCompleted(); + } + } + if (result == null) { + TaskResult executed = executeTask(operationId, task); + JsonObject resultJson = executed.json(); + resultJson.addProperty("attemptId", requireString(task, "attemptId")); + result = new StoredResult(resultJson, true, false); + synchronized (operationLifecycle) { + completedTasks.put(operationId, result); + } + persistCompleted(); + } + return submitCompletedResult(operationId, result); + } + + private boolean hasCompletedTask() { + prepareWriteAheadIntents(); + synchronized (operationLifecycle) { + return completedTasks.values().stream().anyMatch(StoredResult::committed); + } + } + + private CompletableFuture submitCompletedResult() { + Map.Entry pending; + synchronized (operationLifecycle) { + pending = completedTasks.entrySet().stream().filter(entry -> entry.getValue().committed()) + .findFirst().orElse(null); + } + if (pending == null) return CompletableFuture.completedFuture(null); + persistCompleted(); + return submitCompletedResult(pending.getKey(), pending.getValue()); + } + + private CompletableFuture submitCompletedResult(UUID operationId, StoredResult result) { + CompletableFuture submitted = transport.send(resultRequest(operationId, result)); + activeRequest = submitted; + return submitted.thenAccept(resultResponse -> { + if (taskLeaseExpired(resultResponse)) { + synchronized (operationLifecycle) { + completedTasks.put(operationId, new StoredResult(result.result(), false, true)); + } + persistCompleted(); + return; + } + if (operationNotFound(resultResponse)) { + acknowledgeCompletedResult(operationId, result); + return; + } + requireSuccess(resultResponse); + acknowledgeCompletedResult(operationId, result); + }); + } + + private void acknowledgeCompletedResult(UUID operationId, StoredResult result) { + synchronized (operationLifecycle) { completedTasks.remove(operationId); } + try { + persistCompleted(); + } catch (RuntimeException failure) { + synchronized (operationLifecycle) { completedTasks.put(operationId, result); } + throw failure; + } + boolean drained; + synchronized (operationLifecycle) { drained = completedTasks.isEmpty(); } + if (recovering && drained && recoveryComplete != null) recoveryComplete.run(); + } + + private static boolean taskLeaseExpired(Response response) { + return responseCode(response, 409, "TASK_LEASE_EXPIRED"); + } + + private static boolean operationNotFound(Response response) { + return responseCode(response, 404, "OPERATION_NOT_FOUND"); + } + + private static boolean responseCode(Response response, int status, String code) { + if (response.statusCode != status) return false; + try { + JsonObject error = parseObject(response.body).getAsJsonObject("error"); + return error != null && error.has("code") && code.equals(error.get("code").getAsString()); + } catch (RuntimeException ignored) { + return false; + } + } + + private void persistCompleted() { + if (dataDirectory == null || route == null) return; + Map snapshot; + synchronized (operationLifecycle) { snapshot = new LinkedHashMap<>(completedTasks); } + try { + ProxyControlResultStore.save(dataDirectory, route, snapshot); + } catch (IOException e) { + throw new UnavailableException(e); + } + } + + private void persistIntent(UUID operationId, TaskResult anticipated, String attemptId) { + JsonObject result = anticipated.json(); + result.addProperty("attemptId", attemptId); + synchronized (operationLifecycle) { + completedTasks.put(operationId, new StoredResult(result, false, false)); + } + persistCompleted(); + } + + private void prepareWriteAheadIntents() { + Map snapshot; + synchronized (operationLifecycle) { snapshot = new LinkedHashMap<>(completedTasks); } + boolean changed = false; + for (Map.Entry entry : snapshot.entrySet()) { + StoredResult pending = entry.getValue(); + if (pending.committed() || pending.claimRequired()) continue; + StoredResult recovered = anticipatedResultIsInstalled(pending) + ? committedForAttempt(pending, requireString(pending.result(), "attemptId")) + : abortedIntent(pending); + synchronized (operationLifecycle) { + if (completedTasks.get(entry.getKey()) == pending) { + completedTasks.put(entry.getKey(), recovered); + changed = true; + } + } + } + if (changed) persistCompleted(); + } + + private static StoredResult abortedIntent(StoredResult pending) { + JsonObject result = TaskResult.failure("RECOVERY_ABORTED", + "Configuration apply did not finish before node recovery").json(); + result.addProperty("attemptId", requireString(pending.result(), "attemptId")); + return new StoredResult(result, true, false); + } + + private boolean anticipatedResultIsInstalled(StoredResult pending) { + JsonObject result = pending.result(); + return result.has("revision") && result.get("revision").getAsString().equals(configurationService.read().revision()); + } + + private static StoredResult committedForAttempt(StoredResult pending, String attemptId) { + JsonObject result = pending.result().deepCopy(); + result.addProperty("attemptId", attemptId); + return new StoredResult(result, true, false); + } + + private TaskResult executeTask(UUID operationId, JsonObject task) { + if (configurationService == null) return TaskResult.failure("UNSUPPORTED", "Configuration control is unavailable"); + String type = requireString(task, "type"); + try { + ProxyRoutingConfiguration current = configurationService.read(); + if ("READ".equals(type)) return TaskResult.success(current.revision(), current, List.of(), false); + ProxyRoutingConfiguration proposal = parseConfiguration(task.getAsJsonObject("configuration")); + configurationService.validate(proposal); + List changes = proposal.changesFrom(current); + if ("PREVIEW".equals(type)) return TaskResult.success(current.revision(), current, changes, false); + if (!"APPLY".equals(type)) return TaskResult.failure("UNSUPPORTED_TASK", "Task type is unsupported"); + persistIntent(operationId, TaskResult.success(proposal.revision(), proposal, changes, true), + requireString(task, "attemptId")); + configurationService.apply(proposal, requireString(task, "expectedRevision")); + ProxyRoutingConfiguration applied = configurationService.read(); + return TaskResult.success(applied.revision(), applied, changes, true); + } catch (ProxyRoutingConfigurationService.StaleRevisionException e) { + return TaskResult.failure("STALE_REVISION", "Configuration changed after preview"); + } catch (ProxyRoutingConfigurationService.ApplyFailureException e) { + return new TaskResult(false, "RELOAD_FAILED", "Reload failed after persistence", null, null, List.of(), + false, e.rolledBack()); + } catch (IllegalArgumentException e) { + return TaskResult.failure("VALIDATION_ERROR", e.getMessage()); + } catch (IOException | RuntimeException e) { + return TaskResult.failure("APPLY_FAILED", "Configuration operation failed"); + } + } + + private static ProxyRoutingConfiguration parseConfiguration(JsonObject body) { + if (body == null || !body.has("sendVotesToAllServers") || !body.has("blockedServers")) { + throw new IllegalArgumentException("configuration is incomplete"); + } + List blocked = new ArrayList<>(); + body.getAsJsonArray("blockedServers").forEach(value -> blocked.add(value.getAsString())); + return new ProxyRoutingConfiguration(body.get("sendVotesToAllServers").getAsBoolean(), blocked); + } + + private Request resultRequest(UUID operationId, StoredResult result) { + JsonObject body = result.result().deepCopy(); + body.addProperty("sessionId", sessionId.toString()); + return new Request("POST", "/api/v1/nodes/" + settings.nodeId() + "/operations/" + operationId + + "/result", body.toString()); + } + + private static Route route(Settings settings, String credentialFile) { + return new Route(settings.nodeId(), settings.displayName(), settings.platform(), settings.pluginVersion(), + settings.endpoint(), credentialFile, settings.heartbeatSeconds(), settings.connectTimeoutMillis(), + settings.requestTimeoutMillis()); + } + + private static Settings settings(Route route) { + if (route.credentialFile() == null || route.credentialFile().isBlank() + || route.credentialFile().length() > 2048) { + throw new IllegalArgumentException("Control.CredentialFile is invalid"); + } + return new Settings(route.nodeId(), route.displayName(), route.platform(), route.pluginVersion(), route.endpoint(), + route.heartbeatSeconds(), route.connectTimeoutMillis(), route.requestTimeoutMillis()); + } + + private static JsonObject configurationJson(ProxyRoutingConfiguration configuration) { + JsonObject body = new JsonObject(); + body.addProperty("sendVotesToAllServers", configuration.sendVotesToAllServers()); + JsonArray blocked = new JsonArray(); + configuration.blockedServers().forEach(blocked::add); + body.add("blockedServers", blocked); + return body; + } + + private static String requireString(JsonObject body, String name) { + if (body == null || !body.has(name) || !body.get(name).isJsonPrimitive()) throw new MalformedResponseException(); + return body.get(name).getAsString(); + } + + private JsonObject commonBody() { + JsonObject body = sessionBody(); + body.addProperty("nodeId", settings.nodeId()); + return body; + } + + private JsonObject sessionBody() { + JsonObject body = new JsonObject(); + body.addProperty("sessionId", sessionId.toString()); + body.addProperty("protocolVersion", PROTOCOL_VERSION); + return body; + } + + private void addCapabilities(JsonObject body) { + JsonArray advertised = new JsonArray(); + BASE_CAPABILITIES.stream().sorted().forEach(advertised::add); + if (configurationService != null) advertised.add(CONFIGURATION_CAPABILITY); + body.add("capabilities", advertised); + JsonArray required = new JsonArray(); + required.add("presence.snapshot"); + body.add("requiredCapabilities", required); + } + + static long backoffMillis(int failures, long jitterValue) { + int exponent = Math.max(0, Math.min(failures - 1, 8)); + long base = Math.min(1000L << exponent, MAX_BACKOFF_MILLIS); + long jitterBound = Math.max(1, base / 4); + return Math.min(MAX_BACKOFF_MILLIS, base + Math.floorMod(jitterValue, jitterBound)); + } + + static String stableBackendId(String serverName) { + String value = serverName == null ? "" : serverName.trim(); + if (NODE_ID.matcher(value).matches()) { + return value; + } + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + return "backend-" + HexFormat.of().formatHex(digest, 0, 16); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is unavailable"); + } + } + + @Override + public void close() { + synchronized (operationLifecycle) { + closed = true; + status = Status.STOPPED; + deferredReplacement = null; + } + ScheduledFuture scheduledRequest = scheduled; + if (scheduledRequest != null) { + scheduledRequest.cancel(false); + } + CompletableFuture request = activeRequest; + if (request != null) { + request.cancel(true); + } + CompletableFuture operation = activeOperation; + if (operation != null) { + try { + operation.get(OPERATION_SHUTDOWN_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS); + } catch (java.util.concurrent.CancellationException | java.util.concurrent.ExecutionException ignored) { + // Cancellation/failure completes the chain and makes transport shutdown safe. + } catch (java.util.concurrent.TimeoutException e) { + throw new IllegalStateException("Control operation did not stop before connector shutdown", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting for the Control operation", e); + } + } + transport.close(); + } + + private static JsonObject parseObject(String body) { + try { + JsonElement element = JsonParser.parseString(body); + if (!element.isJsonObject()) { + throw new MalformedResponseException(); + } + return element.getAsJsonObject(); + } catch (RuntimeException e) { + if (e instanceof ControlFailure) { + throw e; + } + throw new MalformedResponseException(); + } + } + + private static boolean contains(JsonArray array, String value) { + for (JsonElement element : array) { + if (element.isJsonPrimitive() && value.equals(element.getAsString())) { + return true; + } + } + return false; + } + + private static Throwable unwrap(Throwable failure) { + Throwable result = failure; + while (result instanceof CompletionException && result.getCause() != null) { + result = result.getCause(); + } + return result; + } + + public enum Status { + STARTING, CONNECTED, AUTHENTICATION_FAILED, INCOMPATIBLE, UNAVAILABLE, STOPPED + } + + public record Settings(String nodeId, String displayName, String platform, String pluginVersion, URI endpoint, + int heartbeatSeconds, int connectTimeoutMillis, int requestTimeoutMillis) { + public Settings { + if (nodeId == null || !NODE_ID.matcher(nodeId).matches()) { + throw new IllegalArgumentException("Control node ID is invalid"); + } + if (displayName == null || displayName.isBlank() || displayName.length() > 100) { + throw new IllegalArgumentException("Control display name is invalid"); + } + if (!Set.of("BUNGEECORD", "VELOCITY").contains(platform)) { + throw new IllegalArgumentException("Control platform is invalid"); + } + if (pluginVersion == null || pluginVersion.isBlank() || pluginVersion.length() > 40) { + throw new IllegalArgumentException("Control plugin version is invalid"); + } + Objects.requireNonNull(endpoint, "endpoint"); + if (!Set.of("http", "https").contains(endpoint.getScheme()) || endpoint.getHost() == null + || endpoint.getUserInfo() != null || endpoint.getQuery() != null || endpoint.getFragment() != null + || (endpoint.getPath() != null && !endpoint.getPath().isEmpty() && !"/".equals(endpoint.getPath()))) { + throw new IllegalArgumentException("Control endpoint must be an HTTP(S) origin without credentials or a path"); + } + if (heartbeatSeconds < 10 || heartbeatSeconds > 300) { + throw new IllegalArgumentException("Control heartbeat must be between 10 and 300 seconds"); + } + if (connectTimeoutMillis < 500 || connectTimeoutMillis > 30000 + || requestTimeoutMillis < 500 || requestTimeoutMillis > 30000) { + throw new IllegalArgumentException("Control timeouts must be between 500 and 30000 milliseconds"); + } + } + } + + public record ObservedBackend(String backendId, String displayName, boolean presenceKnown, + boolean available, int playerCount) { + public ObservedBackend { + if (backendId == null || !NODE_ID.matcher(backendId).matches()) { + throw new IllegalArgumentException("Control backend ID is invalid"); + } + if (displayName == null || displayName.isBlank() || displayName.length() > 100) { + throw new IllegalArgumentException("Control backend display name is invalid"); + } + if (playerCount < 0 || playerCount > 100000) { + throw new IllegalArgumentException("Control backend player count is invalid"); + } + } + } + + public record Request(String method, String path, String body) { } + public record Response(int statusCode, String body) { } + + private record TaskResult(boolean success, String code, String message, String revision, + ProxyRoutingConfiguration configuration, List changes, boolean reloaded, boolean rolledBack) { + private JsonObject json() { + JsonObject body = new JsonObject(); + body.addProperty("success", success); + body.addProperty("code", code); + body.addProperty("message", message); + if (revision != null) body.addProperty("revision", revision); + if (configuration != null) body.add("configuration", configurationJson(configuration)); + JsonArray listed = new JsonArray(); + changes.forEach(listed::add); + body.add("changes", listed); + body.addProperty("reloaded", reloaded); + body.addProperty("rolledBack", rolledBack); + return body; + } + + private static TaskResult success(String revision, ProxyRoutingConfiguration configuration, + List changes, boolean reloaded) { + return new TaskResult(true, "OK", "Operation completed", revision, configuration, changes, reloaded, false); + } + private static TaskResult failure(String code, String message) { + return new TaskResult(false, code, message == null ? "Operation failed" : message, null, null, List.of(), false, false); + } + } + + public interface Transport extends AutoCloseable { + CompletableFuture send(Request request); + @Override default void close() { } + } + + static final class HttpControlTransport implements Transport { + private final URI endpoint; + private final String credential; + private final Duration requestTimeout; + private final HttpClient client; + + HttpControlTransport(URI endpoint, String credential, int connectTimeoutMillis, int requestTimeoutMillis) { + this.endpoint = endpoint; + this.credential = credential; + this.requestTimeout = Duration.ofMillis(requestTimeoutMillis); + this.client = HttpClient.newBuilder().connectTimeout(Duration.ofMillis(connectTimeoutMillis)) + .followRedirects(HttpClient.Redirect.NEVER).build(); + } + + @Override + public CompletableFuture send(Request request) { + HttpRequest httpRequest = HttpRequest.newBuilder(endpoint.resolve(request.path)) + .timeout(requestTimeout).header("Content-Type", "application/json") + .header("Authorization", "Bearer " + credential) + .method(request.method, HttpRequest.BodyPublishers.ofString(request.body, StandardCharsets.UTF_8)) + .build(); + return client.sendAsync(httpRequest, new BoundedHttpBodyHandler(MAX_RESPONSE_BYTES, requestTimeout)) + .thenApply(response -> new Response(response.statusCode(), + new String(response.body(), StandardCharsets.UTF_8))); + } + } + + @SuppressWarnings("serial") + private abstract static class ControlFailure extends RuntimeException { } + @SuppressWarnings("serial") + private static final class AuthenticationException extends ControlFailure { } + @SuppressWarnings("serial") + private static final class ProtocolException extends ControlFailure { } + @SuppressWarnings("serial") + private static final class RegistryLostException extends ControlFailure { } + @SuppressWarnings("serial") + private static final class UnavailableException extends ControlFailure { + private UnavailableException() { } + private UnavailableException(Throwable cause) { super.initCause(cause); } + } + @SuppressWarnings("serial") + private static final class MalformedResponseException extends ControlFailure { } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/HostedControlManager.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/HostedControlManager.java new file mode 100644 index 0000000000..528a744c3c --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/HostedControlManager.java @@ -0,0 +1,942 @@ +package com.bencodez.votingplugin.proxy.control; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.util.HexFormat; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.function.LongSupplier; + +import com.bencodez.votingplugin.proxy.VotingPluginProxy; +import com.bencodez.votingplugin.proxy.VotingPluginProxyConfig; +import com.bencodez.votingplugin.util.BoundedHttpBodyHandler; +import com.bencodez.votingplugin.util.DurableFiles; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +/** + * Opt-in provisioner for a separate VotingPlugin Control JVM. Downloads and + * process supervision never run in the proxy event loop or vote-processing path. + */ +public final class HostedControlManager implements AutoCloseable { + static final long MAX_DOWNLOAD_BYTES = 64L * 1024L * 1024L; + private static final int HEALTH_ATTEMPTS_PER_SECOND = 4; + private static final int EXPECTED_PROTOCOL_VERSION = 1; + + private final Settings settings; + private final ScheduledExecutorService executor; + private final ArtifactDownloader downloader; + private final ProcessLauncher launcher; + private final HealthProbe healthProbe; + private final Sleeper sleeper; + private final LongSupplier nanoTime; + private final Consumer logger; + private final AtomicBoolean workInProgress = new AtomicBoolean(); + private final Object processLifecycle = new Object(); + private volatile boolean closed; + private volatile Status status = Status.STARTING; + private volatile ManagedProcess managedProcess; + private volatile ManagedProcess exitAwaitedProcess; + private volatile Future activeTask; + private volatile int failures; + private volatile String quarantinedSha256; + private volatile String trustedRollbackSha256; + private volatile String rollbackCandidateSha256; + private volatile boolean rollbackCandidateReachedHealth; + private volatile boolean rollbackPending; + + private HostedControlManager(Settings settings, Consumer logger) { + this(settings, daemonExecutor(), new JdkArtifactDownloader(), new JdkProcessLauncher(), + new JdkHealthProbe(), Thread::sleep, System::nanoTime, logger); + } + + HostedControlManager(Settings settings, ScheduledExecutorService executor, ArtifactDownloader downloader, + ProcessLauncher launcher, HealthProbe healthProbe, Sleeper sleeper, LongSupplier nanoTime, + Consumer logger) { + this.settings = Objects.requireNonNull(settings, "settings"); + this.executor = Objects.requireNonNull(executor, "executor"); + this.downloader = Objects.requireNonNull(downloader, "downloader"); + this.launcher = Objects.requireNonNull(launcher, "launcher"); + this.healthProbe = Objects.requireNonNull(healthProbe, "healthProbe"); + this.sleeper = Objects.requireNonNull(sleeper, "sleeper"); + this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime"); + this.logger = Objects.requireNonNull(logger, "logger"); + } + + public static HostedControlManager create(VotingPluginProxy proxy) { + VotingPluginProxyConfig config = proxy.getConfig(); + if (!config.getControlHostedEnabled()) { + return null; + } + Path root = proxy.getDataFolderPlugin().toPath().toAbsolutePath().normalize(); + Settings settings = new Settings(root, + resolveInside(root, config.getControlHostedJarFile(), "Control hosted JAR"), + resolveInside(root, config.getControlHostedDataDirectory(), "Control hosted data directory"), + config.getControlHostedAutoDownload(), config.getControlHostedAutoUpdate(), + parseDownloadUri(config.getControlHostedDownloadUrl()), config.getControlHostedSha256(), + config.getControlHostedHost(), config.getControlHostedPort(), + config.getControlHostedStartupTimeoutSeconds(), config.getControlHostedDownloadTimeoutSeconds()); + return new HostedControlManager(settings, message -> proxy.log("[Control Host] " + message)); + } + + public void start() { + if (closed || activeTask != null) { + return; + } + activeTask = executor.submit(this::runOnce); + } + + void runOnce() { + if (closed || !workInProgress.compareAndSet(false, true)) { + return; + } + try { + secureDirectory(settings.rootDirectory(), settings.jarFile().getParent()); + secureDirectory(settings.rootDirectory(), settings.dataDirectory()); + recoverPersistedRollbackState(); + ManagedProcess retained = managedProcess; + if (retained != null) { + if (retained.isAlive()) return; + clearManagedProcess(retained); + } + if (rollbackPending && Files.isRegularFile(settings.previousFile(), LinkOption.NOFOLLOW_LINKS)) { + if (rollbackCandidateReachedHealth) { + if (rollbackAndStartPrevious()) return; + throw new IOException("The previous Control release did not become healthy"); + } + restoreIncompleteActivation(); + } + recoverPersistedQuarantineState(); + boolean updated = prepareArtifact(); + if (updated) { + rollbackCandidateSha256 = settings.sha256(); + rollbackPending = true; + } + if (closed) { + return; + } + LaunchedProcess launched = launch(settings.jarFile()); + ManagedProcess process = launched.process(); + if (updated) { + try { + persistHealthCheckingState(trustedRollbackSha256, rollbackCandidateSha256); + } catch (IOException publicationFailure) { + try { + stopProcess(process, true); + } catch (RuntimeException stopFailure) { + publicationFailure.addSuppressed(stopFailure); + } + throw publicationFailure; + } + rollbackCandidateReachedHealth = true; + } + if (awaitHealthy(launched)) { + failures = 0; + clearPersistedRollbackState(); + rollbackCandidateSha256 = null; + rollbackCandidateReachedHealth = false; + rollbackPending = false; + status = Status.RUNNING; + logger.accept("WebUI is available at " + settings.endpoint()); + monitor(process); + return; + } + stopProcess(process, true); + if (rollbackPending && Files.isRegularFile(settings.previousFile(), LinkOption.NOFOLLOW_LINKS)) { + if (rollbackAndStartPrevious()) return; + } + throw new IOException("Control did not become healthy"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + if (!closed) { + fail(); + } + } catch (Exception e) { + if (!closed) { + fail(); + } + } finally { + workInProgress.set(false); + } + } + + private boolean rollbackAndStartPrevious() throws IOException, InterruptedException { + String failedCandidateSha256 = rollbackCandidateSha256; + persistQuarantineState(trustedRollbackSha256, failedCandidateSha256); + rollback(); + rollbackPending = false; + quarantinedSha256 = failedCandidateSha256; + rollbackCandidateSha256 = null; + rollbackCandidateReachedHealth = false; + LaunchedProcess previousLaunch = launch(settings.jarFile()); + ManagedProcess previous = previousLaunch.process(); + if (awaitHealthy(previousLaunch)) { + failures = 0; + status = Status.ROLLED_BACK; + logger.accept("The new Control release failed health checks; the previous release is running"); + monitor(previous); + return true; + } + stopProcess(previous, true); + return false; + } + + private boolean prepareArtifact() throws IOException, InterruptedException { + String installedSha256 = null; + if (Files.isRegularFile(settings.jarFile(), LinkOption.NOFOLLOW_LINKS)) { + installedSha256 = sha256(settings.jarFile()); + if (settings.sha256().equals(installedSha256)) { + return false; + } + if (settings.sha256().equals(quarantinedSha256)) { + if (installedSha256.equals(trustedRollbackSha256)) return false; + throw new IOException("Rolled-back Control artifact no longer matches its trusted digest"); + } + trustedRollbackSha256 = installedSha256; + } + boolean installed = Files.exists(settings.jarFile(), LinkOption.NOFOLLOW_LINKS); + if (installed && !Files.isRegularFile(settings.jarFile(), LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Control artifact path is not a regular file"); + } + if ((installed && !settings.autoUpdate()) || (!installed && !settings.autoDownload())) { + throw new IOException("Control artifact is unavailable or does not match its pin"); + } + if (settings.downloadUri() == null) { + throw new IOException("Control download URL is not configured"); + } + + status = Status.DOWNLOADING; + Path staged = settings.jarFile().resolveSibling(settings.jarFile().getFileName() + ".staged-" + UUID.randomUUID()); + try { + downloader.download(settings.downloadUri(), staged, MAX_DOWNLOAD_BYTES, + Duration.ofSeconds(settings.downloadTimeoutSeconds())); + if (!settings.sha256().equals(sha256(staged))) { + throw new IOException("Downloaded Control artifact failed SHA-256 verification"); + } + activate(staged, installed, installedSha256); + return installed; + } finally { + Files.deleteIfExists(staged); + } + } + + private void activate(Path staged, boolean installed, String installedSha256) throws IOException { + DurableFiles.forceFile(staged); + if (installed) { + persistRollbackState(installedSha256); + atomicMove(settings.jarFile(), settings.previousFile(), true); + } + try { + atomicMove(staged, settings.jarFile(), false); + } catch (IOException e) { + if (installed && Files.exists(settings.previousFile(), LinkOption.NOFOLLOW_LINKS)) { + atomicMove(settings.previousFile(), settings.jarFile(), true); + clearPersistedRollbackState(); + } + throw e; + } + } + + private void rollback() throws IOException { + if (Files.exists(settings.jarFile(), LinkOption.NOFOLLOW_LINKS)) { + atomicMove(settings.jarFile(), settings.failedFile(), true); + } + atomicMove(settings.previousFile(), settings.jarFile(), false); + clearPersistedRollbackState(); + } + + private void persistRollbackState(String previousSha256) throws IOException { + DurableFiles.deleteIfExists(settings.healthCheckingFile()); + persistDigestState(settings.rollbackPendingFile(), previousSha256, settings.sha256(), "rollback"); + } + + private void persistHealthCheckingState(String previousSha256, String candidateSha256) throws IOException { + persistDigestState(settings.healthCheckingFile(), previousSha256, candidateSha256, "health-checking"); + } + + private void persistQuarantineState(String previousSha256, String candidateSha256) throws IOException { + persistDigestState(settings.quarantineFile(), previousSha256, candidateSha256, "quarantine"); + } + + private void persistDigestState(Path marker, String previousSha256, String candidateSha256, String name) + throws IOException { + if (previousSha256 == null || !previousSha256.matches("[0-9a-f]{64}")) { + throw new IOException("Control " + name + " digest is unavailable"); + } + if (candidateSha256 == null || !candidateSha256.matches("[0-9a-f]{64}")) { + throw new IOException("Control " + name + " candidate digest is unavailable"); + } + if (Files.exists(marker, LinkOption.NOFOLLOW_LINKS) + && !Files.isRegularFile(marker, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Control " + name + " state path is not a regular file"); + } + Path staged = marker.resolveSibling(marker.getFileName() + ".staged-" + UUID.randomUUID()); + byte[] state = (previousSha256 + "\n" + candidateSha256 + "\n") + .getBytes(java.nio.charset.StandardCharsets.US_ASCII); + try (FileChannel channel = FileChannel.open(staged, StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE)) { + ByteBuffer buffer = ByteBuffer.wrap(state); + while (buffer.hasRemaining()) channel.write(buffer); + channel.force(true); + } + try { + atomicMove(staged, marker, true); + } finally { + Files.deleteIfExists(staged); + } + } + + private void recoverPersistedRollbackState() throws IOException { + Path marker = settings.rollbackPendingFile(); + String[] lines = readDigestState(marker, "rollback"); + if (lines == null) return; + String previousSha256 = lines[0]; + String candidateSha256 = lines[1]; + boolean active = Files.isRegularFile(settings.jarFile(), LinkOption.NOFOLLOW_LINKS); + String activeSha256 = active ? sha256(settings.jarFile()) : null; + if (previousSha256.equals(activeSha256)) { + clearPersistedRollbackState(); + rollbackCandidateSha256 = null; + rollbackCandidateReachedHealth = false; + rollbackPending = false; + return; + } + if (active && !candidateSha256.equals(activeSha256)) { + throw new IOException("Active Control artifact does not match pending rollback state"); + } + if (!Files.isRegularFile(settings.previousFile(), LinkOption.NOFOLLOW_LINKS) + || !previousSha256.equals(sha256(settings.previousFile()))) { + throw new IOException("Previous Control artifact does not match pending rollback state"); + } + trustedRollbackSha256 = previousSha256; + rollbackCandidateSha256 = candidateSha256; + String[] healthChecking = readDigestState(settings.healthCheckingFile(), "health-checking"); + if (healthChecking != null && (!previousSha256.equals(healthChecking[0]) + || !candidateSha256.equals(healthChecking[1]))) { + throw new IOException("Control health-checking state does not match pending rollback state"); + } + rollbackCandidateReachedHealth = healthChecking != null; + rollbackPending = true; + } + + private void restoreIncompleteActivation() throws IOException { + if (Files.exists(settings.jarFile(), LinkOption.NOFOLLOW_LINKS)) { + atomicMove(settings.jarFile(), settings.failedFile(), true); + } + atomicMove(settings.previousFile(), settings.jarFile(), false); + clearPersistedRollbackState(); + trustedRollbackSha256 = null; + rollbackCandidateSha256 = null; + rollbackCandidateReachedHealth = false; + rollbackPending = false; + } + + private void recoverPersistedQuarantineState() throws IOException { + String[] lines = readDigestState(settings.quarantineFile(), "quarantine"); + if (lines == null) return; + String previousSha256 = lines[0]; + String candidateSha256 = lines[1]; + if (!settings.sha256().equals(candidateSha256)) { + clearPersistedQuarantineState(); + quarantinedSha256 = null; + trustedRollbackSha256 = null; + return; + } + if (!Files.isRegularFile(settings.jarFile(), LinkOption.NOFOLLOW_LINKS) + || !previousSha256.equals(sha256(settings.jarFile()))) { + throw new IOException("Active Control artifact does not match quarantined rollback state"); + } + trustedRollbackSha256 = previousSha256; + quarantinedSha256 = candidateSha256; + } + + private String[] readDigestState(Path marker, String name) throws IOException { + if (!Files.exists(marker, LinkOption.NOFOLLOW_LINKS)) return null; + if (!Files.isRegularFile(marker, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Control " + name + " state is invalid"); + } + byte[] bytes; + try (FileChannel channel = FileChannel.open(marker, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) { + long size = channel.size(); + if (size > 256L) throw new IOException("Control " + name + " state is invalid"); + bytes = new byte[(int) size]; + ByteBuffer buffer = ByteBuffer.wrap(bytes); + while (buffer.hasRemaining() && channel.read(buffer) >= 0) { } + } + String[] lines = new String(bytes, java.nio.charset.StandardCharsets.US_ASCII).split("\\R"); + if (lines.length != 2 || !lines[0].matches("[0-9a-f]{64}") || !lines[1].matches("[0-9a-f]{64}")) { + throw new IOException("Control " + name + " state is invalid"); + } + return lines; + } + + private void clearPersistedRollbackState() throws IOException { + DurableFiles.deleteIfExists(settings.rollbackPendingFile()); + DurableFiles.deleteIfExists(settings.healthCheckingFile()); + } + + private void clearPersistedQuarantineState() throws IOException { + DurableFiles.deleteIfExists(settings.quarantineFile()); + } + + private LaunchedProcess launch(Path artifact) throws IOException { + status = Status.STARTING_PROCESS; + synchronized (processLifecycle) { + if (closed) throw new IOException("Hosted Control manager is closed"); + if (managedProcess != null) { + if (managedProcess.isAlive()) { + throw new IOException("The previous hosted Control process is still running"); + } + managedProcess = null; + exitAwaitedProcess = null; + } + String launchId = UUID.randomUUID().toString(); + ManagedProcess process = launcher.launch(settings, artifact, launchId); + managedProcess = process; + return new LaunchedProcess(process, launchId); + } + } + + private boolean awaitHealthy(LaunchedProcess launched) throws InterruptedException { + ManagedProcess process = launched.process(); + long timeoutNanos = TimeUnit.SECONDS.toNanos(settings.startupTimeoutSeconds()); + long started = nanoTime.getAsLong(); + long deadline = started + timeoutNanos; + if (deadline < started) { + deadline = Long.MAX_VALUE; + } + boolean firstAttempt = true; + while (!closed) { + if (!process.isAlive()) { + return false; + } + long now = nanoTime.getAsLong(); + if (!firstAttempt && now >= deadline) { + return false; + } + firstAttempt = false; + long remaining = Math.max(1L, deadline - now); + if (healthProbe.isHealthy(settings.endpoint(), + Duration.ofNanos(Math.min(TimeUnit.SECONDS.toNanos(2), remaining)), launched.launchId())) { + return process.isAlive(); + } + if (nanoTime.getAsLong() >= deadline) { + return false; + } + sleeper.sleep(1000L / HEALTH_ATTEMPTS_PER_SECOND); + } + return false; + } + + private void monitor(ManagedProcess process) { + process.onExit().thenRun(() -> { + if (closed || managedProcess != process) { + return; + } + managedProcess = null; + status = Status.FAILED; + logger.accept("Control exited unexpectedly; a bounded restart will be attempted"); + scheduleRetry(); + }); + } + + private void fail() { + status = Status.FAILED; + ManagedProcess process = managedProcess; + if (process != null && process.isAlive()) { + failWhileProcessIsAlive(process); + return; + } + if (process != null) clearManagedProcess(process); + logger.accept("Control could not be provisioned or started; VotingPlugin remains unaffected and will retry"); + scheduleRetry(); + } + + private void failWhileProcessIsAlive(ManagedProcess process) { + status = Status.FAILED; + synchronized (processLifecycle) { + if (closed || managedProcess != process || exitAwaitedProcess == process) return; + exitAwaitedProcess = process; + } + logger.accept("Control could not be stopped; provisioning will retry only after that process exits"); + process.onExit().whenComplete((ignored, failure) -> { + boolean retry; + synchronized (processLifecycle) { + if (exitAwaitedProcess == process) exitAwaitedProcess = null; + retry = !closed && managedProcess == process; + if (retry) managedProcess = null; + } + if (retry) scheduleRetry(); + }); + } + + private void scheduleRetry() { + if (closed) { + return; + } + int attempt = Math.min(failures++, 6); + long delay = Math.min(300L, 5L << attempt); + try { + executor.schedule(this::runOnce, delay, TimeUnit.SECONDS); + } catch (RuntimeException ignored) { + // Executor shutdown raced with the failure callback. + } + } + + public Status status() { + return status; + } + + @Override + public void close() { + close(false); + } + + /** Used only by an explicit plugin reload to avoid racing the replacement bind. */ + public void closeAndWait() { + close(true); + } + + private void close(boolean waitForProcess) { + ManagedProcess process; + synchronized (processLifecycle) { + closed = true; + status = Status.STOPPED; + process = managedProcess; + } + Future task = activeTask; + if (task != null) { + task.cancel(true); + } + RuntimeException shutdownFailure = null; + try { + if (process != null) { + stopProcess(process, waitForProcess); + if (waitForProcess) { + clearManagedProcess(process); + } else { + process.onExit().whenComplete((ignored, failure) -> clearManagedProcess(process)); + } + } + } catch (RuntimeException failure) { + shutdownFailure = failure; + } finally { + executor.shutdownNow(); + } + if (waitForProcess) { + long waitSeconds = (long) settings.downloadTimeoutSeconds() + settings.startupTimeoutSeconds() + 5L; + try { + if (!executor.awaitTermination(waitSeconds, TimeUnit.SECONDS)) { + IllegalStateException workerFailure = new IllegalStateException( + "Hosted Control worker did not stop before reload"); + if (shutdownFailure != null) workerFailure.addSuppressed(shutdownFailure); + throw workerFailure; + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + IllegalStateException interrupted = new IllegalStateException( + "Interrupted while waiting for the hosted Control worker", e); + if (shutdownFailure != null) interrupted.addSuppressed(shutdownFailure); + throw interrupted; + } + } + if (shutdownFailure != null) throw shutdownFailure; + } + + private void clearManagedProcess(ManagedProcess process) { + synchronized (processLifecycle) { + if (managedProcess == process) { + managedProcess = null; + if (exitAwaitedProcess == process) exitAwaitedProcess = null; + } + } + } + + static void stopProcess(ManagedProcess process, boolean wait) { + process.destroy(); + if (wait) { + try { + if (!process.waitFor(3, TimeUnit.SECONDS)) { + process.destroyForcibly(); + if (!process.waitFor(3, TimeUnit.SECONDS)) { + throw new IllegalStateException("Hosted Control process did not stop after forced termination"); + } + } + } catch (InterruptedException e) { + process.destroyForcibly(); + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while stopping the hosted Control process", e); + } + } else { + process.onExit().orTimeout(3, TimeUnit.SECONDS).exceptionally(failure -> { + process.destroyForcibly(); + return null; + }); + } + } + + static String sha256(Path file) throws IOException { + try (InputStream input = Files.newInputStream(file); + DigestInputStream digest = new DigestInputStream(input, MessageDigest.getInstance("SHA-256"))) { + digest.transferTo(OutputStream.nullOutputStream()); + return HexFormat.of().formatHex(digest.getMessageDigest().digest()); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is unavailable", e); + } + } + + private static void secureDirectory(Path root, Path directory) throws IOException { + Files.createDirectories(root); + Files.createDirectories(directory); + Path realRoot = root.toRealPath(); + Path realDirectory = directory.toRealPath(); + if (!realDirectory.startsWith(realRoot)) { + throw new IOException("Control path escapes the plugin data directory"); + } + } + + private static void atomicMove(Path source, Path target, boolean replace) throws IOException { + try { + if (replace) { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } else { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE); + } + DurableFiles.forceMoveDirectories(source, target); + } catch (AtomicMoveNotSupportedException e) { + throw new IOException("Atomic Control artifact activation is unsupported", e); + } + } + + static Path resolveInside(Path root, String configured, String name) { + if (configured == null || configured.isBlank()) { + throw new IllegalArgumentException(name + " is not configured"); + } + Path relative = Path.of(configured.trim()); + if (relative.isAbsolute()) { + throw new IllegalArgumentException(name + " must be relative to the plugin data directory"); + } + Path resolved = root.resolve(relative).normalize(); + if (resolved.equals(root) || !resolved.startsWith(root)) { + throw new IllegalArgumentException(name + " escapes the plugin data directory"); + } + return resolved; + } + + static URI parseDownloadUri(String configured) { + if (configured == null || configured.isBlank()) { + return null; + } + URI uri; + try { + uri = URI.create(configured.trim()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Control download URL is invalid"); + } + if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getHost() == null || uri.getUserInfo() != null + || uri.getQuery() != null || uri.getFragment() != null || uri.getPath() == null + || uri.getPath().toLowerCase(Locale.ROOT).contains("/latest/")) { + throw new IllegalArgumentException("Control download URL must be a versioned HTTPS release asset"); + } + return uri; + } + + private static ScheduledExecutorService daemonExecutor() { + ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1, runnable -> { + Thread thread = new Thread(runnable, "votingplugin-control-host"); + thread.setDaemon(true); + return thread; + }); + executor.setRemoveOnCancelPolicy(true); + return executor; + } + + public enum Status { + STARTING, DOWNLOADING, STARTING_PROCESS, RUNNING, ROLLED_BACK, FAILED, STOPPED + } + + record Settings(Path rootDirectory, Path jarFile, Path dataDirectory, boolean autoDownload, boolean autoUpdate, + URI downloadUri, String sha256, String host, int port, int startupTimeoutSeconds, + int downloadTimeoutSeconds) { + Settings { + rootDirectory = Objects.requireNonNull(rootDirectory, "rootDirectory").toAbsolutePath().normalize(); + jarFile = Objects.requireNonNull(jarFile, "jarFile").toAbsolutePath().normalize(); + dataDirectory = Objects.requireNonNull(dataDirectory, "dataDirectory").toAbsolutePath().normalize(); + if (!jarFile.startsWith(rootDirectory) || !dataDirectory.startsWith(rootDirectory) + || jarFile.equals(rootDirectory) || dataDirectory.equals(rootDirectory) + || dataDirectory.startsWith(jarFile)) { + throw new IllegalArgumentException("Control hosted paths are invalid"); + } + sha256 = sha256 == null ? "" : sha256.trim().toLowerCase(Locale.ROOT); + if (!sha256.matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException("Control hosted SHA-256 pin must contain 64 hexadecimal characters"); + } + if ((autoDownload || autoUpdate) && downloadUri == null) { + throw new IllegalArgumentException("Control hosted download URL is required"); + } + host = host == null ? "" : host.trim(); + if (host.isEmpty() || host.indexOf('\0') >= 0) { + throw new IllegalArgumentException("Control hosted host is invalid"); + } + if (port < 1 || port > 65535 || startupTimeoutSeconds < 1 || startupTimeoutSeconds > 120 + || downloadTimeoutSeconds < 5 || downloadTimeoutSeconds > 300) { + throw new IllegalArgumentException("Control hosted bounds are invalid"); + } + } + + Path previousFile() { + return jarFile.resolveSibling(jarFile.getFileName() + ".previous"); + } + + Path failedFile() { + return jarFile.resolveSibling(jarFile.getFileName() + ".failed"); + } + + Path rollbackPendingFile() { + return jarFile.resolveSibling(jarFile.getFileName() + ".rollback-pending"); + } + + Path quarantineFile() { + return jarFile.resolveSibling(jarFile.getFileName() + ".quarantined"); + } + + Path healthCheckingFile() { + return jarFile.resolveSibling(jarFile.getFileName() + ".health-checking"); + } + + URI endpoint() { + try { + return new URI("http", null, host, port, "/", null, null); + } catch (Exception e) { + throw new IllegalArgumentException("Control hosted endpoint is invalid"); + } + } + } + + interface ArtifactDownloader { + void download(URI source, Path target, long maximumBytes, Duration timeout) + throws IOException, InterruptedException; + } + + interface ProcessLauncher { + ManagedProcess launch(Settings settings, Path artifact, String launchId) throws IOException; + } + + interface HealthProbe { + boolean isHealthy(URI endpoint, Duration timeout, String launchId); + } + + interface Sleeper { + void sleep(long millis) throws InterruptedException; + } + + interface ManagedProcess { + boolean isAlive(); + void destroy(); + void destroyForcibly(); + boolean waitFor(long timeout, TimeUnit unit) throws InterruptedException; + CompletableFuture onExit(); + } + + static final class JdkArtifactDownloader implements ArtifactDownloader { + private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)) + .followRedirects(HttpClient.Redirect.NEVER).build(); + + @Override + public void download(URI source, Path target, long maximumBytes, Duration timeout) + throws IOException, InterruptedException { + long deadline = System.nanoTime() + timeout.toNanos(); + URI current = source; + for (int redirects = 0; redirects <= 5; redirects++) { + long requestNanos = remainingDownloadNanos(deadline); + HttpRequest request = HttpRequest.newBuilder(current).timeout(Duration.ofNanos(requestNanos)) + .header("Accept", "application/java-archive, application/octet-stream") + .header("User-Agent", "VotingPlugin-Control-Host").GET().build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()); + int status = response.statusCode(); + if (status >= 300 && status < 400) { + try (InputStream ignored = response.body()) { + String location = response.headers().firstValue("Location") + .orElseThrow(() -> new IOException("Control download redirect is invalid")); + current = current.resolve(location); + if (!"https".equalsIgnoreCase(current.getScheme()) || current.getHost() == null + || current.getUserInfo() != null) { + throw new IOException("Control download redirected outside HTTPS"); + } + continue; + } + } + if (status != 200) { + response.body().close(); + throw new IOException("Control download did not return HTTP 200"); + } + long declared = response.headers().firstValueAsLong("Content-Length").orElse(-1L); + if (declared > maximumBytes) { + response.body().close(); + throw new IOException("Control artifact exceeds the download limit"); + } + try (InputStream input = response.body(); OutputStream output = Files.newOutputStream(target, + StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { + AtomicBoolean bodyTimedOut = new AtomicBoolean(); + Thread timeoutThread = bodyTimeoutThread(input, deadline, bodyTimedOut); + byte[] buffer = new byte[8192]; + long total = 0; + int count; + try { + while ((count = input.read(buffer)) != -1) { + if (bodyTimedOut.get() || System.nanoTime() >= deadline) { + throw new IOException("Control artifact download timed out"); + } + total += count; + if (total > maximumBytes) { + throw new IOException("Control artifact exceeds the download limit"); + } + output.write(buffer, 0, count); + } + } catch (IOException failure) { + if (bodyTimedOut.get()) throw new IOException("Control artifact download timed out", failure); + throw failure; + } finally { + timeoutThread.interrupt(); + } + if (bodyTimedOut.get()) throw new IOException("Control artifact download timed out"); + } + return; + } + throw new IOException("Control download redirected too many times"); + } + + private static long remainingDownloadNanos(long deadline) throws IOException { + long remaining = deadline - System.nanoTime(); + if (remaining <= 0) throw new IOException("Control artifact download timed out"); + return remaining; + } + + private static Thread bodyTimeoutThread(InputStream input, long deadline, AtomicBoolean timedOut) + throws IOException { + long remaining = remainingDownloadNanos(deadline); + Thread thread = new Thread(() -> { + try { + TimeUnit.NANOSECONDS.sleep(remaining); + timedOut.set(true); + input.close(); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } catch (IOException ignored) { } + }, "VotingPlugin-Control-Download-Timeout"); + thread.setDaemon(true); + thread.setContextClassLoader(ClassLoader.getPlatformClassLoader()); + thread.start(); + return thread; + } + } + + private static final class JdkProcessLauncher implements ProcessLauncher { + @Override + public ManagedProcess launch(Settings settings, Path artifact, String launchId) throws IOException { + Path log = artifact.resolveSibling("control-output.log"); + if (Files.exists(log, LinkOption.NOFOLLOW_LINKS) + && !Files.isRegularFile(log, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Control output path is not a regular file"); + } + if (Files.isRegularFile(log, LinkOption.NOFOLLOW_LINKS) && Files.size(log) > 1024L * 1024L) { + Files.move(log, log.resolveSibling("control-output.previous.log"), StandardCopyOption.REPLACE_EXISTING); + } + Path java = Path.of(System.getProperty("java.home"), "bin", isWindows() ? "java.exe" : "java"); + ProcessBuilder builder = new ProcessBuilder(java.toString(), "-jar", artifact.toString()); + builder.directory(artifact.getParent().toFile()); + builder.redirectErrorStream(true); + builder.redirectOutput(ProcessBuilder.Redirect.appendTo(log.toFile())); + var environment = builder.environment(); + environment.clear(); + for (String safe : Set.of("LANG", "LC_ALL", "SYSTEMROOT", "WINDIR", "TEMP", "TMP")) { + String value = System.getenv(safe); + if (value != null) { + environment.put(safe, value); + } + } + environment.put("CONTROL_HOST", settings.host()); + environment.put("CONTROL_PORT", Integer.toString(settings.port())); + environment.put("CONTROL_DATA_DIR", settings.dataDirectory().toString()); + environment.put("CONTROL_LAUNCH_ID", launchId); + environment.put("CONTROL_PARENT_PID", Long.toString(ProcessHandle.current().pid())); + return new JdkManagedProcess(builder.start()); + } + + private static boolean isWindows() { + return DurableFiles.isWindowsName(System.getProperty("os.name", "")); + } + } + + static final class JdkHealthProbe implements HealthProbe { + private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)) + .followRedirects(HttpClient.Redirect.NEVER).build(); + + @Override + public boolean isHealthy(URI endpoint, Duration timeout, String launchId) { + try { + HttpRequest request = HttpRequest.newBuilder(endpoint.resolve("/api/v1/health")) + .timeout(timeout).header("Accept", "application/json").GET().build(); + HttpResponse response = client.send(request, new BoundedHttpBodyHandler(64 * 1024, timeout)); + if (response.statusCode() != 200) return false; + JsonElement parsed = JsonParser.parseString( + new String(response.body(), java.nio.charset.StandardCharsets.UTF_8)); + if (!parsed.isJsonObject()) return false; + JsonObject object = parsed.getAsJsonObject(); + JsonObject identity = object.has("identity") && object.get("identity").isJsonObject() + ? object.getAsJsonObject("identity") : null; + return object.has("status") && "ok".equals(object.get("status").getAsString()) + && object.has("launchId") && launchId.equals(object.get("launchId").getAsString()) + && identity != null && identity.has("protocolVersion") + && identity.get("protocolVersion").getAsInt() == EXPECTED_PROTOCOL_VERSION; + } catch (Exception e) { + return false; + } + } + } + + private record LaunchedProcess(ManagedProcess process, String launchId) { } + + private static final class JdkManagedProcess implements ManagedProcess { + private final Process process; + + private JdkManagedProcess(Process process) { + this.process = process; + } + + @Override public boolean isAlive() { return process.isAlive(); } + @Override public void destroy() { process.destroy(); } + @Override public void destroyForcibly() { process.destroyForcibly(); } + @Override public boolean waitFor(long timeout, TimeUnit unit) throws InterruptedException { + return process.waitFor(timeout, unit); + } + @Override public CompletableFuture onExit() { return process.onExit().thenApply(ignored -> null); } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyControlResultStore.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyControlResultStore.java new file mode 100644 index 0000000000..73529cfbe6 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyControlResultStore.java @@ -0,0 +1,173 @@ +package com.bencodez.votingplugin.proxy.control; + +import java.io.IOException; +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.channels.SeekableByteChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import com.bencodez.votingplugin.util.DurableFiles; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +/** Durable result journal for proxy-routing operations accepted by a specific Control node. */ +final class ProxyControlResultStore { + private static final int VERSION = 2; + private static final int MAX_BYTES = 256 * 1024; + private static final int MAX_RESULTS = 128; + private static final String FILE_NAME = ".control-proxy-pending-results.json"; + + private ProxyControlResultStore() { } + + static State load(Path dataDirectory) throws IOException { + Path target = target(dataDirectory); + if (!Files.exists(target, LinkOption.NOFOLLOW_LINKS)) return null; + if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target) + || Files.size(target) > MAX_BYTES) { + throw new IOException("Control proxy-result journal is unsafe or too large"); + } + byte[] bytes; + try (SeekableByteChannel channel = Files.newByteChannel(target, + Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS))) { + ByteBuffer buffer = ByteBuffer.allocate((int) Files.size(target) + 1); + while (channel.read(buffer) >= 0 && buffer.hasRemaining()) { } + if (!buffer.hasRemaining()) throw new IOException("Control proxy-result journal is too large"); + buffer.flip(); + bytes = new byte[buffer.remaining()]; + buffer.get(bytes); + } + try { + JsonElement parsed = JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8)); + if (!parsed.isJsonObject()) throw invalid(); + JsonObject root = parsed.getAsJsonObject(); + if (integer(root, "version") != VERSION) throw invalid(); + JsonObject routeJson = object(root, "route"); + Route route = new Route(string(routeJson, "nodeId"), string(routeJson, "displayName"), + string(routeJson, "platform"), string(routeJson, "pluginVersion"), + URI.create(string(routeJson, "endpoint")), string(routeJson, "credentialFile"), + integer(routeJson, "heartbeatSeconds"), integer(routeJson, "connectTimeoutMillis"), + integer(routeJson, "requestTimeoutMillis")); + JsonArray listed = array(root, "results"); + if (listed.size() == 0 || listed.size() > MAX_RESULTS) throw invalid(); + Map results = new LinkedHashMap<>(); + for (JsonElement element : listed) { + if (!element.isJsonObject()) throw invalid(); + JsonObject item = element.getAsJsonObject(); + UUID operationId = UUID.fromString(string(item, "operationId")); + if (!item.has("committed") || !item.get("committed").isJsonPrimitive() + || !item.has("claimRequired") || !item.get("claimRequired").isJsonPrimitive()) throw invalid(); + StoredResult previous = results.put(operationId, + new StoredResult(object(item, "result").deepCopy(), item.get("committed").getAsBoolean(), + item.get("claimRequired").getAsBoolean())); + if (previous != null) throw invalid(); + } + return new State(route, Map.copyOf(results)); + } catch (RuntimeException e) { + throw new IOException("Control proxy-result journal is malformed", e); + } + } + + static void save(Path dataDirectory, Route route, Map results) throws IOException { + Path target = target(dataDirectory); + if (results.isEmpty()) { + if (Files.isSymbolicLink(target)) throw new IOException("Control proxy-result journal is unsafe"); + DurableFiles.deleteIfExists(target); + return; + } + if (results.size() > MAX_RESULTS) throw new IOException("Too many pending Control proxy results"); + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS) + && (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target))) { + throw new IOException("Control proxy-result journal is unsafe"); + } + JsonObject root = new JsonObject(); + root.addProperty("version", VERSION); + JsonObject routeJson = new JsonObject(); + routeJson.addProperty("nodeId", route.nodeId()); + routeJson.addProperty("displayName", route.displayName()); + routeJson.addProperty("platform", route.platform()); + routeJson.addProperty("pluginVersion", route.pluginVersion()); + routeJson.addProperty("endpoint", route.endpoint().toString()); + routeJson.addProperty("credentialFile", route.credentialFile()); + routeJson.addProperty("heartbeatSeconds", route.heartbeatSeconds()); + routeJson.addProperty("connectTimeoutMillis", route.connectTimeoutMillis()); + routeJson.addProperty("requestTimeoutMillis", route.requestTimeoutMillis()); + root.add("route", routeJson); + JsonArray listed = new JsonArray(); + results.forEach((operationId, result) -> { + JsonObject item = new JsonObject(); + item.addProperty("operationId", operationId.toString()); + item.add("result", result.result().deepCopy()); + item.addProperty("committed", result.committed()); + item.addProperty("claimRequired", result.claimRequired()); + listed.add(item); + }); + root.add("results", listed); + byte[] bytes = root.toString().getBytes(StandardCharsets.UTF_8); + if (bytes.length > MAX_BYTES) throw new IOException("Control proxy-result journal is too large"); + Files.createDirectories(dataDirectory); + Path staging = Files.createTempFile(dataDirectory, ".control-proxy-results-", ".json"); + try { + try (FileChannel channel = FileChannel.open(staging, StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE)) { + ByteBuffer buffer = ByteBuffer.wrap(bytes); + while (buffer.hasRemaining()) channel.write(buffer); + channel.force(true); + } + Files.move(staging, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + DurableFiles.forceDirectory(dataDirectory); + } finally { + Files.deleteIfExists(staging); + } + } + + private static Path target(Path dataDirectory) { + return dataDirectory.toAbsolutePath().normalize().resolve(FILE_NAME); + } + + private static JsonObject object(JsonObject object, String name) { + if (!object.has(name) || !object.get(name).isJsonObject()) throw invalid(); + return object.getAsJsonObject(name); + } + + private static JsonArray array(JsonObject object, String name) { + if (!object.has(name) || !object.get(name).isJsonArray()) throw invalid(); + return object.getAsJsonArray(name); + } + + private static String string(JsonObject object, String name) { + if (!object.has(name) || !object.get(name).isJsonPrimitive()) throw invalid(); + String value = object.get(name).getAsString(); + if (value == null || value.isBlank() || value.length() > 2048) throw invalid(); + return value; + } + + private static int integer(JsonObject object, String name) { + if (!object.has(name) || !object.get(name).isJsonPrimitive()) throw invalid(); + return object.get(name).getAsInt(); + } + + private static IllegalArgumentException invalid() { + return new IllegalArgumentException("invalid proxy-result journal"); + } + + record Route(String nodeId, String displayName, String platform, String pluginVersion, URI endpoint, + String credentialFile, int heartbeatSeconds, int connectTimeoutMillis, int requestTimeoutMillis) { } + record StoredResult(JsonObject result, boolean committed, boolean claimRequired) { + StoredResult { + if (committed && claimRequired) throw new IllegalArgumentException("committed result cannot require a claim"); + } + } + record State(Route route, Map results) { } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyRoutingConfiguration.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyRoutingConfiguration.java new file mode 100644 index 0000000000..1033c6edee --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyRoutingConfiguration.java @@ -0,0 +1,48 @@ +package com.bencodez.votingplugin.proxy.control; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** Typed, non-secret configuration domain supported by Control. */ +public record ProxyRoutingConfiguration(boolean sendVotesToAllServers, List blockedServers) { + public ProxyRoutingConfiguration { + blockedServers = blockedServers == null ? List.of() : blockedServers.stream().map(String::trim).toList(); + if (blockedServers.size() > 256) throw new IllegalArgumentException("too many blocked servers"); + Set unique = new HashSet<>(); + for (String server : blockedServers) { + if (server.isBlank() || server.length() > 100 || !unique.add(server.toLowerCase(Locale.ROOT))) { + throw new IllegalArgumentException("blocked server names must be unique and 1 to 100 characters"); + } + } + blockedServers = List.copyOf(blockedServers); + } + + public String revision() { + String canonical = sendVotesToAllServers + "\n" + String.join("\n", blockedServers.stream() + .sorted().toList()); + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(canonical.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is unavailable", e); + } + } + + public List changesFrom(ProxyRoutingConfiguration current) { + List changes = new ArrayList<>(); + if (sendVotesToAllServers != current.sendVotesToAllServers) { + changes.add("sendVotesToAllServers: " + current.sendVotesToAllServers + " -> " + sendVotesToAllServers); + } + if (!blockedServers.equals(current.blockedServers)) { + changes.add("blockedServers: " + current.blockedServers + " -> " + blockedServers); + } + return List.copyOf(changes); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyRoutingConfigurationService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyRoutingConfigurationService.java new file mode 100644 index 0000000000..2c10ade56f --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyRoutingConfigurationService.java @@ -0,0 +1,135 @@ +package com.bencodez.votingplugin.proxy.control; + +import java.io.IOException; +import java.util.HashSet; +import java.util.Set; + +import com.bencodez.votingplugin.proxy.VotingPluginProxy; +import com.bencodez.votingplugin.util.DurableFiles; + +/** Validates, persists, reloads, and rolls back the bounded Control configuration domain. */ +public final class ProxyRoutingConfigurationService { + private final Platform platform; + + public ProxyRoutingConfigurationService(VotingPluginProxy proxy) { + this(new Platform() { + @Override public ProxyRoutingConfiguration read() { + return new ProxyRoutingConfiguration(proxy.getConfig().getSendVotesToAllServers(), + proxy.getConfig().getBlockedServers()); + } + @Override public Set configuredServers() { return proxy.getAllConfiguredServers(); } + @Override public void persist(ProxyRoutingConfiguration proposal, String expectedRevision) throws IOException { + proxy.getConfig().persistControlProxyRouting(proposal.sendVotesToAllServers(), + proposal.blockedServers(), expectedRevision); + } + @Override public void rollback() throws IOException { proxy.getConfig().rollbackControlProxyRouting(); } + @Override public void reload() throws Exception { proxy.reloadControlConfiguration(); } + @Override public void verifyInstalled() throws IOException { + proxy.getConfig().verifyControlProxyRoutingInstalled(); + } + @Override public byte[] captureInstalledSnapshot() throws IOException { + return proxy.getConfig().captureControlProxyRoutingSnapshot(); + } + @Override public void verifyInstalledSnapshot(byte[] snapshot) throws IOException { + proxy.getConfig().verifyControlProxyRoutingSnapshot(snapshot); + } + }); + } + + ProxyRoutingConfigurationService(Platform platform) { + this.platform = platform; + } + + public ProxyRoutingConfiguration read() { + return platform.read(); + } + + public void validate(ProxyRoutingConfiguration proposal) { + Set available = new HashSet<>(platform.configuredServers()); + for (String server : proposal.blockedServers()) { + if (!available.contains(server)) { + throw new IllegalArgumentException("unknown backend: " + server); + } + } + } + + public void apply(ProxyRoutingConfiguration proposal, String expectedRevision) throws IOException { + validate(proposal); + if (expectedRevision == null || !read().revision().equals(expectedRevision)) { + throw new StaleRevisionException(); + } + try { + platform.persist(proposal, expectedRevision); + } catch (com.bencodez.votingplugin.proxy.VotingPluginProxyConfig.StaleControlRevisionException e) { + throw new StaleRevisionException(); + } catch (DurableFiles.PublishedException published) { + throw rollbackAfterFailure(published); + } + try { + platform.reload(); + try { + platform.verifyInstalled(); + } catch (com.bencodez.votingplugin.proxy.VotingPluginProxyConfig.StaleControlRevisionException stale) { + reconcileConcurrentEdit(); + throw new StaleRevisionException(); + } + } catch (StaleRevisionException stale) { + throw stale; + } catch (Exception e) { + throw rollbackAfterFailure(e); + } + } + + private ApplyFailureException rollbackAfterFailure(Exception failure) { + boolean rolledBack = false; + DurableFiles.PublishedException publicationFailure = null; + try { + try { + platform.rollback(); + } catch (DurableFiles.PublishedException published) { + // The restored file is already active. Continue the reload so runtime + // state follows disk, while retaining the durability warning. + publicationFailure = published; + } + platform.reload(); + rolledBack = true; + } catch (Exception rollbackFailure) { + failure.addSuppressed(rollbackFailure); + } + if (publicationFailure != null) failure.addSuppressed(publicationFailure); + return new ApplyFailureException(rolledBack, failure); + } + + private void reconcileConcurrentEdit() throws Exception { + for (int attempt = 0; attempt < 3; attempt++) { + byte[] snapshot = platform.captureInstalledSnapshot(); + platform.reload(); + try { + platform.verifyInstalledSnapshot(snapshot); + return; + } catch (com.bencodez.votingplugin.proxy.VotingPluginProxyConfig.StaleControlRevisionException stale) { + if (attempt == 2) throw new StaleRevisionException(); + } + } + } + + interface Platform { + ProxyRoutingConfiguration read(); + Set configuredServers(); + void persist(ProxyRoutingConfiguration proposal, String expectedRevision) throws IOException; + void rollback() throws IOException; + void reload() throws Exception; + default void verifyInstalled() throws IOException { } + default byte[] captureInstalledSnapshot() throws IOException { return new byte[0]; } + default void verifyInstalledSnapshot(byte[] snapshot) throws IOException { } + } + + @SuppressWarnings("serial") + public static final class StaleRevisionException extends RuntimeException { } + @SuppressWarnings("serial") + public static final class ApplyFailureException extends RuntimeException { + private final boolean rolledBack; + private ApplyFailureException(boolean rolledBack, Throwable cause) { super(cause); this.rolledBack = rolledBack; } + public boolean rolledBack() { return rolledBack; } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandler.java index 933b28a652..be793efa31 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandler.java @@ -53,7 +53,7 @@ public MultiProxyHandler() { /** * Closes the multi-proxy handler. */ - public void close() { + public synchronized void close() { if (multiproxySocketHandler != null) { multiproxySocketHandler.closeConnection(); multiproxySocketHandler = null; @@ -61,6 +61,9 @@ public void close() { if (multiProxyRedis != null && !getMultiProxyRedisUseExistingConnection()) { multiProxyRedis.close(); } + multiProxyRedis = null; + stopSocketClients(multiproxyClientHandles); + multiproxyClientHandles = null; } /** @@ -249,7 +252,7 @@ public abstract void triggerVote(String player, String service, boolean realVote /** * Loads multi-proxy support. */ - public void loadMultiProxySupport() { + public synchronized void loadMultiProxySupport() { if (!getMultiProxySupportEnabled()) { return; } @@ -355,7 +358,7 @@ public void sendStatus() { * * @param envelope the envelope to send */ - public void sendMultiProxyEnvelope(JsonEnvelope envelope) { + public synchronized void sendMultiProxyEnvelope(JsonEnvelope envelope) { if (envelope == null) { return; } @@ -376,6 +379,18 @@ public void sendMultiProxyEnvelope(JsonEnvelope envelope) { } } + static void stopSocketClients(Map clients) { + if (clients == null) return; + for (ClientHandler client : clients.values()) { + if (client == null) continue; + try { + client.stopConnection(); + } catch (RuntimeException ignored) { + // Continue closing the remaining clients after an individual failure. + } + } + } + private void handleEnvelope(JsonEnvelope envelope) { if (envelope == null) { return; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfig.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfig.java index b6d28e214d..2e167420de 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfig.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfig.java @@ -1,24 +1,38 @@ package com.bencodez.votingplugin.proxy.velocity; import java.io.File; +import java.io.IOException; +import java.nio.channels.Channels; +import java.nio.channels.SeekableByteChannel; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import org.checkerframework.checker.nullness.qual.NonNull; import com.bencodez.simpleapi.file.velocity.VelocityYMLFile; import com.bencodez.votingplugin.proxy.VotingPluginProxyConfig; +import com.bencodez.votingplugin.proxy.control.ProxyRoutingConfiguration; +import com.bencodez.votingplugin.util.DurableFiles; import org.spongepowered.configurate.ConfigurationNode; +import org.spongepowered.configurate.yaml.YamlConfigurationLoader; /** * Configuration file handler for Velocity proxy. */ public class VelocityConfig extends VelocityYMLFile implements VotingPluginProxyConfig { + private final File configurationFile; + private byte[] controlInstalledSnapshot; /** * Constructs a new Velocity configuration. @@ -26,6 +40,216 @@ public class VelocityConfig extends VelocityYMLFile implements VotingPluginProxy */ public VelocityConfig(File file) { super(file); + configurationFile = file; + } + + @Override + public synchronized void persistControlProxyRouting(boolean sendVotesToAllServers, List blockedServers, + String expectedRevision) throws IOException { + Path target = configurationFile.toPath(); + Path stage = Files.createTempFile(target.getParent(), target.getFileName().toString(), ".control-stage"); + Path backupStage = null; + Path backup = target.resolveSibling(target.getFileName() + ".control-backup"); + YamlConfigurationLoader sourceLoader = YamlConfigurationLoader.builder().path(target).build(); + try { + backupStage = Files.createTempFile(target.getParent(), target.getFileName().toString(), ".control-backup-stage"); + byte[] sourceSnapshot = Files.readAllBytes(target); + ConfigurationNode latest = sourceLoader.load(); + if (!java.util.Arrays.equals(sourceSnapshot, Files.readAllBytes(target)) + || !routing(latest).revision().equals(expectedRevision)) { + throw new StaleControlRevisionException(); + } + latest.node("SendVotesToAllServers").set(sendVotesToAllServers); + latest.node("BlockedServers").setList(String.class, List.copyOf(blockedServers)); + YamlConfigurationLoader.builder().path(stage).build().save(latest); + byte[] installedSnapshot = Files.readAllBytes(stage); + if (!java.util.Arrays.equals(sourceSnapshot, Files.readAllBytes(target))) { + throw new StaleControlRevisionException(); + } + Files.write(backupStage, sourceSnapshot); + if (!java.util.Arrays.equals(sourceSnapshot, Files.readAllBytes(target))) { + throw new StaleControlRevisionException(); + } + atomicReplace(backupStage, backup); + if (!java.util.Arrays.equals(sourceSnapshot, Files.readAllBytes(target))) { + throw new StaleControlRevisionException(); + } + controlInstalledSnapshot = installedSnapshot; + try { + atomicReplace(stage, target); + } catch (IOException failure) { + if (!(failure instanceof DurableFiles.PublishedException)) controlInstalledSnapshot = null; + throw failure; + } + } finally { + Files.deleteIfExists(stage); + if (backupStage != null) Files.deleteIfExists(backupStage); + } + } + + private static ProxyRoutingConfiguration routing(ConfigurationNode configuration) throws IOException { + return new ProxyRoutingConfiguration(configuration.node("SendVotesToAllServers").getBoolean(true), + configuration.node("BlockedServers").getList(String.class, List.of())); + } + + @Override + public synchronized void rollbackControlProxyRouting() throws IOException { + Path target = configurationFile.toPath(); + Path backup = target.resolveSibling(target.getFileName() + ".control-backup"); + if (!Files.isRegularFile(backup, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Control backup is unavailable or unsafe"); + } + if (controlInstalledSnapshot == null + || !java.util.Arrays.equals(controlInstalledSnapshot, Files.readAllBytes(target))) { + throw new StaleControlRevisionException(); + } + Path stage = Files.createTempFile(target.getParent(), target.getFileName().toString(), ".control-rollback"); + try { + try (SeekableByteChannel source = Files.newByteChannel(backup, + Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS))) { + Files.copy(Channels.newInputStream(source), stage, StandardCopyOption.REPLACE_EXISTING); + } + if (!java.util.Arrays.equals(controlInstalledSnapshot, Files.readAllBytes(target))) { + throw new StaleControlRevisionException(); + } + try { + atomicReplace(stage, target); + } catch (DurableFiles.PublishedException published) { + controlInstalledSnapshot = null; + throw published; + } + } finally { + Files.deleteIfExists(stage); + } + controlInstalledSnapshot = null; + loadControlConfiguration(); + } + + @Override + public synchronized void verifyControlProxyRoutingInstalled() throws IOException { + Path target = configurationFile.toPath(); + if (controlInstalledSnapshot == null + || !java.util.Arrays.equals(controlInstalledSnapshot, Files.readAllBytes(target))) { + throw new StaleControlRevisionException(); + } + } + + @Override + public synchronized byte[] captureControlProxyRoutingSnapshot() throws IOException { + return Files.readAllBytes(configurationFile.toPath()); + } + + @Override + public synchronized void verifyControlProxyRoutingSnapshot(byte[] snapshot) throws IOException { + if (!java.util.Arrays.equals(snapshot, Files.readAllBytes(configurationFile.toPath()))) { + throw new StaleControlRevisionException(); + } + } + + /** Loads the active file without the superclass's empty-config fallback. */ + public synchronized void loadControlConfiguration() throws IOException { + ConfigurationNode loaded = YamlConfigurationLoader.builder().path(configurationFile.toPath()).build().load(); + setConf(loaded); + } + + private static void atomicReplace(Path source, Path target) throws IOException { + try { + DurableFiles.forceFile(source); + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + DurableFiles.forceMoveDirectories(source, target); + } catch (java.nio.file.AtomicMoveNotSupportedException e) { + throw new IOException("Atomic Control configuration activation is unsupported", e); + } + } + + @Override + public boolean getControlEnabled() { + return getBoolean(getNode("Control", "Enabled"), false); + } + + @Override + public String getControlEndpoint() { + return getString(getNode("Control", "Endpoint"), "http://127.0.0.1:8080"); + } + + @Override + public String getControlNodeId() { + return getString(getNode("Control", "NodeId"), ""); + } + + @Override + public String getControlCredentialFile() { + return getString(getNode("Control", "CredentialFile"), "control-credential.txt"); + } + + @Override + public int getControlHeartbeatSeconds() { + return getInt(getNode("Control", "HeartbeatSeconds"), 30); + } + + @Override + public int getControlConnectTimeoutMillis() { + return getInt(getNode("Control", "ConnectTimeoutMillis"), 3000); + } + + @Override + public int getControlRequestTimeoutMillis() { + return getInt(getNode("Control", "RequestTimeoutMillis"), 5000); + } + + @Override + public boolean getControlHostedEnabled() { + return getBoolean(getNode("Control", "Hosted", "Enabled"), false); + } + + @Override + public boolean getControlHostedAutoDownload() { + return getBoolean(getNode("Control", "Hosted", "AutoDownload"), true); + } + + @Override + public boolean getControlHostedAutoUpdate() { + return getBoolean(getNode("Control", "Hosted", "AutoUpdate"), false); + } + + @Override + public String getControlHostedDownloadUrl() { + return getString(getNode("Control", "Hosted", "DownloadUrl"), ""); + } + + @Override + public String getControlHostedSha256() { + return getString(getNode("Control", "Hosted", "Sha256"), ""); + } + + @Override + public String getControlHostedJarFile() { + return getString(getNode("Control", "Hosted", "JarFile"), "control/votingplugin-control.jar"); + } + + @Override + public String getControlHostedDataDirectory() { + return getString(getNode("Control", "Hosted", "DataDirectory"), "control/data"); + } + + @Override + public String getControlHostedHost() { + return getString(getNode("Control", "Hosted", "Host"), "127.0.0.1"); + } + + @Override + public int getControlHostedPort() { + return getInt(getNode("Control", "Hosted", "Port"), 8080); + } + + @Override + public int getControlHostedStartupTimeoutSeconds() { + return getInt(getNode("Control", "Hosted", "StartupTimeoutSeconds"), 30); + } + + @Override + public int getControlHostedDownloadTimeoutSeconds() { + return getInt(getNode("Control", "Hosted", "DownloadTimeoutSeconds"), 60); } @Override diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java index a8ebda1084..09d33ed750 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java @@ -350,13 +350,7 @@ public void onProxyInitialization(ProxyInitializeEvent event) { CommandMeta meta = server.getCommandManager().metaBuilder("votingpluginproxy").aliases("vpp").build(); server.getCommandManager().register(meta, new VotingPluginVelocityCommand(this)); - // create runtime - votingPluginProxy = createProxyRuntime(); - - // full init - reloadAllInternal(true); - - // version + // Load the embedded version before the shared runtime starts optional integrations. try { getVersionFile(); if (versionFile != null) { @@ -368,6 +362,12 @@ public void onProxyInitialization(ProxyInitializeEvent event) { } catch (Exception ignored) { } + // create runtime + votingPluginProxy = createProxyRuntime(); + + // full init + reloadAllInternal(true); + // metrics (same as your original, shortened) Metrics metrics = metricsFactory.make(this, 11547); metrics.addCustomChart(new SimplePie("bungee_method", () -> getConfig().getBungeeMethod().toString())); @@ -478,16 +478,31 @@ public void reloadAllInternal(boolean loadMysql) { } catch (Exception ignored) { } - // Shutdown old runtime completely (this stops old MySQL pool) + // Stop Control first; replacement must not overlap a retained hosted child/connector. try { if (votingPluginProxy != null) { - votingPluginProxy.onDisable(); + votingPluginProxy.prepareForRuntimeReplacement(); } - } catch (Exception ignored) { + } catch (Exception shutdownFailure) { + logger.error("Reload aborted because hosted Control did not stop safely", shutdownFailure); + try { + scheduleTasks(); + } catch (Exception taskFailure) { + shutdownFailure.addSuppressed(taskFailure); + } + reloading = false; + return; + } + // Later transport/cache failures must not leave the old runtime partially disabled. + try { + if (votingPluginProxy != null) votingPluginProxy.completeRuntimeReplacementShutdown(); + } catch (Exception cleanupFailure) { + logger.error("Old proxy runtime cleanup was incomplete; replacement will continue", cleanupFailure); } - // Recreate runtime - votingPluginProxy = createProxyRuntime(); + // Recreate the runtime only after the old connector has drained its result. + VotingPluginProxy replacementRuntime = createProxyRuntime(); + votingPluginProxy = replacementRuntime; // Init MySQL BEFORE calling proxy.load(...) try { @@ -577,11 +592,23 @@ public Set getAllAvailableServers() { return getAvailableAllServers(); } + @Override + public Set getAllConfiguredServers() { + Set configured = new HashSet<>(); + server.getAllServers().forEach(registered -> configured.add(registered.getServerInfo().getName())); + return configured; + } + @Override public VotingPluginProxyConfig getConfig() { return config; } + @Override + public String getProxyPlatform() { + return "VELOCITY"; + } + @Override public String getCurrentPlayerServer(String player) { if (server.getPlayer(player).isPresent()) { @@ -797,6 +824,12 @@ public void reloadCore(boolean mysql) { reloadAllInternal(mysql); } + @Override + public void reloadControlConfiguration() throws Exception { + config.loadControlConfiguration(); + reloadFromControl(); + } + @Override public ScheduledExecutorService getScheduler() { return timer; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BoundedHttpBodyHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BoundedHttpBodyHandler.java new file mode 100644 index 0000000000..218819c9dc --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BoundedHttpBodyHandler.java @@ -0,0 +1,103 @@ +package com.bencodez.votingplugin.util; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.http.HttpTimeoutException; +import java.net.http.HttpResponse; +import java.nio.ByteBuffer; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Flow; +import java.util.concurrent.TimeUnit; + +/** Completes only after the entire bounded HTTP body has been consumed. */ +public final class BoundedHttpBodyHandler implements HttpResponse.BodyHandler { + private final int maximumBytes; + private final Duration timeout; + + public BoundedHttpBodyHandler(int maximumBytes, Duration timeout) { + if (maximumBytes < 0) throw new IllegalArgumentException("maximumBytes must be non-negative"); + if (timeout == null || timeout.isNegative() || timeout.isZero()) + throw new IllegalArgumentException("timeout must be positive"); + this.maximumBytes = maximumBytes; + this.timeout = timeout; + } + + @Override + public HttpResponse.BodySubscriber apply(HttpResponse.ResponseInfo responseInfo) { + return new Subscriber(maximumBytes, timeout); + } + + private static final class Subscriber implements HttpResponse.BodySubscriber { + private final int maximumBytes; + private final ByteArrayOutputStream bytes; + private final CompletableFuture result = new CompletableFuture<>(); + private final long timeoutNanos; + private Flow.Subscription subscription; + + private Subscriber(int maximumBytes, Duration timeout) { + this.maximumBytes = maximumBytes; + this.bytes = new ByteArrayOutputStream(Math.min(maximumBytes, 8192)); + this.timeoutNanos = timeout.toNanos(); + } + + @Override + public CompletionStage getBody() { + return result; + } + + @Override + public void onSubscribe(Flow.Subscription selected) { + if (subscription != null) { + selected.cancel(); + return; + } + subscription = selected; + Thread timeoutThread = new Thread(() -> { + try { + TimeUnit.NANOSECONDS.sleep(timeoutNanos); + if (!result.isDone()) { + selected.cancel(); + result.completeExceptionally(new HttpTimeoutException("HTTP response body timed out")); + } + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + }, "VotingPlugin-Control-Response-Timeout"); + timeoutThread.setDaemon(true); + timeoutThread.setContextClassLoader(ClassLoader.getPlatformClassLoader()); + result.whenComplete((ignored, failure) -> timeoutThread.interrupt()); + timeoutThread.start(); + selected.request(1); + } + + @Override + public void onNext(List items) { + if (result.isDone()) return; + for (ByteBuffer item : items) { + int count = item.remaining(); + if (count > maximumBytes - bytes.size()) { + subscription.cancel(); + result.completeExceptionally(new IOException("HTTP response body exceeds the limit")); + return; + } + byte[] chunk = new byte[count]; + item.get(chunk); + bytes.write(chunk, 0, chunk.length); + } + subscription.request(1); + } + + @Override + public void onError(Throwable failure) { + result.completeExceptionally(failure); + } + + @Override + public void onComplete() { + result.complete(bytes.toByteArray()); + } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ControlCredentialFile.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ControlCredentialFile.java new file mode 100644 index 0000000000..9f54c6620f --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ControlCredentialFile.java @@ -0,0 +1,52 @@ +package com.bencodez.votingplugin.util; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.nio.channels.SeekableByteChannel; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; + +/** Symlink-safe, bounded credential loading shared by proxy and Bukkit Control connectors. */ +public final class ControlCredentialFile { + private static final int MAX_BYTES = 512; + + private ControlCredentialFile() { } + + public static String read(Path rootDirectory, String configuredPath) throws IOException { + if (configuredPath == null || configuredPath.isBlank()) throw invalid(); + Path lexicalRoot = rootDirectory.toAbsolutePath().normalize(); + Path requested = lexicalRoot.resolve(configuredPath).normalize(); + if (!requested.startsWith(lexicalRoot) || requested.getParent() == null) throw invalid(); + Path realRoot = lexicalRoot.toRealPath(); + Path realParent = requested.getParent().toRealPath(); + if (!realParent.startsWith(realRoot)) throw invalid(); + Path target = realParent.resolve(requested.getFileName()); + if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) throw invalid(); + + ByteBuffer bytes = ByteBuffer.allocate(MAX_BYTES + 1); + try (SeekableByteChannel channel = Files.newByteChannel(target, StandardOpenOption.READ, + LinkOption.NOFOLLOW_LINKS)) { + while (bytes.hasRemaining() && channel.read(bytes) > 0) { } + } + if (bytes.position() == 0 || bytes.position() > MAX_BYTES) throw invalid(); + bytes.flip(); + String credential; + try { + credential = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT).decode(bytes).toString().trim(); + } catch (java.nio.charset.CharacterCodingException e) { + throw invalid(); + } + if (credential.isEmpty() || credential.length() > MAX_BYTES || credential.indexOf('\r') >= 0 + || credential.indexOf('\n') >= 0) throw invalid(); + return credential; + } + + private static IOException invalid() { + return new IOException("Control credential file is missing or invalid"); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/DurableFiles.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/DurableFiles.java new file mode 100644 index 0000000000..b57fdb72fd --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/DurableFiles.java @@ -0,0 +1,65 @@ +package com.bencodez.votingplugin.util; + +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.file.AccessDeniedException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Locale; + +/** Cross-platform helpers for forcing file contents and published directory entries. */ +public final class DurableFiles { + private DurableFiles() { } + + public static void forceFile(Path file) throws IOException { + try (FileChannel channel = FileChannel.open(file, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) { + channel.force(true); + } + } + + public static void forceDirectory(Path directory) throws IOException { + if (directory == null) return; + try { + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } + } catch (AccessDeniedException unsupportedDirectoryHandle) { + // The Windows NIO provider cannot open directory handles. File contents are + // still forced before atomic publication; do not make persistence unusable. + if (!isWindowsName(System.getProperty("os.name", ""))) throw unsupportedDirectoryHandle; + } catch (UnsupportedOperationException unsupportedDirectoryForce) { + // Some providers support atomic moves but expose no directory-force operation. + } + } + + public static boolean deleteIfExists(Path target) throws IOException { + boolean deleted = Files.deleteIfExists(target); + if (deleted) forceDirectory(target.toAbsolutePath().normalize().getParent()); + return deleted; + } + + public static void forceMoveDirectories(Path source, Path target) throws IOException { + try { + Path sourceParent = source.toAbsolutePath().normalize().getParent(); + Path targetParent = target.toAbsolutePath().normalize().getParent(); + forceDirectory(targetParent); + if (sourceParent != null && !sourceParent.equals(targetParent)) forceDirectory(sourceParent); + } catch (IOException failure) { + throw new PublishedException(failure); + } + } + + public static boolean isWindowsName(String name) { + return name != null && name.trim().toLowerCase(Locale.ROOT).startsWith("windows"); + } + + /** Indicates that an atomic rename completed before metadata writeback failed. */ + @SuppressWarnings("serial") + public static final class PublishedException extends IOException { + public PublishedException(IOException cause) { + super("File was published but its directory metadata could not be forced", cause); + } + } +} diff --git a/VotingPlugin/src/main/resources/Config.yml b/VotingPlugin/src/main/resources/Config.yml index 3f6f7b2eca..b39337e1f8 100644 --- a/VotingPlugin/src/main/resources/Config.yml +++ b/VotingPlugin/src/main/resources/Config.yml @@ -1158,6 +1158,24 @@ Webhooks: Secret: "change-me" ########################################### +# VotingPlugin Control backend connector +# Optional outbound connection for full local WebUI configuration control. +# Create a node credential with Control's `enroll` owner command and place it +# in the VotingPlugin data folder. The credential is never stored in this YAML. +########################################### + +Control: + Backend: + Enabled: false + # Must be unique for every Bukkit/Paper backend in the network. + NodeId: '' + Endpoint: 'http://127.0.0.1:8080' + CredentialFile: 'control-credential.txt' + HeartbeatSeconds: 30 + ConnectTimeoutMillis: 3000 + RequestTimeoutMillis: 10000 + +########################################### # Additional / Advanced Settings # Rarely needed options for special setups ########################################### diff --git a/VotingPlugin/src/main/resources/bungeeconfig.yml b/VotingPlugin/src/main/resources/bungeeconfig.yml index 99a7f61494..777ae3ffa8 100644 --- a/VotingPlugin/src/main/resources/bungeeconfig.yml +++ b/VotingPlugin/src/main/resources/bungeeconfig.yml @@ -480,3 +480,32 @@ MultiProxyServers: secondproxy: Host: 0.0.0.0 Port: 1235 +# Optional local-first VotingPlugin Control discovery. Missing keys preserve the disabled default. +Control: + Enabled: false + Endpoint: 'http://127.0.0.1:8080' + # Blank reuses ProxyServerName. This must match the identity enrolled in Control. + NodeId: '' + # Relative to the VotingPlugin proxy data folder. Put only the enrollment credential in this file. + CredentialFile: 'control-credential.txt' + HeartbeatSeconds: 30 + ConnectTimeoutMillis: 3000 + RequestTimeoutMillis: 5000 + # Optionally let this proxy provision and supervise VotingPlugin Control as a separate JVM. + # The WebUI/API remains isolated from the proxy and vote processing. + Hosted: + Enabled: false + # Download a missing artifact. An exact SHA-256 pin is always required. + AutoDownload: true + # Disabled by default. When enabled, a new configured SHA-256 may replace the installed artifact. + AutoUpdate: false + # Use a versioned release asset URL; /latest/ URLs are rejected. + DownloadUrl: '' + Sha256: '' + # Both paths must remain relative to the VotingPlugin data folder. + JarFile: 'control/votingplugin-control.jar' + DataDirectory: 'control/data' + Host: '127.0.0.1' + Port: 8080 + StartupTimeoutSeconds: 30 + DownloadTimeoutSeconds: 60 diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandlerLifecycleTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandlerLifecycleTest.java new file mode 100644 index 0000000000..b436fadd54 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandlerLifecycleTest.java @@ -0,0 +1,137 @@ +package com.bencodez.votingplugin.backendproxy; + +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import java.lang.reflect.Field; +import java.util.concurrent.ScheduledExecutorService; + +import org.junit.jupiter.api.Test; + +import com.bencodez.simpleapi.servercomm.sockets.SocketHandler; +import com.bencodez.simpleapi.servercomm.mqtt.MqttHandler; +import com.bencodez.simpleapi.servercomm.mysql.MySqlMessenger; +import com.bencodez.simpleapi.servercomm.redis.RedisHandler; +import com.bencodez.votingplugin.backendproxy.global.BackendGlobalDataSync; +import com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache; +import com.bencodez.votingplugin.backendproxy.transport.MqttBackendProxyTransport; +import com.bencodez.votingplugin.backendproxy.transport.MysqlBackendProxyTransport; +import com.bencodez.votingplugin.backendproxy.transport.BackendProxyTransport; +import com.bencodez.votingplugin.backendproxy.transport.BackendProxyTransportManager; +import com.bencodez.votingplugin.backendproxy.transport.RedisBackendProxyTransport; +import com.bencodez.votingplugin.backendproxy.transport.SocketBackendProxyTransport; +import com.bencodez.votingplugin.proxy.BungeeMethod; + +class BackendProxyHandlerLifecycleTest { + + @Test + void sharesVoteDeduplicationAcrossHandlerReplacement() { + ProcessedVoteCache cache = new ProcessedVoteCache(); + BackendProxyHandler previous = new BackendProxyHandler(null, cache); + BackendProxyHandler replacement = new BackendProxyHandler(null, cache); + + assertSame(previous.getProcessedWireVotes(), replacement.getProcessedWireVotes()); + } + + @Test + void keepsPluginMessageRelayActiveUntilAtomicTargetSwap() throws Exception { + BackendProxyHandler handler = new BackendProxyHandler(null); + Field method = BackendProxyHandler.class.getDeclaredField("method"); + method.setAccessible(true); + method.set(handler, BungeeMethod.PLUGINMESSAGING); + Field managerField = BackendProxyHandler.class.getDeclaredField("transportManager"); + managerField.setAccessible(true); + BackendProxyTransportManager manager = (BackendProxyTransportManager) managerField.get(handler); + BackendProxyTransport transport = mock(BackendProxyTransport.class); + Field transportField = BackendProxyTransportManager.class.getDeclaredField("transport"); + transportField.setAccessible(true); + transportField.set(manager, transport); + + handler.prepareForReplacement(BungeeMethod.PLUGINMESSAGING); + + verifyNoInteractions(transport); + } + + @Test + void releasesSocketListenerBeforeSamePortReplacement() throws Exception { + SocketBackendProxyTransport handler = new SocketBackendProxyTransport(null); + SocketHandler socket = mock(SocketHandler.class); + Field field = SocketBackendProxyTransport.class.getDeclaredField("socketHandler"); + field.setAccessible(true); + field.set(handler, socket); + + handler.prepareForReplacement(); + + verify(socket).closeConnection(); + assertNull(handler.getSocketHandler()); + } + + @Test + void keepsRedisSubscriberUntilReplacementIsReady() throws Exception { + BackendProxyHandler handler = new BackendProxyHandler(null); + Field method = BackendProxyHandler.class.getDeclaredField("method"); + method.setAccessible(true); + method.set(handler, BungeeMethod.REDIS); + Field managerField = BackendProxyHandler.class.getDeclaredField("transportManager"); + managerField.setAccessible(true); + BackendProxyTransportManager manager = (BackendProxyTransportManager) managerField.get(handler); + RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null); + RedisHandler redis = mock(RedisHandler.class); + Field field = RedisBackendProxyTransport.class.getDeclaredField("redisHandler"); + field.setAccessible(true); + field.set(transport, redis); + Field transportField = BackendProxyTransportManager.class.getDeclaredField("transport"); + transportField.setAccessible(true); + transportField.set(manager, transport); + + handler.prepareForReplacement(BungeeMethod.REDIS); + + verifyNoInteractions(redis); + assertSame(redis, handler.getRedisHandler()); + } + + @Test + void releasesMqttSubscriberBeforeSameMethodReplacement() throws Exception { + MqttBackendProxyTransport handler = new MqttBackendProxyTransport(null); + MqttHandler mqtt = mock(MqttHandler.class); + Field field = MqttBackendProxyTransport.class.getDeclaredField("mqttHandler"); + field.setAccessible(true); + field.set(handler, mqtt); + + handler.prepareForReplacement(); + + verify(mqtt).disconnect(); + assertNull(handler.getMqttHandler()); + } + + @Test + void releasesMysqlSubscriberBeforeSameMethodReplacement() throws Exception { + MysqlBackendProxyTransport handler = new MysqlBackendProxyTransport(null); + MySqlMessenger messenger = mock(MySqlMessenger.class); + Field field = MysqlBackendProxyTransport.class.getDeclaredField("messenger"); + field.setAccessible(true); + field.set(handler, messenger); + + handler.prepareForReplacement(); + + verify(messenger).shutdown(); + assertNull(handler.getMessenger()); + } + + @Test + void stopsGlobalDataTimerWhenHandlerIsReplaced() throws Exception { + BackendGlobalDataSync handler = new BackendGlobalDataSync(null, null); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + Field field = BackendGlobalDataSync.class.getDeclaredField("timer"); + field.setAccessible(true); + field.set(handler, timer); + + handler.close(); + + verify(timer).shutdownNow(); + assertNull(handler.getTimer()); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/transport/RedisBackendProxyTransportTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/transport/RedisBackendProxyTransportTest.java new file mode 100644 index 0000000000..fc97bb8347 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/transport/RedisBackendProxyTransportTest.java @@ -0,0 +1,68 @@ +package com.bencodez.votingplugin.backendproxy.transport; + +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 static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.lang.reflect.Field; + +import org.junit.jupiter.api.Test; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.simpleapi.servercomm.global.GlobalMessageHandler; +import com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache; +import com.bencodez.votingplugin.proxy.VotingPluginWire; + +import redis.clients.jedis.DefaultJedisClientConfig; + +class RedisBackendProxyTransportTest { + + @Test + void validationHonorsTlsAndHostnameVerification() { + DefaultJedisClientConfig config = RedisBackendProxyTransport.buildValidationClientConfig(3, "user", "secret", + true); + + assertTrue(config.isSsl()); + assertEquals(3, config.getDatabase()); + assertEquals("HTTPS", config.getSslParameters().getEndpointIdentificationAlgorithm()); + } + + @Test + void validationKeepsTlsDisabledByDefault() { + assertFalse(RedisBackendProxyTransport.buildValidationClientConfig(0, null, null, false).isSsl()); + } + + @Test + void legacyHandoffBufferDegradesAtItsFixedLimit() throws Exception { + RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, new ProcessedVoteCache()); + GlobalMessageHandler messages = mock(GlobalMessageHandler.class); + Field handler = RedisBackendProxyTransport.class.getDeclaredField("messageHandler"); + handler.setAccessible(true); + handler.set(transport, messages); + JsonEnvelope envelope = JsonEnvelope.builder(VotingPluginWire.SUB_VOTE_UPDATE).build(); + + for (int index = 0; index <= RedisBackendProxyTransport.MAX_LEGACY_HANDOFF_DELIVERIES; index++) { + transport.dispatchLegacy(envelope); + } + + verify(messages, times(1)).onMessage(envelope); + } + + @Test + void legacyHandoffBufferDegradesForAnOversizedEnvelope() throws Exception { + RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, new ProcessedVoteCache()); + GlobalMessageHandler messages = mock(GlobalMessageHandler.class); + Field handler = RedisBackendProxyTransport.class.getDeclaredField("messageHandler"); + handler.setAccessible(true); + handler.set(transport, messages); + JsonEnvelope envelope = JsonEnvelope.builder(VotingPluginWire.SUB_VOTE_UPDATE) + .put("payload", "x".repeat(ProcessedVoteCache.MAX_LEGACY_REDIS_DELIVERY_BYTES + 1)).build(); + + transport.dispatchLegacy(envelope); + + verify(messages).onMessage(envelope); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java new file mode 100644 index 0000000000..80871f58fe --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java @@ -0,0 +1,249 @@ +package com.bencodez.votingplugin.control; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.votingplugin.util.DurableFiles; + +class BackendConfigurationServiceTest { + @TempDir Path directory; + + @Test void masksSecretsPreservesThemAndAppliesARevisionedReload() throws Exception { + Path config = directory.resolve("Config.yml"); + Files.writeString(config, "Database:\n Password: keep-me\nFeature: false\n"); + AtomicInteger reloads = new AtomicInteger(); + BackendConfigurationService service = new BackendConfigurationService(directory, reloads::incrementAndGet); + + BackendConfigurationService.Document read = service.read("Config.yml"); + assertFalse(read.content().contains("keep-me")); + assertTrue(read.content().contains(BackendConfigurationService.REDACTED)); + String proposal = read.content().replace("Feature: false", "Feature: true"); + BackendConfigurationService.Preview preview = service.preview("Config.yml", proposal); + assertTrue(preview.changes().contains("changed Feature")); + + BackendConfigurationService.ApplyResult result = service.apply("Config.yml", proposal, read.revision()); + assertEquals(1, reloads.get()); + YamlConfiguration applied = new YamlConfiguration(); + applied.loadFromString(Files.readString(config)); + assertEquals("keep-me", applied.getString("Database.Password")); + assertTrue(applied.getBoolean("Feature")); + assertFalse(result.document().content().contains("keep-me")); + assertTrue(Files.isRegularFile(directory.resolve("Config.yml.control-backup"))); + } + + @Test void rejectsStaleInvalidAndUnmanagedWrites() throws Exception { + Files.writeString(directory.resolve("Config.yml"), "Feature: false\n"); + BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); + String revision = service.read("Config.yml").revision(); + Files.writeString(directory.resolve("Config.yml"), "Feature: manual\n"); + assertThrows(BackendConfigurationService.StaleRevisionException.class, + () -> service.apply("Config.yml", "Feature: true\n", revision)); + assertThrows(IllegalArgumentException.class, () -> service.preview("../server.properties", "x: y")); + assertThrows(IllegalArgumentException.class, () -> service.preview("Config.yml", "bad: [yaml")); + } + + @Test void rejectsManagedFileAndDirectorySymlinkEscapes() throws Exception { + Path root = Files.createDirectory(directory.resolve("plugin-data")); + Path outside = Files.createDirectory(directory.resolve("outside")); + Path externalConfig = outside.resolve("Config.yml"); + Files.writeString(externalConfig, "Feature: external\n"); + Files.createSymbolicLink(root.resolve("Config.yml"), externalConfig); + BackendConfigurationService service = new BackendConfigurationService(root, () -> { }); + + assertThrows(java.io.IOException.class, () -> service.read("Config.yml")); + Files.createDirectory(outside.resolve("sites")); + Files.writeString(outside.resolve("sites/External.yml"), "VoteSites: {}\n"); + Files.createSymbolicLink(root.resolve("VoteSites"), outside.resolve("sites")); + assertThrows(java.io.IOException.class, () -> service.read("VoteSites/External.yml")); + } + + @Test void rejectsSymlinkedBackupSidecarsWithoutTouchingTheirTargets() throws Exception { + Path config = directory.resolve("Config.yml"); + Path external = directory.resolve("external-backup.yml"); + Files.writeString(config, "Feature: false\n"); + Files.writeString(external, "external: preserved\n"); + Files.createSymbolicLink(directory.resolve("Config.yml.control-backup"), external); + BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); + String revision = service.read("Config.yml").revision(); + + assertThrows(java.io.IOException.class, + () -> service.apply("Config.yml", "Feature: true\n", revision)); + assertEquals("external: preserved\n", Files.readString(external)); + assertEquals("Feature: false\n", Files.readString(config)); + } + + @Test void transportValidationFailureRollsBackBeforeApplyReturns() throws Exception { + Path settings = directory.resolve("BungeeSettings.yml"); + Files.writeString(settings, "UseBungeecord: false\nBungeeMethod: PLUGINMESSAGING\n"); + AtomicInteger reloads = new AtomicInteger(); + BackendConfigurationService service = new BackendConfigurationService(directory, + (BackendConfigurationService.ApplyAction) fileName -> { + assertEquals("BungeeSettings.yml", fileName); + if (reloads.incrementAndGet() == 1) throw new IllegalStateException("transport unavailable"); + }); + BackendConfigurationService.Document before = service.read("BungeeSettings.yml"); + BackendConfigurationService.ApplyFailureException failure = assertThrows( + BackendConfigurationService.ApplyFailureException.class, + () -> service.apply("BungeeSettings.yml", + "UseBungeecord: true\nBungeeMethod: MQTT\n", before.revision())); + + assertTrue(failure.rolledBack()); + assertEquals(2, reloads.get()); + assertTrue(service.read("BungeeSettings.yml").content().contains("PLUGINMESSAGING")); + } + + @Test void publishedRollbackStillReloadsTheRestoredConfiguration() throws Exception { + Path config = directory.resolve("Config.yml"); + Files.writeString(config, "Feature: before\n"); + AtomicInteger reloads = new AtomicInteger(); + AtomicInteger moves = new AtomicInteger(); + BackendConfigurationService service = new BackendConfigurationService(directory, + (BackendConfigurationService.ApplyAction) fileName -> { + if (reloads.incrementAndGet() == 1) throw new IOException("reload failed"); + }, (source, target) -> { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + if (moves.incrementAndGet() == 3) { + throw new DurableFiles.PublishedException(new IOException("directory force failed")); + } + }); + BackendConfigurationService.Document before = service.read("Config.yml"); + + BackendConfigurationService.ApplyFailureException failure = assertThrows( + BackendConfigurationService.ApplyFailureException.class, + () -> service.apply("Config.yml", "Feature: proposed\n", before.revision())); + + assertTrue(failure.rolledBack()); + assertEquals(2, reloads.get()); + assertEquals("Feature: before\n", Files.readString(config)); + assertTrue(java.util.Arrays.stream(failure.getCause().getSuppressed()) + .anyMatch(DurableFiles.PublishedException.class::isInstance)); + } + + @Test void failedReloadDoesNotOverwriteAConcurrentManualEdit() throws Exception { + Path config = directory.resolve("Config.yml"); + Files.writeString(config, "Feature: before\n"); + AtomicInteger reloads = new AtomicInteger(); + BackendConfigurationService service = new BackendConfigurationService(directory, + (BackendConfigurationService.ApplyAction) fileName -> { + reloads.incrementAndGet(); + Files.writeString(config, "Feature: manual\n"); + throw new IllegalStateException("reload failed"); + }); + BackendConfigurationService.Document before = service.read("Config.yml"); + + BackendConfigurationService.ApplyFailureException failure = assertThrows( + BackendConfigurationService.ApplyFailureException.class, + () -> service.apply("Config.yml", "Feature: proposed\n", before.revision())); + + assertFalse(failure.rolledBack()); + assertEquals(1, reloads.get()); + assertEquals("Feature: manual\n", Files.readString(config)); + } + + @Test void successfulReloadDoesNotReportAConcurrentManualEditAsApplied() throws Exception { + Path config = directory.resolve("Config.yml"); + Files.writeString(config, "Feature: before\n"); + AtomicInteger reloads = new AtomicInteger(); + BackendConfigurationService service = new BackendConfigurationService(directory, + (BackendConfigurationService.ApplyAction) fileName -> { + if (reloads.incrementAndGet() == 1) Files.writeString(config, "Feature: manual\n"); + }); + BackendConfigurationService.Document before = service.read("Config.yml"); + + assertThrows(BackendConfigurationService.StaleRevisionException.class, + () -> service.apply("Config.yml", "Feature: proposed\n", before.revision())); + assertEquals(2, reloads.get()); + assertEquals("Feature: manual\n", Files.readString(config)); + } + + @Test void reconciliationReloadRetriesWhenAnotherManualEditRacesIt() throws Exception { + Path config = directory.resolve("Config.yml"); + Files.writeString(config, "Feature: before\n"); + AtomicInteger reloads = new AtomicInteger(); + BackendConfigurationService service = new BackendConfigurationService(directory, + (BackendConfigurationService.ApplyAction) fileName -> { + int reload = reloads.incrementAndGet(); + if (reload <= 2) Files.writeString(config, "Feature: manual-" + reload + "\n"); + }); + BackendConfigurationService.Document before = service.read("Config.yml"); + + assertThrows(BackendConfigurationService.StaleRevisionException.class, + () -> service.apply("Config.yml", "Feature: proposed\n", before.revision())); + assertEquals(3, reloads.get()); + assertEquals("Feature: manual-2\n", Files.readString(config)); + } + + @Test void quickSetupsProduceReviewableBackendAndVoteSiteChanges() throws Exception { + Files.writeString(directory.resolve("BungeeSettings.yml"), "UseBungeecord: false\nServer: PleaseSet\n"); + Files.writeString(directory.resolve("VoteSites.yml"), "VoteSites: {}\n"); + BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); + + BackendConfigurationService.QuickPreview proxy = service.previewQuickSetup("proxy-backend", + Map.of("server", "lobby", "method", "PLUGINMESSAGING")); + assertTrue(proxy.proposal().content().contains("UseBungeecord: true")); + assertTrue(proxy.proposal().content().contains("Server: lobby")); + + BackendConfigurationService.QuickPreview site = service.previewQuickSetup("vote-site", Map.of( + "name", "PMC", "serviceSite", "PlanetMinecraft.com", "voteUrl", "https://example.com/vote", + "voteDelay", "24h")); + assertTrue(site.proposal().content().contains("PMC:")); + assertTrue(site.proposal().content().contains("PlanetMinecraft.com")); + + BackendConfigurationService.QuickPreview reward = service.previewQuickSetup("easy-reward", Map.of( + "scope", "site", "name", "PMC", "command", "eco give %player% 100", + "message", "&aThanks for voting!")); + assertTrue(reward.proposal().content().contains("eco give %player% 100")); + + Files.writeString(directory.resolve("Config.yml"), "ProcessRewards: false\nAutoCreateVoteSites: false\n" + + "ExtraAllSitesCheck: false\nCountFakeVotes: false\nDisableNoServiceSiteMessage: false\n" + + "DisableUpdateChecking: false\n"); + BackendConfigurationService.QuickPreview common = service.previewQuickSetup("common-settings", Map.of( + "processRewards", "true", "autoCreateVoteSites", "true", "extraAllSitesCheck", "true", + "countFakeVotes", "true", "disableNoServiceSiteMessage", "false", + "disableUpdateChecking", "false")); + assertTrue(common.proposal().content().contains("ExtraAllSitesCheck: true")); + + Files.writeString(directory.resolve("SpecialRewards.yml"), "VoteParty:\n Enabled: false\n"); + BackendConfigurationService.QuickPreview party = service.previewQuickSetup("vote-party", Map.of( + "votesRequired", "25", "command", "give %player% diamond 1", "broadcast", "Party!", + "giveAllPlayers", "false", "onlineOnly", "true")); + assertTrue(party.proposal().content().contains("VotesRequired: 25")); + } + + @Test void proxyBackendQuickSetupRejectsUnknownTransportMethods() throws Exception { + Files.writeString(directory.resolve("BungeeSettings.yml"), + "UseBungeecord: false\nServer: PleaseSet\nBungeeMethod: PLUGINMESSAGING\n"); + BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); + + assertThrows(IllegalArgumentException.class, () -> service.previewQuickSetup("proxy-backend", + Map.of("server", "lobby", "method", "NOT_A_TRANSPORT"))); + BackendConfigurationService.QuickPreview mqtt = service.previewQuickSetup("proxy-backend", + Map.of("server", "lobby", "method", "mqtt")); + assertTrue(mqtt.proposal().content().contains("BungeeMethod: MQTT")); + } + + @Test void fullBungeeSettingsRejectsUnknownTransportMethods() throws Exception { + Files.writeString(directory.resolve("BungeeSettings.yml"), + "UseBungeecord: true\nServer: lobby\nBungeeMethod: PLUGINMESSAGING\n"); + BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); + + assertThrows(IllegalArgumentException.class, () -> service.preview("BungeeSettings.yml", + "UseBungeecord: true\nServer: lobby\nBungeeMethod: NOT_A_TRANSPORT\n")); + BackendConfigurationService.Preview mqtt = service.preview("BungeeSettings.yml", + "UseBungeecord: true\nServer: lobby\nBungeeMethod: mqtt\n"); + assertTrue(mqtt.resolvedContent().contains("BungeeMethod: MQTT")); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java new file mode 100644 index 0000000000..2be4bd6656 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java @@ -0,0 +1,85 @@ +package com.bencodez.votingplugin.control; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.Test; + +import com.bencodez.votingplugin.control.BackendControlResultStore.StoredResult; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +class BackendControlConnectorProtocolTest { + @Test void onlyTheLeaseExpiryConflictRequestsAResultReclaim() { + assertTrue(BackendControlConnector.taskLeaseExpired(new BackendControlConnector.Response(409, + "{\"error\":{\"code\":\"TASK_LEASE_EXPIRED\"}}"))); + assertFalse(BackendControlConnector.taskLeaseExpired(new BackendControlConnector.Response(409, + "{\"error\":{\"code\":\"SESSION_MISMATCH\"}}"))); + assertFalse(BackendControlConnector.taskLeaseExpired(new BackendControlConnector.Response(500, "not-json"))); + assertTrue(BackendControlConnector.operationNotFound(new BackendControlConnector.Response(404, + "{\"error\":{\"code\":\"OPERATION_NOT_FOUND\"}}"))); + assertFalse(BackendControlConnector.operationNotFound(new BackendControlConnector.Response(404, + "{\"error\":{\"code\":\"NODE_NOT_FOUND\"}}"))); + } + + @Test void abandonedBackendIntentBecomesATerminalRecoveryResult() { + JsonObject anticipated = new JsonObject(); + anticipated.addProperty("attemptId", "00000000-0000-0000-0000-000000000199"); + + StoredResult recovered = BackendControlConnector.abortedIntent( + new StoredResult(anticipated, true, false, false)); + + assertTrue(recovered.committed()); + assertFalse(recovered.claimRequired()); + assertFalse(recovered.restartConnector()); + assertTrue("RECOVERY_ABORTED".equals(recovered.result().get("code").getAsString())); + } + + @Test void registrationRequiresFileControlButAllowsQuickSetupToRemainOptional() { + assertThrows(RuntimeException.class, () -> BackendControlConnector.requireFileCapability(false)); + assertDoesNotThrow(() -> BackendControlConnector.requireFileCapability(true)); + } + + @Test void heartbeatRetainsOmittedCapabilitiesAndHonorsExplicitReplacement() { + JsonObject omitted = new JsonObject(); + assertTrue(BackendControlConnector.negotiatedCapability(omitted, "config.files.v1", true)); + + JsonObject explicit = new JsonObject(); + JsonArray accepted = new JsonArray(); + accepted.add("config.quick-setup.v1"); + explicit.add("acceptedCapabilities", accepted); + assertFalse(BackendControlConnector.negotiatedCapability(explicit, "config.files.v1", true)); + assertTrue(BackendControlConnector.negotiatedCapability(explicit, "config.quick-setup.v1", false)); + } + + @Test void shutdownWaitsForTheClaimedBackendOperation() throws Exception { + var executor = Executors.newSingleThreadScheduledExecutor(); + CompletableFuture operation = new CompletableFuture<>(); + CompletableFuture closing = CompletableFuture.runAsync( + () -> BackendControlConnector.awaitShutdown(executor, operation)); + + assertThrows(java.util.concurrent.TimeoutException.class, () -> closing.get(100, TimeUnit.MILLISECONDS)); + operation.complete(null); + closing.get(2, TimeUnit.SECONDS); + assertTrue(executor.isTerminated()); + } + + @Test void failedResultAcknowledgementDoesNotTriggerConnectorHandoff() throws Exception { + AtomicBoolean handedOff = new AtomicBoolean(); + assertThrows(IOException.class, () -> BackendControlConnector.afterResultAcknowledged( + () -> { throw new IOException("Control result was not acknowledged"); }, + () -> handedOff.set(true))); + assertFalse(handedOff.get()); + + BackendControlConnector.afterResultAcknowledged(() -> { }, () -> handedOff.set(true)); + assertTrue(handedOff.get()); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlResultStoreTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlResultStoreTest.java new file mode 100644 index 0000000000..bdb10fde88 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlResultStoreTest.java @@ -0,0 +1,87 @@ +package com.bencodez.votingplugin.control; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.votingplugin.control.BackendControlResultStore.Route; +import com.bencodez.votingplugin.control.BackendControlResultStore.StoredResult; +import com.google.gson.JsonObject; + +class BackendControlResultStoreTest { + @TempDir Path directory; + + @Test void pendingResultAndOriginalRouteSurviveRestartUntilAcknowledged() throws Exception { + UUID operationId = UUID.fromString("00000000-0000-0000-0000-000000000099"); + Route route = new Route("backend-old", URI.create("https://control.example:8443"), "old-credential.txt", + 30, 3000, 10000); + JsonObject result = new JsonObject(); + result.addProperty("success", true); + result.addProperty("revision", "applied-revision"); + Map pending = new LinkedHashMap<>(); + pending.put(operationId, new StoredResult(result, true, true, false)); + + BackendControlResultStore.save(directory, route, pending); + BackendControlResultStore.State recovered = BackendControlResultStore.load(directory); + + assertEquals(route, recovered.route()); + assertEquals("applied-revision", recovered.results().get(operationId).result().get("revision").getAsString()); + assertTrue(recovered.results().get(operationId).committed()); + BackendControlResultStore.save(directory, route, Map.of()); + assertFalse(Files.exists(directory.resolve(".control-pending-results.json"))); + } + + @Test void writeAheadIntentRetainsItsUncommittedStateAcrossRestart() throws Exception { + UUID operationId = UUID.fromString("00000000-0000-0000-0000-000000000099"); + Route route = new Route("backend-old", URI.create("https://control.example:8443"), "old-credential.txt", + 30, 3000, 10000); + JsonObject result = new JsonObject(); + result.addProperty("revision", "anticipated-revision"); + + BackendControlResultStore.save(directory, route, + Map.of(operationId, new StoredResult(result, false, false, false))); + + StoredResult recovered = BackendControlResultStore.load(directory).results().get(operationId); + assertFalse(recovered.committed()); + assertFalse(recovered.claimRequired()); + } + + @Test void symbolicPendingResultJournalIsRejected() throws Exception { + Path external = directory.resolve("external.json"); + Files.writeString(external, "{}"); + Files.createSymbolicLink(directory.resolve(".control-pending-results.json"), external); + + assertThrows(java.io.IOException.class, () -> BackendControlResultStore.load(directory)); + } + + @Test void maximumEscapeHeavyConfigurationFitsTheEncodedJournal() throws Exception { + Route route = new Route("backend-old", URI.create("https://control.example:8443"), "credential.txt", + 30, 3000, 10000); + JsonObject configuration = new JsonObject(); + configuration.addProperty("domain", "file"); + configuration.addProperty("fileName", "Config.yml"); + configuration.addProperty("content", "\\".repeat(BackendConfigurationService.MAX_CONTENT_BYTES)); + JsonObject result = new JsonObject(); + result.addProperty("success", true); + result.add("configuration", configuration); + + BackendControlResultStore.save(directory, route, + Map.of(UUID.fromString("00000000-0000-0000-0000-000000000099"), + new StoredResult(result, false, true, false))); + + assertEquals(BackendConfigurationService.MAX_CONTENT_BYTES, + BackendControlResultStore.load(directory).results().values().iterator().next().result() + .getAsJsonObject("configuration").get("content").getAsString().length()); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/VotingPluginProxyLifecycleTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/VotingPluginProxyLifecycleTest.java new file mode 100644 index 0000000000..32b68ac8eb --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/VotingPluginProxyLifecycleTest.java @@ -0,0 +1,145 @@ +package com.bencodez.votingplugin.proxy; + +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.simpleapi.servercomm.sockets.ClientHandler; +import com.bencodez.votingplugin.tests.VotingPluginProxyTestImpl; +import com.bencodez.votingplugin.proxy.control.HostedControlManager; +import com.bencodez.votingplugin.proxy.control.ControlConnector; + +class VotingPluginProxyLifecycleTest { + + @Test + void retainsConnectorWhenOperationShutdownFails() throws Exception { + VotingPluginProxyTestImpl proxy = new VotingPluginProxyTestImpl(); + ControlConnector connector = mock(ControlConnector.class); + doThrow(new IllegalStateException("operation still running")).when(connector).close(); + Field control = VotingPluginProxy.class.getDeclaredField("controlConnector"); + control.setAccessible(true); + control.set(proxy, connector); + Method stop = VotingPluginProxy.class.getDeclaredMethod("stopControlServices", boolean.class); + stop.setAccessible(true); + + assertThrows(java.lang.reflect.InvocationTargetException.class, () -> stop.invoke(proxy, true)); + + assertSame(connector, control.get(proxy)); + } + + @Test + void retainsHostedManagerWhenBoundedShutdownFails() throws Exception { + VotingPluginProxyTestImpl proxy = new VotingPluginProxyTestImpl(); + HostedControlManager manager = mock(HostedControlManager.class); + doThrow(new IllegalStateException("still running")).when(manager).closeAndWait(); + Field hosted = VotingPluginProxy.class.getDeclaredField("hostedControlManager"); + hosted.setAccessible(true); + hosted.set(proxy, manager); + Method stop = VotingPluginProxy.class.getDeclaredMethod("stopControlServices", boolean.class); + stop.setAccessible(true); + + assertThrows(java.lang.reflect.InvocationTargetException.class, () -> stop.invoke(proxy, true)); + + assertSame(manager, hosted.get(proxy)); + } + + @Test + void finalStopContinuesToHostedManagerAfterConnectorDrainFailure() throws Exception { + VotingPluginProxyTestImpl proxy = new VotingPluginProxyTestImpl(); + ControlConnector connector = mock(ControlConnector.class); + doThrow(new IllegalStateException("operation still running")).when(connector).close(); + HostedControlManager manager = mock(HostedControlManager.class); + Field control = VotingPluginProxy.class.getDeclaredField("controlConnector"); + control.setAccessible(true); + control.set(proxy, connector); + Field hosted = VotingPluginProxy.class.getDeclaredField("hostedControlManager"); + hosted.setAccessible(true); + hosted.set(proxy, manager); + Method stop = VotingPluginProxy.class.getDeclaredMethod("stopControlServices", boolean.class); + stop.setAccessible(true); + + stop.invoke(proxy, false); + + verify(manager).close(); + assertNull(control.get(proxy)); + assertNull(hosted.get(proxy)); + } + + @Test + void stopsEveryReplacedSocketClientEvenWhenOneStopFails() { + ClientHandler failing = mock(ClientHandler.class); + ClientHandler healthy = mock(ClientHandler.class); + doThrow(new IllegalStateException("already closed")).when(failing).stopConnection(); + Map clients = new LinkedHashMap<>(); + clients.put("failing", failing); + clients.put("healthy", healthy); + + VotingPluginProxy.stopSocketClients(clients); + + verify(failing).stopConnection(); + verify(healthy).stopConnection(); + } + + @Test + void waitsForInFlightSocketSendBeforeClosingClientMap() throws Exception { + VotingPluginProxyTestImpl proxy = new VotingPluginProxyTestImpl(); + proxy.setMethod(BungeeMethod.SOCKETS); + ClientHandler client = mock(ClientHandler.class); + JsonEnvelope envelope = mock(JsonEnvelope.class); + CountDownLatch sending = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch closingStarted = new CountDownLatch(1); + org.mockito.Mockito.doAnswer(ignored -> { + sending.countDown(); + if (!release.await(5, TimeUnit.SECONDS)) throw new AssertionError("send was not released"); + return null; + }).when(client).sendEnvelope(envelope); + + Field handles = VotingPluginProxy.class.getDeclaredField("clientHandles"); + handles.setAccessible(true); + HashMap clients = new HashMap<>(); + clients.put("lobby", client); + handles.set(proxy, clients); + Method close = VotingPluginProxy.class.getDeclaredMethod("closeSocketClients"); + close.setAccessible(true); + + var executor = Executors.newFixedThreadPool(2); + try { + var send = executor.submit(() -> proxy.sendProxyBroadcastEnvelopeNow("lobby", envelope)); + org.junit.jupiter.api.Assertions.assertTrue(sending.await(5, TimeUnit.SECONDS)); + var closing = executor.submit(() -> { + closingStarted.countDown(); + try { + close.invoke(proxy); + } catch (ReflectiveOperationException e) { + throw new RuntimeException(e); + } + }); + org.junit.jupiter.api.Assertions.assertTrue(closingStarted.await(5, TimeUnit.SECONDS)); + org.junit.jupiter.api.Assertions.assertThrows(java.util.concurrent.TimeoutException.class, + () -> closing.get(100, TimeUnit.MILLISECONDS)); + release.countDown(); + org.junit.jupiter.api.Assertions.assertTrue(send.get(5, TimeUnit.SECONDS)); + closing.get(5, TimeUnit.SECONDS); + verify(client).stopConnection(); + } finally { + release.countDown(); + executor.shutdownNow(); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java new file mode 100644 index 0000000000..8fe3dfdc05 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java @@ -0,0 +1,457 @@ +package com.bencodez.votingplugin.proxy.control; + +import com.bencodez.votingplugin.proxy.control.ControlConnector.ObservedBackend; +import com.bencodez.votingplugin.proxy.control.ControlConnector.Request; +import com.bencodez.votingplugin.proxy.control.ControlConnector.Response; +import com.bencodez.votingplugin.proxy.control.ControlConnector.Settings; +import com.bencodez.votingplugin.proxy.control.ControlConnector.Status; +import com.bencodez.votingplugin.proxy.control.ControlConnector.Transport; +import com.bencodez.votingplugin.proxy.control.ProxyControlResultStore.StoredResult; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class ControlConnectorTest { + private ScheduledExecutorService scheduler; + private FakeTransport transport; + private List logs; + private ControlConnector connector; + + @BeforeEach void setUp() { + scheduler = Executors.newSingleThreadScheduledExecutor(); + transport = new FakeTransport(); + logs = new ArrayList<>(); + connector = new ControlConnector(settings(), scheduler, transport, + () -> List.of(new ObservedBackend("lobby", "Lobby", true, true, 3)), logs::add, + UUID.fromString("00000000-0000-0000-0000-000000000001"), () -> 0L); + } + + @AfterEach void tearDown() { + connector.close(); + scheduler.shutdownNow(); + } + + @Test void registrationHeartbeatAndPresenceAreWireCompatibleAndReplacementSnapshotsAdvance() { + connector.cycle(); + assertEquals(Status.CONNECTED, connector.status()); + assertEquals(2, transport.requests.size()); + Request registration = transport.requests.get(0); + assertEquals("POST", registration.method()); + assertEquals("/api/v1/nodes/register", registration.path()); + JsonObject registrationJson = JsonParser.parseString(registration.body()).getAsJsonObject(); + assertEquals("proxy-a", registrationJson.get("nodeId").getAsString()); + assertEquals("VELOCITY", registrationJson.get("platform").getAsString()); + assertEquals(1, registrationJson.get("protocolVersion").getAsInt()); + assertEquals("presence.snapshot", registrationJson.getAsJsonArray("requiredCapabilities").get(0).getAsString()); + + JsonObject firstSnapshot = JsonParser.parseString(transport.requests.get(1).body()).getAsJsonObject(); + assertEquals(0, firstSnapshot.get("sequence").getAsLong()); + assertEquals("lobby", firstSnapshot.getAsJsonArray("backends").get(0).getAsJsonObject() + .get("backendId").getAsString()); + + connector.cycle(); + assertEquals("/api/v1/nodes/proxy-a/heartbeat", transport.requests.get(2).path()); + JsonObject secondSnapshot = JsonParser.parseString(transport.requests.get(3).body()).getAsJsonObject(); + assertEquals(1, secondSnapshot.get("sequence").getAsLong()); + } + + @Test void unavailableAuthenticationProtocolAndMalformedResponsesOnlyChangeConnectorState() { + transport.nextPrimary = new Response(401, "{\"error\":{}}"); + connector.cycle(); + assertEquals(Status.AUTHENTICATION_FAILED, connector.status()); + assertEquals(1, logs.size()); + + transport.nextPrimary = new Response(409, "{\"error\":{}}"); + connector.cycle(); + assertEquals(Status.INCOMPATIBLE, connector.status()); + + transport.nextPrimary = new Response(200, "not-json"); + connector.cycle(); + assertEquals(Status.UNAVAILABLE, connector.status()); + + transport.nextPrimary = new Response(503, "{\"error\":{}}"); + connector.cycle(); + assertEquals(Status.UNAVAILABLE, connector.status()); + } + + @Test void controlRegistryLossCausesReregistrationWithoutAffectingOtherCode() { + connector.cycle(); + transport.nextPrimary = new Response(404, "{\"error\":{}}"); + connector.cycle(); + assertEquals(Status.UNAVAILABLE, connector.status()); + connector.cycle(); + assertEquals("POST", transport.requests.get(3).method()); + assertEquals("/api/v1/nodes/register", transport.requests.get(3).path()); + } + + @Test void explicitHeartbeatCapabilityRevocationIsIncompatibleAndStopsTheCycle() { + connector.cycle(); + transport.nextPrimary = new Response(200, + "{\"node\":{\"acceptedCapabilities\":[\"config.proxy-routing.v1\"]}}"); + + connector.cycle(); + + assertEquals(Status.INCOMPATIBLE, connector.status()); + assertEquals(3, transport.requests.size()); + } + + @Test void skipsOperationClaimsWhenConfigurationCapabilityWasNotAccepted() { + connector.close(); + ProxyRoutingConfigurationService service = new ProxyRoutingConfigurationService(new NoOpPlatform()); + connector = new ControlConnector(settings(), scheduler, transport, + () -> List.of(), logs::add, UUID.randomUUID(), () -> 0L, service); + transport.acceptConfiguration = false; + + connector.cycle(); + + assertEquals(Status.CONNECTED, connector.status()); + assertEquals(2, transport.requests.size()); + assertTrue(transport.requests.stream().noneMatch(request -> request.path().endsWith("/operations"))); + } + + @Test void claimsOperationsWhenConfigurationCapabilityWasAccepted() { + connector.close(); + ProxyRoutingConfigurationService service = new ProxyRoutingConfigurationService(new NoOpPlatform()); + connector = new ControlConnector(settings(), scheduler, transport, + () -> List.of(), logs::add, UUID.randomUUID(), () -> 0L, service); + transport.acceptConfiguration = true; + + connector.cycle(); + + assertEquals(Status.CONNECTED, connector.status()); + assertEquals(3, transport.requests.size()); + assertTrue(transport.requests.get(2).path().endsWith("/operations")); + + connector.cycle(); + assertEquals(6, transport.requests.size()); + assertTrue(transport.requests.get(5).path().endsWith("/operations")); + } + + @Test void slowTransportDoesNotBlockCallerAndShutdownCancelsInFlightRequest() { + CompletableFuture stalled = new CompletableFuture<>(); + transport.stalled = stalled; + connector.cycle(); + assertFalse(stalled.isDone()); + assertEquals(Status.STARTING, connector.status()); + connector.close(); + assertTrue(stalled.isCancelled()); + assertEquals(Status.STOPPED, connector.status()); + } + + @Test void synchronousTransportFailureClearsInFlightAndRetries() { + transport.synchronousFailure = new IllegalArgumentException("invalid header"); + connector.cycle(); + assertEquals(Status.UNAVAILABLE, connector.status()); + connector.cycle(); + assertEquals(Status.CONNECTED, connector.status()); + } + + @Test void closeWaitsForClaimedApplyToFinish() throws Exception { + connector.close(); + CountDownLatch persistEntered = new CountDownLatch(1); + CountDownLatch releasePersist = new CountDownLatch(1); + ProxyRoutingConfiguration current = new ProxyRoutingConfiguration(false, List.of()); + ProxyRoutingConfigurationService service = new ProxyRoutingConfigurationService(new NoOpPlatform() { + @Override public void persist(ProxyRoutingConfiguration proposal, String expectedRevision) + throws java.io.IOException { + persistEntered.countDown(); + try { + releasePersist.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new java.io.IOException(e); + } + } + }); + connector = new ControlConnector(settings(), scheduler, transport, + () -> List.of(), logs::add, UUID.randomUUID(), () -> 0L, service); + transport.acceptConfiguration = true; + transport.operationClaim = new CompletableFuture<>(); + connector.cycle(); + try { + CompletableFuture executing = CompletableFuture.runAsync(() -> transport.operationClaim.complete( + new Response(200, "{\"operationId\":\"00000000-0000-0000-0000-000000000099\"," + + "\"attemptId\":\"00000000-0000-0000-0000-000000000199\"," + + "\"type\":\"APPLY\",\"expectedRevision\":\"" + current.revision() + "\"," + + "\"configuration\":{\"sendVotesToAllServers\":true,\"blockedServers\":[]}}"))); + assertTrue(persistEntered.await(2, TimeUnit.SECONDS)); + CompletableFuture closing = CompletableFuture.runAsync(connector::close); + assertThrows(java.util.concurrent.TimeoutException.class, () -> closing.get(100, TimeUnit.MILLISECONDS)); + + releasePersist.countDown(); + executing.get(2, TimeUnit.SECONDS); + closing.get(2, TimeUnit.SECONDS); + assertEquals(Status.STOPPED, connector.status()); + } finally { + releasePersist.countDown(); + } + } + + @Test void replacementWaitsForTheOriginatingNodeToAcknowledgeItsResult() { + connector.close(); + ProxyRoutingConfiguration current = new ProxyRoutingConfiguration(false, List.of()); + connector = new ControlConnector(settings(), scheduler, transport, + () -> List.of(), logs::add, UUID.randomUUID(), () -> 0L, + new ProxyRoutingConfigurationService(new NoOpPlatform())); + transport.acceptConfiguration = true; + transport.operationClaim = CompletableFuture.completedFuture(new Response(200, + "{\"operationId\":\"00000000-0000-0000-0000-000000000099\"," + + "\"attemptId\":\"00000000-0000-0000-0000-000000000199\"," + + "\"type\":\"APPLY\",\"expectedRevision\":\"" + current.revision() + "\"," + + "\"configuration\":{\"sendVotesToAllServers\":true,\"blockedServers\":[]}}")); + transport.resultSubmission = new CompletableFuture<>(); + connector.cycle(); + + AtomicBoolean replacementStarted = new AtomicBoolean(); + assertTrue(connector.deferReplacementUntilSafe(() -> replacementStarted.set(true))); + assertFalse(replacementStarted.get()); + + transport.resultSubmission.complete(new Response(200, "{}")); + assertTrue(replacementStarted.get()); + } + + @Test void lostResultResponseIsResubmittedBeforeAnotherOperationClaim() { + connector.close(); + ProxyRoutingConfiguration current = new ProxyRoutingConfiguration(false, List.of()); + connector = new ControlConnector(settings(), scheduler, transport, + () -> List.of(), logs::add, UUID.randomUUID(), () -> 0L, + new ProxyRoutingConfigurationService(new NoOpPlatform())); + transport.acceptConfiguration = true; + transport.operationClaim = CompletableFuture.completedFuture(new Response(200, + "{\"operationId\":\"00000000-0000-0000-0000-000000000099\"," + + "\"attemptId\":\"00000000-0000-0000-0000-000000000199\"," + + "\"type\":\"APPLY\",\"expectedRevision\":\"" + current.revision() + "\"," + + "\"configuration\":{\"sendVotesToAllServers\":true,\"blockedServers\":[]}}")); + transport.resultSubmission = new CompletableFuture<>(); + connector.cycle(); + transport.resultSubmission.completeExceptionally(new java.io.IOException("response lost")); + assertEquals(Status.UNAVAILABLE, connector.status()); + + transport.operationClaim = CompletableFuture.completedFuture(new Response(204, "")); + transport.resultSubmission = CompletableFuture.completedFuture(new Response(200, "{}")); + connector.cycle(); + + assertEquals(2, transport.requests.stream().filter(request -> request.path().endsWith("/result")).count()); + assertEquals(1, transport.requests.stream().filter(request -> request.path().endsWith("/operations")).count()); + } + + @Test void expiredResultLeaseIsReclaimedAndReboundBeforeResubmission() { + connector.close(); + ProxyRoutingConfiguration current = new ProxyRoutingConfiguration(false, List.of()); + connector = new ControlConnector(settings(), scheduler, transport, + () -> List.of(), logs::add, UUID.randomUUID(), () -> 0L, + new ProxyRoutingConfigurationService(new NoOpPlatform())); + transport.acceptConfiguration = true; + transport.operationClaim = CompletableFuture.completedFuture(new Response(200, + "{\"operationId\":\"00000000-0000-0000-0000-000000000099\"," + + "\"attemptId\":\"00000000-0000-0000-0000-000000000199\"," + + "\"type\":\"APPLY\",\"expectedRevision\":\"" + current.revision() + "\"," + + "\"configuration\":{\"sendVotesToAllServers\":true,\"blockedServers\":[]}}")); + transport.resultSubmission = CompletableFuture.completedFuture(new Response(409, + "{\"error\":{\"code\":\"TASK_LEASE_EXPIRED\"}}")); + + connector.cycle(); + + transport.operationClaim = CompletableFuture.completedFuture(new Response(200, + "{\"operationId\":\"00000000-0000-0000-0000-000000000099\"," + + "\"attemptId\":\"00000000-0000-0000-0000-000000000299\"," + + "\"type\":\"APPLY\",\"expectedRevision\":\"" + current.revision() + "\"," + + "\"configuration\":{\"sendVotesToAllServers\":true,\"blockedServers\":[]}}")); + transport.resultSubmission = CompletableFuture.completedFuture(new Response(200, "{}")); + connector.cycle(); + + List results = transport.requests.stream().filter(request -> request.path().endsWith("/result")).toList(); + assertEquals(2, results.size()); + assertEquals("00000000-0000-0000-0000-000000000199", + JsonParser.parseString(results.get(0).body()).getAsJsonObject().get("attemptId").getAsString()); + assertEquals("00000000-0000-0000-0000-000000000299", + JsonParser.parseString(results.get(1).body()).getAsJsonObject().get("attemptId").getAsString()); + } + + @Test void forgottenControlOperationDoesNotBlockFutureClaims() { + connector.close(); + ProxyRoutingConfiguration current = new ProxyRoutingConfiguration(false, List.of()); + connector = new ControlConnector(settings(), scheduler, transport, + () -> List.of(), logs::add, UUID.randomUUID(), () -> 0L, + new ProxyRoutingConfigurationService(new NoOpPlatform())); + transport.acceptConfiguration = true; + transport.operationClaim = CompletableFuture.completedFuture(new Response(200, + "{\"operationId\":\"00000000-0000-0000-0000-000000000099\"," + + "\"attemptId\":\"00000000-0000-0000-0000-000000000199\"," + + "\"type\":\"READ\",\"configuration\":" + + "{\"sendVotesToAllServers\":false,\"blockedServers\":[]}}")); + transport.resultSubmission = CompletableFuture.completedFuture(new Response(404, + "{\"error\":{\"code\":\"OPERATION_NOT_FOUND\"}}")); + + connector.cycle(); + transport.operationClaim = CompletableFuture.completedFuture(new Response(204, "")); + connector.cycle(); + + assertEquals(2, transport.requests.stream().filter(request -> request.path().endsWith("/operations")).count()); + assertEquals(1, transport.requests.stream().filter(request -> request.path().endsWith("/result")).count()); + } + + @Test void abandonedWriteAheadIntentIsReportedAndReleasedWhenControlForgotTheOperation() { + connector.close(); + UUID operationId = UUID.fromString("00000000-0000-0000-0000-000000000099"); + ProxyRoutingConfiguration proposal = new ProxyRoutingConfiguration(true, List.of()); + JsonObject anticipated = new JsonObject(); + anticipated.addProperty("success", true); + anticipated.addProperty("code", "OK"); + anticipated.addProperty("message", "Configuration applied"); + anticipated.addProperty("revision", proposal.revision()); + JsonObject configuration = new JsonObject(); + configuration.addProperty("sendVotesToAllServers", true); + configuration.add("blockedServers", new com.google.gson.JsonArray()); + anticipated.add("configuration", configuration); + anticipated.addProperty("attemptId", "00000000-0000-0000-0000-000000000199"); + connector = new ControlConnector(settings(), scheduler, transport, + () -> List.of(), logs::add, UUID.randomUUID(), () -> 0L, + new ProxyRoutingConfigurationService(new NoOpPlatform()), + Map.of(operationId, new StoredResult(anticipated, false, false))); + transport.acceptConfiguration = true; + transport.resultSubmission = CompletableFuture.completedFuture(new Response(404, + "{\"error\":{\"code\":\"OPERATION_NOT_FOUND\"}}")); + + connector.cycle(); + JsonObject submitted = JsonParser.parseString(transport.requests.stream() + .filter(request -> request.path().endsWith("/result")).findFirst().orElseThrow().body()).getAsJsonObject(); + assertEquals("RECOVERY_ABORTED", submitted.get("code").getAsString()); + + transport.operationClaim = CompletableFuture.completedFuture(new Response(204, "")); + connector.cycle(); + assertEquals(1, transport.requests.stream().filter(request -> request.path().endsWith("/operations")).count()); + assertTrue(connector.reserveRuntimeReplacement()); + } + + @Test void closeBeforeOperationPublicationPreventsTheRequestChainFromStarting() throws Exception { + connector.close(); + transport.firstSendEntered = new CountDownLatch(1); + transport.releaseFirstSend = new CountDownLatch(1); + connector = new ControlConnector(settings(), scheduler, transport, + () -> List.of(), logs::add, UUID.randomUUID(), () -> 0L); + CompletableFuture cycling = CompletableFuture.runAsync(connector::cycle); + assertTrue(transport.firstSendEntered.await(2, TimeUnit.SECONDS)); + + try { + connector.close(); + } finally { + transport.releaseFirstSend.countDown(); + } + cycling.get(2, TimeUnit.SECONDS); + assertEquals(1, transport.requests.size()); + assertEquals(Status.STOPPED, connector.status()); + } + + @Test void backoffIsBoundedExponentialAndJitteredWithoutSleeping() { + assertEquals(1000, ControlConnector.backoffMillis(1, 0)); + assertEquals(2000, ControlConnector.backoffMillis(2, 0)); + assertTrue(ControlConnector.backoffMillis(3, 999) >= 4000); + assertTrue(ControlConnector.backoffMillis(30, Long.MAX_VALUE) <= 300000); + assertTrue(ControlConnector.backoffMillis(30, Long.MIN_VALUE) > 0); + } + + @Test void backendIdentityPreservesSafeNamesAndDeterministicallyMapsOtherConfiguredNames() { + assertEquals("lobby-1", ControlConnector.stableBackendId("lobby-1")); + String mapped = ControlConnector.stableBackendId("Modded Lobby"); + assertTrue(mapped.matches("backend-[0-9a-f]{32}")); + assertEquals(mapped, ControlConnector.stableBackendId("Modded Lobby")); + assertNotEquals(mapped, ControlConnector.stableBackendId("Other Lobby")); + } + + @Test void settingsRejectCredentialsInUrlsUnsafePathsAndUnboundedTiming() { + assertThrows(IllegalArgumentException.class, () -> new Settings("proxy-a", "Proxy", "VELOCITY", "7.1.2", + URI.create("http://user:secret@localhost:8080"), 30, 3000, 5000)); + assertThrows(IllegalArgumentException.class, () -> new Settings("proxy-a", "Proxy", "VELOCITY", "7.1.2", + URI.create("http://localhost:8080/api"), 30, 3000, 5000)); + assertThrows(IllegalArgumentException.class, () -> new Settings("proxy-a", "Proxy", "VELOCITY", "7.1.2", + URI.create("http://localhost:8080"), 1, 3000, 5000)); + } + + private Settings settings() { + return new Settings("proxy-a", "Proxy A", "VELOCITY", "7.1.2", + URI.create("http://127.0.0.1:8080"), 30, 3000, 5000); + } + + private static final class FakeTransport implements Transport { + private final List requests = new ArrayList<>(); + private Response nextPrimary; + private CompletableFuture stalled; + private RuntimeException synchronousFailure; + private boolean acceptConfiguration; + private CompletableFuture operationClaim; + private CompletableFuture resultSubmission; + private CountDownLatch firstSendEntered; + private CountDownLatch releaseFirstSend; + + @Override + public CompletableFuture send(Request request) { + if (synchronousFailure != null) { + RuntimeException failure = synchronousFailure; + synchronousFailure = null; + throw failure; + } + requests.add(request); + if (firstSendEntered != null) { + firstSendEntered.countDown(); + try { + releaseFirstSend.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } finally { + firstSendEntered = null; + } + } + if (stalled != null) { + CompletableFuture result = stalled; + stalled = null; + return result; + } + if (request.path().endsWith("/result") && resultSubmission != null) return resultSubmission; + if (request.path().endsWith("/operations")) { + if (operationClaim != null) return operationClaim; + return CompletableFuture.completedFuture(new Response(204, "")); + } + if (!request.path().endsWith("/presence")) { + Response response = nextPrimary; + nextPrimary = null; + if (response != null) { + return CompletableFuture.completedFuture(response); + } + if ("/api/v1/nodes/register".equals(request.path())) { + String capabilities = acceptConfiguration + ? "[\"presence.snapshot\",\"config.proxy-routing.v1\"]" : "[\"presence.snapshot\"]"; + return CompletableFuture.completedFuture(new Response(201, + "{\"identity\":{\"protocolVersion\":1},\"node\":{\"acceptedCapabilities\":" + + capabilities + "}}")); + } + return CompletableFuture.completedFuture(new Response(200, "{\"node\":{}}")); + } + return CompletableFuture.completedFuture(new Response(200, "{\"applied\":true,\"node\":{}}")); + } + } + + private static class NoOpPlatform implements ProxyRoutingConfigurationService.Platform { + @Override public ProxyRoutingConfiguration read() { return new ProxyRoutingConfiguration(false, List.of()); } + @Override public java.util.Set configuredServers() { return java.util.Set.of(); } + @Override public void persist(ProxyRoutingConfiguration proposal, String expectedRevision) + throws java.io.IOException { } + @Override public void rollback() { } + @Override public void reload() { } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/HostedControlManagerTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/HostedControlManagerTest.java new file mode 100644 index 0000000000..0e8a8b02cf --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/HostedControlManagerTest.java @@ -0,0 +1,608 @@ +package com.bencodez.votingplugin.proxy.control; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.sun.net.httpserver.HttpServer; + +class HostedControlManagerTest { + @TempDir + Path directory; + + @Test + void versionPinsAndContainedPathsAreRequired() throws Exception { + Path root = directory.toAbsolutePath().normalize(); + assertThrows(IllegalArgumentException.class, + () -> HostedControlManager.resolveInside(root, "../outside.jar", "jar")); + assertThrows(IllegalArgumentException.class, + () -> HostedControlManager.parseDownloadUri( + "https://github.com/BenCodez/VotingPlugin-Control/releases/latest/download/control.jar")); + assertThrows(IllegalArgumentException.class, () -> settings(root.resolve("control.jar"), root.resolve("data"), + true, false, "not-a-digest", 30)); + } + + @Test + void hostedArtifactBodyReadCannotOutliveDownloadTimeout() throws Exception { + CountDownLatch bodyStarted = new CountDownLatch(1); + CountDownLatch releaseBody = new CountDownLatch(1); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/control.jar", exchange -> { + exchange.sendResponseHeaders(200, 0); + try (var body = exchange.getResponseBody()) { + body.write(1); + body.flush(); + bodyStarted.countDown(); + try { + releaseBody.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + }); + server.start(); + try { + Path target = directory.resolve("stalled.jar"); + URI source = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/control.jar"); + long started = System.nanoTime(); + IOException failure = assertThrows(IOException.class, () -> new HostedControlManager.JdkArtifactDownloader() + .download(source, target, 1024, Duration.ofMillis(250))); + assertTrue(bodyStarted.await(2, TimeUnit.SECONDS)); + assertTrue(failure.getMessage().contains("timed out")); + assertTrue(Duration.ofNanos(System.nanoTime() - started).compareTo(Duration.ofSeconds(2)) < 0); + } finally { + releaseBody.countDown(); + server.stop(0); + } + } + + @Test + void hostedHealthBodyReadCannotOutliveProbeTimeout() throws Exception { + CountDownLatch bodyStarted = new CountDownLatch(1); + CountDownLatch releaseBody = new CountDownLatch(1); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/api/v1/health", exchange -> { + exchange.sendResponseHeaders(200, 0); + try (var body = exchange.getResponseBody()) { + body.write("{\"status\":\"ok\"".getBytes(StandardCharsets.UTF_8)); + body.flush(); + bodyStarted.countDown(); + try { + releaseBody.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + }); + server.start(); + try { + URI endpoint = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/"); + long started = System.nanoTime(); + assertFalse(new HostedControlManager.JdkHealthProbe().isHealthy(endpoint, Duration.ofMillis(250), + "00000000-0000-0000-0000-000000000001")); + assertTrue(bodyStarted.await(2, TimeUnit.SECONDS)); + assertTrue(Duration.ofNanos(System.nanoTime() - started).compareTo(Duration.ofSeconds(2)) < 0); + } finally { + releaseBody.countDown(); + server.stop(0); + } + } + + @Test + void hostedHealthRequiresTheLaunchedChildIdentity() throws Exception { + String launchId = "00000000-0000-0000-0000-000000000001"; + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/api/v1/health", exchange -> { + byte[] body = ("{\"status\":\"ok\",\"launchId\":\"" + launchId + + "\",\"identity\":{\"protocolVersion\":1}}").getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + try (var output = exchange.getResponseBody()) { + output.write(body); + } + }); + server.start(); + try { + URI endpoint = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/"); + HostedControlManager.JdkHealthProbe probe = new HostedControlManager.JdkHealthProbe(); + assertTrue(probe.isHealthy(endpoint, Duration.ofSeconds(2), launchId)); + assertFalse(probe.isHealthy(endpoint, Duration.ofSeconds(2), + "00000000-0000-0000-0000-000000000002")); + } finally { + server.stop(0); + } + } + + @Test + void missingPinnedArtifactIsStagedVerifiedAndStarted() throws Exception { + byte[] release = "signed-release-content".getBytes(StandardCharsets.UTF_8); + Path root = directory.toAbsolutePath().normalize(); + Path jar = root.resolve("control/control.jar"); + HostedControlManager.Settings settings = settings(jar, root.resolve("control/data"), true, false, + digest(release), 30); + FakeLauncher launcher = new FakeLauncher(); + AtomicInteger downloads = new AtomicInteger(); + ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + HostedControlManager manager = new HostedControlManager(settings, executor, + (source, target, maximum, timeout) -> { + downloads.incrementAndGet(); + Files.write(target, release); + }, launcher, (endpoint, timeout, launchId) -> true, + millis -> { throw new AssertionError("healthy startup must not sleep"); }, System::nanoTime, + message -> { }); + + manager.runOnce(); + + assertEquals(HostedControlManager.Status.RUNNING, manager.status()); + assertEquals(1, downloads.get()); + assertEquals(List.of("signed-release-content"), launcher.launchedContents); + assertEquals("signed-release-content", Files.readString(jar)); + manager.close(); + assertTrue(launcher.processes.get(0).destroyed); + } + + @Test + void failedUpdateRollsBackAndStartsPreviousRelease() throws Exception { + byte[] previous = "previous-release".getBytes(StandardCharsets.UTF_8); + byte[] update = "bad-new-release".getBytes(StandardCharsets.UTF_8); + Path root = directory.toAbsolutePath().normalize(); + Path jar = root.resolve("control/control.jar"); + Files.createDirectories(jar.getParent()); + Files.write(jar, previous); + HostedControlManager.Settings settings = settings(jar, root.resolve("control/data"), true, true, + digest(update), 1); + FakeLauncher launcher = new FakeLauncher(); + AtomicInteger healthChecks = new AtomicInteger(); + AtomicLong clock = new AtomicLong(); + ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + HostedControlManager manager = new HostedControlManager(settings, executor, + (source, target, maximum, timeout) -> Files.write(target, update), launcher, + (endpoint, timeout, launchId) -> healthChecks.incrementAndGet() > 4, + millis -> clock.addAndGet(TimeUnit.MILLISECONDS.toNanos(millis)), clock::get, message -> { }); + + manager.runOnce(); + + assertEquals(HostedControlManager.Status.ROLLED_BACK, manager.status()); + assertEquals(List.of("bad-new-release", "previous-release"), launcher.launchedContents); + assertEquals("previous-release", Files.readString(jar)); + assertEquals("bad-new-release", Files.readString(settings.failedFile())); + assertTrue(launcher.processes.get(0).destroyed); + launcher.processes.get(1).destroy(); + Files.writeString(jar, "corrupted-after-rollback"); + manager.runOnce(); + assertEquals(HostedControlManager.Status.FAILED, manager.status()); + assertEquals(2, launcher.launchedContents.size()); + manager.close(); + } + + @Test + void manualPinnedInstallNeedsNoDownloaderAndCloseIsNonBlocking() throws Exception { + byte[] release = "manual-release".getBytes(StandardCharsets.UTF_8); + Path root = directory.toAbsolutePath().normalize(); + Path jar = root.resolve("control/control.jar"); + Files.createDirectories(jar.getParent()); + Files.write(jar, release); + HostedControlManager.Settings settings = new HostedControlManager.Settings(root, jar, + root.resolve("control/data"), false, false, null, digest(release), "127.0.0.1", 8080, 30, 60); + FakeLauncher launcher = new FakeLauncher(); + ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + HostedControlManager manager = new HostedControlManager(settings, executor, + (source, target, maximum, timeout) -> { throw new AssertionError("manual install downloaded"); }, + launcher, (endpoint, timeout, launchId) -> true, millis -> { }, System::nanoTime, message -> { }); + + manager.runOnce(); + manager.close(); + + assertEquals(HostedControlManager.Status.STOPPED, manager.status()); + assertFalse(launcher.processes.get(0).isAlive()); + } + + @Test + void digestMismatchNeverActivatesOrLaunchesDownloadedCode() throws Exception { + byte[] expected = "expected-release".getBytes(StandardCharsets.UTF_8); + byte[] untrusted = "different-content".getBytes(StandardCharsets.UTF_8); + Path root = directory.toAbsolutePath().normalize(); + Path jar = root.resolve("control/control.jar"); + HostedControlManager.Settings settings = settings(jar, root.resolve("control/data"), true, false, + digest(expected), 30); + FakeLauncher launcher = new FakeLauncher(); + ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + HostedControlManager manager = new HostedControlManager(settings, executor, + (source, target, maximum, timeout) -> Files.write(target, untrusted), launcher, + (endpoint, timeout, launchId) -> true, millis -> { }, System::nanoTime, message -> { }); + + manager.runOnce(); + + assertEquals(HostedControlManager.Status.FAILED, manager.status()); + assertFalse(Files.exists(jar)); + assertTrue(launcher.processes.isEmpty()); + manager.close(); + } + + @Test + void closeAndWaitDoesNotReturnUntilArtifactWorkerStops() throws Exception { + byte[] release = "release".getBytes(StandardCharsets.UTF_8); + Path root = directory.toAbsolutePath().normalize(); + Path jar = root.resolve("control/control.jar"); + HostedControlManager.Settings settings = settings(jar, root.resolve("control/data"), true, false, + digest(release), 1); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch releaseWorker = new CountDownLatch(1); + ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + HostedControlManager manager = new HostedControlManager(settings, executor, (source, target, maximum, timeout) -> { + entered.countDown(); + while (releaseWorker.getCount() > 0) { + try { + releaseWorker.await(); + } catch (InterruptedException ignored) { } + } + Files.write(target, release); + }, new FakeLauncher(), (endpoint, timeout, launchId) -> true, millis -> { }, System::nanoTime, message -> { }); + manager.start(); + assertTrue(entered.await(2, TimeUnit.SECONDS)); + CountDownLatch closingStarted = new CountDownLatch(1); + CompletableFuture closing = CompletableFuture.runAsync(() -> { + closingStarted.countDown(); + manager.closeAndWait(); + }); + assertTrue(closingStarted.await(2, TimeUnit.SECONDS)); + assertFalse(closing.isDone()); + releaseWorker.countDown(); + closing.get(2, TimeUnit.SECONDS); + assertTrue(executor.isTerminated()); + } + + @Test + void forcedProcessTerminationIsAwaitedBeforeReturning() { + StubbornProcess process = new StubbornProcess(); + + HostedControlManager.stopProcess(process, true); + + assertTrue(process.forciblyDestroyed); + assertEquals(2, process.waits.get()); + } + + @Test + void failedForcedTerminationRetainsManagedProcessForRetry() throws Exception { + Path root = directory.toAbsolutePath().normalize(); + ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + HostedControlManager manager = new HostedControlManager( + settings(root.resolve("control.jar"), root.resolve("data"), false, false, digest("x".getBytes()), 1), + executor, (source, target, maximum, timeout) -> { }, new FakeLauncher(), + (endpoint, timeout, launchId) -> true, millis -> { }, System::nanoTime, message -> { }); + UnkillableProcess process = new UnkillableProcess(); + java.lang.reflect.Field managed = HostedControlManager.class.getDeclaredField("managedProcess"); + managed.setAccessible(true); + managed.set(manager, process); + + assertThrows(IllegalStateException.class, manager::closeAndWait); + + assertSame(process, managed.get(manager)); + assertTrue(process.forciblyDestroyed); + assertEquals(2, process.waits.get()); + } + + @Test + void failedHealthTerminationDefersRetryUntilRetainedProcessExits() throws Exception { + byte[] release = "manual-release".getBytes(StandardCharsets.UTF_8); + Path root = directory.toAbsolutePath().normalize(); + Path jar = root.resolve("control.jar"); + Files.write(jar, release); + ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1); + RetainedProcess process = new RetainedProcess(); + AtomicInteger launches = new AtomicInteger(); + AtomicLong clock = new AtomicLong(); + HostedControlManager manager = new HostedControlManager( + settings(jar, root.resolve("data"), false, false, digest(release), 1), executor, + (source, target, maximum, timeout) -> { }, (settings, artifact, launchId) -> { + launches.incrementAndGet(); + return process; + }, (endpoint, timeout, launchId) -> false, + millis -> clock.addAndGet(TimeUnit.MILLISECONDS.toNanos(millis)), clock::get, message -> { }); + + manager.runOnce(); + manager.runOnce(); + + assertEquals(HostedControlManager.Status.FAILED, manager.status()); + assertEquals(1, launches.get()); + assertTrue(executor.getQueue().isEmpty()); + process.exit(); + assertEquals(1, executor.getQueue().size()); + manager.close(); + } + + @Test + void delayedUpdateProcessExitPreservesRollbackEligibility() throws Exception { + byte[] previous = "previous-release".getBytes(StandardCharsets.UTF_8); + byte[] update = "bad-new-release".getBytes(StandardCharsets.UTF_8); + Path root = directory.toAbsolutePath().normalize(); + Path jar = root.resolve("control.jar"); + Files.write(jar, previous); + HostedControlManager.Settings settings = settings(jar, root.resolve("data"), true, true, + digest(update), 1); + ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1); + RetainedProcess retained = new RetainedProcess(); + List launches = new ArrayList<>(); + AtomicInteger launchCount = new AtomicInteger(); + AtomicLong clock = new AtomicLong(); + HostedControlManager manager = new HostedControlManager(settings, executor, + (source, target, maximum, timeout) -> Files.write(target, update), + (launchSettings, artifact, launchId) -> { + launches.add(Files.readString(artifact)); + return launchCount.getAndIncrement() == 0 ? retained : new FakeProcess(); + }, (endpoint, timeout, launchId) -> "previous-release".equals(launches.get(launches.size() - 1)), + millis -> clock.addAndGet(TimeUnit.MILLISECONDS.toNanos(millis)), clock::get, message -> { }); + + manager.runOnce(); + assertEquals(HostedControlManager.Status.FAILED, manager.status()); + retained.exit(); + manager.runOnce(); + + assertEquals(HostedControlManager.Status.ROLLED_BACK, manager.status()); + assertEquals(List.of("bad-new-release", "previous-release"), launches); + assertEquals("previous-release", Files.readString(jar)); + assertEquals("bad-new-release", Files.readString(settings.failedFile())); + manager.close(); + } + + @Test + void pendingUpdateRollbackSurvivesManagerRestart() throws Exception { + byte[] previous = "previous-release".getBytes(StandardCharsets.UTF_8); + byte[] failedUpdate = "bad-new-release-a".getBytes(StandardCharsets.UTF_8); + byte[] nextUpdate = "new-release-b".getBytes(StandardCharsets.UTF_8); + Path root = directory.toAbsolutePath().normalize(); + Path jar = root.resolve("control.jar"); + Files.write(jar, previous); + HostedControlManager.Settings settings = settings(jar, root.resolve("data"), true, true, + digest(failedUpdate), 1); + FakeLauncher failedLauncher = new FakeLauncher(); + ScheduledExecutorService failedExecutor = Executors.newSingleThreadScheduledExecutor(); + HostedControlManager failedManager = new HostedControlManager(settings, failedExecutor, + (source, target, maximum, timeout) -> Files.write(target, failedUpdate), failedLauncher, + (endpoint, timeout, launchId) -> { throw new IllegalStateException("simulated JVM exit"); }, + millis -> { }, System::nanoTime, message -> { }); + + failedManager.runOnce(); + failedExecutor.shutdownNow(); + assertEquals("bad-new-release-a", Files.readString(jar)); + assertTrue(Files.isRegularFile(settings.rollbackPendingFile())); + + HostedControlManager.Settings recoveredSettings = settings(jar, root.resolve("data"), true, true, + digest(nextUpdate), 1); + FakeLauncher recoveredLauncher = new FakeLauncher(); + AtomicInteger recoveredDownloads = new AtomicInteger(); + ScheduledExecutorService recoveredExecutor = Executors.newSingleThreadScheduledExecutor(); + HostedControlManager recoveredManager = new HostedControlManager(recoveredSettings, recoveredExecutor, + (source, target, maximum, timeout) -> { + recoveredDownloads.incrementAndGet(); + Files.write(target, nextUpdate); + }, + recoveredLauncher, (endpoint, timeout, launchId) -> true, millis -> { }, System::nanoTime, + message -> { }); + recoveredManager.runOnce(); + + assertEquals(HostedControlManager.Status.ROLLED_BACK, recoveredManager.status()); + assertEquals(List.of("previous-release"), recoveredLauncher.launchedContents); + assertEquals("previous-release", Files.readString(jar)); + assertEquals(0, recoveredDownloads.get()); + assertFalse(Files.exists(recoveredSettings.rollbackPendingFile())); + + recoveredLauncher.processes.get(0).destroy(); + recoveredManager.runOnce(); + assertEquals(HostedControlManager.Status.RUNNING, recoveredManager.status()); + assertEquals(1, recoveredDownloads.get()); + assertEquals(List.of("previous-release", "new-release-b"), recoveredLauncher.launchedContents); + assertEquals("new-release-b", Files.readString(jar)); + recoveredManager.close(); + } + + @Test + void quarantinedUpdateDigestSurvivesManagerRestart() throws Exception { + byte[] previous = "previous-release".getBytes(StandardCharsets.UTF_8); + byte[] update = "bad-new-release".getBytes(StandardCharsets.UTF_8); + Path root = directory.toAbsolutePath().normalize(); + Path jar = root.resolve("control.jar"); + Files.write(jar, previous); + HostedControlManager.Settings settings = settings(jar, root.resolve("data"), true, true, + digest(update), 1); + FakeLauncher failedLauncher = new FakeLauncher(); + AtomicInteger healthChecks = new AtomicInteger(); + AtomicLong clock = new AtomicLong(); + ScheduledExecutorService failedExecutor = Executors.newSingleThreadScheduledExecutor(); + HostedControlManager failedManager = new HostedControlManager(settings, failedExecutor, + (source, target, maximum, timeout) -> Files.write(target, update), failedLauncher, + (endpoint, timeout, launchId) -> healthChecks.incrementAndGet() > 4, + millis -> clock.addAndGet(TimeUnit.MILLISECONDS.toNanos(millis)), clock::get, message -> { }); + failedManager.runOnce(); + assertEquals(HostedControlManager.Status.ROLLED_BACK, failedManager.status()); + assertTrue(Files.isRegularFile(settings.quarantineFile())); + failedManager.close(); + + FakeLauncher restartedLauncher = new FakeLauncher(); + ScheduledExecutorService restartedExecutor = Executors.newSingleThreadScheduledExecutor(); + HostedControlManager restartedManager = new HostedControlManager(settings, restartedExecutor, + (source, target, maximum, timeout) -> { throw new AssertionError("quarantined update downloaded"); }, + restartedLauncher, (endpoint, timeout, launchId) -> true, millis -> { }, System::nanoTime, + message -> { }); + restartedManager.runOnce(); + + assertEquals(HostedControlManager.Status.RUNNING, restartedManager.status()); + assertEquals(List.of("previous-release"), restartedLauncher.launchedContents); + assertEquals("previous-release", Files.readString(jar)); + restartedManager.close(); + } + + @Test + void incompleteActivationIsRetriedWithoutQuarantiningCandidate() throws Exception { + byte[] previous = "previous-release".getBytes(StandardCharsets.UTF_8); + byte[] update = "candidate-release".getBytes(StandardCharsets.UTF_8); + Path root = directory.toAbsolutePath().normalize(); + Path jar = root.resolve("control.jar"); + Files.write(jar, previous); + HostedControlManager.Settings settings = settings(jar, root.resolve("data"), true, true, + digest(update), 1); + AtomicInteger downloads = new AtomicInteger(); + AtomicInteger launches = new AtomicInteger(); + ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + HostedControlManager manager = new HostedControlManager(settings, executor, + (source, target, maximum, timeout) -> { + downloads.incrementAndGet(); + Files.write(target, update); + }, (launchSettings, artifact, launchId) -> { + if (launches.getAndIncrement() == 0) throw new IOException("simulated pre-launch exit"); + return new FakeProcess(); + }, (endpoint, timeout, launchId) -> true, millis -> { }, System::nanoTime, message -> { }); + + manager.runOnce(); + assertEquals(HostedControlManager.Status.FAILED, manager.status()); + assertFalse(Files.exists(settings.healthCheckingFile())); + manager.runOnce(); + + assertEquals(HostedControlManager.Status.RUNNING, manager.status()); + assertEquals(2, downloads.get()); + assertEquals(2, launches.get()); + assertEquals("candidate-release", Files.readString(jar)); + assertFalse(Files.exists(settings.quarantineFile())); + manager.close(); + } + + @Test + void launchStatePublicationFailureStopsTheLaunchedChild() throws Exception { + byte[] previous = "previous-release".getBytes(StandardCharsets.UTF_8); + byte[] update = "candidate-release".getBytes(StandardCharsets.UTF_8); + Path root = directory.toAbsolutePath().normalize(); + Path jar = root.resolve("control.jar"); + Files.write(jar, previous); + HostedControlManager.Settings settings = settings(jar, root.resolve("data"), true, true, + digest(update), 1); + FakeProcess process = new FakeProcess(); + ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + HostedControlManager manager = new HostedControlManager(settings, executor, + (source, target, maximum, timeout) -> Files.write(target, update), + (launchSettings, artifact, launchId) -> { + Files.createDirectory(settings.healthCheckingFile()); + Files.writeString(settings.healthCheckingFile().resolve("blocker"), "x"); + return process; + }, (endpoint, timeout, launchId) -> true, millis -> { }, System::nanoTime, message -> { }); + + manager.runOnce(); + + assertEquals(HostedControlManager.Status.FAILED, manager.status()); + assertTrue(process.destroyed); + assertFalse(process.isAlive()); + manager.close(); + } + + private HostedControlManager.Settings settings(Path jar, Path data, boolean autoDownload, boolean autoUpdate, + String sha256, int startupTimeout) { + Path root = directory.toAbsolutePath().normalize(); + return new HostedControlManager.Settings(root, jar, data, autoDownload, autoUpdate, + URI.create("https://github.com/BenCodez/VotingPlugin-Control/releases/download/v0.1.0/control.jar"), + sha256, "127.0.0.1", 8080, startupTimeout, 60); + } + + private String digest(byte[] bytes) throws Exception { + Path input = directory.resolve("digest-" + System.nanoTime()); + Files.write(input, bytes); + return HostedControlManager.sha256(input); + } + + private static final class FakeLauncher implements HostedControlManager.ProcessLauncher { + private final List launchedContents = new ArrayList<>(); + private final List processes = new ArrayList<>(); + + @Override + public HostedControlManager.ManagedProcess launch(HostedControlManager.Settings settings, Path artifact, + String launchId) + throws java.io.IOException { + launchedContents.add(Files.readString(artifact)); + FakeProcess process = new FakeProcess(); + processes.add(process); + return process; + } + } + + private static final class FakeProcess implements HostedControlManager.ManagedProcess { + private final CompletableFuture exit = new CompletableFuture<>(); + private volatile boolean alive = true; + private volatile boolean destroyed; + + @Override public boolean isAlive() { return alive; } + @Override public void destroy() { destroyed = true; alive = false; exit.complete(null); } + @Override public void destroyForcibly() { destroy(); } + @Override public boolean waitFor(long timeout, TimeUnit unit) { return !alive; } + @Override public CompletableFuture onExit() { return exit; } + } + + private static final class StubbornProcess implements HostedControlManager.ManagedProcess { + private final AtomicInteger waits = new AtomicInteger(); + private final CompletableFuture exit = new CompletableFuture<>(); + private volatile boolean alive = true; + private volatile boolean forciblyDestroyed; + + @Override public boolean isAlive() { return alive; } + @Override public void destroy() { } + @Override public void destroyForcibly() { + forciblyDestroyed = true; + alive = false; + exit.complete(null); + } + @Override public boolean waitFor(long timeout, TimeUnit unit) { + waits.incrementAndGet(); + return !alive; + } + @Override public CompletableFuture onExit() { return exit; } + } + + private static final class UnkillableProcess implements HostedControlManager.ManagedProcess { + private final AtomicInteger waits = new AtomicInteger(); + private final CompletableFuture exit = new CompletableFuture<>(); + private volatile boolean forciblyDestroyed; + + @Override public boolean isAlive() { return true; } + @Override public void destroy() { } + @Override public void destroyForcibly() { forciblyDestroyed = true; } + @Override public boolean waitFor(long timeout, TimeUnit unit) { + waits.incrementAndGet(); + return false; + } + @Override public CompletableFuture onExit() { return exit; } + } + + private static final class RetainedProcess implements HostedControlManager.ManagedProcess { + private final CompletableFuture exit = new CompletableFuture<>(); + private volatile boolean alive = true; + + @Override public boolean isAlive() { return alive; } + @Override public void destroy() { } + @Override public void destroyForcibly() { } + @Override public boolean waitFor(long timeout, TimeUnit unit) { return false; } + @Override public CompletableFuture onExit() { return exit; } + private void exit() { + alive = false; + exit.complete(null); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyControlResultStoreTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyControlResultStoreTest.java new file mode 100644 index 0000000000..5f6551bde0 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyControlResultStoreTest.java @@ -0,0 +1,66 @@ +package com.bencodez.votingplugin.proxy.control; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.votingplugin.proxy.control.ProxyControlResultStore.Route; +import com.bencodez.votingplugin.proxy.control.ProxyControlResultStore.StoredResult; +import com.google.gson.JsonObject; + +class ProxyControlResultStoreTest { + @TempDir Path directory; + + @Test void proxyResultAndOriginSurviveRestartUntilControlAcknowledgesThem() throws Exception { + UUID operationId = UUID.fromString("00000000-0000-0000-0000-000000000099"); + Route route = new Route("proxy-old", "Proxy Old", "VELOCITY", "7.1.2", + URI.create("https://control.example:8443"), "old-credential.txt", 30, 3000, 5000); + JsonObject result = new JsonObject(); + result.addProperty("success", true); + result.addProperty("revision", "applied-revision"); + Map pending = new LinkedHashMap<>(); + pending.put(operationId, new StoredResult(result, true, false)); + + ProxyControlResultStore.save(directory, route, pending); + ProxyControlResultStore.State recovered = ProxyControlResultStore.load(directory); + + assertEquals(route, recovered.route()); + assertEquals("applied-revision", recovered.results().get(operationId).result().get("revision").getAsString()); + assertTrue(recovered.results().get(operationId).committed()); + ProxyControlResultStore.save(directory, route, Map.of()); + assertFalse(Files.exists(directory.resolve(".control-proxy-pending-results.json"))); + } + + @Test void writeAheadIntentRetainsItsUncommittedStateAcrossRestart() throws Exception { + UUID operationId = UUID.fromString("00000000-0000-0000-0000-000000000099"); + Route route = new Route("proxy-old", "Proxy Old", "VELOCITY", "7.1.2", + URI.create("https://control.example:8443"), "old-credential.txt", 30, 3000, 5000); + JsonObject result = new JsonObject(); + result.addProperty("revision", "anticipated-revision"); + + ProxyControlResultStore.save(directory, route, Map.of(operationId, new StoredResult(result, false, false))); + + StoredResult recovered = ProxyControlResultStore.load(directory).results().get(operationId); + assertFalse(recovered.committed()); + assertFalse(recovered.claimRequired()); + } + + @Test void symbolicProxyResultJournalIsRejected() throws Exception { + Path external = directory.resolve("external.json"); + Files.writeString(external, "{}"); + Files.createSymbolicLink(directory.resolve(".control-proxy-pending-results.json"), external); + + assertThrows(java.io.IOException.class, () -> ProxyControlResultStore.load(directory)); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyRoutingConfigurationServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyRoutingConfigurationServiceTest.java new file mode 100644 index 0000000000..ff7df8876f --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyRoutingConfigurationServiceTest.java @@ -0,0 +1,203 @@ +package com.bencodez.votingplugin.proxy.control; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import com.bencodez.votingplugin.util.DurableFiles; + +class ProxyRoutingConfigurationServiceTest { + @Test + void validatesBlockedServersAgainstCompleteConfiguredSet() { + FakePlatform platform = new FakePlatform(); + platform.configuredServers = Set.of("Lobby"); + + ProxyRoutingConfigurationService service = new ProxyRoutingConfigurationService(platform); + assertDoesNotThrow(() -> service.validate(new ProxyRoutingConfiguration(true, List.of("Lobby")))); + assertThrows(IllegalArgumentException.class, + () -> service.validate(new ProxyRoutingConfiguration(true, List.of("lobby")))); + } + + @Test + void strictReloadFailureRestoresBackupAndReloadsAgain() throws Exception { + ProxyRoutingConfiguration current = new ProxyRoutingConfiguration(false, List.of()); + ProxyRoutingConfiguration proposal = new ProxyRoutingConfiguration(true, List.of()); + FakePlatform platform = new FakePlatform(); + platform.current = current; + platform.failFirstReload = true; + + ProxyRoutingConfigurationService.ApplyFailureException failure = assertThrows( + ProxyRoutingConfigurationService.ApplyFailureException.class, + () -> new ProxyRoutingConfigurationService(platform).apply(proposal, current.revision())); + + assertTrue(failure.rolledBack()); + assertTrue(platform.rolledBack); + assertTrue(platform.reloadCalls == 2); + } + + @Test + void persistenceRevisionRaceRemainsAStaleRevision() { + ProxyRoutingConfiguration current = new ProxyRoutingConfiguration(false, List.of()); + FakePlatform platform = new FakePlatform(); + platform.current = current; + platform.staleDuringPersist = true; + + assertThrows(ProxyRoutingConfigurationService.StaleRevisionException.class, + () -> new ProxyRoutingConfigurationService(platform).apply( + new ProxyRoutingConfiguration(true, List.of()), current.revision())); + assertTrue(platform.reloadCalls == 0); + } + + @Test + void postRenameDirectoryForceFailureRollsBackPublishedConfiguration() { + ProxyRoutingConfiguration current = new ProxyRoutingConfiguration(false, List.of()); + FakePlatform platform = new FakePlatform(); + platform.current = current; + platform.failAfterPublication = true; + + ProxyRoutingConfigurationService.ApplyFailureException failure = assertThrows( + ProxyRoutingConfigurationService.ApplyFailureException.class, + () -> new ProxyRoutingConfigurationService(platform).apply( + new ProxyRoutingConfiguration(true, List.of()), current.revision())); + + assertTrue(failure.rolledBack()); + assertTrue(platform.rolledBack); + assertTrue(platform.current.equals(current)); + assertTrue(platform.reloadCalls == 1); + } + + @Test + void publishedRollbackStillReloadsTheRestoredConfiguration() { + ProxyRoutingConfiguration current = new ProxyRoutingConfiguration(false, List.of()); + FakePlatform platform = new FakePlatform(); + platform.current = current; + platform.failFirstReload = true; + platform.failAfterRollbackPublication = true; + + ProxyRoutingConfigurationService.ApplyFailureException failure = assertThrows( + ProxyRoutingConfigurationService.ApplyFailureException.class, + () -> new ProxyRoutingConfigurationService(platform).apply( + new ProxyRoutingConfiguration(true, List.of()), current.revision())); + + assertTrue(failure.rolledBack()); + assertTrue(platform.rolledBack); + assertTrue(platform.current.equals(current)); + assertTrue(platform.reloadCalls == 2); + assertTrue(java.util.Arrays.stream(failure.getCause().getSuppressed()) + .anyMatch(DurableFiles.PublishedException.class::isInstance)); + } + + @Test + void failedReloadPreservesAConcurrentEditDetectedByTheAdapter() { + ProxyRoutingConfiguration current = new ProxyRoutingConfiguration(false, List.of()); + FakePlatform platform = new FakePlatform(); + platform.current = current; + platform.manualEditDuringFailedReload = true; + + ProxyRoutingConfigurationService.ApplyFailureException failure = assertThrows( + ProxyRoutingConfigurationService.ApplyFailureException.class, + () -> new ProxyRoutingConfigurationService(platform).apply( + new ProxyRoutingConfiguration(true, List.of()), current.revision())); + + assertFalse(failure.rolledBack()); + assertFalse(platform.rolledBack); + assertTrue(platform.current.blockedServers().contains("manual-edit")); + } + + @Test + void successfulReloadReconcilesAndRejectsAConcurrentEdit() { + ProxyRoutingConfiguration current = new ProxyRoutingConfiguration(false, List.of()); + FakePlatform platform = new FakePlatform(); + platform.current = current; + platform.manualEditDuringSuccessfulReload = true; + + assertThrows(ProxyRoutingConfigurationService.StaleRevisionException.class, + () -> new ProxyRoutingConfigurationService(platform).apply( + new ProxyRoutingConfiguration(true, List.of()), current.revision())); + assertTrue(platform.reloadCalls == 2); + assertTrue(platform.current.blockedServers().contains("manual-edit")); + } + + @Test + void reconciliationReloadIsRecheckedAndRepeatedAfterAnotherConcurrentEdit() { + ProxyRoutingConfiguration current = new ProxyRoutingConfiguration(false, List.of()); + FakePlatform platform = new FakePlatform(); + platform.current = current; + platform.manualEditsDuringSuccessfulReloads = 2; + + assertThrows(ProxyRoutingConfigurationService.StaleRevisionException.class, + () -> new ProxyRoutingConfigurationService(platform).apply( + new ProxyRoutingConfiguration(true, List.of()), current.revision())); + assertTrue(platform.reloadCalls == 3); + assertTrue(platform.current.blockedServers().contains("manual-edit-2")); + } + + private static final class FakePlatform implements ProxyRoutingConfigurationService.Platform { + private ProxyRoutingConfiguration current = new ProxyRoutingConfiguration(false, List.of()); + private Set configuredServers = Set.of(); + private boolean failFirstReload; + private boolean rolledBack; + private boolean staleDuringPersist; + private boolean failAfterPublication; + private boolean failAfterRollbackPublication; + private boolean manualEditDuringFailedReload; + private boolean manualEditDuringSuccessfulReload; + private int manualEditsDuringSuccessfulReloads; + private ProxyRoutingConfiguration installed; + private ProxyRoutingConfiguration previous; + private int reloadCalls; + + @Override public ProxyRoutingConfiguration read() { return current; } + @Override public Set configuredServers() { return configuredServers; } + @Override public void persist(ProxyRoutingConfiguration proposal, String expectedRevision) throws IOException { + if (staleDuringPersist) throw new com.bencodez.votingplugin.proxy.VotingPluginProxyConfig.StaleControlRevisionException(); + previous = current; + current = proposal; + installed = proposal; + if (failAfterPublication) throw new DurableFiles.PublishedException(new IOException("directory force failed")); + } + @Override public void rollback() throws IOException { + if (!current.equals(installed)) throw new IOException("active configuration changed"); + current = previous; + rolledBack = true; + if (failAfterRollbackPublication) { + throw new DurableFiles.PublishedException(new IOException("directory force failed")); + } + } + @Override public void reload() throws Exception { + reloadCalls++; + if (manualEditDuringSuccessfulReload && reloadCalls == 1) { + current = new ProxyRoutingConfiguration(false, List.of("manual-edit")); + } + if (reloadCalls <= manualEditsDuringSuccessfulReloads) { + current = new ProxyRoutingConfiguration(false, List.of("manual-edit-" + reloadCalls)); + } + if (manualEditDuringFailedReload && reloadCalls == 1) { + current = new ProxyRoutingConfiguration(false, List.of("manual-edit")); + throw new IOException("invalid reloaded configuration"); + } + if (failFirstReload && reloadCalls == 1) throw new IOException("invalid reloaded configuration"); + } + @Override public void verifyInstalled() throws IOException { + if (!current.equals(installed)) { + throw new com.bencodez.votingplugin.proxy.VotingPluginProxyConfig.StaleControlRevisionException(); + } + } + @Override public byte[] captureInstalledSnapshot() { + return current.revision().getBytes(StandardCharsets.US_ASCII); + } + @Override public void verifyInstalledSnapshot(byte[] snapshot) throws IOException { + if (!java.util.Arrays.equals(snapshot, current.revision().getBytes(StandardCharsets.US_ASCII))) { + throw new com.bencodez.votingplugin.proxy.VotingPluginProxyConfig.StaleControlRevisionException(); + } + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyRoutingConfigurationTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyRoutingConfigurationTest.java new file mode 100644 index 0000000000..e4ac9f4da8 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyRoutingConfigurationTest.java @@ -0,0 +1,27 @@ +package com.bencodez.votingplugin.proxy.control; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class ProxyRoutingConfigurationTest { + @Test void revisionIgnoresOrderButPreservesBehaviorallySignificantCase() { + ProxyRoutingConfiguration first = new ProxyRoutingConfiguration(true, List.of("Lobby", "survival")); + ProxyRoutingConfiguration second = new ProxyRoutingConfiguration(true, List.of("survival", "Lobby")); + assertEquals(first.revision(), second.revision()); + assertNotEquals(first.revision(), + new ProxyRoutingConfiguration(true, List.of("lobby", "survival")).revision()); + } + + @Test void rejectsDuplicateBlankAndOversizedServerLists() { + assertThrows(IllegalArgumentException.class, + () -> new ProxyRoutingConfiguration(true, List.of("Lobby", "lobby"))); + assertThrows(IllegalArgumentException.class, + () -> new ProxyRoutingConfiguration(true, List.of(" "))); + assertThrows(IllegalArgumentException.class, + () -> new ProxyRoutingConfiguration(true, java.util.Collections.nCopies(257, "lobby"))); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandlerLifecycleTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandlerLifecycleTest.java new file mode 100644 index 0000000000..d1fbadaf97 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandlerLifecycleTest.java @@ -0,0 +1,30 @@ +package com.bencodez.votingplugin.proxy.multiproxy; + +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import com.bencodez.simpleapi.servercomm.sockets.ClientHandler; + +class MultiProxyHandlerLifecycleTest { + + @Test + void stopsEveryReplacedSocketClientEvenWhenOneStopFails() { + ClientHandler failing = mock(ClientHandler.class); + ClientHandler healthy = mock(ClientHandler.class); + doThrow(new IllegalStateException("already closed")).when(failing).stopConnection(); + Map clients = new LinkedHashMap<>(); + clients.put("failing", failing); + clients.put("healthy", healthy); + + MultiProxyHandler.stopSocketClients(clients); + + verify(failing).stopConnection(); + verify(healthy).stopConnection(); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfigControlTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfigControlTest.java new file mode 100644 index 0000000000..13660a25ab --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfigControlTest.java @@ -0,0 +1,55 @@ +package com.bencodez.votingplugin.proxy.velocity; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.votingplugin.proxy.control.ProxyRoutingConfiguration; + +class VelocityConfigControlTest { + @TempDir Path directory; + + @Test + void omittedSendVotesSettingUsesRuntimeDefaultDuringRevisionCheck() throws Exception { + Path file = directory.resolve("velocity.yml"); + Files.writeString(file, "BlockedServers: []\n"); + VelocityConfig config = new VelocityConfig(file.toFile()); + config.loadControlConfiguration(); + ProxyRoutingConfiguration current = new ProxyRoutingConfiguration(config.getSendVotesToAllServers(), + config.getBlockedServers()); + + assertTrue(config.getSendVotesToAllServers()); + config.persistControlProxyRouting(false, List.of("lobby"), current.revision()); + config.loadControlConfiguration(); + + assertFalse(config.getSendVotesToAllServers()); + assertTrue(config.getBlockedServers().contains("lobby")); + } + + @Test + void rollbackRejectsASymbolicBackupSidecar() throws Exception { + Path file = directory.resolve("velocity.yml"); + Files.writeString(file, "SendVotesToAllServers: true\nBlockedServers: []\n"); + VelocityConfig config = new VelocityConfig(file.toFile()); + config.loadControlConfiguration(); + ProxyRoutingConfiguration current = new ProxyRoutingConfiguration(config.getSendVotesToAllServers(), + config.getBlockedServers()); + config.persistControlProxyRouting(false, List.of("lobby"), current.revision()); + Path backup = file.resolveSibling(file.getFileName() + ".control-backup"); + Path external = directory.resolve("external.yml"); + Files.writeString(external, "external: preserved\n"); + Files.delete(backup); + Files.createSymbolicLink(backup, external); + + assertThrows(java.io.IOException.class, config::rollbackControlProxyRouting); + assertTrue(Files.readString(file).contains("lobby")); + assertTrue(Files.readString(external).contains("preserved")); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTestImpl.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTestImpl.java index 500c043708..2bfbe1e7c6 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTestImpl.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTestImpl.java @@ -45,6 +45,11 @@ public Set getAllAvailableServers() { return new HashSet<>(Arrays.asList("Server1", "Server2")); } + @Override + public Set getAllConfiguredServers() { + return getAllAvailableServers(); + } + @Override public VotingPluginProxyConfig getConfig() { if (config == null) { @@ -81,6 +86,11 @@ public String getPluginVersion() { return "1.0.0"; } + @Override + public String getProxyPlatform() { + return "VELOCITY"; + } + @Override public int getVoteCacheCurrentVotePartyVotes() { return 0; @@ -155,6 +165,11 @@ public void reloadCore(boolean mysql) { // Mocked for testing } + @Override + public void reloadControlConfiguration() throws Exception { + // Mocked for testing + } + @Override public void log(String message) { // For testing, simply print the message diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/backendproxy/cache/ProcessedVoteCacheTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/backendproxy/cache/ProcessedVoteCacheTest.java index f98fce6761..66bcd22dfc 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/backendproxy/cache/ProcessedVoteCacheTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/backendproxy/cache/ProcessedVoteCacheTest.java @@ -4,6 +4,8 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.lang.reflect.Field; +import java.util.Map; import java.util.UUID; import org.junit.jupiter.api.Test; @@ -38,4 +40,62 @@ public void expiredVoteIdCanBeReservedAgain() throws Exception { assertTrue(cache.reserve(voteId)); assertEquals(1, cache.getProcessedVotes().size()); } + + @Test + public void redisDeliveryIsAcceptedOnceAcrossSharedHandlers() { + ProcessedVoteCache cache = new ProcessedVoteCache(); + String deliveryId = UUID.randomUUID().toString(); + assertTrue(cache.reserveRedisDelivery(deliveryId)); + assertFalse(cache.reserveRedisDelivery(deliveryId)); + assertTrue(cache.reserveRedisDelivery(UUID.randomUUID().toString())); + assertTrue(cache.reserveRedisDelivery(null)); + } + + @Test + public void legacyRedisHandoffMatchesOldAndBufferedDeliveryCounts() { + ProcessedVoteCache cache = new ProcessedVoteCache(); + Object previous = new Object(); + Object replacement = new Object(); + cache.registerRedisSubscriber(previous); + cache.registerRedisSubscriber(replacement); + + assertTrue(cache.reserveLegacyRedisDelivery(previous, "same-envelope")); + assertFalse(cache.reserveLegacyRedisDelivery(replacement, "same-envelope")); + cache.activateRedisSubscriber(replacement); + assertTrue(cache.consumeLegacyRedisDelivery("same-envelope")); + assertFalse(cache.consumeLegacyRedisDelivery("same-envelope")); + cache.finishRedisHandoff(); + } + + @Test + public void legacyRedisHandoffAccountingIsBounded() throws Exception { + ProcessedVoteCache cache = new ProcessedVoteCache(); + Object previous = new Object(); + Object replacement = new Object(); + cache.registerRedisSubscriber(previous); + cache.registerRedisSubscriber(replacement); + + for (int index = 0; index < 5000; index++) { + assertTrue(cache.reserveLegacyRedisDelivery(previous, "envelope-" + index)); + } + + Field deliveries = ProcessedVoteCache.class.getDeclaredField("legacyRedisDeliveries"); + deliveries.setAccessible(true); + assertEquals(4096, ((Map) deliveries.get(cache)).size()); + } + + @Test + public void legacyRedisHandoffAccountingRejectsOversizedSignatures() throws Exception { + ProcessedVoteCache cache = new ProcessedVoteCache(); + Object previous = new Object(); + cache.registerRedisSubscriber(previous); + cache.registerRedisSubscriber(new Object()); + + assertTrue(cache.reserveLegacyRedisDelivery(previous, + "x".repeat(ProcessedVoteCache.MAX_LEGACY_REDIS_DELIVERY_BYTES + 1))); + + Field deliveries = ProcessedVoteCache.class.getDeclaredField("legacyRedisDeliveries"); + deliveries.setAccessible(true); + assertTrue(((Map) deliveries.get(cache)).isEmpty()); + } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/ControlCredentialFileTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/ControlCredentialFileTest.java new file mode 100644 index 0000000000..df0f475de1 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/ControlCredentialFileTest.java @@ -0,0 +1,30 @@ +package com.bencodez.votingplugin.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ControlCredentialFileTest { + @TempDir Path directory; + + @Test void readsContainedFileAndRejectsFileDirectoryAndTraversalEscapes() throws Exception { + Path root = Files.createDirectory(directory.resolve("plugin")); + Path outside = Files.createDirectory(directory.resolve("outside")); + Files.writeString(root.resolve("credential.txt"), "vpctl_node_valid\n"); + assertEquals("vpctl_node_valid", ControlCredentialFile.read(root, "credential.txt")); + + Path external = outside.resolve("external.txt"); + Files.writeString(external, "vpctl_node_external"); + Files.createSymbolicLink(root.resolve("linked.txt"), external); + assertThrows(java.io.IOException.class, () -> ControlCredentialFile.read(root, "linked.txt")); + Files.createSymbolicLink(root.resolve("linked-directory"), outside); + assertThrows(java.io.IOException.class, + () -> ControlCredentialFile.read(root, "linked-directory/external.txt")); + assertThrows(java.io.IOException.class, () -> ControlCredentialFile.read(root, "../outside/external.txt")); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/DurableFilesTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/DurableFilesTest.java new file mode 100644 index 0000000000..c58b44a41a --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/DurableFilesTest.java @@ -0,0 +1,16 @@ +package com.bencodez.votingplugin.util; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class DurableFilesTest { + @Test + void identifiesWindowsWithoutMistakingDarwin() { + assertTrue(DurableFiles.isWindowsName("Windows 11")); + assertTrue(DurableFiles.isWindowsName("Windows Server 2022")); + assertFalse(DurableFiles.isWindowsName("Darwin")); + assertFalse(DurableFiles.isWindowsName("Linux")); + } +} diff --git a/docs/control-connector.md b/docs/control-connector.md new file mode 100644 index 0000000000..11fc8c4369 --- /dev/null +++ b/docs/control-connector.md @@ -0,0 +1,197 @@ +# VotingPlugin Control discovery connector + +This development milestone adds an optional connector from the BungeeCord and Velocity VotingPlugin proxy JAR +to the separate [VotingPlugin Control](https://github.com/BenCodez/VotingPlugin-Control) application. It discovers each +proxy and its eligible backend server names. Enrolled Bukkit nodes can also connect outbound for full configuration-file +and quick-setup control. Control includes the local WebUI. VotingPlugin may now explicitly opt in to +provisioning and supervising that application as a separate child JVM; it is never loaded into the proxy classloader. The +connector does not expose backend addresses. Configuration secrets are masked on every read and never enter audit records. + +## Failure isolation + +The connector is disabled by default. Missing `Control` keys therefore preserve existing installations exactly. When +enabled, startup performs only bounded local configuration/credential reads; network requests use Java's asynchronous HTTP +client outside proxy event-loop/main threads. There is at most one in-flight discovery cycle and no request queue. + +Control being stopped, slow, malformed, unauthorized, or protocol-incompatible changes only connector status and its +rate-limited `[Control]` log. Voting, proxy startup, joins, server switches, existing plugin messages, and shutdown do not +call into or wait on Control. Retries use exponential backoff from one second to five minutes with per-process jitter. +Shutdown cancels the scheduled cycle and active future without waiting indefinitely. + +The hosted-Control manager is independently disabled by default. Downloads, hashing, activation, health checks, process +startup, and restart backoff run on its own daemon worker. Hosting failure changes only its redacted status/log and never +blocks the proxy event loop or vote processing. Stopping VotingPlugin asks only the child process it created to terminate +and applies a bounded asynchronous force-stop fallback. + +## Optional hosted WebUI + +Use one hosted Control instance for the network. Other proxies should use the connector against that instance rather than +each starting a competing service. Hosting and discovery are separate switches: `Control.Hosted.Enabled` runs Control, +while `Control.Enabled` reports this proxy to it. + +Control releases publish `votingplugin-control.jar` and `votingplugin-control.jar.sha256`. Configure an immutable versioned +asset URL and copy its exact digest into `Sha256`; `/latest/` URLs, unpinned artifacts, plaintext download URLs, redirects +outside HTTPS, and paths outside VotingPlugin's data folder are rejected. + +```yaml +Control: + Enabled: true + Endpoint: 'http://127.0.0.1:8080' + NodeId: 'proxy-a' + CredentialFile: 'control-credential.txt' + Hosted: + Enabled: true + AutoDownload: true + AutoUpdate: false + DownloadUrl: 'https://github.com/BenCodez/VotingPlugin-Control/releases/download/v0.1.0/votingplugin-control.jar' + Sha256: '<64-character SHA-256 from the pinned release>' + JarFile: 'control/votingplugin-control.jar' + DataDirectory: 'control/data' + Host: '127.0.0.1' + Port: 8080 + StartupTimeoutSeconds: 30 + DownloadTimeoutSeconds: 60 +``` + +On first start, the artifact is downloaded to a unique staging file with a 64 MiB hard limit, verified, and atomically +activated before launch with the same Java runtime as the proxy. An existing matching manual installation is launched +without downloading. `AutoUpdate` is false by default; when explicitly enabled and the configured digest changes, the +current release is retained as `.previous`. A failed process/protocol health check atomically restores and starts that +previous release, retaining the failed candidate as `.failed`. Unexpected exits use bounded restart backoff. + +The child receives only its bind host, port, contained data directory, and a random per-launch ID. Health must echo that +ID, preventing an unrelated Control process on the same port from satisfying startup checks. It does not receive +VotingPlugin configuration or credentials in release metadata. Process output is written beside the hosted JAR and rotated at 1 MiB. Configure the +WebUI password directly against the hosted data directory (the prompt is not echoed and the password is not an argument): + +```shell +java -jar plugins/VotingPlugin/control/votingplugin-control.jar web-password plugins/VotingPlugin/control/data +``` + +The WebUI is then available at `http://127.0.0.1:8080/`. Password rotation invalidates every existing browser session. +To expose a non-loopback listener, create either the WebUI password or an admin API token in +`Control.Hosted.DataDirectory` first and use HTTPS or a private tunnel; Control otherwise refuses the bind. + +Manual installation remains supported: put a Control JAR at `JarFile`, configure its exact SHA-256, set `AutoDownload` and +`AutoUpdate` false, and enable hosting. VotingPlugin will verify and supervise it without network access. + +## Enrollment and configuration + +1. Build/run Control and use its owner command to enroll the exact stable node ID: + + ```shell + java -jar votingplugin-control-0.1.0-SNAPSHOT-all.jar enroll proxy-a data + ``` + +2. On that proxy, create `plugins/VotingPlugin/control-credential.txt` (or the platform-equivalent VotingPlugin proxy data + directory) containing only the printed credential. Restrict filesystem access to the proxy service account. + +3. Configure `bungeeconfig.yml`: + + ```yaml + Control: + Enabled: true + Endpoint: 'http://127.0.0.1:8080' + NodeId: 'proxy-a' + CredentialFile: 'control-credential.txt' + HeartbeatSeconds: 30 + ConnectTimeoutMillis: 3000 + RequestTimeoutMillis: 5000 + ``` + +`NodeId` may be blank to reuse the existing `ProxyServerName`, but every simultaneously connected proxy must have a unique, +stable enrolled identity. Explicitly setting it is recommended. `CredentialFile` must resolve inside VotingPlugin's data +directory; credentials in URLs and parent-directory traversal are rejected. The credential is never logged or placed in +plugin messages. + +The endpoint must be an `http` or `https` origin without embedded credentials, query, fragment, or path. Plain HTTP is +suitable only for loopback or a trusted private network. Use HTTPS or a private authenticated tunnel when crossing an +untrusted network. Control authentication does not itself encrypt traffic. + +Timing bounds are intentional: + +- heartbeat: 10–300 seconds; +- connect/request timeout: 500–30,000 milliseconds; +- response body: at most 64 KiB; +- backend snapshot: at most 4096 entries. + +Do not raise these by patching around validation; correct the topology or connectivity problem instead. + +### Bukkit full-configuration enrollment + +Each backend is separately opt-in and separately enrolled so one node credential cannot impersonate another. Put its +credential in that backend's VotingPlugin data folder and configure `Config.yml`: + +```yaml +Control: + Backend: + Enabled: true + NodeId: 'backend-lobby' + Endpoint: 'http://127.0.0.1:8080' + CredentialFile: 'control-credential.txt' + HeartbeatSeconds: 30 + ConnectTimeoutMillis: 3000 + RequestTimeoutMillis: 10000 +``` + +The Bukkit connector owns one daemon worker and performs no Control I/O on the server thread. It reports a bounded list of +installed plugin names for WebUI command suggestions and negotiates +`config.files.v1` and `config.quick-setup.v1`, then polls the same outbound operation queue as proxies. File apply schedules +the VotingPlugin reload on the Bukkit thread and waits only on the connector worker. Control failure never blocks votes, +joins, commands, or plugin shutdown. A successful `Config.yml` apply reports its result first, then recreates the connector +so changes to `Control.Backend` take effect without a full server restart. + +## Discovery semantics + +Both platforms use the same connector implementation and protocol version `1`. Each proxy process creates a new session +ID, registers, and sends full replacement backend snapshots with a monotonically increasing sequence. Registration and +retries are idempotent. A Control restart is detected by a 404 heartbeat/presence response and causes re-registration. + +Backend identity preserves VotingPlugin's existing eligible server name when it is already a protocol-safe identifier; +other configured names receive a deterministic SHA-256-derived ID while retaining a bounded safe display name. For transports that support VotingPlugin's backend +presence protocol, the existing `BackendPlayerPresenceTracker` supplies availability and player count. Where that protocol +has no authoritative observation, the backend remains discoverable with `presenceKnown: false`; the connector does not +invent an online state from a server address or arbitrary wall-clock timestamp. + +The connector requires `presence.snapshot` and advertises `config.proxy-routing.v1` when its typed adapter is available. +The latter permits reads and preview/apply of only `SendVotesToAllServers` and `BlockedServers`. Every preview validates +blocked names as exact, case-preserving matches against that proxy's configured backends and returns a deterministic +SHA-256 revision. Apply rejects stale +revisions, writes a local `.control-backup`, requires atomic activation of a staged YAML file, and soft-reloads the +proxy. A reload failure triggers immediate backup restoration and another reload attempt; Control receives the per-node +reload/rollback result. Task results are cached by operation ID so a leased retry cannot apply the same change twice. + +For enrolled Bukkit nodes, the WebUI can read and edit all six user-facing files: `Config.yml`, `VoteSites.yml`, +`SpecialRewards.yml`, `GUI.yml`, `Shop.yml`, and `BungeeSettings.yml`. YAML is parsed before preview; each apply uses an +exact SHA-256 revision, stages and atomically installs the file, retains `.control-backup`, reloads, and restores the backup +if reload fails. Returned YAML is normalized and masks password/secret/token/API-key/authorization/webhook-secret paths +with `__VOTINGPLUGIN_CONTROL_REDACTED__`; leaving the marker unchanged preserves the local value. A replacement secret may +be submitted through the authenticated preview, but is never returned or audited. + +Quick setups cover standalone backend mode, proxy-connected backend mode with an explicit server identity, adding/updating +a vote site, an easy per-site or every-site command/message reward, six common operational toggles, and vote-party basics. +Detected Essentials/EssentialsX, CMI, and LuckPerms installations add editable command suggestions alongside generic +Minecraft rewards. Plugin detection does not inspect third-party configuration or versions. Every shortcut uses the same +preview, revision, approval, backup, reload, and rollback path—not a bypass. + +An admin must select capable online nodes, preview the change, and confirm the +single-use approval generated for that exact successful preview. Nodes claim work over their existing outbound connection, +so no inbound listener is added to VotingPlugin. Authentication failures, unavailable required capabilities, +and protocol mismatches back off only the connector. Current redacted diagnostic status is one of `DISABLED`, `STARTING`, +`CONNECTED`, `AUTHENTICATION_FAILED`, `INCOMPATIBLE`, `UNAVAILABLE`, or `STOPPED`. + +## Rotation, disabling, and troubleshooting + +- Rotate with Control's `enroll ` command, replace the credential file, then reload/restart VotingPlugin. +- Revoke immediately with Control's `revoke ` command. +- Disable by setting `Control.Enabled: false` and reloading/restarting; deleting all new keys also returns to the disabled +default. +- `AUTHENTICATION_FAILED`: confirm exact node-ID enrollment and replace a revoked/rotated credential. +- `INCOMPATIBLE`: the two JARs do not share protocol version/capability support; upgrade the older side. +- `UNAVAILABLE`: verify endpoint routing, Control health, TLS trust, and timeout settings. +- Backend listed with unknown presence: this is expected when the selected existing VotingPlugin transport does not provide + backend-presence observations. + +Arbitrary console commands, manual rollback, topology persistence, cloud relay, diagnostics downloads, signed remote +release manifests, and remote support remain later milestones. Hosted downloads currently require the SHA-256 trust pin +to be supplied in local configuration; VotingPlugin never trusts a remotely fetched checksum by itself.