Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b3cac6f
Add verified VotingPlugin staging service
BenCodez Sep 18, 2026
dec7cd1
Test verified VotingPlugin staging
BenCodez Sep 18, 2026
157ddde
Enable verified Control staging on Bukkit nodes
BenCodez Sep 18, 2026
c3be440
Cover deployment capability advertisement
BenCodez Sep 18, 2026
d89bdf5
Enable verified Control staging on proxy nodes
BenCodez Sep 18, 2026
f623288
Expose deterministic proxy capability advertisement
BenCodez Sep 18, 2026
607129f
Cover proxy deployment capability advertisement
BenCodez Sep 18, 2026
3369cb8
Harden verified deployment transport and idempotency
BenCodez Sep 18, 2026
187db97
Restrict backend deployment to active secure routes
BenCodez Sep 18, 2026
bca3ec4
Fence and secure proxy deployment polling
BenCodez Sep 18, 2026
ad2c992
Cover secure deployment and consumed Bukkit updates
BenCodez Sep 18, 2026
f938c9f
Document verified deployment protocol
BenCodez Sep 18, 2026
632058f
Document verified deployment protocol
BenCodez Sep 18, 2026
a2b1381
Fix verified deployment hardening build
BenCodez Sep 18, 2026
5e4a20e
Merge current master with deployment capability gates
BenCodez Sep 19, 2026
b12bf79
Complete queued proxy deployment on connector shutdown
BenCodez Sep 19, 2026
fa14482
Merge current master into verified update staging
BenCodez Sep 19, 2026
5766dbf
Recognize verified staged artifact across deployment retries
BenCodez Sep 19, 2026
ef2be52
Publish deployment marker before proxy activation
BenCodez Sep 19, 2026
9bd840f
Reject unsafe Bukkit update targets and unknown deployment fields
BenCodez Sep 20, 2026
0c5b725
Require atomic deployment publication
BenCodez Sep 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,11 @@ public class VotingPluginMain extends AdvancedCorePlugin {
@Getter
public static VotingPluginMain plugin;

/** Exact backend plugin file name Bukkit uses when consuming its update folder. */
public File getLoadedPluginJarFile() {
return getFile();
}

@Getter
@Setter
private ArrayList<CommandHandler> adminVoteCommand;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.control.BackendControlResultStore.Route;
import com.bencodez.votingplugin.control.BackendControlResultStore.StoredResult;
import com.bencodez.votingplugin.proxy.control.HostedControlManager;
import com.bencodez.votingplugin.proxy.control.HostedControlManager.HostConfiguration;
import com.bencodez.votingplugin.util.BoundedHttpBodyHandler;
import com.bencodez.votingplugin.util.ControlCredentialFile;
Expand All @@ -53,6 +54,7 @@ public final class BackendControlConnector implements AutoCloseable {
private static final Set<String> CAPABILITIES = Set.of("config.files.v1", "config.file-comments.v1",
"config.quick-setup.v1", "config.quick-setup.v2", "config.vote-sites-sync.v1",
"config.proxy-method.v1", "config.reward-files.v1", "data.inspect.v1");
private static final Set<String> DEPLOYMENT_TASK_FIELDS = Set.of("deploymentId", "artifactId", "sha256", "size", "attemptId");

private final VotingPluginMain plugin;
private final Path dataDirectory;
Expand All @@ -65,6 +67,8 @@ public final class BackendControlConnector implements AutoCloseable {
private final HttpClient http;
private final BackendConfigurationService configurations;
private final ControlInspectionService inspections;
private final PluginDeploymentService deployments;
private final boolean directLocalDeploymentEndpoint;
private final UUID sessionId = UUID.randomUUID();
private final Map<UUID, StoredResult> completed = new LinkedHashMap<>();
private final boolean recovering;
Expand All @@ -80,12 +84,14 @@ public final class BackendControlConnector implements AutoCloseable {
private volatile boolean voteSitesSyncAccepted;
private volatile boolean rewardFilesAccepted;
private volatile boolean inspectionsAccepted;
private volatile boolean deploymentsAccepted;
private volatile int inspectionFailures;
private volatile long inspectionRetryAtNanos;
private volatile int failures;
private volatile ScheduledFuture<?> scheduled;
private volatile ScheduledFuture<?> operationPolling;
private volatile ScheduledFuture<?> inspectionPolling;
private volatile ScheduledFuture<?> deploymentPolling;
private volatile Future<?> activeReload;
private volatile CompletableFuture<Void> activeOperation;

Expand Down Expand Up @@ -128,6 +134,22 @@ private BackendControlConnector(VotingPluginMain plugin, Path dataDirectory, Set
}
});
inspections = new ControlInspectionService(plugin);
directLocalDeploymentEndpoint = HostedControlManager.isDirectLocalEndpoint(
settings.endpoint().toString(), hostedConfiguration);
PluginDeploymentService prepared = null;
boolean deploymentEndpointAllowed = PluginDeploymentService.credentialEndpointAllowed(
settings.endpoint(), directLocalDeploymentEndpoint);
if (!recovering && deploymentEndpointAllowed) {
try {
prepared = PluginDeploymentService.backend(plugin.getServer().getUpdateFolderFile().toPath(),
plugin.getLoadedPluginJarFile().toPath());
} catch (Exception failure) {
plugin.getLogger().warning("[Control] Plugin deployment staging is unavailable; capability not advertised");
}
} else if (!recovering && !deploymentEndpointAllowed) {
plugin.getLogger().warning("[Control] Plugin deployment staging requires HTTPS unless Control is hosted directly on this node");
}
deployments = prepared;
}

private void reloadConfiguration(String fileName, String expectedContent) throws Exception {
Expand Down Expand Up @@ -268,6 +290,76 @@ public void start() {
OPERATION_POLL_MILLIS, OPERATION_POLL_MILLIS, TimeUnit.MILLISECONDS);
inspectionPolling = inspectionExecutor.scheduleWithFixedDelay(this::pollInspections,
OPERATION_POLL_MILLIS, OPERATION_POLL_MILLIS, TimeUnit.MILLISECONDS);
if (deployments != null) {
deploymentPolling = executor.scheduleWithFixedDelay(this::pollDeployments,
OPERATION_POLL_MILLIS, OPERATION_POLL_MILLIS, TimeUnit.MILLISECONDS);
}
}

/** Deployment is a separate leased lane; its large I/O never runs on Bukkit's primary thread. */
private void pollDeployments() {
if (closed || deployments == null || !registered || failures != 0 || !deploymentsAccepted
|| !running.compareAndSet(false, true)) return;
CompletableFuture<Void> operation = new CompletableFuture<>();
synchronized (operationLifecycle) {
if (closed) {
running.set(false);
return;
}
activeOperation = operation;
}
try {
claimAndDeploy();
} catch (Exception failure) {
registered = false;
failures = Math.min(30, failures + 1);
if (failures == 1 || failures % 10 == 0) {
plugin.getLogger().warning("[Control] Bukkit deployment polling unavailable; VotingPlugin remains active");
}
ScheduledFuture<?> heartbeat = scheduled;
if (heartbeat != null) heartbeat.cancel(false);
if (!closed) schedule(Math.min(TimeUnit.MINUTES.toMillis(5),
1000L << Math.min(failures - 1, 8)));
} finally {
operation.complete(null);
synchronized (operationLifecycle) {
if (activeOperation == operation) activeOperation = null;
}
running.set(false);
}
}

private void claimAndDeploy() throws Exception {
JsonObject body = new JsonObject();
body.addProperty("sessionId", sessionId.toString());
Response response = send("POST", "/api/v1/nodes/" + settings.nodeId() + "/deployments", body);
if (response.status() == 204 || closed) return;
JsonObject claimed = requireObject(response, 200);
PluginDeploymentService.Task task = deploymentTask(claimed);
PluginDeploymentService.Result result = deployments.deploy(task, settings.endpoint(), directLocalDeploymentEndpoint,
settings.nodeId(), sessionId, credential, http, Duration.ofMillis(settings.requestTimeoutMillis()),
() -> !closed);
if (closed) return;
JsonObject submitted = new JsonObject();
submitted.addProperty("sessionId", sessionId.toString());
submitted.addProperty("success", result.success());
submitted.addProperty("code", result.code());
submitted.addProperty("message", boundedResultMessage(result.message()));
submitted.addProperty("attemptId", task.attemptId().toString());
requireObject(send("POST", "/api/v1/nodes/" + settings.nodeId() + "/deployments/" + task.deploymentId()
+ "/result", submitted), 200);
}

static PluginDeploymentService.Task deploymentTask(JsonObject task) {
try {
if (task == null || task.size() != DEPLOYMENT_TASK_FIELDS.size()
|| !DEPLOYMENT_TASK_FIELDS.containsAll(task.keySet())) throw new IllegalArgumentException();
return new PluginDeploymentService.Task(UUID.fromString(string(task, "deploymentId")),
string(task, "artifactId"), string(task, "sha256"), Long.parseLong(string(task, "size")),
UUID.fromString(string(task, "attemptId")));
Comment thread
BenCodez marked this conversation as resolved.
} catch (RuntimeException failure) {
throw new IllegalArgumentException("deployment task is invalid");
}
}

/** Polls the separately negotiated read-only lane on the connector worker. */
Expand Down Expand Up @@ -393,6 +485,8 @@ private void cycle() {
&& negotiatedCapability(node, "config.reward-files.v1", rewardFilesAccepted);
boolean inspectionsWereAccepted = inspectionsAccepted;
inspectionsAccepted = negotiatedCapability(node, "data.inspect.v1", inspectionsAccepted);
deploymentsAccepted = deployments != null
&& negotiatedCapability(node, PluginDeploymentService.CAPABILITY, deploymentsAccepted);
if (inspectionsAccepted && !inspectionsWereAccepted) {
inspectionFailures = 0;
inspectionRetryAtNanos = 0;
Expand Down Expand Up @@ -445,6 +539,7 @@ private JsonObject register() throws Exception {
voteSitesSyncAccepted = false;
rewardFilesAccepted = false;
inspectionsAccepted = false;
deploymentsAccepted = false;
JsonObject body = sessionBody();
body.addProperty("nodeId", settings.nodeId());
body.addProperty("displayName", settings.nodeId());
Expand All @@ -455,13 +550,13 @@ private JsonObject register() throws Exception {
.map(installed -> installed.getDescription().getName()).filter(name -> name != null && !name.isBlank())
.distinct().sorted(String.CASE_INSENSITIVE_ORDER).limit(128).forEach(detectedPlugins::add);
body.add("detectedPlugins", detectedPlugins);
addCapabilities(body, configurations.supportsNamedRewardFiles());
addCapabilities(body, configurations.supportsNamedRewardFiles(), deployments != null);
return requireObject(send("POST", "/api/v1/nodes/register", body), 200, 201);
}

private JsonObject heartbeat() throws Exception {
JsonObject body = sessionBody();
addCapabilities(body, configurations.supportsNamedRewardFiles());
addCapabilities(body, configurations.supportsNamedRewardFiles(), deployments != null);
Response response = send("PUT", "/api/v1/nodes/" + settings.nodeId() + "/heartbeat", body);
if (response.status() == 404) {
registered = false;
Expand Down Expand Up @@ -880,10 +975,15 @@ private JsonObject sessionBody() {
return body;
}

static void addCapabilities(JsonObject body, boolean rewardFilesSupported) {
static void addCapabilities(JsonObject body) {
addCapabilities(body, false, false);
}

static void addCapabilities(JsonObject body, boolean rewardFilesSupported, boolean deploymentReady) {
JsonArray capabilities = new JsonArray();
CAPABILITIES.stream().sorted().filter(capability -> rewardFilesSupported
|| !"config.reward-files.v1".equals(capability)).forEach(capabilities::add);
if (deploymentReady) capabilities.add(PluginDeploymentService.CAPABILITY);
body.add("capabilities", capabilities);
JsonArray required = new JsonArray();
required.add("config.files.v1");
Expand Down Expand Up @@ -961,6 +1061,9 @@ public void close() {
if (polling != null) polling.cancel(false);
ScheduledFuture<?> inspection = inspectionPolling;
if (inspection != null) inspection.cancel(false);
ScheduledFuture<?> deployment = deploymentPolling;
if (deployment != null) deployment.cancel(false);
if (deployments != null) deployments.cancel();
inspectionExecutor.shutdownNow();
if (reload != null && Bukkit.isPrimaryThread()) reload.cancel(false);
awaitShutdown(executor, operation);
Expand Down
Loading
Loading