From 0406ff7b109508061fa7f76ed2b9961fc161d089 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:49:28 -0600 Subject: [PATCH] Abort invalid Velocity configuration reload before runtime changes --- .../proxy/velocity/VelocityConfig.java | 18 ++- .../proxy/velocity/VotingPluginVelocity.java | 12 +- .../velocity/VelocityConfigReloadTest.java | 106 ++++++++++++++++++ docs/proxy-vote-delivery.md | 9 ++ 4 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfigReloadTest.java 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 4c855ad618..adcebe3c8f 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,7 +1,9 @@ package com.bencodez.votingplugin.proxy.velocity; +import java.io.BufferedReader; import java.io.File; import java.io.IOException; +import java.io.UncheckedIOException; import java.nio.channels.Channels; import java.nio.channels.SeekableByteChannel; import java.nio.file.Files; @@ -203,9 +205,23 @@ public synchronized void verifyControlProxyRoutingSnapshot(byte[] snapshot) thro } } + /** Normal reload must never publish defaults after a failed replacement load. */ + @Override + public synchronized void reload() { + try { + loadControlConfiguration(); + } catch (IOException failure) { + throw new UncheckedIOException("Velocity configuration reload failed; previous configuration remains active", failure); + } + } + /** 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(); + ConfigurationNode loaded; + // Open first so Configurate cannot substitute an empty root for a missing file. + try (BufferedReader reader = Files.newBufferedReader(configurationFile.toPath())) { + loaded = YamlConfigurationLoader.builder().source(() -> reader).build().load(); + } setConf(loaded); } 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 7beb489de3..b1ea08a776 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 @@ -699,15 +699,17 @@ public boolean isReloading() { */ public void reloadAllInternal(boolean loadMysql) { synchronized (reloadLock) { - final boolean retainedRuntimeWasOperational = runtimeOperational; - reloading = true; - cancelTasks(); - + // Reject an invalid replacement before fencing votes or retiring runtime services. try { config.reload(); } catch (Exception e) { - logger.error("Failed to reload bungeeconfig.yml", e); + // Parser exceptions may contain administrator secrets from the source YAML. + logger.error("Failed to reload bungeeconfig.yml; the previous configuration and runtime remain active."); + return; } + final boolean retainedRuntimeWasOperational = runtimeOperational; + reloading = true; + cancelTasks(); try { ChannelIdentifier old = channel; diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfigReloadTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfigReloadTest.java new file mode 100644 index 0000000000..1eb2180100 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfigReloadTest.java @@ -0,0 +1,106 @@ +package com.bencodez.votingplugin.proxy.velocity; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.bstats.velocity.Metrics; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.Logger; + +import com.bencodez.votingplugin.proxy.VotingPluginProxy; +import com.velocitypowered.api.proxy.ProxyServer; +import com.velocitypowered.api.scheduler.ScheduledTask; + +class VelocityConfigReloadTest { + @TempDir Path directory; + + @Test void malformedNormalReloadPreservesMqttAndAdministratorSettings() throws Exception { + Path file = validFile(); + VelocityConfig config = new VelocityConfig(file.toFile()); + var active = config.getData(); + String malformed = "BungeeMethod: [broken\n"; + Files.writeString(file, malformed); + assertThrows(java.io.UncheckedIOException.class, config::reload); + assertSame(active, config.getData()); + assertEquals("MQTT", config.getBungeeMethod()); + assertEquals("administrator", config.getNode("Custom", "Label").getString()); + assertEquals(malformed, Files.readString(file)); + Files.writeString(file, "BungeeMethod: REDIS\nCustom:\n Label: repaired\n"); + config.reload(); + assertEquals("REDIS", config.getBungeeMethod()); + assertEquals("repaired", config.getNode("Custom", "Label").getString()); + } + + @Test void strictControlReloadStillRejectsWithoutReplacingSnapshot() throws Exception { + Path file = validFile(); + VelocityConfig config = new VelocityConfig(file.toFile()); + var active = config.getData(); + Files.writeString(file, "BungeeMethod: [broken\n"); + assertThrows(IOException.class, config::loadControlConfiguration); + assertSame(active, config.getData()); + assertEquals("MQTT", config.getBungeeMethod()); + } + + @Test void missingReloadPreservesSnapshotButMissingStartupUsesDefaults() throws Exception { + Path file = validFile(); + VelocityConfig config = new VelocityConfig(file.toFile()); + var active = config.getData(); + Files.delete(file); + assertThrows(java.io.UncheckedIOException.class, config::reload); + assertSame(active, config.getData()); + assertFalse(Files.exists(file)); + VelocityConfig firstStartup = new VelocityConfig(file.toFile()); + assertEquals("PLUGINMESSAGING", firstStartup.getBungeeMethod()); + assertTrue(Files.exists(file)); + } + + @Test void malformedRuntimeReloadAbortsBeforeTasksChannelsOrTransportChange() throws Exception { + for (boolean full : new boolean[] {false, true}) { + Path file = validFile(); + VelocityConfig config = new VelocityConfig(file.toFile()); + var active = config.getData(); + ProxyServer server = mock(ProxyServer.class); + Logger logger = mock(Logger.class); + VotingPluginVelocity plugin = new VotingPluginVelocity(server, logger, + mock(Metrics.Factory.class), directory); + VotingPluginProxy runtime = mock(VotingPluginProxy.class); + ScheduledTask voteCheck = mock(ScheduledTask.class); + ScheduledTask cacheSave = mock(ScheduledTask.class); + setField(plugin, "config", config); + setField(plugin, "votingPluginProxy", runtime); + setField(plugin, "runtimeOperational", true); + setField(plugin, "voteCheckTask", voteCheck); + setField(plugin, "cacheSaveTask", cacheSave); + String malformed = "BungeeMethod: [broken\n"; + Files.writeString(file, malformed); + try { + plugin.reloadAllInternal(full); + assertSame(active, config.getData()); + assertEquals("MQTT", config.getBungeeMethod()); + assertTrue(plugin.isRuntimeOperational()); + assertFalse(plugin.isReloading()); + assertSame(runtime, plugin.getVotingPluginProxy()); + verifyNoInteractions(runtime, voteCheck, cacheSave, server); + verify(logger).error("Failed to reload bungeeconfig.yml; the previous configuration and runtime remain active."); + assertEquals(malformed, Files.readString(file)); + } finally { plugin.getTimer().shutdownNow(); } + } + } + + private Path validFile() throws IOException { + Path file = directory.resolve("bungeeconfig.yml"); + Files.writeString(file, "BungeeMethod: MQTT\nCustom:\n Label: administrator\n"); + return file; + } + + private static void setField(Object target, String name, Object value) throws Exception { + var field = VotingPluginVelocity.class.getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/docs/proxy-vote-delivery.md b/docs/proxy-vote-delivery.md index a5061b3c69..d438e47c6a 100644 --- a/docs/proxy-vote-delivery.md +++ b/docs/proxy-vote-delivery.md @@ -52,3 +52,12 @@ HTTP retains its existing request recovery and also uses the completion outbox after capability discovery. The generic outbox covers plugin messaging, Redis, MQTT, MySQL, sockets, and HTTP. No reward data model or Bukkit vote ordering changes. + +## Velocity configuration reload failures + +Normal Velocity soft/full reload validates `bungeeconfig.yml` before changing +channels, cancelling tasks, or replacing the proxy runtime. If the file is +missing, unreadable, or malformed, reload logs a failure and keeps the previous +active configuration and runtime. It does not rewrite the invalid file or +substitute default transport settings. Repair the file and reload again. +This preservation contract is distinct from initial startup default handling.