From c39a934954f2a079d38821616727aae3a0536811 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:57:58 -0600 Subject: [PATCH 1/7] Add Control proxy configuration management --- VotingPlugin/pom.xml | 21 +- .../votingplugin/VotingPluginMain.java | 8 + .../control/BackendConfigurationService.java | 22 +- .../control/BackendControlConnector.java | 63 +- .../control/ControlInspectionService.java | 13 + .../control/ControlPlayerDataService.java | 104 +++ .../control/ControlRewardProposal.java | 10 +- .../proxy/control/ControlConnector.java | 119 ++- .../ProxyConfigurationFileService.java | 692 ++++++++++++++++++ .../control/ProxyControlResultStore.java | 8 +- .../proxy/velocity/VelocityConfig.java | 1 + .../src/main/resources/bungeeconfig.yml | 3 +- .../BackendConfigurationServiceTest.java | 39 + .../control/ControlInspectionServiceTest.java | 44 ++ .../proxy/control/ControlConnectorTest.java | 226 +++++- .../ProxyConfigurationFileServiceTest.java | 348 +++++++++ .../control/ProxyControlResultStoreTest.java | 21 + 17 files changed, 1705 insertions(+), 37 deletions(-) create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlPlayerDataService.java create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyConfigurationFileService.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyConfigurationFileServiceTest.java diff --git a/VotingPlugin/pom.xml b/VotingPlugin/pom.xml index 465e2f743..d555a6764 100644 --- a/VotingPlugin/pom.xml +++ b/VotingPlugin/pom.xml @@ -68,6 +68,11 @@ + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + org.apache.maven.plugins maven-compiler-plugin @@ -89,6 +94,10 @@ com.velocitypowered velocity-brigadier + + org.yaml + snakeyaml + @@ -164,6 +173,10 @@ io.leangen.geantyref ${project.groupId}.simpleapi.geantyref + + org.yaml.snakeyaml + ${project.groupId}.votingplugin.snakeyaml + @@ -254,6 +267,12 @@ + + + org.yaml + snakeyaml + 2.6 + org.spigotmc spigot-api @@ -692,4 +711,4 @@ - \ No newline at end of file + diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 06cf263bd..62fae9d95 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -1244,6 +1244,14 @@ public synchronized void restartBackendProxyHandler() { } } + /** Applies only proxy communication settings without reloading unrelated Bukkit configuration. */ + public synchronized void reloadBackendProxyMethodFromControl() { + bungeeSettings.reloadData(); + getOptions().setServer(bungeeSettings.getServer()); + updateAdvancedCoreHook(); + restartBackendProxyHandler(); + } + /** 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); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java index fc00e3e9b..c71168c86 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java @@ -96,6 +96,11 @@ public ApplyResult apply(String fileName, String proposedContent, String expecte private ApplyResult apply(String fileName, String proposedContent, String expectedRevision, boolean restoreRedactedSecrets) throws IOException { + return apply(fileName, proposedContent, expectedRevision, restoreRedactedSecrets, reload); + } + + private ApplyResult apply(String fileName, String proposedContent, String expectedRevision, + boolean restoreRedactedSecrets, ApplyAction applyAction) throws IOException { Path target = resolve(fileName); String current = readRaw(target, false); if (expectedRevision == null || !revision(current).equals(expectedRevision)) throw new StaleRevisionException(); @@ -120,11 +125,11 @@ private ApplyResult apply(String fileName, String proposedContent, String expect installed = true; throw published; } - reload.run(fileName); + applyAction.run(fileName); String applied = readRaw(target, false); String installedRevision = revision(preview.resolvedContent()); if (!revision(applied).equals(installedRevision)) { - reconcileConcurrentEdit(fileName, target); + reconcileConcurrentEdit(fileName, target, applyAction); throw new StaleRevisionException(); } return new ApplyResult(new Document(fileName, mask(parse(applied)), revision(applied)), @@ -146,7 +151,7 @@ private ApplyResult apply(String fileName, String proposedContent, String expect // still reload it so runtime and disk cannot diverge. failure.addSuppressed(published); } - reload.run(fileName); + applyAction.run(fileName); rolledBack = true; } catch (Exception rollbackFailure) { failure.addSuppressed(rollbackFailure); @@ -159,10 +164,10 @@ private ApplyResult apply(String fileName, String proposedContent, String expect } } - private void reconcileConcurrentEdit(String fileName, Path target) throws Exception { + private void reconcileConcurrentEdit(String fileName, Path target, ApplyAction applyAction) throws Exception { for (int attempt = 0; attempt < 3; attempt++) { String snapshotRevision = revision(readRaw(target, false)); - reload.run(fileName); + applyAction.run(fileName); if (revision(readRaw(target, false)).equals(snapshotRevision)) return; } throw new StaleRevisionException(); @@ -330,6 +335,11 @@ String currentQuickSetupRevision(String preset, Map options) thr public ApplyResult applyQuickSetup(String preset, Map options, String expectedRevision) throws IOException { + return applyQuickSetup(preset, options, expectedRevision, reload); + } + + ApplyResult applyQuickSetup(String preset, Map options, String expectedRevision, + ApplyAction applyAction) throws IOException { String fileName = quickSetupFile(preset, options); String current = readRaw(resolve(fileName), false); if (expectedRevision == null || !quickSetupRevision(preset, current).equals(expectedRevision)) { @@ -338,7 +348,7 @@ public ApplyResult applyQuickSetup(String preset, Map options, S QuickProposal proposal = quickProposal(preset, options, fileName, current); // Quick proposals are generated from this fresh, unmasked server snapshot. // Editor placeholder restoration must remain limited to client-authored YAML. - ApplyResult applied = apply(proposal.fileName(), proposal.content(), revision(current), false); + ApplyResult applied = apply(proposal.fileName(), proposal.content(), revision(current), false, applyAction); if (!"sync-vote-sites".equals(preset)) return applied; Document document = applied.document(); String installed = readRaw(resolve(fileName), false); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java index 2b903ccea..b87a607f3 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java @@ -111,14 +111,21 @@ private BackendControlConnector(VotingPluginMain plugin, Path dataDirectory, Set } private void reloadConfiguration(String fileName) throws Exception { + reloadOnServerThread(() -> { + plugin.reloadFromControl(); + if ("BungeeSettings.yml".equals(fileName)) plugin.restartBackendProxyHandler(); + }); + } + + private void reloadProxyMethod(String ignored) throws Exception { + reloadOnServerThread(plugin::reloadBackendProxyMethodFromControl); + } + + private void reloadOnServerThread(Runnable action) throws Exception { Future reload; synchronized (operationLifecycle) { if (closed) throw new IllegalStateException("Bukkit Control connector is stopping"); - reload = plugin.getServer().getScheduler().callSyncMethod(plugin, () -> { - plugin.reloadFromControl(); - if ("BungeeSettings.yml".equals(fileName)) plugin.restartBackendProxyHandler(); - return null; - }); + reload = plugin.getServer().getScheduler().callSyncMethod(plugin, () -> { action.run(); return null; }); activeReload = reload; } try { @@ -504,10 +511,20 @@ public void publishHostedConfiguration(HostConfiguration configuration, Runnable private void persistIntent(UUID operationId, TaskResult anticipated, String attemptId) throws IOException { JsonObject result = anticipated.json(); result.addProperty("attemptId", attemptId); + StoredResult previous; synchronized (completed) { - completed.put(operationId, new StoredResult(result, anticipated.restartConnector(), false, false)); + previous = completed.put(operationId, + new StoredResult(result, anticipated.restartConnector(), false, false)); + } + try { + persistCompleted(); + } catch (IOException failure) { + synchronized (completed) { + if (previous == null) completed.remove(operationId); + else completed.put(operationId, previous); + } + throw failure; } - persistCompleted(); } private void prepareWriteAheadIntents() throws IOException { @@ -527,7 +544,17 @@ private void prepareWriteAheadIntents() throws IOException { } } } - if (changed) persistCompleted(); + if (changed) { + try { + persistCompleted(); + } catch (IOException failure) { + synchronized (completed) { + completed.clear(); + completed.putAll(snapshot); + } + throw failure; + } + } } static StoredResult abortedIntent(StoredResult pending) { @@ -633,8 +660,8 @@ private TaskResult executeFile(UUID operationId, String type, JsonObject configu 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")); + TaskResult.fileIntent(fileName, configurations.proposedDocument(preview).revision(), + preview.changes(), "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(), @@ -664,8 +691,10 @@ private TaskResult executeQuick(UUID operationId, String type, JsonObject config TaskResult.quick(preset, options, configurations.proposedQuickSetupRevision(preset, 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, + string(task, "expectedRevision"), "proxy-method".equals(preset) + ? this::reloadProxyMethod : this::reloadConfiguration); + return TaskResult.quick(preset, options, applied.document().revision(), applied.changes(), + !"proxy-method".equals(preset), "Config.yml".equals(applied.document().fileName())); } return TaskResult.failure("UNSUPPORTED_TASK", "Task type is unsupported"); @@ -902,10 +931,20 @@ private static TaskResult file(BackendConfigurationService.Document document, Li List.copyOf(changes), reloaded, rolledBack, restartConnector); } + private static TaskResult fileIntent(String fileName, String revision, List changes, + boolean restartConnector) { + JsonObject config = new JsonObject(); + config.addProperty("domain", "file"); + config.addProperty("fileName", fileName); + return new TaskResult(true, "OK", "Configuration apply is pending", revision, config, + List.copyOf(changes), true, false, 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(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java index 33e4efd50..52a0c902a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java @@ -31,6 +31,7 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; +import com.google.gson.JsonParser; /** * Typed, read-only data surface used by VotingPlugin Control. @@ -280,6 +281,18 @@ private JsonObject player(JsonObject filters) { result.add("lastVotes", lastVotes); result.addProperty("lastVotesTruncated", lastVoteSnapshot.size() > MAX_ROWS); result.addProperty("pendingOfflineVotes", Math.min(user.getOfflineVotes().size(), 100000)); + try { + JsonObject exact = JsonParser.parseString(new ControlPlayerDataService(plugin).readLoaded(user).content()) + .getAsJsonObject(); + result.addProperty("storageRowAvailable", true); + result.addProperty("storage", exact.get("storage").getAsString()); + result.add("columns", exact.getAsJsonArray("columns")); + result.addProperty("columnsTruncated", exact.get("columnsTruncated").getAsBoolean()); + } catch (java.io.IOException failure) { + result.addProperty("storageRowAvailable", false); + result.add("columns", new JsonArray()); + result.addProperty("columnsTruncated", false); + } return result; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlPlayerDataService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlPlayerDataService.java new file mode 100644 index 000000000..fabcda876 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlPlayerDataService.java @@ -0,0 +1,104 @@ +package com.bencodez.votingplugin.control; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +import com.bencodez.simpleapi.sql.data.DataValue; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.user.VotingPluginUser; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +/** Exact, bounded, read-only rendering of allow-listed VotingPlugin player fields. */ +final class ControlPlayerDataService { + private static final int MAX_CONTENT_BYTES = 512 * 1024; + private static final int MAX_VALUE_BYTES = 16 * 1024; + private static final int MAX_COLUMNS = 128; + private static final Set SAFE_STRING_COLUMNS = Set.of("UUID", "PlayerName", "LastOnline", + "DayVoteStreakLastUpdate", "VoteRemindersLast"); + private static final Set SAFE_BOOLEAN_COLUMNS = Set.of("TopVoterIgnore", "Reminded", "DisableBroadcast"); + private static final Set SAFE_INTEGER_COLUMNS = Set.of("VotePartyVotes", "MonthTotal", "AllTimeTotal", + "DailyTotal", "WeeklyTotal", "Points", "DayVoteStreak", "BestDayVoteStreak", "WeekVoteStreak", + "BestWeekVoteStreak", "MonthVoteStreak", "BestMonthVoteStreak", "HighestDailyTotal", + "HighestMonthlyTotal", "HighestWeeklyTotal", "LastMonthTotal", "LastWeeklyTotal", "LastDailyTotal"); + private static final Pattern SAFE_DYNAMIC_INTEGER_COLUMN = Pattern.compile( + "(?:MonthTotal_[0-9]{4}_[0-9]{1,2}|VoteShopLimit[A-Za-z0-9_-]{1,64})"); + private final VotingPluginMain plugin; + + ControlPlayerDataService(VotingPluginMain plugin) { + this.plugin = plugin; + } + + Document readLoaded(VotingPluginUser user) throws IOException { + java.util.Objects.requireNonNull(user, "user"); + if (user.getUserData() == null || plugin.getStorageType() == null) { + throw new IOException("player data storage is unavailable"); + } + Map values = user.getUserData().getValues(); + if (values == null) throw new IOException("player data storage is unavailable"); + List> columns = new ArrayList<>(values.entrySet()); + columns.sort(Map.Entry.comparingByKey(String.CASE_INSENSITIVE_ORDER) + .thenComparing(Map.Entry.comparingByKey())); + JsonObject content = new JsonObject(); + content.addProperty("uuid", user.getUUID()); + content.addProperty("name", user.getPlayerName()); + content.addProperty("storage", plugin.getStorageType().name()); + JsonArray listed = new JsonArray(); + boolean truncated = false; + for (Map.Entry entry : columns) { + String name = entry.getKey(); + DataValue value = entry.getValue(); + if (name == null || value == null || !safeColumn(name, value)) continue; + if (listed.size() >= MAX_COLUMNS) { + truncated = true; + break; + } + String rendered = render(value); + if (rendered.getBytes(StandardCharsets.UTF_8).length > MAX_VALUE_BYTES) { + truncated = true; + continue; + } + JsonObject column = new JsonObject(); + column.addProperty("name", name); + column.addProperty("type", value.getType().name()); + column.addProperty("value", rendered); + listed.add(column); + } + content.add("columns", listed); + content.addProperty("columnsTruncated", truncated); + String json = content.toString(); + if (json.getBytes(StandardCharsets.UTF_8).length > MAX_CONTENT_BYTES) { + throw new IOException("player data exceeds Control limits"); + } + return new Document(json); + } + + private boolean safeColumn(String name, DataValue value) { + if (SAFE_STRING_COLUMNS.contains(name)) return value.isString(); + if (SAFE_BOOLEAN_COLUMNS.contains(name)) { + if (value.isBoolean()) return true; + return value.isString() && value.getString() != null + && value.getString().matches("(?i:true|false)"); + } + if (SAFE_INTEGER_COLUMNS.contains(name)) return value.isInt(); + if (SAFE_DYNAMIC_INTEGER_COLUMN.matcher(name).matches()) return value.isInt(); + if (name.equals(plugin.getVotingPluginUserManager().getCoolDownCheckPath())) return value.isBoolean(); + if (name.equals(plugin.getVotingPluginUserManager().getCoolDownCheckSitePath())) return value.isString(); + return (name.equals(plugin.getVotingPluginUserManager().getGottenAllSitesDayPath()) + || name.equals(plugin.getVotingPluginUserManager().getGottenAlmostAllSitesDayPath())) && value.isInt(); + } + + private static String render(DataValue value) { + if (value.isInt()) return Integer.toString(value.getInt()); + if (value.isBoolean()) return Boolean.toString(value.getBoolean()); + String rendered = value.getString(); + return rendered == null ? "" : rendered; + } + + record Document(String content) { } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlRewardProposal.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlRewardProposal.java index 907d993b5..ac4e846ba 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlRewardProposal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlRewardProposal.java @@ -98,10 +98,12 @@ private static List boundedItems(JsonObject proposal) { throw new IllegalArgumentException("item material is invalid"); } Material material = Material.matchMaterial(materialName); - // Modern Bukkit resolves item-ness through the live registry. The null-server - // path keeps the shared parser usable in isolated validation tests; production - // connector calls always have a server and therefore enforce isItem(). - if (material == null || (org.bukkit.Bukkit.getServer() != null && !material.isItem())) { + // Modern Bukkit resolves item-ness through the live registry. Mockito-based + // unit tests can expose a non-null skeletal Server without registries, so only + // use the registry-backed check when the implementation identifies itself. + org.bukkit.Server server = org.bukkit.Bukkit.getServer(); + boolean liveServer = server != null && server.getName() != null && !server.getName().isBlank(); + if (material == null || (liveServer && !material.isItem())) { throw new IllegalArgumentException("item material is invalid"); } result.add(new Item(material.name(), boundedInt(item, "amount", 1, 1, 64))); 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 index 82b834c82..628fe7b20 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java @@ -50,13 +50,14 @@ */ public final class ControlConnector implements AutoCloseable { static final int PROTOCOL_VERSION = 1; - static final int MAX_RESPONSE_BYTES = 64 * 1024; + static final int MAX_RESPONSE_BYTES = 4 * 1024 * 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 String COMMUNICATION_TEST_CAPABILITY = "config.transport-test.v1"; private static final String COMMUNICATION_TEST_PRESET = "communication-test"; private static final String PROXY_METHOD_CAPABILITY = "config.proxy-method.v1"; + private static final String PROXY_FILE_CAPABILITY = "config.proxy-files.v1"; private static final String PROXY_METHOD_PRESET = "proxy-method"; private static final String INTERNAL_OPERATION_TYPE = "_controlOperationType"; private static final long OPERATION_POLL_MILLIS = 1000; @@ -72,6 +73,7 @@ public final class ControlConnector implements AutoCloseable { private final LongSupplier jitterSource; private final ProxyRoutingConfigurationService configurationService; private final ProxyMethodConfigurationService methodConfigurationService; + private final ProxyConfigurationFileService fileConfigurationService; private final Function> communicationTest; private final Runnable runtimeReplacement; private final Path dataDirectory; @@ -97,14 +99,14 @@ public ControlConnector(Settings settings, ScheduledExecutorService scheduler, T Supplier> snapshotSource, Consumer logger, UUID sessionId, LongSupplier jitterSource) { this(settings, scheduler, transport, snapshotSource, logger, sessionId, jitterSource, null, - null, null, false, null, null, null, null); + null, null, false, null, null, null, null, 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, null, null, null); + null, null, false, null, null, null, null, null); } ControlConnector(Settings settings, ScheduledExecutorService scheduler, Transport transport, @@ -112,7 +114,7 @@ public ControlConnector(Settings settings, ScheduledExecutorService scheduler, T LongSupplier jitterSource, ProxyRoutingConfigurationService configurationService, Map recoveredTasks) { this(settings, scheduler, transport, snapshotSource, logger, sessionId, jitterSource, configurationService, - null, null, false, null, null, null, null); + null, null, false, null, null, null, null, null); completedTasks.putAll(recoveredTasks); } @@ -121,7 +123,8 @@ private ControlConnector(Settings settings, ScheduledExecutorService scheduler, LongSupplier jitterSource, ProxyRoutingConfigurationService configurationService, Path dataDirectory, Route route, boolean recovering, Runnable recoveryComplete, Function> communicationTest, - ProxyMethodConfigurationService methodConfigurationService, Runnable runtimeReplacement) { + ProxyMethodConfigurationService methodConfigurationService, Runnable runtimeReplacement, + ProxyConfigurationFileService fileConfigurationService) { this.settings = Objects.requireNonNull(settings, "settings"); this.scheduler = Objects.requireNonNull(scheduler, "scheduler"); this.transport = Objects.requireNonNull(transport, "transport"); @@ -133,6 +136,7 @@ private ControlConnector(Settings settings, ScheduledExecutorService scheduler, this.methodConfigurationService = methodConfigurationService; this.communicationTest = communicationTest; this.runtimeReplacement = runtimeReplacement; + this.fileConfigurationService = fileConfigurationService; this.dataDirectory = dataDirectory; this.route = route; this.recovering = recovering; @@ -193,7 +197,7 @@ public static ControlConnector create(VotingPluginProxy proxy) throws IOExceptio () -> ThreadLocalRandom.current().nextLong(), new ProxyRoutingConfigurationService(proxy), dataDirectory, route, recovering, proxy::restartControlServicesAfterRecovery, server -> proxy.testBackendCommunication(server, 5000L), new ProxyMethodConfigurationService(proxy), - () -> proxy.reloadCore(true)); + () -> proxy.reloadCore(true), new ProxyConfigurationFileService(proxy)); if (recovered != null) connector.completedTasks.putAll(recovered.results()); return connector; } @@ -398,7 +402,7 @@ private void handlePrimaryResponse(Response response, boolean registration) { } configurationAccepted = contains(accepted, CONFIGURATION_CAPABILITY) || contains(accepted, COMMUNICATION_TEST_CAPABILITY) - || contains(accepted, PROXY_METHOD_CAPABILITY); + || contains(accepted, PROXY_METHOD_CAPABILITY) || contains(accepted, PROXY_FILE_CAPABILITY); } } @@ -633,10 +637,19 @@ private void persistIntent(UUID operationId, TaskResult anticipated, String atte JsonObject result = anticipated.json(); result.addProperty("attemptId", attemptId); result.addProperty(INTERNAL_OPERATION_TYPE, "APPLY"); + StoredResult previous; synchronized (operationLifecycle) { - completedTasks.put(operationId, new StoredResult(result, false, false)); + previous = completedTasks.put(operationId, new StoredResult(result, false, false)); + } + try { + persistCompleted(); + } catch (RuntimeException failure) { + synchronized (operationLifecycle) { + if (previous == null) completedTasks.remove(operationId); + else completedTasks.put(operationId, previous); + } + throw failure; } - persistCompleted(); } private void prepareWriteAheadIntents() { @@ -656,7 +669,17 @@ private void prepareWriteAheadIntents() { } } } - if (changed) persistCompleted(); + if (changed) { + try { + persistCompleted(); + } catch (RuntimeException failure) { + synchronized (operationLifecycle) { + completedTasks.clear(); + completedTasks.putAll(snapshot); + } + throw failure; + } + } } private static StoredResult abortedIntent(StoredResult pending) { @@ -673,6 +696,14 @@ private boolean anticipatedResultIsInstalled(StoredResult pending) { if (configuration != null && isProxyMethod(configuration) && methodConfigurationService != null) { return result.get("revision").getAsString().equals(methodConfigurationService.read().revision()); } + if (isProxyFile(configuration) && fileConfigurationService != null) { + try { + return result.get("revision").getAsString().equals( + fileConfigurationService.read(requireString(configuration, "fileName")).revision()); + } catch (IOException failure) { + return false; + } + } return configurationService != null && result.get("revision").getAsString().equals(configurationService.read().revision()); } @@ -685,6 +716,9 @@ private static StoredResult committedForAttempt(StoredResult pending, String att private CompletableFuture executeTask(UUID operationId, JsonObject task) { JsonObject requested = task.getAsJsonObject("configuration"); + if (isProxyFile(requested)) { + return executeProxyFile(operationId, task, requested); + } if (isCommunicationTest(requested)) return executeCommunicationTest(task, requested); if (isProxyMethod(requested)) return executeProxyMethod(operationId, task, requested); if (configurationService == null) return completed(TaskResult.failure("UNSUPPORTED", "Configuration control is unavailable")); @@ -758,6 +792,41 @@ private CompletableFuture executeProxyMethod(UUID operationId, JsonO } } + private CompletableFuture executeProxyFile(UUID operationId, JsonObject task, JsonObject requested) { + if (fileConfigurationService == null) { + return completed(TaskResult.failure("UNSUPPORTED", "Proxy file control is unavailable")); + } + String type = requireString(task, "type"); + String fileName = requireString(requested, "fileName"); + try { + if ("READ".equals(type)) { + return completed(TaskResult.file(fileConfigurationService.read(fileName), List.of(), false, false)); + } + String content = requireString(requested, "content"); + ProxyConfigurationFileService.Preview preview = fileConfigurationService.preview(fileName, content); + if ("PREVIEW".equals(type)) { + return completed(TaskResult.file(fileConfigurationService.read(fileName), preview.changes(), false, false)); + } + if (!"APPLY".equals(type)) return completed(TaskResult.failure("UNSUPPORTED_TASK", "Task type is unsupported")); + persistIntent(operationId, TaskResult.fileIntent(fileName, + ProxyConfigurationFileService.revision(preview.resolvedContent()), preview.changes()), + requireString(task, "attemptId")); + ProxyConfigurationFileService.ApplyResult applied = fileConfigurationService.apply(fileName, content, + requireString(task, "expectedRevision")); + return completed(TaskResult.file(applied.document(), applied.changes(), false, applied.rolledBack(), + "Proxy configuration saved; restart the proxy to activate general settings")); + } catch (ProxyConfigurationFileService.StaleRevisionException failure) { + return completed(TaskResult.failure("STALE_REVISION", "Proxy configuration changed after preview")); + } catch (ProxyConfigurationFileService.ApplyFailureException failure) { + return completed(new TaskResult(false, "APPLY_FAILED", "Proxy configuration could not be saved", null, null, + List.of(), false, failure.rolledBack())); + } catch (IllegalArgumentException failure) { + return completed(TaskResult.failure("VALIDATION_ERROR", failure.getMessage())); + } catch (IOException | RuntimeException failure) { + return completed(TaskResult.failure("APPLY_FAILED", "Proxy configuration operation failed")); + } + } + private CompletableFuture executeCommunicationTest(JsonObject task, JsonObject requested) { if (!"READ".equals(requireString(task, "type"))) { return completed(TaskResult.failure("UNSUPPORTED_TASK", "Communication tests are read-only")); @@ -799,6 +868,12 @@ private static boolean isProxyMethod(JsonObject requested) { && PROXY_METHOD_PRESET.equals(requested.get("preset").getAsString()); } + private static boolean isProxyFile(JsonObject requested) { + return requested != null && requested.has("domain") && requested.get("domain").isJsonPrimitive() + && requested.getAsJsonPrimitive("domain").isString() + && "file".equals(requested.get("domain").getAsString()); + } + private static CompletableFuture completed(TaskResult result) { return CompletableFuture.completedFuture(result); } @@ -868,6 +943,7 @@ private void addCapabilities(JsonObject body) { if (configurationService != null) advertised.add(CONFIGURATION_CAPABILITY); if (communicationTest != null) advertised.add(COMMUNICATION_TEST_CAPABILITY); if (methodConfigurationService != null) advertised.add(PROXY_METHOD_CAPABILITY); + if (fileConfigurationService != null) advertised.add(PROXY_FILE_CAPABILITY); body.add("capabilities", advertised); JsonArray required = new JsonArray(); required.add("presence.snapshot"); @@ -1037,6 +1113,29 @@ private static TaskResult success(String revision, JsonObject configuration, Lis boolean reloaded, String message) { return new TaskResult(true, "OK", message, revision, configuration, changes, reloaded, false); } + private static TaskResult file(ProxyConfigurationFileService.Document document, List changes, + boolean reloaded, boolean rolledBack) { + return file(document, changes, reloaded, rolledBack, "Operation completed"); + } + + private static TaskResult file(ProxyConfigurationFileService.Document document, List changes, + boolean reloaded, boolean rolledBack, String message) { + JsonObject configuration = new JsonObject(); + configuration.addProperty("domain", "file"); + configuration.addProperty("fileName", document.fileName()); + configuration.addProperty("content", document.content()); + return new TaskResult(true, "OK", message, document.revision(), configuration, + List.copyOf(changes), reloaded, rolledBack); + } + + private static TaskResult fileIntent(String fileName, String revision, List changes) { + JsonObject configuration = new JsonObject(); + configuration.addProperty("domain", "file"); + configuration.addProperty("fileName", fileName); + return new TaskResult(true, "OK", + "Proxy configuration saved; restart the proxy to activate general settings", revision, configuration, + List.copyOf(changes), false, 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); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyConfigurationFileService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyConfigurationFileService.java new file mode 100644 index 000000000..37f619a5b --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyConfigurationFileService.java @@ -0,0 +1,692 @@ +package com.bencodez.votingplugin.proxy.control; + +import java.io.IOException; +import java.io.InputStream; +import java.io.StringReader; +import java.io.StringWriter; +import java.nio.channels.Channels; +import java.nio.channels.SeekableByteChannel; +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.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import org.yaml.snakeyaml.DumperOptions; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.comments.CommentLine; +import org.yaml.snakeyaml.constructor.SafeConstructor; +import org.yaml.snakeyaml.nodes.MappingNode; +import org.yaml.snakeyaml.nodes.Node; +import org.yaml.snakeyaml.nodes.NodeTuple; +import org.yaml.snakeyaml.nodes.ScalarNode; +import org.yaml.snakeyaml.nodes.SequenceNode; +import org.yaml.snakeyaml.nodes.Tag; + +import com.bencodez.votingplugin.proxy.VotingPluginProxy; +import com.bencodez.votingplugin.util.DurableFiles; + +/** Strict, revisioned access to the proxy's single bungeeconfig.yml file. */ +final class ProxyConfigurationFileService { + static final String FILE_NAME = "bungeeconfig.yml"; + static final String REDACTED = "__VOTINGPLUGIN_CONTROL_REDACTED__"; + static final int MAX_BYTES = 512 * 1024; + private static final String REDACTED_COMMENT = " " + REDACTED; + private final Path target; + private final MoveAction mover; + private final TempFileAction tempFiles; + + ProxyConfigurationFileService(VotingPluginProxy proxy) { + this(proxy.getDataFolderPlugin().toPath().toAbsolutePath().normalize().resolve(FILE_NAME), + ProxyConfigurationFileService::move, Files::createTempFile); + } + + ProxyConfigurationFileService(Path target, MoveAction mover) { + this(target, mover, Files::createTempFile); + } + + ProxyConfigurationFileService(Path target, MoveAction mover, TempFileAction tempFiles) { + this.target = target.toAbsolutePath().normalize(); + this.mover = java.util.Objects.requireNonNull(mover, "mover"); + this.tempFiles = java.util.Objects.requireNonNull(tempFiles, "tempFiles"); + } + + Document read(String fileName) throws IOException { + requireFile(fileName); + String raw = readRaw(); + Map parsed = parse(raw); + return new Document(FILE_NAME, renderMasked(raw, parsed), revision(raw)); + } + + Preview preview(String fileName, String proposed) throws IOException { + requireFile(fileName); + String currentRaw = readRaw(); + Map current = parse(currentRaw); + Map proposedValues = parse(proposed); + Map resolved = resolve(proposedValues, current, ""); + validateRedactedValues(current, proposedValues, ""); + Node currentTree = compose(currentRaw); + Node currentValuesTree = compose(currentRaw); + Map redactedComments = redactComments(currentTree, current, "", + sensitiveValues(currentTree, current)); + Node proposedTree = compose(proposed); + restoreRedactedComments(proposedTree, redactedComments); + restoreRedactedValues(proposedTree, currentValuesTree, current, proposedValues, ""); + String content = serialize(proposedTree); + ensureBounded(content); + if (!resolved.equals(parse(content))) throw new IllegalArgumentException("proxy configuration content is invalid"); + return new Preview(content, revision(currentRaw), changes(current, resolved)); + } + + ApplyResult apply(String fileName, String proposed, String expectedRevision) throws IOException { + requireFile(fileName); + String currentRaw = readRaw(); + if (expectedRevision == null || !revision(currentRaw).equals(expectedRevision)) throw new StaleRevisionException(); + Preview preview = preview(fileName, proposed); + Path backup = target.resolveSibling(FILE_NAME + ".control-backup"); + Path stage = null; + Path backupStage = null; + boolean installed = false; + try { + stage = tempFiles.create(target.getParent(), ".control-proxy-", ".yml"); + backupStage = tempFiles.create(target.getParent(), ".control-proxy-backup-", ".yml"); + Files.writeString(stage, preview.resolvedContent, StandardCharsets.UTF_8, StandardOpenOption.TRUNCATE_EXISTING); + copyPermissions(target, stage); + parse(readStrict(stage)); + if (Files.isSymbolicLink(backup)) throw new IOException("unsafe proxy configuration backup"); + Files.writeString(backupStage, currentRaw, StandardCharsets.UTF_8, StandardOpenOption.TRUNCATE_EXISTING); + copyPermissions(target, backupStage); + if (!revision(readRaw()).equals(expectedRevision)) throw new StaleRevisionException(); + mover.move(backupStage, backup); + if (!revision(readRaw()).equals(expectedRevision)) throw new StaleRevisionException(); + try { + mover.move(stage, target); + installed = true; + } catch (DurableFiles.PublishedException published) { + installed = true; + throw published; + } + String applied = readRaw(); + if (!revision(applied).equals(revision(preview.resolvedContent))) throw new StaleRevisionException(); + return new ApplyResult(new Document(FILE_NAME, renderMasked(applied, parse(applied)), revision(applied)), + preview.changes, false); + } catch (StaleRevisionException stale) { + throw stale; + } catch (Exception failure) { + boolean rolledBack = false; + if (installed) { + try { + if (!revision(readRaw()).equals(revision(preview.resolvedContent))) { + throw new IOException("proxy configuration changed during rollback"); + } + if (!Files.isRegularFile(backup, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("proxy configuration backup is unavailable"); + } + Path rollback = tempFiles.create(target.getParent(), ".control-proxy-rollback-", ".yml"); + try { + try (SeekableByteChannel source = Files.newByteChannel(backup, + Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS))) { + Files.copy(Channels.newInputStream(source), rollback, StandardCopyOption.REPLACE_EXISTING); + } + copyPermissions(backup, rollback); + mover.move(rollback, target); + } finally { Files.deleteIfExists(rollback); } + rolledBack = true; + } catch (Exception rollbackFailure) { failure.addSuppressed(rollbackFailure); } + } + throw new ApplyFailureException(rolledBack, failure); + } finally { + if (stage != null) Files.deleteIfExists(stage); + if (backupStage != null) Files.deleteIfExists(backupStage); + } + } + + private String readRaw() throws IOException { + if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) throw new IOException("proxy configuration is unavailable"); + return readStrict(target); + } + + private static String readStrict(Path path) throws IOException { + long size = Files.size(path); + if (size < 0 || size > MAX_BYTES) throw new IOException("proxy configuration exceeds limits"); + byte[] bytes; + try (InputStream input = Files.newInputStream(path, LinkOption.NOFOLLOW_LINKS)) { + bytes = input.readNBytes(MAX_BYTES + 1); + } + if (bytes.length > MAX_BYTES) throw new IOException("proxy configuration exceeds limits"); + try { + return StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT).decode(java.nio.ByteBuffer.wrap(bytes)).toString(); + } catch (CharacterCodingException failure) { throw new IOException("proxy configuration is not UTF-8", failure); } + } + + @SuppressWarnings("unchecked") + private static Map parse(String yaml) { + ensureBounded(yaml); + if (yaml.matches("(?s).*(?:^|[\\s\\[{,])(?:[&*][A-Za-z0-9_-]+|<<\\s*:).*")) { + throw new IllegalArgumentException("proxy configuration aliases are not supported"); + } + LoaderOptions loaderOptions = loaderOptions(); + SafeConstructor constructor = new SafeConstructor(loaderOptions); + Object parsed; + try { parsed = new Yaml(constructor).load(yaml); } + catch (RuntimeException failure) { throw new IllegalArgumentException("proxy configuration YAML is invalid"); } + if (!(parsed instanceof Map root)) throw new IllegalArgumentException("proxy configuration must be a mapping"); + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : root.entrySet()) { + if (!(entry.getKey() instanceof String key)) throw new IllegalArgumentException("proxy configuration keys must be strings"); + result.put(key, normalize(entry.getValue(), 1)); + } + return result; + } + + private static Object normalize(Object value, int depth) { + if (depth > 50) throw new IllegalArgumentException("proxy configuration is too deeply nested"); + if (value == null || value instanceof String || value instanceof Boolean || value instanceof Number) return value; + if (value instanceof Map map) { + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + if (!(entry.getKey() instanceof String key)) throw new IllegalArgumentException("proxy configuration keys must be strings"); + result.put(key, normalize(entry.getValue(), depth + 1)); + } + return result; + } + if (value instanceof List list) return list.stream().map(item -> normalize(item, depth + 1)).toList(); + throw new IllegalArgumentException("proxy configuration contains an unsupported YAML value"); + } + + private static String renderMasked(String raw, Map parsed) { + Node tree = compose(raw); + redactValues(tree, parsed, "", sensitiveValues(tree, parsed)); + String content = serialize(tree); + ensureBounded(content); + if (!mask(parsed).equals(parse(content))) throw new IllegalArgumentException("proxy configuration content is invalid"); + return content; + } + + private static Node compose(String yaml) { + // Construct first so comment parsing cannot bypass the strict SafeConstructor checks. + parse(yaml); + LoaderOptions options = loaderOptions(); + options.setProcessComments(true); + try { + Node node = new Yaml(options, dumperOptions()).compose(new StringReader(yaml)); + if (!(node instanceof MappingNode)) throw new IllegalArgumentException("proxy configuration must be a mapping"); + return node; + } catch (RuntimeException failure) { + throw new IllegalArgumentException("proxy configuration YAML is invalid"); + } + } + + private static String serialize(Node node) { + StringWriter writer = new StringWriter(); + new Yaml(dumperOptions()).serialize(node, writer); + return writer.toString(); + } + + private static LoaderOptions loaderOptions() { + LoaderOptions loaderOptions = new LoaderOptions(); + loaderOptions.setAllowDuplicateKeys(false); + loaderOptions.setMaxAliasesForCollections(0); + loaderOptions.setNestingDepthLimit(50); + loaderOptions.setCodePointLimit(MAX_BYTES); + return loaderOptions; + } + + private static DumperOptions dumperOptions() { + DumperOptions options = new DumperOptions(); + options.setIndent(2); + options.setPrettyFlow(true); + options.setProcessComments(true); + return options; + } + + @SuppressWarnings("unchecked") + private static Map mask(Map source) { + return mask(source, ""); + } + + @SuppressWarnings("unchecked") + private static Map mask(Map source, String path) { + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : source.entrySet()) { + Object value = entry.getValue(); + String childPath = path + entry.getKey(); + if (secret(childPath, entry.getKey(), value)) result.put(entry.getKey(), REDACTED); + else if (value instanceof Map map) result.put(entry.getKey(), mask((Map) map, childPath + ".")); + else result.put(entry.getKey(), value); + } + return result; + } + + private static void redactValues(Node node, Map source, String path, Set values) { + if (!(node instanceof MappingNode mapping)) { + redactDescendantComments(node, path, false, values, new LinkedHashMap<>()); + return; + } + redactComments(mapping, path, false, values, new LinkedHashMap<>()); + List tuples = new ArrayList<>(); + for (NodeTuple tuple : mapping.getValue()) { + String key = key(tuple.getKeyNode()); + Object value = source.get(key); + String childPath = path + key; + boolean hidden = secret(childPath, key, value); + redactComments(tuple.getKeyNode(), childPath + "#key", hidden, values, new LinkedHashMap<>()); + Node child = tuple.getValueNode(); + if (hidden) { + redactComments(child, childPath, true, values, new LinkedHashMap<>()); + child = marker(child); + } else if (value instanceof Map nested) { + @SuppressWarnings("unchecked") Map nestedValues = (Map) nested; + redactValues(child, nestedValues, childPath + ".", values); + } else { + redactDescendantComments(child, childPath, false, values, new LinkedHashMap<>()); + } + tuples.add(new NodeTuple(tuple.getKeyNode(), child)); + } + mapping.setValue(tuples); + } + + private static void validateRedactedValues(Map current, Map proposed, String path) { + for (Map.Entry entry : current.entrySet()) { + String key = entry.getKey(); + Object old = entry.getValue(); + String childPath = path + key; + if (secret(childPath, key, old)) { + if (!proposed.containsKey(key) || proposed.get(key) instanceof Map + || proposed.get(key) instanceof List) { + throw new IllegalArgumentException("redacted placeholder is invalid"); + } + continue; + } + Object candidate = proposed.get(key); + if (old instanceof Map oldMap) { + if (!(candidate instanceof Map proposedMap)) { + if (containsSecrets(oldMap, childPath + ".")) { + throw new IllegalArgumentException("redacted placeholder is invalid"); + } + continue; + } + @SuppressWarnings("unchecked") Map oldValues = (Map) oldMap; + @SuppressWarnings("unchecked") Map candidateValues = (Map) proposedMap; + validateRedactedValues(oldValues, candidateValues, childPath + "."); + } + } + } + + @SuppressWarnings("unchecked") + private static boolean containsSecrets(Map source, String path) { + for (Map.Entry entry : source.entrySet()) { + if (!(entry.getKey() instanceof String key)) return true; + Object value = entry.getValue(); + String childPath = path + key; + if (secret(childPath, key, value)) return true; + if (value instanceof Map nested && containsSecrets(nested, childPath + ".")) return true; + } + return false; + } + + private static void restoreRedactedValues(Node proposed, Node current, Map currentValues, + Map proposedValues, String path) { + if (!(proposed instanceof MappingNode proposedMap) || !(current instanceof MappingNode currentMap)) return; + Map currentTuples = tuples(currentMap); + List restored = new ArrayList<>(); + for (NodeTuple tuple : proposedMap.getValue()) { + String key = key(tuple.getKeyNode()); + Object old = currentValues.get(key); + String childPath = path + key; + Node value = tuple.getValueNode(); + if (currentTuples.containsKey(key) && secret(childPath, key, old) + && REDACTED.equals(proposedValues.get(key))) { + value = restoreSecretNode(currentTuples.get(key).getValueNode(), value); + } else if (old instanceof Map oldMap && proposedValues.get(key) instanceof Map proposedMapValue) { + @SuppressWarnings("unchecked") Map oldValues = (Map) oldMap; + @SuppressWarnings("unchecked") Map candidateValues = (Map) proposedMapValue; + restoreRedactedValues(value, currentTuples.containsKey(key) ? currentTuples.get(key).getValueNode() : value, + oldValues, candidateValues, childPath + "."); + } + restored.add(new NodeTuple(tuple.getKeyNode(), value)); + } + proposedMap.setValue(restored); + } + + private static Node restoreSecretNode(Node current, Node proposed) { + if (current instanceof ScalarNode oldScalar && proposed instanceof ScalarNode proposedScalar) { + ScalarNode restored = new ScalarNode(oldScalar.getTag(), oldScalar.getValue(), proposedScalar.getStartMark(), + proposedScalar.getEndMark(), oldScalar.getScalarStyle()); + copyComments(proposedScalar, restored); + return restored; + } + return current; + } + + private static Node marker(Node source) { + ScalarNode marker = new ScalarNode(Tag.STR, REDACTED, source.getStartMark(), source.getEndMark(), + DumperOptions.ScalarStyle.PLAIN); + copyComments(source, marker); + return marker; + } + + private static Map redactComments(Node node, Map source, String path, + Set values) { + Map result = new LinkedHashMap<>(); + redactComments(node, path, false, values, result); + if (node instanceof MappingNode mapping) { + for (NodeTuple tuple : mapping.getValue()) { + String key = key(tuple.getKeyNode()); + Object value = source.get(key); + String childPath = path + key; + boolean hidden = secret(childPath, key, value); + redactComments(tuple.getKeyNode(), childPath + "#key", hidden, values, result); + if (hidden) redactComments(tuple.getValueNode(), childPath, true, values, result); + else if (value instanceof Map nested) { + @SuppressWarnings("unchecked") Map nestedValues = (Map) nested; + result.putAll(redactComments(tuple.getValueNode(), nestedValues, childPath + ".", values)); + } else redactDescendantComments(tuple.getValueNode(), childPath, false, values, result); + } + } + return result; + } + + private static void redactDescendantComments(Node node, String path, boolean sensitiveContext, Set values, + Map redacted) { + redactComments(node, path, sensitiveContext, values, redacted); + if (node instanceof MappingNode mapping) { + for (NodeTuple tuple : mapping.getValue()) { + String key = key(tuple.getKeyNode()); + redactDescendantComments(tuple.getKeyNode(), path + key + "#key", sensitiveContext, values, redacted); + redactDescendantComments(tuple.getValueNode(), path + key, sensitiveContext, values, redacted); + } + } else if (node instanceof SequenceNode sequence) { + for (int index = 0; index < sequence.getValue().size(); index++) { + redactDescendantComments(sequence.getValue().get(index), path + "[" + index + "]", sensitiveContext, + values, redacted); + } + } + } + + private static void redactComments(Node node, String path, boolean sensitiveContext, Set values, + Map redacted) { + redactCommentList(node, path, "block", node.getBlockComments(), sensitiveContext, values, redacted); + redactCommentList(node, path, "inline", node.getInLineComments(), sensitiveContext, values, redacted); + redactCommentList(node, path, "end", node.getEndComments(), sensitiveContext, values, redacted); + } + + private static void redactCommentList(Node node, String path, String kind, List comments, + boolean sensitiveContext, Set values, Map redacted) { + if (comments == null) return; + List replacement = new ArrayList<>(comments); + for (int index = 0; index < replacement.size(); index++) { + CommentLine line = replacement.get(index); + if (!sensitiveComment(line.getValue(), sensitiveContext, values)) continue; + String slot = commentSlot(path, kind, index); + redacted.put(slot, line); + replacement.set(index, new CommentLine(line.getStartMark(), line.getEndMark(), REDACTED_COMMENT, + line.getCommentType())); + } + setComments(node, kind, replacement); + } + + private static void restoreRedactedComments(Node proposed, Map expected) { + Map comments = commentReferences(proposed, ""); + for (Map.Entry entry : expected.entrySet()) { + CommentReference reference = comments.get(entry.getKey()); + if (reference == null || !REDACTED_COMMENT.equals(reference.line().getValue())) { + throw new IllegalArgumentException("redacted placeholder is invalid"); + } + reference.replace(entry.getValue()); + } + for (Map.Entry entry : comments.entrySet()) { + if (REDACTED_COMMENT.equals(entry.getValue().line().getValue()) && !expected.containsKey(entry.getKey())) { + throw new IllegalArgumentException("redacted placeholder is invalid"); + } + } + } + + private static Map commentReferences(Node root, String path) { + Map result = new LinkedHashMap<>(); + collectComments(root, path, result); + return result; + } + + private static void collectComments(Node node, String path, Map result) { + collectCommentReferences(node, path, "block", node.getBlockComments(), result); + collectCommentReferences(node, path, "inline", node.getInLineComments(), result); + collectCommentReferences(node, path, "end", node.getEndComments(), result); + if (node instanceof MappingNode mapping) { + for (NodeTuple tuple : mapping.getValue()) { + String key = key(tuple.getKeyNode()); + String childPath = path + key; + collectComments(tuple.getKeyNode(), childPath + "#key", result); + collectComments(tuple.getValueNode(), tuple.getValueNode() instanceof MappingNode ? childPath + "." : childPath, + result); + } + } else if (node instanceof SequenceNode sequence) { + for (int index = 0; index < sequence.getValue().size(); index++) { + collectComments(sequence.getValue().get(index), path + "[" + index + "]", result); + } + } + } + + private static void collectCommentReferences(Node node, String path, String kind, List comments, + Map result) { + if (comments == null) return; + for (int index = 0; index < comments.size(); index++) { + result.put(commentSlot(path, kind, index), new CommentReference(node, kind, index, comments.get(index))); + } + } + + private static void setComments(Node node, String kind, List comments) { + switch (kind) { + case "block" -> node.setBlockComments(comments); + case "inline" -> node.setInLineComments(comments); + case "end" -> node.setEndComments(comments); + default -> throw new IllegalArgumentException("invalid comment kind"); + } + } + + private static String commentSlot(String path, String kind, int index) { + return path + "|" + kind + "|" + index; + } + + private static boolean sensitiveComment(String comment, boolean sensitiveContext, Set values) { + if (sensitiveContext) return true; + String lowered = comment.toLowerCase(Locale.ROOT); + if (lowered.matches("(?s).*\\b(password|secret|token|api[ _-]?key|authorization|jdbc|webhook)\\b.*") + || lowered.matches("(?s).*[a-z][a-z0-9+.-]*://[^/@\\s]+:[^/@\\s]+@.*")) return true; + for (String value : values) { + if (lowered.contains(value.toLowerCase(Locale.ROOT))) return true; + } + return false; + } + + private static Set sensitiveValues(Node node, Map source) { + Set values = new java.util.LinkedHashSet<>(); + collectSensitiveValues(node, source, "", values); + return values; + } + + @SuppressWarnings("unchecked") + private static void collectSensitiveValues(Node node, Map source, String path, Set values) { + if (!(node instanceof MappingNode mapping)) return; + for (NodeTuple tuple : mapping.getValue()) { + String key = key(tuple.getKeyNode()); + Object value = source.get(key); + String childPath = path + key; + if (secret(childPath, key, value) && tuple.getValueNode() instanceof ScalarNode scalar + && safeSecretValue(scalar.getValue())) { + values.add(scalar.getValue().trim()); + } else if (value instanceof Map nested) { + collectSensitiveValues(tuple.getValueNode(), (Map) nested, childPath + ".", values); + } + } + } + + private static boolean safeSecretValue(Object value) { + if (value == null) return false; + String text = String.valueOf(value).trim(); + return !text.isEmpty(); + } + + private static Map tuples(MappingNode node) { + Map result = new LinkedHashMap<>(); + for (NodeTuple tuple : node.getValue()) result.put(key(tuple.getKeyNode()), tuple); + return result; + } + + private static String key(Node node) { + if (!(node instanceof ScalarNode scalar)) throw new IllegalArgumentException("proxy configuration keys must be strings"); + return scalar.getValue(); + } + + private static void copyComments(Node source, Node target) { + target.setBlockComments(copyCommentList(source.getBlockComments())); + target.setInLineComments(copyCommentList(source.getInLineComments())); + target.setEndComments(copyCommentList(source.getEndComments())); + } + + private static List copyCommentList(List comments) { + return comments == null ? null : new ArrayList<>(comments); + } + + private record CommentReference(Node node, String kind, int index, CommentLine line) { + void replace(CommentLine replacement) { + List comments = switch (kind) { + case "block" -> node.getBlockComments(); + case "inline" -> node.getInLineComments(); + case "end" -> node.getEndComments(); + default -> throw new IllegalArgumentException("invalid comment kind"); + }; + List updated = new ArrayList<>(comments); + updated.set(index, replacement); + setComments(node, kind, updated); + } + } + + @SuppressWarnings("unchecked") + private static Map resolve(Map proposed, Map current, String path) { + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : proposed.entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue(); + Object old = current.get(key); + String childPath = path + key; + if (REDACTED.equals(value)) { + if (!secret(childPath, key, old) || !current.containsKey(key)) throw new IllegalArgumentException("redacted placeholder is invalid"); + result.put(key, old); + } else if (value instanceof Map nested) { + Map oldValues; + if (old == null) oldValues = Map.of(); + else if (old instanceof Map oldNested) oldValues = (Map) oldNested; + else throw new IllegalArgumentException("proxy configuration shape changed"); + result.put(key, resolve((Map) nested, oldValues, childPath + ".")); + } else result.put(key, value); + } + return result; + } + + private static boolean secret(String path, String key, Object value) { + String normalized = key.toLowerCase(Locale.ROOT).replace("_", "").replace("-", ""); + if (normalized.contains("password") || normalized.contains("secret") || normalized.equals("token") + || normalized.contains("apikey") || normalized.contains("authorization") + || normalized.contains("webhookurl")) return true; + String normalizedPath = path.toLowerCase(Locale.ROOT).replace("_", "").replace("-", ""); + if (normalizedPath.startsWith("database.") || normalizedPath.startsWith("globaldata.")) { + return Set.of("host", "port", "database", "username", "password", "line", "driver", "poolname") + .contains(normalized); + } + if (normalizedPath.startsWith("control.")) { + return normalized.endsWith("file") || normalized.endsWith("directory"); + } + if (value instanceof String text) { + String lowered = text.trim().toLowerCase(Locale.ROOT); + return lowered.startsWith("jdbc:") || lowered.matches("^[a-z][a-z0-9+.-]*://[^/@\\s]+:[^/@\\s]+@.*"); + } + return false; + } + + private static void copyPermissions(Path source, Path destination) throws IOException { + java.nio.file.attribute.PosixFileAttributeView sourceView = Files.getFileAttributeView(source, + java.nio.file.attribute.PosixFileAttributeView.class, LinkOption.NOFOLLOW_LINKS); + java.nio.file.attribute.PosixFileAttributeView destinationView = Files.getFileAttributeView(destination, + java.nio.file.attribute.PosixFileAttributeView.class, LinkOption.NOFOLLOW_LINKS); + if (sourceView != null && destinationView != null) { + destinationView.setPermissions(sourceView.readAttributes().permissions()); + } + } + + private static List changes(Map before, Map after) { + Map left = flatten(before, ""); + Map right = flatten(after, ""); + Set keys = new java.util.TreeSet<>(); keys.addAll(left.keySet()); keys.addAll(right.keySet()); + List result = new ArrayList<>(); + for (String key : keys) { + if (java.util.Objects.equals(left.get(key), right.get(key))) continue; + result.add((left.containsKey(key) ? right.containsKey(key) ? "changed " : "removed " : "added ") + key); + if (result.size() == 20) break; + } + return List.copyOf(result); + } + + @SuppressWarnings("unchecked") + private static Map flatten(Map source, String prefix) { + Map result = new LinkedHashMap<>(); + source.entrySet().stream().sorted(Map.Entry.comparingByKey(Comparator.naturalOrder())).forEach(entry -> { + String path = prefix + entry.getKey(); + if (entry.getValue() instanceof Map nested) result.putAll(flatten((Map) nested, path + ".")); + else result.put(path, String.valueOf(entry.getValue())); + }); + return result; + } + + private static void move(Path source, Path destination) throws IOException { + try { + DurableFiles.forceFile(source); + Files.move(source, destination, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + DurableFiles.forceMoveDirectories(source, destination); + } catch (java.nio.file.AtomicMoveNotSupportedException failure) { + throw new IOException("atomic proxy configuration activation is unsupported", failure); + } + } + + private static void requireFile(String name) { + if (!FILE_NAME.equals(name)) throw new IllegalArgumentException("proxy configuration file is not managed"); + } + + private static void ensureBounded(String value) { + if (value == null || value.indexOf('\0') >= 0 || value.getBytes(StandardCharsets.UTF_8).length > MAX_BYTES) { + throw new IllegalArgumentException("proxy configuration content is invalid"); + } + } + + static String revision(String value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); } + } + + record Document(String fileName, String content, String revision) { } + record Preview(String resolvedContent, String revision, List changes) { } + record ApplyResult(Document document, List changes, boolean rolledBack) { } + @FunctionalInterface interface MoveAction { void move(Path source, Path destination) throws IOException; } + @FunctionalInterface interface TempFileAction { Path create(Path directory, String prefix, String suffix) throws IOException; } + @SuppressWarnings("serial") static final class StaleRevisionException extends RuntimeException { } + @SuppressWarnings("serial") static final class ApplyFailureException extends IOException { + private final boolean rolledBack; + private ApplyFailureException(boolean rolledBack, Throwable cause) { super(cause); this.rolledBack = rolledBack; } + boolean rolledBack() { return rolledBack; } + } +} 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 index 73529cfbe..121593392 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyControlResultStore.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyControlResultStore.java @@ -25,7 +25,9 @@ /** 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; + /** Matches the bounded Control response envelope, including escaped managed-file content. */ + private static final int MAX_BYTES = 4 * 1024 * 1024; + private static final int MAX_RESULT_BYTES = MAX_BYTES; private static final int MAX_RESULTS = 128; private static final String FILE_NAME = ".control-proxy-pending-results.json"; @@ -68,8 +70,10 @@ static State load(Path dataDirectory) throws IOException { UUID operationId = UUID.fromString(string(item, "operationId")); if (!item.has("committed") || !item.get("committed").isJsonPrimitive() || !item.has("claimRequired") || !item.get("claimRequired").isJsonPrimitive()) throw invalid(); + JsonObject result = object(item, "result"); + if (result.toString().getBytes(StandardCharsets.UTF_8).length > MAX_RESULT_BYTES) throw invalid(); StoredResult previous = results.put(operationId, - new StoredResult(object(item, "result").deepCopy(), item.get("committed").getAsBoolean(), + new StoredResult(result.deepCopy(), item.get("committed").getAsBoolean(), item.get("claimRequired").getAsBoolean())); if (previous != null) throw invalid(); } 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 008aa7716..693e2bd11 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 @@ -131,6 +131,7 @@ public synchronized void persistControlProxyMethod(String method, String expecte if (!(failure instanceof DurableFiles.PublishedException)) controlInstalledSnapshot = null; throw failure; } + loadControlConfiguration(); } finally { Files.deleteIfExists(stage); if (backupStage != null) Files.deleteIfExists(backupStage); diff --git a/VotingPlugin/src/main/resources/bungeeconfig.yml b/VotingPlugin/src/main/resources/bungeeconfig.yml index 0c37b46fa..e4e5eb180 100644 --- a/VotingPlugin/src/main/resources/bungeeconfig.yml +++ b/VotingPlugin/src/main/resources/bungeeconfig.yml @@ -246,7 +246,8 @@ BlockedServers: WhiteListedServers: [] # What type of bungee setup -# Requires restart and set on all servers +# Control can coordinate this method across the reported network after a successful preflight. +# The proxy runtime is replaced only after its durable result is acknowledged. # https://github.com/BenCodez/VotingPlugin/wiki/Bungeecoord-Setups # Available: # PLUGINMESSAGING (Recommended) diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java index bd282b5f8..55d5c665f 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java @@ -715,6 +715,45 @@ class BackendConfigurationServiceTest { Map.of("method", "PLUGINMESSAGING"))); } + @Test void proxyMethodApplyUsesOnlyTheTargetedRuntimeAction() throws Exception { + Path settings = directory.resolve("BungeeSettings.yml"); + Files.writeString(settings, "UseBungeecord: true\nServer: lobby\nBungeeMethod: PLUGINMESSAGING\n" + + "PluginMessageChannel: vp:vp\nRedis:\n Host: localhost\n Port: 6379\n"); + AtomicInteger fullReloads = new AtomicInteger(); + AtomicInteger transportSwitches = new AtomicInteger(); + BackendConfigurationService service = new BackendConfigurationService(directory, fullReloads::incrementAndGet); + BackendConfigurationService.QuickPreview preview = service.previewQuickSetup("proxy-method", + Map.of("method", "REDIS")); + + service.applyQuickSetup("proxy-method", Map.of("method", "REDIS"), preview.revision(), + ignored -> transportSwitches.incrementAndGet()); + + assertEquals(0, fullReloads.get()); + assertEquals(1, transportSwitches.get()); + assertTrue(Files.readString(settings).contains("BungeeMethod: REDIS")); + } + + @Test void targetedProxyMethodFailureRollsBackWithTheSameTargetedAction() throws Exception { + Path settings = directory.resolve("BungeeSettings.yml"); + String original = "UseBungeecord: true\nServer: lobby\nBungeeMethod: PLUGINMESSAGING\n" + + "PluginMessageChannel: vp:vp\nRedis:\n Host: localhost\n Port: 6379\n"; + Files.writeString(settings, original); + AtomicInteger targeted = new AtomicInteger(); + BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); + BackendConfigurationService.QuickPreview preview = service.previewQuickSetup("proxy-method", + Map.of("method", "REDIS")); + + BackendConfigurationService.ApplyFailureException failure = assertThrows( + BackendConfigurationService.ApplyFailureException.class, + () -> service.applyQuickSetup("proxy-method", Map.of("method", "REDIS"), preview.revision(), ignored -> { + if (targeted.incrementAndGet() == 1) throw new IOException("transport activation failed"); + })); + + assertTrue(failure.rolledBack()); + assertEquals(2, targeted.get()); + assertEquals(original, Files.readString(settings)); + } + @Test void voteSitesSyncAddsAndUpdatesDefinitionsWithoutTouchingRewardsOrTargetOnlySites() throws Exception { Path voteSites = directory.resolve("VoteSites.yml"); Files.writeString(voteSites, "VoteSites:\n" diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java index da591b54c..c8e7a3874 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java @@ -18,6 +18,10 @@ import org.junit.jupiter.api.Test; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.simpleapi.sql.data.DataValue; +import com.bencodez.simpleapi.sql.data.DataValueInt; +import com.bencodez.simpleapi.sql.data.DataValueString; import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.user.VotingPluginUser; import com.bencodez.votingplugin.votelog.VoteLogMysqlTable; @@ -230,6 +234,46 @@ class ControlInspectionServiceTest { assertEquals("Zulu", result.getAsJsonArray("lastVotes").get(1).getAsJsonObject() .get("siteKey").getAsString()); assertFalse(result.get("lastVotesTruncated").getAsBoolean()); + assertFalse(result.get("storageRowAvailable").getAsBoolean()); + assertTrue(result.getAsJsonArray("columns").isEmpty()); + } + + @Test void playerInspectionShowsExactAllowListedStorageValuesWithoutInternalPayloads() { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + VotingPluginUser user = mock(VotingPluginUser.class, RETURNS_DEEP_STUBS); + when(plugin.getUserManager().userExist("ExactName")).thenReturn(true); + when(plugin.getVotingPluginUserManager().getVotingPluginUser("ExactName")).thenReturn(user); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(user.getUUID()).thenReturn("3b0c76c1-b7ef-4a2c-a565-b7bc662531f9"); + when(user.getPlayerName()).thenReturn("ExactName"); + when(user.getOfflineVotes()).thenReturn(new ArrayList<>()); + when(user.getLastVotes()).thenReturn(new HashMap<>()); + HashMap stored = new HashMap<>(); + stored.put("Points", new DataValueInt(42)); + stored.put("VoteShopLimitKeys", new DataValueInt(3)); + stored.put("TopVoterIgnore", new DataValueString("true")); + stored.put("VoteShopLimitInjected", new DataValueString("must not leave through a dynamic field")); + stored.put("DailyTotal", new DataValueString("must not leave through an integer field")); + stored.put("Reminded", new DataValueString("must not leave through a boolean field")); + stored.put("OfflineVotes", new DataValueString("private serialized vote payload")); + stored.put("FuturePluginSecret", new DataValueString("must not leave the backend")); + when(user.getUserData().getValues()).thenReturn(stored); + ControlInspectionService service = new ControlInspectionService(plugin); + + JsonObject result = service.inspect(JsonParser.parseString( + "{\"kind\":\"player\",\"filters\":{\"name\":\"ExactName\"}}") + .getAsJsonObject()).getAsJsonObject("result"); + assertTrue(result.get("storageRowAvailable").getAsBoolean()); + assertEquals("SQLITE", result.get("storage").getAsString()); + assertEquals(List.of("Points", "TopVoterIgnore", "VoteShopLimitKeys"), result.getAsJsonArray("columns").asList().stream() + .map(value -> value.getAsJsonObject().get("name").getAsString()).toList()); + assertEquals(List.of("42", "true", "3"), result.getAsJsonArray("columns").asList().stream() + .map(value -> value.getAsJsonObject().get("value").getAsString()).toList()); + assertFalse(result.toString().contains("private serialized vote payload")); + assertFalse(result.toString().contains("must not leave through a dynamic field")); + assertFalse(result.toString().contains("must not leave through an integer field")); + assertFalse(result.toString().contains("must not leave through a boolean field")); + assertFalse(result.toString().contains("must not leave the backend")); } @Test void voteSiteHealthIncludesPersistedDetectedInboxWithoutVoteLogging() { 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 index a6940fe03..badf9a79d 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java @@ -9,7 +9,13 @@ import com.bencodez.votingplugin.proxy.control.ProxyControlResultStore.StoredResult; import com.google.gson.JsonObject; import com.google.gson.JsonParser; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -20,15 +26,25 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.LongSupplier; +import java.util.function.Supplier; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import static org.junit.jupiter.api.Assertions.*; class ControlConnectorTest { + @TempDir Path dataDirectory; private ScheduledExecutorService scheduler; private FakeTransport transport; private List logs; + + @Test void responseBudgetCanCarryTheLargestEscapedManagedFileTask() { + assertTrue(ControlConnector.MAX_RESPONSE_BYTES >= ProxyConfigurationFileService.MAX_BYTES * 6); + } private ControlConnector connector; @BeforeEach void setUp() { @@ -275,6 +291,135 @@ class ControlConnectorTest { assertFalse(ControlConnector.requiresRuntimeReplacement(new StoredResult(result, true, false))); } + @Test void proxyFileCapabilityAdvertisesAndDispatchesMaskedReadResults() throws Exception { + Path file = dataDirectory.resolve(ProxyConfigurationFileService.FILE_NAME); + Files.writeString(file, "Database:\n Password: local-secret\nProxy:\n Enabled: true\n"); + connector.close(); + connector = fileConnector(new ProxyConfigurationFileService(file, ControlConnectorTest::atomicMove)); + transport.acceptProxyFiles = true; + transport.operationClaim = CompletableFuture.completedFuture(new Response(200, + "{\"operationId\":\"00000000-0000-0000-0000-000000000099\"," + + "\"attemptId\":\"00000000-0000-0000-0000-000000000199\"," + + "\"type\":\"READ\",\"configuration\":{\"domain\":\"file\"," + + "\"fileName\":\"bungeeconfig.yml\"}}")); + + connector.cycle(); + + JsonObject registration = JsonParser.parseString(transport.requests.get(0).body()).getAsJsonObject(); + assertTrue(registration.getAsJsonArray("capabilities").asList().stream() + .anyMatch(value -> "config.proxy-files.v1".equals(value.getAsString()))); + JsonObject result = submittedResult(); + JsonObject configuration = result.getAsJsonObject("configuration"); + assertEquals("file", configuration.get("domain").getAsString()); + assertEquals("bungeeconfig.yml", configuration.get("fileName").getAsString()); + assertTrue(configuration.get("content").getAsString().contains(ProxyConfigurationFileService.REDACTED)); + assertFalse(transport.requests.stream().map(Request::body).anyMatch(body -> body.contains("local-secret"))); + } + + @Test void proxyFileRejectsUnmanagedNamesWithAStructuredSafeFailure() throws Exception { + Path file = dataDirectory.resolve(ProxyConfigurationFileService.FILE_NAME); + Files.writeString(file, "Database:\n Password: local-secret\n"); + connector.close(); + connector = fileConnector(new ProxyConfigurationFileService(file, ControlConnectorTest::atomicMove)); + transport.acceptProxyFiles = true; + transport.operationClaim = CompletableFuture.completedFuture(new Response(200, + "{\"operationId\":\"00000000-0000-0000-0000-000000000099\"," + + "\"attemptId\":\"00000000-0000-0000-0000-000000000199\"," + + "\"type\":\"READ\",\"configuration\":{\"domain\":\"file\"," + + "\"fileName\":\"../../private/local-secret.yml\"}}")); + + connector.cycle(); + + JsonObject result = submittedResult(); + assertFalse(result.get("success").getAsBoolean()); + assertEquals("VALIDATION_ERROR", result.get("code").getAsString()); + assertEquals("proxy configuration file is not managed", result.get("message").getAsString()); + assertFalse(result.toString().contains("local-secret")); + assertFalse(result.toString().contains("../")); + } + + @Test void proxyFileWriteAheadIntentPersistsOnlyRecoveryMetadataBeforeTheFileIsPublished() throws Exception { + Path file = dataDirectory.resolve(ProxyConfigurationFileService.FILE_NAME); + Files.writeString(file, "Database:\n Password: local-secret\nProxy:\n Enabled: true\n"); + CountDownLatch firstMove = new CountDownLatch(1); + CountDownLatch releaseMove = new CountDownLatch(1); + AtomicBoolean blockFirstMove = new AtomicBoolean(true); + ProxyConfigurationFileService service = new ProxyConfigurationFileService(file, (source, target) -> { + if (blockFirstMove.compareAndSet(true, false)) { + firstMove.countDown(); + try { + releaseMove.await(); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new java.io.IOException(failure); + } + } + atomicMove(source, target); + }); + String proposed = "Database:\n Password: " + ProxyConfigurationFileService.REDACTED + + "\nProxy:\n Enabled: false\n"; + String expectedRevision = ProxyConfigurationFileService.revision(Files.readString(file)); + String expectedProposedRevision = ProxyConfigurationFileService.revision(service.preview( + ProxyConfigurationFileService.FILE_NAME, proposed).resolvedContent()); + connector.close(); + connector = fileConnector(service); + transport.acceptProxyFiles = true; + transport.operationClaim = CompletableFuture.completedFuture(new Response(200, + "{\"operationId\":\"00000000-0000-0000-0000-000000000099\"," + + "\"attemptId\":\"00000000-0000-0000-0000-000000000199\"," + + "\"type\":\"APPLY\",\"expectedRevision\":\"" + expectedRevision + "\"," + + "\"configuration\":{\"domain\":\"file\",\"fileName\":\"bungeeconfig.yml\"," + + "\"content\":\"Database:\\n Password: " + ProxyConfigurationFileService.REDACTED + + "\\nProxy:\\n Enabled: false\\n\"}}")); + + CompletableFuture cycle = CompletableFuture.runAsync(connector::cycle); + assertTrue(firstMove.await(2, TimeUnit.SECONDS)); + try { + String journal = Files.readString(dataDirectory.resolve(".control-proxy-pending-results.json")); + StoredResult intent = ProxyControlResultStore.load(dataDirectory).results().values().iterator().next(); + JsonObject configuration = intent.result().getAsJsonObject("configuration"); + assertFalse(intent.committed()); + assertFalse(intent.result().get("reloaded").getAsBoolean()); + assertTrue(intent.result().get("message").getAsString().contains("restart the proxy")); + assertEquals("file", configuration.get("domain").getAsString()); + assertEquals("bungeeconfig.yml", configuration.get("fileName").getAsString()); + assertEquals(expectedProposedRevision, intent.result().get("revision").getAsString()); + assertFalse(configuration.has("content")); + assertFalse(journal.contains("local-secret")); + assertFalse(journal.contains("Enabled: false")); + } finally { + releaseMove.countDown(); + } + cycle.get(2, TimeUnit.SECONDS); + } + + @Test void failedProxyIntentPublicationRestoresTheInMemoryWriteAheadState() throws Exception { + connector.close(); + connector = fileConnector(new ProxyConfigurationFileService(dataDirectory.resolve( + ProxyConfigurationFileService.FILE_NAME), ControlConnectorTest::atomicMove)); + Path journal = dataDirectory.resolve(".control-proxy-pending-results.json"); + Path external = dataDirectory.resolve("external-journal.json"); + Files.writeString(external, "{}"); + try { + Files.createSymbolicLink(journal, external.getFileName()); + } catch (UnsupportedOperationException unsupported) { + return; + } + + Method fileIntent = taskResultClass().getDeclaredMethod("fileIntent", String.class, String.class, List.class); + fileIntent.setAccessible(true); + Object intent = fileIntent.invoke(null, ProxyConfigurationFileService.FILE_NAME, "anticipated-revision", + List.of("changed Proxy.Enabled")); + Method persistIntent = ControlConnector.class.getDeclaredMethod("persistIntent", UUID.class, + taskResultClass(), String.class); + persistIntent.setAccessible(true); + + assertThrows(java.lang.reflect.InvocationTargetException.class, () -> persistIntent.invoke(connector, + UUID.fromString("00000000-0000-0000-0000-000000000099"), intent, + "00000000-0000-0000-0000-000000000199")); + assertEquals(0, completedTaskCount()); + } + @Test void lostResultResponseIsResubmittedBeforeAnotherOperationClaim() { connector.close(); ProxyRoutingConfiguration current = new ProxyRoutingConfiguration(false, List.of()); @@ -300,6 +445,36 @@ class ControlConnectorTest { assertEquals(1, transport.requests.stream().filter(request -> request.path().endsWith("/operations")).count()); } + @Test void largeProxyFileReadSurvivesLostAcknowledgementAndConnectorRestart() throws Exception { + Path file = dataDirectory.resolve(ProxyConfigurationFileService.FILE_NAME); + Files.writeString(file, "Large: '" + "a".repeat(300 * 1024) + "'\n"); + ProxyConfigurationFileService service = new ProxyConfigurationFileService(file, + ControlConnectorTest::atomicMove); + connector.close(); + connector = fileConnector(service); + transport.acceptProxyFiles = true; + transport.operationClaim = CompletableFuture.completedFuture(new Response(200, + "{\"operationId\":\"00000000-0000-0000-0000-000000000099\"," + + "\"attemptId\":\"00000000-0000-0000-0000-000000000199\"," + + "\"type\":\"READ\",\"configuration\":{\"domain\":\"file\"," + + "\"fileName\":\"bungeeconfig.yml\"}}")); + transport.resultSubmission = new CompletableFuture<>(); + + connector.cycle(); + transport.resultSubmission.completeExceptionally(new java.io.IOException("acknowledgement lost")); + assertEquals(Status.UNAVAILABLE, connector.status()); + assertTrue(Files.size(dataDirectory.resolve(".control-proxy-pending-results.json")) > 256 * 1024); + + connector.close(); + connector = fileConnector(service); + transport.operationClaim = CompletableFuture.completedFuture(new Response(204, "")); + transport.resultSubmission = CompletableFuture.completedFuture(new Response(200, "{}")); + connector.cycle(); + + assertFalse(Files.exists(dataDirectory.resolve(".control-proxy-pending-results.json"))); + assertEquals(2, transport.requests.stream().filter(request -> request.path().endsWith("/result")).count()); + } + @Test void expiredResultLeaseIsReclaimedAndReboundBeforeResubmission() { connector.close(); ProxyRoutingConfiguration current = new ProxyRoutingConfiguration(false, List.of()); @@ -438,12 +613,59 @@ private Settings settings() { URI.create("http://127.0.0.1:8080"), 30, 3000, 5000); } + private JsonObject submittedResult() { + return JsonParser.parseString(transport.requests.stream().filter(request -> request.path().endsWith("/result")) + .findFirst().orElseThrow().body()).getAsJsonObject(); + } + + private static void atomicMove(Path source, Path target) throws java.io.IOException { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } + + private static Class taskResultClass() { + return java.util.Arrays.stream(ControlConnector.class.getDeclaredClasses()) + .filter(type -> type.getSimpleName().equals("TaskResult")).findFirst().orElseThrow(); + } + + @SuppressWarnings("unchecked") + private int completedTaskCount() throws Exception { + Field completed = ControlConnector.class.getDeclaredField("completedTasks"); + completed.setAccessible(true); + return ((Map) completed.get(connector)).size(); + } + + @SuppressWarnings("unchecked") + private ControlConnector fileConnector(ProxyConfigurationFileService fileService) throws Exception { + Constructor constructor = ControlConnector.class.getDeclaredConstructor(Settings.class, + ScheduledExecutorService.class, Transport.class, Supplier.class, Consumer.class, UUID.class, + LongSupplier.class, ProxyRoutingConfigurationService.class, Path.class, ProxyControlResultStore.Route.class, + boolean.class, Runnable.class, Function.class, ProxyMethodConfigurationService.class, Runnable.class, + ProxyConfigurationFileService.class); + constructor.setAccessible(true); + ControlConnector created = constructor.newInstance(settings(), scheduler, transport, + (Supplier>) List::of, + (Consumer) logs::add, UUID.randomUUID(), (LongSupplier) () -> 0L, null, dataDirectory, + new ProxyControlResultStore.Route("proxy-a", "Proxy A", "VELOCITY", "7.1.2", + URI.create("http://127.0.0.1:8080"), "credential.txt", 30, 3000, 5000), + false, null, + (Function>) null, + null, null, fileService); + ProxyControlResultStore.State recovered = ProxyControlResultStore.load(dataDirectory); + if (recovered != null) { + Field completed = ControlConnector.class.getDeclaredField("completedTasks"); + completed.setAccessible(true); + ((Map) completed.get(created)).putAll(recovered.results()); + } + return created; + } + 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 boolean acceptProxyFiles; private CompletableFuture operationClaim; private CompletableFuture resultSubmission; private CountDownLatch firstSendEntered; @@ -488,7 +710,9 @@ public CompletableFuture send(Request request) { } if ("/api/v1/nodes/register".equals(request.path())) { String capabilities = acceptConfiguration - ? "[\"presence.snapshot\",\"config.proxy-routing.v1\"]" : "[\"presence.snapshot\"]"; + ? "[\"presence.snapshot\",\"config.proxy-routing.v1\"]" + : acceptProxyFiles ? "[\"presence.snapshot\",\"config.proxy-files.v1\"]" + : "[\"presence.snapshot\"]"; return CompletableFuture.completedFuture(new Response(201, "{\"identity\":{\"protocolVersion\":1},\"node\":{\"acceptedCapabilities\":" + capabilities + "}}")); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyConfigurationFileServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyConfigurationFileServiceTest.java new file mode 100644 index 000000000..09d44c992 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyConfigurationFileServiceTest.java @@ -0,0 +1,348 @@ +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.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ProxyConfigurationFileServiceTest { + @TempDir Path directory; + + @Test + void readMasksCredentialsJdbcDetailsAndControlPaths() throws Exception { + Path file = write(""" + Database: + Host: db.internal + Port: 3306 + Database: voting + Username: admin + Password: secret + Redis: + Host: redis.internal + Password: redis-secret + MQTT: + BrokerURL: tcp://user:pass@broker.internal:1883 + Control: + CredentialFile: control/credential.txt + Hosted: + JarFile: control/control.jar + DataDirectory: control/data + BungeeMethod: PLUGINMESSAGING + """); + + String content = service(file).read(ProxyConfigurationFileService.FILE_NAME).content(); + + assertFalse(content.contains("db.internal")); + assertFalse(content.contains("voting")); + assertFalse(content.contains("admin")); + assertFalse(content.contains("secret")); + assertFalse(content.contains("user:pass")); + assertFalse(content.contains("control/credential.txt")); + assertFalse(content.contains("control/control.jar")); + assertFalse(content.contains("control/data")); + assertTrue(content.contains("redis.internal")); + assertTrue(content.contains(ProxyConfigurationFileService.REDACTED)); + } + + @Test + void previewRestoresMaskedValuesAndAllowsSafeNestedAdditions() throws Exception { + Path file = write(""" + Database: + Host: db.internal + Password: secret + BungeeMethod: PLUGINMESSAGING + """); + ProxyConfigurationFileService service = service(file); + String proposal = service.read(ProxyConfigurationFileService.FILE_NAME).content() + + "NewSection:\n Enabled: true\n"; + + ProxyConfigurationFileService.Preview preview = service.preview( + ProxyConfigurationFileService.FILE_NAME, proposal); + + assertTrue(preview.resolvedContent().contains("db.internal")); + assertTrue(preview.resolvedContent().contains("secret")); + assertTrue(preview.resolvedContent().contains("NewSection")); + assertTrue(preview.changes().contains("added NewSection.Enabled")); + } + + @Test + void roundTripsCommentsStylesAndNestedAdditionsWithoutLeakingSecrets() throws Exception { + Path file = write(""" + # public header + General: # public inline + # nested public comment + Message: "hello" # still public + Items: + - first # sequence comment + - | + a block value + remains styled + Flow: {Enabled: true, Label: 'quoted'} + Database: + Password: secret-value # password is secret-value + MQTT: + BrokerURL: tcp://user:password@broker.internal:1883 # jdbc://user:password@host + Debug: 'false' + """); + ProxyConfigurationFileService service = service(file); + + ProxyConfigurationFileService.Document document = service.read(ProxyConfigurationFileService.FILE_NAME); + assertTrue(document.content().contains("# public header")); + assertTrue(document.content().contains("# nested public comment")); + assertTrue(document.content().contains("# sequence comment")); + assertTrue(document.content().contains("Message: \"hello\"")); + assertTrue(document.content().contains("Flow: {")); + assertTrue(document.content().contains("Label: 'quoted'")); + assertTrue(document.content().contains("Debug: 'false'")); + assertFalse(document.content().contains("secret-value")); + assertFalse(document.content().contains("user:password")); + assertFalse(document.content().contains("jdbc://")); + assertTrue(document.content().contains("# " + ProxyConfigurationFileService.REDACTED)); + + String proposal = document.content() + "Added:\n Value: true\n"; + ProxyConfigurationFileService.Preview preview = service.preview(ProxyConfigurationFileService.FILE_NAME, proposal); + assertTrue(preview.resolvedContent().contains("# public header")); + assertTrue(preview.resolvedContent().contains("# nested public comment")); + assertTrue(preview.resolvedContent().contains("# sequence comment")); + assertTrue(preview.resolvedContent().contains("Password: secret-value # password is secret-value")); + assertTrue(preview.resolvedContent().contains("BrokerURL: tcp://user:password@broker.internal:1883")); + assertTrue(preview.resolvedContent().contains("Added:\n Value: true")); + service.apply(ProxyConfigurationFileService.FILE_NAME, proposal, document.revision()); + String applied = Files.readString(file); + assertTrue(applied.contains("# public header")); + assertTrue(applied.contains("# nested public comment")); + assertTrue(applied.contains("# sequence comment")); + assertTrue(applied.contains("Password: secret-value # password is secret-value")); + assertTrue(applied.contains("Added:\n Value: true")); + } + + @Test + void masksShortAndBooleanLikeSecretsRepeatedInOtherwisePublicComments() throws Exception { + Path file = write(""" + Database: + Password: abcde + Redis: + Password: false + MQTT: + Password: on + Socket: + Password: no + Other: + Password: x + Debug: false # repeats abcde + Feature: true # false + Mode: safe # on + Fallback: safe # no + Marker: safe # x + """); + + String content = service(file).read(ProxyConfigurationFileService.FILE_NAME).content(); + + assertFalse(content.contains("abcde")); + assertFalse(content.contains("# false")); + assertFalse(content.contains("# on")); + assertFalse(content.contains("# no")); + assertFalse(content.contains("# x")); + assertTrue(content.contains("# " + ProxyConfigurationFileService.REDACTED)); + } + + @Test + void previewAllowsAnExplicitReplacementForAnExistingSecret() throws Exception { + Path file = write("Database:\n Password: old-secret\nDebug: false\n"); + ProxyConfigurationFileService service = service(file); + ProxyConfigurationFileService.Document current = service.read(ProxyConfigurationFileService.FILE_NAME); + String proposal = current.content().replace("Password: " + ProxyConfigurationFileService.REDACTED, + "Password: new-secret"); + + ProxyConfigurationFileService.Preview preview = service.preview( + ProxyConfigurationFileService.FILE_NAME, proposal); + + assertTrue(preview.resolvedContent().contains("Password: new-secret")); + assertFalse(preview.resolvedContent().contains("old-secret")); + } + + @Test + void rejectsEditedDeletedOrMovedSecretValueAndCommentMarkers() throws Exception { + Path file = write(""" + Database: + Password: secret # a password comment + Debug: false + """); + ProxyConfigurationFileService service = service(file); + String proposal = service.read(ProxyConfigurationFileService.FILE_NAME).content(); + + assertThrows(IllegalArgumentException.class, () -> service.preview(ProxyConfigurationFileService.FILE_NAME, + proposal.replace(ProxyConfigurationFileService.REDACTED, "changed"))); + assertThrows(IllegalArgumentException.class, () -> service.preview(ProxyConfigurationFileService.FILE_NAME, + proposal.replace(" Password: " + ProxyConfigurationFileService.REDACTED + " # " + + ProxyConfigurationFileService.REDACTED + "\n", ""))); + assertThrows(IllegalArgumentException.class, () -> service.preview(ProxyConfigurationFileService.FILE_NAME, + proposal.replace("# " + ProxyConfigurationFileService.REDACTED, "# edited"))); + assertThrows(IllegalArgumentException.class, + () -> service.preview(ProxyConfigurationFileService.FILE_NAME, "Debug: false\n")); + String moved = proposal.replace(" Password: " + ProxyConfigurationFileService.REDACTED + " # " + + ProxyConfigurationFileService.REDACTED + "\n", "") + + "OtherPassword: " + ProxyConfigurationFileService.REDACTED + " # " + + ProxyConfigurationFileService.REDACTED + "\n"; + assertThrows(IllegalArgumentException.class, + () -> service.preview(ProxyConfigurationFileService.FILE_NAME, moved)); + } + + @Test + void applyPublishesConfigurationAndPreservesTargetPermissions() throws Exception { + Path file = write("BungeeMethod: PLUGINMESSAGING\nDebug: false\n"); + try { + Files.setPosixFilePermissions(file, java.nio.file.attribute.PosixFilePermissions.fromString("rw-------")); + } catch (UnsupportedOperationException ignored) { } + ProxyConfigurationFileService service = service(file); + ProxyConfigurationFileService.Document current = service.read(ProxyConfigurationFileService.FILE_NAME); + + ProxyConfigurationFileService.ApplyResult applied = service.apply(ProxyConfigurationFileService.FILE_NAME, + current.content().replace("false", "true"), current.revision()); + + assertTrue(Files.readString(file).contains("Debug: true")); + assertFalse(applied.rolledBack()); + assertTrue(Files.isRegularFile(directory.resolve("bungeeconfig.yml.control-backup"))); + try { + assertEquals("rw-------", java.nio.file.attribute.PosixFilePermissions.toString( + Files.getPosixFilePermissions(file))); + } catch (UnsupportedOperationException ignored) { } + } + + @Test + void publicationFailureLeavesOriginalReadableAndRetryable() throws Exception { + Path file = write("BungeeMethod: PLUGINMESSAGING\nDebug: false\n"); + String original = Files.readString(file); + AtomicInteger moves = new AtomicInteger(); + ProxyConfigurationFileService failing = new ProxyConfigurationFileService(file, (source, destination) -> { + if (moves.incrementAndGet() == 2) throw new IOException("forced publication failure " + destination); + atomicMove(source, destination); + }); + ProxyConfigurationFileService.Document current = failing.read(ProxyConfigurationFileService.FILE_NAME); + + assertThrows(ProxyConfigurationFileService.ApplyFailureException.class, + () -> failing.apply(ProxyConfigurationFileService.FILE_NAME, + current.content().replace("false", "true"), current.revision())); + assertEquals(original, Files.readString(file)); + try (java.util.stream.Stream files = Files.list(directory)) { + assertFalse(files.anyMatch(path -> path.getFileName().toString().startsWith(".control-proxy-"))); + } + + ProxyConfigurationFileService retry = service(file); + ProxyConfigurationFileService.ApplyResult applied = retry.apply(ProxyConfigurationFileService.FILE_NAME, + retry.read(ProxyConfigurationFileService.FILE_NAME).content().replace("false", "true"), + retry.read(ProxyConfigurationFileService.FILE_NAME).revision()); + assertTrue(Files.readString(file).contains("Debug: true")); + assertFalse(applied.rolledBack()); + } + + @Test + void publishedDurabilityFailureRollsBackOriginal() throws Exception { + Path file = write("BungeeMethod: PLUGINMESSAGING\nDebug: false\n"); + try { + Files.setPosixFilePermissions(file, java.nio.file.attribute.PosixFilePermissions.fromString("rw-r-----")); + } catch (UnsupportedOperationException ignored) { } + String original = Files.readString(file); + AtomicInteger moves = new AtomicInteger(); + ProxyConfigurationFileService failing = new ProxyConfigurationFileService(file, (source, destination) -> { + atomicMove(source, destination); + if (moves.incrementAndGet() == 2) { + throw new com.bencodez.votingplugin.util.DurableFiles.PublishedException( + new IOException("forced directory sync failure")); + } + }); + ProxyConfigurationFileService.Document current = failing.read(ProxyConfigurationFileService.FILE_NAME); + + ProxyConfigurationFileService.ApplyFailureException failure = assertThrows( + ProxyConfigurationFileService.ApplyFailureException.class, + () -> failing.apply(ProxyConfigurationFileService.FILE_NAME, + current.content().replace("false", "true"), current.revision())); + + assertTrue(failure.rolledBack()); + assertEquals(original, Files.readString(file)); + try { + assertEquals("rw-r-----", java.nio.file.attribute.PosixFilePermissions.toString( + Files.getPosixFilePermissions(file))); + } catch (UnsupportedOperationException ignored) { } + } + + @Test + void cleansTheFirstTemporaryFileWhenBackupStagingCannotBeCreated() throws Exception { + Path file = write("BungeeMethod: PLUGINMESSAGING\nDebug: false\n"); + AtomicInteger calls = new AtomicInteger(); + ProxyConfigurationFileService service = new ProxyConfigurationFileService(file, + ProxyConfigurationFileServiceTest::atomicMove, (parent, prefix, suffix) -> { + if (calls.incrementAndGet() == 2) throw new IOException("forced backup-stage failure"); + return Files.createTempFile(parent, prefix, suffix); + }); + ProxyConfigurationFileService.Document current = service.read(ProxyConfigurationFileService.FILE_NAME); + + assertThrows(ProxyConfigurationFileService.ApplyFailureException.class, + () -> service.apply(ProxyConfigurationFileService.FILE_NAME, + current.content().replace("false", "true"), current.revision())); + assertEquals("BungeeMethod: PLUGINMESSAGING\nDebug: false\n", Files.readString(file)); + try (java.util.stream.Stream files = Files.list(directory)) { + assertFalse(files.anyMatch(path -> path.getFileName().toString().startsWith(".control-proxy-"))); + } + } + + @Test + void rejectsDuplicateKeysAliasesInvalidPlaceholderAndStaleRevision() throws Exception { + Path file = write("BungeeMethod: PLUGINMESSAGING\nDebug: false\n"); + ProxyConfigurationFileService service = service(file); + ProxyConfigurationFileService.Document current = service.read(ProxyConfigurationFileService.FILE_NAME); + + assertThrows(IllegalArgumentException.class, + () -> service.preview(ProxyConfigurationFileService.FILE_NAME, "Debug: true\nDebug: false\n")); + assertThrows(IllegalArgumentException.class, + () -> service.preview(ProxyConfigurationFileService.FILE_NAME, "Base: &base {Debug: true}\nCopy: *base\n")); + assertThrows(IllegalArgumentException.class, + () -> service.preview(ProxyConfigurationFileService.FILE_NAME, + "Debug: " + ProxyConfigurationFileService.REDACTED + "\n")); + Files.writeString(file, "BungeeMethod: REDIS\nDebug: false\n"); + assertThrows(ProxyConfigurationFileService.StaleRevisionException.class, + () -> service.apply(ProxyConfigurationFileService.FILE_NAME, current.content(), current.revision())); + } + + @Test + void rejectsSymlinkTargetsAndInvalidUtf8() throws Exception { + Path real = directory.resolve("real.yml"); + Files.writeString(real, "Debug: false\n"); + Path linked = directory.resolve("bungeeconfig.yml"); + try { + Files.createSymbolicLink(linked, real.getFileName()); + } catch (UnsupportedOperationException | IOException unsupported) { + return; + } + assertThrows(IOException.class, + () -> service(linked).read(ProxyConfigurationFileService.FILE_NAME)); + Files.delete(linked); + Files.write(linked, new byte[] {(byte) 0xc3, (byte) 0x28}); + assertThrows(IOException.class, + () -> service(linked).read(ProxyConfigurationFileService.FILE_NAME)); + } + + private Path write(String content) throws IOException { + Path file = directory.resolve("bungeeconfig.yml"); + Files.writeString(file, content, StandardCharsets.UTF_8); + return file; + } + + private static ProxyConfigurationFileService service(Path file) { + return new ProxyConfigurationFileService(file, ProxyConfigurationFileServiceTest::atomicMove); + } + + private static void atomicMove(Path source, Path destination) throws IOException { + Files.move(source, destination, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } +} 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 index 5f6551bde..78e0724c9 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyControlResultStoreTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyControlResultStoreTest.java @@ -56,6 +56,27 @@ class ProxyControlResultStoreTest { assertFalse(recovered.claimRequired()); } + @Test void managedFileReadLargerThanLegacyJournalLimitSurvivesRestart() 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); + JsonObject configuration = new JsonObject(); + configuration.addProperty("domain", "file"); + configuration.addProperty("fileName", ProxyConfigurationFileService.FILE_NAME); + configuration.addProperty("content", "a".repeat(300 * 1024)); + result.add("configuration", configuration); + + ProxyControlResultStore.save(directory, route, + Map.of(operationId, new StoredResult(result, true, false))); + ProxyControlResultStore.State recovered = ProxyControlResultStore.load(directory); + + assertEquals(300 * 1024, recovered.results().get(operationId).result() + .getAsJsonObject("configuration").get("content").getAsString().length()); + assertTrue(Files.size(directory.resolve(".control-proxy-pending-results.json")) > 256 * 1024); + } + @Test void symbolicProxyResultJournalIsRejected() throws Exception { Path external = directory.resolve("external.json"); Files.writeString(external, "{}"); From 3d948d817da8e80c741b2f72fbade41fac075c90 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:28:19 -0600 Subject: [PATCH 2/7] Harden proxy configuration control --- .../proxy/control/ControlConnector.java | 29 +- .../ProxyConfigurationFileService.java | 282 +++++++++++++++--- .../proxy/control/ControlConnectorTest.java | 23 ++ .../ProxyConfigurationFileServiceTest.java | 103 ++++++- 4 files changed, 384 insertions(+), 53 deletions(-) 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 index 628fe7b20..1b3ac32a5 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java @@ -15,6 +15,7 @@ import java.util.HexFormat; import java.util.List; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.Map; import java.util.Objects; import java.util.Set; @@ -87,6 +88,7 @@ public final class ControlConnector implements AutoCloseable { private volatile boolean closed; private volatile boolean registered; private volatile boolean configurationAccepted; + private volatile Set acceptedCapabilities = Set.of(); private volatile int failures; private volatile long snapshotSequence; private volatile ScheduledFuture scheduled; @@ -400,9 +402,16 @@ private void handlePrimaryResponse(Response response, boolean registration) { if (!contains(accepted, "presence.snapshot")) { throw new ProtocolException(); } - configurationAccepted = contains(accepted, CONFIGURATION_CAPABILITY) - || contains(accepted, COMMUNICATION_TEST_CAPABILITY) - || contains(accepted, PROXY_METHOD_CAPABILITY) || contains(accepted, PROXY_FILE_CAPABILITY); + LinkedHashSet negotiated = new LinkedHashSet<>(); + for (JsonElement capability : accepted) { + if (!capability.isJsonPrimitive() || !capability.getAsJsonPrimitive().isString()) { + throw new ProtocolException(); + } + negotiated.add(capability.getAsString()); + } + acceptedCapabilities = Set.copyOf(negotiated); + configurationAccepted = acceptedCapabilities.stream().anyMatch(Set.of(CONFIGURATION_CAPABILITY, + COMMUNICATION_TEST_CAPABILITY, PROXY_METHOD_CAPABILITY, PROXY_FILE_CAPABILITY)::contains); } } @@ -717,10 +726,20 @@ private static StoredResult committedForAttempt(StoredResult pending, String att private CompletableFuture executeTask(UUID operationId, JsonObject task) { JsonObject requested = task.getAsJsonObject("configuration"); if (isProxyFile(requested)) { + if (!acceptedCapabilities.contains(PROXY_FILE_CAPABILITY)) { + return completed(TaskResult.failure("UNSUPPORTED", "Proxy file control was not negotiated")); + } return executeProxyFile(operationId, task, requested); } - if (isCommunicationTest(requested)) return executeCommunicationTest(task, requested); - if (isProxyMethod(requested)) return executeProxyMethod(operationId, task, requested); + if (isCommunicationTest(requested)) return acceptedCapabilities.contains(COMMUNICATION_TEST_CAPABILITY) + ? executeCommunicationTest(task, requested) + : completed(TaskResult.failure("UNSUPPORTED", "Communication testing was not negotiated")); + if (isProxyMethod(requested)) return acceptedCapabilities.contains(PROXY_METHOD_CAPABILITY) + ? executeProxyMethod(operationId, task, requested) + : completed(TaskResult.failure("UNSUPPORTED", "Proxy method control was not negotiated")); + if (!acceptedCapabilities.contains(CONFIGURATION_CAPABILITY)) { + return completed(TaskResult.failure("UNSUPPORTED", "Proxy routing control was not negotiated")); + } if (configurationService == null) return completed(TaskResult.failure("UNSUPPORTED", "Configuration control is unavailable")); String type = requireString(task, "type"); try { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyConfigurationFileService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyConfigurationFileService.java index 37f619a5b..064a1907a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyConfigurationFileService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyConfigurationFileService.java @@ -267,22 +267,58 @@ private static Map mask(Map source, String path) Object value = entry.getValue(); String childPath = path + entry.getKey(); if (secret(childPath, entry.getKey(), value)) result.put(entry.getKey(), REDACTED); - else if (value instanceof Map map) result.put(entry.getKey(), mask((Map) map, childPath + ".")); - else result.put(entry.getKey(), value); + else result.put(entry.getKey(), maskStructure(value, childPath)); } return result; } - private static void redactValues(Node node, Map source, String path, Set values) { - if (!(node instanceof MappingNode mapping)) { + @SuppressWarnings("unchecked") + private static Object maskStructure(Object value, String path) { + if (value instanceof Map map) return mask((Map) map, path + "."); + if (value instanceof List list) { + List result = new ArrayList<>(); + for (int index = 0; index < list.size(); index++) { + Object item = list.get(index); + String itemPath = path + "[" + index + "]"; + result.add(secret(itemPath, "", item) ? REDACTED : maskStructure(item, itemPath)); + } + return List.copyOf(result); + } + return value; + } + + private static void redactValues(Node node, Object source, String path, Set values) { + if (node instanceof SequenceNode sequence && source instanceof List list) { + redactComments(sequence, path, false, values, new LinkedHashMap<>()); + List children = new ArrayList<>(); + for (int index = 0; index < sequence.getValue().size(); index++) { + Node child = sequence.getValue().get(index); + Object value = index < list.size() ? list.get(index) : null; + String itemPath = path + "[" + index + "]"; + if (secret(itemPath, "", value)) { + redactComments(child, itemPath, true, values, new LinkedHashMap<>()); + child = marker(child); + } else if (value instanceof Map || value instanceof List) { + redactValues(child, value, value instanceof Map ? itemPath + "." : itemPath, values); + } else { + redactDescendantComments(child, itemPath, false, values, new LinkedHashMap<>()); + } + children.add(child); + } + sequence.getValue().clear(); + sequence.getValue().addAll(children); + return; + } + if (!(node instanceof MappingNode mapping) || !(source instanceof Map rawSource)) { redactDescendantComments(node, path, false, values, new LinkedHashMap<>()); return; } + @SuppressWarnings("unchecked") Map sourceMap = (Map) rawSource; redactComments(mapping, path, false, values, new LinkedHashMap<>()); List tuples = new ArrayList<>(); for (NodeTuple tuple : mapping.getValue()) { String key = key(tuple.getKeyNode()); - Object value = source.get(key); + Object value = sourceMap.get(key); String childPath = path + key; boolean hidden = secret(childPath, key, value); redactComments(tuple.getKeyNode(), childPath + "#key", hidden, values, new LinkedHashMap<>()); @@ -290,9 +326,8 @@ private static void redactValues(Node node, Map source, String p if (hidden) { redactComments(child, childPath, true, values, new LinkedHashMap<>()); child = marker(child); - } else if (value instanceof Map nested) { - @SuppressWarnings("unchecked") Map nestedValues = (Map) nested; - redactValues(child, nestedValues, childPath + ".", values); + } else if (value instanceof Map || value instanceof List) { + redactValues(child, value, value instanceof Map ? childPath + "." : childPath, values); } else { redactDescendantComments(child, childPath, false, values, new LinkedHashMap<>()); } @@ -324,40 +359,124 @@ private static void validateRedactedValues(Map current, Map oldValues = (Map) oldMap; @SuppressWarnings("unchecked") Map candidateValues = (Map) proposedMap; validateRedactedValues(oldValues, candidateValues, childPath + "."); + } else if (old instanceof List oldList) { + if (!(candidate instanceof List proposedList)) { + if (containsSecrets(oldList, childPath)) { + throw new IllegalArgumentException("redacted placeholder is invalid"); + } + continue; + } + validateRedactedList(oldList, proposedList, childPath); + } else if (REDACTED.equals(candidate)) { + throw new IllegalArgumentException("redacted placeholder is invalid"); + } + } + for (Map.Entry entry : proposed.entrySet()) { + if (!current.containsKey(entry.getKey()) && containsMarker(entry.getValue())) { + throw new IllegalArgumentException("redacted placeholder is invalid"); } } } @SuppressWarnings("unchecked") - private static boolean containsSecrets(Map source, String path) { - for (Map.Entry entry : source.entrySet()) { - if (!(entry.getKey() instanceof String key)) return true; - Object value = entry.getValue(); - String childPath = path + key; - if (secret(childPath, key, value)) return true; - if (value instanceof Map nested && containsSecrets(nested, childPath + ".")) return true; + private static void validateRedactedList(List current, List proposed, String path) { + if (containsSecrets(current, path) && current.size() != proposed.size()) { + throw new IllegalArgumentException("redacted placeholder is invalid"); + } + for (int index = 0; index < Math.min(current.size(), proposed.size()); index++) { + Object old = current.get(index); + Object candidate = proposed.get(index); + String itemPath = path + "[" + index + "]"; + if (secret(itemPath, "", old)) { + if (candidate instanceof Map || candidate instanceof List) { + throw new IllegalArgumentException("redacted placeholder is invalid"); + } + } else if (old instanceof Map oldMap) { + if (!(candidate instanceof Map candidateMap)) { + if (containsSecrets(oldMap, itemPath + ".")) throw new IllegalArgumentException("redacted placeholder is invalid"); + } else { + validateRedactedValues((Map) oldMap, (Map) candidateMap, itemPath + "."); + } + } else if (old instanceof List oldList) { + if (!(candidate instanceof List candidateList)) { + if (containsSecrets(oldList, itemPath)) throw new IllegalArgumentException("redacted placeholder is invalid"); + } else validateRedactedList(oldList, candidateList, itemPath); + } else if (REDACTED.equals(candidate)) { + throw new IllegalArgumentException("redacted placeholder is invalid"); + } + } + for (int index = current.size(); index < proposed.size(); index++) { + if (containsMarker(proposed.get(index))) throw new IllegalArgumentException("redacted placeholder is invalid"); + } + } + + private static boolean containsSecrets(Object source, String path) { + if (source instanceof Map map) { + for (Map.Entry entry : map.entrySet()) { + if (!(entry.getKey() instanceof String key)) return true; + Object value = entry.getValue(); + String childPath = path + key; + if (secret(childPath, key, value) || containsSecrets(value, + value instanceof Map ? childPath + "." : childPath)) return true; + } + } else if (source instanceof List list) { + for (int index = 0; index < list.size(); index++) { + Object value = list.get(index); + String itemPath = path + "[" + index + "]"; + if (secret(itemPath, "", value) || containsSecrets(value, + value instanceof Map ? itemPath + "." : itemPath)) return true; + } } return false; } - private static void restoreRedactedValues(Node proposed, Node current, Map currentValues, - Map proposedValues, String path) { - if (!(proposed instanceof MappingNode proposedMap) || !(current instanceof MappingNode currentMap)) return; + private static boolean containsMarker(Object value) { + if (REDACTED.equals(value)) return true; + if (value instanceof Map map) return map.values().stream().anyMatch(ProxyConfigurationFileService::containsMarker); + if (value instanceof List list) return list.stream().anyMatch(ProxyConfigurationFileService::containsMarker); + return false; + } + + private static void restoreRedactedValues(Node proposed, Node current, Object currentValues, + Object proposedValues, String path) { + if (proposed instanceof SequenceNode proposedSequence && current instanceof SequenceNode currentSequence + && currentValues instanceof List oldList && proposedValues instanceof List candidateList) { + List restored = new ArrayList<>(); + for (int index = 0; index < proposedSequence.getValue().size(); index++) { + Node value = proposedSequence.getValue().get(index); + Object old = index < oldList.size() ? oldList.get(index) : null; + Object candidate = index < candidateList.size() ? candidateList.get(index) : null; + String itemPath = path + "[" + index + "]"; + if (index < currentSequence.getValue().size() && secret(itemPath, "", old) && REDACTED.equals(candidate)) { + value = restoreSecretNode(currentSequence.getValue().get(index), value); + } else if (index < currentSequence.getValue().size() + && (old instanceof Map || old instanceof List)) { + restoreRedactedValues(value, currentSequence.getValue().get(index), old, candidate, + old instanceof Map ? itemPath + "." : itemPath); + } + restored.add(value); + } + proposedSequence.getValue().clear(); + proposedSequence.getValue().addAll(restored); + return; + } + if (!(proposed instanceof MappingNode proposedMap) || !(current instanceof MappingNode currentMap) + || !(currentValues instanceof Map rawCurrent) || !(proposedValues instanceof Map rawProposed)) return; + @SuppressWarnings("unchecked") Map currentMapValues = (Map) rawCurrent; + @SuppressWarnings("unchecked") Map proposedMapValues = (Map) rawProposed; Map currentTuples = tuples(currentMap); List restored = new ArrayList<>(); for (NodeTuple tuple : proposedMap.getValue()) { String key = key(tuple.getKeyNode()); - Object old = currentValues.get(key); + Object old = currentMapValues.get(key); String childPath = path + key; Node value = tuple.getValueNode(); if (currentTuples.containsKey(key) && secret(childPath, key, old) - && REDACTED.equals(proposedValues.get(key))) { + && REDACTED.equals(proposedMapValues.get(key))) { value = restoreSecretNode(currentTuples.get(key).getValueNode(), value); - } else if (old instanceof Map oldMap && proposedValues.get(key) instanceof Map proposedMapValue) { - @SuppressWarnings("unchecked") Map oldValues = (Map) oldMap; - @SuppressWarnings("unchecked") Map candidateValues = (Map) proposedMapValue; + } else if ((old instanceof Map || old instanceof List) && currentTuples.containsKey(key)) { restoreRedactedValues(value, currentTuples.containsKey(key) ? currentTuples.get(key).getValueNode() : value, - oldValues, candidateValues, childPath + "."); + old, proposedMapValues.get(key), old instanceof Map ? childPath + "." : childPath); } restored.add(new NodeTuple(tuple.getKeyNode(), value)); } @@ -381,23 +500,34 @@ private static Node marker(Node source) { return marker; } - private static Map redactComments(Node node, Map source, String path, + private static Map redactComments(Node node, Object source, String path, Set values) { Map result = new LinkedHashMap<>(); redactComments(node, path, false, values, result); - if (node instanceof MappingNode mapping) { + if (node instanceof MappingNode mapping && source instanceof Map rawSource) { + @SuppressWarnings("unchecked") Map sourceMap = (Map) rawSource; for (NodeTuple tuple : mapping.getValue()) { String key = key(tuple.getKeyNode()); - Object value = source.get(key); + Object value = sourceMap.get(key); String childPath = path + key; boolean hidden = secret(childPath, key, value); redactComments(tuple.getKeyNode(), childPath + "#key", hidden, values, result); if (hidden) redactComments(tuple.getValueNode(), childPath, true, values, result); - else if (value instanceof Map nested) { - @SuppressWarnings("unchecked") Map nestedValues = (Map) nested; - result.putAll(redactComments(tuple.getValueNode(), nestedValues, childPath + ".", values)); + else if (value instanceof Map || value instanceof List) { + result.putAll(redactComments(tuple.getValueNode(), value, + value instanceof Map ? childPath + "." : childPath, values)); } else redactDescendantComments(tuple.getValueNode(), childPath, false, values, result); } + } else if (node instanceof SequenceNode sequence && source instanceof List list) { + for (int index = 0; index < sequence.getValue().size(); index++) { + Node child = sequence.getValue().get(index); + Object value = index < list.size() ? list.get(index) : null; + String itemPath = path + "[" + index + "]"; + if (secret(itemPath, "", value)) redactComments(child, itemPath, true, values, result); + else if (value instanceof Map || value instanceof List) { + result.putAll(redactComments(child, value, value instanceof Map ? itemPath + "." : itemPath, values)); + } else redactDescendantComments(child, itemPath, false, values, result); + } } return result; } @@ -409,11 +539,14 @@ private static void redactDescendantComments(Node node, String path, boolean sen for (NodeTuple tuple : mapping.getValue()) { String key = key(tuple.getKeyNode()); redactDescendantComments(tuple.getKeyNode(), path + key + "#key", sensitiveContext, values, redacted); - redactDescendantComments(tuple.getValueNode(), path + key, sensitiveContext, values, redacted); + Node child = tuple.getValueNode(); + redactDescendantComments(child, path + key + (child instanceof MappingNode ? "." : ""), + sensitiveContext, values, redacted); } } else if (node instanceof SequenceNode sequence) { for (int index = 0; index < sequence.getValue().size(); index++) { - redactDescendantComments(sequence.getValue().get(index), path + "[" + index + "]", sensitiveContext, + Node child = sequence.getValue().get(index); + redactDescendantComments(child, path + "[" + index + "]" + (child instanceof MappingNode ? "." : ""), sensitiveContext, values, redacted); } } @@ -477,7 +610,8 @@ private static void collectComments(Node node, String path, Map sensitiveValues(Node node, Map source return values; } - @SuppressWarnings("unchecked") - private static void collectSensitiveValues(Node node, Map source, String path, Set values) { - if (!(node instanceof MappingNode mapping)) return; - for (NodeTuple tuple : mapping.getValue()) { - String key = key(tuple.getKeyNode()); - Object value = source.get(key); - String childPath = path + key; - if (secret(childPath, key, value) && tuple.getValueNode() instanceof ScalarNode scalar - && safeSecretValue(scalar.getValue())) { - values.add(scalar.getValue().trim()); - } else if (value instanceof Map nested) { - collectSensitiveValues(tuple.getValueNode(), (Map) nested, childPath + ".", values); + private static void collectSensitiveValues(Node node, Object source, String path, Set values) { + if (node instanceof MappingNode mapping && source instanceof Map sourceMap) { + for (NodeTuple tuple : mapping.getValue()) { + String key = key(tuple.getKeyNode()); + Object value = sourceMap.get(key); + String childPath = path + key; + if (secret(childPath, key, value) && tuple.getValueNode() instanceof ScalarNode scalar + && safeSecretValue(scalar.getValue())) { + values.add(scalar.getValue().trim()); + } else if (value instanceof Map || value instanceof List) { + collectSensitiveValues(tuple.getValueNode(), value, + value instanceof Map ? childPath + "." : childPath, values); + } + } + } else if (node instanceof SequenceNode sequence && source instanceof List list) { + for (int index = 0; index < sequence.getValue().size(); index++) { + Node child = sequence.getValue().get(index); + Object value = index < list.size() ? list.get(index) : null; + String itemPath = path + "[" + index + "]"; + if (secret(itemPath, "", value) && child instanceof ScalarNode scalar && safeSecretValue(scalar.getValue())) { + values.add(scalar.getValue().trim()); + } else if (value instanceof Map || value instanceof List) { + collectSensitiveValues(child, value, value instanceof Map ? itemPath + "." : itemPath, values); + } } } } @@ -594,19 +740,57 @@ private static Map resolve(Map proposed, Map oldNested) oldValues = (Map) oldNested; else throw new IllegalArgumentException("proxy configuration shape changed"); result.put(key, resolve((Map) nested, oldValues, childPath + ".")); + } else if (value instanceof List list) { + List oldValues; + if (old == null) oldValues = List.of(); + else if (old instanceof List oldList) oldValues = oldList; + else throw new IllegalArgumentException("proxy configuration shape changed"); + result.put(key, resolveList(list, oldValues, childPath)); } else result.put(key, value); } return result; } + @SuppressWarnings("unchecked") + private static List resolveList(List proposed, List current, String path) { + List result = new ArrayList<>(); + for (int index = 0; index < proposed.size(); index++) { + Object value = proposed.get(index); + Object old = index < current.size() ? current.get(index) : null; + String itemPath = path + "[" + index + "]"; + if (REDACTED.equals(value)) { + if (index >= current.size() || !secret(itemPath, "", old)) { + throw new IllegalArgumentException("redacted placeholder is invalid"); + } + result.add(old); + } else if (value instanceof Map map) { + Map oldValues; + if (old == null) oldValues = Map.of(); + else if (old instanceof Map oldMap) oldValues = (Map) oldMap; + else throw new IllegalArgumentException("proxy configuration shape changed"); + result.add(resolve((Map) map, oldValues, itemPath + ".")); + } else if (value instanceof List list) { + List oldValues; + if (old == null) oldValues = List.of(); + else if (old instanceof List oldList) oldValues = oldList; + else throw new IllegalArgumentException("proxy configuration shape changed"); + result.add(resolveList(list, oldValues, itemPath)); + } else result.add(value); + } + return List.copyOf(result); + } + private static boolean secret(String path, String key, Object value) { String normalized = key.toLowerCase(Locale.ROOT).replace("_", "").replace("-", ""); if (normalized.contains("password") || normalized.contains("secret") || normalized.equals("token") || normalized.contains("apikey") || normalized.contains("authorization") || normalized.contains("webhookurl")) return true; String normalizedPath = path.toLowerCase(Locale.ROOT).replace("_", "").replace("-", ""); - if (normalizedPath.startsWith("database.") || normalizedPath.startsWith("globaldata.")) { - return Set.of("host", "port", "database", "username", "password", "line", "driver", "poolname") + if (infrastructurePath(normalizedPath, "database") || infrastructurePath(normalizedPath, "globaldata") + || infrastructurePath(normalizedPath, "redis") + || infrastructurePath(normalizedPath, "multiproxyredis")) { + return Set.of("host", "port", "database", "username", "password", "line", "driver", "poolname", + "prefix", "dbindex") .contains(normalized); } if (normalizedPath.startsWith("control.")) { @@ -619,6 +803,10 @@ private static boolean secret(String path, String key, Object value) { return false; } + private static boolean infrastructurePath(String normalizedPath, String section) { + return normalizedPath.startsWith(section + ".") || normalizedPath.contains("." + section + "."); + } + private static void copyPermissions(Path source, Path destination) throws IOException { java.nio.file.attribute.PosixFileAttributeView sourceView = Files.getFileAttributeView(source, java.nio.file.attribute.PosixFileAttributeView.class, LinkOption.NOFOLLOW_LINKS); 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 index badf9a79d..be4f31219 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java @@ -316,6 +316,29 @@ class ControlConnectorTest { assertFalse(transport.requests.stream().map(Request::body).anyMatch(body -> body.contains("local-secret"))); } + @Test void proxyFileTaskIsRejectedWhenOnlyRoutingControlWasNegotiated() throws Exception { + Path file = dataDirectory.resolve(ProxyConfigurationFileService.FILE_NAME); + Files.writeString(file, "Debug: false\n"); + connector.close(); + connector = fileConnector(new ProxyConfigurationFileService(file, (source, target) -> { + throw new AssertionError("an unnegotiated proxy file task must not publish changes"); + })); + 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\":\"ignored\"," + + "\"configuration\":{\"domain\":\"file\"," + + "\"fileName\":\"bungeeconfig.yml\",\"content\":\"Debug: true\\n\"}}")); + + connector.cycle(); + + JsonObject result = submittedResult(); + assertFalse(result.get("success").getAsBoolean()); + assertEquals("UNSUPPORTED", result.get("code").getAsString()); + assertEquals("Debug: false\n", Files.readString(file)); + } + @Test void proxyFileRejectsUnmanagedNamesWithAStructuredSafeFailure() throws Exception { Path file = dataDirectory.resolve(ProxyConfigurationFileService.FILE_NAME); Files.writeString(file, "Database:\n Password: local-secret\n"); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyConfigurationFileServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyConfigurationFileServiceTest.java index 09d44c992..4387d422d 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyConfigurationFileServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyConfigurationFileServiceTest.java @@ -50,10 +50,111 @@ void readMasksCredentialsJdbcDetailsAndControlPaths() throws Exception { assertFalse(content.contains("control/credential.txt")); assertFalse(content.contains("control/control.jar")); assertFalse(content.contains("control/data")); - assertTrue(content.contains("redis.internal")); + assertFalse(content.contains("redis.internal")); + assertFalse(content.contains("redis-secret")); assertTrue(content.contains(ProxyConfigurationFileService.REDACTED)); } + @Test + void masksAndRestoresSecretsNestedInSequences() throws Exception { + Path file = write(""" + Hooks: + - Name: primary + Authorization: sequence-secret # sequence-secret + Enabled: true + - Redis: + Host: redis.internal # redis.internal + Port: 6379 + Password: nested-password + SSL: true + Endpoints: + - jdbc:mysql://sequence-user:sequence-password@db.internal/votes # sequence-password + Debug: false + """); + ProxyConfigurationFileService service = service(file); + + ProxyConfigurationFileService.Document current = service.read(ProxyConfigurationFileService.FILE_NAME); + assertFalse(current.content().contains("sequence-secret")); + assertFalse(current.content().contains("redis.internal")); + assertFalse(current.content().contains("nested-password")); + assertFalse(current.content().contains("sequence-user")); + assertFalse(current.content().contains("sequence-password")); + assertTrue(current.content().contains("SSL: true")); + assertTrue(current.content().contains(ProxyConfigurationFileService.REDACTED)); + + String proposal = current.content().replace("Debug: false", "Debug: true"); + ProxyConfigurationFileService.Preview preview = service.preview(ProxyConfigurationFileService.FILE_NAME, proposal); + assertTrue(preview.resolvedContent().contains("Authorization: sequence-secret # sequence-secret")); + assertTrue(preview.resolvedContent().contains("Host: redis.internal # redis.internal")); + assertTrue(preview.resolvedContent().contains("Password: nested-password")); + assertTrue(preview.resolvedContent().contains("jdbc:mysql://sequence-user:sequence-password@db.internal/votes # sequence-password")); + assertTrue(preview.resolvedContent().contains("SSL: true")); + service.apply(ProxyConfigurationFileService.FILE_NAME, proposal, current.revision()); + String applied = Files.readString(file); + assertTrue(applied.contains("Authorization: sequence-secret # sequence-secret")); + assertTrue(applied.contains("Host: redis.internal # redis.internal")); + assertTrue(applied.contains("Password: nested-password")); + assertTrue(applied.contains("jdbc:mysql://sequence-user:sequence-password@db.internal/votes # sequence-password")); + assertTrue(applied.contains("Debug: true")); + } + + @Test + void masksAndRestoresMultiProxyRedisInfrastructure() throws Exception { + Path file = write(""" + MultiProxyRedis: + Host: multi-redis.internal # multi-redis.internal + Port: 6380 + Username: multi-user + Password: multi-password + Db-Index: 2 + SSL: true + Debug: false + """); + ProxyConfigurationFileService service = service(file); + + ProxyConfigurationFileService.Document current = service.read(ProxyConfigurationFileService.FILE_NAME); + assertFalse(current.content().contains("multi-redis.internal")); + assertFalse(current.content().contains("6380")); + assertFalse(current.content().contains("multi-user")); + assertFalse(current.content().contains("multi-password")); + assertFalse(current.content().contains("Db-Index: 2")); + assertTrue(current.content().contains("SSL: true")); + + String proposal = current.content().replace("Debug: false", "Debug: true"); + ProxyConfigurationFileService.Preview preview = service.preview(ProxyConfigurationFileService.FILE_NAME, proposal); + assertTrue(preview.resolvedContent().contains("Host: multi-redis.internal # multi-redis.internal")); + assertTrue(preview.resolvedContent().contains("Port: 6380")); + assertTrue(preview.resolvedContent().contains("Username: multi-user")); + assertTrue(preview.resolvedContent().contains("Password: multi-password")); + assertTrue(preview.resolvedContent().contains("Db-Index: 2")); + service.apply(ProxyConfigurationFileService.FILE_NAME, proposal, current.revision()); + assertTrue(Files.readString(file).contains("Host: multi-redis.internal # multi-redis.internal")); + } + + @Test + void rejectsRemovedReorderedOrIntroducedSequenceSecretMarkers() throws Exception { + Path file = write(""" + Hooks: + - Name: primary + Authorization: sequence-secret + - Name: secondary + Enabled: true + """); + ProxyConfigurationFileService service = service(file); + String proposal = service.read(ProxyConfigurationFileService.FILE_NAME).content(); + String removed = "Hooks:\n - Name: primary\n - Name: secondary\n Enabled: true\n"; + String reordered = "Hooks:\n - Name: secondary\n Enabled: true\n - Name: primary\n" + + " Authorization: " + ProxyConfigurationFileService.REDACTED + "\n"; + + assertThrows(IllegalArgumentException.class, + () -> service.preview(ProxyConfigurationFileService.FILE_NAME, removed)); + assertThrows(IllegalArgumentException.class, + () -> service.preview(ProxyConfigurationFileService.FILE_NAME, reordered)); + assertThrows(IllegalArgumentException.class, + () -> service.preview(ProxyConfigurationFileService.FILE_NAME, + proposal + "Unexpected:\n - " + ProxyConfigurationFileService.REDACTED + "\n")); + } + @Test void previewRestoresMaskedValuesAndAllowsSafeNestedAdditions() throws Exception { Path file = write(""" From 3b3c3735aa8072bfb51a1948ab558627daf68a53 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:48:04 -0600 Subject: [PATCH 3/7] Tighten Control inspection redaction bounds --- AGENTS.md | 2 +- .../control/ControlInspectionService.java | 15 +++-- .../control/ControlPlayerDataService.java | 3 +- .../ProxyConfigurationFileService.java | 12 +++- .../control/ControlInspectionServiceTest.java | 57 +++++++++++++++++++ .../ProxyConfigurationFileServiceTest.java | 48 +++++++++++++++- docs/control-agent-contract.md | 6 +- 7 files changed, 127 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b37897f4e..a7f56a822 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,7 +106,7 @@ The allow-listed kinds are `overview`, `vote-site-health`, `player`, `vote-log-s Maintain these global bounds unless a versioned contract deliberately replaces them: - result JSON: 512 KiB; -- general result rows: 100 (diagnostics may report up to 128 detected plugin names); +- general result rows: 100 (including detected plugin names in diagnostics); - top lists: 20; - lookback: 365 days; - exact player lookup only; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java index 52a0c902a..9d62c7993 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java @@ -44,7 +44,7 @@ public final class ControlInspectionService { public static final int SCHEMA_VERSION = 1; public static final int MAX_DATA_BYTES = 512 * 1024; - private static final int MAX_ROWS = 100; + static final int MAX_ROWS = 100; private static final int MAX_TOP_ROWS = 20; private static final Comparator SERVICE_HEALTH_ORDER = Comparator .comparingLong(ServiceHealth::lastVoteTime).reversed() @@ -453,14 +453,17 @@ private JsonObject diagnostics(JsonObject filters) { result.addProperty("profile", safe(plugin.getProfile(), 80)); result.addProperty("javaVersion", safe(System.getProperty("java.version", "unknown"), 80)); result.addProperty("backgroundTaskSeconds", plugin.getLastBackgroundTaskTimeTaken()); - JsonArray detected = new JsonArray(); Plugin[] plugins = plugin.getServer().getPluginManager().getPlugins(); - java.util.Arrays.stream(plugins).map(installed -> installed.getDescription().getName()) - .filter(name -> name != null && !name.isBlank()).distinct().sorted(String.CASE_INSENSITIVE_ORDER) - .limit(128).forEach(name -> detected.add(safe(name, 80))); + List detectedNames = java.util.Arrays.stream(plugins) + .map(installed -> installed.getDescription().getName()) + .filter(name -> name != null && !name.isBlank()).distinct() + .sorted(String.CASE_INSENSITIVE_ORDER.thenComparing(Comparator.naturalOrder())).toList(); + JsonArray detected = new JsonArray(); + detectedNames.stream().limit(MAX_ROWS).forEach(name -> detected.add(safe(name, 80))); result.add("detectedPlugins", detected); + result.addProperty("detectedPluginsTruncated", detectedNames.size() > MAX_ROWS); JsonArray redacted = new JsonArray(); - List.of("credentials", "database hosts and credentials", "Redis/MQTT hosts and credentials", + List.of("credentials", "database and transport hosts and credentials", "Control endpoints and paths", "webhook URLs", "raw configuration", "raw logs", "player records") .forEach(redacted::add); result.add("omittedSensitiveData", redacted); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlPlayerDataService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlPlayerDataService.java index fabcda876..5e615d4de 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlPlayerDataService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlPlayerDataService.java @@ -18,7 +18,6 @@ final class ControlPlayerDataService { private static final int MAX_CONTENT_BYTES = 512 * 1024; private static final int MAX_VALUE_BYTES = 16 * 1024; - private static final int MAX_COLUMNS = 128; private static final Set SAFE_STRING_COLUMNS = Set.of("UUID", "PlayerName", "LastOnline", "DayVoteStreakLastUpdate", "VoteRemindersLast"); private static final Set SAFE_BOOLEAN_COLUMNS = Set.of("TopVoterIgnore", "Reminded", "DisableBroadcast"); @@ -54,7 +53,7 @@ Document readLoaded(VotingPluginUser user) throws IOException { String name = entry.getKey(); DataValue value = entry.getValue(); if (name == null || value == null || !safeColumn(name, value)) continue; - if (listed.size() >= MAX_COLUMNS) { + if (listed.size() >= ControlInspectionService.MAX_ROWS) { truncated = true; break; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyConfigurationFileService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyConfigurationFileService.java index 064a1907a..a232948a3 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyConfigurationFileService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyConfigurationFileService.java @@ -793,8 +793,16 @@ private static boolean secret(String path, String key, Object value) { "prefix", "dbindex") .contains(normalized); } - if (normalizedPath.startsWith("control.")) { - return normalized.endsWith("file") || normalized.endsWith("directory"); + if (infrastructurePath(normalizedPath, "mqtt")) { + return Set.of("clientid", "brokerurl", "username", "password", "prefix").contains(normalized); + } + if (infrastructurePath(normalizedPath, "multiproxysockethost") + || infrastructurePath(normalizedPath, "multiproxyservers")) { + return Set.of("host", "port").contains(normalized); + } + if (infrastructurePath(normalizedPath, "control")) { + return normalized.endsWith("file") || normalized.endsWith("directory") + || Set.of("endpoint", "host", "port").contains(normalized); } if (value instanceof String text) { String lowered = text.trim().toLowerCase(Locale.ROOT); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java index c8e7a3874..dd4b86c4e 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java @@ -276,6 +276,63 @@ class ControlInspectionServiceTest { assertFalse(result.toString().contains("must not leave the backend")); } + @Test void playerInspectionBoundsAllowListedStorageColumnsAtTheSharedRowLimit() { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + VotingPluginUser user = mock(VotingPluginUser.class, RETURNS_DEEP_STUBS); + when(plugin.getUserManager().userExist("ExactName")).thenReturn(true); + when(plugin.getVotingPluginUserManager().getVotingPluginUser("ExactName")).thenReturn(user); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(user.getUUID()).thenReturn("3b0c76c1-b7ef-4a2c-a565-b7bc662531f9"); + when(user.getPlayerName()).thenReturn("ExactName"); + when(user.getOfflineVotes()).thenReturn(new ArrayList<>()); + when(user.getLastVotes()).thenReturn(new HashMap<>()); + HashMap stored = new HashMap<>(); + for (int index = 0; index <= ControlInspectionService.MAX_ROWS; index++) { + stored.put(String.format("VoteShopLimit%03d", index), new DataValueInt(index)); + } + when(user.getUserData().getValues()).thenReturn(stored); + ControlInspectionService service = new ControlInspectionService(plugin); + + JsonObject result = service.inspect(JsonParser.parseString( + "{\"kind\":\"player\",\"filters\":{\"name\":\"ExactName\"}}") + .getAsJsonObject()).getAsJsonObject("result"); + + assertEquals(ControlInspectionService.MAX_ROWS, result.getAsJsonArray("columns").size()); + assertEquals("VoteShopLimit000", result.getAsJsonArray("columns").get(0).getAsJsonObject() + .get("name").getAsString()); + assertEquals("VoteShopLimit099", result.getAsJsonArray("columns").get(99).getAsJsonObject() + .get("name").getAsString()); + assertTrue(result.get("columnsTruncated").getAsBoolean()); + } + + @Test void diagnosticsBoundsAndReportsTruncatedPluginInventory() { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + org.bukkit.configuration.file.YamlConfiguration config = new org.bukkit.configuration.file.YamlConfiguration(); + org.bukkit.configuration.file.YamlConfiguration voteSites = new org.bukkit.configuration.file.YamlConfiguration(); + when(plugin.getConfigFile().getData()).thenReturn(config); + when(plugin.getConfigVoteSites().getData()).thenReturn(voteSites); + when(plugin.getVoteSiteManager().getVoteSites()).thenReturn(new ArrayList<>()); + when(plugin.getVoteLogMysqlTable()).thenReturn(null); + org.bukkit.plugin.Plugin[] installed = new org.bukkit.plugin.Plugin[ControlInspectionService.MAX_ROWS + 1]; + for (int index = 0; index < installed.length; index++) { + installed[index] = mock(org.bukkit.plugin.Plugin.class, RETURNS_DEEP_STUBS); + when(installed[index].getDescription().getName()).thenReturn(String.format("Plugin%03d", index)); + } + when(installed[99].getDescription().getName()).thenReturn("plugina"); + when(installed[100].getDescription().getName()).thenReturn("PluginA"); + when(plugin.getServer().getPluginManager().getPlugins()).thenReturn(installed); + ControlInspectionService service = new ControlInspectionService(plugin); + + JsonObject result = service.inspect(JsonParser.parseString( + "{\"kind\":\"diagnostics\",\"filters\":{}}") + .getAsJsonObject()).getAsJsonObject("result"); + + assertEquals(ControlInspectionService.MAX_ROWS, result.getAsJsonArray("detectedPlugins").size()); + assertEquals("Plugin000", result.getAsJsonArray("detectedPlugins").get(0).getAsString()); + assertEquals("PluginA", result.getAsJsonArray("detectedPlugins").get(99).getAsString()); + assertTrue(result.get("detectedPluginsTruncated").getAsBoolean()); + } + @Test void voteSiteHealthIncludesPersistedDetectedInboxWithoutVoteLogging() { VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); org.bukkit.configuration.file.YamlConfiguration voteSites = new org.bukkit.configuration.file.YamlConfiguration(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyConfigurationFileServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyConfigurationFileServiceTest.java index 4387d422d..4365cb0ea 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyConfigurationFileServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyConfigurationFileServiceTest.java @@ -31,12 +31,26 @@ void readMasksCredentialsJdbcDetailsAndControlPaths() throws Exception { Host: redis.internal Password: redis-secret MQTT: - BrokerURL: tcp://user:pass@broker.internal:1883 + ClientID: private-client + BrokerURL: ssl://broker.internal:8883 + Username: mqtt-user + Password: mqtt-password + Prefix: private-prefix Control: + Endpoint: http://control.internal:8080 CredentialFile: control/credential.txt Hosted: JarFile: control/control.jar DataDirectory: control/data + Host: control-host.internal + Port: 8081 + MultiProxySocketHost: + Host: socket.internal + Port: 1234 + MultiProxyServers: + second: + Host: second.internal + Port: 1235 BungeeMethod: PLUGINMESSAGING """); @@ -46,7 +60,15 @@ void readMasksCredentialsJdbcDetailsAndControlPaths() throws Exception { assertFalse(content.contains("voting")); assertFalse(content.contains("admin")); assertFalse(content.contains("secret")); - assertFalse(content.contains("user:pass")); + assertFalse(content.contains("private-client")); + assertFalse(content.contains("broker.internal")); + assertFalse(content.contains("mqtt-user")); + assertFalse(content.contains("mqtt-password")); + assertFalse(content.contains("private-prefix")); + assertFalse(content.contains("control.internal")); + assertFalse(content.contains("control-host.internal")); + assertFalse(content.contains("socket.internal")); + assertFalse(content.contains("second.internal")); assertFalse(content.contains("control/credential.txt")); assertFalse(content.contains("control/control.jar")); assertFalse(content.contains("control/data")); @@ -67,6 +89,15 @@ void masksAndRestoresSecretsNestedInSequences() throws Exception { Port: 6379 Password: nested-password SSL: true + - MQTT: + BrokerURL: ssl://sequence-broker.internal:8883 # sequence-broker.internal + Username: sequence-mqtt-user + Prefix: sequence-prefix + - Control: + Endpoint: http://sequence-control.internal:8080 # sequence-control.internal + Hosted: + Host: sequence-control-host.internal + Port: 8081 Endpoints: - jdbc:mysql://sequence-user:sequence-password@db.internal/votes # sequence-password Debug: false @@ -77,6 +108,11 @@ void masksAndRestoresSecretsNestedInSequences() throws Exception { assertFalse(current.content().contains("sequence-secret")); assertFalse(current.content().contains("redis.internal")); assertFalse(current.content().contains("nested-password")); + assertFalse(current.content().contains("sequence-broker.internal")); + assertFalse(current.content().contains("sequence-mqtt-user")); + assertFalse(current.content().contains("sequence-prefix")); + assertFalse(current.content().contains("sequence-control.internal")); + assertFalse(current.content().contains("sequence-control-host.internal")); assertFalse(current.content().contains("sequence-user")); assertFalse(current.content().contains("sequence-password")); assertTrue(current.content().contains("SSL: true")); @@ -87,6 +123,12 @@ void masksAndRestoresSecretsNestedInSequences() throws Exception { assertTrue(preview.resolvedContent().contains("Authorization: sequence-secret # sequence-secret")); assertTrue(preview.resolvedContent().contains("Host: redis.internal # redis.internal")); assertTrue(preview.resolvedContent().contains("Password: nested-password")); + assertTrue(preview.resolvedContent().contains("BrokerURL: ssl://sequence-broker.internal:8883 # sequence-broker.internal")); + assertTrue(preview.resolvedContent().contains("Username: sequence-mqtt-user")); + assertTrue(preview.resolvedContent().contains("Prefix: sequence-prefix")); + assertTrue(preview.resolvedContent().contains("Endpoint: http://sequence-control.internal:8080 # sequence-control.internal")); + assertTrue(preview.resolvedContent().contains("Host: sequence-control-host.internal")); + assertTrue(preview.resolvedContent().contains("Port: 8081")); assertTrue(preview.resolvedContent().contains("jdbc:mysql://sequence-user:sequence-password@db.internal/votes # sequence-password")); assertTrue(preview.resolvedContent().contains("SSL: true")); service.apply(ProxyConfigurationFileService.FILE_NAME, proposal, current.revision()); @@ -94,6 +136,8 @@ void masksAndRestoresSecretsNestedInSequences() throws Exception { assertTrue(applied.contains("Authorization: sequence-secret # sequence-secret")); assertTrue(applied.contains("Host: redis.internal # redis.internal")); assertTrue(applied.contains("Password: nested-password")); + assertTrue(applied.contains("BrokerURL: ssl://sequence-broker.internal:8883 # sequence-broker.internal")); + assertTrue(applied.contains("Endpoint: http://sequence-control.internal:8080 # sequence-control.internal")); assertTrue(applied.contains("jdbc:mysql://sequence-user:sequence-password@db.internal/votes # sequence-password")); assertTrue(applied.contains("Debug: true")); } diff --git a/docs/control-agent-contract.md b/docs/control-agent-contract.md index 127e7b5bc..3fe1cd5be 100644 --- a/docs/control-agent-contract.md +++ b/docs/control-agent-contract.md @@ -90,7 +90,7 @@ JSON object whose `schemaVersion` is the JSON integer `1` (not a string), `kind` `generatedAt` parses as an ISO-8601 instant, and `result` is a JSON object. Failures omit `data` and use `VALIDATION_ERROR`, `UNAVAILABLE`, `RESULT_TOO_LARGE`, or `INSPECTION_FAILED`. -Data is limited to 512 KiB. General rows are limited to 100, top lists to 20, diagnostics to 128 detected plugin names, +Data is limited to 512 KiB. General rows and diagnostics plugin inventories are limited to 100, top lists to 20, and lookback windows to 365 days. Summary top lists order vote counts descending, then names case-insensitively and by exact spelling; the database applies the same name tie-break before its limit. The connector performs inspections on a dedicated single-thread daemon executor, separate from presence and configuration work and never on the Bukkit primary @@ -119,7 +119,7 @@ encoded `proposal` may be larger, with a 64 KiB hard limit. | `vote-trace` | required canonical 36-character UUID `voteId`; optional string `days`/`limit` | Chronological VoteLog events sharing one correlation ID | | `vote-site-resolution` | required valid `serviceSite` (1–64 characters); optional string boolean `includeDisabled` | Dry-runs existing resolution and reports whether auto-create would be attempted; never calls the creating resolver | | `reward-simulation` | required `proposal`, a JSON object encoded as one filter string | Validates and normalizes typed actions, reports the plan, and never invokes `RewardBuilder` | -| `diagnostics` | none | Bounded redacted environment/configuration status, configured/readable VoteLog state, and detected plugin names | +| `diagnostics` | none | Bounded redacted environment/configuration status, configured/readable VoteLog state, and up to 100 detected plugin names with an explicit truncation indicator | Valid VoteLog `event` values are `VOTE_RECEIVED`, `VOTEMILESTONE`, `VOTE_STREAK_REWARD`, `TOP_VOTER_REWARD`, and `VOTESHOP_PURCHASE`. Search values are exact, not substrings. VoteLog summary/search/trace return `UNAVAILABLE` when the @@ -229,7 +229,7 @@ unloaded site keys are not returned. They are not log enumeration or an end-to-e ## Data and security invariants - Inspection results may contain only the typed fields documented above. Never echo a credential, password, token, - database/Redis/MQTT host, webhook URL, raw configuration, or raw server log. + database or transport host, Control endpoint, webhook URL, raw configuration, or raw server log. - Unexpected managed configuration read, preview, apply, and reload exceptions retain their action-specific result code but return fixed external text; their detailed cause is logged only on the backend and is never copied into a Control result. - An unexpected handler exception returns only generic `INSPECTION_FAILED` text. The backend log may identify its exception From c8d35fedbda1bb37d67a99e7216149e05aa7e767 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:06:17 -0600 Subject: [PATCH 4/7] Harden proxy YAML inspection --- .../ProxyConfigurationFileService.java | 43 +++++++++-- .../ProxyConfigurationFileServiceTest.java | 72 +++++++++++++++++++ 2 files changed, 110 insertions(+), 5 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyConfigurationFileService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyConfigurationFileService.java index a232948a3..6f3c0d48c 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyConfigurationFileService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyConfigurationFileService.java @@ -17,8 +17,10 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; +import java.util.Collections; import java.util.Comparator; import java.util.HexFormat; +import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; @@ -30,6 +32,8 @@ import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.comments.CommentLine; import org.yaml.snakeyaml.constructor.SafeConstructor; +import org.yaml.snakeyaml.error.YAMLException; +import org.yaml.snakeyaml.nodes.AnchorNode; import org.yaml.snakeyaml.nodes.MappingNode; import org.yaml.snakeyaml.nodes.Node; import org.yaml.snakeyaml.nodes.NodeTuple; @@ -177,10 +181,8 @@ private static String readStrict(Path path) throws IOException { @SuppressWarnings("unchecked") private static Map parse(String yaml) { ensureBounded(yaml); - if (yaml.matches("(?s).*(?:^|[\\s\\[{,])(?:[&*][A-Za-z0-9_-]+|<<\\s*:).*")) { - throw new IllegalArgumentException("proxy configuration aliases are not supported"); - } LoaderOptions loaderOptions = loaderOptions(); + rejectAliases(yaml, loaderOptions); SafeConstructor constructor = new SafeConstructor(loaderOptions); Object parsed; try { parsed = new Yaml(constructor).load(yaml); } @@ -194,6 +196,34 @@ private static Map parse(String yaml) { return result; } + private static void rejectAliases(String yaml, LoaderOptions loaderOptions) { + Node root; + try { + root = new Yaml(loaderOptions).compose(new StringReader(yaml)); + } catch (YAMLException failure) { + throw new IllegalArgumentException("proxy configuration YAML is invalid"); + } + rejectAliases(root, Collections.newSetFromMap(new IdentityHashMap<>())); + } + + private static void rejectAliases(Node node, Set visited) { + if (node == null || !visited.add(node)) return; + if (node instanceof AnchorNode || node.getAnchor() != null) { + throw new IllegalArgumentException("proxy configuration aliases are not supported"); + } + if (node instanceof MappingNode mapping) { + for (NodeTuple tuple : mapping.getValue()) { + if (Tag.MERGE.equals(tuple.getKeyNode().getTag())) { + throw new IllegalArgumentException("proxy configuration aliases are not supported"); + } + rejectAliases(tuple.getKeyNode(), visited); + rejectAliases(tuple.getValueNode(), visited); + } + } else if (node instanceof SequenceNode sequence) { + sequence.getValue().forEach(child -> rejectAliases(child, visited)); + } + } + private static Object normalize(Object value, int depth) { if (depth > 50) throw new IllegalArgumentException("proxy configuration is too deeply nested"); if (value == null || value instanceof String || value instanceof Boolean || value instanceof Number) return value; @@ -796,9 +826,12 @@ private static boolean secret(String path, String key, Object value) { if (infrastructurePath(normalizedPath, "mqtt")) { return Set.of("clientid", "brokerurl", "username", "password", "prefix").contains(normalized); } - if (infrastructurePath(normalizedPath, "multiproxysockethost") + if (infrastructurePath(normalizedPath, "bungeeserver") + || infrastructurePath(normalizedPath, "spigotservers") + || infrastructurePath(normalizedPath, "multiproxysockethost") || infrastructurePath(normalizedPath, "multiproxyservers")) { - return Set.of("host", "port").contains(normalized); + return !(value instanceof Map) && !(value instanceof List) + && Set.of("host", "port").contains(normalized); } if (infrastructurePath(normalizedPath, "control")) { return normalized.endsWith("file") || normalized.endsWith("directory") diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyConfigurationFileServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyConfigurationFileServiceTest.java index 4365cb0ea..06d067e42 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyConfigurationFileServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyConfigurationFileServiceTest.java @@ -142,6 +142,78 @@ void masksAndRestoresSecretsNestedInSequences() throws Exception { assertTrue(applied.contains("Debug: true")); } + @Test + void masksAndRestoresPrimarySocketsEndpointsAndComments() throws Exception { + Path file = write(""" + BungeeServer: + Host: proxy.internal # proxy.internal + Port: 1297 # listener port 1297 + SpigotServers: + Host: + Host: survival.internal # survival.internal + Port: 1298 # backend port 1298 + Enabled: true + BungeeMethod: SOCKETS + Debug: false + """); + ProxyConfigurationFileService service = service(file); + + ProxyConfigurationFileService.Document current = service.read(ProxyConfigurationFileService.FILE_NAME); + assertFalse(current.content().contains("proxy.internal")); + assertFalse(current.content().contains("survival.internal")); + assertFalse(current.content().contains("1297")); + assertFalse(current.content().contains("1298")); + assertTrue(current.content().contains("SpigotServers:\n Host:")); + assertTrue(current.content().contains("Enabled: true")); + + String proposal = current.content().replace("Debug: false", "Debug: true"); + ProxyConfigurationFileService.Preview preview = service.preview(ProxyConfigurationFileService.FILE_NAME, proposal); + assertTrue(preview.resolvedContent().contains("Host: proxy.internal # proxy.internal")); + assertTrue(preview.resolvedContent().contains("Port: 1297 # listener port 1297")); + assertTrue(preview.resolvedContent().contains("Host: survival.internal # survival.internal")); + assertTrue(preview.resolvedContent().contains("Port: 1298 # backend port 1298")); + service.apply(ProxyConfigurationFileService.FILE_NAME, proposal, current.revision()); + String applied = Files.readString(file); + assertTrue(applied.contains("Host: proxy.internal # proxy.internal")); + assertTrue(applied.contains("Host: survival.internal # survival.internal")); + assertTrue(applied.contains("Debug: true")); + } + + @Test + void aliasValidationUsesYamlSyntaxInsteadOfScalarOrCommentText() throws Exception { + Path file = write(""" + General: + Broadcast: "Hello &aPlayer and *literal" + Explanation: | + Keep << text, &literal, and *literal unchanged. + "<<": quoted-key + # use *name and &name in documentation + Debug: false + """); + ProxyConfigurationFileService service = service(file); + + ProxyConfigurationFileService.Document current = service.read(ProxyConfigurationFileService.FILE_NAME); + assertTrue(current.content().contains("Hello &aPlayer and *literal")); + assertTrue(current.content().contains("Keep << text, &literal, and *literal unchanged.")); + assertTrue(current.content().contains("# use *name and &name in documentation")); + assertTrue(current.content().contains("'<<': quoted-key") + || current.content().contains("\"<<\": quoted-key")); + String proposal = current.content().replace("Debug: false", "Debug: true"); + ProxyConfigurationFileService.Preview preview = service.preview(ProxyConfigurationFileService.FILE_NAME, proposal); + assertTrue(preview.resolvedContent().contains("Hello &aPlayer and *literal")); + service.apply(ProxyConfigurationFileService.FILE_NAME, proposal, current.revision()); + assertTrue(Files.readString(file).contains("Debug: true")); + + assertThrows(IllegalArgumentException.class, () -> service.preview(ProxyConfigurationFileService.FILE_NAME, + "Primary: &name value\nCopy: *name\n")); + assertThrows(IllegalArgumentException.class, () -> service.preview(ProxyConfigurationFileService.FILE_NAME, + "Primary: &values\n - one\nCopy: *values\n")); + assertThrows(IllegalArgumentException.class, () -> service.preview(ProxyConfigurationFileService.FILE_NAME, + "Primary: &unused value\nDebug: false\n")); + assertThrows(IllegalArgumentException.class, () -> service.preview(ProxyConfigurationFileService.FILE_NAME, + "General:\n <<: {Debug: true}\n")); + } + @Test void masksAndRestoresMultiProxyRedisInfrastructure() throws Exception { Path file = write(""" From ae9e6c2e904aaaefc39a6ac25109b2cdc0edd8f7 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:19:56 -0600 Subject: [PATCH 5/7] Harden Control preview and player history --- .../control/ControlPlayerDataService.java | 2 +- .../proxy/control/ControlConnector.java | 6 ++- .../control/ControlInspectionServiceTest.java | 49 ++++++++++++++++++- .../proxy/control/ControlConnectorTest.java | 40 +++++++++++++++ 4 files changed, 93 insertions(+), 4 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlPlayerDataService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlPlayerDataService.java index 5e615d4de..94d995340 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlPlayerDataService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlPlayerDataService.java @@ -26,7 +26,7 @@ final class ControlPlayerDataService { "BestWeekVoteStreak", "MonthVoteStreak", "BestMonthVoteStreak", "HighestDailyTotal", "HighestMonthlyTotal", "HighestWeeklyTotal", "LastMonthTotal", "LastWeeklyTotal", "LastDailyTotal"); private static final Pattern SAFE_DYNAMIC_INTEGER_COLUMN = Pattern.compile( - "(?:MonthTotal_[0-9]{4}_[0-9]{1,2}|VoteShopLimit[A-Za-z0-9_-]{1,64})"); + "(?:MonthTotal-(?:JANUARY|FEBRUARY|MARCH|APRIL|MAY|JUNE|JULY|AUGUST|SEPTEMBER|OCTOBER|NOVEMBER|DECEMBER)-[0-9]{4}|VoteShopLimit[A-Za-z0-9_-]{1,64})"); private final VotingPluginMain plugin; ControlPlayerDataService(VotingPluginMain plugin) { 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 index 1b3ac32a5..8382bf97f 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java @@ -824,7 +824,11 @@ private CompletableFuture executeProxyFile(UUID operationId, JsonObj String content = requireString(requested, "content"); ProxyConfigurationFileService.Preview preview = fileConfigurationService.preview(fileName, content); if ("PREVIEW".equals(type)) { - return completed(TaskResult.file(fileConfigurationService.read(fileName), preview.changes(), false, false)); + ProxyConfigurationFileService.Document current = fileConfigurationService.read(fileName); + if (!preview.revision().equals(current.revision())) { + throw new ProxyConfigurationFileService.StaleRevisionException(); + } + return completed(TaskResult.file(current, preview.changes(), false, false)); } if (!"APPLY".equals(type)) return completed(TaskResult.failure("UNSUPPORTED_TASK", "Task type is unsupported")); persistIntent(operationId, TaskResult.fileIntent(fileName, diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java index dd4b86c4e..f6ec87d74 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java @@ -250,6 +250,8 @@ class ControlInspectionServiceTest { when(user.getLastVotes()).thenReturn(new HashMap<>()); HashMap stored = new HashMap<>(); stored.put("Points", new DataValueInt(42)); + stored.put("MonthTotal-JANUARY-2025", new DataValueInt(11)); + stored.put("MonthTotal-DECEMBER-2026", new DataValueInt(12)); stored.put("VoteShopLimitKeys", new DataValueInt(3)); stored.put("TopVoterIgnore", new DataValueString("true")); stored.put("VoteShopLimitInjected", new DataValueString("must not leave through a dynamic field")); @@ -257,6 +259,12 @@ class ControlInspectionServiceTest { stored.put("Reminded", new DataValueString("must not leave through a boolean field")); stored.put("OfflineVotes", new DataValueString("private serialized vote payload")); stored.put("FuturePluginSecret", new DataValueString("must not leave the backend")); + stored.put("MonthTotal_2025_1", new DataValueInt(91)); + stored.put("MonthTotal-JANUARY-25", new DataValueInt(92)); + stored.put("MonthTotal-JANUARY-2025-extra", new DataValueInt(93)); + stored.put("MonthTotal-january-2025", new DataValueInt(94)); + stored.put("MonthTotal-SMARCH-2025", new DataValueInt(95)); + stored.put("MonthTotal-FEBRUARY-2025", new DataValueString("wrong type")); when(user.getUserData().getValues()).thenReturn(stored); ControlInspectionService service = new ControlInspectionService(plugin); @@ -265,15 +273,52 @@ class ControlInspectionServiceTest { .getAsJsonObject()).getAsJsonObject("result"); assertTrue(result.get("storageRowAvailable").getAsBoolean()); assertEquals("SQLITE", result.get("storage").getAsString()); - assertEquals(List.of("Points", "TopVoterIgnore", "VoteShopLimitKeys"), result.getAsJsonArray("columns").asList().stream() + assertEquals(List.of("MonthTotal-DECEMBER-2026", "MonthTotal-JANUARY-2025", "Points", "TopVoterIgnore", "VoteShopLimitKeys"), result.getAsJsonArray("columns").asList().stream() .map(value -> value.getAsJsonObject().get("name").getAsString()).toList()); - assertEquals(List.of("42", "true", "3"), result.getAsJsonArray("columns").asList().stream() + assertEquals(List.of("12", "11", "42", "true", "3"), result.getAsJsonArray("columns").asList().stream() .map(value -> value.getAsJsonObject().get("value").getAsString()).toList()); assertFalse(result.toString().contains("private serialized vote payload")); assertFalse(result.toString().contains("must not leave through a dynamic field")); assertFalse(result.toString().contains("must not leave through an integer field")); assertFalse(result.toString().contains("must not leave through a boolean field")); assertFalse(result.toString().contains("must not leave the backend")); + assertFalse(result.toString().contains("MonthTotal_2025_1")); + assertFalse(result.toString().contains("MonthTotal-JANUARY-25")); + assertFalse(result.toString().contains("MonthTotal-JANUARY-2025-extra")); + assertFalse(result.toString().contains("MonthTotal-january-2025")); + assertFalse(result.toString().contains("MonthTotal-SMARCH-2025")); + assertFalse(result.toString().contains("MonthTotal-FEBRUARY-2025")); + } + + @Test void playerInspectionBoundsHistoricalMonthTotalsDeterministically() { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + VotingPluginUser user = mock(VotingPluginUser.class, RETURNS_DEEP_STUBS); + when(plugin.getUserManager().userExist("ExactName")).thenReturn(true); + when(plugin.getVotingPluginUserManager().getVotingPluginUser("ExactName")).thenReturn(user); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(user.getUUID()).thenReturn("3b0c76c1-b7ef-4a2c-a565-b7bc662531f9"); + when(user.getPlayerName()).thenReturn("ExactName"); + when(user.getOfflineVotes()).thenReturn(new ArrayList<>()); + when(user.getLastVotes()).thenReturn(new HashMap<>()); + HashMap stored = new HashMap<>(); + List expected = new ArrayList<>(); + for (int index = 0; index <= ControlInspectionService.MAX_ROWS; index++) { + String name = "MonthTotal-" + java.time.Month.of(index % 12 + 1).name() + "-" + (2000 + index / 12); + stored.put(name, new DataValueInt(index)); + expected.add(name); + } + expected.sort(String.CASE_INSENSITIVE_ORDER.thenComparing(java.util.Comparator.naturalOrder())); + when(user.getUserData().getValues()).thenReturn(stored); + ControlInspectionService service = new ControlInspectionService(plugin); + + JsonObject result = service.inspect(JsonParser.parseString( + "{\"kind\":\"player\",\"filters\":{\"name\":\"ExactName\"}}") + .getAsJsonObject()).getAsJsonObject("result"); + + assertEquals(expected.subList(0, ControlInspectionService.MAX_ROWS), + result.getAsJsonArray("columns").asList().stream() + .map(value -> value.getAsJsonObject().get("name").getAsString()).toList()); + assertTrue(result.get("columnsTruncated").getAsBoolean()); } @Test void playerInspectionBoundsAllowListedStorageColumnsAtTheSharedRowLimit() { 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 index be4f31219..8cc44d4e0 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java @@ -35,6 +35,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; class ControlConnectorTest { @TempDir Path dataDirectory; @@ -316,6 +317,45 @@ class ControlConnectorTest { assertFalse(transport.requests.stream().map(Request::body).anyMatch(body -> body.contains("local-secret"))); } + @Test void proxyFilePreviewRejectsARevisionThatChangesAfterCalculation() throws Exception { + Path file = dataDirectory.resolve(ProxyConfigurationFileService.FILE_NAME); + Files.writeString(file, "Debug: false\n"); + ProxyConfigurationFileService actual = new ProxyConfigurationFileService(file, + ControlConnectorTest::atomicMove); + ProxyConfigurationFileService service = mock(ProxyConfigurationFileService.class); + AtomicBoolean mutated = new AtomicBoolean(); + when(service.preview(ProxyConfigurationFileService.FILE_NAME, "Debug: true\n")).thenAnswer(invocation -> { + ProxyConfigurationFileService.Preview preview = actual.preview( + invocation.getArgument(0), invocation.getArgument(1)); + if (!mutated.compareAndSet(false, true)) return preview; + try { + Files.writeString(file, "Debug: changed-locally\n"); + } catch (java.io.IOException failure) { + throw new AssertionError(failure); + } + return preview; + }); + when(service.read(ProxyConfigurationFileService.FILE_NAME)).thenAnswer(invocation -> + actual.read(invocation.getArgument(0))); + connector.close(); + connector = fileConnector(service); + transport.acceptProxyFiles = true; + transport.operationClaim = CompletableFuture.completedFuture(new Response(200, + "{\"operationId\":\"00000000-0000-0000-0000-000000000099\"," + + "\"attemptId\":\"00000000-0000-0000-0000-000000000199\"," + + "\"type\":\"PREVIEW\",\"configuration\":{\"domain\":\"file\"," + + "\"fileName\":\"bungeeconfig.yml\",\"content\":\"Debug: true\\n\"}}")); + + connector.cycle(); + + JsonObject result = submittedResult(); + assertTrue(mutated.get()); + assertFalse(result.get("success").getAsBoolean()); + assertEquals("STALE_REVISION", result.get("code").getAsString()); + assertFalse(result.has("configuration")); + assertEquals("Debug: changed-locally\n", Files.readString(file)); + } + @Test void proxyFileTaskIsRejectedWhenOnlyRoutingControlWasNegotiated() throws Exception { Path file = dataDirectory.resolve(ProxyConfigurationFileService.FILE_NAME); Files.writeString(file, "Debug: false\n"); From 4b023c6eac57f2d813aedf4d9eb035bcc77c5cd4 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:31:51 -0600 Subject: [PATCH 6/7] Keep VoteLog summary counts compatible --- .../votingplugin/control/ControlInspectionService.java | 2 ++ .../control/ControlInspectionServiceTest.java | 8 ++++++++ docs/control-agent-contract.md | 2 +- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java index 9d62c7993..feea9ddf1 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java @@ -315,6 +315,7 @@ private JsonObject voteLogSummary(JsonObject filters) { .forEach(count -> { JsonObject row = new JsonObject(); row.addProperty("service", safe(count.service, 64)); + row.addProperty("count", count.votes); row.addProperty("votes", count.votes); services.add(row); }); @@ -326,6 +327,7 @@ private JsonObject voteLogSummary(JsonObject filters) { .forEach(count -> { JsonObject row = new JsonObject(); row.addProperty("server", safe(count.server, 64)); + row.addProperty("count", count.votes); row.addProperty("votes", count.votes); servers.add(row); }); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java index f6ec87d74..7de3ceb0e 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java @@ -192,8 +192,16 @@ class ControlInspectionServiceTest { assertEquals(List.of("Middle", "alpha", "Zulu"), result.getAsJsonArray("topServices").asList().stream() .map(row -> row.getAsJsonObject().get("service").getAsString()).toList()); + assertEquals(List.of(6L, 3L, 3L), result.getAsJsonArray("topServices").asList().stream() + .map(row -> row.getAsJsonObject().get("count").getAsLong()).toList()); + assertEquals(List.of(6L, 3L, 3L), result.getAsJsonArray("topServices").asList().stream() + .map(row -> row.getAsJsonObject().get("votes").getAsLong()).toList()); assertEquals(List.of("hub", "Creative", "survival"), result.getAsJsonArray("topServers").asList().stream() .map(row -> row.getAsJsonObject().get("server").getAsString()).toList()); + assertEquals(List.of(8L, 2L, 2L), result.getAsJsonArray("topServers").asList().stream() + .map(row -> row.getAsJsonObject().get("count").getAsLong()).toList()); + assertEquals(List.of(8L, 2L, 2L), result.getAsJsonArray("topServers").asList().stream() + .map(row -> row.getAsJsonObject().get("votes").getAsLong()).toList()); } @Test void exactPlayerMissDoesNotLoadOrEnumerateUsers() { diff --git a/docs/control-agent-contract.md b/docs/control-agent-contract.md index 3fe1cd5be..6bd95e648 100644 --- a/docs/control-agent-contract.md +++ b/docs/control-agent-contract.md @@ -114,7 +114,7 @@ encoded `proposal` may be larger, with a 64 KiB hard limit. | `overview` | none | Versions, configuration health, bounded data-storage mode, proxy mode, vote-site counts, and configured/readable VoteLog state | | `vote-site-health` | string `days` (1–365, default 30) | Configured site status, bounded aggregate last-vote/count data, unmatched logged services, and persisted unconfigured service observations | | `player` | exactly one of `name` (1–16 characters) or `uuid` (canonical 36-character UUID) | Exact existing-player lookup; totals, points, streaks, up to 100 per-site last-vote rows, and pending vote count saturated at 100,000; never lists players | -| `vote-log-summary` | string `days` (1–365, default 30) | Vote totals, immediate/cached split, unique voters, top services, and top servers | +| `vote-log-summary` | string `days` (1–365, default 30) | Vote totals, immediate/cached split, unique voters, and bounded top-service (`service`, canonical `count`) and top-server (`server`, canonical `count`) rows; schema-v1 nodes also emit the equal legacy `votes` alias for staggered dashboard upgrades | | `vote-log-search` | at most one of exact `player` (1–16 characters), `service` (1–64), or `server` (1–64); optional `event` and string `days`/`limit` | Bounded recent event rows; `limit` is 1–100 and defaults to 25 | | `vote-trace` | required canonical 36-character UUID `voteId`; optional string `days`/`limit` | Chronological VoteLog events sharing one correlation ID | | `vote-site-resolution` | required valid `serviceSite` (1–64 characters); optional string boolean `includeDisabled` | Dry-runs existing resolution and reports whether auto-create would be attempted; never calls the creating resolver | From a1217a572eb4d780957c071ba8bfabc05da6bd53 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:53:32 -0600 Subject: [PATCH 7/7] Document Control proxy and player contracts --- docs/control-agent-contract.md | 96 ++++++++++++++++++++++++++++++++++ docs/control-connector.md | 20 +++++++ 2 files changed, 116 insertions(+) diff --git a/docs/control-agent-contract.md b/docs/control-agent-contract.md index 6bd95e648..8375e58f1 100644 --- a/docs/control-agent-contract.md +++ b/docs/control-agent-contract.md @@ -10,6 +10,67 @@ has two separate lanes: Do not translate an inspection request into a configuration operation. Do not add raw SQL, arbitrary commands, player enumeration, database browsing, filesystem paths, or generic key/value reads to either contract. +## Proxy file contract (`config.proxy-files.v1`) + +This is a proxy-only capability, advertised by an enrolled BungeeCord or Velocity node. It is separate from +`config.proxy-routing.v1`, `config.proxy-method.v1`, and the backend `config.files.v1` capability. The only managed file +is the proxy's top-level `bungeeconfig.yml` in the VotingPlugin data folder. `fileName` must be exactly +`bungeeconfig.yml`; paths, subdirectories, and other filenames are rejected. The file and proposed UTF-8 content are +bounded at 512 KiB. YAML must be a mapping, use string keys and supported scalar/map/list values, and may not contain +aliases/merge keys, duplicate keys, invalid UTF-8, NULs, or nesting deeper than 50. + +The operation is carried in the normal authenticated node operation queue. Control claims an operation with +`POST /api/v1/nodes/{nodeId}/operations` and `{"sessionId":""}`. A claimed task has +`operationId`, `attemptId`, `type`, and, for APPLY, `expectedRevision`, plus this configuration object: + +```json +{ + "domain": "file", + "fileName": "bungeeconfig.yml", + "content": "...masked YAML..." +} +``` + +`content` is omitted for READ. The node submits the result through the normal operation-result endpoint; a result has +`success`, `code`, `message`, `revision` (on success), `configuration` (on success), `changes`, `reloaded`, and +`rolledBack`, and includes the claimed `attemptId`. A successful configuration object contains `domain`, `fileName`, +and masked `content`. `changes` is a deterministic, lexicographically ordered list of at most 20 flattened YAML paths, +using `added`, `changed`, or `removed` prefixes. The complete operation-result request and connector HTTP response retain +the shared 4 MiB protocol bound; the stricter 512 KiB limit applies to the managed YAML content itself. + +The node posts that result to `POST /api/v1/nodes/{nodeId}/operations/{operationId}/result` with the same +`sessionId`; there is no second proxy-file-specific envelope. `attemptId` is retained in the result so Control can +match the leased attempt. Control's HTTP success response acknowledges receipt; a `409` `TASK_LEASE_EXPIRED` leaves +the result journaled for recovery/retry, while `404` `OPERATION_NOT_FOUND` is treated as an acknowledgement because +the Control-side operation is already gone. Malformed or non-success transport responses affect only this connector +queue and are retried with its normal bounded backoff. + +READ returns the current masked document and its SHA-256 revision. PREVIEW parses and validates the proposal, resolves +unchanged redacted secret markers against the local document, returns the current revision and changes, and does not +write. APPLY requires the exact revision returned by the preview (and checks it again around staging/installation), +writes through a staged file and atomic activation, and retains `bungeeconfig.yml.control-backup`. A stale or changed +revision returns `success:false`, `code:"STALE_REVISION"`, with no configuration payload. Invalid file names, YAML, +content, or redaction use `VALIDATION_ERROR`; unavailable/read or save failures use the fixed `APPLY_FAILED` result; +an installation failure returns `APPLY_FAILED` and reports whether rollback succeeded in `rolledBack`. + +Proxy-file APPLY does not reload the proxy in this connector (`reloaded:false`); the success message instructs an +operator to restart the proxy for general settings to take effect. It is not the `proxy-method` runtime-replacement +operation. A failed installation attempts to restore the backup atomically. The result is journaled by operation ID +until Control acknowledges it, so a leased retry does not apply the change twice; on node recovery an unfinished APPLY +is either recognized as already installed by revision or reported as `RECOVERY_ABORTED`. + +Control must authenticate as the enrolled node and must include `config.proxy-files.v1` in `acceptedCapabilities` +before assigning these tasks. If the capability was not negotiated, the node returns `UNSUPPORTED` without reading or +writing the file. Proxy and backend nodes may be enrolled against the same Control instance, but each node has its own +identity, credential/session, operation lease, revision, and result journal: a backend capability does not authorize a +proxy-file task, and one peer's approval or revision cannot be used for another peer. Control should therefore present +this editor only for a capable proxy node and require its normal authenticated admin preview/approval flow. + +All reads mask secrets. The mask covers password/secret/token/API-key/authorization/webhook URL fields and selected +database, Redis, MQTT, proxy-host, and Control infrastructure fields; JDBC-style and credential-bearing URLs are also +masked. A submitted redaction marker preserves the local secret; replacement secrets may be submitted in an authenticated +operation but are never returned or journaled. + ## Easy automatic vote-site toggle The `auto-create-vote-sites` quick-setup preset owns exactly one setting: @@ -226,6 +287,41 @@ An exact player result includes at most 100 `lastVotes` rows with `siteKey`, `di unloaded site keys are not returned. They are not log enumeration or an end-to-end delivery history. `pendingOfflineVotes` is a bounded count saturated at 100,000 rather than a detailed queue view. +### Exact-player storage fields (schema version 1) + +The `player` result additionally contains `storageRowAvailable`, `storage`, `columns`, and `columnsTruncated`. +`storageRowAvailable` is true only when the exact loaded player has user data and a configured storage type that can be +read. When false, `columns` is an empty array, `columnsTruncated` is false, and `storage` is omitted. `storage` is the +storage enum name (for example `SQLITE`), not a connection or table description. + +Each `columns` entry is exactly `{ "name": , "type": , "value": }`. `value` is a bounded +string rendering: integer values are decimal text, booleans are `true`/`false` text, and string values are returned as +text (a null string renders as empty text). `type` is exactly the underlying stored `DataValue.getType().name()`; +the allow-list accepts only the corresponding string, boolean, or integer value type described below. Only these fields +are eligible: + +- Static string names (must have a string value): `UUID`, `PlayerName`, `LastOnline`, `DayVoteStreakLastUpdate`, + `VoteRemindersLast`. +- Static boolean names (native boolean, or a string exactly matching `true`/`false`, case-insensitively): `TopVoterIgnore`, + `Reminded`, `DisableBroadcast`. +- Static integer names (integer value): `VotePartyVotes`, `MonthTotal`, `AllTimeTotal`, `DailyTotal`, `WeeklyTotal`, + `Points`, `DayVoteStreak`, `BestDayVoteStreak`, `WeekVoteStreak`, `BestWeekVoteStreak`, `MonthVoteStreak`, + `BestMonthVoteStreak`, `HighestDailyTotal`, `HighestMonthlyTotal`, `HighestWeeklyTotal`, `LastMonthTotal`, + `LastWeeklyTotal`, `LastDailyTotal`. +- Dynamic integer names: `MonthTotal--` where `` is an uppercase English month name and `` is + exactly four digits; and `VoteShopLimit` where `` is 1–64 characters from `[A-Za-z0-9_-]`. +- Four runtime-derived exact names: the configured cooldown flag (`CoolDownCheck` or `CoolDownCheck_`, + boolean), cooldown-site list (`CoolDownCheck_Sites` or `CoolDownCheck__Sites`, string), + all-sites day (`AllSitesLast` or `AllSitesLast_`, integer), and almost-all-sites day + (`AlmostAllSitesLast` or `AlmostAllSitesLast_`, integer). The runtime names are compared exactly; + they are not wildcards. + +Other stored keys—including serialized offline/reward payloads, plugin-specific keys, malformed dynamic spellings, and +allow-listed names with the wrong value type—are omitted. Entries are sorted by `name` case-insensitively, then by exact +spelling, and at most 100 eligible entries are returned. A 101st eligible entry or a value larger than 16 KiB sets +`columnsTruncated:true`; oversized values themselves are omitted. The complete result is bounded at 512 KiB. No +credential, secret, raw payload, SQL metadata, or arbitrary storage key is exposed. + ## Data and security invariants - Inspection results may contain only the typed fields documented above. Never echo a credential, password, token, diff --git a/docs/control-connector.md b/docs/control-connector.md index e523fd22f..5520a9196 100644 --- a/docs/control-connector.md +++ b/docs/control-connector.md @@ -231,6 +231,20 @@ Control configuration snapshots store the redacted managed-file content returned Restore resolves unchanged markers against each target's current secrets during preview/apply. Protect Control's data directory anyway because snapshots contain complete managed configuration structure and operational values. +Proxy nodes separately advertise `config.proxy-files.v1` when their proxy-file adapter is available. This capability is +restricted to exactly the proxy's top-level `bungeeconfig.yml` (no paths or alternate filenames) and to a 512 KiB +UTF-8/YAML content bound inside the shared 4 MiB operation-response envelope. Control uses the normal authenticated +operation queue. READ returns masked content and its SHA-256 revision; PREVIEW validates a proposal and reports +deterministic changes without writing; APPLY requires the +preview's exact revision and rejects a changed file with `STALE_REVISION`. APPLY stages and atomically installs the file, +retains `bungeeconfig.yml.control-backup`, and reports `reloaded:false`: a successful edit requires a proxy restart to +activate general settings. Redaction markers preserve local secrets, and replacement secrets are never returned or +journaled. Results include the normal success/code/message/revision/configuration/changes/reloaded/rolledBack fields, +remain durable by operation ID until acknowledged, and survive leased retries/recovery. Control must show this editor +only to an authenticated, capable proxy node; backend `config.files.v1` enrollment, approval, and revisions are not +interchangeable with a proxy peer. See [the Control agent contract](control-agent-contract.md#proxy-file-contract-configproxy-filesv1) +for the exact task/result envelope and error behavior. + 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, a dedicated `auto-create-vote-sites` switch that changes only `Config.yml` → `AutoCreateVoteSites`, a non-secret `vote-logging` setup, @@ -262,6 +276,12 @@ lookbacks at 365 days. It does not expose SQL, arbitrary user enumeration, raw c commands, reward execution, or writes. The exact schemas and safety invariants are documented in [the Control agent contract](control-agent-contract.md). +Exact-player results may include the schema-v1 storage fields `storageRowAvailable`, `storage`, `columns`, and +`columnsTruncated`. Storage output is read-only and allow-listed, with at most 100 deterministically ordered column +entries and per-value/content bounds; unavailable storage returns an empty column list and no storage name. It excludes +serialized payloads, credentials, secrets, and arbitrary keys. See [the exact-player storage schema](control-agent-contract.md#exact-player-storage-fields-schema-version-1) +for the static/dynamic names and value-type rules. + Inspection filters are string values on the wire and are parsed by the selected kind's strict schema. The connector runs the handlers, including bounded VoteLog/player storage reads, on the dedicated inspection daemon rather than Bukkit's primary thread or the configuration executor. VoteLog statements use a 10-second JDBC timeout. Reward and vote-site