Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -1127,6 +1127,38 @@ public synchronized void restartBackendControlConnector() {
}
}

/** Starts the connector for an admitted enrollment without replacing its route challenge. */
public void startBackendControlConnectorForEnrollment(BackendControlAutoEnrollment enrollment) {
try {
backendControlConnectorLifecycle.execute(() -> {
BackendControlConnector replacement;
synchronized (this) {
if (backendControlConnectorStopping || backendControlAutoEnrollment != enrollment
|| backendControlConnector != null && !backendControlConnector.isClosed()) return;
}
try {
replacement = BackendControlConnector.create(this);
} catch (Exception e) {
getLogger().warning("[Control] Bukkit connector could not start after automatic enrollment: "
+ e.getMessage());
return;
}
if (replacement == null) return;
synchronized (this) {
if (backendControlConnectorStopping || backendControlAutoEnrollment != enrollment
|| backendControlConnector != null && !backendControlConnector.isClosed()) {
replacement.close();
return;
}
backendControlConnector = replacement;
replacement.start();
}
});
} catch (RejectedExecutionException e) {
getLogger().warning("[Control] Bukkit connector could not start after lifecycle shutdown");
}
}

private void reconcileBackendControlConnector() {
while (true) {
BackendControlConnector previous;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import java.io.IOException;
import java.util.UUID;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;

Expand Down Expand Up @@ -32,20 +35,28 @@ public final class BackendControlAutoEnrollment implements AutoCloseable {
private final String credentialFile;
private final String endpoint;
private final UUID requestId = UUID.randomUUID();
private final BungeeMethod method;
private final ControlEnrollmentAuthenticator authenticator;
private final AtomicBoolean closed = new AtomicBoolean();
private volatile BukkitTask retryTask;
private volatile ScheduledExecutorService retryExecutor;
private volatile ScheduledFuture<?> retryFuture;
private PendingAutoEnrollment enrollment;
private boolean verifierInstalled;
private boolean connectorAuthenticated;
private long verifierAcknowledgedAt;
private String routeChallenge = "";

private BackendControlAutoEnrollment(VotingPluginMain plugin, String nodeId, String credentialFile,
String endpoint, PendingAutoEnrollment enrollment) {
String endpoint, PendingAutoEnrollment enrollment, BungeeMethod method,
ControlEnrollmentAuthenticator authenticator) {
this.plugin = plugin;
this.nodeId = nodeId;
this.credentialFile = credentialFile;
this.endpoint = endpoint;
this.enrollment = enrollment;
this.method = method;
this.authenticator = authenticator;
}

/** Prepares a verifier that this Bukkit process's own hosted Control will install. */
Expand All @@ -59,14 +70,13 @@ public static PendingAutoEnrollment prepareLocal(VotingPluginMain plugin, HostCo
return prepare(plugin, control);
}

/** Prepares proxy-mediated enrollment only when source-bound plugin messaging is active. */
/** Prepares proxy-mediated enrollment through the configured backend transport. */
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) {
|| !plugin.getBungeeSettings().isUseBungeecoord()) {
return null;
}
String serverName = plugin.getOptions().getServer();
Expand All @@ -81,8 +91,11 @@ public static BackendControlAutoEnrollment create(VotingPluginMain plugin, HostC
PendingAutoEnrollment pending = inspection.pending();
if (pending == null && inspection.credentialPresent()) return null;
if (pending != null && !nodeId.equals(pending.nodeId())) pending = null;
BungeeMethod method = BungeeMethod.getByName(plugin.getBungeeSettings().getBungeeMethod());
ControlEnrollmentAuthenticator authenticator = method == BungeeMethod.PLUGINMESSAGING || method == BungeeMethod.HTTP
? null : ControlEnrollmentAuthenticator.load(plugin.getDataFolder().toPath().resolve("secretkey.key"));
return new BackendControlAutoEnrollment(plugin, nodeId, credentialFile,
endpoint == null ? "" : endpoint.trim(), pending);
endpoint == null ? "" : endpoint.trim(), pending, method, authenticator);
}

public static String configuredNodeId(VotingPluginMain plugin, ConfigurationSection control) {
Expand Down Expand Up @@ -110,45 +123,88 @@ private static String nodeId(VotingPluginMain plugin, ConfigurationSection contr
}

public synchronized void start() {
if (closed.get() || retryTask != null) return;
if (closed.get() || retryTask != null || retryFuture != null) return;
try {
retryTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::send, 1L, RETRY_TICKS);
if (method == BungeeMethod.PLUGINMESSAGING) {
retryTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sendSafely, 1L, RETRY_TICKS);
} else {
retryExecutor = Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(runnable, "VotingPlugin-ControlEnrollment");
thread.setDaemon(true);
return thread;
});
retryFuture = retryExecutor.scheduleWithFixedDelay(this::sendSafely, 0L, 15L, TimeUnit.SECONDS);
}
} catch (RuntimeException failure) {
ScheduledExecutorService executor = retryExecutor;
if (executor != null) executor.shutdownNow();
retryExecutor = null;
retryFuture = null;
closed.set(true);
plugin.getLogger().warning("[Control] Automatic backend enrollment could not be scheduled");
}
}

