diff --git a/VotingPlugin/pom.xml b/VotingPlugin/pom.xml index 465e2f743..646b1e8ac 100644 --- a/VotingPlugin/pom.xml +++ b/VotingPlugin/pom.xml @@ -15,6 +15,7 @@ 21 21 3.4.0 + 1.85 @@ -151,6 +152,10 @@ ${project.groupId}.votingplugin.bstats + + org.bouncycastle + ${project.groupId}.votingplugin.bouncycastle + xyz.upperlevel.spigot @@ -254,6 +259,16 @@ + + org.bouncycastle + bcpkix-jdk18on + ${bouncycastle.version} + + + org.bouncycastle + bcprov-jdk18on + ${bouncycastle.version} + org.spigotmc spigot-api @@ -692,4 +707,4 @@ - \ No newline at end of file + diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 06cf263bd..867137251 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -1214,29 +1214,78 @@ public String getBackendHostedControlStatus() { } /** Recreates proxy transports after Control applies BungeeSettings.yml. */ - public synchronized void restartBackendProxyHandler() { + public void restartBackendProxyHandler() { + restartBackendProxyHandler(System.nanoTime() + TimeUnit.SECONDS.toNanos(25)); + } + + /** Recreates proxy transports while preserving the caller's end-to-end validation deadline. */ + public void restartBackendProxyHandler(long validationDeadlineNanos) { + BackendProxyRestart restart = prepareBackendProxyHandlerRestart(); + try { + validateBackendProxyHandlerRestart(restart, validationDeadlineNanos); + completeBackendProxyHandlerRestart(restart); + } catch (RuntimeException failure) { + abortBackendProxyHandlerRestart(restart); + throw failure; + } + } + + /** Prepared on the Bukkit thread, validated off-thread, then atomically published on Bukkit. */ + public static final class BackendProxyRestart { + private final BackendProxyHandler previous; + private final BackendProxyHandler replacement; + private final boolean disabled; + private final boolean previousPrepared; + private boolean finished; + + private BackendProxyRestart(BackendProxyHandler previous, BackendProxyHandler replacement, boolean disabled, + boolean previousPrepared) { + this.previous = previous; + this.replacement = replacement; + this.disabled = disabled; + this.previousPrepared = previousPrepared; + } + } + + public synchronized BackendProxyRestart prepareBackendProxyHandlerRestart() { BackendProxyHandler previous = backendProxyHandler; if (!bungeeSettings.isUseBungeecoord()) { - backendProxyHandler = null; - if (previous != null) previous.close(); - BackendControlAutoEnrollment enrollment = backendControlAutoEnrollment; - backendControlAutoEnrollment = null; - if (enrollment != null) enrollment.close(); - return; + return new BackendProxyRestart(previous, null, true, false); } BungeeMethod replacementMethod = BungeeMethod.getByName(bungeeSettings.getBungeeMethod()); - if (previous != null) previous.prepareForReplacement(replacementMethod); + boolean previousPrepared = previous != null && previous.prepareForReplacement(replacementMethod); BackendProxyHandler replacement = new BackendProxyHandler(this, backendProcessedVoteCache); try { replacement.load(); - replacement.validateTransport(); - if (previous != null) previous.completeRedisHandoff(replacement); } catch (RuntimeException failure) { replacement.close(); + if (previousPrepared) previous.restoreAfterFailedReplacement(); throw failure; } - backendProxyHandler = replacement; - if (previous != null) previous.close(); + return new BackendProxyRestart(previous, replacement, false, previousPrepared); + } + + public void validateBackendProxyHandlerRestart(BackendProxyRestart restart, long validationDeadlineNanos) { + if (restart == null) throw new IllegalArgumentException("Backend proxy restart is required"); + if (restart.replacement != null) restart.replacement.validateTransport(validationDeadlineNanos); + } + + public synchronized void completeBackendProxyHandlerRestart(BackendProxyRestart restart) { + if (restart == null || restart.finished) throw new IllegalStateException("Backend proxy restart is no longer active"); + if (backendProxyHandler != restart.previous) throw new IllegalStateException("Backend proxy handler changed during restart"); + if (restart.disabled) { + backendProxyHandler = null; + if (restart.previous != null) restart.previous.close(); + BackendControlAutoEnrollment enrollment = backendControlAutoEnrollment; + backendControlAutoEnrollment = null; + if (enrollment != null) enrollment.close(); + restart.finished = true; + return; + } + if (restart.previous != null) restart.previous.completeRedisHandoff(restart.replacement); + backendProxyHandler = restart.replacement; + if (restart.previous != null) restart.previous.close(); + restart.finished = true; try { refreshBackendControlAutoEnrollment(); } catch (IOException e) { @@ -1244,6 +1293,15 @@ public synchronized void restartBackendProxyHandler() { } } + public synchronized void abortBackendProxyHandlerRestart(BackendProxyRestart restart) { + if (restart == null || restart.finished) return; + if (restart.replacement != null) restart.replacement.close(); + if (restart.previousPrepared && backendProxyHandler == restart.previous) { + restart.previous.restoreAfterFailedReplacement(); + } + restart.finished = true; + } + /** Keeps one plugin-message listener for the plugin lifetime and atomically swaps its active backend handler. */ public synchronized void activateBackendPluginMessageHandler(GlobalMessageHandler target) { backendPluginMessageTarget.set(target); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java index 8395c38b4..10062b023 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java @@ -102,18 +102,30 @@ public void close() { } /** Releases a same-method subscriber/listener before its replacement starts. */ - public void prepareForReplacement(BungeeMethod replacementMethod) { + public boolean prepareForReplacement(BungeeMethod replacementMethod) { if (method == replacementMethod && method != BungeeMethod.PLUGINMESSAGING && method != BungeeMethod.REDIS) { transportManager.prepareForReplacement(); + return method == BungeeMethod.HTTP; } + return false; + } + + /** Restores a prepared HTTP transport when its replacement fails validation. */ + public void restoreAfterFailedReplacement() { + transportManager.restorePreparedTransport(); } /** Fails a configuration apply when its selected transport did not initialize. */ public void validateTransport() { + validateTransport(System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(25)); + } + + /** Validates transport startup without extending the caller's existing deadline. */ + public void validateTransport(long deadlineNanos) { if (method == null || globalMessageHandler == null || presenceManager == null) { throw new IllegalStateException("Backend proxy handler initialization failed"); } - transportManager.validate(); + transportManager.validate(deadlineNanos); } /** Completes the no-loss/no-duplicate same-Redis subscriber handoff after validation. */ diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/http/HttpBackendTransportConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/http/HttpBackendTransportConnector.java new file mode 100644 index 000000000..f05f5eb61 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/http/HttpBackendTransportConnector.java @@ -0,0 +1,360 @@ +package com.bencodez.votingplugin.backendproxy.http; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.cert.X509Certificate; +import java.time.Clock; +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; + +/** Backend-side, persistent HTTP/1.1 long-poll connector. */ +public final class HttpBackendTransportConnector implements AutoCloseable { + public static final Duration CLIENT_TIMEOUT = Duration.ofSeconds(35); + static final int CALLBACK_QUEUE_CAPACITY = 128; + private volatile HttpClientCredentialStore.HttpClientProfile profile; + private final String serverId; + private final Consumer onEnvelope; + private volatile HttpClient client; + private volatile HttpClientCredentialStore.ClientCredential credential; + private final Path credentialDirectory; + private final HttpInboundDeliveryStore inboundDeliveries; + private final URI transportEndpoint; + private final ThreadPoolExecutor callbackExecutor; + private final AtomicBoolean running = new AtomicBoolean(); + private final CountDownLatch firstResponse = new CountDownLatch(1); + private final Object state = new Object(); + private final Object renewal = new Object(); + private final LinkedHashMap outgoing = new LinkedHashMap<>(); + private final Set received = new LinkedHashSet<>(), processing = new LinkedHashSet<>(); + private final ArrayDeque acknowledgements = new ArrayDeque<>(); + private final String session = UUID.randomUUID().toString(); + private volatile Thread poller; + private long sequence; + private volatile long nextRenewalCheckNanos; + + /** In-memory test constructor; production transport must use a directory-backed constructor. */ + HttpBackendTransportConnector(HttpConnectionCode code, String serverId, + HttpClientCredentialStore.ClientCredential credential, Consumer onEnvelope) throws Exception { + this(profile(code, serverId), credential, onEnvelope); + } + + /** In-memory test constructor; production transport must use a directory-backed constructor. */ + HttpBackendTransportConnector(HttpClientCredentialStore.EnrolledClient enrolled, Consumer onEnvelope) throws Exception { + this(enrolled, onEnvelope, null); + } + + /** In-memory test constructor; production transport must use a directory-backed constructor. */ + HttpBackendTransportConnector(HttpClientCredentialStore.HttpClientProfile profile, + HttpClientCredentialStore.ClientCredential credential, Consumer onEnvelope) throws Exception { + this(profile, credential, onEnvelope, null); + } + + private HttpBackendTransportConnector(HttpClientCredentialStore.EnrolledClient enrolled, Consumer onEnvelope, + Path credentialDirectory) throws Exception { + this(enrolled == null ? null : enrolled.profile(), enrolled == null ? null : enrolled.credential(), onEnvelope, credentialDirectory); + } + + private HttpBackendTransportConnector(HttpClientCredentialStore.HttpClientProfile profile, + HttpClientCredentialStore.ClientCredential credential, Consumer onEnvelope, Path credentialDirectory) throws Exception { + if (profile == null || credential == null || onEnvelope == null) throw new IllegalArgumentException("HTTP backend transport configuration is invalid"); + if (!matchesCredential(profile, credential)) throw new IllegalArgumentException("HTTP client certificate does not match transport profile"); + this.profile = profile; this.serverId = profile.serverId(); this.onEnvelope = onEnvelope; + this.credential = credential; + this.credentialDirectory = credentialDirectory; + inboundDeliveries = credentialDirectory == null ? null : new HttpInboundDeliveryStore(credentialDirectory); + if (inboundDeliveries != null) for (var entry : inboundDeliveries.snapshot().entrySet()) { + if (entry.getValue() == HttpInboundDeliveryStore.State.COMPLETED) { + received.add(entry.getKey()); + queueAck(entry.getKey()); + } + } + client = client(profile, credential); + transportEndpoint = profile.endpoint().resolve("v1/transport"); + // GlobalMessageHandler routes mutate backend vote state and must observe the + // wire order. One bounded lane preserves batch ordering without running work on + // the long-poll thread; bounded admission below backpressures this poller. + callbackExecutor = executor("VotingPlugin-HTTP-callback", 1, CALLBACK_QUEUE_CAPACITY); + } + + /** Convenience constructor for the owner-only credential directory produced by {@link #enroll}. */ + public HttpBackendTransportConnector(HttpConnectionCode code, String serverId, Path credentials, + Consumer onEnvelope) throws Exception { + this(HttpClientCredentialStore.loadEnrolled(credentials), onEnvelope, credentials); + if (code == null || !profile(code, serverId).equals(this.profile)) throw new IllegalArgumentException("HTTP transport profile does not match connection code"); + } + + /** Starts normal transport using only the persisted certificate and non-secret profile. */ + public HttpBackendTransportConnector(Path credentials, Consumer onEnvelope) throws Exception { + this(HttpClientCredentialStore.loadEnrolled(credentials), onEnvelope, credentials); + } + + /** Performs enrollment network I/O; call this from a connector/setup worker, never a platform main thread. */ + public static HttpClientCredentialStore.ClientCredential enroll(HttpConnectionCode code, String serverId, Path credentials) throws Exception { + if (code == null || credentials == null || serverId == null || !serverId.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}")) throw new IllegalArgumentException("Enrollment configuration is invalid"); + if (!code.serverId().equals(HttpTlsIdentity.canonicalServerId(serverId))) + throw new IllegalArgumentException("HTTP connection code belongs to a different backend"); + code.requireActive(Clock.systemUTC()); + byte[] payload = ("{\"server\":\"" + serverId + "\",\"token\":\"" + code.enrollmentToken() + "\"}").getBytes(StandardCharsets.UTF_8); + HttpClient client = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).followRedirects(HttpClient.Redirect.NEVER) + .connectTimeout(Duration.ofSeconds(5)).sslContext(HttpPinnedTls.clientContext(code)).build(); + HttpRequest request = HttpRequest.newBuilder(code.endpoint().resolve("v1/enroll")).timeout(CLIENT_TIMEOUT) + .header("Content-Type", "application/json").header("Cache-Control", "no-store").POST(HttpRequest.BodyPublishers.ofByteArray(payload)).build(); + LimitedResponse response = sendLimited(client, request); + if (response.statusCode() != 201) throw new IllegalArgumentException("Enrollment was rejected"); + HttpTlsIdentity.IssuedClientCertificate issued = HttpTransportProtocol.parseEnrollmentResponse(serverId, response.body()); + HttpClientCredentialStore.saveEnrolled(credentials, code, issued); return HttpClientCredentialStore.load(credentials); + } + + public void start() { + if (!running.compareAndSet(false, true)) return; + poller = new Thread(this::pollLoop, "VotingPlugin-HTTP-poll"); poller.setDaemon(true); poller.start(); + } + /** Waits for one authenticated, protocol-valid transport response. */ + public boolean awaitFirstResponse(long deadlineNanos) throws InterruptedException { + long remaining = deadlineNanos - System.nanoTime(); + return remaining > 0L && firstResponse.await(remaining, TimeUnit.NANOSECONDS) && running.get(); + } + /** + * Inserts an in-memory at-least-once delivery. It survives retry/lost responses while this process remains alive; + * callers needing restart durability must retain the application operation independently. + */ + public boolean send(JsonEnvelope envelope) { + if (envelope == null || !running.get()) return false; + try { HttpTransportProtocol.validateEnvelope(envelope); } + catch (IllegalArgumentException invalid) { return false; } + synchronized (state) { + if (outgoing.size() >= HttpTransportProtocol.MAX_QUEUE) return false; + String id = UUID.randomUUID().toString(); outgoing.put(id, new HttpTransportProtocol.Delivery(id, envelope)); return true; + } + } + /** A synchronous single poll, useful for lifecycle-controlled integrations and tests. */ + public synchronized boolean pollOnce() { + if (!running.get()) return false; + List acks = List.of(); boolean acknowledgementsConfirmed = false; + try { + maybeRenewCredential(); + List messages; long requestSequence; + synchronized (state) { + acks = first(acknowledgements); requestSequence = sequence++; + messages = HttpTransportProtocol.fittingMessages(serverId, session, requestSequence, acks, outgoing.values()); + for (int index = 0; index < acks.size(); index++) acknowledgements.removeFirst(); + } + HttpRequest request = HttpRequest.newBuilder(transportEndpoint).timeout(CLIENT_TIMEOUT).header("Content-Type", "application/json") + .header("Cache-Control", "no-store").POST(HttpRequest.BodyPublishers.ofByteArray(HttpTransportProtocol.request(serverId, session, requestSequence, acks, messages))).build(); + LimitedResponse response = sendLimited(client, request); + if (response.statusCode() != 200) return false; + HttpTransportProtocol.Packet packet = HttpTransportProtocol.parsePacket(response.body()); + if (!serverId.equals(packet.server()) || !session.equals(packet.session()) || packet.sequence() != requestSequence) return false; + confirmAcknowledgements(acks); + acknowledgementsConfirmed = true; + synchronized (state) { for (String ack : packet.acks()) outgoing.remove(ack); } + for (HttpTransportProtocol.Delivery delivery : accept(packet.messages())) dispatch(delivery); + firstResponse.countDown(); + return true; + } catch (Exception failure) { return false; + } finally { if (!acknowledgementsConfirmed) requeueAcknowledgements(acks); } + } + @Override public void close() { + running.getAndSet(false); + firstResponse.countDown(); + // Revoke this connector's journal writer before a replacement snapshots it. + // In-flight transitions serialize with seal(): either COMPLETED is already + // durable, or the delivery remains durably RUNNING and fail-closed. + if (inboundDeliveries != null) inboundDeliveries.seal(); + Thread current = poller; if (current != null) current.interrupt(); + callbackExecutor.shutdown(); try { if (!callbackExecutor.awaitTermination(5, TimeUnit.SECONDS)) callbackExecutor.shutdownNow(); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); callbackExecutor.shutdownNow(); } + } + + private void pollLoop() { + long retry = 1000L; + while (running.get()) { if (pollOnce()) { retry = 1000L; continue; } try { Thread.sleep(retry); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); break; } retry = Math.min(30_000L, retry * 2); } + } + List accept(List deliveries) { + synchronized (state) { + List accepted = new java.util.ArrayList<>(); + for (HttpTransportProtocol.Delivery delivery : deliveries) { + HttpInboundDeliveryStore.State persisted = inboundDeliveries == null ? null : inboundDeliveries.state(delivery.id()); + if (received.contains(delivery.id()) || persisted == HttpInboundDeliveryStore.State.COMPLETED) { + received.add(delivery.id()); queueAck(delivery.id()); continue; + } + // A callback that was running when the process stopped may already have + // produced external side effects. Keep the proxy copy without replaying or + // acknowledging it; arbitrary plugin callbacks cannot share this journal. + if (persisted == HttpInboundDeliveryStore.State.RUNNING) continue; + if (!processing.contains(delivery.id())) { + processing.add(delivery.id()); accepted.add(delivery); + } + } + return accepted; + } + } + void dispatch(HttpTransportProtocol.Delivery delivery) { + Runnable callback = () -> { + boolean success = false; + try { + if (inboundDeliveries != null) { + if (inboundDeliveries.state(delivery.id()) == null) inboundDeliveries.reserve(delivery.id()); + inboundDeliveries.markRunning(delivery.id()); + } + onEnvelope.accept(delivery.envelope()); + if (inboundDeliveries != null) inboundDeliveries.markCompleted(delivery.id()); + success = true; + } catch (IOException persistenceFailure) { + // Never run before RUNNING is durable and never acknowledge until + // COMPLETED is durable. An uncertain transition stays fail-closed. + } catch (RuntimeException callbackFailure) { + // The callback may have failed after partial external effects. Leave RUNNING + // unacknowledged so a restart cannot silently lose or duplicate the delivery. + } + completeIncoming(delivery.id(), success); + }; + if (!executeOrdered(callbackExecutor, callback)) completeIncoming(delivery.id(), false); + } + void completeIncoming(String id, boolean success) { synchronized (state) { + processing.remove(id); + if (success) { received.add(id); while (received.size() > HttpTransportProtocol.MAX_QUEUE) received.remove(received.iterator().next()); queueAck(id); } + } } + private void queueAck(String id) { if (acknowledgements.size() < HttpTransportProtocol.MAX_QUEUE && !acknowledgements.contains(id)) acknowledgements.add(id); } + private void requeueAcknowledgements(List ids) { synchronized (state) { + for (int index = ids.size() - 1; index >= 0; index--) { + String id = ids.get(index); + if (!acknowledgements.contains(id)) { + while (acknowledgements.size() >= HttpTransportProtocol.MAX_QUEUE) acknowledgements.removeLast(); + acknowledgements.addFirst(id); + } + } + } } + private void confirmAcknowledgements(Collection ids) { + for (String id : ids) { + if (inboundDeliveries != null) try { inboundDeliveries.remove(id); } + catch (IOException cleanupFailure) { continue; } + synchronized (state) { received.remove(id); } + } + } + int queuedOutgoing() { synchronized (state) { return outgoing.size(); } } + List drainAcknowledgements() { synchronized (state) { return drain(acknowledgements); } } + private static List first(Collection values) { List output = new java.util.ArrayList<>(); for (T value : values) { output.add(value); if (output.size() == HttpTransportProtocol.MAX_BATCH) break; } return output; } + private static List drain(ArrayDeque values) { List output = new java.util.ArrayList<>(); while (!values.isEmpty() && output.size() < HttpTransportProtocol.MAX_BATCH) output.add(values.remove()); return output; } + private static ThreadPoolExecutor executor(String name, int threads, int queue) { ThreadFactory factory = task -> { Thread thread = new Thread(task, name); thread.setDaemon(true); return thread; }; return new ThreadPoolExecutor(threads, threads, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(queue), factory, new ThreadPoolExecutor.AbortPolicy()); } + static boolean executeOrdered(ThreadPoolExecutor executor, Runnable task) { + try { executor.execute(task); return true; } + catch (RejectedExecutionException fullOrClosed) { + if (executor.isShutdown()) return false; + try { + while (!executor.isShutdown()) { + if (!executor.getQueue().offer(task, 100L, TimeUnit.MILLISECONDS)) continue; + if (executor.isShutdown() && executor.remove(task)) return false; + return true; + } + } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + return false; + } + } + private static HttpClientCredentialStore.HttpClientProfile profile(HttpConnectionCode code, String serverId) { + if (code == null || serverId == null) throw new IllegalArgumentException("HTTP backend transport configuration is invalid"); + if (!code.serverId().equals(HttpTlsIdentity.canonicalServerId(serverId))) throw new IllegalArgumentException("HTTP connection code belongs to a different backend"); + return new HttpClientCredentialStore.HttpClientProfile(serverId, code.endpoint(), code.serverCertificatePin(), code.caCertificatePin()); + } + private static boolean matchesCredential(HttpClientCredentialStore.HttpClientProfile profile, + HttpClientCredentialStore.ClientCredential credential) { + try { + credential.certificate().checkValidity(); credential.certificate().verify(credential.caCertificate().getPublicKey()); + String expected = "urn:votingplugin:http-backend:" + profile.serverId(); + var names = credential.certificate().getSubjectAlternativeNames(); if (names == null) return false; + for (java.util.List name : names) if (name.size() == 2 && Integer.valueOf(6).equals(name.get(0)) && expected.equals(name.get(1))) return true; + return false; + } catch (Exception invalid) { return false; } + } + private void maybeRenewCredential() { + synchronized (renewal) { + Path directory = credentialDirectory; + if (directory == null || !HttpTlsIdentity.needsRenewal(credential.certificate(), Clock.systemUTC())) return; + long now = System.nanoTime(); + if (nextRenewalCheckNanos != 0L && now - nextRenewalCheckNanos < 0L) return; + nextRenewalCheckNanos = now + Duration.ofHours(6).toNanos(); + try { + byte[] body = HttpTransportProtocol.renewalRequest(serverId); + HttpRequest request = HttpRequest.newBuilder(profile.endpoint().resolve("v1/renew")).timeout(CLIENT_TIMEOUT) + .header("Content-Type", "application/json").header("Cache-Control", "no-store") + .POST(HttpRequest.BodyPublishers.ofByteArray(body)).build(); + LimitedResponse response = sendLimited(client, request); + if (response.statusCode() != 201) return; + HttpTlsIdentity.IssuedClientCertificate issued = HttpTransportProtocol.parseEnrollmentResponse(serverId, response.body()); + HttpClientCredentialStore.StagedCredential staged = HttpClientCredentialStore.stageReplacement(directory, issued); + HttpClientCredentialStore.ClientCredential replacement = staged.credential(); + HttpClientCredentialStore.HttpClientProfile replacementProfile = staged.profile(); + if (!matchesCredential(replacementProfile, replacement)) throw new IllegalArgumentException("Renewed HTTP certificate is invalid"); + HttpClient replacementClient = client(replacementProfile, replacement); + HttpClientCredentialStore.activateReplacement(directory, staged); + profile = replacementProfile; + client = replacementClient; + credential = replacement; + } catch (Exception ignored) { /* The active generation is unchanged; retry on the bounded schedule. */ } + } + } + private static HttpClient client(HttpClientCredentialStore.HttpClientProfile profile, + HttpClientCredentialStore.ClientCredential credential) throws Exception { + return HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).followRedirects(HttpClient.Redirect.NEVER) + .connectTimeout(Duration.ofSeconds(5)).sslContext(clientContext(profile, credential)).build(); + } + private static LimitedResponse sendLimited(HttpClient client, HttpRequest request) throws IOException, InterruptedException { + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()); + try (InputStream body = response.body()) { + long declaredLength = response.headers().firstValueAsLong("Content-Length").orElse(-1L); + if (declaredLength > HttpTransportProtocol.MAX_BODY_BYTES) + throw new IOException("HTTP transport response exceeds its limit"); + return new LimitedResponse(response.statusCode(), readLimited(body)); + } + } + static byte[] readLimited(InputStream body) throws IOException { + byte[] bytes = body.readNBytes(HttpTransportProtocol.MAX_BODY_BYTES + 1); + if (bytes.length > HttpTransportProtocol.MAX_BODY_BYTES) + throw new IOException("HTTP transport response exceeds its limit"); + return bytes; + } + private record LimitedResponse(int statusCode, byte[] body) { } + private static SSLContext clientContext(HttpClientCredentialStore.HttpClientProfile profile, HttpClientCredentialStore.ClientCredential credential) throws Exception { + String caPin = HttpTransportSecrets.certificatePin(credential.caCertificate()); + if (!HttpTransportSecrets.constantTimeEquals(profile.caCertificatePin().getBytes(StandardCharsets.US_ASCII), + caPin.getBytes(StandardCharsets.US_ASCII))) throw new IllegalArgumentException("HTTP authority does not match transport profile"); + char[] password = credential.password(); + try { + KeyStore store = KeyStore.getInstance("PKCS12"); store.load(null, new char[0]); + store.setKeyEntry("client", credential.privateKey(), password, + new java.security.cert.Certificate[] { credential.certificate(), credential.caCertificate() }); + KeyManagerFactory keys = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); keys.init(store, password); + KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); trustStore.load(null, new char[0]); + trustStore.setCertificateEntry("http-transport-ca", credential.caCertificate()); + javax.net.ssl.TrustManagerFactory trusts = javax.net.ssl.TrustManagerFactory.getInstance( + javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm()); + trusts.init(trustStore); + SSLContext context = SSLContext.getInstance("TLS"); context.init(keys.getKeyManagers(), trusts.getTrustManagers(), null); return context; + } finally { java.util.Arrays.fill(password, '\0'); } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/http/HttpClientCredentialStore.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/http/HttpClientCredentialStore.java new file mode 100644 index 000000000..518cf4ee7 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/http/HttpClientCredentialStore.java @@ -0,0 +1,293 @@ +package com.bencodez.votingplugin.backendproxy.http; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.PosixFilePermission; +import java.net.URI; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.Base64; +import java.util.EnumSet; +import java.util.Properties; +import com.bencodez.votingplugin.util.DurableFiles; + +/** Owner-only persistence for the client certificate bundle returned by enrollment. */ +public final class HttpClientCredentialStore { + private static final String BUNDLE_FILE = "http-transport-client.p12"; + private static final String PASSWORD_FILE = "http-transport-client-password"; + private static final String PROFILE_FILE = "http-transport-profile.properties"; + private static final String CONNECTION_CODE_DIGEST_FILE = "http-transport-connection-code.sha256"; + private static final String GENERATIONS_DIRECTORY = "http-transport-client-generations"; + private static final String CURRENT_FILE = "http-transport-client-current"; + private HttpClientCredentialStore() { } + + public static void save(Path directory, HttpTlsIdentity.IssuedClientCertificate issued) throws IOException { + if (issued == null) throw new IllegalArgumentException("Issued credential is required"); + Files.createDirectories(directory); + byte[] bundle = issued.pkcs12(); + try { writePrivate(safe(directory.resolve(BUNDLE_FILE)), bundle); } + finally { java.util.Arrays.fill(bundle, (byte) 0); } + char[] password = issued.password(); + try { writePrivate(safe(directory.resolve(PASSWORD_FILE)), asciiBytes(password)); } + finally { java.util.Arrays.fill(password, '\0'); } + } + + /** Persists the certificate plus the non-secret normal-transport profile after enrollment. */ + public static void saveEnrolled(Path directory, HttpConnectionCode code, HttpTlsIdentity.IssuedClientCertificate issued) + throws IOException { + if (code == null || issued == null) throw new IllegalArgumentException("Connection code and credential are required"); + HttpClientProfile profile = new HttpClientProfile(HttpTlsIdentity.canonicalServerId(issued.serverId()), code.endpoint(), + code.serverCertificatePin(), code.caCertificatePin()); + try { + StagedCredential staged = stage(directory, issued, profile, connectionCodeDigest(code)); + activateReplacement(directory, staged); + } catch (IOException failure) { throw failure; + } catch (Exception failure) { throw new IOException("Could not persist HTTP client credential", failure); } + } + + private static void writeProfile(Path directory, HttpClientProfile profile) throws IOException { + Properties properties = new Properties(); + properties.setProperty("version", "1"); + properties.setProperty("serverId", profile.serverId()); + properties.setProperty("endpoint", profile.endpoint().toASCIIString()); + properties.setProperty("serverPin", profile.serverCertificatePin()); + properties.setProperty("caPin", profile.caCertificatePin()); + java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream(); + properties.store(bytes, "VotingPlugin HTTP transport profile"); + writePrivate(safe(directory.resolve(PROFILE_FILE)), bytes.toByteArray()); + } + + public static ClientCredential load(Path directory) throws Exception { + return loadCredential(activeDirectory(directory)); + } + + private static ClientCredential loadCredential(Path directory) throws Exception { + Path bundle = safe(directory.resolve(BUNDLE_FILE)); + Path passwordFile = safe(directory.resolve(PASSWORD_FILE)); + if (!Files.isRegularFile(bundle, LinkOption.NOFOLLOW_LINKS) || !Files.isRegularFile(passwordFile, LinkOption.NOFOLLOW_LINKS)) + throw new IOException("HTTP client certificate has not been enrolled"); + byte[] passwordBytes = Files.readAllBytes(passwordFile); + if (passwordBytes.length < 40 || passwordBytes.length > 128) throw new IOException("HTTP client password is invalid"); + char[] password = new String(passwordBytes, StandardCharsets.US_ASCII).toCharArray(); + java.util.Arrays.fill(passwordBytes, (byte) 0); + try { + KeyStore store = KeyStore.getInstance("PKCS12"); + try (var input = Files.newInputStream(bundle, LinkOption.NOFOLLOW_LINKS)) { store.load(input, password); } + PrivateKey privateKey = (PrivateKey) store.getKey("client", password); + java.security.cert.Certificate[] chain = store.getCertificateChain("client"); + if (privateKey == null || chain == null || chain.length != 2 + || !(chain[0] instanceof X509Certificate client) || !(chain[1] instanceof X509Certificate authority)) + throw new IOException("HTTP client certificate bundle is invalid"); + return new ClientCredential(privateKey, client, authority, password); + } finally { java.util.Arrays.fill(password, '\0'); } + } + + /** Writes and validates a replacement generation without touching the active credential. */ + static StagedCredential stageReplacement(Path directory, HttpTlsIdentity.IssuedClientCertificate issued) throws Exception { + Path active = activeDirectory(directory); + return stage(directory, issued, loadProfileFile(active), readConnectionCodeDigest(active)); + } + + private static StagedCredential stage(Path directory, HttpTlsIdentity.IssuedClientCertificate issued, + HttpClientProfile profile, String connectionCodeDigest) throws Exception { + if (directory == null || issued == null) throw new IllegalArgumentException("Credential replacement is required"); + Path credentialDirectory = directory.toAbsolutePath().normalize(); + boolean created = !Files.exists(credentialDirectory, LinkOption.NOFOLLOW_LINKS); + Path generations = credentialDirectory.resolve(GENERATIONS_DIRECTORY); + Files.createDirectories(generations); + // Credential files cannot make the newly created credential-root entry durable. + // Persist its parent before an enrolled transport can activate this root. + if (created) DurableFiles.forceDirectory(credentialDirectory.getParent()); + if (Files.isSymbolicLink(generations) || !Files.isDirectory(generations, LinkOption.NOFOLLOW_LINKS)) + throw new IOException("HTTP credential generation directory is unsafe"); + String name = java.util.UUID.randomUUID().toString(); + Path generation = generations.resolve(name); + Files.createDirectory(generation); + setOwnerOnlyDirectory(generation); + try { + save(generation, issued); + ClientCredential replacement = loadCredential(generation); + profile = new HttpClientProfile(profile.serverId(), profile.endpoint(), profile.serverCertificatePin(), + HttpTransportSecrets.certificatePin(replacement.caCertificate())); + writeProfile(generation, profile); + if (connectionCodeDigest != null) writePrivate(safe(generation.resolve(CONNECTION_CODE_DIGEST_FILE)), + connectionCodeDigest.getBytes(StandardCharsets.US_ASCII)); + EnrolledClient enrolled = loadEnrolled(generation); + // Each file is durable within the generation, but the generation name is + // published by its parent. Persist it before CURRENT can activate it. + DurableFiles.forceDirectory(generations); + return new StagedCredential(name, enrolled.credential(), enrolled.profile()); + } catch (Exception failure) { + try { Files.deleteIfExists(generation.resolve(BUNDLE_FILE)); Files.deleteIfExists(generation.resolve(PASSWORD_FILE)); + Files.deleteIfExists(generation.resolve(PROFILE_FILE)); Files.deleteIfExists(generation.resolve(CONNECTION_CODE_DIGEST_FILE)); + Files.deleteIfExists(generation); } + catch (IOException cleanup) { failure.addSuppressed(cleanup); } + throw failure; + } + } + + /** Atomically makes a fully validated generation durable and active. */ + static void activateReplacement(Path directory, StagedCredential staged) throws IOException { + if (directory == null || staged == null || !staged.name().matches("[0-9a-f-]{36}")) + throw new IllegalArgumentException("Staged credential is invalid"); + Path generations = directory.toAbsolutePath().normalize().resolve(GENERATIONS_DIRECTORY); + Path generation = generations.resolve(staged.name()).normalize(); + if (!generation.getParent().equals(generations) || Files.isSymbolicLink(generation) + || !Files.isRegularFile(generation.resolve(BUNDLE_FILE), LinkOption.NOFOLLOW_LINKS) + || !Files.isRegularFile(generation.resolve(PASSWORD_FILE), LinkOption.NOFOLLOW_LINKS) + || !Files.isRegularFile(generation.resolve(PROFILE_FILE), LinkOption.NOFOLLOW_LINKS)) + throw new IOException("Staged HTTP credential is incomplete"); + writePrivate(safe(directory.resolve(CURRENT_FILE)), staged.name().getBytes(StandardCharsets.US_ASCII)); + } + + static record StagedCredential(String name, ClientCredential credential, HttpClientProfile profile) { } + + public static HttpClientProfile loadProfile(Path directory) throws IOException { + return loadProfileFile(activeDirectory(directory)); + } + + private static HttpClientProfile loadProfileFile(Path directory) throws IOException { + Path profile = safe(directory.resolve(PROFILE_FILE)); + if (!Files.isRegularFile(profile, LinkOption.NOFOLLOW_LINKS) || Files.size(profile) > 8192) + throw new IOException("HTTP transport profile has not been enrolled"); + Properties properties = new Properties(); + try (var input = Files.newInputStream(profile, LinkOption.NOFOLLOW_LINKS)) { properties.load(input); } + if (properties.size() != 5 || !"1".equals(properties.getProperty("version"))) + throw new IOException("HTTP transport profile is invalid"); + try { + return new HttpClientProfile(properties.getProperty("serverId"), URI.create(properties.getProperty("endpoint")), + properties.getProperty("serverPin"), properties.getProperty("caPin")); + } catch (IllegalArgumentException failure) { throw new IOException("HTTP transport profile is invalid", failure); } + } + + public static boolean hasEnrolledProfile(Path directory) { + try { loadEnrolled(directory); return true; } + catch (Exception unavailable) { return false; } + } + + /** Returns whether this exact one-time code created the active credential, without persisting the code itself. */ + public static boolean matchesEnrollmentCode(Path directory, HttpConnectionCode code) throws IOException { + if (code == null) throw new IllegalArgumentException("Connection code is required"); + String stored = readConnectionCodeDigest(activeDirectory(directory)); + if (stored == null) return false; + return HttpTransportSecrets.constantTimeEquals(stored.getBytes(StandardCharsets.US_ASCII), + connectionCodeDigest(code).getBytes(StandardCharsets.US_ASCII)); + } + + /** Loads and cross-checks the persisted client key material and bound normal-transport profile. */ + public static EnrolledClient loadEnrolled(Path directory) throws Exception { + Path active = activeDirectory(directory); + ClientCredential credential = loadCredential(active); + HttpClientProfile profile = loadProfileFile(active); + if (!matchesProfile(credential, profile)) throw new IOException("HTTP client certificate does not match its profile"); + return new EnrolledClient(profile, credential); + } + + public record ClientCredential(PrivateKey privateKey, X509Certificate certificate, X509Certificate caCertificate, char[] password) { + public ClientCredential { password = password.clone(); } + @Override public char[] password() { return password.clone(); } + } + + public record HttpClientProfile(String serverId, URI endpoint, String serverCertificatePin, String caCertificatePin) { + public HttpClientProfile { + serverId = HttpTlsIdentity.canonicalServerId(serverId); + HttpConnectionCode validation = new HttpConnectionCode(serverId, endpoint, serverCertificatePin, caCertificatePin, + java.time.Instant.now().plusSeconds(1), HttpTransportSecrets.randomToken()); + endpoint = validation.endpoint(); + serverCertificatePin = validation.serverCertificatePin(); + caCertificatePin = validation.caCertificatePin(); + } + } + + public record EnrolledClient(HttpClientProfile profile, ClientCredential credential) { } + + private static boolean matchesProfile(ClientCredential credential, HttpClientProfile profile) { + try { + String authorityPin = HttpTransportSecrets.certificatePin(credential.caCertificate()); + if (!HttpTransportSecrets.constantTimeEquals(profile.caCertificatePin().getBytes(StandardCharsets.US_ASCII), + authorityPin.getBytes(StandardCharsets.US_ASCII))) return false; + credential.certificate().checkValidity(); + credential.certificate().verify(credential.caCertificate().getPublicKey()); + java.util.List usage = credential.certificate().getExtendedKeyUsage(); + boolean[] keyUsage = credential.certificate().getKeyUsage(); + if (usage == null || !usage.contains(org.bouncycastle.asn1.x509.KeyPurposeId.id_kp_clientAuth.getId()) + || keyUsage == null || !keyUsage[0]) return false; + String expected = "urn:votingplugin:http-backend:" + profile.serverId(); + var names = credential.certificate().getSubjectAlternativeNames(); + if (names == null) return false; + for (java.util.List name : names) if (name.size() == 2 + && Integer.valueOf(6).equals(name.get(0)) && expected.equals(name.get(1))) return true; + return false; + } catch (Exception invalid) { return false; } + } + + private static Path safe(Path file) throws IOException { + if (Files.isSymbolicLink(file)) throw new IOException("Refusing unsafe HTTP credential path"); + return file.toAbsolutePath().normalize(); + } + + private static Path activeDirectory(Path directory) throws IOException { + Path root = directory.toAbsolutePath().normalize(); + Path current = safe(root.resolve(CURRENT_FILE)); + if (!Files.exists(current, LinkOption.NOFOLLOW_LINKS)) return root; + if (!Files.isRegularFile(current, LinkOption.NOFOLLOW_LINKS) || Files.size(current) > 64) + throw new IOException("HTTP client credential pointer is invalid"); + String name = Files.readString(current, StandardCharsets.US_ASCII); + if (!name.matches("[0-9a-f-]{36}")) throw new IOException("HTTP client credential pointer is invalid"); + Path generations = root.resolve(GENERATIONS_DIRECTORY); + Path generation = generations.resolve(name).normalize(); + if (!generation.getParent().equals(generations) || Files.isSymbolicLink(generation) || !Files.isDirectory(generation, LinkOption.NOFOLLOW_LINKS)) + throw new IOException("HTTP client credential generation is invalid"); + return generation; + } + + private static String readConnectionCodeDigest(Path directory) throws IOException { + Path digest = safe(directory.resolve(CONNECTION_CODE_DIGEST_FILE)); + if (!Files.exists(digest, LinkOption.NOFOLLOW_LINKS)) return null; + if (!Files.isRegularFile(digest, LinkOption.NOFOLLOW_LINKS) || Files.size(digest) != 64) + throw new IOException("HTTP connection-code marker is invalid"); + String value = Files.readString(digest, StandardCharsets.US_ASCII); + if (!value.matches("[0-9a-f]{64}")) throw new IOException("HTTP connection-code marker is invalid"); + return value; + } + + private static String connectionCodeDigest(HttpConnectionCode code) { + return HttpTransportSecrets.sha256Hex(code.encode().getBytes(StandardCharsets.US_ASCII)); + } + + private static void writePrivate(Path file, byte[] contents) throws IOException { + Path temporary = Files.createTempFile(file.getParent(), file.getFileName().toString(), ".tmp"); + try { + setOwnerOnly(temporary); + Files.write(temporary, contents, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING); + DurableFiles.forceFile(temporary); + try { Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } + catch (java.nio.file.AtomicMoveNotSupportedException unsupported) { Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING); } + setOwnerOnly(file); + DurableFiles.forceDirectory(file.getParent()); + } finally { Files.deleteIfExists(temporary); } + } + + private static void setOwnerOnly(Path path) throws IOException { + try { Files.setPosixFilePermissions(path, EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)); } + catch (UnsupportedOperationException ignored) { } + } + + private static void setOwnerOnlyDirectory(Path path) throws IOException { + try { Files.setPosixFilePermissions(path, EnumSet.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE)); } + catch (UnsupportedOperationException ignored) { } + } + + private static byte[] asciiBytes(char[] characters) { + byte[] output = new byte[characters.length]; + for (int index = 0; index < characters.length; index++) output[index] = (byte) characters[index]; + return output; + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/http/HttpConnectionCode.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/http/HttpConnectionCode.java new file mode 100644 index 000000000..5700147fb --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/http/HttpConnectionCode.java @@ -0,0 +1,98 @@ +package com.bencodez.votingplugin.backendproxy.http; + +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.util.Base64; +import java.util.Locale; + +/** + * A copy/paste connection code. It deliberately includes no long-lived credential: + * its only secret is a short-lived, single-use enrollment token. The trailing MAC is a + * corruption check keyed by that included token; it is not a proxy signature and cannot stop + * someone who can replace the whole code from replacing it with another valid code. + */ +public record HttpConnectionCode(String serverId, URI endpoint, String serverCertificatePin, String caCertificatePin, + Instant expiresAt, String enrollmentToken) { + private static final String VERSION = "VPH1"; + private static final int MAX_CODE_LENGTH = 4096; + + public HttpConnectionCode { + serverId = HttpTlsIdentity.canonicalServerId(serverId); + endpoint = validateEndpoint(endpoint); + serverCertificatePin = validatePin(serverCertificatePin, "server certificate pin"); + caCertificatePin = validatePin(caCertificatePin, "CA certificate pin"); + if (expiresAt == null) throw new IllegalArgumentException("Expiry is required"); + enrollmentToken = validateToken(enrollmentToken); + } + + public String encode() { + String endpointPart = Base64.getUrlEncoder().withoutPadding() + .encodeToString(endpoint.toASCIIString().getBytes(StandardCharsets.UTF_8)); + String unsigned = String.join(".", VERSION, serverId, endpointPart, serverCertificatePin, caCertificatePin, + Long.toString(expiresAt.getEpochSecond()), enrollmentToken); + byte[] token = Base64.getUrlDecoder().decode(enrollmentToken); + return unsigned + "." + HttpTransportSecrets.hmacSha256Url(token, unsigned); + } + + public boolean isActive(Clock clock) { + return expiresAt.isAfter(clock.instant()); + } + + public void requireActive(Clock clock) { + if (!isActive(clock)) throw new IllegalArgumentException("Connection code has expired"); + } + + public static HttpConnectionCode parse(String code) { + if (code == null || code.length() > MAX_CODE_LENGTH || code.indexOf('\n') >= 0 || code.indexOf('\r') >= 0) + throw new IllegalArgumentException("Connection code is invalid"); + String[] parts = code.split("\\.", -1); + if (parts.length != 8 || !VERSION.equals(parts[0])) throw new IllegalArgumentException("Connection code is invalid"); + try { + String endpoint = new String(Base64.getUrlDecoder().decode(parts[2]), StandardCharsets.UTF_8); + String unsigned = String.join(".", parts[0], parts[1], parts[2], parts[3], parts[4], parts[5], parts[6]); + byte[] token = Base64.getUrlDecoder().decode(parts[6]); + String expected = HttpTransportSecrets.hmacSha256Url(token, unsigned); + if (!HttpTransportSecrets.constantTimeEquals(expected.getBytes(StandardCharsets.US_ASCII), + parts[7].getBytes(StandardCharsets.US_ASCII))) throw new IllegalArgumentException("Connection code is invalid"); + return new HttpConnectionCode(parts[1], new URI(endpoint), parts[3], parts[4], Instant.ofEpochSecond(Long.parseLong(parts[5])), + parts[6]); + } catch (IllegalArgumentException | URISyntaxException failure) { + throw new IllegalArgumentException("Connection code is invalid", failure); + } + } + + private static URI validateEndpoint(URI value) { + if (value == null || !"https".equalsIgnoreCase(value.getScheme()) || value.getHost() == null + || value.getUserInfo() != null || value.getFragment() != null || value.getRawQuery() != null) + throw new IllegalArgumentException("Endpoint must be an absolute HTTPS URL without credentials or query"); + if (value.getPort() == 0 || value.getPort() > 65535 || value.getPort() < -1) + throw new IllegalArgumentException("Endpoint port is invalid"); + String path = value.getRawPath(); + if (path == null || path.isEmpty()) path = "/"; + if (!path.endsWith("/")) path += "/"; + try { + return new URI("https", null, value.getHost().toLowerCase(Locale.ROOT), value.getPort(), path, null, null); + } catch (URISyntaxException failure) { + throw new IllegalArgumentException("Endpoint is invalid", failure); + } + } + + private static String validatePin(String pin, String name) { + if (pin == null || !pin.matches("[0-9a-fA-F]{64}")) throw new IllegalArgumentException(name + " is invalid"); + return pin.toLowerCase(Locale.ROOT); + } + + private static String validateToken(String token) { + if (token == null || token.length() < 43 || token.length() > 128 || !token.matches("[A-Za-z0-9_-]+")) + throw new IllegalArgumentException("Enrollment token is invalid"); + try { + if (Base64.getUrlDecoder().decode(token).length < 32) throw new IllegalArgumentException("Enrollment token is invalid"); + return token; + } catch (IllegalArgumentException failure) { + throw new IllegalArgumentException("Enrollment token is invalid", failure); + } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/http/HttpEnrollmentAuthority.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/http/HttpEnrollmentAuthority.java new file mode 100644 index 000000000..719028472 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/http/HttpEnrollmentAuthority.java @@ -0,0 +1,212 @@ +package com.bencodez.votingplugin.backendproxy.http; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.PosixFilePermission; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.Properties; +import java.util.Base64; +import java.util.EnumSet; +import com.bencodez.votingplugin.util.DurableFiles; + +/** + * Single-use enrollment tokens and client-certificate binding. Token material is never retained; + * only SHA-256 hashes are kept until expiry. This type is thread-safe. + */ +public final class HttpEnrollmentAuthority { + private static final Duration MAX_ENROLLMENT_LIFETIME = Duration.ofMinutes(15); + private final HttpTlsIdentity identity; + private final Clock clock; + private final Path stateFile; + private final Map enrollments = new HashMap<>(); + private final Map bindings = new HashMap<>(); + private final Set revokedCertificatePins = new HashSet<>(); + private boolean persistenceFailure; + + /** Creates a restart-safe authority. State contains only public certificate pins and revocations. */ + public HttpEnrollmentAuthority(HttpTlsIdentity identity, Path stateDirectory) throws java.io.IOException { + this(identity, Clock.systemUTC(), stateFile(stateDirectory)); + loadState(); + } + + HttpEnrollmentAuthority(HttpTlsIdentity identity, Clock clock) { + this(identity, clock, null); + } + + HttpEnrollmentAuthority(HttpTlsIdentity identity, Clock clock, Path stateFile) { + if (identity == null || clock == null) throw new IllegalArgumentException("Identity and clock are required"); + this.identity = identity; + this.clock = clock; + this.stateFile = stateFile; + } + + public synchronized HttpConnectionCode createConnectionCode(String serverId, URI endpoint, Duration lifetime) { + serverId = HttpTlsIdentity.canonicalServerId(serverId); + if (lifetime == null || lifetime.isNegative() || lifetime.isZero() || lifetime.compareTo(MAX_ENROLLMENT_LIFETIME) > 0) + throw new IllegalArgumentException("Enrollment lifetime must be between one second and fifteen minutes"); + expireEnrollments(); + Instant expiresAt = clock.instant().plus(lifetime); + String token = HttpTransportSecrets.randomToken(); + byte[] tokenHash = HttpTransportSecrets.sha256(token.getBytes(StandardCharsets.US_ASCII)); + String lookup = java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(tokenHash); + enrollments.put(lookup, new Enrollment(tokenHash, expiresAt, serverId)); + return new HttpConnectionCode(serverId, endpoint, identity.serverCertificatePin(), identity.caCertificatePin(), expiresAt, token); + } + + public synchronized HttpTlsIdentity.IssuedClientCertificate enroll(String serverId, String enrollmentToken) throws Exception { + serverId = HttpTlsIdentity.canonicalServerId(serverId); + if (enrollmentToken == null || enrollmentToken.length() > 128) throw new IllegalArgumentException("Enrollment was rejected"); + expireEnrollments(); + byte[] suppliedHash = HttpTransportSecrets.sha256(enrollmentToken.getBytes(StandardCharsets.US_ASCII)); + String lookup = java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(suppliedHash); + Enrollment enrollment = enrollments.get(lookup); + if (enrollment == null || !HttpTransportSecrets.constantTimeEquals(enrollment.tokenHash(), suppliedHash)) + throw new IllegalArgumentException("Enrollment was rejected"); + if (!serverId.equals(enrollment.serverId())) throw new IllegalArgumentException("Enrollment was rejected"); + enrollments.remove(lookup); // consume only after the token and its intended backend both match. + ClientBinding existing = bindings.get(serverId); + if (existing != null && !existing.revoked()) throw new IllegalStateException("Server id is already enrolled"); + HttpTlsIdentity.IssuedClientCertificate issued = identity.issueClientCertificate(serverId); + bindings.put(serverId, new ClientBinding(HttpTransportSecrets.certificatePin(issued.certificate()), null, false)); + try { persistState(); } + catch (java.io.IOException failure) { persistenceFailure = true; throw failure; } + return issued; + } + + public synchronized boolean authenticate(String serverId, java.security.cert.X509Certificate certificate) { + if (persistenceFailure || serverId == null || certificate == null) return false; + try { serverId = HttpTlsIdentity.canonicalServerId(serverId); } + catch (IllegalArgumentException invalid) { return false; } + if (!identity.validClientCertificate(serverId, certificate)) return false; + ClientBinding binding = bindings.get(serverId); + String pin = HttpTransportSecrets.certificatePin(certificate); + if (binding == null || binding.revoked() || revokedCertificatePins.contains(pin)) return false; + if (samePin(binding.certificatePin(), pin)) return true; + if (!samePin(binding.pendingCertificatePin(), pin)) return false; + bindings.put(serverId, new ClientBinding(pin, null, false)); + revokedCertificatePins.add(binding.certificatePin()); + try { persistState(); return true; } + catch (java.io.IOException failure) { persistenceFailure = true; return false; } + } + + /** Issues a replacement while the currently bound certificate is still valid. The old binding remains active + * until the replacement successfully authenticates, making a lost renewal response safe to retry. */ + public synchronized HttpTlsIdentity.IssuedClientCertificate renew(String serverId, + java.security.cert.X509Certificate currentCertificate) throws Exception { + if (!authenticate(serverId, currentCertificate)) throw new IllegalArgumentException("Certificate renewal was rejected"); + serverId = HttpTlsIdentity.canonicalServerId(serverId); + ClientBinding binding = bindings.get(serverId); + HttpTlsIdentity.IssuedClientCertificate issued = identity.issueClientCertificate(serverId); + bindings.put(serverId, new ClientBinding(binding.certificatePin(), + HttpTransportSecrets.certificatePin(issued.certificate()), false)); + try { persistState(); } + catch (java.io.IOException failure) { persistenceFailure = true; throw failure; } + return issued; + } + + public synchronized void revoke(String serverId) { + try { serverId = HttpTlsIdentity.canonicalServerId(serverId); } + catch (IllegalArgumentException invalid) { return; } + final String revokedServer = serverId; + enrollments.entrySet().removeIf(entry -> revokedServer.equals(entry.getValue().serverId())); + ClientBinding binding = bindings.get(serverId); + if (binding != null) { + bindings.put(serverId, new ClientBinding(binding.certificatePin(), binding.pendingCertificatePin(), true)); + revokedCertificatePins.add(binding.certificatePin()); + if (binding.pendingCertificatePin() != null) revokedCertificatePins.add(binding.pendingCertificatePin()); + try { persistState(); } + catch (java.io.IOException failure) { persistenceFailure = true; throw new IllegalStateException("Could not persist HTTP certificate revocation", failure); } + } + } + + private synchronized void loadState() throws java.io.IOException { + if (stateFile == null || !Files.exists(stateFile, LinkOption.NOFOLLOW_LINKS)) return; + if (!Files.isRegularFile(stateFile, LinkOption.NOFOLLOW_LINKS) || Files.size(stateFile) > 65536) + throw new java.io.IOException("HTTP enrollment state is invalid"); + Properties properties = new Properties(); + try (var input = Files.newInputStream(stateFile, LinkOption.NOFOLLOW_LINKS)) { properties.load(input); } + for (String key : properties.stringPropertyNames()) { + if (key.startsWith("binding.")) { + String serverId = new String(Base64.getUrlDecoder().decode(key.substring("binding.".length())), StandardCharsets.UTF_8); + serverId = HttpTlsIdentity.canonicalServerId(serverId); + String[] value = properties.getProperty(key, "").split(":", -1); + if (!((value.length == 2 && "1".equals(properties.getProperty("version"))) + || (value.length == 3 && "2".equals(properties.getProperty("version")))) + || !value[0].matches("[0-9a-f]{64}")) + throw new java.io.IOException("HTTP enrollment state is invalid"); + String pending = value.length == 3 && !"-".equals(value[1]) ? value[1] : null; + String revoked = value[value.length - 1]; + if ((pending != null && !pending.matches("[0-9a-f]{64}")) || !("0".equals(revoked) || "1".equals(revoked))) + throw new java.io.IOException("HTTP enrollment state is invalid"); + bindings.put(serverId, new ClientBinding(value[0], pending, "1".equals(revoked))); + if ("1".equals(revoked)) { revokedCertificatePins.add(value[0]); if (pending != null) revokedCertificatePins.add(pending); } + } else if (!"version".equals(key)) throw new java.io.IOException("HTTP enrollment state is invalid"); + } + if (!("1".equals(properties.getProperty("version")) || "2".equals(properties.getProperty("version")))) + throw new java.io.IOException("HTTP enrollment state is invalid"); + } + + private synchronized void persistState() throws java.io.IOException { + if (stateFile == null) return; + Properties properties = new Properties(); + properties.setProperty("version", "2"); + for (Map.Entry entry : bindings.entrySet()) { + String key = Base64.getUrlEncoder().withoutPadding().encodeToString(entry.getKey().getBytes(StandardCharsets.UTF_8)); + properties.setProperty("binding." + key, entry.getValue().certificatePin() + ":" + + (entry.getValue().pendingCertificatePin() == null ? "-" : entry.getValue().pendingCertificatePin()) + + ":" + (entry.getValue().revoked() ? "1" : "0")); + } + java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream(); + properties.store(bytes, "VotingPlugin HTTP certificate bindings"); + Path temporary = Files.createTempFile(stateFile.getParent(), stateFile.getFileName().toString(), ".tmp"); + try { + setOwnerOnly(temporary); + Files.write(temporary, bytes.toByteArray(), StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING); + DurableFiles.forceFile(temporary); + try { Files.move(temporary, stateFile, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } + catch (java.nio.file.AtomicMoveNotSupportedException unsupported) { Files.move(temporary, stateFile, StandardCopyOption.REPLACE_EXISTING); } + setOwnerOnly(stateFile); + DurableFiles.forceDirectory(stateFile.getParent()); + } finally { Files.deleteIfExists(temporary); } + } + + private static Path stateFile(Path directory) throws java.io.IOException { + if (directory == null) throw new IllegalArgumentException("State directory is required"); + Files.createDirectories(directory); + Path file = directory.toAbsolutePath().normalize().resolve("http-transport-clients.properties"); + if (Files.isSymbolicLink(file)) throw new java.io.IOException("Refusing unsafe HTTP enrollment state path"); + return file; + } + + private static void setOwnerOnly(Path path) throws java.io.IOException { + try { Files.setPosixFilePermissions(path, EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)); } + catch (UnsupportedOperationException ignored) { } + } + + private void expireEnrollments() { + Instant now = clock.instant(); + enrollments.entrySet().removeIf(entry -> !entry.getValue().expiresAt().isAfter(now)); + } + + private record Enrollment(byte[] tokenHash, Instant expiresAt, String serverId) { + private Enrollment { tokenHash = tokenHash.clone(); } + @Override public byte[] tokenHash() { return tokenHash.clone(); } + } + private static boolean samePin(String expected, String actual) { + return expected != null && actual != null && HttpTransportSecrets.constantTimeEquals( + expected.getBytes(StandardCharsets.US_ASCII), actual.getBytes(StandardCharsets.US_ASCII)); + } + + private record ClientBinding(String certificatePin, String pendingCertificatePin, boolean revoked) { } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/http/HttpInboundDeliveryStore.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/http/HttpInboundDeliveryStore.java new file mode 100644 index 000000000..ba04a31ec --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/http/HttpInboundDeliveryStore.java @@ -0,0 +1,173 @@ +package com.bencodez.votingplugin.backendproxy.http; + +import com.bencodez.votingplugin.util.DurableFiles; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; +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.PosixFilePermission; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +/** Crash-durable state for proxy deliveries around a non-transactional application callback. */ +final class HttpInboundDeliveryStore { + private static final String DIRECTORY = "http-transport-inbound-deliveries"; + private static final int MAX_ENTRIES = HttpTransportProtocol.MAX_QUEUE; + private final Path root; + private final Map entries = new LinkedHashMap<>(); + private boolean sealed; + + HttpInboundDeliveryStore(Path credentialDirectory) throws IOException { + Path credentials = credentialDirectory.toAbsolutePath().normalize(); + if (Files.isSymbolicLink(credentials) || !Files.isDirectory(credentials, LinkOption.NOFOLLOW_LINKS)) + throw new IOException("HTTP credential directory is unsafe"); + ownerOnlyDirectory(credentials); + root = credentials.resolve(DIRECTORY).normalize(); + if (!root.getParent().equals(credentials)) throw new IOException("HTTP inbound delivery directory is invalid"); + boolean created = false; + try { Files.createDirectory(root); created = true; } + catch (java.nio.file.FileAlreadyExistsException existing) { } + try { + requireRoot(); + ownerOnlyDirectory(root); + } finally { + if (created) DurableFiles.forceDirectory(credentials); + } + load(); + } + + synchronized State state(String id) { return entries.get(canonical(id)); } + + synchronized void reserve(String id) throws IOException { + requireWritable(); + id = canonical(id); + State existing = entries.get(id); + if (existing == State.RESERVED) return; + if (existing != null) throw new IOException("HTTP inbound delivery fence is already active"); + if (entries.size() >= MAX_ENTRIES) throw new IOException("HTTP inbound delivery fence is full"); + requireRoot(); + Path target = file(id, State.RESERVED); + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) + throw new IOException("HTTP inbound delivery fence is inconsistent"); + Path temporary = Files.createTempFile(root, ".pending-", ".tmp"); + try { + ownerOnlyFile(temporary); + Files.writeString(temporary, id, StandardCharsets.US_ASCII, StandardOpenOption.TRUNCATE_EXISTING); + DurableFiles.forceFile(temporary); + move(temporary, target); + ownerOnlyFile(target); + DurableFiles.forceDirectory(root); + entries.put(id, State.RESERVED); + } finally { Files.deleteIfExists(temporary); } + } + + synchronized void markRunning(String id) throws IOException { transition(id, State.RESERVED, State.RUNNING); } + synchronized void markCompleted(String id) throws IOException { transition(id, State.RUNNING, State.COMPLETED); } + synchronized void seal() { sealed = true; } + + synchronized void remove(String id) throws IOException { + requireWritable(); + id = canonical(id); + State state = entries.get(id); + if (state == null) return; + requireRoot(); + DurableFiles.deleteIfExists(file(id, state)); + entries.remove(id); + } + + synchronized Map snapshot() { return Map.copyOf(entries); } + + private void transition(String id, State expected, State replacement) throws IOException { + requireWritable(); + id = canonical(id); + if (entries.get(id) != expected) throw new IOException("HTTP inbound delivery fence state is invalid"); + requireRoot(); + Path source = file(id, expected), target = file(id, replacement); + if (Files.isSymbolicLink(source) || !Files.isRegularFile(source, LinkOption.NOFOLLOW_LINKS) + || Files.exists(target, LinkOption.NOFOLLOW_LINKS)) + throw new IOException("HTTP inbound delivery fence state is unsafe"); + move(source, target); + DurableFiles.forceDirectory(root); + entries.put(id, replacement); + } + + private void load() throws IOException { + try (DirectoryStream files = Files.newDirectoryStream(root)) { + for (Path file : files) { + String name = file.getFileName().toString(); + if (name.startsWith(".pending-") && name.endsWith(".tmp") && !Files.isSymbolicLink(file) + && Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) { + DurableFiles.deleteIfExists(file); + continue; + } + State state = State.fromFileName(name); + if (state == null || Files.isSymbolicLink(file) || !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) + || Files.size(file) > 64L) + throw new IOException("HTTP inbound delivery fence contains an invalid entry"); + String id; + try { id = canonical(name.substring(0, name.length() - state.suffix.length())); } + catch (IllegalArgumentException invalid) { + throw new IOException("HTTP inbound delivery fence entry is invalid", invalid); + } + if (!name.equals(id + state.suffix) || !Files.readString(file, StandardCharsets.US_ASCII).equals(id)) + throw new IOException("HTTP inbound delivery fence entry is invalid"); + State existing = entries.get(id); + if (existing == null) entries.put(id, state); + else { + // A provider without atomic moves may expose both names after an + // interrupted transition. Preserve the furthest fail-closed state: + // RUNNING never replays, and COMPLETED alone may be acknowledged. + State retained = existing.ordinal() >= state.ordinal() ? existing : state; + State obsolete = retained == existing ? state : existing; + DurableFiles.deleteIfExists(file(id, obsolete)); + entries.put(id, retained); + } + if (entries.size() > MAX_ENTRIES) throw new IOException("HTTP inbound delivery fence exceeds its bound"); + } + } + } + + private Path file(String id, State state) { return root.resolve(id + state.suffix); } + private void requireWritable() throws IOException { + if (sealed) throw new IOException("HTTP inbound delivery store ownership has ended"); + } + private static void move(Path source, Path target) throws IOException { + try { Files.move(source, target, StandardCopyOption.ATOMIC_MOVE); } + catch (java.nio.file.AtomicMoveNotSupportedException unsupported) { Files.move(source, target); } + } + private static String canonical(String id) { + if (id == null) throw new IllegalArgumentException("HTTP delivery id is invalid"); + String canonical = UUID.fromString(id).toString(); + if (!canonical.equals(id)) throw new IllegalArgumentException("HTTP delivery id is not canonical"); + return canonical; + } + private void requireRoot() throws IOException { + if (Files.isSymbolicLink(root) || !Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) + throw new IOException("HTTP inbound delivery directory is unsafe"); + } + private static void ownerOnlyFile(Path path) throws IOException { + try { Files.setPosixFilePermissions(path, EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)); } + catch (UnsupportedOperationException ignored) { } + } + private static void ownerOnlyDirectory(Path path) throws IOException { + try { Files.setPosixFilePermissions(path, EnumSet.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE)); } + catch (UnsupportedOperationException ignored) { } + } + + enum State { + RESERVED(".reserved"), RUNNING(".running"), COMPLETED(".completed"); + private final String suffix; + State(String suffix) { this.suffix = suffix; } + private static State fromFileName(String name) { + for (State state : values()) if (name.endsWith(state.suffix)) return state; + return null; + } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/http/HttpPinnedTls.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/http/HttpPinnedTls.java new file mode 100644 index 000000000..9da95637d --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/http/HttpPinnedTls.java @@ -0,0 +1,103 @@ +package com.bencodez.votingplugin.backendproxy.http; + +import java.security.cert.X509Certificate; +import java.security.KeyStore; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; + +/** Builds the backend TLS context. A public CA store is intentionally not consulted. */ +public final class HttpPinnedTls { + private HttpPinnedTls() { } + + public static SSLContext clientContext(HttpConnectionCode code) throws Exception { + if (code == null) throw new IllegalArgumentException("Connection code is required"); + SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, new TrustManager[] { new PinnedServerTrustManager(code.serverCertificatePin(), code.caCertificatePin()) }, null); + return context; + } + + /** + * Normal transport context: presents the enrolled client certificate and trusts only the + * pinned private authority. Callers must not override the HttpClient default endpoint-identification + * settings; hostname verification remains enabled and the proxy leaf may renew under the same CA. + */ + public static SSLContext mutualTlsContext(HttpConnectionCode code, HttpClientCredentialStore.ClientCredential credential) + throws Exception { + if (code == null || credential == null || credential.privateKey() == null || credential.certificate() == null || credential.caCertificate() == null) + throw new IllegalArgumentException("Enrolled client credential is required"); + char[] password = credential.password(); + try { + String authorityPin = HttpTransportSecrets.certificatePin(credential.caCertificate()); + if (!HttpTransportSecrets.constantTimeEquals(code.caCertificatePin().getBytes(java.nio.charset.StandardCharsets.US_ASCII), + authorityPin.getBytes(java.nio.charset.StandardCharsets.US_ASCII))) + throw new IllegalArgumentException("HTTP authority does not match connection code"); + KeyStore store = KeyStore.getInstance("PKCS12"); + store.load(null, new char[0]); + store.setKeyEntry("client", credential.privateKey(), password, + new java.security.cert.Certificate[] { credential.certificate(), credential.caCertificate() }); + KeyManagerFactory managers = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + managers.init(store, password); + KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + trustStore.load(null, new char[0]); + trustStore.setCertificateEntry("http-transport-ca", credential.caCertificate()); + javax.net.ssl.TrustManagerFactory trusts = javax.net.ssl.TrustManagerFactory.getInstance( + javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm()); + trusts.init(trustStore); + SSLContext context = SSLContext.getInstance("TLS"); + context.init(managers.getKeyManagers(), trusts.getTrustManagers(), null); + return context; + } finally { java.util.Arrays.fill(password, '\0'); } + } + + public static boolean matchesServerPin(HttpConnectionCode code, X509Certificate certificate) { + if (code == null || certificate == null) return false; + String actual = HttpTransportSecrets.certificatePin(certificate); + return HttpTransportSecrets.constantTimeEquals(code.serverCertificatePin() + .getBytes(java.nio.charset.StandardCharsets.US_ASCII), actual.getBytes(java.nio.charset.StandardCharsets.US_ASCII)); + } + + /** TLS 1.3 is used where the runtime exposes it; hostname verification is deliberately left enabled. */ + public static SSLParameters secureParameters(SSLContext context) { + SSLParameters parameters = context.getDefaultSSLParameters(); + for (String protocol : context.getSupportedSSLParameters().getProtocols()) { + if ("TLSv1.3".equals(protocol)) { + parameters.setProtocols(new String[] { "TLSv1.3" }); + break; + } + } + return parameters; + } + + private static final class PinnedServerTrustManager implements X509TrustManager { + private final String expectedPin; + private final String expectedCaPin; + private PinnedServerTrustManager(String expectedPin, String expectedCaPin) { + this.expectedPin = expectedPin; + this.expectedCaPin = expectedCaPin; + } + @Override public void checkClientTrusted(X509Certificate[] chain, String authType) { throw new UnsupportedOperationException(); } + @Override public void checkServerTrusted(X509Certificate[] chain, String authType) throws java.security.cert.CertificateException { + if (chain == null || chain.length < 2) throw new java.security.cert.CertificateException("Server certificate chain is incomplete"); + chain[0].checkValidity(); + chain[chain.length - 1].checkValidity(); + try { chain[0].verify(chain[chain.length - 1].getPublicKey()); } + catch (java.security.GeneralSecurityException invalid) { + throw new java.security.cert.CertificateException("Server certificate signature is invalid", invalid); + } + if (chain[chain.length - 1].getBasicConstraints() < 0) + throw new java.security.cert.CertificateException("Server certificate authority is invalid"); + String actual = HttpTransportSecrets.certificatePin(chain[0]); + if (!HttpTransportSecrets.constantTimeEquals(expectedPin.getBytes(java.nio.charset.StandardCharsets.US_ASCII), + actual.getBytes(java.nio.charset.StandardCharsets.US_ASCII))) + throw new java.security.cert.CertificateException("Server certificate pin does not match"); + String issuer = HttpTransportSecrets.certificatePin(chain[chain.length - 1]); + if (!HttpTransportSecrets.constantTimeEquals(expectedCaPin.getBytes(java.nio.charset.StandardCharsets.US_ASCII), + issuer.getBytes(java.nio.charset.StandardCharsets.US_ASCII))) + throw new java.security.cert.CertificateException("Server certificate authority pin does not match"); + } + @Override public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/http/HttpProxyTransportServer.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/http/HttpProxyTransportServer.java new file mode 100644 index 000000000..8e1ecc925 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/http/HttpProxyTransportServer.java @@ -0,0 +1,433 @@ +package com.bencodez.votingplugin.backendproxy.http; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.votingplugin.util.DurableFiles; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsExchange; +import com.sun.net.httpserver.HttpsParameters; +import com.sun.net.httpserver.HttpsServer; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Semaphore; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLPeerUnverifiedException; + +/** + * One HTTPS listener for enrollment and the backend-to-proxy long-poll transport. + * Every normal request is certificate-authenticated in the handler, rather than relying on TLS WANT auth. + */ +public final class HttpProxyTransportServer implements AutoCloseable { + static { + // JDK HttpServer reads these once when its internal server configuration is initialized. + // Set conservative process-wide bounds before this transport creates its listener. + setDefault("sun.net.httpserver.maxReqTime", "10"); + setDefault("sun.net.httpserver.maxRspTime", "10"); + setDefault("jdk.httpserver.maxConnections", "144"); + setDefault("sun.net.httpserver.maxReqHeaders", "32"); + setDefault("sun.net.httpserver.maxReqHeaderSize", "16384"); + } + // Keep an idle request open long enough to reuse the TLS connection, but bound backend-origin + // latency when a message is queued immediately after the request body has already been sent. + public static final Duration LONG_POLL = Duration.ofSeconds(2); + private final HttpTlsIdentity identity; + private final HttpEnrollmentAuthority authority; + private final HttpsServer server; + private final ThreadPoolExecutor listenerExecutor; + private final ThreadPoolExecutor handlerExecutor; + private final Semaphore admission = new Semaphore(64); + private final Map backends = new HashMap<>(); + private final DurableOutgoingQueue durableOutgoing; + private final Consumer onEnvelope; + private volatile boolean closed; + + public HttpProxyTransportServer(InetSocketAddress bind, HttpTlsIdentity identity, HttpEnrollmentAuthority authority, + Consumer onEnvelope) throws Exception { + this(bind, identity, authority, null, onEnvelope); + } + + public HttpProxyTransportServer(InetSocketAddress bind, HttpTlsIdentity identity, HttpEnrollmentAuthority authority, + Path outgoingDirectory, Consumer onEnvelope) throws Exception { + if (bind == null || identity == null || authority == null || onEnvelope == null) throw new IllegalArgumentException("HTTP transport configuration is required"); + this.identity = identity; this.authority = authority; this.onEnvelope = onEnvelope; + durableOutgoing = outgoingDirectory == null ? null : new DurableOutgoingQueue(outgoingDirectory); + if (durableOutgoing != null) for (Map.Entry> pending + : durableOutgoing.load().entrySet()) { + BackendState state = new BackendState(pending.getKey(), durableOutgoing); + state.restore(pending.getValue()); + backends.put(pending.getKey(), state); + } + server = HttpsServer.create(bind, 32); + server.setHttpsConfigurator(new HttpsConfigurator(identity.serverContext()) { + @Override public void configure(HttpsParameters parameters) { + SSLParameters ssl = HttpPinnedTls.secureParameters(getSSLContext()); + ssl.setWantClientAuth(true); parameters.setSSLParameters(ssl); + } + }); + // Long polls are blocking by design. Capacity is bounded by admission, while enough workers + // remain available for all admitted polls plus setup requests. + listenerExecutor = executor("VotingPlugin-HTTP-listener", 72, 72); + // The proxy router mutates shared presence, vote, and reward state. A separate + // bounded FIFO lane keeps wire order without blocking long-poll workers. + handlerExecutor = executor("VotingPlugin-HTTP-handler", 1, HttpBackendTransportConnector.CALLBACK_QUEUE_CAPACITY); + server.setExecutor(listenerExecutor); + server.createContext("/v1/enroll", exchange -> enroll((HttpsExchange) exchange)); + server.createContext("/v1/renew", exchange -> renew((HttpsExchange) exchange)); + server.createContext("/v1/transport", exchange -> transport((HttpsExchange) exchange)); + } + + private void renew(HttpsExchange exchange) throws IOException { + if (!"/v1/renew".equals(exchange.getRequestURI().getPath()) || exchange.getRequestURI().getRawQuery() != null) { reply(exchange, 404, new byte[0]); return; } + if (!"POST".equals(exchange.getRequestMethod())) { reply(exchange, 405, new byte[0]); return; } + if (!json(exchange)) { reply(exchange, 415, new byte[0]); return; } + if (!boundedFixedBody(exchange, 1024) || !admission.tryAcquire()) { reply(exchange, 429, new byte[0]); return; } + try { + String serverId = HttpTransportProtocol.parseRenewal(read(exchange.getRequestBody(), 1024)); + X509Certificate certificate = peerCertificate(exchange); + if (certificate == null || !authority.authenticate(serverId, certificate)) { reply(exchange, 401, new byte[0]); return; } + HttpTlsIdentity.IssuedClientCertificate issued = authority.renew(serverId, certificate); + reply(exchange, 201, HttpTransportProtocol.enrollmentResponse(issued)); + } catch (IllegalArgumentException rejected) { reply(exchange, 403, new byte[0]); + } catch (Exception failure) { reply(exchange, 503, new byte[0]); + } finally { admission.release(); } + } + + public void start() { if (closed) throw new IllegalStateException("HTTP transport is closed"); server.start(); } + public int port() { return server.getAddress().getPort(); } + public URI endpoint(String host) { return URI.create("https://" + host + ":" + port() + "/"); } + + /** Queues a proxy-origin envelope durably before reporting acceptance. */ + public boolean send(String serverId, JsonEnvelope envelope) { + if (closed || serverId == null || envelope == null) return false; + try { serverId = HttpTlsIdentity.canonicalServerId(serverId); HttpTransportProtocol.validateEnvelope(envelope); } + catch (IllegalArgumentException invalid) { return false; } + BackendState backend; + final String canonicalServerId = serverId; + synchronized (backends) { backend = backends.computeIfAbsent(serverId, + ignored -> new BackendState(canonicalServerId, durableOutgoing)); } + return backend.enqueue(new HttpTransportProtocol.Delivery(UUID.randomUUID().toString(), envelope)); + } + + @Override public void close() { + if (closed) return; closed = true; server.stop(1); + shutdown(handlerExecutor); shutdown(listenerExecutor); + synchronized (backends) { for (BackendState backend : backends.values()) backend.signal(); backends.clear(); } + } + + private void enroll(HttpsExchange exchange) throws IOException { + if (!"/v1/enroll".equals(exchange.getRequestURI().getPath()) || exchange.getRequestURI().getRawQuery() != null) { reply(exchange, 404, new byte[0]); return; } + if (!"POST".equals(exchange.getRequestMethod())) { reply(exchange, 405, new byte[0]); return; } + if (!json(exchange)) { reply(exchange, 415, new byte[0]); return; } + if (!boundedFixedBody(exchange, 8192) || !admission.tryAcquire()) { reply(exchange, 429, new byte[0]); return; } + try { + HttpTransportProtocol.Enrollment request = HttpTransportProtocol.parseEnrollment(read(exchange.getRequestBody(), 8192)); + HttpTlsIdentity.IssuedClientCertificate issued = authority.enroll(request.server(), request.token()); + reply(exchange, 201, HttpTransportProtocol.enrollmentResponse(issued)); + } catch (Exception rejected) { reply(exchange, 403, new byte[0]); } + finally { admission.release(); } + } + + private void transport(HttpsExchange exchange) throws IOException { + if (!"/v1/transport".equals(exchange.getRequestURI().getPath()) || exchange.getRequestURI().getRawQuery() != null) { reply(exchange, 404, new byte[0]); return; } + if (!"POST".equals(exchange.getRequestMethod())) { reply(exchange, 405, new byte[0]); return; } + if (!json(exchange)) { reply(exchange, 415, new byte[0]); return; } + if (!boundedFixedBody(exchange, HttpTransportProtocol.MAX_BODY_BYTES) || !admission.tryAcquire()) { reply(exchange, 429, new byte[0]); return; } + try { + HttpTransportProtocol.Packet packet = HttpTransportProtocol.parsePacket(read(exchange.getRequestBody(), HttpTransportProtocol.MAX_BODY_BYTES)); + X509Certificate certificate = peerCertificate(exchange); + if (certificate == null || !authority.authenticate(packet.server(), certificate)) { reply(exchange, 401, new byte[0]); return; } + BackendState backend; + synchronized (backends) { backend = backends.computeIfAbsent(packet.server(), + ignored -> new BackendState(packet.server(), durableOutgoing)); } + if (!backend.beginPoll(packet.session())) { reply(exchange, 409, new byte[0]); return; } + try { + handlePacket(packet, backend); + Response response = backend.await(packet.server(), packet.session(), packet.sequence()); + reply(exchange, 200, HttpTransportProtocol.response(packet.server(), packet.session(), packet.sequence(), response.acks(), response.messages())); + } finally { backend.endPoll(); } + } catch (IllegalArgumentException rejected) { reply(exchange, 400, new byte[0]); + } catch (Exception failure) { reply(exchange, 503, new byte[0]); + } finally { admission.release(); } + } + + private void handlePacket(HttpTransportProtocol.Packet packet, BackendState backend) throws IOException { + List accepted; + synchronized (backend) { + if (!backend.allowRequest()) throw new IllegalArgumentException("transport rate limited"); + if (!backend.acceptSession(packet.session(), packet.sequence())) throw new IllegalArgumentException("stale session request"); + backend.acknowledge(packet.acks()); accepted = backend.acceptIncoming(packet.messages()); + } + for (HttpTransportProtocol.Delivery delivery : accepted) dispatch(packet.server(), backend, delivery); + } + private void dispatch(String serverId, BackendState backend, HttpTransportProtocol.Delivery delivery) { + Runnable callback = () -> { + boolean success = false; + try { onEnvelope.accept(new ReceivedEnvelope(serverId, delivery.id(), normalizeBackendIdentity(serverId, delivery.envelope()))); success = true; } + catch (RuntimeException ignored) { } + synchronized (backend) { backend.completeIncoming(delivery.id(), success); } + }; + if (!HttpBackendTransportConnector.executeOrdered(handlerExecutor, callback)) + synchronized (backend) { backend.completeIncoming(delivery.id(), false); } + } + private static JsonEnvelope normalizeBackendIdentity(String serverId, JsonEnvelope envelope) { + // The authenticated TLS identity is authoritative; never forward a forged `server` field. + return envelope.toBuilder().put("server", serverId).build(); + } + private static X509Certificate peerCertificate(HttpsExchange exchange) { + try { Certificate[] peer = exchange.getSSLSession().getPeerCertificates(); + return peer.length > 0 && peer[0] instanceof X509Certificate certificate ? certificate : null; + } catch (SSLPeerUnverifiedException absent) { return null; } + } + private static byte[] read(InputStream input, int maximum) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); byte[] buffer = new byte[4096]; int total = 0, read; + while ((read = input.read(buffer)) >= 0) { total += read; if (total > maximum) throw new IllegalArgumentException("HTTP body is too large"); output.write(buffer, 0, read); } + return output.toByteArray(); + } + private static void reply(HttpsExchange exchange, int status, byte[] body) throws IOException { + Headers headers = exchange.getResponseHeaders(); headers.set("Cache-Control", "no-store"); headers.set("Content-Type", "application/json; charset=utf-8"); + exchange.sendResponseHeaders(status, body.length); try (var output = exchange.getResponseBody()) { output.write(body); } + } + private static boolean json(HttpsExchange exchange) { + String contentType = exchange.getRequestHeaders().getFirst("Content-Type"); + return contentType != null && contentType.toLowerCase(java.util.Locale.ROOT).matches("application/json(?:\\s*;.*)?"); + } + private static boolean boundedFixedBody(HttpsExchange exchange, int maximum) { + if (exchange.getRequestHeaders().getFirst("Transfer-Encoding") != null) return false; + String value = exchange.getRequestHeaders().getFirst("Content-Length"); + try { long length = Long.parseLong(value); return length > 0L && length <= maximum; } + catch (RuntimeException invalid) { return false; } + } + private static ThreadPoolExecutor executor(String name, int threads, int queue) { + ThreadFactory factory = task -> { Thread thread = new Thread(task, name); thread.setDaemon(true); return thread; }; + return new ThreadPoolExecutor(threads, threads, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(queue), factory, new ThreadPoolExecutor.AbortPolicy()); + } + private static void setDefault(String name, String value) { if (System.getProperty(name) == null) System.setProperty(name, value); } + private static void shutdown(ExecutorService executor) { executor.shutdown(); try { if (!executor.awaitTermination(5, TimeUnit.SECONDS)) executor.shutdownNow(); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); executor.shutdownNow(); } } + + public record ReceivedEnvelope(String serverId, String messageId, JsonEnvelope envelope) { } + static record Response(Collection acks, Collection messages) { } + static final class BackendState { + private final String serverId; + private final DurableOutgoingQueue durableOutgoing; + private String session; private long sequence = -1L; + private final LinkedHashMap outgoing = new LinkedHashMap<>(); + private final Set seen = new LinkedHashSet<>(); private final Set processing = new LinkedHashSet<>(); + private final ArrayDeque acknowledgements = new ArrayDeque<>(); + private final Set delivered = new LinkedHashSet<>(); + private long lastDeliveryNanos; + private double requestTokens = 24.0d; + private long lastTokenNanos = System.nanoTime(); + private boolean activePoll; + BackendState() { this(null, null); } + private BackendState(String serverId, DurableOutgoingQueue durableOutgoing) { + this.serverId = serverId; this.durableOutgoing = durableOutgoing; + } + private synchronized void restore(Collection deliveries) { + for (HttpTransportProtocol.Delivery delivery : deliveries) outgoing.put(delivery.id(), delivery); + } + private boolean beginPoll(String requestedSession) { synchronized (this) { if (activePoll) return false; activePoll = true; return true; } } + private void endPoll() { synchronized (this) { activePoll = false; notifyAll(); } } + private boolean allowRequest() { + long now = System.nanoTime(); requestTokens = Math.min(24.0d, requestTokens + ((now - lastTokenNanos) / 1_000_000_000.0d) * 2.0d); + lastTokenNanos = now; if (requestTokens < 1.0d) return false; requestTokens -= 1.0d; return true; + } + boolean acceptSession(String requested, long requestedSequence) { + if (!requested.equals(session)) { session = requested; sequence = -1L; delivered.clear(); lastDeliveryNanos = 0L; } + // The connector allocates a fresh monotonic sequence for every attempt. Rejecting equality + // prevents a captured request from being replayed with altered ACKs or a new payload. + if (requestedSequence <= sequence) return false; sequence = requestedSequence; return true; + } + synchronized boolean enqueue(HttpTransportProtocol.Delivery delivery) { + if (outgoing.size() >= HttpTransportProtocol.MAX_QUEUE) return false; + if (durableOutgoing != null) try { durableOutgoing.persist(serverId, delivery); } + catch (IOException failure) { return false; } + outgoing.put(delivery.id(), delivery); signal(); return true; + } + private void acknowledge(Collection acks) throws IOException { + for (String id : acks) { + if (!outgoing.containsKey(id)) continue; + // A 200 response is the backend's proof that its durable replay fence may + // be deleted. Never return success while the proxy delivery still exists. + if (durableOutgoing != null) durableOutgoing.remove(serverId, id); + outgoing.remove(id); delivered.remove(id); + } + } + List acceptIncoming(List received) { + List accepted = new java.util.ArrayList<>(); + for (HttpTransportProtocol.Delivery delivery : received) { + if (seen.contains(delivery.id())) { queueAck(delivery.id()); continue; } + if (!processing.contains(delivery.id())) { + processing.add(delivery.id()); accepted.add(delivery); + } + } + return accepted; + } + synchronized void completeIncoming(String id, boolean success) { processing.remove(id); if (success) { seen.add(id); while (seen.size() > HttpTransportProtocol.MAX_QUEUE) seen.remove(seen.iterator().next()); queueAck(id); signal(); } } + private void queueAck(String id) { if (acknowledgements.size() < HttpTransportProtocol.MAX_QUEUE && !acknowledgements.contains(id)) acknowledgements.add(id); } + synchronized Response await(String serverId, String requestedSession, long requestedSequence) { + long deadline = System.nanoTime() + LONG_POLL.toNanos(); + while (acknowledgements.isEmpty() && !hasUndelivered() && !redeliveryDue()) { + long remaining = deadline - System.nanoTime(); if (remaining <= 0) break; + try { TimeUnit.NANOSECONDS.timedWait(this, remaining); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); break; } + } + List acks = new java.util.ArrayList<>(); while (!acknowledgements.isEmpty() && acks.size() < HttpTransportProtocol.MAX_BATCH) acks.add(acknowledgements.remove()); + List candidates = new java.util.ArrayList<>(); + if (hasUndelivered() || redeliveryDue()) for (HttpTransportProtocol.Delivery delivery : outgoing.values()) { + if (!delivered.contains(delivery.id()) || redeliveryDue()) candidates.add(delivery); + if (candidates.size() == HttpTransportProtocol.MAX_BATCH) break; + } + List messages = HttpTransportProtocol.fittingMessages(serverId, requestedSession, + requestedSequence, acks, candidates); + for (HttpTransportProtocol.Delivery delivery : messages) delivered.add(delivery.id()); + if (!messages.isEmpty()) lastDeliveryNanos = System.nanoTime(); + return new Response(acks, messages); + } + private boolean hasUndelivered() { for (String id : outgoing.keySet()) if (!delivered.contains(id)) return true; return false; } + private boolean redeliveryDue() { return !outgoing.isEmpty() && lastDeliveryNanos > 0L && System.nanoTime() - lastDeliveryNanos >= LONG_POLL.toNanos(); } + private synchronized void signal() { notifyAll(); } + } + + private static final class DurableOutgoingQueue { + private static final String FILE_PATTERN = "[0-9]{20}-[0-9a-f-]{36}\\.json"; + private final Path root; + private final Map> files = new HashMap<>(); + private long sequence; + + private DurableOutgoingQueue(Path root) throws IOException { + this.root = root.toAbsolutePath().normalize(); + boolean created = false; + try { Files.createDirectory(this.root); created = true; } + catch (java.nio.file.FileAlreadyExistsException existing) { } + try { + if (Files.isSymbolicLink(this.root) || !Files.isDirectory(this.root, LinkOption.NOFOLLOW_LINKS)) + throw new IOException("HTTP outgoing queue directory is invalid"); + ownerOnlyDirectory(this.root); + } finally { + if (created) DurableFiles.forceDirectory(this.root.getParent()); + } + } + + private synchronized Map> load() throws IOException { + Map> loaded = new LinkedHashMap<>(); + try (DirectoryStream servers = Files.newDirectoryStream(root)) { + for (Path directory : servers) { + if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) + throw new IOException("HTTP outgoing queue contains an invalid entry"); + String serverId; + try { serverId = HttpTlsIdentity.canonicalServerId(directory.getFileName().toString()); } + catch (IllegalArgumentException invalid) { throw new IOException("HTTP outgoing queue server is invalid", invalid); } + if (!serverId.equals(directory.getFileName().toString())) + throw new IOException("HTTP outgoing queue server is not canonical"); + List entries = new java.util.ArrayList<>(); + try (DirectoryStream messages = Files.newDirectoryStream(directory)) { + for (Path message : messages) entries.add(message); + } + entries.sort(java.util.Comparator.comparing(path -> path.getFileName().toString())); + List deliveries = new java.util.ArrayList<>(); + Map serverFiles = files.computeIfAbsent(serverId, ignored -> new HashMap<>()); + for (Path message : entries) { + String name = message.getFileName().toString(); + if (name.startsWith(".pending-") && name.endsWith(".tmp") + && !Files.isSymbolicLink(message) && Files.isRegularFile(message, LinkOption.NOFOLLOW_LINKS)) { + DurableFiles.deleteIfExists(message); + continue; + } + if (Files.isSymbolicLink(message) || !Files.isRegularFile(message, LinkOption.NOFOLLOW_LINKS) + || !name.matches(FILE_PATTERN) || Files.size(message) > HttpTransportProtocol.MAX_ENVELOPE_BYTES * 2L) + throw new IOException("HTTP outgoing queue message is invalid"); + HttpTransportProtocol.Delivery delivery; + try { delivery = HttpTransportProtocol.parseStoredDelivery(Files.readAllBytes(message)); } + catch (IllegalArgumentException invalid) { throw new IOException("HTTP outgoing queue message is invalid", invalid); } + if (!name.endsWith("-" + delivery.id() + ".json") || serverFiles.put(delivery.id(), message) != null) + throw new IOException("HTTP outgoing queue message id is invalid"); + deliveries.add(delivery); + if (deliveries.size() > HttpTransportProtocol.MAX_QUEUE) + throw new IOException("HTTP outgoing queue exceeds its bound"); + sequence = Math.max(sequence, Long.parseLong(name.substring(0, 20))); + } + if (!deliveries.isEmpty()) loaded.put(serverId, deliveries); + } + } + return loaded; + } + + private synchronized void persist(String serverId, HttpTransportProtocol.Delivery delivery) throws IOException { + Path directory = root.resolve(serverId).normalize(); + if (!directory.getParent().equals(root)) throw new IOException("HTTP outgoing queue server is invalid"); + boolean created = false; + try { Files.createDirectory(directory); created = true; } + catch (java.nio.file.FileAlreadyExistsException existing) { } + try { + if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) + throw new IOException("HTTP outgoing queue server directory is invalid"); + ownerOnlyDirectory(directory); + } finally { + // The child fsync below cannot make this newly published name durable in + // its parent. Persist the root entry before accepting the first message. + if (created) DurableFiles.forceDirectory(root); + } + if (sequence == Long.MAX_VALUE) throw new IOException("HTTP outgoing queue sequence is exhausted"); + String name = String.format(java.util.Locale.ROOT, "%020d-%s.json", ++sequence, delivery.id()); + Path target = directory.resolve(name); + Path temporary = Files.createTempFile(directory, ".pending-", ".tmp"); + try { + ownerOnlyFile(temporary); + Files.write(temporary, HttpTransportProtocol.storedDelivery(delivery), StandardOpenOption.TRUNCATE_EXISTING); + DurableFiles.forceFile(temporary); + try { Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE); } + catch (java.nio.file.AtomicMoveNotSupportedException unsupported) { Files.move(temporary, target); } + ownerOnlyFile(target); DurableFiles.forceDirectory(directory); + files.computeIfAbsent(serverId, ignored -> new HashMap<>()).put(delivery.id(), target); + } finally { Files.deleteIfExists(temporary); } + } + + private synchronized void remove(String serverId, String id) throws IOException { + Map serverFiles = files.get(serverId); + Path file = serverFiles == null ? null : serverFiles.get(id); + if (file == null) throw new IOException("HTTP outgoing queue acknowledgement is unknown"); + DurableFiles.deleteIfExists(file); + serverFiles.remove(id); + } + + private static void ownerOnlyFile(Path path) throws IOException { + try { Files.setPosixFilePermissions(path, java.util.EnumSet.of( + java.nio.file.attribute.PosixFilePermission.OWNER_READ, + java.nio.file.attribute.PosixFilePermission.OWNER_WRITE)); } + catch (UnsupportedOperationException ignored) { } + } + private static void ownerOnlyDirectory(Path path) throws IOException { + try { Files.setPosixFilePermissions(path, java.util.EnumSet.of( + java.nio.file.attribute.PosixFilePermission.OWNER_READ, + java.nio.file.attribute.PosixFilePermission.OWNER_WRITE, + java.nio.file.attribute.PosixFilePermission.OWNER_EXECUTE)); } + catch (UnsupportedOperationException ignored) { } + } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/http/HttpTlsIdentity.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/http/HttpTlsIdentity.java new file mode 100644 index 000000000..5a61daefc --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/http/HttpTlsIdentity.java @@ -0,0 +1,430 @@ +package com.bencodez.votingplugin.backendproxy.http; + +import java.io.IOException; +import java.io.OutputStream; +import java.math.BigInteger; +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.PosixFilePermission; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.Principal; +import java.security.Security; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.time.Clock; +import java.time.Duration; +import java.util.Date; +import java.util.EnumSet; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.net.Socket; +import javax.net.ssl.KeyManager; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509ExtendedKeyManager; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import com.bencodez.votingplugin.util.DurableFiles; + +/** Durable private CA plus server identity used by the proxy HTTP listener. */ +public final class HttpTlsIdentity { + private static final String CA_FILE = "http-transport-ca.p12"; + private static final String SERVER_FILE = "http-transport-server.p12"; + private static final String PASSWORD_FILE = "http-transport-password"; + private static final char[] EMPTY_PASSWORD = new char[0]; + static final Duration RENEW_BEFORE = Duration.ofDays(30); + static final Duration CA_RENEW_BEFORE = Duration.ofDays(365); + private final PrivateKey caKey; + private volatile X509Certificate caCertificate; + private volatile PrivateKey serverKey; + private volatile X509Certificate serverCertificate; + private final char[] password; + private final Path caFile; + private final Path serverFile; + private final String advertisedHost; + + private HttpTlsIdentity(PrivateKey caKey, X509Certificate caCertificate, PrivateKey serverKey, + X509Certificate serverCertificate, char[] password, Path caFile, Path serverFile, String advertisedHost) { + this.caKey = caKey; + this.caCertificate = caCertificate; + this.serverKey = serverKey; + this.serverCertificate = serverCertificate; + this.password = password.clone(); + this.caFile = caFile; + this.serverFile = serverFile; + this.advertisedHost = advertisedHost; + } + + public static HttpTlsIdentity loadOrCreate(Path directory, String advertisedHost) throws Exception { + return loadOrCreate(directory, advertisedHost, Clock.systemUTC()); + } + + static HttpTlsIdentity loadOrCreate(Path directory, String advertisedHost, Clock clock) throws Exception { + if (advertisedHost == null || advertisedHost.isBlank() || advertisedHost.length() > 253) + throw new IllegalArgumentException("Advertised HTTPS host is invalid"); + if (clock == null) throw new IllegalArgumentException("Clock is required"); + Path identityDirectory = directory.toAbsolutePath().normalize(); + boolean created = !Files.exists(identityDirectory, LinkOption.NOFOLLOW_LINKS); + Files.createDirectories(identityDirectory); + // The identity files cannot make the newly created directory entry durable. + // Persist its parent before the TLS identity is returned for listener use. + if (created) DurableFiles.forceDirectory(identityDirectory.getParent()); + directory = identityDirectory; + Path caFile = safe(directory.resolve(CA_FILE)); + Path serverFile = safe(directory.resolve(SERVER_FILE)); + Path passwordFile = safe(directory.resolve(PASSWORD_FILE)); + boolean caExists = Files.exists(caFile, LinkOption.NOFOLLOW_LINKS); + boolean serverExists = Files.exists(serverFile, LinkOption.NOFOLLOW_LINKS); + boolean passwordExists = Files.exists(passwordFile, LinkOption.NOFOLLOW_LINKS); + if (caExists || serverExists || passwordExists) { + if (!(caExists && serverExists && passwordExists)) throw new IOException("HTTP TLS identity files are incomplete"); + char[] password = readPassword(passwordFile); + try { + KeyStore ca = load(caFile, password); + KeyStore server = load(serverFile, password); + PrivateKey caKey = (PrivateKey) ca.getKey("ca", password); + X509Certificate caCertificate = (X509Certificate) ca.getCertificate("ca"); + PrivateKey serverKey = (PrivateKey) server.getKey("server", password); + X509Certificate serverCertificate = (X509Certificate) server.getCertificate("server"); + if (caKey == null || caCertificate == null || serverKey == null || serverCertificate == null) + throw new IOException("HTTP TLS identity files are invalid"); + boolean caRenewed = needsCaRenewal(caCertificate, clock); + if (caRenewed) { + ensureBouncyCastle(); + KeyPair caPair = new KeyPair(caCertificate.getPublicKey(), caKey); + caCertificate = certificate("CN=VotingPlugin HTTP private CA", caPair, null, null, + CertificateRole.CA, null, clock.instant()); + ca = KeyStore.getInstance("PKCS12"); + ca.load(null, EMPTY_PASSWORD); + ca.setKeyEntry("ca", caKey, password, new Certificate[] { caCertificate }); + writeStore(caFile, ca, password); + } + if (caRenewed || !hasServerName(serverCertificate, advertisedHost) || needsRenewal(serverCertificate, clock)) { + ensureBouncyCastle(); + KeyPair serverPair = keyPair(); + serverCertificate = certificate("CN=" + certificateName(advertisedHost), serverPair, caCertificate, caKey, + CertificateRole.SERVER, advertisedHost, clock.instant()); + serverKey = serverPair.getPrivate(); + server = KeyStore.getInstance("PKCS12"); + server.load(null, EMPTY_PASSWORD); + server.setKeyEntry("server", serverKey, password, new Certificate[] { serverCertificate, caCertificate }); + writeStore(serverFile, server, password); + } + return new HttpTlsIdentity(caKey, caCertificate, serverKey, serverCertificate, password, caFile, serverFile, + advertisedHost); + } finally { Arrays.fill(password, '\0'); } + } + ensureBouncyCastle(); + char[] password = HttpTransportSecrets.randomToken().toCharArray(); + try { + KeyPair caPair = keyPair(); + X509Certificate caCertificate = certificate("CN=VotingPlugin HTTP private CA", caPair, null, null, CertificateRole.CA, null, + clock.instant()); + KeyPair serverPair = keyPair(); + X509Certificate serverCertificate = certificate("CN=" + certificateName(advertisedHost), serverPair, caCertificate, + caPair.getPrivate(), CertificateRole.SERVER, advertisedHost, clock.instant()); + KeyStore ca = KeyStore.getInstance("PKCS12"); + ca.load(null, EMPTY_PASSWORD); + ca.setKeyEntry("ca", caPair.getPrivate(), password, new Certificate[] { caCertificate }); + KeyStore server = KeyStore.getInstance("PKCS12"); + server.load(null, EMPTY_PASSWORD); + server.setKeyEntry("server", serverPair.getPrivate(), password, new Certificate[] { serverCertificate, caCertificate }); + writeStore(caFile, ca, password); + writeStore(serverFile, server, password); + byte[] passwordBytes = asciiBytes(password); + try { writePrivate(passwordFile, passwordBytes); } + finally { Arrays.fill(passwordBytes, (byte) 0); } + return new HttpTlsIdentity(caPair.getPrivate(), caCertificate, serverPair.getPrivate(), serverCertificate, password, + caFile, serverFile, advertisedHost); + } finally { Arrays.fill(password, '\0'); } + } + + public String serverCertificatePin() { + refreshIdentity(); + return HttpTransportSecrets.certificatePin(serverCertificate); + } + public String caCertificatePin() { refreshIdentity(); return HttpTransportSecrets.certificatePin(caCertificate); } + public X509Certificate caCertificate() { return caCertificate; } + public X509Certificate serverCertificate() { return serverCertificate; } + + /** + * The listener requests an optional client certificate so enrollment can share the same port. + * Any certificate that is presented must chain to this transport's private CA; normal requests + * additionally validate the certificate's persisted backend binding in the HTTP handler. + */ + public SSLContext serverContext() throws Exception { + renewIdentityIfNeeded(); + SSLContext context = SSLContext.getInstance("TLS"); + context.init(new KeyManager[] { new RotatingServerKeyManager() }, trustManagers(caCertificate), null); + return context; + } + + static TrustManager[] trustManagers(X509Certificate caCertificate) throws Exception { + KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + trustStore.load(null, EMPTY_PASSWORD); + trustStore.setCertificateEntry("http-transport-ca", caCertificate); + TrustManagerFactory factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + factory.init(trustStore); + return factory.getTrustManagers(); + } + + private void refreshIdentity() { + try { renewIdentityIfNeeded(); } + catch (Exception failure) { throw new IllegalStateException("Could not renew HTTP TLS identity", failure); } + } + + private synchronized void renewIdentityIfNeeded() throws Exception { + Clock clock = Clock.systemUTC(); + boolean renewCa = needsCaRenewal(caCertificate, clock); + if (!renewCa && !needsRenewal(serverCertificate, clock)) return; + ensureBouncyCastle(); + X509Certificate replacementCa = caCertificate; + if (renewCa) { + KeyPair caPair = new KeyPair(caCertificate.getPublicKey(), caKey); + replacementCa = certificate("CN=VotingPlugin HTTP private CA", caPair, null, null, + CertificateRole.CA, null, clock.instant()); + KeyStore caStore = KeyStore.getInstance("PKCS12"); + caStore.load(null, EMPTY_PASSWORD); + caStore.setKeyEntry("ca", caKey, password, new Certificate[] { replacementCa }); + writeStore(caFile, caStore, password); + } + KeyPair pair = keyPair(); + X509Certificate replacement = certificate("CN=" + certificateName(advertisedHost), pair, replacementCa, caKey, + CertificateRole.SERVER, advertisedHost, clock.instant()); + KeyStore store = KeyStore.getInstance("PKCS12"); + store.load(null, EMPTY_PASSWORD); + store.setKeyEntry("server", pair.getPrivate(), password, new Certificate[] { replacement, replacementCa }); + writeStore(serverFile, store, password); + caCertificate = replacementCa; + serverKey = pair.getPrivate(); + serverCertificate = replacement; + } + + public IssuedClientCertificate issueClientCertificate(String serverId) throws Exception { + return issueClientCertificate(serverId, Instant.now()); + } + + IssuedClientCertificate issueClientCertificate(String serverId, Instant issuedAt) throws Exception { + serverId = canonicalServerId(serverId); + if (issuedAt == null) throw new IllegalArgumentException("Certificate issuance time is required"); + ensureBouncyCastle(); + KeyPair pair = keyPair(); + X509Certificate certificate = certificate("CN=" + serverId, pair, caCertificate, caKey, CertificateRole.CLIENT, + "urn:votingplugin:http-backend:" + serverId, issuedAt); + char[] clientPassword = HttpTransportSecrets.randomToken().toCharArray(); + try { + KeyStore store = KeyStore.getInstance("PKCS12"); + store.load(null, EMPTY_PASSWORD); + store.setKeyEntry("client", pair.getPrivate(), clientPassword, new Certificate[] { certificate, caCertificate }); + java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream(); + store.store(bytes, clientPassword); + return new IssuedClientCertificate(serverId, certificate, bytes.toByteArray(), clientPassword); + } finally { Arrays.fill(clientPassword, '\0'); } + } + + public static String canonicalServerId(String serverId) { + if (serverId == null || !serverId.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}")) + throw new IllegalArgumentException("Server id is invalid"); + return serverId.toLowerCase(java.util.Locale.ROOT); + } + + public boolean issuedByThisCa(X509Certificate certificate) { + if (certificate == null) return false; + try { + certificate.checkValidity(); + certificate.verify(caCertificate.getPublicKey()); + return true; + } catch (Exception failure) { + return false; + } + } + + public boolean validClientCertificate(String expectedServerId, X509Certificate certificate) { + if (!issuedByThisCa(certificate)) return false; + try { + List usage = certificate.getExtendedKeyUsage(); + boolean[] keyUsage = certificate.getKeyUsage(); + if (usage == null || !usage.contains(KeyPurposeId.id_kp_clientAuth.getId()) || keyUsage == null || !keyUsage[0]) return false; + String expectedUri = "urn:votingplugin:http-backend:" + canonicalServerId(expectedServerId); + Collection> names = certificate.getSubjectAlternativeNames(); + if (names == null) return false; + for (List name : names) { + if (name.size() == 2 && Integer.valueOf(GeneralName.uniformResourceIdentifier).equals(name.get(0)) + && expectedUri.equals(name.get(1))) return true; + } + return false; + } catch (Exception failure) { return false; } + } + + public record IssuedClientCertificate(String serverId, X509Certificate certificate, byte[] pkcs12, char[] password) { + public IssuedClientCertificate { + pkcs12 = pkcs12.clone(); + password = password.clone(); + } + @Override public byte[] pkcs12() { return pkcs12.clone(); } + @Override public char[] password() { return password.clone(); } + } + + private static KeyPair keyPair() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator.initialize(new java.security.spec.ECGenParameterSpec("secp256r1")); + return generator.generateKeyPair(); + } + + private static X509Certificate certificate(String subject, KeyPair subjectKey, X509Certificate issuer, PrivateKey issuerKey, + CertificateRole role, String subjectAlternativeName, Instant now) throws Exception { + X500Name issuerName = issuer == null ? new X500Name(subject) : new X500Name(issuer.getSubjectX500Principal().getName()); + X509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(issuerName, + new BigInteger(160, new java.security.SecureRandom()).setBit(159), Date.from(now.minusSeconds(300)), + Date.from(now.plusSeconds(role == CertificateRole.CA ? 315360000L : 31536000L)), new X500Name(subject), subjectKey.getPublic()); + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(role == CertificateRole.CA)); + builder.addExtension(Extension.keyUsage, true, new KeyUsage(role == CertificateRole.CA ? KeyUsage.keyCertSign | KeyUsage.cRLSign + : KeyUsage.digitalSignature)); + if (role == CertificateRole.SERVER) builder.addExtension(Extension.extendedKeyUsage, false, + new ExtendedKeyUsage(KeyPurposeId.id_kp_serverAuth)); + if (role == CertificateRole.CLIENT) builder.addExtension(Extension.extendedKeyUsage, false, + new ExtendedKeyUsage(KeyPurposeId.id_kp_clientAuth)); + if (role == CertificateRole.SERVER && subjectAlternativeName != null) { + GeneralName name; + if (subjectAlternativeName.matches("(?:\\d{1,3}\\.){3}\\d{1,3}") || subjectAlternativeName.indexOf(':') >= 0) + name = new GeneralName(GeneralName.iPAddress, subjectAlternativeName); + else name = new GeneralName(GeneralName.dNSName, subjectAlternativeName); + builder.addExtension(Extension.subjectAlternativeName, false, new GeneralNames(name)); + } + if (role == CertificateRole.CLIENT) builder.addExtension(Extension.subjectAlternativeName, false, + new GeneralNames(new GeneralName(GeneralName.uniformResourceIdentifier, subjectAlternativeName))); + ContentSigner signer = new JcaContentSignerBuilder("SHA256withECDSA").setProvider("BC") + .build(issuerKey == null ? subjectKey.getPrivate() : issuerKey); + X509CertificateHolder holder = builder.build(signer); + return new JcaX509CertificateConverter().setProvider("BC").getCertificate(holder); + } + + static boolean needsRenewal(X509Certificate certificate, Clock clock) { + return certificate == null || !certificate.getNotAfter().toInstant().isAfter(clock.instant().plus(RENEW_BEFORE)); + } + + static boolean needsCaRenewal(X509Certificate certificate, Clock clock) { + return certificate == null || !certificate.getNotAfter().toInstant().isAfter(clock.instant().plus(CA_RENEW_BEFORE)); + } + + private static void ensureBouncyCastle() { + if (Security.getProvider("BC") == null) Security.addProvider(new BouncyCastleProvider()); + } + + private static String certificateName(String host) { + return host.replaceAll("[^A-Za-z0-9 ._-]", "_"); + } + + private static boolean hasServerName(X509Certificate certificate, String advertisedHost) { + try { + Collection> names = certificate.getSubjectAlternativeNames(); + if (names == null) return false; + for (List name : names) { + if (name.size() != 2 || !(name.get(1) instanceof String value)) continue; + if ((Integer.valueOf(GeneralName.dNSName).equals(name.get(0)) || Integer.valueOf(GeneralName.iPAddress).equals(name.get(0))) + && advertisedHost.equalsIgnoreCase(value)) return true; + } + return false; + } catch (Exception failure) { return false; } + } + + private static Path safe(Path file) throws IOException { + Path parent = file.toAbsolutePath().normalize().getParent(); + if (parent == null || Files.isSymbolicLink(file)) throw new IOException("Refusing unsafe HTTP TLS identity path"); + return file.toAbsolutePath().normalize(); + } + + private static KeyStore load(Path path, char[] password) throws Exception { + KeyStore store = KeyStore.getInstance("PKCS12"); + try (var input = Files.newInputStream(path, LinkOption.NOFOLLOW_LINKS)) { store.load(input, password); } + return store; + } + + private static char[] readPassword(Path path) throws IOException { + byte[] bytes = Files.readAllBytes(path); + if (bytes.length < 40 || bytes.length > 128) throw new IOException("HTTP TLS password file is invalid"); + try { return new String(bytes, java.nio.charset.StandardCharsets.US_ASCII).toCharArray(); } + finally { Arrays.fill(bytes, (byte) 0); } + } + + private static void writeStore(Path file, KeyStore store, char[] password) throws Exception { + java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream(); + store.store(bytes, password); + byte[] contents = bytes.toByteArray(); + try { writePrivate(file, contents); } + finally { Arrays.fill(contents, (byte) 0); } + } + + private static void writePrivate(Path file, byte[] contents) throws IOException { + Path temporary = Files.createTempFile(file.getParent(), file.getFileName().toString(), ".tmp"); + try { + setOwnerOnly(temporary); + Files.write(temporary, contents, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING); + DurableFiles.forceFile(temporary); + try { Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } + catch (java.nio.file.AtomicMoveNotSupportedException unsupported) { Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING); } + setOwnerOnly(file); + DurableFiles.forceDirectory(file.getParent()); + } finally { Files.deleteIfExists(temporary); } + } + + private static byte[] asciiBytes(char[] characters) { + byte[] output = new byte[characters.length]; + for (int index = 0; index < characters.length; index++) output[index] = (byte) characters[index]; + return output; + } + + private static void setOwnerOnly(Path path) throws IOException { + try { Files.setPosixFilePermissions(path, EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)); } + catch (UnsupportedOperationException ignored) { /* Windows ACLs are inherited; never make the file world-readable. */ } + } + + private final class RotatingServerKeyManager extends X509ExtendedKeyManager { + private static final String ALIAS = "server"; + private void refresh() { + refreshIdentity(); + } + private String alias(String keyType) { + refresh(); + return keyType != null && ("EC".equalsIgnoreCase(keyType) || keyType.toUpperCase(java.util.Locale.ROOT).startsWith("EC_")) + ? ALIAS : null; + } + @Override public String[] getClientAliases(String keyType, Principal[] issuers) { return null; } + @Override public String chooseClientAlias(String[] keyTypes, Principal[] issuers, Socket socket) { return null; } + @Override public String[] getServerAliases(String keyType, Principal[] issuers) { + return alias(keyType) == null ? null : new String[] { ALIAS }; + } + @Override public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) { return alias(keyType); } + @Override public String chooseEngineServerAlias(String keyType, Principal[] issuers, SSLEngine engine) { return alias(keyType); } + @Override public X509Certificate[] getCertificateChain(String alias) { + refresh(); + return ALIAS.equals(alias) ? new X509Certificate[] { serverCertificate, caCertificate } : null; + } + @Override public PrivateKey getPrivateKey(String alias) { refresh(); return ALIAS.equals(alias) ? serverKey : null; } + } + + private enum CertificateRole { CA, SERVER, CLIENT } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/http/HttpTransportProtocol.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/http/HttpTransportProtocol.java new file mode 100644 index 000000000..b3c825cdd --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/http/HttpTransportProtocol.java @@ -0,0 +1,231 @@ +package com.bencodez.votingplugin.backendproxy.http; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +/** Strict, versioned HTTP transport envelope. Payloads remain canonical JsonEnvelopeCodec values. */ +final class HttpTransportProtocol { + static final int VERSION = 1; + static final int MAX_BODY_BYTES = 256 * 1024; + static final int MAX_BATCH = 64; + static final int MAX_ENVELOPE_BYTES = 48 * 1024; + static final int MAX_QUEUE = 1024; + static final long MAX_CLOCK_SKEW_MILLIS = 90_000L; + + private HttpTransportProtocol() { } + + static void validateEnvelope(JsonEnvelope envelope) { + if (envelope == null || JsonEnvelopeCodec.encode(envelope).getBytes(StandardCharsets.UTF_8).length > MAX_ENVELOPE_BYTES) throw bad(); + } + + static byte[] request(String server, String session, long sequence, Collection acks, + Collection messages) { + JsonObject root = base(server, session, sequence); + root.add("acks", ids(acks)); + root.add("messages", messages(messages)); + byte[] encoded = root.toString().getBytes(StandardCharsets.UTF_8); + if (encoded.length > MAX_BODY_BYTES) throw bad(); + return encoded; + } + + static List fittingMessages(String server, String session, long sequence, Collection acks, + Collection candidates) { + List output = new ArrayList<>(); + for (Delivery candidate : candidates) { + if (output.size() == MAX_BATCH) break; + output.add(candidate); + try { request(server, session, sequence, acks, output); } + catch (IllegalArgumentException tooLarge) { output.remove(output.size() - 1); break; } + } + return output; + } + + static byte[] storedDelivery(Delivery delivery) { + validId(delivery.id()); + validateEnvelope(delivery.envelope()); + JsonObject root = new JsonObject(); + root.addProperty("v", VERSION); + root.addProperty("id", delivery.id()); + root.addProperty("payload", Base64.getUrlEncoder().withoutPadding().encodeToString( + JsonEnvelopeCodec.encode(delivery.envelope()).getBytes(StandardCharsets.UTF_8))); + return root.toString().getBytes(StandardCharsets.UTF_8); + } + + static Delivery parseStoredDelivery(byte[] body) { + if (body == null || body.length == 0 || body.length > MAX_ENVELOPE_BYTES * 2) throw bad(); + try { + JsonObject root = JsonParser.parseString(new String(body, StandardCharsets.UTF_8)).getAsJsonObject(); + requireOnly(root, "v", "id", "payload"); + if (integer(root, "v") != VERSION) throw bad(); + String id = string(root, "id", 64); validId(id); + byte[] payload = Base64.getUrlDecoder().decode(string(root, "payload", MAX_ENVELOPE_BYTES * 2)); + if (payload.length == 0 || payload.length > MAX_ENVELOPE_BYTES) throw bad(); + return new Delivery(id, JsonEnvelopeCodec.decode(new String(payload, StandardCharsets.UTF_8))); + } catch (RuntimeException invalid) { throw bad(); } + } + + static byte[] response(String server, String session, long sequence, Collection acks, + Collection messages) { + return request(server, session, sequence, acks, messages); + } + + static Packet parsePacket(byte[] body) { + if (body == null || body.length == 0 || body.length > MAX_BODY_BYTES) throw bad(); + try { + JsonElement parsed = JsonParser.parseString(new String(body, StandardCharsets.UTF_8)); + if (!parsed.isJsonObject()) throw bad(); + JsonObject root = parsed.getAsJsonObject(); + requireOnly(root, "v", "server", "session", "sequence", "timestamp", "acks", "messages"); + if (integer(root, "v") != VERSION) throw bad(); + String server = HttpTlsIdentity.canonicalServerId(string(root, "server", 64)); + String session = uuid(root, "session"); + long sequence = nonNegative(root, "sequence"); + long timestamp = integer(root, "timestamp"); + long now = Instant.now().toEpochMilli(); + if (timestamp < now - MAX_CLOCK_SKEW_MILLIS || timestamp > now + MAX_CLOCK_SKEW_MILLIS) throw bad(); + List acks = parseIds(root.get("acks")); + List messages = parseMessages(root.get("messages")); + return new Packet(server, session, sequence, acks, messages); + } catch (RuntimeException invalid) { throw bad(); } + } + + static byte[] enrollmentResponse(HttpTlsIdentity.IssuedClientCertificate certificate) { + JsonObject output = new JsonObject(); + byte[] bundle = certificate.pkcs12(); + try { output.addProperty("bundle", Base64.getUrlEncoder().withoutPadding().encodeToString(bundle)); } + finally { java.util.Arrays.fill(bundle, (byte) 0); } + char[] password = certificate.password(); + try { output.addProperty("password", new String(password)); } + finally { java.util.Arrays.fill(password, '\0'); } + return output.toString().getBytes(StandardCharsets.UTF_8); + } + + static Enrollment parseEnrollment(byte[] body) { + if (body == null || body.length == 0 || body.length > 8192) throw bad(); + try { + JsonElement parsed = JsonParser.parseString(new String(body, StandardCharsets.UTF_8)); + if (!parsed.isJsonObject()) throw bad(); + JsonObject root = parsed.getAsJsonObject(); + requireOnly(root, "server", "token"); + String server = HttpTlsIdentity.canonicalServerId(string(root, "server", 64)); + String token = string(root, "token", 128); + if (!token.matches("[A-Za-z0-9_-]{43,128}")) throw bad(); + return new Enrollment(server, token); + } catch (RuntimeException invalid) { throw bad(); } + } + + static byte[] renewalRequest(String server) { + JsonObject root = new JsonObject(); + root.addProperty("server", HttpTlsIdentity.canonicalServerId(server)); + return root.toString().getBytes(StandardCharsets.UTF_8); + } + + static String parseRenewal(byte[] body) { + if (body == null || body.length == 0 || body.length > 1024) throw bad(); + try { + JsonElement parsed = JsonParser.parseString(new String(body, StandardCharsets.UTF_8)); + if (!parsed.isJsonObject()) throw bad(); + JsonObject root = parsed.getAsJsonObject(); + requireOnly(root, "server"); + return HttpTlsIdentity.canonicalServerId(string(root, "server", 64)); + } catch (RuntimeException invalid) { throw bad(); } + } + + static HttpTlsIdentity.IssuedClientCertificate parseEnrollmentResponse(String server, byte[] body) { + if (body == null || body.length == 0 || body.length > MAX_BODY_BYTES) throw bad(); + try { + JsonObject root = JsonParser.parseString(new String(body, StandardCharsets.UTF_8)).getAsJsonObject(); + requireOnly(root, "bundle", "password"); + byte[] bundle = Base64.getUrlDecoder().decode(string(root, "bundle", MAX_BODY_BYTES * 2)); + char[] password = string(root, "password", 128).toCharArray(); + if (bundle.length == 0 || password.length < 40) throw bad(); + try { return new HttpTlsIdentity.IssuedClientCertificate(HttpTlsIdentity.canonicalServerId(server), null, bundle, password); } + finally { java.util.Arrays.fill(bundle, (byte) 0); java.util.Arrays.fill(password, '\0'); } + } catch (RuntimeException invalid) { throw bad(); } + } + + private static JsonObject base(String server, String session, long sequence) { + JsonObject root = new JsonObject(); + root.addProperty("v", VERSION); root.addProperty("server", server); root.addProperty("session", session); + root.addProperty("sequence", sequence); root.addProperty("timestamp", Instant.now().toEpochMilli()); + return root; + } + private static JsonArray ids(Collection values) { + if (values == null || values.size() > MAX_BATCH) throw bad(); + JsonArray output = new JsonArray(); + for (String value : values) { validId(value); output.add(value); } + return output; + } + private static JsonArray messages(Collection values) { + if (values == null || values.size() > MAX_BATCH) throw bad(); + JsonArray output = new JsonArray(); + for (Delivery delivery : values) { + validId(delivery.id()); + String encoded = JsonEnvelopeCodec.encode(delivery.envelope()); + byte[] bytes = encoded.getBytes(StandardCharsets.UTF_8); + if (bytes.length > MAX_ENVELOPE_BYTES) throw bad(); + JsonObject item = new JsonObject(); item.addProperty("id", delivery.id()); + item.addProperty("payload", Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)); output.add(item); + } + return output; + } + private static List parseIds(JsonElement value) { + if (value == null || !value.isJsonArray() || value.getAsJsonArray().size() > MAX_BATCH) throw bad(); + List output = new ArrayList<>(); + for (JsonElement item : value.getAsJsonArray()) { if (!item.isJsonPrimitive()) throw bad(); String id = item.getAsString(); validId(id); output.add(id); } + return output; + } + private static List parseMessages(JsonElement value) { + if (value == null || !value.isJsonArray() || value.getAsJsonArray().size() > MAX_BATCH) throw bad(); + List output = new ArrayList<>(); + for (JsonElement item : value.getAsJsonArray()) { + if (!item.isJsonObject()) throw bad(); JsonObject object = item.getAsJsonObject(); requireOnly(object, "id", "payload"); + String id = string(object, "id", 64); validId(id); + byte[] payload = Base64.getUrlDecoder().decode(string(object, "payload", MAX_ENVELOPE_BYTES * 2)); + if (payload.length == 0 || payload.length > MAX_ENVELOPE_BYTES) throw bad(); + JsonEnvelope envelope = JsonEnvelopeCodec.decode(new String(payload, StandardCharsets.UTF_8)); + output.add(new Delivery(id, envelope)); + } + return output; + } + private static void requireOnly(JsonObject object, String... names) { + for (String name : object.keySet()) { boolean found = false; for (String allowed : names) if (allowed.equals(name)) { found = true; break; } if (!found) throw bad(); } + for (String name : names) if (!object.has(name) || object.get(name).isJsonNull()) throw bad(); + } + private static String string(JsonObject object, String name, int max) { JsonElement v = object.get(name); if (!v.isJsonPrimitive() || !v.getAsJsonPrimitive().isString()) throw bad(); String value = v.getAsString(); if (value.isEmpty() || value.length() > max) throw bad(); return value; } + private static long integer(JsonObject object, String name) { + try { + JsonElement value = object.get(name); + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) throw bad(); + String token = value.getAsString(); + if (!token.matches("-?(?:0|[1-9][0-9]*)")) throw bad(); + return Long.parseLong(token); + } catch (RuntimeException failure) { throw bad(); } + } + private static long nonNegative(JsonObject object, String name) { long n = integer(object, name); if (n < 0) throw bad(); return n; } + private static String uuid(JsonObject object, String name) { return canonicalUuid(string(object, name, 64)); } + private static void validId(String id) { if (id == null || id.length() > 64) throw bad(); canonicalUuid(id); } + private static String canonicalUuid(String value) { + try { + UUID parsed = UUID.fromString(value); + if (!parsed.toString().equals(value)) throw bad(); + return value; + } catch (IllegalArgumentException invalid) { throw bad(); } + } + private static IllegalArgumentException bad() { return new IllegalArgumentException("Invalid HTTP transport message"); } + + record Delivery(String id, JsonEnvelope envelope) { } + record Packet(String server, String session, long sequence, List acks, List messages) { } + record Enrollment(String server, String token) { } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/http/HttpTransportSecrets.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/http/HttpTransportSecrets.java new file mode 100644 index 000000000..f1a8ec04b --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/http/HttpTransportSecrets.java @@ -0,0 +1,64 @@ +package com.bencodez.votingplugin.backendproxy.http; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.util.Base64; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** Small, deliberately dependency-free cryptographic helpers for the HTTP transport. */ +final class HttpTransportSecrets { + private static final SecureRandom RANDOM = new SecureRandom(); + + private HttpTransportSecrets() { } + + static byte[] randomBytes(int length) { + if (length < 16) throw new IllegalArgumentException("Secret length is too small"); + byte[] value = new byte[length]; + RANDOM.nextBytes(value); + return value; + } + + static String randomToken() { + return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes(32)); + } + + static byte[] sha256(byte[] value) { + try { + return MessageDigest.getInstance("SHA-256").digest(value); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 is unavailable", impossible); + } + } + + static String sha256Hex(byte[] value) { + StringBuilder output = new StringBuilder(64); + for (byte part : sha256(value)) output.append(String.format("%02x", part & 0xff)); + return output.toString(); + } + + static String certificatePin(X509Certificate certificate) { + try { + return sha256Hex(certificate.getEncoded()); + } catch (Exception failure) { + throw new IllegalArgumentException("Could not encode certificate", failure); + } + } + + static boolean constantTimeEquals(byte[] first, byte[] second) { + return first != null && second != null && MessageDigest.isEqual(first, second); + } + + static String hmacSha256Url(byte[] key, String value) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + return Base64.getUrlEncoder().withoutPadding().encodeToString(mac.doFinal(value.getBytes(StandardCharsets.US_ASCII))); + } catch (Exception failure) { + throw new IllegalStateException("HMAC-SHA-256 is unavailable", failure); + } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/BackendProxyTransportManager.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/BackendProxyTransportManager.java index 4d7b20872..6446a23d0 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/BackendProxyTransportManager.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/BackendProxyTransportManager.java @@ -19,6 +19,7 @@ public class BackendProxyTransportManager { private final VotingPluginMain plugin; private final ProcessedVoteCache processedVoteCache; private BackendProxyTransport transport; + private BackendProxyTransport preparedTransport; public BackendProxyTransportManager(VotingPluginMain plugin) { this(plugin, new ProcessedVoteCache()); @@ -41,6 +42,9 @@ public void start(BungeeMethod method, GlobalMessageHandler messageHandler) { case SOCKETS: transport = new SocketBackendProxyTransport(plugin); break; + case HTTP: + transport = new HttpBackendProxyTransport(plugin); + break; case REDIS: transport = new RedisBackendProxyTransport(plugin, processedVoteCache); break; @@ -64,20 +68,40 @@ public void close() { transport.close(); transport = null; } + if (preparedTransport != null) { + preparedTransport.close(); + preparedTransport = null; + } } public void validate() { + validate(System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(25)); + } + + public void validate(long deadlineNanos) { if (transport == null) throw new IllegalStateException("Backend proxy transport was not initialized"); - transport.validate(); + if (transport instanceof HttpBackendProxyTransport http) http.validate(deadlineNanos); + else transport.validate(); } public void prepareForReplacement() { if (transport != null) { transport.prepareForReplacement(); + preparedTransport = transport; transport = null; } } + public void restorePreparedTransport() { + if (transport != null || preparedTransport == null) return; + if (preparedTransport instanceof HttpBackendProxyTransport http) { + transport = http.recreatePrepared(); + } else { + throw new IllegalStateException("Prepared backend proxy transport cannot be restored"); + } + preparedTransport = null; + } + public void closeRedisForHandoff() { if (!(transport instanceof RedisBackendProxyTransport)) { throw new IllegalStateException("Redis backend proxy transport is unavailable"); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/HttpBackendProxyTransport.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/HttpBackendProxyTransport.java new file mode 100644 index 000000000..e1dada297 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/HttpBackendProxyTransport.java @@ -0,0 +1,216 @@ +package com.bencodez.votingplugin.backendproxy.transport; + +import java.nio.file.Path; +import java.time.Clock; +import java.util.ArrayDeque; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.simpleapi.servercomm.global.GlobalMessageHandler; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.backendproxy.http.HttpBackendTransportConnector; +import com.bencodez.votingplugin.backendproxy.http.HttpClientCredentialStore; +import com.bencodez.votingplugin.backendproxy.http.HttpConnectionCode; + +/** Backend adapter for the secure outbound-only HTTP proxy transport. */ +public final class HttpBackendProxyTransport implements BackendProxyTransport { + private static final int MAX_STARTUP_QUEUE = 1024; + private static final long DEFAULT_STARTUP_VALIDATION_SECONDS = 25L; + private static final ConcurrentHashMap DIRECTORY_OWNERS = new ConcurrentHashMap<>(); + private final VotingPluginMain plugin; + private final Object lifecycle = new Object(); + private final CountDownLatch startupComplete = new CountDownLatch(1); + private final ArrayDeque startupQueue = new ArrayDeque<>(); + private volatile HttpBackendTransportConnector connector; + private volatile Thread worker; + private volatile RuntimeException startupFailure; + private volatile boolean started; + private volatile boolean closed; + private Path configuredDirectory; + private String configuredServerId; + private String configuredConnectionCode; + private GlobalMessageHandler configuredMessageHandler; + private Semaphore directoryOwner; + private final java.util.concurrent.atomic.AtomicBoolean queueWarning = new java.util.concurrent.atomic.AtomicBoolean(); + + public HttpBackendProxyTransport(VotingPluginMain plugin) { + this.plugin = plugin; + } + + @Override + public void start(GlobalMessageHandler messageHandler) { + Path directory = plugin.getDataFolder().toPath().resolve("http"); + String serverId = plugin.getBungeeSettings().getServer(); + String connectionCode = plugin.getBungeeSettings().getHttpConnectionCode(); + start(directory, serverId, connectionCode, messageHandler); + } + + private void start(Path directory, String serverId, String connectionCode, + GlobalMessageHandler messageHandler) { + validateConfiguration(directory, serverId, connectionCode); + configuredDirectory = directory; + configuredServerId = serverId; + configuredConnectionCode = connectionCode; + configuredMessageHandler = messageHandler; + started = true; + worker = new Thread(() -> initialize(directory, serverId, connectionCode, messageHandler), + "VotingPlugin-HTTP-Backend-Setup"); + worker.setDaemon(true); + worker.start(); + } + + HttpBackendProxyTransport recreatePrepared() { + HttpBackendProxyTransport restored = new HttpBackendProxyTransport(plugin); + restored.start(configuredDirectory, configuredServerId, configuredConnectionCode, configuredMessageHandler); + return restored; + } + + private void initialize(Path directory, String serverId, String configuredCode, + GlobalMessageHandler messageHandler) { + Path ownerKey = directory.toAbsolutePath().normalize(); + Semaphore owner = DIRECTORY_OWNERS.computeIfAbsent(ownerKey, ignored -> new Semaphore(1)); + boolean acquired = false, installed = false; + HttpBackendTransportConnector replacement = null; + try { + owner.acquire(); + acquired = true; + synchronized (lifecycle) { if (closed) return; } + HttpConnectionCode code = enrollmentCode(directory, serverId, configuredCode); + if (code != null) HttpBackendTransportConnector.enroll(code, serverId, directory); + HttpClientCredentialStore.EnrolledClient enrolled = HttpClientCredentialStore.loadEnrolled(directory); + if (!enrolled.profile().serverId().equals(com.bencodez.votingplugin.backendproxy.http.HttpTlsIdentity.canonicalServerId(serverId))) + throw new IllegalStateException("Persisted HTTP identity belongs to a different backend Server name"); + replacement = new HttpBackendTransportConnector(directory, messageHandler::onMessage); + replacement.start(); + if (!replacement.awaitFirstResponse(System.nanoTime() + + TimeUnit.SECONDS.toNanos(DEFAULT_STARTUP_VALIDATION_SECONDS))) { + throw new IllegalStateException("HTTP backend could not authenticate with the proxy"); + } + boolean discard = false; + synchronized (lifecycle) { + if (closed) { + discard = true; + } else { + while (!startupQueue.isEmpty()) { + if (!replacement.send(startupQueue.removeFirst())) { + throw new IllegalStateException("HTTP startup queue could not be transferred"); + } + } + connector = replacement; + directoryOwner = owner; + installed = true; + } + } + if (discard) replacement.close(); + } catch (Exception failure) { + startupFailure = new IllegalStateException("Secure HTTP backend enrollment or connection failed", failure); + plugin.getLogger().severe("Secure HTTP backend transport is unavailable; check the connection code and proxy endpoint"); + } finally { + if (!installed) { + if (replacement != null) replacement.close(); + if (acquired) owner.release(); + } + startupComplete.countDown(); + } + } + + @Override + public void send(JsonEnvelope envelope) { + synchronized (lifecycle) { + if (closed) return; + HttpBackendTransportConnector active = connector; + if (active != null) { + if (!active.send(envelope) && queueWarning.compareAndSet(false, true)) + plugin.getLogger().severe("Secure HTTP transport queue is full or rejected an oversized message; delivery was not accepted"); + } else if (startupQueue.size() < MAX_STARTUP_QUEUE) { + startupQueue.addLast(envelope); + } + } + } + + @Override + public void validate() { + validate(System.nanoTime() + TimeUnit.SECONDS.toNanos(DEFAULT_STARTUP_VALIDATION_SECONDS)); + } + + void validate(long deadlineNanos) { + String serverId = plugin.getBungeeSettings().getServer(); + if (serverId == null || !serverId.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}")) { + throw new IllegalStateException("HTTP requires a valid unique backend Server name"); + } + Path directory = plugin.getDataFolder().toPath().resolve("http"); + validateConfiguration(directory, serverId, plugin.getBungeeSettings().getHttpConnectionCode()); + if (!started) return; + try { + long remaining = deadlineNanos - System.nanoTime(); + if (remaining <= 0L || !startupComplete.await(remaining, TimeUnit.NANOSECONDS)) + throw new IllegalStateException("Secure HTTP backend setup did not finish within the validation deadline"); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Secure HTTP backend setup validation was interrupted", interrupted); + } + RuntimeException failure = startupFailure; + if (failure != null) throw failure; + if (closed || connector == null) throw new IllegalStateException("Secure HTTP backend transport did not become ready"); + } + + public static void validateConfiguration(Path directory, String serverId, String configuredCode) { + enrollmentCode(directory, serverId, configuredCode); + } + + static HttpConnectionCode enrollmentCode(Path directory, String serverId, String configuredCode) { + try { serverId = com.bencodez.votingplugin.backendproxy.http.HttpTlsIdentity.canonicalServerId(serverId); } + catch (IllegalArgumentException invalid) { throw new IllegalStateException("HTTP requires a valid unique backend Server name", invalid); } + boolean enrolled = HttpClientCredentialStore.hasEnrolledProfile(directory); + if (configuredCode != null && !configuredCode.isBlank()) { + try { + HttpConnectionCode code = HttpConnectionCode.parse(configuredCode); + if (!code.serverId().equals(serverId)) + throw new IllegalArgumentException("Connection code belongs to a different backend"); + if (enrolled && HttpClientCredentialStore.matchesEnrollmentCode(directory, code)) return null; + code.requireActive(Clock.systemUTC()); + return code; + } catch (Exception invalid) { + throw new IllegalStateException("HTTP ConnectionCode is invalid, expired, or belongs to a different backend", invalid); + } + } + if (!enrolled) + throw new IllegalStateException("HTTP requires a temporary ConnectionCode for initial enrollment"); + return null; + } + + @Override + public void close() { + Thread setup; + HttpBackendTransportConnector active; + Semaphore owner; + synchronized (lifecycle) { + if (closed) return; + closed = true; + startupQueue.clear(); + setup = worker; + worker = null; + active = connector; + connector = null; + owner = directoryOwner; + directoryOwner = null; + } + startupComplete.countDown(); + if (setup != null) setup.interrupt(); + if (setup == null && active == null && owner == null) return; + Thread cleanup = new Thread(() -> drain(setup, active, owner), "VotingPlugin-HTTP-Backend-Cleanup"); + cleanup.setDaemon(true); + cleanup.start(); + } + + private static void drain(Thread setup, HttpBackendTransportConnector active, Semaphore owner) { + try { + if (setup != null) try { setup.join(TimeUnit.SECONDS.toMillis(5)); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + if (active != null) active.close(); + } finally { if (owner != null) owner.release(); } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/BungeeSettings.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/BungeeSettings.java index b9f8b1afc..5c8a05ab7 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/BungeeSettings.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/BungeeSettings.java @@ -88,7 +88,11 @@ public class BungeeSettings extends YMLFile { @ConfigDataInt(path = "BungeeServer.Port") @Getter - private int bungeeServerPort = 1297; + private int bungeeServerPort = 1297; + + @ConfigDataString(path = "HTTP.ConnectionCode") + @Getter + private String httpConnectionCode = ""; @ConfigDataBoolean(path = "PerServerPoints") @Getter diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java index fc00e3e9b..3c84caeb0 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java @@ -28,6 +28,7 @@ import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.file.YamlConfiguration; +import com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport; import com.bencodez.votingplugin.proxy.BungeeMethod; import com.bencodez.votingplugin.util.DurableFiles; @@ -47,7 +48,7 @@ public final class BackendConfigurationService { "WaitUntilVoteDelay", "PermissionToView", "IgnoreCanVote", "VoteDelayDailyHour", "VoteDelayMin", "GiveOffline"); private static final Pattern COMMENT_SECRET = Pattern.compile( - "(?i)([\"']?\\b(?:[\\w-]*(?:password|secret)[\\w-]*|token|api[ _.-]?key|authorization|[\\w.-]*webhook[ _.-]?url)" + "(?i)([\"']?\\b(?:[\\w-]*(?:password|secret)[\\w-]*|token|connection[ _.-]?code|api[ _.-]?key|authorization|[\\w.-]*webhook[ _.-]?url)" + "\\b[\"']?\\s*[:=]\\s*)(.*)$"); private static final Pattern SECRET_PATH_URL = Pattern.compile("(?i)([\"']?\\burl\\b[\"']?\\s*[:=]\\s*)(.*)$"); private static final Pattern BLOCK_SCALAR_INDICATOR = Pattern.compile("[|>](?:[+-][1-9]?|[1-9][+-]?)?"); @@ -592,6 +593,11 @@ private void validateProxyMethod(BungeeMethod method, YamlConfiguration settings case SOCKETS: configuredHostAndPort(settings, "BungeeServer.Host", "BungeeServer.Port", 1297, "BungeeServer"); break; + case HTTP: + String connectionCode = settings.getString("HTTP.ConnectionCode", ""); + try { HttpBackendProxyTransport.validateConfiguration(dataDirectory.resolve("http"), server, connectionCode); } + catch (IllegalStateException invalid) { throw new IllegalArgumentException(invalid.getMessage(), invalid); } + break; case MYSQL: try { YamlConfiguration main = parse(readRaw(resolve("Config.yml"), false)); @@ -957,7 +963,7 @@ private static List restoreCommentSecrets(List proposed, List reload; + long validationDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(29); + AtomicBoolean preparationAbandoned = new AtomicBoolean(); + AtomicReference preparedRestart = new AtomicReference<>(); + Future preparation; synchronized (operationLifecycle) { if (closed) throw new IllegalStateException("Bukkit Control connector is stopping"); - reload = plugin.getServer().getScheduler().callSyncMethod(plugin, () -> { + preparation = plugin.getServer().getScheduler().callSyncMethod(plugin, () -> { plugin.reloadFromControl(); - if ("BungeeSettings.yml".equals(fileName)) plugin.restartBackendProxyHandler(); - return null; + VotingPluginMain.BackendProxyRestart prepared = "BungeeSettings.yml".equals(fileName) + ? plugin.prepareBackendProxyHandlerRestart() : null; + preparedRestart.set(prepared); + if (prepared != null && preparationAbandoned.get()) { + plugin.abortBackendProxyHandlerRestart(prepared); + } + return prepared; }); - activeReload = reload; + activeReload = preparation; } + VotingPluginMain.BackendProxyRestart restart = null; + Future publication = null; try { - reload.get(30, TimeUnit.SECONDS); + restart = preparation.get(remaining(validationDeadline), TimeUnit.NANOSECONDS); + if (restart == null) return; + // Network enrollment/readiness is deliberately awaited on this Control worker, + // never on Bukkit's primary thread. + plugin.validateBackendProxyHandlerRestart(restart, validationDeadline); + VotingPluginMain.BackendProxyRestart prepared = restart; + publication = plugin.getServer().getScheduler().callSyncMethod(plugin, () -> { + plugin.completeBackendProxyHandlerRestart(prepared); + return null; + }); + synchronized (operationLifecycle) { + if (closed) publication.cancel(true); + activeReload = publication; + } + publication.get(remaining(validationDeadline), TimeUnit.NANOSECONDS); + } catch (Exception failure) { + // Timed-out Bukkit work must not remain queued ahead of configuration rollback. + preparationAbandoned.set(true); + preparation.cancel(false); + if (publication != null) publication.cancel(false); + if (restart == null) restart = preparedRestart.get(); + if (restart != null) { + VotingPluginMain.BackendProxyRestart prepared = restart; + try { + Future abort = plugin.getServer().getScheduler().callSyncMethod(plugin, () -> { + plugin.abortBackendProxyHandlerRestart(prepared); + return null; + }); + abort.get(5, TimeUnit.SECONDS); + } catch (Exception cleanupFailure) { failure.addSuppressed(cleanupFailure); } + } + throw failure; } finally { synchronized (operationLifecycle) { - if (activeReload == reload) activeReload = null; + activeReload = null; } } } + private static long remaining(long deadlineNanos) throws java.util.concurrent.TimeoutException { + long remaining = deadlineNanos - System.nanoTime(); + if (remaining <= 0L) throw new java.util.concurrent.TimeoutException("Bukkit configuration reload timed out"); + return remaining; + } + public static BackendControlConnector create(VotingPluginMain plugin) throws IOException { Path root = plugin.getDataFolder().toPath().toAbsolutePath().normalize(); BackendControlResultStore.State recovered = BackendControlResultStore.load(root); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java index 4928a28ae..aea4c5eff 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java @@ -73,11 +73,12 @@ public void run() { plugin.getServerData().addServiceSite(voteSite); if (plugin.getBungeeSettings().isUseBungeecoord() && !plugin.getBungeeSettings().isVotifierBypass() && (plugin.getBackendProxyHandler().getMethod().equals(BungeeMethod.PLUGINMESSAGING) - || plugin.getBackendProxyHandler().getMethod().equals(BungeeMethod.SOCKETS) + || plugin.getBackendProxyHandler().getMethod().equals(BungeeMethod.SOCKETS) + || plugin.getBackendProxyHandler().getMethod().equals(BungeeMethod.HTTP) || plugin.getBackendProxyHandler().getMethod().equals(BungeeMethod.MQTT) || plugin.getBackendProxyHandler().getMethod().equals(BungeeMethod.REDIS))) { plugin.getLogger().severe( - "Ignoring vote from votifier since pluginmessaging, socket, redis, or mqtt bungee method is enabled, this means you aren't setup correctly for those methods, please check: https://github.com/BenCodez/VotingPlugin/wiki/Bungeecord-Setups"); + "Ignoring vote from votifier since a proxy vote transport is enabled; receive votes on the proxy or enable VotifierBypass, then check: https://github.com/BenCodez/VotingPlugin/wiki/Bungeecord-Setups"); return; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/BungeeMethod.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/BungeeMethod.java index c2cb5d9c8..2142037e8 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/BungeeMethod.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/BungeeMethod.java @@ -8,9 +8,11 @@ public enum BungeeMethod { MYSQL, /** Plugin messaging channel. */ PLUGINMESSAGING, - /** Socket connection. */ - SOCKETS, - /** Redis connection. */ + /** Socket connection. */ + SOCKETS, + /** Encrypted single-port HTTP connector. */ + HTTP, + /** Redis connection. */ REDIS, /** MQTT message broker. */ MQTT; 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 841ed2eba..1a23fed4a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java @@ -64,6 +64,9 @@ import com.bencodez.simpleapi.sql.data.DataValueInt; import com.bencodez.simpleapi.sql.data.DataValueString; import com.bencodez.simpleapi.sql.mysql.config.MysqlConfig; +import com.bencodez.votingplugin.backendproxy.http.HttpEnrollmentAuthority; +import com.bencodez.votingplugin.backendproxy.http.HttpProxyTransportServer; +import com.bencodez.votingplugin.backendproxy.http.HttpTlsIdentity; import com.bencodez.votingplugin.proxy.broadcast.ProxyBroadcastDecider; import com.bencodez.votingplugin.proxy.cache.IVoteCache; import com.bencodez.votingplugin.proxy.cache.VoteCacheHandler; @@ -118,6 +121,8 @@ public abstract class VotingPluginProxy { private HashMap clientHandles; private SocketHandler socketHandler; + private HttpProxyTransportServer httpTransportServer; + private HttpEnrollmentAuthority httpEnrollmentAuthority; @Getter @Setter @@ -566,11 +571,31 @@ protected boolean sendProxyBroadcastEnvelopeNow(String server, JsonEnvelope enve // envelopes. This preserves the socket connection and its delivery // acknowledgement instead of creating a second short-lived socket. return sendSocketEnvelope(server, envelope); + case HTTP: + return sendHttpEnvelope(server, envelope); default: return false; } } + /** + * Sends a reward-bearing vote envelope and reports whether the selected + * transport accepted it. Legacy transports retain their existing asynchronous + * semantics; HTTP exposes its bounded-queue result so a vote is never discarded + * when the queue is full. + */ + protected boolean sendVoteEnvelopeAccepted(String server, int delay, JsonEnvelope envelope) { + if (method == BungeeMethod.HTTP) { + return sendHttpEnvelope(server, envelope); + } + GlobalMessageProxyHandler handler = globalMessageProxyHandler; + if (handler == null) { + return false; + } + handler.sendMessage(server, delay, envelope); + return true; + } + public synchronized void checkCachedVotes(String server) { int delay = 1; if (isServerValid(server)) { @@ -619,11 +644,14 @@ && getConfig().getProxyBroadcastEnabled()) { broadcastHere = proxyBroadcastDecider.shouldBroadcast(server, targets); } - globalMessageProxyHandler.sendMessage(server, delay, + if (!sendVoteEnvelopeAccepted(server, delay, VotingPluginWire.vote(cache.getPlayerName(), cache.getUuid(), cache.getService(), cache.getTime(), false, cache.isRealVote(), cache.getText(), cache.getVoteId(), getConfig().getBungeeManageTotals(), - broadcastHere, num, numberOfVotes)); + broadcastHere, num, numberOfVotes))) { + debug("Retaining cached vote because the transport rejected delivery for " + server); + continue; + } delay++; num++; removed.add(cache); @@ -681,10 +709,14 @@ && getConfig().getProxyBroadcastEnabled()) { } if (!cache.isRewardDelivered()) { - globalMessageProxyHandler.sendMessage(server, delay, + if (!sendVoteEnvelopeAccepted(server, delay, VotingPluginWire.voteOnline(cache.getPlayerName(), cache.getUuid(), cache.getService(), cache.getTime(), false, cache.isRealVote(), cache.getText(), cache.getVoteId(), - getConfig().getBungeeManageTotals(), broadcastHere, num, numberOfVotes)); + getConfig().getBungeeManageTotals(), broadcastHere, num, numberOfVotes))) { + debug("Retaining online vote because the transport rejected delivery for " + server); + retained.add(cache); + continue; + } // The normal envelope is also a valid broadcast delivery for the // current target. Record it so a previously pending standalone // retry cannot announce the same vote again later. @@ -1391,6 +1423,9 @@ public void sendMessage(String server, int delay, JsonEnvelope envelope) { case SOCKETS: sendSocketEnvelope(server, envelope); break; + case HTTP: + sendHttpEnvelope(server, envelope); + break; default: break; } @@ -1553,6 +1588,9 @@ public void onReceive(JsonEnvelope message) { PRESENCE_MAINTENANCE_INTERVAL_SECONDS); } startControlServices(); + // Open the listener last: backend callbacks can immediately reach routing, + // presence, vote-log, multi-proxy, and Control-adjacent runtime helpers. + if (method.equals(BungeeMethod.HTTP)) startHttpTransport(); debug("VotingPluginProxy loaded, ONLINEMODE: " + getConfig().getOnlineMode()); } @@ -2418,6 +2456,7 @@ public void completeRuntimeReplacementShutdown() { if (socketHandler != null) socketHandler.closeConnection(); }); runCleanup("socket clients", this::closeSocketClients); + runCleanup("HTTP transport", this::closeHttpTransport); runCleanup("Redis subscriber", () -> { if (redisHandler != null) redisHandler.close(); }); @@ -2653,6 +2692,82 @@ private synchronized boolean sendSocketEnvelope(String server, JsonEnvelope enve } } + private synchronized boolean sendHttpEnvelope(String server, JsonEnvelope envelope) { + HttpProxyTransportServer transport = httpTransportServer; + return transport != null && transport.send(server, envelope); + } + + private void startHttpTransport() { + try { + URI endpoint = URI.create(getConfig().getHttpPublicEndpoint()); + if (!"https".equalsIgnoreCase(endpoint.getScheme()) || endpoint.getHost() == null + || endpoint.getPort() == 0 || endpoint.getPort() > 65535 + || endpoint.getUserInfo() != null || endpoint.getQuery() != null || endpoint.getFragment() != null + || (endpoint.getPath() != null && !endpoint.getPath().isEmpty() && !"/".equals(endpoint.getPath()))) { + throw new IllegalArgumentException("HTTP.PublicEndpoint must be an HTTPS origin"); + } + File directory = new File(getDataFolderPlugin(), "http"); + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.toPath(), endpoint.getHost()); + httpEnrollmentAuthority = new HttpEnrollmentAuthority(identity, directory.toPath()); + httpTransportServer = new HttpProxyTransportServer( + new InetSocketAddress(getConfig().getHttpHost(), getConfig().getHttpPort()), identity, + httpEnrollmentAuthority, directory.toPath().resolve("outgoing-v1"), this::handleHttpTransportEnvelope); + httpTransportServer.start(); + logInfo("HTTP transport listening securely on " + getConfig().getHttpHost() + ":" + + httpTransportServer.port() + "; use /votingpluginbungee httpcode for each backend"); + } catch (Exception failure) { + closeHttpTransport(); + throw new IllegalStateException("HTTP transport could not start securely", failure); + } + } + + /** Keeps the authenticated mTLS backend identity attached to security-sensitive proxy routing. */ + protected void handleHttpTransportEnvelope(HttpProxyTransportServer.ReceivedEnvelope received) { + if (!isAuthenticatedHttpEnvelopeAllowed(received)) { + debug("Ignored HTTP envelope whose player-presence claim did not match its authenticated backend"); + return; + } + GlobalMessageProxyHandler handler = globalMessageProxyHandler; + if (handler == null) throw new IllegalStateException("HTTP message router is not ready"); + handler.onMessage(received.envelope()); + } + + private boolean isAuthenticatedHttpEnvelopeAllowed(HttpProxyTransportServer.ReceivedEnvelope received) { + if (received == null || received.envelope() == null || received.serverId() == null) return false; + String stampedServer = received.envelope().getFields().getOrDefault(VotingPluginWire.K_SERVER, ""); + if (!received.serverId().equalsIgnoreCase(stampedServer)) return false; + if (!VotingPluginWire.SUB_LOGIN.equals(received.envelope().getSubChannel())) return true; + VotingPluginWire.PlayerPresenceEvent event = VotingPluginWire.readPlayerPresenceEvent(received.envelope()); + boolean modern = event.connectionId != null || event.backendIncarnationId != null + || event.backendStartedAt != 0L || event.presenceTimestamp != 0L; + if (!modern || isDedicatedVotingProxyEnabled()) return true; + // A player-facing proxy has a stronger authority than any backend: its live + // player connection supplies both the current route and (in online mode) UUID. + return isLegacyLoginDestinationAuthoritative(event.player, event.uuid, received.serverId()); + } + + private synchronized void closeHttpTransport() { + HttpProxyTransportServer transport = httpTransportServer; + httpTransportServer = null; + httpEnrollmentAuthority = null; + if (transport != null) transport.close(); + } + + public String createHttpConnectionCode(String serverId) { + HttpEnrollmentAuthority authority = httpEnrollmentAuthority; + if (method != BungeeMethod.HTTP || authority == null) { + throw new IllegalStateException("The HTTP transport is not running"); + } + return authority.createConnectionCode(serverId, URI.create(getConfig().getHttpPublicEndpoint()), Duration.ofMinutes(15)) + .encode(); + } + + public void revokeHttpBackend(String serverId) { + HttpEnrollmentAuthority authority = httpEnrollmentAuthority; + if (method != BungeeMethod.HTTP || authority == null) throw new IllegalStateException("The HTTP transport is not running"); + authority.revoke(HttpTlsIdentity.canonicalServerId(serverId)); + } + private synchronized void closeSocketClients() { HashMap clients = clientHandles; clientHandles = null; @@ -2673,7 +2788,7 @@ static void stopSocketClients(Map clients) { private void warnUnsupportedDedicatedVotingProxyMode() { if (getConfig().getDedicatedVotingProxy() && (method == null || !method.supportsBackendPresence())) { - logSevere("DedicatedVotingProxy requires MYSQL, REDIS, MQTT, or SOCKETS; PLUGINMESSAGING is disabled for " + logSevere("DedicatedVotingProxy requires MYSQL, REDIS, MQTT, SOCKETS, or HTTP; PLUGINMESSAGING is disabled for " + "dedicated-proxy routing. Falling back to normal proxy routing."); } } @@ -3394,9 +3509,18 @@ private synchronized QueuedVoteResult vote(String player, String service, boolea broadcastHere = proxyBroadcastDecider.shouldBroadcast(s, targets); } - globalMessageProxyHandler.sendMessage(s, 2, + if (!sendVoteEnvelopeAccepted(s, 2, VotingPluginWire.vote(player, uuid, service, time, true, realVote, text.toString(), - voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1)); + voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1))) { + voteStatus = VoteLogStatus.CACHED; + boolean broadcastForwarded = standaloneProxyBroadcast + && broadcastForwardedServers.containsAll(proxyBroadcastTargets); + getVoteCacheHandler().addServerVote(s, + new OfflineBungeeVote(voteId, player, uuid, service, time, realVote, + text.toString(), broadcastForwarded, standaloneProxyBroadcast, + proxyBroadcastTargets, broadcastForwardedServers, false)); + debug("Caching vote after the transport rejected delivery for " + s); + } } } } else { @@ -3412,11 +3536,21 @@ private synchronized QueuedVoteResult vote(String player, String service, boolea broadcastHere = proxyBroadcastDecider.shouldBroadcast(server, targets); } - globalMessageProxyHandler.sendMessage(server, 1, + boolean rewardAccepted = sendVoteEnvelopeAccepted(server, 1, VotingPluginWire.voteOnline(player, uuid, service, time, true, realVote, text.toString(), voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1)); + if (!rewardAccepted) { + voteStatus = VoteLogStatus.CACHED; + boolean broadcastForwarded = standaloneProxyBroadcast + && broadcastForwardedServers.containsAll(proxyBroadcastTargets); + getVoteCacheHandler().addOnlineVote(uuid, + new OfflineBungeeVote(voteId, player, uuid, service, time, realVote, text.toString(), + broadcastForwarded, standaloneProxyBroadcast, proxyBroadcastTargets, + broadcastForwardedServers, false)); + debug("Caching online vote after the transport rejected delivery for " + server); + } - if (canValidateStandaloneBroadcast && getConfig().getProxyBroadcastEnabled() + if (rewardAccepted && canValidateStandaloneBroadcast && getConfig().getProxyBroadcastEnabled() && !standaloneProxyBroadcast) { Set targets = proxyBroadcastDecider.resolveTargets(true, playerServer); @@ -3438,7 +3572,7 @@ private synchronized QueuedVoteResult vote(String player, String service, boolea } // multiproxy: envelope-only clear vote - if (getConfig().getMultiProxySupport() && getConfig().getMultiProxyOneGlobalReward()) { + if (rewardAccepted && getConfig().getMultiProxySupport() && getConfig().getMultiProxyOneGlobalReward()) { multiProxyHandler.sendClearVote(uuid, player); } } else { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyCommand.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyCommand.java index 7420f0a49..069796fb6 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyCommand.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyCommand.java @@ -47,6 +47,24 @@ public String execute(String[] args) { case "status": return handleStatusCommand(); + case "httpcode": + if (args.length != 2) return "&cUsage: httpcode "; + try { + return "&aTemporary HTTP backend connection code (expires in 15 minutes and works once):\n&f" + + plugin.createHttpConnectionCode(args[1]); + } catch (IllegalArgumentException | IllegalStateException failure) { + return "&cThe secure HTTP transport is not running."; + } + + case "httprevoke": + if (args.length != 2) return "&cUsage: httprevoke "; + try { + plugin.revokeHttpBackend(args[1]); + return "&aRevoked HTTP backend identity for " + args[1] + ". Generate a new connection code to re-enroll it."; + } catch (IllegalArgumentException | IllegalStateException failure) { + return "&cCould not revoke that HTTP backend identity."; + } + case "multiproxystatus": plugin.getMultiProxyHandler().sendStatus(); return "&aSent status message across multi-proxy"; @@ -92,6 +110,8 @@ private String getHelpMessage() { helpBuilder.append("/votingplugin vote - Send a vote\n"); helpBuilder.append("/votingplugin forcetimechange - Force a time change\n"); helpBuilder.append("/votingplugin status - Check connection status\n"); + helpBuilder.append("/votingplugin httpcode - Create a node-bound one-time HTTP connection code\n"); + helpBuilder.append("/votingplugin httprevoke - Revoke a backend identity before re-enrollment\n"); helpBuilder.append("/votingplugin multiproxystatus - Send status message across proxies\n"); helpBuilder.append("/votingplugin voteparty - Trigger or modify vote party\n"); return helpBuilder.toString(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyConfig.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyConfig.java index 3c5eeff94..cd00c91e0 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyConfig.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyConfig.java @@ -241,6 +241,21 @@ default List getProxyBroadcastOfflineForwardServers() { */ public int getBungeePort(); + /** Bind address for the single-port HTTP transport listener. */ + default String getHttpHost() { + return "0.0.0.0"; + } + + /** Public HTTPS origin embedded in newly generated backend connection codes. */ + default String getHttpPublicEndpoint() { + return ""; + } + + /** Listener port for the single-port HTTP transport. */ + default int getHttpPort() { + return 1297; + } + /** * Gets the plugin message channel. * diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeConfig.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeConfig.java index ee1ff540a..4aa394cca 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeConfig.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeConfig.java @@ -209,9 +209,24 @@ public String getBungeeMethod() { } @Override - public int getBungeePort() { - return getData().getInt("BungeeServer.Port", 1297); - } + public int getBungeePort() { + return getData().getInt("BungeeServer.Port", 1297); + } + + @Override + public String getHttpHost() { + return getData().getString("HTTP.Host", "0.0.0.0"); + } + + @Override + public String getHttpPublicEndpoint() { + return getData().getString("HTTP.PublicEndpoint", ""); + } + + @Override + public int getHttpPort() { + return getData().getInt("HTTP.Port", 1297); + } @Override public boolean getDebug() { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationService.java index c98f054c8..d58041214 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationService.java @@ -58,6 +58,22 @@ private void validate(ProxyMethodConfiguration proposal, VotingPluginProxyConfig } } break; + case HTTP: + if (blank(config.getHttpHost()) || config.getHttpPort() < 1 || config.getHttpPort() > 65535) { + throw new IllegalArgumentException("HTTP.Host and HTTP.Port must be set"); + } + String endpoint = config.getHttpPublicEndpoint(); + if (blank(endpoint)) throw new IllegalArgumentException("HTTP.PublicEndpoint must be an HTTPS origin"); + URI httpEndpoint; + try { httpEndpoint = URI.create(endpoint); } + catch (RuntimeException invalid) { throw new IllegalArgumentException("HTTP.PublicEndpoint must be an HTTPS origin"); } + if (!"https".equalsIgnoreCase(httpEndpoint.getScheme()) || httpEndpoint.getHost() == null + || httpEndpoint.getPort() == 0 || httpEndpoint.getPort() > 65535 + || httpEndpoint.getUserInfo() != null || httpEndpoint.getQuery() != null || httpEndpoint.getFragment() != null + || (httpEndpoint.getPath() != null && !httpEndpoint.getPath().isEmpty() && !"/".equals(httpEndpoint.getPath()))) { + throw new IllegalArgumentException("HTTP.PublicEndpoint must be an HTTPS origin"); + } + break; case MYSQL: if (!config.hasDatabaseConfigured()) { throw new IllegalArgumentException("The proxy database Host must be configured for MYSQL"); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfig.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfig.java index 008aa7716..9b6077896 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfig.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfig.java @@ -360,6 +360,21 @@ public int getBungeePort() { return getInt(getNode("BungeeServer", "Port"), 1297); } + @Override + public String getHttpHost() { + return getString(getNode("HTTP", "Host"), "0.0.0.0"); + } + + @Override + public String getHttpPublicEndpoint() { + return getString(getNode("HTTP", "PublicEndpoint"), ""); + } + + @Override + public int getHttpPort() { + return getInt(getNode("HTTP", "Port"), 1297); + } + @Override public boolean getDebug() { return getBoolean(getNode("Debug"), false); diff --git a/VotingPlugin/src/main/resources/BungeeSettings.yml b/VotingPlugin/src/main/resources/BungeeSettings.yml index 036566edc..a8310126d 100644 --- a/VotingPlugin/src/main/resources/BungeeSettings.yml +++ b/VotingPlugin/src/main/resources/BungeeSettings.yml @@ -10,7 +10,7 @@ # 📑 Config Index (matches this file) # ───────────────────────────────────────────────────────────────────────────── # • Core proxy toggle (UseBungeecord) + method selection (BungeeMethod) -# • Method configs (PLUGINMESSAGING / REDIS / MQTT / MYSQL / SOCKETS) +# • Method configs (PLUGINMESSAGING / HTTP / REDIS / MQTT / MYSQL / SOCKETS) # • Broadcast behavior (BungeeBroadcast / DisableBroadcast / Always) # • Per-server behavior (PerServerRewards / Milestones / Points) # • VoteParty (proxy rewards + global commands) @@ -61,12 +61,19 @@ UseBungeecord: false # Requires restart and set on all servers # https://github.com/BenCodez/VotingPlugin/wiki/Bungeecord-Setups # Available: -# PLUGINMESSAGING (Recommended) -# REDIS (2nd Most Recommended) +# PLUGINMESSAGING (Recommended) +# HTTP (secure direct connection; one proxy port) +# REDIS (2nd Most Recommended) # MQTT # MYSQL # SOCKETS (Not recommended) -BungeeMethod: PLUGINMESSAGING +BungeeMethod: PLUGINMESSAGING + +# Secure direct transport. The proxy generates a temporary ConnectionCode. +# Paste it here once, start the backend, then remove it after enrollment succeeds. +# The backend stores its private client identity in the VotingPlugin data folder. +HTTP: + ConnectionCode: '' # Use Redis for between server communication, set BungeeMethod to REDIS to use this Redis: diff --git a/VotingPlugin/src/main/resources/bungeeconfig.yml b/VotingPlugin/src/main/resources/bungeeconfig.yml index 0c37b46fa..b500d4d7b 100644 --- a/VotingPlugin/src/main/resources/bungeeconfig.yml +++ b/VotingPlugin/src/main/resources/bungeeconfig.yml @@ -249,17 +249,31 @@ WhiteListedServers: [] # Requires restart and set on all servers # https://github.com/BenCodez/VotingPlugin/wiki/Bungeecoord-Setups # Available: -# PLUGINMESSAGING (Recommended) -# REDIS +# PLUGINMESSAGING (Recommended) +# HTTP (secure direct connection; one proxy port) +# REDIS # MQTT # SOCKETS # MYSQL (Not recommended) # PLUGINMESSAGING uses this proxy's player/server state and disables backend # presence messages. Other methods trust each backend's configured Server name. -BungeeMethod: PLUGINMESSAGING +BungeeMethod: PLUGINMESSAGING ###################################################################################### -# Settings for each setup below +# Settings for each setup below + +########################################### +# HTTP Settings +########################################### + +# HTTP uses one encrypted listener on the proxy. Backends connect outbound, so +# they do not need an exposed port. PublicEndpoint must be the HTTPS URL that +# backend servers can reach. VotingPlugin creates the TLS identity, enrollment +# codes, and per-backend client certificates automatically. +HTTP: + Host: '0.0.0.0' + Port: 1297 + PublicEndpoint: '' ########################################### # PLUGINMESSAGING settings diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/http/HttpTransportRuntimeTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/http/HttpTransportRuntimeTest.java new file mode 100644 index 000000000..3c2658a54 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/http/HttpTransportRuntimeTest.java @@ -0,0 +1,594 @@ +package com.bencodez.votingplugin.backendproxy.http; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import java.net.InetSocketAddress; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class HttpTransportRuntimeTest { + @TempDir Path directory; + + @Test + void enrollsThenDeliversBothDirectionsWithAuthenticatedIdentity() throws Exception { + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("proxy"), "localhost"); + HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(identity, directory.resolve("authority")); + CountDownLatch proxyReceived = new CountDownLatch(1), backendReceived = new CountDownLatch(1); + AtomicReference received = new AtomicReference<>(); + try (HttpProxyTransportServer server = new HttpProxyTransportServer(new InetSocketAddress("localhost", 0), identity, authority, + message -> { received.set(message); proxyReceived.countDown(); })) { + server.start(); + HttpConnectionCode code = authority.createConnectionCode("lobby-1", server.endpoint("localhost"), Duration.ofMinutes(5)); + HttpBackendTransportConnector.enroll(code, "lobby-1", directory.resolve("client")); + try (HttpBackendTransportConnector connector = new HttpBackendTransportConnector(directory.resolve("client"), + envelope -> backendReceived.countDown())) { + connector.start(); + assertTrue(connector.awaitFirstResponse(System.nanoTime() + TimeUnit.SECONDS.toNanos(8)), + "an authenticated transport response must make the connector ready"); + assertTrue(connector.send(JsonEnvelope.builder("to-proxy").put("server", "forged").build())); + assertTrue(proxyReceived.await(8, TimeUnit.SECONDS)); + assertEquals("lobby-1", received.get().serverId()); + assertEquals("lobby-1", received.get().envelope().getFields().get("server")); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3); + while (connector.queuedOutgoing() != 0 && System.nanoTime() < deadline) Thread.sleep(10); + assertEquals(0, connector.queuedOutgoing(), "proxy ACK must remove the exact outbound delivery ID"); + assertTrue(server.send("lobby-1", JsonEnvelope.builder("to-backend").build())); + assertTrue(backendReceived.await(8, TimeUnit.SECONDS)); + Path inboundFence = directory.resolve("client").resolve("http-transport-inbound-deliveries"); + long fenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (countRegularFiles(inboundFence) != 0L && System.nanoTime() < fenceDeadline) Thread.sleep(20); + assertEquals(0L, countRegularFiles(inboundFence), "a confirmed ACK must remove the backend replay fence"); + } + } + } + + @Test + void normalTransportRejectsAClientWithoutCertificate() throws Exception { + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("proxy"), "localhost"); + HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(identity, directory.resolve("authority")); + try (HttpProxyTransportServer server = new HttpProxyTransportServer(new InetSocketAddress("localhost", 0), identity, authority, ignored -> { })) { + server.start(); + HttpConnectionCode code = authority.createConnectionCode("lobby-1", server.endpoint("localhost"), Duration.ofMinutes(5)); + HttpClient client = HttpClient.newBuilder().sslContext(HttpPinnedTls.clientContext(code)).build(); + byte[] body = HttpTransportProtocol.request("lobby-1", java.util.UUID.randomUUID().toString(), 0, java.util.List.of(), java.util.List.of()); + HttpResponse response = client.send(HttpRequest.newBuilder(code.endpoint().resolve("v1/transport")) + .timeout(Duration.ofSeconds(5)).header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofByteArray(body)).build(), HttpResponse.BodyHandlers.ofByteArray()); + assertEquals(401, response.statusCode()); + } + } + + @Test + void boundedQueuesFailClosed() throws Exception { + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("proxy"), "localhost"); + HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(identity, directory.resolve("authority")); + try (HttpProxyTransportServer server = new HttpProxyTransportServer(new InetSocketAddress("localhost", 0), identity, authority, ignored -> { })) { + for (int i = 0; i < HttpTransportProtocol.MAX_QUEUE; i++) assertTrue(server.send("lobby-1", JsonEnvelope.builder("x").build())); + assertFalse(server.send("lobby-1", JsonEnvelope.builder("x").build())); + assertFalse(server.send("lobby-1", JsonEnvelope.builder("x").put("large", "x".repeat(HttpTransportProtocol.MAX_ENVELOPE_BYTES)).build())); + } + } + + @Test + void proxyOutgoingQueueSurvivesRestartUntilBackendAcknowledges() throws Exception { + Path proxyDirectory = directory.resolve("proxy"); + Path authorityDirectory = directory.resolve("authority"); + Path queueDirectory = directory.resolve("outgoing"); + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(proxyDirectory, "localhost"); + HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(identity, authorityDirectory); + HttpProxyTransportServer first = new HttpProxyTransportServer(new InetSocketAddress("localhost", 0), + identity, authority, queueDirectory, ignored -> { }); + assertTrue(first.send("lobby-1", JsonEnvelope.builder("durable").build())); + first.close(); + + CountDownLatch received = new CountDownLatch(1); + try (HttpProxyTransportServer restarted = new HttpProxyTransportServer( + new InetSocketAddress("localhost", 0), identity, authority, queueDirectory, ignored -> { })) { + restarted.start(); + HttpConnectionCode code = authority.createConnectionCode("lobby-1", restarted.endpoint("localhost"), + Duration.ofMinutes(5)); + HttpClientCredentialStore.ClientCredential credential = HttpBackendTransportConnector.enroll(code, + "lobby-1", directory.resolve("durable-client")); + try (HttpBackendTransportConnector connector = new HttpBackendTransportConnector(code, "lobby-1", + credential, envelope -> received.countDown())) { + connector.start(); + assertTrue(received.await(8, TimeUnit.SECONDS)); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (countRegularFiles(queueDirectory) != 0L && System.nanoTime() < deadline) Thread.sleep(20); + assertEquals(0L, countRegularFiles(queueDirectory), "backend ACK must durably remove the delivery"); + } + } + } + + @Test + void pollCreatedBackendStateUsesDurableOutgoingQueue() throws Exception { + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("poll-proxy"), "localhost"); + HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(identity, directory.resolve("poll-authority")); + Path queueDirectory = directory.resolve("poll-outgoing"); + CountDownLatch proxyReceived = new CountDownLatch(1), backendReceived = new CountDownLatch(1); + CountDownLatch releaseBackendCallback = new CountDownLatch(1); + try (HttpProxyTransportServer server = new HttpProxyTransportServer(new InetSocketAddress("localhost", 0), + identity, authority, queueDirectory, ignored -> proxyReceived.countDown())) { + server.start(); + HttpConnectionCode code = authority.createConnectionCode("lobby-1", server.endpoint("localhost"), + Duration.ofMinutes(5)); + HttpClientCredentialStore.ClientCredential credential = HttpBackendTransportConnector.enroll(code, + "lobby-1", directory.resolve("poll-client")); + try (HttpBackendTransportConnector connector = new HttpBackendTransportConnector(code, "lobby-1", + credential, envelope -> { + backendReceived.countDown(); + try { releaseBackendCallback.await(5, TimeUnit.SECONDS); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + })) { + connector.start(); + assertTrue(connector.send(JsonEnvelope.builder("establish-poll").build())); + assertTrue(proxyReceived.await(8, TimeUnit.SECONDS)); + assertTrue(server.send("lobby-1", JsonEnvelope.builder("durable-after-poll").build())); + assertTrue(backendReceived.await(8, TimeUnit.SECONDS)); + assertEquals(1L, countRegularFiles(queueDirectory), + "a poll-created backend state must persist before reporting acceptance"); + releaseBackendCallback.countDown(); + } + } finally { + releaseBackendCallback.countDown(); + } + } + + private static long countRegularFiles(Path root) throws Exception { + try (java.util.stream.Stream paths = java.nio.file.Files.walk(root)) { + return paths.filter(path -> java.nio.file.Files.isRegularFile(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)).count(); + } + } + + @Test + void aggregatePacketBudgetSplitsLargeValidEnvelopes() { + java.util.List candidates = new java.util.ArrayList<>(); + for (int index = 0; index < 12; index++) candidates.add(new HttpTransportProtocol.Delivery( + java.util.UUID.randomUUID().toString(), JsonEnvelope.builder("large").put("value", "x".repeat(40_000)).build())); + String session = java.util.UUID.randomUUID().toString(); + java.util.List fitted = HttpTransportProtocol.fittingMessages( + "lobby-1", session, 0, java.util.List.of(), candidates); + assertTrue(fitted.size() > 0 && fitted.size() < candidates.size()); + assertTrue(HttpTransportProtocol.request("lobby-1", session, 0, java.util.List.of(), fitted).length + <= HttpTransportProtocol.MAX_BODY_BYTES); + } + + @Test + void packetNumbersMustUseCanonicalJsonIntegerTokens() { + com.google.gson.JsonObject packet = com.google.gson.JsonParser.parseString(new String(HttpTransportProtocol.request( + "lobby-1", java.util.UUID.randomUUID().toString(), 0, java.util.List.of(), java.util.List.of()), + java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject(); + assertDoesNotThrow(() -> HttpTransportProtocol.parsePacket(packet.toString() + .getBytes(java.nio.charset.StandardCharsets.UTF_8))); + String timestamp = packet.get("timestamp").getAsString(); + java.util.Map> invalid = java.util.Map.of( + "v", java.util.List.of("\"1\"", "1.0", "1e0"), + "sequence", java.util.List.of("\"0\"", "0.0", "0e0"), + "timestamp", java.util.List.of("\"" + timestamp + "\"", timestamp + ".0", timestamp + "e0")); + for (var field : invalid.entrySet()) for (String token : field.getValue()) { + com.google.gson.JsonObject rejected = packet.deepCopy(); + rejected.add(field.getKey(), com.google.gson.JsonParser.parseString(token)); + assertThrows(IllegalArgumentException.class, () -> HttpTransportProtocol.parsePacket( + rejected.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)), field.getKey() + "=" + token); + } + } + + @Test + void packetParsingRejectsNoncanonicalUuidForms() { + String deliveryId = java.util.UUID.randomUUID().toString(); + com.google.gson.JsonObject packet = com.google.gson.JsonParser.parseString(new String(HttpTransportProtocol.request( + "lobby-1", java.util.UUID.randomUUID().toString(), 0, java.util.List.of(deliveryId), + java.util.List.of(new HttpTransportProtocol.Delivery(deliveryId, JsonEnvelope.builder("payload").build()))), + java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject(); + String abbreviated = "1-1-1-1-1"; + + com.google.gson.JsonObject invalidSession = packet.deepCopy(); + invalidSession.addProperty("session", abbreviated); + assertThrows(IllegalArgumentException.class, () -> HttpTransportProtocol.parsePacket( + invalidSession.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8))); + + com.google.gson.JsonObject invalidAck = packet.deepCopy(); + invalidAck.getAsJsonArray("acks").set(0, new com.google.gson.JsonPrimitive(abbreviated)); + assertThrows(IllegalArgumentException.class, () -> HttpTransportProtocol.parsePacket( + invalidAck.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8))); + + com.google.gson.JsonObject invalidMessage = packet.deepCopy(); + invalidMessage.getAsJsonArray("messages").get(0).getAsJsonObject().addProperty("id", abbreviated); + assertThrows(IllegalArgumentException.class, () -> HttpTransportProtocol.parsePacket( + invalidMessage.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8))); + } + + @Test + void persistedProfileStartsAfterTheEnrollmentCodeExpires() throws Exception { + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("proxy"), "localhost"); + HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(identity, directory.resolve("authority")); + try (HttpProxyTransportServer server = new HttpProxyTransportServer(new InetSocketAddress("localhost", 0), identity, authority, ignored -> { })) { + server.start(); + HttpConnectionCode active = authority.createConnectionCode("lobby-1", server.endpoint("localhost"), Duration.ofMinutes(5)); + HttpBackendTransportConnector.enroll(active, "lobby-1", directory.resolve("client")); + HttpConnectionCode expired = new HttpConnectionCode(active.serverId(), active.endpoint(), active.serverCertificatePin(), active.caCertificatePin(), + java.time.Instant.now().minusSeconds(1), active.enrollmentToken()); + try (HttpBackendTransportConnector ignored = new HttpBackendTransportConnector(expired, "lobby-1", directory.resolve("client"), message -> { })) { + assertTrue(true); + } + try (HttpBackendTransportConnector ignored = new HttpBackendTransportConnector(directory.resolve("client"), message -> { })) { + assertTrue(true); + } + } + } + + @Test + void persistedBackendConnectsAfterAutomaticServerLeafRotation() throws Exception { + Instant now = Instant.now(); + Path proxyDirectory = directory.resolve("proxy"); + HttpTlsIdentity original = HttpTlsIdentity.loadOrCreate(proxyDirectory, "localhost", + java.time.Clock.fixed(now.minus(Duration.ofDays(340)), java.time.ZoneOffset.UTC)); + String originalServerPin = HttpTransportSecrets.certificatePin(original.serverCertificate()); + HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(original, directory.resolve("authority")); + HttpTlsIdentity rotated = HttpTlsIdentity.loadOrCreate(proxyDirectory, "localhost"); + CountDownLatch received = new CountDownLatch(1); + try (HttpProxyTransportServer server = new HttpProxyTransportServer(new InetSocketAddress("localhost", 0), rotated, + authority, ignored -> received.countDown())) { + HttpConnectionCode activeCode = authority.createConnectionCode("lobby-1", server.endpoint("localhost"), Duration.ofMinutes(5)); + HttpTlsIdentity.IssuedClientCertificate issued = authority.enroll("lobby-1", activeCode.enrollmentToken()); + HttpConnectionCode oldProfileCode = new HttpConnectionCode(activeCode.serverId(), activeCode.endpoint(), originalServerPin, + activeCode.caCertificatePin(), activeCode.expiresAt(), activeCode.enrollmentToken()); + HttpClientCredentialStore.saveEnrolled(directory.resolve("client"), oldProfileCode, issued); + assertFalse(oldProfileCode.serverCertificatePin().equals(rotated.serverCertificatePin())); + server.start(); + try (HttpBackendTransportConnector connector = new HttpBackendTransportConnector(directory.resolve("client"), ignored -> { })) { + connector.start(); + assertTrue(connector.send(JsonEnvelope.builder("after-rotation").build())); + assertTrue(received.await(8, TimeUnit.SECONDS)); + } + } + } + + @Test + void backendRenewsClientCertificateBeforeExpiryWithoutNewConnectionCode() throws Exception { + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("proxy"), "localhost"); + HttpTlsIdentity.IssuedClientCertificate expiring = identity.issueClientCertificate("lobby-1", + Instant.now().minus(Duration.ofDays(340))); + String originalPin = HttpTransportSecrets.certificatePin(expiring.certificate()); + Path authorityDirectory = directory.resolve("authority"); + java.nio.file.Files.createDirectories(authorityDirectory); + String key = java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString("lobby-1".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + java.nio.file.Files.writeString(authorityDirectory.resolve("http-transport-clients.properties"), + "version=2\nbinding." + key + "=" + originalPin + ":-:0\n"); + HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(identity, authorityDirectory); + try (HttpProxyTransportServer server = new HttpProxyTransportServer(new InetSocketAddress("localhost", 0), identity, + authority, ignored -> { })) { + HttpConnectionCode profileCode = new HttpConnectionCode("lobby-1", server.endpoint("localhost"), + identity.serverCertificatePin(), identity.caCertificatePin(), Instant.now().plusSeconds(60), "A".repeat(43)); + Path clientDirectory = directory.resolve("client"); + HttpClientCredentialStore.saveEnrolled(clientDirectory, profileCode, expiring); + server.start(); + try (HttpBackendTransportConnector connector = new HttpBackendTransportConnector(clientDirectory, ignored -> { })) { + connector.start(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(8); + String renewedPin = originalPin; + while (renewedPin.equals(originalPin) && System.nanoTime() < deadline) { + Thread.sleep(25); + renewedPin = HttpTransportSecrets.certificatePin(HttpClientCredentialStore.load(clientDirectory).certificate()); + } + assertFalse(renewedPin.equals(originalPin)); + HttpClientCredentialStore.ClientCredential renewed = HttpClientCredentialStore.load(clientDirectory); + long promotionDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (authority.authenticate("lobby-1", expiring.certificate()) && System.nanoTime() < promotionDeadline) Thread.sleep(25); + assertTrue(authority.authenticate("lobby-1", renewed.certificate())); + assertFalse(authority.authenticate("lobby-1", expiring.certificate())); + } + } + } + + @Test + void duplicateInboundDeliveryIsReAcknowledgedWithoutSecondDispatch() { + HttpProxyTransportServer.BackendState state = new HttpProxyTransportServer.BackendState(); + String session = java.util.UUID.randomUUID().toString(); + String id = java.util.UUID.randomUUID().toString(); + HttpTransportProtocol.Delivery delivery = new HttpTransportProtocol.Delivery(id, JsonEnvelope.builder("x").build()); + assertTrue(state.acceptSession(session, 0)); + assertEquals(1, state.acceptIncoming(java.util.List.of(delivery)).size()); + state.completeIncoming(id, true); + assertEquals(java.util.List.of(id), state.await("lobby-1", session, 0).acks()); + assertTrue(state.acceptSession(session, 1)); + assertTrue(state.acceptIncoming(java.util.List.of(delivery)).isEmpty()); + assertEquals(java.util.List.of(id), state.await("lobby-1", session, 1).acks()); + String replacementSession = java.util.UUID.randomUUID().toString(); + assertTrue(state.acceptSession(replacementSession, 0)); + assertTrue(state.acceptIncoming(java.util.List.of(delivery)).isEmpty()); + assertEquals(java.util.List.of(id), state.await("lobby-1", replacementSession, 0).acks()); + } + + @Test + void proxyDedupWindowEvictsOldestCompletedDeliveryAtCapacity() { + HttpProxyTransportServer.BackendState state = new HttpProxyTransportServer.BackendState(); + String oldest = null, newest = null; + for (int index = 0; index < HttpTransportProtocol.MAX_QUEUE + 1; index++) { + String id = java.util.UUID.randomUUID().toString(); + if (index == 0) oldest = id; + newest = id; + HttpTransportProtocol.Delivery delivery = new HttpTransportProtocol.Delivery(id, JsonEnvelope.builder("x").build()); + assertEquals(1, state.acceptIncoming(java.util.List.of(delivery)).size()); + state.completeIncoming(id, true); + } + HttpTransportProtocol.Delivery evicted = new HttpTransportProtocol.Delivery(oldest, JsonEnvelope.builder("x").build()); + HttpTransportProtocol.Delivery retained = new HttpTransportProtocol.Delivery(newest, JsonEnvelope.builder("x").build()); + assertEquals(1, state.acceptIncoming(java.util.List.of(evicted)).size()); + assertTrue(state.acceptIncoming(java.util.List.of(retained)).isEmpty()); + } + + @Test + void backendReAcknowledgesLostAckDuplicateWithoutSecondCallback() throws Exception { + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("proxy"), "localhost"); + HttpTlsIdentity.IssuedClientCertificate issued = identity.issueClientCertificate("lobby-1"); + HttpClientCredentialStore.save(directory.resolve("client"), issued); + HttpClientCredentialStore.HttpClientProfile profile = new HttpClientCredentialStore.HttpClientProfile("lobby-1", + java.net.URI.create("https://localhost:8443/"), identity.serverCertificatePin(), identity.caCertificatePin()); + CountDownLatch callback = new CountDownLatch(1); + try (HttpBackendTransportConnector connector = new HttpBackendTransportConnector(profile, + HttpClientCredentialStore.load(directory.resolve("client")), envelope -> callback.countDown())) { + String id = java.util.UUID.randomUUID().toString(); + HttpTransportProtocol.Delivery delivery = new HttpTransportProtocol.Delivery(id, JsonEnvelope.builder("x").build()); + connector.dispatch(delivery); + assertTrue(callback.await(2, TimeUnit.SECONDS)); + java.util.List acknowledgements = java.util.List.of(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (acknowledgements.isEmpty() && System.nanoTime() < deadline) { + acknowledgements = connector.drainAcknowledgements(); + if (acknowledgements.isEmpty()) Thread.sleep(5); + } + assertEquals(java.util.List.of(id), acknowledgements); + assertTrue(connector.accept(java.util.List.of(delivery)).isEmpty()); + assertEquals(java.util.List.of(id), connector.drainAcknowledgements()); + } + } + + @Test + void backendCallbacksAreSerializedInDeliveryOrder() throws Exception { + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("ordered-proxy"), "localhost"); + HttpTlsIdentity.IssuedClientCertificate issued = identity.issueClientCertificate("lobby-1"); + HttpClientCredentialStore.save(directory.resolve("ordered-client"), issued); + HttpClientCredentialStore.HttpClientProfile profile = new HttpClientCredentialStore.HttpClientProfile("lobby-1", + java.net.URI.create("https://localhost:8443/"), identity.serverCertificatePin(), identity.caCertificatePin()); + CountDownLatch firstStarted = new CountDownLatch(1), releaseFirst = new CountDownLatch(1), secondStarted = new CountDownLatch(1); + java.util.List order = new java.util.concurrent.CopyOnWriteArrayList<>(); + try (HttpBackendTransportConnector connector = new HttpBackendTransportConnector(profile, + HttpClientCredentialStore.load(directory.resolve("ordered-client")), envelope -> { + String marker = String.valueOf(envelope.getFields().get("marker")); + order.add(marker); + if ("first".equals(marker)) { + firstStarted.countDown(); + try { releaseFirst.await(5, TimeUnit.SECONDS); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + } else secondStarted.countDown(); + })) { + connector.dispatch(new HttpTransportProtocol.Delivery(java.util.UUID.randomUUID().toString(), + JsonEnvelope.builder("x").put("marker", "first").build())); + connector.dispatch(new HttpTransportProtocol.Delivery(java.util.UUID.randomUUID().toString(), + JsonEnvelope.builder("x").put("marker", "second").build())); + assertTrue(firstStarted.await(2, TimeUnit.SECONDS)); + assertFalse(secondStarted.await(150, TimeUnit.MILLISECONDS)); + releaseFirst.countDown(); + assertTrue(secondStarted.await(2, TimeUnit.SECONDS)); + assertEquals(java.util.List.of("first", "second"), order); + } finally { releaseFirst.countDown(); } + } + + @Test + void backendCallbackQueueBackpressuresWithoutBreakingFifo() throws Exception { + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("backpressure-proxy"), "localhost"); + HttpTlsIdentity.IssuedClientCertificate issued = identity.issueClientCertificate("lobby-1"); + HttpClientCredentialStore.save(directory.resolve("backpressure-client"), issued); + HttpClientCredentialStore.HttpClientProfile profile = new HttpClientCredentialStore.HttpClientProfile("lobby-1", + java.net.URI.create("https://localhost:8443/"), identity.serverCertificatePin(), identity.caCertificatePin()); + CountDownLatch firstStarted = new CountDownLatch(1), releaseFirst = new CountDownLatch(1); + int deliveries = HttpBackendTransportConnector.CALLBACK_QUEUE_CAPACITY + 2; + CountDownLatch completed = new CountDownLatch(deliveries), overflowSubmitted = new CountDownLatch(1); + java.util.List order = new java.util.concurrent.CopyOnWriteArrayList<>(); + try (HttpBackendTransportConnector connector = new HttpBackendTransportConnector(profile, + HttpClientCredentialStore.load(directory.resolve("backpressure-client")), envelope -> { + int marker = Integer.parseInt(envelope.getFields().get("marker")); + order.add(marker); + if (marker == 0) { + firstStarted.countDown(); + try { releaseFirst.await(5, TimeUnit.SECONDS); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + } + completed.countDown(); + })) { + connector.dispatch(delivery(0)); + assertTrue(firstStarted.await(2, TimeUnit.SECONDS)); + for (int marker = 1; marker <= HttpBackendTransportConnector.CALLBACK_QUEUE_CAPACITY; marker++) + connector.dispatch(delivery(marker)); + Thread overflow = new Thread(() -> { + connector.dispatch(delivery(deliveries - 1)); + overflowSubmitted.countDown(); + }, "HTTP-overflow-submitter"); + overflow.start(); + assertFalse(overflowSubmitted.await(150, TimeUnit.MILLISECONDS), "a full ordered lane must backpressure its producer"); + releaseFirst.countDown(); + assertTrue(overflowSubmitted.await(2, TimeUnit.SECONDS)); + assertTrue(completed.await(5, TimeUnit.SECONDS)); + assertEquals(java.util.stream.IntStream.range(0, deliveries).boxed().toList(), order); + } finally { releaseFirst.countDown(); } + } + + @Test + void durableBackendFencePreventsCallbackReplayAfterRestartBeforeAck() throws Exception { + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("fence-proxy"), "localhost"); + HttpTlsIdentity.IssuedClientCertificate issued = identity.issueClientCertificate("lobby-1"); + HttpConnectionCode code = new HttpConnectionCode("lobby-1", java.net.URI.create("https://localhost:8443/"), + identity.serverCertificatePin(), identity.caCertificatePin(), Instant.now().plusSeconds(300), "A".repeat(43)); + Path clientDirectory = directory.resolve("fence-client"); + HttpClientCredentialStore.saveEnrolled(clientDirectory, code, issued); + java.util.concurrent.atomic.AtomicInteger callbacks = new java.util.concurrent.atomic.AtomicInteger(); + String id = java.util.UUID.randomUUID().toString(); + HttpTransportProtocol.Delivery delivery = new HttpTransportProtocol.Delivery(id, JsonEnvelope.builder("vote").build()); + try (HttpBackendTransportConnector first = new HttpBackendTransportConnector(clientDirectory, + ignored -> callbacks.incrementAndGet())) { + first.dispatch(delivery); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (callbacks.get() != 1 && System.nanoTime() < deadline) Thread.sleep(5); + assertEquals(1, callbacks.get()); + } + try (HttpBackendTransportConnector restarted = new HttpBackendTransportConnector(clientDirectory, + ignored -> callbacks.incrementAndGet())) { + assertEquals(java.util.List.of(id), restarted.drainAcknowledgements(), + "restart must retain and acknowledge the pre-callback delivery fence"); + assertTrue(restarted.accept(java.util.List.of(delivery)).isEmpty()); + assertEquals(1, callbacks.get(), "a durable proxy replay must not award twice"); + } + } + + @Test + void failedBackendCallbackRemainsUnacknowledgedAndIsNotReplayed() throws Exception { + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("retry-fence-proxy"), "localhost"); + HttpTlsIdentity.IssuedClientCertificate issued = identity.issueClientCertificate("lobby-1"); + HttpConnectionCode code = new HttpConnectionCode("lobby-1", java.net.URI.create("https://localhost:8443/"), + identity.serverCertificatePin(), identity.caCertificatePin(), Instant.now().plusSeconds(300), "B".repeat(43)); + Path clientDirectory = directory.resolve("retry-fence-client"); + HttpClientCredentialStore.saveEnrolled(clientDirectory, code, issued); + java.util.concurrent.atomic.AtomicInteger attempts = new java.util.concurrent.atomic.AtomicInteger(); + CountDownLatch failed = new CountDownLatch(1); + HttpTransportProtocol.Delivery delivery = new HttpTransportProtocol.Delivery(java.util.UUID.randomUUID().toString(), + JsonEnvelope.builder("vote").build()); + try (HttpBackendTransportConnector first = new HttpBackendTransportConnector(clientDirectory, ignored -> { + attempts.incrementAndGet(); failed.countDown(); throw new IllegalStateException("retry"); + })) { + first.dispatch(delivery); + assertTrue(failed.await(2, TimeUnit.SECONDS)); + assertTrue(first.drainAcknowledgements().isEmpty()); + } + try (HttpBackendTransportConnector restarted = new HttpBackendTransportConnector(clientDirectory, + ignored -> attempts.incrementAndGet())) { + assertTrue(restarted.drainAcknowledgements().isEmpty()); + java.util.List accepted = restarted.accept(java.util.List.of(delivery)); + assertTrue(accepted.isEmpty(), "an ambiguous callback must not be awarded twice"); + assertEquals(1, attempts.get()); + } + } + + @Test + void reservedButNotStartedDeliveryResumesAfterRestart() throws Exception { + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("reserved-proxy"), "localhost"); + HttpTlsIdentity.IssuedClientCertificate issued = identity.issueClientCertificate("lobby-1"); + HttpConnectionCode code = new HttpConnectionCode("lobby-1", java.net.URI.create("https://localhost:8443/"), + identity.serverCertificatePin(), identity.caCertificatePin(), Instant.now().plusSeconds(300), "C".repeat(43)); + Path clientDirectory = directory.resolve("reserved-client"); + HttpClientCredentialStore.saveEnrolled(clientDirectory, code, issued); + String id = java.util.UUID.randomUUID().toString(); + new HttpInboundDeliveryStore(clientDirectory).reserve(id); + CountDownLatch completed = new CountDownLatch(1); + HttpTransportProtocol.Delivery delivery = new HttpTransportProtocol.Delivery(id, JsonEnvelope.builder("vote").build()); + try (HttpBackendTransportConnector restarted = new HttpBackendTransportConnector(clientDirectory, ignored -> completed.countDown())) { + assertTrue(restarted.drainAcknowledgements().isEmpty(), "a reservation alone must never be acknowledged"); + java.util.List accepted = restarted.accept(java.util.List.of(delivery)); + assertEquals(1, accepted.size()); + restarted.dispatch(accepted.get(0)); + assertTrue(completed.await(2, TimeUnit.SECONDS)); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + java.util.List acknowledgements = java.util.List.of(); + while (acknowledgements.isEmpty() && System.nanoTime() < deadline) { + acknowledgements = restarted.drainAcknowledgements(); + if (acknowledgements.isEmpty()) Thread.sleep(5); + } + assertEquals(java.util.List.of(id), acknowledgements); + } + } + + @Test + void interruptedStateRenameRetainsTheFurthestSafeState() throws Exception { + Path clientDirectory = directory.resolve("interrupted-state-client"); + String id = java.util.UUID.randomUUID().toString(); + String completedId = java.util.UUID.randomUUID().toString(); + Path states = clientDirectory.resolve("http-transport-inbound-deliveries"); + Files.createDirectories(states); + Files.writeString(states.resolve(id + ".reserved"), id); + Files.writeString(states.resolve(id + ".running"), id); + Files.writeString(states.resolve(completedId + ".running"), completedId); + Files.writeString(states.resolve(completedId + ".completed"), completedId); + HttpInboundDeliveryStore store = new HttpInboundDeliveryStore(clientDirectory); + assertEquals(HttpInboundDeliveryStore.State.RUNNING, store.state(id)); + assertEquals(HttpInboundDeliveryStore.State.COMPLETED, store.state(completedId)); + assertFalse(Files.exists(states.resolve(id + ".reserved"))); + assertTrue(Files.exists(states.resolve(id + ".running"))); + assertFalse(Files.exists(states.resolve(completedId + ".running"))); + assertTrue(Files.exists(states.resolve(completedId + ".completed"))); + } + + private static HttpTransportProtocol.Delivery delivery(int marker) { + return new HttpTransportProtocol.Delivery(java.util.UUID.randomUUID().toString(), + JsonEnvelope.builder("x").put("marker", marker).build()); + } + + @Test + void proxyCallbacksAreSerializedInDeliveryOrder() throws Exception { + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("ordered-proxy-server"), "localhost"); + HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(identity, directory.resolve("ordered-authority")); + CountDownLatch firstStarted = new CountDownLatch(1), releaseFirst = new CountDownLatch(1), secondStarted = new CountDownLatch(1); + java.util.List order = new java.util.concurrent.CopyOnWriteArrayList<>(); + try (HttpProxyTransportServer server = new HttpProxyTransportServer(new InetSocketAddress("localhost", 0), identity, + authority, received -> { + String marker = String.valueOf(received.envelope().getFields().get("marker")); + order.add(marker); + if ("first".equals(marker)) { + firstStarted.countDown(); + try { releaseFirst.await(5, TimeUnit.SECONDS); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + } else secondStarted.countDown(); + })) { + server.start(); + HttpConnectionCode code = authority.createConnectionCode("lobby-1", server.endpoint("localhost"), Duration.ofMinutes(5)); + HttpClientCredentialStore.ClientCredential credential = HttpBackendTransportConnector.enroll(code, "lobby-1", + directory.resolve("ordered-proxy-client")); + try (HttpBackendTransportConnector connector = new HttpBackendTransportConnector(code, "lobby-1", credential, + ignored -> { })) { + connector.start(); + assertTrue(connector.send(JsonEnvelope.builder("x").put("marker", "first").build())); + assertTrue(connector.send(JsonEnvelope.builder("x").put("marker", "second").build())); + assertTrue(firstStarted.await(3, TimeUnit.SECONDS)); + assertFalse(secondStarted.await(150, TimeUnit.MILLISECONDS)); + releaseFirst.countDown(); + assertTrue(secondStarted.await(3, TimeUnit.SECONDS)); + assertEquals(java.util.List.of("first", "second"), order); + } + } finally { releaseFirst.countDown(); } + } + + @Test + void backendDedupWindowContinuesAfterCapacity() throws Exception { + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("proxy"), "localhost"); + HttpTlsIdentity.IssuedClientCertificate issued = identity.issueClientCertificate("lobby-1"); + HttpClientCredentialStore.save(directory.resolve("client"), issued); + HttpClientCredentialStore.HttpClientProfile profile = new HttpClientCredentialStore.HttpClientProfile("lobby-1", + java.net.URI.create("https://localhost:8443/"), identity.serverCertificatePin(), identity.caCertificatePin()); + try (HttpBackendTransportConnector connector = new HttpBackendTransportConnector(profile, + HttpClientCredentialStore.load(directory.resolve("client")), envelope -> { })) { + for (int index = 0; index < HttpTransportProtocol.MAX_QUEUE; index++) { + String id = java.util.UUID.randomUUID().toString(); + HttpTransportProtocol.Delivery delivery = new HttpTransportProtocol.Delivery(id, JsonEnvelope.builder("x").build()); + assertEquals(1, connector.accept(java.util.List.of(delivery)).size()); + connector.completeIncoming(id, true); + } + HttpTransportProtocol.Delivery next = new HttpTransportProtocol.Delivery(java.util.UUID.randomUUID().toString(), + JsonEnvelope.builder("x").build()); + assertEquals(1, connector.accept(java.util.List.of(next)).size()); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/http/HttpTransportSecurityTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/http/HttpTransportSecurityTest.java new file mode 100644 index 000000000..4c006c02d --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/http/HttpTransportSecurityTest.java @@ -0,0 +1,288 @@ +package com.bencodez.votingplugin.backendproxy.http; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.cert.X509Certificate; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Arrays; +import javax.net.ssl.X509TrustManager; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class HttpTransportSecurityTest { + @Test + void connectionCodeRejectsExplicitZeroPort() { + assertThrows(IllegalArgumentException.class, + () -> new HttpConnectionCode("lobby", URI.create("https://proxy.example.test:0/"), pin('a'), + pin('b'), Instant.now().plusSeconds(60), "token")); + } + + @TempDir Path directory; + + @Test + void backendResponseReaderRejectsBodiesBeyondTheWireLimit() throws Exception { + byte[] maximum = new byte[HttpTransportProtocol.MAX_BODY_BYTES]; + assertEquals(maximum.length, HttpBackendTransportConnector.readLimited( + new java.io.ByteArrayInputStream(maximum)).length); + assertThrows(java.io.IOException.class, () -> HttpBackendTransportConnector.readLimited( + new java.io.ByteArrayInputStream(new byte[HttpTransportProtocol.MAX_BODY_BYTES + 1]))); + } + + @Test + void connectionCodeRoundTripsAndRejectsAccidentalCorruption() { + HttpConnectionCode original = new HttpConnectionCode("lobby", URI.create("https://Proxy.Example.test:8443/http"), pin('a'), pin('b'), + Instant.parse("2030-01-01T00:00:00Z"), HttpTransportSecrets.randomToken()); + String encoded = original.encode(); + HttpConnectionCode parsed = HttpConnectionCode.parse(encoded); + assertEquals(URI.create("https://proxy.example.test:8443/http/"), parsed.endpoint()); + assertEquals(original.serverCertificatePin(), parsed.serverCertificatePin()); + char last = encoded.charAt(encoded.length() - 1); + assertThrows(IllegalArgumentException.class, () -> HttpConnectionCode.parse(encoded.substring(0, encoded.length() - 1) + + (last == 'A' ? 'B' : 'A'))); + assertThrows(IllegalArgumentException.class, () -> HttpConnectionCode.parse("http://not-a-code")); + } + + @Test + void expiredCodesAreNotActive() { + HttpConnectionCode code = new HttpConnectionCode("lobby", URI.create("https://proxy.example.test/"), pin('a'), pin('b'), + Instant.parse("2029-12-31T23:59:59Z"), HttpTransportSecrets.randomToken()); + assertFalse(code.isActive(Clock.fixed(Instant.parse("2030-01-01T00:00:00Z"), ZoneOffset.UTC))); + assertThrows(IllegalArgumentException.class, () -> code.requireActive(Clock.fixed(Instant.parse("2030-01-01T00:00:00Z"), ZoneOffset.UTC))); + } + + @Test + void inboundDeliveryFenceRejectsCorruptionAndPathReplacement() throws Exception { + Path corruptCredentials = directory.resolve("corrupt-client"); + Path corruptFence = corruptCredentials.resolve("http-transport-inbound-deliveries"); + Files.createDirectories(corruptFence); + Files.writeString(corruptFence.resolve("not-a-delivery.seen"), "not-a-delivery"); + assertThrows(java.io.IOException.class, () -> new HttpInboundDeliveryStore(corruptCredentials)); + + Path replacedCredentials = directory.resolve("replaced-client"); + Files.createDirectories(replacedCredentials); + HttpInboundDeliveryStore store = new HttpInboundDeliveryStore(replacedCredentials); + Path fence = replacedCredentials.resolve("http-transport-inbound-deliveries"); + Path outside = directory.resolve("outside-fence"); + Files.createDirectory(outside); + Files.delete(fence); + Files.createSymbolicLink(fence, outside); + assertThrows(java.io.IOException.class, () -> store.reserve(java.util.UUID.randomUUID().toString())); + } + + @Test + void sealedInboundStoreCannotChangeAfterOwnershipHandoff() throws Exception { + Path credentials = directory.resolve("sealed-client"); + Files.createDirectories(credentials); + String id = java.util.UUID.randomUUID().toString(); + HttpInboundDeliveryStore store = new HttpInboundDeliveryStore(credentials); + store.reserve(id); + store.markRunning(id); + store.seal(); + assertThrows(java.io.IOException.class, () -> store.markCompleted(id)); + assertEquals(HttpInboundDeliveryStore.State.RUNNING, new HttpInboundDeliveryStore(credentials).state(id)); + } + + @Test + void identityIsDurableAndPinsRejectTheWrongServer() throws Exception { + HttpTlsIdentity created = HttpTlsIdentity.loadOrCreate(directory, "localhost"); + HttpTlsIdentity loaded = HttpTlsIdentity.loadOrCreate(directory, "localhost"); + assertEquals(created.serverCertificatePin(), loaded.serverCertificatePin()); + assertEquals(created.caCertificatePin(), loaded.caCertificatePin()); + HttpConnectionCode correct = new HttpConnectionCode("lobby", URI.create("https://localhost:8443/"), created.serverCertificatePin(), + created.caCertificatePin(), Instant.now().plusSeconds(60), HttpTransportSecrets.randomToken()); + HttpConnectionCode incorrect = new HttpConnectionCode("lobby", URI.create("https://localhost:8443/"), pin('0'), created.caCertificatePin(), + Instant.now().plusSeconds(60), HttpTransportSecrets.randomToken()); + assertTrue(HttpPinnedTls.matchesServerPin(correct, created.serverCertificate())); + assertFalse(HttpPinnedTls.matchesServerPin(incorrect, created.serverCertificate())); + assertTrue(Files.exists(directory.resolve("http-transport-ca.p12"))); + HttpTlsIdentity rotated = HttpTlsIdentity.loadOrCreate(directory, "127.0.0.1"); + assertEquals(created.caCertificatePin(), rotated.caCertificatePin()); + assertNotEquals(created.serverCertificatePin(), rotated.serverCertificatePin()); + } + + @Test + void enrollmentIsSingleUseBoundToServerAndRevocable() throws Exception { + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory, "localhost"); + Clock clock = Clock.fixed(Instant.parse("2030-01-01T00:00:00Z"), ZoneOffset.UTC); + HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(identity, clock); + HttpConnectionCode wrongTargetCode = authority.createConnectionCode("lobby-1", URI.create("https://localhost:8443/"), Duration.ofMinutes(5)); + assertThrows(IllegalArgumentException.class, () -> authority.enroll("attacker", wrongTargetCode.enrollmentToken())); + assertTrue(authority.authenticate("lobby-1", authority.enroll("lobby-1", wrongTargetCode.enrollmentToken()).certificate()), + "a wrong backend must not consume another backend's connection code"); + authority.revoke("lobby-1"); + HttpConnectionCode code = authority.createConnectionCode("lobby-1", URI.create("https://localhost:8443/"), Duration.ofMinutes(5)); + HttpTlsIdentity.IssuedClientCertificate issued = authority.enroll("lobby-1", code.enrollmentToken()); + assertTrue(authority.authenticate("lobby-1", issued.certificate())); + assertTrue(identity.validClientCertificate("LOBBY-1", issued.certificate())); + assertFalse(authority.authenticate("lobby-2", issued.certificate())); + assertThrows(IllegalArgumentException.class, () -> authority.enroll("lobby-2", code.enrollmentToken())); + authority.revoke("lobby-1"); + assertFalse(authority.authenticate("lobby-1", issued.certificate())); + HttpConnectionCode replacementCode = authority.createConnectionCode("lobby-1", URI.create("https://localhost:8443/"), Duration.ofMinutes(5)); + HttpTlsIdentity.IssuedClientCertificate replacement = authority.enroll("lobby-1", replacementCode.enrollmentToken()); + assertTrue(authority.authenticate("lobby-1", replacement.certificate())); + assertFalse(authority.authenticate("lobby-1", issued.certificate())); + HttpClientCredentialStore.saveEnrolled(directory.resolve("client"), code, issued); + HttpClientCredentialStore.ClientCredential restored = HttpClientCredentialStore.load(directory.resolve("client")); + assertEquals(HttpTransportSecrets.certificatePin(issued.certificate()), HttpTransportSecrets.certificatePin(restored.certificate())); + HttpClientCredentialStore.HttpClientProfile profile = HttpClientCredentialStore.loadProfile(directory.resolve("client")); + assertEquals("lobby-1", profile.serverId()); + assertEquals(code.endpoint(), profile.endpoint()); + assertEquals("lobby-1", HttpClientCredentialStore.loadEnrolled(directory.resolve("client")).profile().serverId()); + assertNotEquals(null, HttpPinnedTls.mutualTlsContext(code, restored)); + Path clientDirectory = directory.resolve("client"); + String generation = Files.readString(clientDirectory.resolve("http-transport-client-current")); + Files.writeString(clientDirectory.resolve("http-transport-client-generations").resolve(generation) + .resolve("http-transport-profile.properties"), "version=1\nserverId=lobby-1\n"); + assertThrows(java.io.IOException.class, () -> HttpClientCredentialStore.loadProfile(directory.resolve("client"))); + HttpEnrollmentAuthority durable = new HttpEnrollmentAuthority(identity, directory.resolve("state")); + HttpConnectionCode durableCode = durable.createConnectionCode("survival", URI.create("https://localhost:8443/"), Duration.ofMinutes(5)); + HttpTlsIdentity.IssuedClientCertificate durableIssued = durable.enroll("survival", durableCode.enrollmentToken()); + assertTrue(new HttpEnrollmentAuthority(identity, directory.resolve("state")).authenticate("survival", durableIssued.certificate())); + durable.revoke("survival"); + assertFalse(new HttpEnrollmentAuthority(identity, directory.resolve("state")).authenticate("survival", durableIssued.certificate())); + } + + @Test + void revocationInvalidatesEveryPendingCodeForTheBackend() throws Exception { + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("revoke-pending"), "localhost"); + HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(identity, + Clock.fixed(Instant.parse("2030-01-01T00:00:00Z"), ZoneOffset.UTC)); + URI endpoint = URI.create("https://localhost:8443/"); + HttpConnectionCode beforeEnrollment = authority.createConnectionCode("lobby-1", endpoint, Duration.ofMinutes(5)); + authority.revoke("LOBBY-1"); + assertThrows(IllegalArgumentException.class, + () -> authority.enroll("lobby-1", beforeEnrollment.enrollmentToken())); + + HttpConnectionCode active = authority.createConnectionCode("lobby-1", endpoint, Duration.ofMinutes(5)); + authority.enroll("lobby-1", active.enrollmentToken()); + HttpConnectionCode firstPending = authority.createConnectionCode("lobby-1", endpoint, Duration.ofMinutes(5)); + HttpConnectionCode secondPending = authority.createConnectionCode("lobby-1", endpoint, Duration.ofMinutes(5)); + authority.revoke("lobby-1"); + assertThrows(IllegalArgumentException.class, + () -> authority.enroll("lobby-1", firstPending.enrollmentToken())); + assertThrows(IllegalArgumentException.class, + () -> authority.enroll("lobby-1", secondPending.enrollmentToken())); + } + + @Test + void renewalKeepsOldCredentialUntilReplacementAuthenticates() throws Exception { + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("proxy"), "localhost"); + HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(identity, directory.resolve("state")); + HttpConnectionCode code = authority.createConnectionCode("lobby-1", URI.create("https://localhost:8443/"), Duration.ofMinutes(5)); + HttpTlsIdentity.IssuedClientCertificate original = authority.enroll("lobby-1", code.enrollmentToken()); + HttpTlsIdentity.IssuedClientCertificate replacement = authority.renew("lobby-1", original.certificate()); + + assertTrue(authority.authenticate("lobby-1", original.certificate()), "lost renewal responses must leave the old credential usable"); + assertTrue(authority.authenticate("lobby-1", replacement.certificate()), "first replacement request promotes the pending binding"); + assertFalse(authority.authenticate("lobby-1", original.certificate()), "promotion revokes the superseded credential"); + assertTrue(new HttpEnrollmentAuthority(identity, directory.resolve("state")) + .authenticate("lobby-1", replacement.certificate()), "promoted renewal must survive restart"); + } + + @Test + void serverLeafRotatesInsideRenewalWindowAndPreservesAuthority() throws Exception { + Instant now = Instant.now(); + HttpTlsIdentity original = HttpTlsIdentity.loadOrCreate(directory, "localhost", + Clock.fixed(now.minus(Duration.ofDays(340)), ZoneOffset.UTC)); + String originalPin = HttpTransportSecrets.certificatePin(original.serverCertificate()); + HttpTlsIdentity renewed = HttpTlsIdentity.loadOrCreate(directory, "localhost", Clock.fixed(now, ZoneOffset.UTC)); + assertNotEquals(originalPin, renewed.serverCertificatePin()); + assertEquals(original.caCertificatePin(), renewed.caCertificatePin()); + assertFalse(HttpTlsIdentity.needsRenewal(renewed.serverCertificate(), Clock.fixed(now, ZoneOffset.UTC))); + } + + @Test + void runningPrivateCaRollsOverBeforeExpiryWithoutStrandingExistingClients() throws Exception { + Instant now = Instant.now(); + Clock originalClock = Clock.fixed(now.minus(Duration.ofDays(9 * 365L + 30L)), ZoneOffset.UTC); + HttpTlsIdentity original = HttpTlsIdentity.loadOrCreate(directory, "localhost", originalClock); + X509Certificate originalCa = original.caCertificate(); + HttpTlsIdentity.IssuedClientCertificate existingClient = original.issueClientCertificate("lobby-1", now); + Path client = directory.resolve("client"); + HttpConnectionCode oldCode = new HttpConnectionCode("lobby-1", URI.create("https://localhost:8443/"), + HttpTransportSecrets.certificatePin(original.serverCertificate()), HttpTransportSecrets.certificatePin(originalCa), + now.plusSeconds(60), "A".repeat(43)); + HttpClientCredentialStore.saveEnrolled(client, oldCode, existingClient); + + String renewedPin = original.caCertificatePin(); + assertNotEquals(HttpTransportSecrets.certificatePin(originalCa), renewedPin); + assertEquals(originalCa.getPublicKey(), original.caCertificate().getPublicKey(), + "certificate rollover keeps the private authority key so old and new trust anchors overlap"); + assertFalse(HttpTlsIdentity.needsCaRenewal(original.caCertificate(), Clock.fixed(now, ZoneOffset.UTC))); + assertTrue(original.validClientCertificate("lobby-1", existingClient.certificate())); + + X509TrustManager oldClientTrust = Arrays.stream(HttpTlsIdentity.trustManagers(originalCa)) + .filter(X509TrustManager.class::isInstance).map(X509TrustManager.class::cast).findFirst().orElseThrow(); + assertDoesNotThrow(() -> oldClientTrust.checkServerTrusted( + new X509Certificate[] { original.serverCertificate(), original.caCertificate() }, "ECDHE_ECDSA")); + HttpClientCredentialStore.StagedCredential staged = HttpClientCredentialStore.stageReplacement(client, + original.issueClientCertificate("lobby-1", now)); + assertEquals(HttpTransportSecrets.certificatePin(originalCa), HttpClientCredentialStore.loadProfile(client).caCertificatePin()); + assertEquals(renewedPin, staged.profile().caCertificatePin()); + HttpClientCredentialStore.activateReplacement(client, staged); + assertEquals(renewedPin, HttpClientCredentialStore.loadProfile(client).caCertificatePin()); + assertEquals(renewedPin, HttpTlsIdentity.loadOrCreate(directory, "localhost").caCertificatePin(), + "live CA rollover must survive restart"); + } + + @Test + void activeTlsContextRotatesServerLeafInsideRenewalWindow() throws Exception { + Instant now = Instant.now(); + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory, "localhost", + Clock.fixed(now.minus(Duration.ofDays(340)), ZoneOffset.UTC)); + String expiringPin = HttpTransportSecrets.certificatePin(identity.serverCertificate()); + identity.serverContext(); + assertNotEquals(expiringPin, identity.serverCertificatePin()); + assertFalse(HttpTlsIdentity.needsRenewal(identity.serverCertificate(), Clock.systemUTC())); + } + + @Test + void serverTlsUsesPrivateCaTrustAndRejectsForeignClients() throws Exception { + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("proxy"), "localhost"); + HttpTlsIdentity foreign = HttpTlsIdentity.loadOrCreate(directory.resolve("foreign"), "localhost"); + X509TrustManager trust = Arrays.stream(HttpTlsIdentity.trustManagers(identity.caCertificate())) + .filter(X509TrustManager.class::isInstance).map(X509TrustManager.class::cast).findFirst().orElseThrow(); + HttpTlsIdentity.IssuedClientCertificate accepted = identity.issueClientCertificate("lobby-1"); + HttpTlsIdentity.IssuedClientCertificate rejected = foreign.issueClientCertificate("lobby-1"); + assertDoesNotThrow(() -> trust.checkClientTrusted( + new java.security.cert.X509Certificate[] { accepted.certificate(), identity.caCertificate() }, "EC")); + assertThrows(java.security.cert.CertificateException.class, () -> trust.checkClientTrusted( + new java.security.cert.X509Certificate[] { rejected.certificate(), foreign.caCertificate() }, "EC")); + } + + @Test + void stagedCredentialDoesNotReplaceActiveGenerationUntilAtomicActivation() throws Exception { + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("proxy"), "localhost"); + Path client = directory.resolve("client"); + HttpTlsIdentity.IssuedClientCertificate original = identity.issueClientCertificate("lobby-1"); + HttpConnectionCode code = new HttpConnectionCode("lobby-1", URI.create("https://localhost:8443/"), + identity.serverCertificatePin(), identity.caCertificatePin(), Instant.now().plusSeconds(60), "A".repeat(43)); + HttpClientCredentialStore.saveEnrolled(client, code, original); + String originalPin = HttpTransportSecrets.certificatePin(HttpClientCredentialStore.load(client).certificate()); + HttpTlsIdentity.IssuedClientCertificate replacement = identity.issueClientCertificate("lobby-1"); + HttpClientCredentialStore.StagedCredential staged = HttpClientCredentialStore.stageReplacement(client, replacement); + assertEquals(originalPin, HttpTransportSecrets.certificatePin(HttpClientCredentialStore.load(client).certificate())); + HttpClientCredentialStore.activateReplacement(client, staged); + assertNotEquals(originalPin, HttpTransportSecrets.certificatePin(HttpClientCredentialStore.load(client).certificate())); + assertTrue(HttpClientCredentialStore.matchesEnrollmentCode(client, code), + "automatic certificate renewal must retain the consumed-code marker"); + HttpTlsIdentity.IssuedClientCertificate manuallyReenrolled = identity.issueClientCertificate("lobby-1"); + HttpClientCredentialStore.saveEnrolled(client, code, manuallyReenrolled); + assertEquals(HttpTransportSecrets.certificatePin(manuallyReenrolled.certificate()), + HttpTransportSecrets.certificatePin(HttpClientCredentialStore.loadEnrolled(client).credential().certificate())); + } + + private static String pin(char character) { return String.valueOf(character).repeat(64); } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/transport/HttpBackendProxyTransportTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/transport/HttpBackendProxyTransportTest.java new file mode 100644 index 000000000..1b8491efd --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/transport/HttpBackendProxyTransportTest.java @@ -0,0 +1,135 @@ +package com.bencodez.votingplugin.backendproxy.transport; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +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; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.backendproxy.http.HttpClientCredentialStore; +import com.bencodez.votingplugin.backendproxy.http.HttpConnectionCode; +import com.bencodez.votingplugin.backendproxy.http.HttpTlsIdentity; +import com.bencodez.votingplugin.config.BungeeSettings; +import com.bencodez.simpleapi.servercomm.global.GlobalMessageHandler; +import java.net.URI; +import java.nio.file.Path; +import java.time.Instant; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class HttpBackendProxyTransportTest { + @TempDir Path directory; + + @Test + void validatesInitialConnectionCodeSynchronously() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + BungeeSettings settings = mock(BungeeSettings.class); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getBungeeSettings()).thenReturn(settings); + when(settings.getServer()).thenReturn("lobby-1"); + HttpBackendProxyTransport transport = new HttpBackendProxyTransport(plugin); + + when(settings.getHttpConnectionCode()).thenReturn("malformed"); + assertThrows(IllegalStateException.class, transport::validate); + + when(settings.getHttpConnectionCode()).thenReturn(code("lobby-1", Instant.now().minusSeconds(1)).encode()); + assertThrows(IllegalStateException.class, transport::validate); + + when(settings.getHttpConnectionCode()).thenReturn(code("survival", Instant.now().plusSeconds(60)).encode()); + assertThrows(IllegalStateException.class, transport::validate); + assertThrows(IllegalStateException.class, () -> transport.start(mock(GlobalMessageHandler.class)), + "invalid configuration must fail before the enrollment worker starts"); + + when(settings.getHttpConnectionCode()).thenReturn(code("lobby-1", Instant.now().plusSeconds(60)).encode()); + assertDoesNotThrow(() -> transport.validate()); + } + + @Test + void freshConnectionCodeOverridesAnExistingEnrollment() throws Exception { + Path credentials = directory.resolve("http"); + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("proxy"), "proxy.example.test"); + HttpConnectionCode original = new HttpConnectionCode("lobby-1", URI.create("https://proxy.example.test:1297/"), + identity.serverCertificatePin(), identity.caCertificatePin(), Instant.now().minusSeconds(1), "A".repeat(43)); + HttpClientCredentialStore.saveEnrolled(credentials, original, identity.issueClientCertificate("lobby-1")); + assertNull(HttpBackendProxyTransport.enrollmentCode(credentials, "lobby-1", "")); + assertNull(HttpBackendProxyTransport.enrollmentCode(credentials, "lobby-1", original.encode()), + "the already-consumed code must not be retried, even after it expires"); + + HttpConnectionCode replacement = new HttpConnectionCode("lobby-1", original.endpoint(), original.serverCertificatePin(), + original.caCertificatePin(), Instant.now().plusSeconds(60), "B".repeat(43)); + assertEquals(replacement.encode(), HttpBackendProxyTransport.enrollmentCode(credentials, "lobby-1", replacement.encode()).encode()); + assertThrows(IllegalStateException.class, + () -> HttpBackendProxyTransport.enrollmentCode(credentials, "lobby-1", "malformed")); + } + + @Test + void closeNeverWaitsForSetupOnTheCallingThread() throws Exception { + HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class)); + CountDownLatch started = new CountDownLatch(1), release = new CountDownLatch(1); + Thread blocked = new Thread(() -> { + started.countDown(); + while (release.getCount() != 0) try { release.await(); } + catch (InterruptedException ignored) { /* Simulate setup I/O that has not unwound yet. */ } + }); + blocked.start(); + assertTrue(started.await(1, TimeUnit.SECONDS)); + java.lang.reflect.Field worker = HttpBackendProxyTransport.class.getDeclaredField("worker"); + worker.setAccessible(true); + worker.set(transport, blocked); + + long startedAt = System.nanoTime(); + transport.close(); + long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt); + try { assertTrue(elapsedMillis < 500, "close blocked the calling thread for " + elapsedMillis + " ms"); } + finally { release.countDown(); blocked.join(TimeUnit.SECONDS.toMillis(1)); } + } + + @Test + @SuppressWarnings("unchecked") + void validationWaitsForThePreviousDirectoryOwnerBeforeReadinessFailure() throws Exception { + Path credentials = directory.resolve("http"); + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("proxy-owner"), "proxy.example.test"); + HttpConnectionCode code = new HttpConnectionCode("lobby-1", URI.create("https://proxy.example.test:1297/"), + identity.serverCertificatePin(), identity.caCertificatePin(), Instant.now().plusSeconds(60), "C".repeat(43)); + HttpClientCredentialStore.saveEnrolled(credentials, code, identity.issueClientCertificate("lobby-1")); + VotingPluginMain plugin = mock(VotingPluginMain.class); + BungeeSettings settings = mock(BungeeSettings.class); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getBungeeSettings()).thenReturn(settings); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + when(settings.getServer()).thenReturn("lobby-1"); + when(settings.getHttpConnectionCode()).thenReturn(""); + java.lang.reflect.Field ownersField = HttpBackendProxyTransport.class.getDeclaredField("DIRECTORY_OWNERS"); + ownersField.setAccessible(true); + var owners = (java.util.concurrent.ConcurrentHashMap) ownersField.get(null); + java.util.concurrent.Semaphore predecessor = new java.util.concurrent.Semaphore(0); + owners.put(credentials.toAbsolutePath().normalize(), predecessor); + HttpBackendProxyTransport transport = new HttpBackendProxyTransport(plugin); + transport.start(mock(GlobalMessageHandler.class)); + java.util.concurrent.atomic.AtomicReference failure = new java.util.concurrent.atomic.AtomicReference<>(); + CountDownLatch finished = new CountDownLatch(1); + Thread validation = new Thread(() -> { + try { transport.validate(System.nanoTime() + TimeUnit.SECONDS.toNanos(1)); } + catch (Throwable thrown) { failure.set(thrown); } + finally { finished.countDown(); } + }); + validation.start(); + assertFalse(finished.await(150, TimeUnit.MILLISECONDS), "validation published before journal handoff"); + predecessor.release(); + assertTrue(finished.await(3, TimeUnit.SECONDS)); + validation.join(TimeUnit.SECONDS.toMillis(1)); + assertTrue(failure.get() instanceof IllegalStateException); + transport.close(); + } + + private static HttpConnectionCode code(String serverId, Instant expiry) { + return new HttpConnectionCode(serverId, URI.create("https://proxy.example.test:1297/"), "a".repeat(64), + "b".repeat(64), expiry, "A".repeat(43)); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java index bd282b5f8..a2d111189 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java @@ -679,6 +679,62 @@ class BackendConfigurationServiceTest { BackendConfigurationService.QuickPreview mqtt = service.previewQuickSetup("proxy-backend", Map.of("server", "lobby", "method", "mqtt")); assertTrue(mqtt.proposal().content().contains("BungeeMethod: MQTT")); + BackendConfigurationService.QuickPreview http = service.previewQuickSetup("proxy-backend", + Map.of("server", "lobby", "method", "http")); + assertTrue(http.proposal().content().contains("BungeeMethod: HTTP")); + } + + @Test void httpConnectionCodeIsRedactedFromManagedConfiguration() throws Exception { + Files.writeString(directory.resolve("BungeeSettings.yml"), + "HTTP:\n ConnectionCode: VPH1-sensitive-enrollment-code\n"); + BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); + + BackendConfigurationService.Document read = service.read("BungeeSettings.yml"); + assertFalse(read.content().contains("VPH1-sensitive-enrollment-code")); + assertTrue(read.content().contains(BackendConfigurationService.REDACTED)); + } + + @Test void httpMethodAcceptsGenerationBasedEnrolledProfile() throws Exception { + Files.writeString(directory.resolve("BungeeSettings.yml"), + "UseBungeecord: true\nServer: lobby-1\nBungeeMethod: PLUGINMESSAGING\nPluginMessageChannel: vp:vp\n"); + com.bencodez.votingplugin.backendproxy.http.HttpTlsIdentity identity = + com.bencodez.votingplugin.backendproxy.http.HttpTlsIdentity.loadOrCreate(directory.resolve("proxy"), "localhost"); + com.bencodez.votingplugin.backendproxy.http.HttpConnectionCode code = + new com.bencodez.votingplugin.backendproxy.http.HttpConnectionCode("lobby-1", + java.net.URI.create("https://localhost:1297/"), identity.serverCertificatePin(), + identity.caCertificatePin(), java.time.Instant.now().plusSeconds(60), "A".repeat(43)); + com.bencodez.votingplugin.backendproxy.http.HttpClientCredentialStore.saveEnrolled(directory.resolve("http"), code, + identity.issueClientCertificate("lobby-1")); + + BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); + assertDoesNotThrow(() -> service.previewQuickSetup("proxy-method", Map.of("method", "HTTP"))); + } + + @Test void httpMethodPreflightRejectsInvalidExpiredAndWrongServerConnectionCodes() throws Exception { + Path settings = directory.resolve("BungeeSettings.yml"); + Files.writeString(settings, "UseBungeecord: true\nServer: lobby-1\nBungeeMethod: PLUGINMESSAGING\n" + + "PluginMessageChannel: vp:vp\nHTTP:\n ConnectionCode: malformed\n"); + BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); + assertThrows(IllegalArgumentException.class, + () -> service.previewQuickSetup("proxy-method", Map.of("method", "HTTP"))); + + com.bencodez.votingplugin.backendproxy.http.HttpTlsIdentity identity = + com.bencodez.votingplugin.backendproxy.http.HttpTlsIdentity.loadOrCreate(directory.resolve("code-proxy"), "localhost"); + com.bencodez.votingplugin.backendproxy.http.HttpConnectionCode expired = + new com.bencodez.votingplugin.backendproxy.http.HttpConnectionCode("lobby-1", + java.net.URI.create("https://localhost:1297/"), identity.serverCertificatePin(), + identity.caCertificatePin(), java.time.Instant.now().minusSeconds(1), "A".repeat(43)); + Files.writeString(settings, Files.readString(settings).replace("malformed", expired.encode())); + assertThrows(IllegalArgumentException.class, + () -> service.previewQuickSetup("proxy-method", Map.of("method", "HTTP"))); + + com.bencodez.votingplugin.backendproxy.http.HttpConnectionCode wrongServer = + new com.bencodez.votingplugin.backendproxy.http.HttpConnectionCode("survival", + expired.endpoint(), expired.serverCertificatePin(), expired.caCertificatePin(), + java.time.Instant.now().plusSeconds(60), "B".repeat(43)); + Files.writeString(settings, Files.readString(settings).replace(expired.encode(), wrongServer.encode())); + assertThrows(IllegalArgumentException.class, + () -> service.previewQuickSetup("proxy-method", Map.of("method", "HTTP"))); } @Test void proxyMethodSwitchPreflightsRequiredBackendSettings() throws Exception { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationServiceTest.java index 4e6a25da7..f49600dce 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationServiceTest.java @@ -59,6 +59,27 @@ void validatesRequiredSettingsForEveryTransport() { when(config.getSpigotServerConfiguration("lobby")).thenReturn(Map.of("Host", "localhost")); assertDoesNotThrow(() -> service.validate(new ProxyMethodConfiguration(BungeeMethod.SOCKETS))); + assertThrows(IllegalArgumentException.class, + () -> service.validate(new ProxyMethodConfiguration(BungeeMethod.HTTP))); + when(config.getHttpHost()).thenReturn("0.0.0.0"); + when(config.getHttpPort()).thenReturn(1297); + when(config.getHttpPublicEndpoint()).thenReturn("http://proxy.example.test:1297"); + assertThrows(IllegalArgumentException.class, + () -> service.validate(new ProxyMethodConfiguration(BungeeMethod.HTTP))); + when(config.getHttpPublicEndpoint()).thenReturn("https://proxy.example.test:1297/terminated-path"); + assertThrows(IllegalArgumentException.class, + () -> service.validate(new ProxyMethodConfiguration(BungeeMethod.HTTP))); + when(config.getHttpPublicEndpoint()).thenReturn("https://proxy.example.test:0"); + assertThrows(IllegalArgumentException.class, + () -> service.validate(new ProxyMethodConfiguration(BungeeMethod.HTTP))); + when(config.getHttpPublicEndpoint()).thenReturn("https://proxy.example.test:65536"); + assertThrows(IllegalArgumentException.class, + () -> service.validate(new ProxyMethodConfiguration(BungeeMethod.HTTP))); + when(config.getHttpPublicEndpoint()).thenReturn("https://proxy.example.test"); + assertDoesNotThrow(() -> service.validate(new ProxyMethodConfiguration(BungeeMethod.HTTP))); + when(config.getHttpPublicEndpoint()).thenReturn("https://proxy.example.test:1297"); + assertDoesNotThrow(() -> service.validate(new ProxyMethodConfiguration(BungeeMethod.HTTP))); + assertThrows(IllegalArgumentException.class, () -> service.validate(new ProxyMethodConfiguration(BungeeMethod.MYSQL))); when(config.hasDatabaseConfigured()).thenReturn(true); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/BungeeMethodTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/BungeeMethodTest.java index 9d77bd7df..df9c09bc2 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/BungeeMethodTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/BungeeMethodTest.java @@ -1,6 +1,7 @@ package com.bencodez.votingplugin.tests; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.Test; @@ -22,4 +23,12 @@ public void standaloneTransportsSupportBackendPresence() { } } } + + @Test + public void httpMethodNameIsCaseInsensitive() { + assertEquals(BungeeMethod.HTTP, BungeeMethod.getByName("http")); + assertEquals(BungeeMethod.HTTP, BungeeMethod.getByName("HTTP")); + assertFalse(BungeeMethod.HTTP.requiresPlayerOnline()); + assertTrue(BungeeMethod.HTTP.supportsBackendPresence()); + } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java index b6c587a5a..6b3fb402d 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java @@ -16,6 +16,7 @@ import org.mockito.MockitoAnnotations; import com.bencodez.advancedcore.bungeeapi.globaldata.GlobalDataHandlerProxy; +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; import com.bencodez.simpleapi.servercomm.mysql.MySqlMessenger; import com.bencodez.votingplugin.proxy.BungeeMethod; import com.bencodez.votingplugin.proxy.OfflineBungeeVote; @@ -350,6 +351,76 @@ void pluginMessagingIgnoresExtendedPresenceLogin() { assertEquals(0, spyProxy.getBackendPlayerPresenceTracker().getOnlinePlayerCount()); } + @Test + void httpModernPresenceRejectsAuthenticatedBackendThatDoesNotMatchProxyRoute() { + String uuid = java.util.UUID.randomUUID().toString(); + Mockito.when(votingPluginProxy.getConfig().getOnlineMode()).thenReturn(true); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(uuid).when(spyProxy).getUUID("Player"); + var handler = Mockito.mock(com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler.class); + spyProxy.setGlobalMessageProxyHandlerForTest(handler); + JsonEnvelope envelope = VotingPluginWire.login("Player", uuid, "Server2", java.util.UUID.randomUUID(), + java.util.UUID.randomUUID(), 1000L, 1100L); + + spyProxy.handleHttpTransportEnvelopeForTest(new com.bencodez.votingplugin.backendproxy.http.HttpProxyTransportServer.ReceivedEnvelope( + "Server2", java.util.UUID.randomUUID().toString(), envelope)); + + verify(handler, never()).onMessage(Mockito.any()); + assertEquals(0, spyProxy.getBackendPlayerPresenceTracker().getOnlinePlayerCount()); + } + + @Test + void httpModernPresenceAcceptsAuthenticatedBackendMatchingProxyRouteAndUuid() { + String uuid = java.util.UUID.randomUUID().toString(); + Mockito.when(votingPluginProxy.getConfig().getOnlineMode()).thenReturn(true); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(uuid).when(spyProxy).getUUID("Player"); + var handler = Mockito.mock(com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler.class); + spyProxy.setGlobalMessageProxyHandlerForTest(handler); + JsonEnvelope envelope = VotingPluginWire.login("Player", uuid, "Server1", java.util.UUID.randomUUID(), + java.util.UUID.randomUUID(), 1000L, 1100L); + + spyProxy.handleHttpTransportEnvelopeForTest(new com.bencodez.votingplugin.backendproxy.http.HttpProxyTransportServer.ReceivedEnvelope( + "Server1", java.util.UUID.randomUUID().toString(), envelope)); + + verify(handler).onMessage(envelope); + } + + @Test + void httpModernPresenceRejectsUuidThatDoesNotMatchProxyPlayer() { + String authoritativeUuid = java.util.UUID.randomUUID().toString(); + String claimedUuid = java.util.UUID.randomUUID().toString(); + Mockito.when(votingPluginProxy.getConfig().getOnlineMode()).thenReturn(true); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(authoritativeUuid).when(spyProxy).getUUID("Player"); + var handler = Mockito.mock(com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler.class); + spyProxy.setGlobalMessageProxyHandlerForTest(handler); + JsonEnvelope envelope = VotingPluginWire.login("Player", claimedUuid, "Server1", java.util.UUID.randomUUID(), + java.util.UUID.randomUUID(), 1000L, 1100L); + + spyProxy.handleHttpTransportEnvelopeForTest(new com.bencodez.votingplugin.backendproxy.http.HttpProxyTransportServer.ReceivedEnvelope( + "Server1", java.util.UUID.randomUUID().toString(), envelope)); + + verify(handler, never()).onMessage(Mockito.any()); + assertEquals(0, spyProxy.getBackendPlayerPresenceTracker().getOnlinePlayerCount()); + } + + @Test + void httpEnvelopeRejectsServerFieldThatDoesNotMatchAuthenticatedBackend() { + votingPluginProxy.setMethod(BungeeMethod.HTTP); + var handler = Mockito.mock(com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler.class); + votingPluginProxy.setGlobalMessageProxyHandlerForTest(handler); + JsonEnvelope envelope = JsonEnvelope.builder("vote").put(VotingPluginWire.K_SERVER, "Server2").build(); + + votingPluginProxy.handleHttpTransportEnvelopeForTest(new com.bencodez.votingplugin.backendproxy.http.HttpProxyTransportServer.ReceivedEnvelope( + "Server1", java.util.UUID.randomUUID().toString(), envelope)); + + verify(handler, never()).onMessage(Mockito.any()); + } + @Test void dedicatedVotingProxyRoutesUsingConfirmedBackendPresence() { Mockito.when(votingPluginProxy.getConfig().getDedicatedVotingProxy()).thenReturn(true); @@ -604,6 +675,49 @@ void pendingServerBroadcastRetriesBeforeOfflineRewardDelivery() { verify(voteCache).updateServerVote("Server1", vote); } + @Test + void rejectedHttpQueueDeliveryRetainsCachedServerVote() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + OfflineBungeeVote vote = new OfflineBungeeVote(java.util.UUID.randomUUID(), "Player", "player-uuid", + "Service", 100L, true, "totals"); + Mockito.when(voteCache.hasVotes("Server1")).thenReturn(true); + Mockito.when(voteCache.getVotes("Server1")) + .thenReturn(new java.util.ArrayList<>(java.util.List.of(vote))); + Mockito.when(votingPluginProxy.getConfig().getBlockedServers()) + .thenReturn(java.util.Collections.emptyList()); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setVoteEnvelopeDeliveryResult(false); + + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + spyProxy.checkCachedVotes("Server1"); + + verify(voteCache).removeServerVotes(Mockito.eq("Server1"), + Mockito.argThat(java.util.List::isEmpty)); + } + + @Test + void rejectedHttpQueueDeliveryRetainsCachedOnlineVote() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + OfflineBungeeVote vote = new OfflineBungeeVote(java.util.UUID.randomUUID(), "Player", "player-uuid", + "Service", 100L, true, "totals"); + Mockito.when(voteCache.hasOnlineVotes("player-uuid")).thenReturn(true); + Mockito.when(voteCache.getOnlineVotes("player-uuid")) + .thenReturn(new java.util.ArrayList<>(java.util.List.of(vote))); + Mockito.when(votingPluginProxy.getConfig().getBlockedServers()) + .thenReturn(java.util.Collections.emptyList()); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setVoteEnvelopeDeliveryResult(false); + + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + spyProxy.checkOnlineVotes("Player", "player-uuid", "Server1"); + + assertFalse(vote.isRewardDelivered()); + verify(voteCache).addOnlineVote("player-uuid", vote); + verify(multiProxyHandler, never()).sendClearVote(Mockito.anyString(), Mockito.anyString()); + } + @Test void pendingOnlineBroadcastRetriesWhenTargetGainsAnyCarrier() { VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTestImpl.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTestImpl.java index ecdd7e6d1..a2d18b1c1 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTestImpl.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTestImpl.java @@ -17,12 +17,14 @@ import com.bencodez.votingplugin.proxy.OfflineBungeeVote; import com.bencodez.votingplugin.proxy.VotingPluginProxy; import com.bencodez.votingplugin.proxy.VotingPluginProxyConfig; +import com.bencodez.votingplugin.backendproxy.http.HttpProxyTransportServer; import com.bencodez.votingplugin.timequeue.VoteTimeQueue; public class VotingPluginProxyTestImpl extends VotingPluginProxy { private final List warnings = new ArrayList<>(); private VotingPluginProxyConfig config; private boolean pluginMessageDeliveryResult = true; + private boolean voteEnvelopeDeliveryResult = true; private boolean communicationTestDeliveryResult = true; private JsonEnvelope lastCommunicationTestEnvelope; private boolean playerOnline = true; @@ -212,6 +214,18 @@ public boolean sendProxyBroadcastImmediately(String server, JsonEnvelope envelop return sendProxyBroadcastEnvelopeNow(server, envelope); } + @Override + protected boolean sendVoteEnvelopeAccepted(String server, int delay, JsonEnvelope envelope) { + if (getMethod() == com.bencodez.votingplugin.proxy.BungeeMethod.HTTP) { + return voteEnvelopeDeliveryResult; + } + return super.sendVoteEnvelopeAccepted(server, delay, envelope); + } + + public void setVoteEnvelopeDeliveryResult(boolean voteEnvelopeDeliveryResult) { + this.voteEnvelopeDeliveryResult = voteEnvelopeDeliveryResult; + } + @Override protected boolean sendCommunicationTestEnvelopeNow(String server, JsonEnvelope envelope) { lastCommunicationTestEnvelope = envelope; @@ -230,6 +244,10 @@ public void handleLoginMessageForTest(JsonEnvelope envelope) { handleLoginMessage(envelope); } + public void handleHttpTransportEnvelopeForTest(HttpProxyTransportServer.ReceivedEnvelope received) { + handleHttpTransportEnvelope(received); + } + public void handleStatusOkayForTest(JsonEnvelope envelope) { handleStatusOkay(envelope); } diff --git a/docs/http-transport.md b/docs/http-transport.md new file mode 100644 index 000000000..740911e45 --- /dev/null +++ b/docs/http-transport.md @@ -0,0 +1,53 @@ +# Secure HTTP proxy transport + +The `HTTP` bungee method gives every backend an outbound encrypted connection to one HTTPS listener on the proxy. Only the proxy port is opened; backend servers need no inbound transport ports. + +## Quick setup + +1. On the proxy, set the following in `bungeeconfig.yml`: + + ```yaml + BungeeMethod: HTTP + HTTP: + Host: '0.0.0.0' + Port: 1297 + PublicEndpoint: 'https://proxy.example.com:1297/' + ``` + +2. Allow TCP port `1297` to the proxy. `PublicEndpoint` must resolve directly to this VotingPlugin listener. +3. Restart the proxy and run `/votingpluginbungee httpcode `. The name must exactly identify the intended backend; generate a separate code for each backend. +4. On a backend, set a unique `Server`, enable bungee mode, select `HTTP`, and paste its code into `BungeeSettings.yml`: + + ```yaml + UseBungeecord: true + Server: lobby-1 + BungeeMethod: HTTP + HTTP: + ConnectionCode: 'paste-code-here' + ``` + +5. Restart the backend. Once enrollment succeeds, remove `ConnectionCode` from the configuration. The backend's private identity is stored in its VotingPlugin data folder and is reused automatically. A different nonblank code is treated as an explicit re-enrollment request; a digest lets harmless restarts recognize the already-consumed code without storing its secret token. + +Connection codes expire after 15 minutes and can be used only once. Treat a fresh code like a temporary password: transfer it privately and do not publish it in logs, tickets, or chat rooms. + +## Security model + +- TLS keys and a private certificate authority are generated automatically on the proxy. No shared transport password or public CA setup is required. +- Enrollment pins both the exact proxy certificate and its private authority. Normal traffic trusts only that pinned private authority and keeps HTTPS hostname verification enabled, allowing the proxy leaf certificate to rotate safely without trusting public certificate authorities. It uses a distinct client certificate bound to that backend's canonical `Server` name. +- Proxy and backend leaf certificates renew automatically during a 30-day pre-expiry window. Backend renewal is authenticated by the still-valid mTLS identity, persisted before use, and switches the proxy binding only after the replacement successfully connects; no new connection code is needed. +- Every normal request is authenticated again at the application boundary. A payload cannot claim another backend identity, and redirect following is disabled. +- TLS 1.3 is required and weak protocols are disabled. +- Enrollment tokens are 256-bit random capabilities, single-use, short-lived, and retained by the proxy only as hashes. +- Credentials, keys, pins, and revocation state are written atomically with owner-only permissions where the operating system supports them. +- Request bodies, queues, batches, worker pools, concurrent requests, per-backend polling, and request rates are bounded. Exact paths, methods, and JSON content types are enforced. +- Proxy-to-backend messages stay in an owner-only durable proxy queue until the backend acknowledgement is durably applied. The backend fsyncs three delivery states around each callback: a reserved callback resumes after restart, a completed callback is acknowledged without running twice, and a running callback with an uncertain outcome is neither replayed nor acknowledged. The proxy therefore retains the durable source copy instead of silently losing it. The completed entry is deleted only after a matching authenticated HTTP 200 confirms the proxy removed the queued message. +- A host failure while a callback is running has an inherently ambiguous result because arbitrary reward commands and external plugin effects cannot share a transaction with the transport journal. Such a delivery remains quarantined for operator investigation, avoiding both an automatic duplicate reward and an acknowledgement that could hide a missed reward. +- Backend-to-proxy messages retain the existing bounded in-process retry semantics. Vote caching remains responsible for application-level durability in that direction. + +The listener must terminate TLS itself because client-certificate authentication is part of the protocol. Do not put an HTTP TLS-terminating reverse proxy or CDN in front of it. A TCP/L4 proxy that passes TLS through unchanged is suitable. Internet-facing installations should also use the host firewall or provider firewall for volumetric denial-of-service protection; an application cannot fully absorb a link or TCP flood. + +If a backend host or its private credential is compromised, run `/votingpluginbungee httprevoke ` on the proxy before generating a new connection code. Revocation takes effect on the next request and permits a replacement identity to enroll under that server name. Keep the proxy's `http` data directory backed up and private: it contains the transport authority. + +## Performance + +The connector reuses HTTP/1.1 TLS connections, batches messages and acknowledgements, and performs all network, certificate, and delivery-journal work off the game thread. A two-second bounded long poll avoids busy polling while limiting the worst-case delay for a backend message queued just after an idle request began. Callback lanes are single-worker FIFO queues; when their fixed capacity is reached, bounded listener/poller workers apply backpressure instead of dropping an older callback and admitting newer work out of order. All queues and admission pools remain bounded to prevent traffic bursts from causing unbounded memory growth.