diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 08e3afff8f..52e3ec594a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -80,6 +80,7 @@ import com.bencodez.votingplugin.config.SpecialRewardsConfig; import com.bencodez.votingplugin.config.VotingPluginConfigHealth; import com.bencodez.votingplugin.cooldown.CoolDownCheck; +import com.bencodez.votingplugin.control.BackendControlAutoEnrollment; import com.bencodez.votingplugin.control.BackendControlConnector; import com.bencodez.votingplugin.data.ServerData; import com.bencodez.votingplugin.discord.DiscordHandler; @@ -96,6 +97,7 @@ import com.bencodez.votingplugin.placeholders.VotingPluginExpansion; import com.bencodez.votingplugin.presets.VoteSitePresetSetupHandler; import com.bencodez.votingplugin.proxy.control.HostedControlManager; +import com.bencodez.votingplugin.util.ControlCredentialFile.PendingAutoEnrollment; import com.bencodez.votingplugin.rewards.VotingPluginRewardRegistrar; import com.bencodez.votingplugin.servicesites.ServiceSiteHandler; import com.bencodez.votingplugin.signs.Signs; @@ -163,6 +165,7 @@ public class VotingPluginMain extends AdvancedCorePlugin { private final ProcessedVoteCache backendProcessedVoteCache = new ProcessedVoteCache(); private final AtomicReference backendPluginMessageTarget = new AtomicReference<>(); private PluginMessageHandler backendPluginMessageRelay; + private volatile BackendControlAutoEnrollment backendControlAutoEnrollment; private volatile BackendControlConnector backendControlConnector; private final ScheduledExecutorService backendControlConnectorLifecycle = Executors.newSingleThreadScheduledExecutor(runnable -> { Thread thread = new Thread(runnable, "votingplugin-control-backend-connector-lifecycle"); @@ -785,12 +788,11 @@ private void startBackendHostedControl() { backendHostedControlActiveConfiguration = configuration; if (!configuration.enabled()) return; try { - backendHostedControlManager = HostedControlManager.create(getDataFolder().toPath(), configuration, - message -> getLogger().info("[Control Host] " + message)); + backendHostedControlManager = createBackendHostedControlManager(configuration); backendHostedControlActiveManager = backendHostedControlManager; if (backendHostedControlActiveManager != null) backendHostedControlManagers.add(backendHostedControlActiveManager); if (backendHostedControlActiveManager != null) backendHostedControlActiveManager.start(); - } catch (IllegalArgumentException e) { + } catch (IOException | IllegalArgumentException e) { backendHostedControlManager = null; backendHostedControlActiveManager = null; getLogger().warning("[Control Host] Bukkit hosting configuration is invalid; VotingPlugin remains active: " @@ -798,6 +800,19 @@ private void startBackendHostedControl() { } } + private HostedControlManager createBackendHostedControlManager( + HostedControlManager.HostConfiguration configuration) throws IOException { + PendingAutoEnrollment enrollment = null; + try { + enrollment = BackendControlAutoEnrollment.prepareLocal(this, configuration); + } catch (IOException | IllegalArgumentException enrollmentFailure) { + getLogger().warning( + "[Control] Automatic local credential enrollment was skipped because its connector settings are invalid"); + } + return HostedControlManager.create(getDataFolder().toPath(), configuration, enrollment, + message -> getLogger().info("[Control Host] " + message)); + } + private HostedControlManager.HostConfiguration readBackendHostedControlConfiguration() { ConfigurationSection hosted = getConfigFile().getData().getConfigurationSection("Control.Hosted"); if (hosted == null) { @@ -828,9 +843,8 @@ private synchronized void restartBackendHostedControlIfChanged() { } HostedControlManager replacement; try { - replacement = HostedControlManager.create(getDataFolder().toPath(), replacementConfiguration, - message -> getLogger().info("[Control Host] " + message)); - } catch (IllegalArgumentException e) { + replacement = createBackendHostedControlManager(replacementConfiguration); + } catch (IOException | IllegalArgumentException e) { getLogger().warning("[Control Host] Applied Bukkit hosting settings are invalid; the current host is unchanged: " + e.getMessage()); return; @@ -932,6 +946,7 @@ private void replaceBackendHostedControl(HostedControlManager replacement, Runnable publication = () -> { backendHostedControlActiveManager = replacement; backendHostedControlActiveConfiguration = replacementConfiguration; + restartBackendControlConnector(); }; BackendControlConnector connector = backendControlConnector; if (connector == null) publication.run(); @@ -961,8 +976,7 @@ private void restoreBackendHostedControl(HostedControlManager.HostConfiguration } HostedControlManager restored = null; try { - restored = HostedControlManager.create(getDataFolder().toPath(), previousConfiguration, - message -> getLogger().info("[Control Host] " + message)); + restored = createBackendHostedControlManager(previousConfiguration); if (restored != null) backendHostedControlManagers.add(restored); if (restored != null && !restored.startAndWaitForInitialResult()) { closeBackendHostedControlManager(restored, true); @@ -1033,6 +1047,8 @@ private void stopBackendHostedControlLifecycle() { private void startBackendControlConnector() { try { + refreshBackendControlAutoEnrollment(); + if (backendControlAutoEnrollment != null && backendControlAutoEnrollment.isAwaitingCredential()) return; backendControlConnector = BackendControlConnector.create(this); if (backendControlConnector != null) backendControlConnector.start(); } catch (Exception e) { @@ -1041,6 +1057,31 @@ private void startBackendControlConnector() { } } + private synchronized void refreshBackendControlAutoEnrollment() throws IOException { + BackendControlAutoEnrollment previous = backendControlAutoEnrollment; + backendControlAutoEnrollment = null; + if (previous != null) previous.close(); + BackendControlAutoEnrollment replacement = BackendControlAutoEnrollment.create(this, + getActiveBackendHostedControlConfigurationSnapshot()); + backendControlAutoEnrollment = replacement; + if (replacement != null) replacement.start(); + } + + private synchronized void clearBackendControlAutoEnrollment() { + BackendControlAutoEnrollment previous = backendControlAutoEnrollment; + backendControlAutoEnrollment = null; + if (previous != null) previous.close(); + } + + /** True while a newly configured local host must be published before its connector is created. */ + private boolean backendControlAwaitsHostedPublication() { + HostedControlManager.HostConfiguration configured = readBackendHostedControlConfiguration(); + if (configured.equals(backendHostedControlActiveConfiguration)) return false; + ConfigurationSection control = getConfigFile().getData().getConfigurationSection("Control.Backend"); + return control != null && control.getBoolean("Enabled", false) + && HostedControlManager.isDirectLocalEndpoint(control.getString("Endpoint", ""), configured); + } + /** Refreshes the optional Control services after their Config.yml settings were applied. */ public synchronized void restartBackendControlConnector() { backendControlConnectorReconcileRequested = true; @@ -1092,7 +1133,15 @@ private void reconcileBackendControlConnector() { BackendControlConnector replacement; boolean creationFailed = false; try { - replacement = BackendControlConnector.create(this); + if (backendControlAwaitsHostedPublication()) { + clearBackendControlAutoEnrollment(); + replacement = null; + } else { + refreshBackendControlAutoEnrollment(); + BackendControlAutoEnrollment enrollment = backendControlAutoEnrollment; + replacement = enrollment != null && enrollment.isAwaitingCredential() + ? null : BackendControlConnector.create(this); + } } catch (Exception e) { replacement = null; creationFailed = true; @@ -1135,6 +1184,9 @@ public synchronized boolean hasDeferredBackendControlReconciliation() { private void stopBackendControlConnectorLifecycle() { backendControlConnectorStopping = true; backendControlConnectorLifecycle.shutdownNow(); + BackendControlAutoEnrollment enrollment = backendControlAutoEnrollment; + backendControlAutoEnrollment = null; + if (enrollment != null) enrollment.close(); BackendControlConnector connector = backendControlConnector; backendControlConnector = null; if (connector != null) { @@ -1167,6 +1219,9 @@ public synchronized void restartBackendProxyHandler() { if (!bungeeSettings.isUseBungeecoord()) { backendProxyHandler = null; if (previous != null) previous.close(); + BackendControlAutoEnrollment enrollment = backendControlAutoEnrollment; + backendControlAutoEnrollment = null; + if (enrollment != null) enrollment.close(); return; } BungeeMethod replacementMethod = BungeeMethod.getByName(bungeeSettings.getBungeeMethod()); @@ -1182,6 +1237,11 @@ public synchronized void restartBackendProxyHandler() { } backendProxyHandler = replacement; if (previous != null) previous.close(); + try { + refreshBackendControlAutoEnrollment(); + } catch (IOException e) { + getLogger().warning("[Control] Automatic backend enrollment was not refreshed: " + e.getMessage()); + } } /** Keeps one plugin-message listener for the plugin lifetime and atomically swaps its active backend handler. */ @@ -1203,6 +1263,18 @@ public void deactivateBackendPluginMessageHandler(GlobalMessageHandler target) { backendPluginMessageTarget.compareAndSet(target, null); } + /** Handles the non-secret acknowledgement for a pending proxy-mediated enrollment. */ + public void handleBackendControlEnrollmentResult(com.bencodez.simpleapi.servercomm.codec.JsonEnvelope envelope) { + BackendControlAutoEnrollment enrollment = backendControlAutoEnrollment; + if (enrollment != null) enrollment.handle(envelope); + } + + /** Confirms that the generated credential authenticated to the configured Control endpoint. */ + public void backendControlAuthenticated(String nodeId, String credentialFile, String endpoint, String verifier) { + BackendControlAutoEnrollment enrollment = backendControlAutoEnrollment; + if (enrollment != null) enrollment.connectorAuthenticated(nodeId, credentialFile, endpoint, verifier); + } + private void migrateVoteBroadcast(Config configFile) { ConfigurationSection cfg = configFile.getData(); // If new section exists, do nothing diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java index 59563bffbd..40b6cb689a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java @@ -50,6 +50,9 @@ public void register(GlobalMessageHandler messages, BungeeMethod method) { messages.addListener(new GlobalMessageListener(VotingPluginWire.SUB_VOTE_DELAY_REJECTED) { @Override public void onReceive(JsonEnvelope msg) { handleWireVoteDelayRejected(msg); } }); + messages.addListener(new GlobalMessageListener(VotingPluginWire.SUB_CONTROL_ENROLLMENT_RESULT) { + @Override public void onReceive(JsonEnvelope msg) { plugin.handleBackendControlEnrollmentResult(msg); } + }); if (method.supportsBackendPresence()) { messages.addListener(new GlobalMessageListener(VotingPluginWire.SUB_PRESENCE_RESYNC_REQUEST) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java new file mode 100644 index 0000000000..34a02dca98 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java @@ -0,0 +1,197 @@ +package com.bencodez.votingplugin.control; + +import java.io.IOException; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.scheduler.BukkitTask; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.backendproxy.BackendProxyHandler; +import com.bencodez.votingplugin.proxy.BungeeMethod; +import com.bencodez.votingplugin.proxy.VotingPluginWire; +import com.bencodez.votingplugin.proxy.control.HostedControlManager; +import com.bencodez.votingplugin.proxy.control.HostedControlManager.HostConfiguration; +import com.bencodez.votingplugin.util.ControlCredentialFile; +import com.bencodez.votingplugin.util.ControlCredentialFile.AutoEnrollmentInspection; +import com.bencodez.votingplugin.util.ControlCredentialFile.PendingAutoEnrollment; + +/** + * Enrolls a Bukkit node through its authenticated proxy server connection while + * keeping the generated bearer credential on the Bukkit node. + */ +public final class BackendControlAutoEnrollment implements AutoCloseable { + private static final long RETRY_TICKS = 20L * 15L; + private static final long VERIFIER_REFRESH_NANOS = TimeUnit.SECONDS.toNanos(60); + + private final VotingPluginMain plugin; + private final String nodeId; + private final String credentialFile; + private final String endpoint; + private final UUID requestId = UUID.randomUUID(); + private final AtomicBoolean closed = new AtomicBoolean(); + private volatile BukkitTask retryTask; + private PendingAutoEnrollment enrollment; + private boolean verifierInstalled; + private boolean connectorAuthenticated; + private long verifierAcknowledgedAt; + + private BackendControlAutoEnrollment(VotingPluginMain plugin, String nodeId, String credentialFile, + String endpoint, PendingAutoEnrollment enrollment) { + this.plugin = plugin; + this.nodeId = nodeId; + this.credentialFile = credentialFile; + this.endpoint = endpoint; + this.enrollment = enrollment; + } + + /** Prepares a verifier that this Bukkit process's own hosted Control will install. */ + public static PendingAutoEnrollment prepareLocal(VotingPluginMain plugin, HostConfiguration hosted) + throws IOException { + ConfigurationSection control = control(plugin); + if (control == null || !control.getBoolean("Enabled", false) + || !HostedControlManager.isDirectLocalEndpoint(control.getString("Endpoint", ""), hosted)) { + return null; + } + return prepare(plugin, control); + } + + /** Prepares proxy-mediated enrollment only when source-bound plugin messaging is active. */ + public static BackendControlAutoEnrollment create(VotingPluginMain plugin, HostConfiguration hosted) + throws IOException { + ConfigurationSection control = control(plugin); + if (control == null || !control.getBoolean("Enabled", false) + || HostedControlManager.isDirectLocalEndpoint(control.getString("Endpoint", ""), hosted) + || !plugin.getBungeeSettings().isUseBungeecoord() + || BungeeMethod.getByName(plugin.getBungeeSettings().getBungeeMethod()) != BungeeMethod.PLUGINMESSAGING) { + return null; + } + String serverName = plugin.getOptions().getServer(); + String nodeId = nodeId(plugin, control); + if (!nodeId.equals(serverName) || "pleaseset".equalsIgnoreCase(nodeId)) { + return null; + } + String endpoint = control.getString("Endpoint", ""); + String credentialFile = control.getString("CredentialFile", "control/control-credential.txt"); + AutoEnrollmentInspection inspection = ControlCredentialFile.inspectAutoEnrollment( + plugin.getDataFolder().toPath(), credentialFile); + PendingAutoEnrollment pending = inspection.pending(); + if (pending == null && inspection.credentialPresent()) return null; + if (pending != null && !nodeId.equals(pending.nodeId())) pending = null; + return new BackendControlAutoEnrollment(plugin, nodeId, credentialFile, + endpoint == null ? "" : endpoint.trim(), pending); + } + + public static String configuredNodeId(VotingPluginMain plugin, ConfigurationSection control) { + return nodeId(plugin, control); + } + + private static PendingAutoEnrollment prepare(VotingPluginMain plugin, ConfigurationSection control) + throws IOException { + String credentialFile = control.getString("CredentialFile", "control/control-credential.txt"); + return prepare(plugin, credentialFile, nodeId(plugin, control)); + } + + private static PendingAutoEnrollment prepare(VotingPluginMain plugin, String credentialFile, String nodeId) + throws IOException { + return ControlCredentialFile.prepareAutoEnrollment(plugin.getDataFolder().toPath(), credentialFile, nodeId); + } + + private static ConfigurationSection control(VotingPluginMain plugin) { + return plugin.getConfigFile().getData().getConfigurationSection("Control.Backend"); + } + + private static String nodeId(VotingPluginMain plugin, ConfigurationSection control) { + String configured = control.getString("NodeId", ""); + return configured == null || configured.isBlank() ? plugin.getOptions().getServer() : configured.trim(); + } + + public synchronized void start() { + if (closed.get() || retryTask != null) return; + try { + retryTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::send, 1L, RETRY_TICKS); + } catch (RuntimeException failure) { + closed.set(true); + plugin.getLogger().warning("[Control] Automatic backend enrollment could not be scheduled"); + } + } + + /** True while the proxy must confirm that this route reaches its hosted Control. */ + public synchronized boolean isAwaitingCredential() { + return enrollment == null; + } + + private void send() { + PendingAutoEnrollment pending; + synchronized (this) { + if (closed.get() || (verifierInstalled + && System.nanoTime() - verifierAcknowledgedAt < VERIFIER_REFRESH_NANOS)) return; + pending = enrollment; + } + BackendProxyHandler handler = plugin.getBackendProxyHandler(); + if (handler == null || handler.getMethod() != BungeeMethod.PLUGINMESSAGING + || handler.getGlobalMessageHandler() == null) return; + handler.getGlobalMessageHandler().sendMessage(VotingPluginWire.controlEnrollmentRequest( + nodeId, pending == null ? "" : pending.verifier(), endpoint, requestId)); + } + + public void handle(JsonEnvelope envelope) { + VotingPluginWire.ControlEnrollmentResult result = VotingPluginWire.readControlEnrollmentResult(envelope); + boolean restartConnector = false; + synchronized (this) { + if (closed.get() || !result.valid || !result.success || !requestId.equals(result.requestId) + || !nodeId.equals(result.nodeId)) return; + if (enrollment == null) { + try { + enrollment = prepare(plugin, credentialFile, nodeId); + if (enrollment == null) close(); + restartConnector = true; + } catch (IOException e) { + plugin.getLogger().warning( + "[Control] Automatic backend credential could not be prepared; it will retry"); + } + } else { + verifierInstalled = true; + verifierAcknowledgedAt = System.nanoTime(); + completeIfConnected(); + } + } + if (restartConnector) plugin.restartBackendControlConnector(); + } + + /** Completes durable enrollment only after this credential authenticated to the configured endpoint. */ + public synchronized void connectorAuthenticated(String nodeId, String credentialFile, String endpoint, + String verifier) { + PendingAutoEnrollment pending = enrollment; + if (closed.get() || pending == null || !pending.nodeId().equals(nodeId) + || !pending.configuredPath().equals(credentialFile) || !this.endpoint.equals(endpoint) + || !pending.verifier().equals(verifier)) return; + connectorAuthenticated = true; + completeIfConnected(); + } + + private void completeIfConnected() { + if (!verifierInstalled || !connectorAuthenticated) return; + try { + PendingAutoEnrollment pending = enrollment; + if (pending == null) return; + ControlCredentialFile.completeAutoEnrollment(pending); + plugin.getLogger().info("[Control] Automatic credential enrollment completed for " + pending.nodeId()); + close(); + } catch (IOException e) { + verifierInstalled = false; + plugin.getLogger().warning("[Control] Automatic credential enrollment could not be finalized; it will retry"); + } + } + + @Override + public synchronized void close() { + if (!closed.compareAndSet(false, true)) return; + BukkitTask task = retryTask; + if (task != null) task.cancel(); + retryTask = null; + } +} 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 7337db1cf5..6a7cf384ec 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java @@ -50,6 +50,7 @@ public final class BackendControlConnector implements AutoCloseable { private final Path dataDirectory; private final Settings settings; private final String credential; + private final String credentialVerifier; private volatile HostConfiguration hostedConfiguration; private final ScheduledExecutorService executor; private final HttpClient http; @@ -75,6 +76,7 @@ private BackendControlConnector(VotingPluginMain plugin, Path dataDirectory, Set this.dataDirectory = dataDirectory; this.settings = settings; this.credential = credential; + this.credentialVerifier = ControlCredentialFile.sha256Verifier(credential); this.hostedConfiguration = hostedConfiguration; this.recovering = recovering; ThreadFactory factory = runnable -> { @@ -121,7 +123,7 @@ public static BackendControlConnector create(VotingPluginMain plugin) throws IOE } ConfigurationSection control = plugin.getConfigFile().getData().getConfigurationSection("Control.Backend"); if (control == null || !control.getBoolean("Enabled", false)) return null; - String nodeId = control.getString("NodeId", "").trim(); + String nodeId = BackendControlAutoEnrollment.configuredNodeId(plugin, control); if (!NODE_ID.matcher(nodeId).matches()) throw new IllegalArgumentException("Control.Backend.NodeId is invalid"); String endpointValue = control.getString("Endpoint", "").trim(); URI endpoint = URI.create(endpointValue); @@ -180,6 +182,8 @@ private void cycle() { throw incompatible; } registered = true; + plugin.backendControlAuthenticated(settings.nodeId(), settings.credentialFile(), + settings.endpoint().toString(), credentialVerifier); if (closed) return; if (operationsAccepted) { CompletableFuture operation = new CompletableFuture<>(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java index ab0dab545a..f00a6cf647 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java @@ -31,6 +31,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import javax.net.ssl.SSLParameters; @@ -97,6 +98,7 @@ public abstract class VotingPluginProxy { private static final long PRESENCE_STARTUP_RESYNC_DELAY_SECONDS = 5L; private static final long PRESENCE_MAINTENANCE_INTERVAL_SECONDS = 30L; private static final long PRESENCE_BACKEND_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(90); + private static final long CONTROL_ENROLLMENT_MIN_INTERVAL_NANOS = TimeUnit.SECONDS.toNanos(10); @Getter @Setter @@ -168,6 +170,7 @@ public abstract class VotingPluginProxy { private final BackendPlayerPresenceTracker backendPlayerPresenceTracker = new BackendPlayerPresenceTracker(); private final Map pendingPresenceHandoffs = new HashMap<>(); private final Set pendingBackendRecoverySnapshots = ConcurrentHashMap.newKeySet(); + private final Map controlEnrollmentNextAllowed = new ConcurrentHashMap<>(); private volatile ControlConnector controlConnector; private volatile HostedControlManager hostedControlManager; private final Object controlLifecycleLock = new Object(); @@ -1609,9 +1612,9 @@ private void startControlServicesLocked() { try { hostedControlManager = HostedControlManager.create(this); if (hostedControlManager != null) hostedControlManager.start(); - } catch (IllegalArgumentException e) { + } catch (IOException | IllegalArgumentException e) { hostedControlManager = null; - logSevere("[Control Host] configuration is invalid; VotingPlugin remains unaffected"); + logSevere("[Control Host] configuration or automatic enrollment is invalid; VotingPlugin remains unaffected"); } } try { @@ -2446,6 +2449,11 @@ private void runCleanup(String service, CleanupAction cleanup) { private interface CleanupAction { void run() throws Exception; } public void onPluginMessageReceived(DataInputStream in) { + onPluginMessageReceived(in, null); + } + + /** Receives a plugin message bound to the backend server connection that sent it. */ + public void onPluginMessageReceived(DataInputStream in, String sourceServer) { runAsync(() -> { try { final String headerSub; @@ -2480,6 +2488,11 @@ public void onPluginMessageReceived(DataInputStream in) { return; } + if (VotingPluginWire.SUB_CONTROL_ENROLLMENT_REQUEST.equals(envelope.getSubChannel())) { + handleControlEnrollmentRequest(sourceServer, envelope); + return; + } + globalMessageProxyHandler.onMessage(envelope); } catch (Exception e) { e.printStackTrace(); @@ -2487,6 +2500,38 @@ public void onPluginMessageReceived(DataInputStream in) { }); } + private void handleControlEnrollmentRequest(String sourceServer, JsonEnvelope envelope) { + VotingPluginWire.ControlEnrollmentRequest request = VotingPluginWire.readControlEnrollmentRequest(envelope); + if (!request.valid || sourceServer == null || sourceServer.isBlank()) return; + if (!sourceServer.equals(request.nodeId)) { + sendPluginMessageServer(sourceServer, 0, + VotingPluginWire.controlEnrollmentResult(sourceServer, request.requestId, false)); + return; + } + long now = System.nanoTime(); + AtomicBoolean allowed = new AtomicBoolean(); + controlEnrollmentNextAllowed.compute(sourceServer, (ignored, nextAllowed) -> { + if (nextAllowed == null || now >= nextAllowed) { + allowed.set(true); + return now + CONTROL_ENROLLMENT_MIN_INTERVAL_NANOS; + } + return nextAllowed; + }); + if (!allowed.get()) return; + HostedControlManager manager = hostedControlManager; + if (manager == null) { + sendPluginMessageServer(sourceServer, 0, + VotingPluginWire.controlEnrollmentResult(sourceServer, request.requestId, false)); + return; + } + manager.installNodeVerifier(sourceServer, request.verifier, request.endpoint).whenComplete((installed, failure) -> { + boolean success = failure == null && Boolean.TRUE.equals(installed); + sendPluginMessageServer(sourceServer, 0, + VotingPluginWire.controlEnrollmentResult(sourceServer, request.requestId, success)); + if (success) log("[Control] automatically enrolled backend node " + sourceServer); + }); + } + private UUID parseUUIDFromString(String uuidAsString) { String[] parts = { "0x" + uuidAsString.substring(0, 8), "0x" + uuidAsString.substring(8, 12), "0x" + uuidAsString.substring(12, 16), "0x" + uuidAsString.substring(16, 20), diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java index ad1449a57e..23de6628a6 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java @@ -59,6 +59,8 @@ private VotingPluginWire() { public static final String SUB_PRESENCE_RESYNC_REQUEST = "PresenceResyncRequest"; public static final String SUB_PRESENCE_SNAPSHOT_REQUEST = "PresenceSnapshotRequest"; public static final String SUB_PRESENCE_SNAPSHOT = "PresenceSnapshot"; + public static final String SUB_CONTROL_ENROLLMENT_REQUEST = "ControlEnrollmentRequest"; + public static final String SUB_CONTROL_ENROLLMENT_RESULT = "ControlEnrollmentResult"; public static final String SUB_VOTEUPDATE_RELAY = "voteupdate"; // ========================= @@ -85,6 +87,10 @@ private VotingPluginWire() { public static final String K_PLAYERS = "players"; public static final String K_CHUNK_INDEX = "chunkIndex"; public static final String K_CHUNK_COUNT = "chunkCount"; + public static final String K_NODE_ID = "nodeId"; + public static final String K_VERIFIER = "verifier"; + public static final String K_ENDPOINT = "endpoint"; + public static final String K_SUCCESS = "success"; // Vote/VoteOnline extras public static final String K_WAS_ONLINE = "wasOnline"; @@ -172,6 +178,19 @@ public static JsonEnvelope serverName(String server) { return base(SUB_SERVER_NAME).put(K_SERVER, safe(server)).build(); } + public static JsonEnvelope controlEnrollmentRequest(String nodeId, String verifier, String endpoint, UUID requestId) { + return base(SUB_CONTROL_ENROLLMENT_REQUEST).put(K_NODE_ID, safe(nodeId)) + .put(K_VERIFIER, safe(verifier)) + .put(K_ENDPOINT, safe(endpoint)) + .put(K_REQUEST_ID, requestId == null ? "" : requestId.toString()).build(); + } + + public static JsonEnvelope controlEnrollmentResult(String nodeId, UUID requestId, boolean success) { + return base(SUB_CONTROL_ENROLLMENT_RESULT).put(K_NODE_ID, safe(nodeId)) + .put(K_REQUEST_ID, requestId == null ? "" : requestId.toString()) + .put(K_SUCCESS, success).build(); + } + public static JsonEnvelope login(String player, String uuid, String server) { return login(player, uuid, server, null); } @@ -461,6 +480,51 @@ public static VoteUpdate readVoteUpdate(JsonEnvelope env) { return new VoteUpdate(uuid, cur, req, service, t); } + public static final class ControlEnrollmentRequest { + public final String nodeId; + public final String verifier; + public final String endpoint; + public final UUID requestId; + public final boolean valid; + + private ControlEnrollmentRequest(String nodeId, String verifier, String endpoint, UUID requestId) { + this.nodeId = nodeId; + this.verifier = verifier; + this.endpoint = endpoint; + this.requestId = requestId; + this.valid = nodeId.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}") + && (verifier.isEmpty() || verifier.matches("[0-9a-f]{64}")) + && !endpoint.isBlank() && endpoint.length() <= 2048 + && requestId != null; + } + } + + public static ControlEnrollmentRequest readControlEnrollmentRequest(JsonEnvelope env) { + Map fields = env.getFields(); + return new ControlEnrollmentRequest(safe(fields.get(K_NODE_ID)), safe(fields.get(K_VERIFIER)), + safe(fields.get(K_ENDPOINT)), readUuid(fields, K_REQUEST_ID)); + } + + public static final class ControlEnrollmentResult { + public final String nodeId; + public final UUID requestId; + public final boolean success; + public final boolean valid; + + private ControlEnrollmentResult(String nodeId, UUID requestId, boolean success) { + this.nodeId = nodeId; + this.requestId = requestId; + this.success = success; + this.valid = nodeId.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}") && requestId != null; + } + } + + public static ControlEnrollmentResult readControlEnrollmentResult(JsonEnvelope env) { + Map fields = env.getFields(); + return new ControlEnrollmentResult(safe(fields.get(K_NODE_ID)), readUuid(fields, K_REQUEST_ID), + readBool(fields, K_SUCCESS, false)); + } + public static final class PresencePlayer { public final String player; public final String uuid; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungee.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungee.java index f567245a95..bd644c4743 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungee.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungee.java @@ -89,7 +89,7 @@ public class VotingPluginBungee extends Plugin implements Listener { /** * Plugin messages received during reload are queued and replayed after reload. */ - private final Queue queuedPluginMessages = new ConcurrentLinkedQueue(); + private final Queue queuedPluginMessages = new ConcurrentLinkedQueue<>(); /** * Logs debug output if enabled in config. @@ -279,16 +279,17 @@ public void onPluginMessage(PluginMessageEvent ev) { debug("Ignore plugin message (not from server)"); return; } + String sourceServer = ((Server) ev.getSender()).getInfo().getName(); // During reload, queue and replay later to avoid calling into disposed runtime if (reloading) { byte[] copy = new byte[ev.getData().length]; System.arraycopy(ev.getData(), 0, copy, 0, ev.getData().length); - queuedPluginMessages.add(copy); + queuedPluginMessages.add(new QueuedPluginMessage(sourceServer, copy)); return; } - handlePluginMessageBytes(ev.getData()); + handlePluginMessageBytes(sourceServer, ev.getData()); } /** @@ -896,11 +897,11 @@ private void runAsyncNow(Runnable runnable) { * * @param data payload bytes */ - private void handlePluginMessageBytes(byte[] data) { + private void handlePluginMessageBytes(String sourceServer, byte[] data) { ByteArrayInputStream instream = new ByteArrayInputStream(data); DataInputStream in = new DataInputStream(instream); try { - getVotingPluginProxy().onPluginMessageReceived(in); + getVotingPluginProxy().onPluginMessageReceived(in, sourceServer); } catch (Exception e) { e.printStackTrace(); } @@ -910,13 +911,15 @@ private void handlePluginMessageBytes(byte[] data) { * Replays queued plugin messages after reload completes. */ private void drainQueuedPluginMessages() { - byte[] msg; + QueuedPluginMessage msg; while ((msg = queuedPluginMessages.poll()) != null) { try { - handlePluginMessageBytes(msg); + handlePluginMessageBytes(msg.sourceServer(), msg.data()); } catch (Exception e) { e.printStackTrace(); } } } + + private record QueuedPluginMessage(String sourceServer, byte[] data) { } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/HostedControlManager.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/HostedControlManager.java index 65ebc395ee..2ec1faf8ce 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/HostedControlManager.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/HostedControlManager.java @@ -25,6 +25,7 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledThreadPoolExecutor; @@ -36,6 +37,8 @@ import com.bencodez.votingplugin.proxy.VotingPluginProxy; import com.bencodez.votingplugin.proxy.VotingPluginProxyConfig; import com.bencodez.votingplugin.util.BoundedHttpBodyHandler; +import com.bencodez.votingplugin.util.ControlCredentialFile; +import com.bencodez.votingplugin.util.ControlCredentialFile.PendingAutoEnrollment; import com.bencodez.votingplugin.util.DurableFiles; import com.google.gson.JsonElement; import com.google.gson.JsonObject; @@ -54,11 +57,13 @@ public final class HostedControlManager implements AutoCloseable { private final ScheduledExecutorService executor; private final ArtifactDownloader downloader; private final ProcessLauncher launcher; + private final VerifierInstaller verifierInstaller; private final HealthProbe healthProbe; private final Sleeper sleeper; private final LongSupplier nanoTime; private final Consumer logger; private final AtomicBoolean workInProgress = new AtomicBoolean(); + private final ConcurrentHashMap> enrollmentTasks = new ConcurrentHashMap<>(); private final Object processLifecycle = new Object(); private final Object preparationLifecycle = new Object(); private volatile boolean closed; @@ -73,26 +78,39 @@ public final class HostedControlManager implements AutoCloseable { private volatile String rollbackCandidateSha256; private volatile boolean rollbackCandidateReachedHealth; private volatile boolean rollbackPending; + private volatile String healthyLaunchId; + private volatile PendingAutoEnrollment localAutoEnrollment; - private HostedControlManager(Settings settings, Consumer logger) { + private HostedControlManager(Settings settings, PendingAutoEnrollment localAutoEnrollment, + Consumer logger) { this(settings, daemonExecutor(), new JdkArtifactDownloader(), new JdkProcessLauncher(), - new JdkHealthProbe(), Thread::sleep, System::nanoTime, logger); + new JdkVerifierInstaller(), new JdkHealthProbe(), Thread::sleep, System::nanoTime, + localAutoEnrollment, logger); } HostedControlManager(Settings settings, ScheduledExecutorService executor, ArtifactDownloader downloader, ProcessLauncher launcher, HealthProbe healthProbe, Sleeper sleeper, LongSupplier nanoTime, Consumer logger) { + this(settings, executor, downloader, launcher, (configured, artifact, nodeId, verifier, timeout) -> { }, + healthProbe, sleeper, nanoTime, null, logger); + } + + HostedControlManager(Settings settings, ScheduledExecutorService executor, ArtifactDownloader downloader, + ProcessLauncher launcher, VerifierInstaller verifierInstaller, HealthProbe healthProbe, Sleeper sleeper, + LongSupplier nanoTime, PendingAutoEnrollment localAutoEnrollment, Consumer logger) { this.settings = Objects.requireNonNull(settings, "settings"); this.executor = Objects.requireNonNull(executor, "executor"); this.downloader = Objects.requireNonNull(downloader, "downloader"); this.launcher = Objects.requireNonNull(launcher, "launcher"); + this.verifierInstaller = Objects.requireNonNull(verifierInstaller, "verifierInstaller"); this.healthProbe = Objects.requireNonNull(healthProbe, "healthProbe"); this.sleeper = Objects.requireNonNull(sleeper, "sleeper"); this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime"); + this.localAutoEnrollment = localAutoEnrollment; this.logger = Objects.requireNonNull(logger, "logger"); } - public static HostedControlManager create(VotingPluginProxy proxy) { + public static HostedControlManager create(VotingPluginProxy proxy) throws IOException { VotingPluginProxyConfig config = proxy.getConfig(); HostConfiguration hosted = new HostConfiguration(config.getControlHostedEnabled(), config.getControlHostedAutoDownload(), config.getControlHostedAutoUpdate(), @@ -100,8 +118,20 @@ public static HostedControlManager create(VotingPluginProxy proxy) { config.getControlHostedJarFile(), config.getControlHostedDataDirectory(), config.getControlHostedHost(), config.getControlHostedPort(), config.getControlHostedStartupTimeoutSeconds(), config.getControlHostedDownloadTimeoutSeconds()); - return create(proxy.getDataFolderPlugin().toPath(), hosted, - message -> proxy.log("[Control Host] " + message)); + Path root = proxy.getDataFolderPlugin().toPath(); + PendingAutoEnrollment enrollment = null; + if (config.getControlEnabled() && isDirectLocalEndpoint(config.getControlEndpoint(), hosted)) { + try { + String configuredNodeId = config.getControlNodeId(); + String nodeId = configuredNodeId == null || configuredNodeId.isBlank() + ? config.getProxyServerName() : configuredNodeId.trim(); + enrollment = ControlCredentialFile.prepareAutoEnrollment(root, + config.getControlCredentialFile(), nodeId); + } catch (IOException | IllegalArgumentException enrollmentFailure) { + proxy.log("[Control] Automatic local credential enrollment was skipped because its connector settings are invalid"); + } + } + return create(root, hosted, enrollment, message -> proxy.log("[Control Host] " + message)); } /** @@ -111,6 +141,12 @@ public static HostedControlManager create(VotingPluginProxy proxy) { */ public static HostedControlManager create(Path rootDirectory, HostConfiguration config, Consumer logger) { + return create(rootDirectory, config, null, logger); + } + + /** Creates a hosted supervisor with an optional verifier-only local enrollment. */ + public static HostedControlManager create(Path rootDirectory, HostConfiguration config, + PendingAutoEnrollment localAutoEnrollment, Consumer logger) { Objects.requireNonNull(config, "config"); if (!config.enabled()) return null; Path root = Objects.requireNonNull(rootDirectory, "rootDirectory").toAbsolutePath().normalize(); @@ -119,7 +155,7 @@ public static HostedControlManager create(Path rootDirectory, HostConfiguration resolveInside(root, config.dataDirectory(), "Control hosted data directory"), config.autoDownload(), config.autoUpdate(), parseDownloadUri(config.downloadUrl()), config.sha256(), config.host(), config.port(), config.startupTimeoutSeconds(), config.downloadTimeoutSeconds()); - return new HostedControlManager(settings, Objects.requireNonNull(logger, "logger")); + return new HostedControlManager(settings, localAutoEnrollment, Objects.requireNonNull(logger, "logger")); } public void start() { @@ -197,6 +233,7 @@ void runOnce() { if (closed) { return; } + installLocalAutoEnrollment(); LaunchedProcess launched = launch(settings.jarFile()); ManagedProcess process = launched.process(); if (updated) { @@ -218,6 +255,7 @@ void runOnce() { rollbackCandidateSha256 = null; rollbackCandidateReachedHealth = false; rollbackPending = false; + healthyLaunchId = launched.launchId(); status = Status.RUNNING; logger.accept("WebUI is available at " + settings.endpoint()); monitor(process); @@ -254,6 +292,7 @@ private boolean rollbackAndStartPrevious() throws IOException, InterruptedExcept ManagedProcess previous = previousLaunch.process(); if (awaitHealthy(previousLaunch)) { failures = 0; + healthyLaunchId = previousLaunch.launchId(); status = Status.ROLLED_BACK; logger.accept("The new Control release failed health checks; the previous release is running"); monitor(previous); @@ -512,8 +551,81 @@ private void clearPersistedQuarantineState() throws IOException { DurableFiles.deleteIfExists(settings.quarantineFile()); } + private void installLocalAutoEnrollment() throws IOException, InterruptedException { + PendingAutoEnrollment enrollment = localAutoEnrollment; + if (enrollment == null) return; + requireTrustedInstalledArtifact(); + verifierInstaller.install(settings, settings.jarFile(), enrollment.nodeId(), enrollment.verifier(), + Duration.ofSeconds(Math.min(30, settings.startupTimeoutSeconds()))); + ControlCredentialFile.completeAutoEnrollment(enrollment); + localAutoEnrollment = null; + logger.accept("Automatically enrolled local node " + enrollment.nodeId()); + } + + /** + * Proves a backend route reaches this hosted launch, then optionally installs + * its verifier. The raw credential never reaches this process. + */ + public CompletableFuture installNodeVerifier(String nodeId, String verifier, String endpoint) { + if (nodeId == null || !nodeId.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}") + || verifier == null || (!verifier.isEmpty() && !verifier.matches("[0-9a-f]{64}"))) { + return CompletableFuture.completedFuture(false); + } + URI requestedEndpoint = hostedBackendEndpoint(endpoint); + if (requestedEndpoint == null) return CompletableFuture.completedFuture(false); + String key = nodeId + ':' + verifier + ':' + requestedEndpoint; + return enrollmentTasks.computeIfAbsent(key, ignored -> { + CompletableFuture result = new CompletableFuture<>(); + try { + executor.execute(() -> { + try { + String launchId = healthyLaunchId; + if (closed || (status != Status.RUNNING && status != Status.ROLLED_BACK) + || launchId == null + || !Files.isRegularFile(settings.jarFile(), LinkOption.NOFOLLOW_LINKS)) { + result.complete(false); + return; + } + if (!healthProbe.isHealthy(requestedEndpoint, Duration.ofSeconds(2), launchId)) { + result.complete(false); + return; + } + if (verifier.isEmpty()) { + result.complete(true); + return; + } + requireTrustedInstalledArtifact(); + verifierInstaller.install(settings, settings.jarFile(), nodeId, verifier, + Duration.ofSeconds(Math.min(30, settings.startupTimeoutSeconds()))); + result.complete(true); + } catch (Exception failure) { + if (failure instanceof InterruptedException) Thread.currentThread().interrupt(); + result.complete(false); + } finally { + enrollmentTasks.remove(key, result); + } + }); + } catch (RuntimeException failure) { + result.complete(false); + enrollmentTasks.remove(key, result); + } + return result; + }); + } + + private void requireTrustedInstalledArtifact() throws IOException { + if (!Files.isRegularFile(settings.jarFile(), LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Control artifact is unavailable"); + } + String installed = sha256(settings.jarFile()); + if (!settings.sha256().equals(installed) && !installed.equals(trustedRollbackSha256)) { + throw new IOException("Control artifact no longer matches a trusted digest"); + } + } + private LaunchedProcess launch(Path artifact) throws IOException { status = Status.STARTING_PROCESS; + healthyLaunchId = null; synchronized (processLifecycle) { if (closed) throw new IOException("Hosted Control manager is closed"); if (managedProcess != null) { @@ -567,6 +679,7 @@ private void monitor(ManagedProcess process) { return; } managedProcess = null; + healthyLaunchId = null; status = Status.FAILED; logger.accept("Control exited unexpectedly; a bounded restart will be attempted"); scheduleRetry(); @@ -574,6 +687,7 @@ private void monitor(ManagedProcess process) { } private void fail() { + healthyLaunchId = null; status = Status.FAILED; ManagedProcess process = managedProcess; if (process != null && process.isAlive()) { @@ -586,6 +700,7 @@ private void fail() { } private void failWhileProcessIsAlive(ManagedProcess process) { + healthyLaunchId = null; status = Status.FAILED; synchronized (processLifecycle) { if (closed || managedProcess != process || exitAwaitedProcess == process) return; @@ -634,6 +749,7 @@ private void close(boolean waitForProcess) { ManagedProcess process; synchronized (processLifecycle) { closed = true; + healthyLaunchId = null; status = Status.STOPPED; process = managedProcess; } @@ -656,6 +772,8 @@ private void close(boolean waitForProcess) { } finally { discardPreparedArtifact(); executor.shutdownNow(); + enrollmentTasks.values().forEach(result -> result.complete(false)); + enrollmentTasks.clear(); } if (waitForProcess) { long waitSeconds = (long) settings.downloadTimeoutSeconds() + settings.startupTimeoutSeconds() + 5L; @@ -757,6 +875,71 @@ static Path resolveInside(Path root, String configured, String name) { return resolved; } + /** True only for direct HTTP to this hosted listener on the same node. */ + public static boolean isDirectLocalEndpoint(String configuredEndpoint, HostConfiguration hosted) { + if (hosted == null || !hosted.enabled() || configuredEndpoint == null) return false; + try { + URI endpoint = URI.create(configuredEndpoint.trim()); + String host = normalizeHostLiteral(endpoint.getHost()); + int port = endpoint.getPort() < 0 ? 80 : endpoint.getPort(); + String path = endpoint.getPath(); + boolean loopback = isLoopbackHost(host); + String listenerHost = normalizeHostLiteral(hosted.host()); + boolean configuredListener = host != null && host.equalsIgnoreCase(listenerHost); + boolean listenerLoopback = isLoopbackHost(listenerHost); + boolean listenerWildcard = "0.0.0.0".equals(listenerHost) || "::".equals(listenerHost) + || "0:0:0:0:0:0:0:0".equals(listenerHost); + return "http".equalsIgnoreCase(endpoint.getScheme()) + && (configuredListener || (loopback && (listenerLoopback || listenerWildcard))) + && port == hosted.port() + && endpoint.getUserInfo() == null && endpoint.getQuery() == null && endpoint.getFragment() == null + && (path == null || path.isEmpty() || "/".equals(path)); + } catch (IllegalArgumentException e) { + return false; + } + } + + /** + * Accepts a backend route only when it can address this listener. A subsequent + * launch-id health probe proves that the route reaches this exact hosted process. + */ + private URI hostedBackendEndpoint(String configuredEndpoint) { + if (configuredEndpoint == null || configuredEndpoint.isBlank()) return null; + try { + URI endpoint = URI.create(configuredEndpoint.trim()); + String host = normalizeHostLiteral(endpoint.getHost()); + String listenerHost = normalizeHostLiteral(settings.host()); + int port = endpoint.getPort() < 0 ? 80 : endpoint.getPort(); + String path = endpoint.getPath(); + boolean listenerWildcard = "0.0.0.0".equals(listenerHost) || "::".equals(listenerHost) + || "0:0:0:0:0:0:0:0".equals(listenerHost); + if (!"http".equalsIgnoreCase(endpoint.getScheme()) || host.isBlank() || isLoopbackHost(host) + || (!listenerWildcard && !host.equalsIgnoreCase(listenerHost)) || port != settings.port() + || endpoint.getUserInfo() != null || endpoint.getQuery() != null || endpoint.getFragment() != null + || (path != null && !path.isEmpty() && !"/".equals(path))) { + return null; + } + return endpoint; + } catch (IllegalArgumentException e) { + return null; + } + } + + private static String normalizeHostLiteral(String host) { + if (host == null) return ""; + String normalized = host.trim(); + if (normalized.length() >= 2 && normalized.charAt(0) == '[' + && normalized.charAt(normalized.length() - 1) == ']') { + return normalized.substring(1, normalized.length() - 1); + } + return normalized; + } + + private static boolean isLoopbackHost(String host) { + return "localhost".equalsIgnoreCase(host) || host.startsWith("127.") + || "::1".equalsIgnoreCase(host) || "0:0:0:0:0:0:0:1".equalsIgnoreCase(host); + } + static URI parseDownloadUri(String configured) { if (configured == null || configured.isBlank()) { return null; @@ -872,6 +1055,11 @@ interface ProcessLauncher { ManagedProcess launch(Settings settings, Path artifact, String launchId) throws IOException; } + interface VerifierInstaller { + void install(Settings settings, Path artifact, String nodeId, String verifier, Duration timeout) + throws IOException, InterruptedException; + } + interface HealthProbe { boolean isHealthy(URI endpoint, Duration timeout, String launchId); } @@ -981,6 +1169,34 @@ private static Thread bodyTimeoutThread(InputStream input, long deadline, Atomic } } + private static final class JdkVerifierInstaller implements VerifierInstaller { + @Override + public void install(Settings settings, Path artifact, String nodeId, String verifier, Duration timeout) + throws IOException, InterruptedException { + Path java = Path.of(System.getProperty("java.home"), "bin", isWindows() ? "java.exe" : "java"); + ProcessBuilder builder = new ProcessBuilder(java.toString(), "-jar", artifact.toString(), + "enroll-verifier", nodeId, verifier, settings.dataDirectory().toString()); + builder.directory(artifact.getParent().toFile()); + builder.redirectOutput(ProcessBuilder.Redirect.DISCARD); + builder.redirectError(ProcessBuilder.Redirect.DISCARD); + copySafeEnvironment(builder); + Process process = builder.start(); + boolean exited; + try { + exited = process.waitFor(Math.max(1L, timeout.toMillis()), TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + process.destroyForcibly(); + throw e; + } + if (!exited) { + process.destroy(); + if (!process.waitFor(2, TimeUnit.SECONDS)) process.destroyForcibly(); + throw new IOException("Control verifier enrollment timed out"); + } + if (process.exitValue() != 0) throw new IOException("Control verifier enrollment failed"); + } + } + private static final class JdkProcessLauncher implements ProcessLauncher { @Override public ManagedProcess launch(Settings settings, Path artifact, String launchId) throws IOException { @@ -997,14 +1213,8 @@ public ManagedProcess launch(Settings settings, Path artifact, String launchId) builder.directory(artifact.getParent().toFile()); builder.redirectErrorStream(true); builder.redirectOutput(ProcessBuilder.Redirect.appendTo(log.toFile())); + copySafeEnvironment(builder); var environment = builder.environment(); - environment.clear(); - for (String safe : Set.of("LANG", "LC_ALL", "SYSTEMROOT", "WINDIR", "TEMP", "TMP")) { - String value = System.getenv(safe); - if (value != null) { - environment.put(safe, value); - } - } environment.put("CONTROL_HOST", settings.host()); environment.put("CONTROL_PORT", Integer.toString(settings.port())); environment.put("CONTROL_DATA_DIR", settings.dataDirectory().toString()); @@ -1014,10 +1224,23 @@ public ManagedProcess launch(Settings settings, Path artifact, String launchId) } private static boolean isWindows() { - return DurableFiles.isWindowsName(System.getProperty("os.name", "")); + return HostedControlManager.isWindows(); } } + private static void copySafeEnvironment(ProcessBuilder builder) { + var environment = builder.environment(); + environment.clear(); + for (String safe : Set.of("LANG", "LC_ALL", "SYSTEMROOT", "WINDIR", "TEMP", "TMP")) { + String value = System.getenv(safe); + if (value != null) environment.put(safe, value); + } + } + + private static boolean isWindows() { + return DurableFiles.isWindowsName(System.getProperty("os.name", "")); + } + static final class JdkHealthProbe implements HealthProbe { private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)) .followRedirects(HttpClient.Redirect.NEVER).build(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java index 09d33ed750..176c045c65 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java @@ -124,7 +124,7 @@ public class VotingPluginVelocity { /** * Plugin messages received during reload are queued. */ - private final Queue queuedPluginMessages = new ConcurrentLinkedQueue(); + private final Queue queuedPluginMessages = new ConcurrentLinkedQueue<>(); @Inject public VotingPluginVelocity(ProxyServer server, Logger logger, Metrics.Factory metricsFactory, @@ -265,15 +265,16 @@ public void onPluginMessagingReceived(PluginMessageEvent event) { if (!(event.getSource() instanceof ServerConnection)) { return; } + String sourceServer = ((ServerConnection) event.getSource()).getServerInfo().getName(); if (reloading) { byte[] copy = new byte[event.getData().length]; System.arraycopy(event.getData(), 0, copy, 0, event.getData().length); - queuedPluginMessages.add(copy); + queuedPluginMessages.add(new QueuedPluginMessage(sourceServer, copy)); return; } - handlePluginMessageBytes(event.getData()); + handlePluginMessageBytes(sourceServer, event.getData()); } @Subscribe @@ -986,23 +987,25 @@ private void runAsyncNow(Runnable runnable) { server.getScheduler().buildTask(this, runnable).schedule(); } - private void handlePluginMessageBytes(byte[] data) { + private void handlePluginMessageBytes(String sourceServer, byte[] data) { ByteArrayInputStream instream = new ByteArrayInputStream(data); DataInputStream in = new DataInputStream(instream); try { - getVotingPluginProxy().onPluginMessageReceived(in); + getVotingPluginProxy().onPluginMessageReceived(in, sourceServer); } catch (Exception e) { e.printStackTrace(); } } private void drainQueuedPluginMessages() { - byte[] msg; + QueuedPluginMessage msg; while ((msg = queuedPluginMessages.poll()) != null) { - handlePluginMessageBytes(msg); + handlePluginMessageBytes(msg.sourceServer(), msg.data()); } } + private record QueuedPluginMessage(String sourceServer, byte[] data) { } + /** * Convert old YAML cache files to JSON. */ diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ControlCredentialFile.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ControlCredentialFile.java index 149dc9f661..c4d6faa5a4 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ControlCredentialFile.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ControlCredentialFile.java @@ -5,31 +5,107 @@ import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; import java.nio.channels.SeekableByteChannel; +import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; import java.nio.file.attribute.PosixFileAttributeView; import java.nio.file.attribute.PosixFilePermission; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.HexFormat; import java.util.Set; +import java.util.regex.Pattern; -/** Symlink-safe, bounded credential loading shared by proxy and Bukkit Control connectors. */ +/** Symlink-safe, bounded credential loading and supervised enrollment state. */ public final class ControlCredentialFile { private static final int MAX_BYTES = 512; + private static final int MAX_MARKER_BYTES = 256; + private static final Pattern NODE_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); + private static final Pattern VERIFIER = Pattern.compile("[0-9a-f]{64}"); private static final Set OWNER_ONLY = Set.of( PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE); + private static final SecureRandom RANDOM = new SecureRandom(); private ControlCredentialFile() { } public static String read(Path rootDirectory, String configuredPath) throws IOException { + String credential = readCredential(resolveAndCreate(rootDirectory, configuredPath)); + if (credential == null) throw invalid(); + return credential; + } + + /** Inspects existing enrollment state without generating a new credential. */ + public static synchronized AutoEnrollmentInspection inspectAutoEnrollment(Path rootDirectory, + String configuredPath) throws IOException { + Path target = resolveAndCreate(rootDirectory, configuredPath); + Path marker = marker(target); + String credential = readCredential(target); + PendingAutoEnrollment pending = readMarker(marker, target, configuredPath); + if (credential != null && pending != null && !sha256Verifier(credential).equals(pending.verifier())) { + DurableFiles.deleteIfExists(marker); + pending = null; + } + if (credential == null) pending = null; + return new AutoEnrollmentInspection(credential != null, pending); + } + + /** + * Generates a credential only when the configured file is empty and retains a + * verifier-only marker until the supervising host acknowledges enrollment. + * Existing manually managed credentials are never replaced. + */ + public static synchronized PendingAutoEnrollment prepareAutoEnrollment(Path rootDirectory, String configuredPath, + String nodeId) throws IOException { + if (nodeId == null || !NODE_ID.matcher(nodeId).matches()) throw invalid(); Path target = resolveAndCreate(rootDirectory, configuredPath); + Path marker = marker(target); + String credential = readCredential(target); + PendingAutoEnrollment pending = readMarker(marker, target, configuredPath); + if (credential != null && pending == null) return null; + if (credential != null && !sha256Verifier(credential).equals(pending.verifier())) { + DurableFiles.deleteIfExists(marker); + return null; + } + if (credential != null && nodeId.equals(pending.nodeId())) return pending; + + String generated = token(); + String generatedVerifier = sha256Verifier(generated); + PendingAutoEnrollment replacement = new PendingAutoEnrollment(target, configuredPath, nodeId, + generatedVerifier); + writeAtomically(marker, markerContents(replacement)); + writeAtomically(target, generated); + return replacement; + } + /** Removes the pending marker only when it still describes the installed verifier. */ + public static synchronized void completeAutoEnrollment(PendingAutoEnrollment enrollment) throws IOException { + if (enrollment == null) return; + Path target = enrollment.credentialFile().toAbsolutePath().normalize(); + Path marker = marker(target); + PendingAutoEnrollment current = readMarker(marker, target, enrollment.configuredPath()); + String credential = readCredential(target); + if (current == null && credential != null && enrollment.verifier().equals(sha256Verifier(credential))) return; + if (current == null || credential == null || !current.nodeId().equals(enrollment.nodeId()) + || !current.verifier().equals(enrollment.verifier()) + || !current.verifier().equals(sha256Verifier(credential))) { + throw new IOException("Control automatic enrollment state changed"); + } + DurableFiles.deleteIfExists(marker); + } + + private static String readCredential(Path target) throws IOException { ByteBuffer bytes = ByteBuffer.allocate(MAX_BYTES + 1); try (SeekableByteChannel channel = Files.newByteChannel(target, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) { while (bytes.hasRemaining() && channel.read(bytes) > 0) { } } - if (bytes.position() == 0 || bytes.position() > MAX_BYTES) throw invalid(); + if (bytes.position() == 0) return null; + if (bytes.position() > MAX_BYTES) throw invalid(); bytes.flip(); String credential; try { @@ -38,11 +114,64 @@ public static String read(Path rootDirectory, String configuredPath) throws IOEx } catch (java.nio.charset.CharacterCodingException e) { throw invalid(); } - if (credential.isEmpty() || credential.length() > MAX_BYTES || credential.indexOf('\r') >= 0 + if (credential.isEmpty()) return null; + if (credential.length() > MAX_BYTES || credential.indexOf('\r') >= 0 || credential.indexOf('\n') >= 0) throw invalid(); return credential; } + private static PendingAutoEnrollment readMarker(Path marker, Path credentialFile, String configuredPath) + throws IOException { + if (!Files.exists(marker, LinkOption.NOFOLLOW_LINKS)) return null; + if (!Files.isRegularFile(marker, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(marker)) throw invalid(); + ByteBuffer bytes = ByteBuffer.allocate(MAX_MARKER_BYTES + 1); + try (SeekableByteChannel channel = Files.newByteChannel(marker, StandardOpenOption.READ, + LinkOption.NOFOLLOW_LINKS)) { + while (bytes.hasRemaining() && channel.read(bytes) > 0) { } + } + if (bytes.position() > MAX_MARKER_BYTES) throw invalid(); + bytes.flip(); + String contents; + try { + contents = StandardCharsets.US_ASCII.newDecoder().onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT).decode(bytes).toString(); + } catch (java.nio.charset.CharacterCodingException e) { + throw invalid(); + } + String[] lines = contents.split("\\n", -1); + if (lines.length != 3 || contents.indexOf('\r') >= 0 || !"1".equals(lines[0]) + || !NODE_ID.matcher(lines[1]).matches() + || !VERIFIER.matcher(lines[2]).matches()) throw invalid(); + restrictPermissions(marker); + return new PendingAutoEnrollment(credentialFile, configuredPath, lines[1], lines[2]); + } + + private static String markerContents(PendingAutoEnrollment enrollment) { + return "1\n" + enrollment.nodeId() + "\n" + enrollment.verifier(); + } + + private static Path marker(Path credentialFile) { + return credentialFile.resolveSibling(credentialFile.getFileName() + ".auto-enroll"); + } + + private static void writeAtomically(Path target, String value) throws IOException { + Path temporary = Files.createTempFile(target.getParent(), target.getFileName().toString(), ".temporary"); + try { + restrictPermissions(temporary); + Files.writeString(temporary, value, StandardCharsets.UTF_8, StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE); + DurableFiles.forceFile(temporary); + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + throw new IOException("Atomic Control credential publication is unsupported", e); + } + DurableFiles.forceMoveDirectories(temporary, target); + } finally { + Files.deleteIfExists(temporary); + } + } + private static Path resolveAndCreate(Path rootDirectory, String configuredPath) throws IOException { if (configuredPath == null || configuredPath.isBlank()) throw invalid(); Path lexicalRoot = rootDirectory.toAbsolutePath().normalize(); @@ -69,7 +198,7 @@ private static Path resolveAndCreate(Path rootDirectory, String configuredPath) if (!Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { try (SeekableByteChannel ignored = Files.newByteChannel(target, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) { - // Publish an empty placeholder for enrollment through the file manager or WebUI. + // Publish an empty placeholder for manual or supervised enrollment. } catch (java.nio.file.FileAlreadyExistsException ignored) { // Revalidate below in case another process created it. } @@ -90,7 +219,29 @@ private static void restrictPermissions(Path target) throws IOException { } } + private static String token() { + byte[] bytes = new byte[32]; + RANDOM.nextBytes(bytes); + return "vpctl_node_" + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + /** Returns the non-secret verifier used to bind connector authentication to pending enrollment. */ + public static String sha256Verifier(String credential) { + if (credential == null) throw new IllegalArgumentException("credential is required"); + try { + return HexFormat.of().formatHex( + MessageDigest.getInstance("SHA-256").digest(credential.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is unavailable", e); + } + } + private static IOException invalid() { return new IOException("Control credential file is missing or invalid"); } + + /** Non-secret durable request for a supervising Control host to install. */ + public record PendingAutoEnrollment(Path credentialFile, String configuredPath, String nodeId, String verifier) { } + + public record AutoEnrollmentInspection(boolean credentialPresent, PendingAutoEnrollment pending) { } } diff --git a/VotingPlugin/src/main/resources/Config.yml b/VotingPlugin/src/main/resources/Config.yml index f5f3e29be4..c566b4070a 100644 --- a/VotingPlugin/src/main/resources/Config.yml +++ b/VotingPlugin/src/main/resources/Config.yml @@ -1160,8 +1160,8 @@ Webhooks: ########################################### # VotingPlugin Control backend connector # Optional outbound connection for full local WebUI configuration control. -# Create a node credential with Control's `enroll` owner command and place it -# in the VotingPlugin data folder. The credential is never stored in this YAML. +# A locally hosted Control, or a proxy-hosted Control reached with PLUGINMESSAGING, +# generates and enrolls credentials automatically. The credential never enters this YAML. ########################################### Control: @@ -1185,10 +1185,13 @@ Control: DownloadTimeoutSeconds: 60 Backend: Enabled: false - # Must be unique for every Bukkit/Paper backend in the network. + # Blank reuses BungeeSettings.Server, which must be unique for every backend. NodeId: '' + # For a proxy-hosted Control, use the proxy VM/private IP. Loopback is accepted only for Control hosted by this backend. Endpoint: 'http://127.0.0.1:8080' - # VotingPlugin creates this blank file automatically; paste only the enrollment credential into it. + # VotingPlugin automatically generates this local credential only after confirming it can enroll through + # the hosted Control on this server or the proxy's authenticated, route-verified plugin-message path. + # Unsupported/external topologies retain the blank file for manual enrollment. CredentialFile: 'control/control-credential.txt' HeartbeatSeconds: 30 ConnectTimeoutMillis: 3000 diff --git a/VotingPlugin/src/main/resources/bungeeconfig.yml b/VotingPlugin/src/main/resources/bungeeconfig.yml index 8945a8e1db..0a053a97fe 100644 --- a/VotingPlugin/src/main/resources/bungeeconfig.yml +++ b/VotingPlugin/src/main/resources/bungeeconfig.yml @@ -484,10 +484,11 @@ MultiProxyServers: Control: Enabled: false Endpoint: 'http://127.0.0.1:8080' - # Blank reuses ProxyServerName. This must match the identity enrolled in Control. + # Blank reuses ProxyServerName. NodeId: '' - # Relative to the VotingPlugin proxy data folder. VotingPlugin creates it blank automatically. - # Paste only the enrollment credential into this file. + # Relative to the VotingPlugin proxy data folder. When this connector points to its own + # hosted Control listener, VotingPlugin generates and enrolls this credential automatically. + # External Control installations still create a blank file for manual enrollment. CredentialFile: 'control/control-credential.txt' HeartbeatSeconds: 30 ConnectTimeoutMillis: 3000 diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/HostedControlManagerTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/HostedControlManagerTest.java index 3bf150f3e2..7b1f4ce4cb 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/HostedControlManagerTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/HostedControlManagerTest.java @@ -28,6 +28,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import com.bencodez.votingplugin.util.ControlCredentialFile; import com.sun.net.httpserver.HttpServer; class HostedControlManagerTest { @@ -61,6 +62,23 @@ void platformNeutralFactoryKeepsHostingOptIn() { () -> HostedControlManager.create(directory, invalidHost, message -> { })); } + @Test + void localEndpointMatchesLoopbackOrTheConfiguredListenerAddress() { + HostedControlManager.HostConfiguration hosted = new HostedControlManager.HostConfiguration(true, false, + false, "", "0".repeat(64), "control/control.jar", "control/data", "10.0.0.5", 2150, 30, 60); + + assertFalse(HostedControlManager.isDirectLocalEndpoint("http://127.0.0.1:2150", hosted)); + assertTrue(HostedControlManager.isDirectLocalEndpoint("http://10.0.0.5:2150", hosted)); + assertFalse(HostedControlManager.isDirectLocalEndpoint("http://10.0.0.6:2150", hosted)); + assertFalse(HostedControlManager.isDirectLocalEndpoint("https://10.0.0.5:2150", hosted)); + HostedControlManager.HostConfiguration ipv6 = new HostedControlManager.HostConfiguration(true, false, + false, "", "0".repeat(64), "control/control.jar", "control/data", "::1", 2150, 30, 60); + assertTrue(HostedControlManager.isDirectLocalEndpoint("http://[::1]:2150", ipv6)); + HostedControlManager.HostConfiguration wildcard = new HostedControlManager.HostConfiguration(true, false, + false, "", "0".repeat(64), "control/control.jar", "control/data", "0.0.0.0", 2150, 30, 60); + assertTrue(HostedControlManager.isDirectLocalEndpoint("http://127.0.0.1:2150", wildcard)); + } + @Test void hostedArtifactBodyReadCannotOutliveDownloadTimeout() throws Exception { CountDownLatch bodyStarted = new CountDownLatch(1); @@ -179,6 +197,102 @@ void missingPinnedArtifactIsStagedVerifiedAndStarted() throws Exception { assertTrue(launcher.processes.get(0).destroyed); } + @Test + void localEnrollmentInstallsVerifierBeforeTheHostedProcessStarts() throws Exception { + byte[] release = "release-with-enrollment-command".getBytes(StandardCharsets.UTF_8); + Path root = directory.toAbsolutePath().normalize(); + Path jar = root.resolve("control/control.jar"); + Files.createDirectories(jar.getParent()); + Files.write(jar, release); + HostedControlManager.Settings settings = settings(jar, root.resolve("control/data"), false, false, + digest(release), 30); + ControlCredentialFile.PendingAutoEnrollment pending = ControlCredentialFile.prepareAutoEnrollment(root, + "control/control-credential.txt", "proxy-a"); + AtomicInteger installations = new AtomicInteger(); + FakeLauncher launcher = new FakeLauncher(); + ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + HostedControlManager manager = new HostedControlManager(settings, executor, + (source, target, maximum, timeout) -> { throw new AssertionError("download was not expected"); }, + launcher, (configured, artifact, nodeId, verifier, timeout) -> { + assertTrue(launcher.launchedContents.isEmpty()); + assertEquals("proxy-a", nodeId); + assertEquals(pending.verifier(), verifier); + installations.incrementAndGet(); + }, (endpoint, timeout, launchId) -> true, millis -> { }, System::nanoTime, pending, message -> { }); + + manager.runOnce(); + + assertEquals(HostedControlManager.Status.RUNNING, manager.status()); + assertEquals(1, installations.get()); + assertFalse(Files.exists(root.resolve("control/control-credential.txt.auto-enroll"))); + manager.close(); + } + + @Test + void runningHostAcceptsVerifierOnlyBackendEnrollment() throws Exception { + byte[] release = "release-with-enrollment-command".getBytes(StandardCharsets.UTF_8); + Path root = directory.toAbsolutePath().normalize(); + Path jar = root.resolve("control/control.jar"); + Files.createDirectories(jar.getParent()); + Files.write(jar, release); + HostedControlManager.Settings settings = settings(jar, root.resolve("control/data"), false, false, + digest(release), 30); + settings = new HostedControlManager.Settings(settings.rootDirectory(), settings.jarFile(), + settings.dataDirectory(), settings.autoDownload(), settings.autoUpdate(), settings.downloadUri(), + settings.sha256(), "10.0.0.5", 8080, settings.startupTimeoutSeconds(), + settings.downloadTimeoutSeconds()); + AtomicInteger installations = new AtomicInteger(); + ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + HostedControlManager manager = new HostedControlManager(settings, executor, + (source, target, maximum, timeout) -> { throw new AssertionError("download was not expected"); }, + new FakeLauncher(), (configured, artifact, nodeId, verifier, timeout) -> installations.incrementAndGet(), + (endpoint, timeout, launchId) -> true, millis -> { }, System::nanoTime, null, message -> { }); + manager.runOnce(); + + assertTrue(manager.installNodeVerifier("survival", "", "http://10.0.0.5:8080") + .get(5, TimeUnit.SECONDS)); + assertEquals(0, installations.get()); + assertTrue(manager.installNodeVerifier("survival", "a".repeat(64), "http://10.0.0.5:8080") + .get(5, TimeUnit.SECONDS)); + assertEquals(1, installations.get()); + assertFalse(manager.installNodeVerifier("../invalid", "a".repeat(64), "http://10.0.0.5:8080") + .get(5, TimeUnit.SECONDS)); + assertFalse(manager.installNodeVerifier("survival", "b".repeat(64), "http://external.example:8080") + .get(5, TimeUnit.SECONDS)); + manager.close(); + } + + @Test + void wildcardHostProvesRequestedRouteReachesTheRunningLaunch() throws Exception { + byte[] release = "release-with-enrollment-command".getBytes(StandardCharsets.UTF_8); + Path root = directory.toAbsolutePath().normalize(); + Path jar = root.resolve("control/wildcard-control.jar"); + Files.createDirectories(jar.getParent()); + Files.write(jar, release); + HostedControlManager.Settings base = settings(jar, root.resolve("control/wildcard-data"), false, false, + digest(release), 30); + HostedControlManager.Settings settings = new HostedControlManager.Settings(base.rootDirectory(), base.jarFile(), + base.dataDirectory(), base.autoDownload(), base.autoUpdate(), base.downloadUri(), base.sha256(), + "0.0.0.0", 2150, base.startupTimeoutSeconds(), base.downloadTimeoutSeconds()); + AtomicInteger installations = new AtomicInteger(); + ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + HostedControlManager manager = new HostedControlManager(settings, executor, + (source, target, maximum, timeout) -> { throw new AssertionError("download was not expected"); }, + new FakeLauncher(), (configured, artifact, nodeId, verifier, timeout) -> installations.incrementAndGet(), + (endpoint, timeout, launchId) -> !"external.example".equals(endpoint.getHost()), + millis -> { }, System::nanoTime, null, message -> { }); + manager.runOnce(); + + assertTrue(manager.installNodeVerifier("survival", "a".repeat(64), "http://10.0.0.5:2150") + .get(5, TimeUnit.SECONDS)); + assertFalse(manager.installNodeVerifier("creative", "b".repeat(64), "http://external.example:2150") + .get(5, TimeUnit.SECONDS)); + assertFalse(manager.installNodeVerifier("loopback", "c".repeat(64), "http://127.0.0.1:2150") + .get(5, TimeUnit.SECONDS)); + assertEquals(1, installations.get()); + manager.close(); + } + @Test void failedUpdateRollsBackAndStartsPreviousRelease() throws Exception { byte[] previous = "previous-release".getBytes(StandardCharsets.UTF_8); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java index 4ca9342110..e8e59cba4e 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java @@ -178,4 +178,44 @@ public void chunkedPresenceSnapshotPreservesChunkMetadata() { assertEquals(1000L, snapshot.backendStartedAt); assertEquals(1300L, snapshot.presenceTimestamp); } + + @Test + public void controlEnrollmentRoundTripContainsVerifierIdentityAndRoute() { + UUID requestId = UUID.randomUUID(); + String verifier = "a".repeat(64); + + VotingPluginWire.ControlEnrollmentRequest request = VotingPluginWire.readControlEnrollmentRequest( + VotingPluginWire.controlEnrollmentRequest("survival", verifier, "http://10.0.0.5:2150", requestId)); + VotingPluginWire.ControlEnrollmentResult result = VotingPluginWire.readControlEnrollmentResult( + VotingPluginWire.controlEnrollmentResult("survival", requestId, true)); + + assertTrue(request.valid); + assertEquals("survival", request.nodeId); + assertEquals(verifier, request.verifier); + assertEquals("http://10.0.0.5:2150", request.endpoint); + assertEquals(requestId, request.requestId); + assertTrue(result.valid); + assertTrue(result.success); + assertEquals(requestId, result.requestId); + } + + @Test + public void blankVerifierIsAValidHostedRoutePreflight() { + VotingPluginWire.ControlEnrollmentRequest request = VotingPluginWire.readControlEnrollmentRequest( + VotingPluginWire.controlEnrollmentRequest("survival", "", "http://10.0.0.5:2150", + UUID.randomUUID())); + + assertTrue(request.valid); + assertEquals("", request.verifier); + } + + @Test + public void malformedControlEnrollmentIsRejected() { + JsonEnvelope malformed = JsonEnvelope.builder(VotingPluginWire.SUB_CONTROL_ENROLLMENT_REQUEST) + .schema(VotingPluginWire.SCHEMA_VERSION).put(VotingPluginWire.K_NODE_ID, "../proxy") + .put(VotingPluginWire.K_VERIFIER, "not-a-verifier") + .put(VotingPluginWire.K_REQUEST_ID, "not-a-uuid").build(); + + assertFalse(VotingPluginWire.readControlEnrollmentRequest(malformed).valid); + } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/ControlCredentialFileTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/ControlCredentialFileTest.java index 80f707bc65..834bdf63d2 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/ControlCredentialFileTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/ControlCredentialFileTest.java @@ -1,6 +1,8 @@ package com.bencodez.votingplugin.util; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -16,7 +18,6 @@ class ControlCredentialFileTest { @TempDir Path directory; - @Test void createsBlankContainedCredentialFileBeforeEnrollment() throws Exception { Path root = Files.createDirectory(directory.resolve("plugin")); Path credential = root.resolve("control/control-credential.txt"); @@ -33,6 +34,77 @@ class ControlCredentialFileTest { } } + @Test void automaticEnrollmentSurvivesRestartUntilAcknowledged() throws Exception { + Path root = Files.createDirectory(directory.resolve("auto-plugin")); + String configured = "control/control-credential.txt"; + + ControlCredentialFile.PendingAutoEnrollment first = + ControlCredentialFile.prepareAutoEnrollment(root, configured, "proxy-a"); + String credential = ControlCredentialFile.read(root, configured); + ControlCredentialFile.PendingAutoEnrollment recovered = + ControlCredentialFile.prepareAutoEnrollment(root, configured, "proxy-a"); + + assertTrue(credential.startsWith("vpctl_node_")); + assertEquals(first.verifier(), recovered.verifier()); + assertTrue(Files.isRegularFile(root.resolve(configured + ".auto-enroll"))); + ControlCredentialFile.completeAutoEnrollment(recovered); + assertFalse(Files.exists(root.resolve(configured + ".auto-enroll"))); + ControlCredentialFile.completeAutoEnrollment(recovered); + assertNull(ControlCredentialFile.prepareAutoEnrollment(root, configured, "proxy-a")); + assertEquals(credential, ControlCredentialFile.read(root, configured)); + } + + @Test void inspectionNeverGeneratesAndRecoversOnlyMatchingPendingState() throws Exception { + Path root = Files.createDirectory(directory.resolve("inspection-plugin")); + String configured = "control/control-credential.txt"; + + ControlCredentialFile.AutoEnrollmentInspection blank = + ControlCredentialFile.inspectAutoEnrollment(root, configured); + assertFalse(blank.credentialPresent()); + assertNull(blank.pending()); + + ControlCredentialFile.PendingAutoEnrollment generated = + ControlCredentialFile.prepareAutoEnrollment(root, configured, "survival"); + ControlCredentialFile.AutoEnrollmentInspection pending = + ControlCredentialFile.inspectAutoEnrollment(root, configured); + assertTrue(pending.credentialPresent()); + assertEquals(generated, pending.pending()); + + Files.writeString(root.resolve(configured), "vpctl_node_manually_replaced"); + ControlCredentialFile.AutoEnrollmentInspection replaced = + ControlCredentialFile.inspectAutoEnrollment(root, configured); + assertTrue(replaced.credentialPresent()); + assertNull(replaced.pending()); + assertFalse(Files.exists(root.resolve(configured + ".auto-enroll"))); + } + + @Test void existingManualCredentialIsNeverReplaced() throws Exception { + Path root = Files.createDirectory(directory.resolve("manual-plugin")); + Path control = Files.createDirectories(root.resolve("control")); + Path credential = control.resolve("control-credential.txt"); + Files.writeString(credential, "vpctl_node_manually_enrolled"); + + assertNull(ControlCredentialFile.prepareAutoEnrollment(root, + "control/control-credential.txt", "proxy-a")); + assertEquals("vpctl_node_manually_enrolled", Files.readString(credential)); + assertFalse(Files.exists(control.resolve("control-credential.txt.auto-enroll"))); + } + + @Test void enrollmentMarkerContainsOnlyBoundedNonSecretState() throws Exception { + Path root = Files.createDirectory(directory.resolve("marker-plugin")); + String configured = "control/control-credential.txt"; + ControlCredentialFile.prepareAutoEnrollment(root, configured, "survival"); + Path credential = root.resolve(configured); + Path marker = root.resolve(configured + ".auto-enroll"); + + String secret = Files.readString(credential); + String state = Files.readString(marker); + assertFalse(state.contains(secret)); + Files.writeString(marker, state + "\nunexpected"); + assertThrows(java.io.IOException.class, + () -> ControlCredentialFile.prepareAutoEnrollment(root, configured, "survival")); + } + @Test void readsContainedFileAndRejectsFileDirectoryAndTraversalEscapes() throws Exception { Path root = Files.createDirectory(directory.resolve("plugin")); Path outside = Files.createDirectory(directory.resolve("outside")); diff --git a/docs/control-connector.md b/docs/control-connector.md index 7c2a68d56f..5edc994883 100644 --- a/docs/control-connector.md +++ b/docs/control-connector.md @@ -39,13 +39,13 @@ outside HTTPS, and paths outside VotingPlugin's data folder are rejected. Control: Enabled: true Endpoint: 'http://127.0.0.1:8080' - NodeId: 'proxy-a' + NodeId: '' CredentialFile: 'control/control-credential.txt' Hosted: Enabled: true AutoDownload: true AutoUpdate: false - DownloadUrl: 'https://github.com/BenCodez/VotingPlugin-Control/releases/download/v0.1.0/votingplugin-control.jar' + DownloadUrl: 'https://github.com/BenCodez/VotingPlugin-Control/releases/download/vX.Y.Z/votingplugin-control.jar' Sha256: '<64-character SHA-256 from the pinned release>' JarFile: 'control/votingplugin-control.jar' DataDirectory: 'control/data' @@ -64,7 +64,7 @@ Control: Enabled: true AutoDownload: true AutoUpdate: false - DownloadUrl: 'https://github.com/BenCodez/VotingPlugin-Control/releases/download/v0.1.0/votingplugin-control.jar' + DownloadUrl: 'https://github.com/BenCodez/VotingPlugin-Control/releases/download/vX.Y.Z/votingplugin-control.jar' Sha256: '<64-character SHA-256 from the pinned release>' JarFile: 'control/votingplugin-control.jar' DataDirectory: 'control/data' @@ -74,7 +74,7 @@ Control: DownloadTimeoutSeconds: 60 Backend: Enabled: true - NodeId: 'backend-lobby' + NodeId: '' Endpoint: 'http://127.0.0.1:8080' CredentialFile: 'control/control-credential.txt' HeartbeatSeconds: 30 @@ -90,35 +90,31 @@ previous release, retaining the failed candidate as `.failed`. Unexpected exits The child receives only its bind host, port, contained data directory, and a random per-launch ID. Health must echo that ID, preventing an unrelated Control process on the same port from satisfying startup checks. It does not receive -VotingPlugin configuration or credentials in release metadata. Process output is written beside the hosted JAR and rotated at 1 MiB. Configure the -WebUI password directly against the hosted data directory (the prompt is not echoed and the password is not an argument): +VotingPlugin configuration or credentials in release metadata. Process output is written beside the hosted JAR and +rotated at 1 MiB. -```shell -java -jar plugins/VotingPlugin/control/votingplugin-control.jar web-password plugins/VotingPlugin/control/data -``` - -The WebUI is then available at `http://127.0.0.1:8080/`. Password rotation invalidates every existing browser session. -To expose a non-loopback listener, create either the WebUI password or an admin API token in -`Control.Hosted.DataDirectory` first and use HTTPS or a private tunnel; Control otherwise refuses the bind. +On first start, Control creates an owner-readable `web-setup-code.txt` inside `Control.Hosted.DataDirectory`. Open the +WebUI, copy that one-time value using the server file manager, and choose the WebUI password in the browser. The code is +consumed immediately, and no server command is required. Password rotation invalidates every existing browser session. +Use HTTPS or a private authenticated tunnel when exposing the listener outside a trusted private network. Manual installation remains supported: put a Control JAR at `JarFile`, configure its exact SHA-256, set `AutoDownload` and `AutoUpdate` false, and enable hosting. VotingPlugin will verify and supervise it without network access. ## Enrollment and configuration -1. Start Control, complete its one-time WebUI setup, and use the Node enrollment page to enroll the exact stable node ID. - -2. On the first connector start, VotingPlugin automatically creates the blank - `plugins/VotingPlugin/control/control-credential.txt` placeholder (or its platform-equivalent data path). Paste only - the generated enrollment credential into that file and restrict filesystem access to the Minecraft service account. +The easiest network setup hosts one Control instance on the proxy. Configure `bungeeconfig.yml` as below, using a Control +release that supports the `enroll-verifier` owner command. On first start VotingPlugin creates a random proxy credential +under `plugins/VotingPlugin/control/`, installs only its SHA-256 verifier into the hosted Control data directory, and starts +the connector. No enrollment command or credential copy is required. -3. Configure `bungeeconfig.yml`: +Configure `bungeeconfig.yml`: ```yaml Control: Enabled: true Endpoint: 'http://127.0.0.1:8080' - NodeId: 'proxy-a' + NodeId: '' CredentialFile: 'control/control-credential.txt' HeartbeatSeconds: 30 ConnectTimeoutMillis: 3000 @@ -126,10 +122,10 @@ Manual installation remains supported: put a Control JAR at `JarFile`, configure ``` `NodeId` may be blank to reuse the existing `ProxyServerName`, but every simultaneously connected proxy must have a unique, -stable enrolled identity. Explicitly setting it is recommended. `CredentialFile` must resolve inside VotingPlugin's data -directory. Its missing parent directories and blank placeholder are created automatically; credentials in URLs and -parent-directory traversal are rejected. The credential is never logged or placed in -plugin messages. +stable identity. Automatic enrollment is enabled only when the connector endpoint addresses that same hosted listener by +loopback or by its configured bind address and port. `CredentialFile` must resolve inside VotingPlugin's data directory. +Its missing parent directories are created automatically; credentials in URLs and parent-directory traversal are rejected. +The generated credential is never logged or passed to the child process. The endpoint must be an `http` or `https` origin without embedded credentials, query, fragment, or path. Plain HTTP is suitable only for loopback or a trusted private network. Use HTTPS or a private authenticated tunnel when crossing an @@ -146,21 +142,37 @@ Do not raise these by patching around validation; correct the topology or connec ### Bukkit full-configuration enrollment -Each backend is separately opt-in and separately enrolled so one node credential cannot impersonate another. Put its -credential in that backend's VotingPlugin data folder and configure `Config.yml`: +Each backend is separately opt-in and receives its own credential so one node cannot impersonate another. With +`BungeeMethod: PLUGINMESSAGING`, configure each backend's `Config.yml` and point `Endpoint` at the proxy-hosted listener: ```yaml Control: Backend: Enabled: true - NodeId: 'backend-lobby' - Endpoint: 'http://127.0.0.1:8080' + NodeId: '' + Endpoint: 'http://:8080' CredentialFile: 'control/control-credential.txt' HeartbeatSeconds: 30 ConnectTimeoutMillis: 3000 RequestTimeoutMillis: 10000 ``` +Use an address the backend itself can reach, normally the proxy VM/private IP. Proxy-mediated enrollment deliberately +rejects `localhost`, `127.0.0.0/8`, and IPv6 loopback because those addresses resolve to the backend rather than the proxy +when the processes run on different machines. + +Blank `NodeId` reuses `BungeeSettings.Server`. The backend generates the raw credential locally and sends only its SHA-256 +verifier over the plugin-message channel. Velocity/BungeeCord binds the request to the actual backend server connection, +requires that identity to equal `BungeeSettings.Server`, installs the verifier in its hosted Control, and returns a +non-secret acknowledgement. The pending marker is removed only after the backend also authenticates to its configured +Control endpoint, so an incorrect endpoint cannot strand a newly generated credential. The raw credential never leaves the +backend. Requests retry safely after restarts and while no player is available to carry plugin messages. + +If a Bukkit server hosts its own Control and its endpoint addresses that local listener, the same generation and verifier +installation happen locally. External Control installations, custom backend node IDs that differ from +`BungeeSettings.Server`, and non-plugin-message transports retain manual WebUI/owner-command enrollment; an existing +nonblank credential file is always treated as manually managed and is never replaced. + The Bukkit connector owns one daemon worker and performs no Control I/O on the server thread. It reports a bounded list of installed plugin names for WebUI command suggestions and negotiates `config.files.v1` and `config.quick-setup.v1`, then polls the same outbound operation queue as proxies. File apply schedules