private void sendSafely() {
try {
send();
} catch (RuntimeException failure) {
plugin.getLogger().warning("[Control] Automatic backend enrollment send failed; it will retry");
plugin.debug(failure);
}
}

/** True while the proxy must confirm that this route reaches its hosted Control. */
public synchronized boolean isAwaitingCredential() {
return enrollment == null;
}

private void send() {
void send() {
PendingAutoEnrollment pending;
String challenge;
synchronized (this) {
if (closed.get() || (verifierInstalled
&& System.nanoTime() - verifierAcknowledgedAt < VERIFIER_REFRESH_NANOS)) return;
pending = enrollment;
challenge = routeChallenge;
}
BackendProxyHandler handler = plugin.getBackendProxyHandler();
if (handler == null || handler.getMethod() != BungeeMethod.PLUGINMESSAGING
|| handler.getGlobalMessageHandler() == null) return;
if (handler == null || handler.getGlobalMessageHandler() == null) return;
String verifier = pending == null ? "" : pending.verifier();
String proof = authenticator == null ? ""
: authenticator.signRequest(nodeId, requestId, endpoint, verifier, challenge);
if (closed.get()) return;
handler.getGlobalMessageHandler().sendMessage(VotingPluginWire.controlEnrollmentRequest(
nodeId, pending == null ? "" : pending.verifier(), endpoint, requestId));
nodeId, verifier, endpoint, requestId, challenge, proof));
}

public void handle(JsonEnvelope envelope) {
VotingPluginWire.ControlEnrollmentResult result = VotingPluginWire.readControlEnrollmentResult(envelope);
boolean restartConnector = false;
if (!result.valid || (authenticator != null && !authenticator.verifiesResult(result.authenticator,
result.nodeId, result.requestId, result.success, result.challenge))) return;
boolean ensureConnector = false;
synchronized (this) {
if (closed.get() || !result.valid || !result.success || !requestId.equals(result.requestId)
if (closed.get() || !requestId.equals(result.requestId)
|| !nodeId.equals(result.nodeId)) return;
if (enrollment == null) {
if (!result.challenge.isEmpty()) {
routeChallenge = result.challenge;
if (enrollment == null) {
try {
enrollment = prepare(plugin, credentialFile, nodeId);
if (enrollment == null) close();
} catch (IOException e) {
plugin.getLogger().warning(
"[Control] Automatic backend credential could not be prepared; it will retry");
}
}
ensureConnector = enrollment != null;
} else if (!result.success) {
return;
} else if (enrollment == null) {
try {
enrollment = prepare(plugin, credentialFile, nodeId);
if (enrollment == null) close();
restartConnector = true;
ensureConnector = enrollment != null;
} catch (IOException e) {
plugin.getLogger().warning(
"[Control] Automatic backend credential could not be prepared; it will retry");
Expand All @@ -159,7 +215,7 @@ public void handle(JsonEnvelope envelope) {
completeIfConnected();
}
}
if (restartConnector) plugin.restartBackendControlConnector();
if (ensureConnector) plugin.startBackendControlConnectorForEnrollment(this);
}

/** Completes durable enrollment only after this credential authenticated to the configured endpoint. */
Expand Down Expand Up @@ -193,5 +249,11 @@ public synchronized void close() {
BukkitTask task = retryTask;
if (task != null) task.cancel();
retryTask = null;
ScheduledFuture<?> future = retryFuture;
if (future != null) future.cancel(true);
retryFuture = null;
ScheduledExecutorService executor = retryExecutor;
if (executor != null) executor.shutdownNow();
retryExecutor = null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package com.bencodez.votingplugin.control;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.util.Base64;
import java.util.UUID;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

/** Authenticates proxy-mediated enrollment over shared, unbound transports. */
public final class ControlEnrollmentAuthenticator {
private static final String ALGORITHM = "HmacSHA256";
private static final String REQUEST_DOMAIN = "votingplugin-control-enrollment-request-v1";
private static final String RESULT_DOMAIN = "votingplugin-control-enrollment-result-v1";
private final byte[] key;

private ControlEnrollmentAuthenticator(byte[] key) {
this.key = key.clone();
}

public static ControlEnrollmentAuthenticator load(Path keyFile) throws IOException {
if (!Files.isRegularFile(keyFile)) {
throw new IOException("Control enrollment requires a shared secretkey.key");
}
try {
byte[] decoded = Base64.getDecoder().decode(Files.readString(keyFile, StandardCharsets.US_ASCII).trim());
if (decoded.length < 16) throw new IOException("Control enrollment key is too short");
return new ControlEnrollmentAuthenticator(decoded);
} catch (IllegalArgumentException invalid) {
throw new IOException("Control enrollment key is invalid", invalid);
}
}

public String signRequest(String nodeId, UUID requestId, String endpoint, String verifier, String challenge) {
try {
Mac mac = Mac.getInstance(ALGORITHM);
mac.init(new SecretKeySpec(key, ALGORITHM));
update(mac, REQUEST_DOMAIN);
update(mac, nodeId);
update(mac, requestId == null ? "" : requestId.toString());
update(mac, endpoint);
update(mac, verifier);
update(mac, challenge);
return java.util.HexFormat.of().formatHex(mac.doFinal());
} catch (GeneralSecurityException impossible) {
throw new IllegalStateException("HMAC-SHA256 is unavailable", impossible);
}
}

public String signResult(String nodeId, UUID requestId, boolean success, String challenge) {
try {
Mac mac = Mac.getInstance(ALGORITHM);
mac.init(new SecretKeySpec(key, ALGORITHM));
update(mac, RESULT_DOMAIN);
update(mac, nodeId);
update(mac, requestId == null ? "" : requestId.toString());
update(mac, Boolean.toString(success));
update(mac, challenge);
return java.util.HexFormat.of().formatHex(mac.doFinal());
} catch (GeneralSecurityException impossible) {
throw new IllegalStateException("HMAC-SHA256 is unavailable", impossible);
}
}

public boolean verifiesRequest(String authenticator, String nodeId, UUID requestId, String endpoint, String verifier,
String challenge) {
if (authenticator == null || !authenticator.matches("[0-9a-f]{64}")) return false;
byte[] expected = signRequest(nodeId, requestId, endpoint, verifier, challenge)
.getBytes(StandardCharsets.US_ASCII);
return MessageDigest.isEqual(expected, authenticator.getBytes(StandardCharsets.US_ASCII));
}

public boolean verifiesResult(String authenticator, String nodeId, UUID requestId, boolean success,
String challenge) {
if (authenticator == null || !authenticator.matches("[0-9a-f]{64}")) return false;
byte[] expected = signResult(nodeId, requestId, success, challenge).getBytes(StandardCharsets.US_ASCII);
return MessageDigest.isEqual(expected, authenticator.getBytes(StandardCharsets.US_ASCII));
}

private static void update(Mac mac, String value) {
byte[] bytes = value == null ? new byte[0] : value.getBytes(StandardCharsets.UTF_8);
mac.update((byte) (bytes.length >>> 24));
mac.update((byte) (bytes.length >>> 16));
mac.update((byte) (bytes.length >>> 8));
mac.update((byte) bytes.length);
mac.update(bytes);
}
}
Loading
Loading