From ad80cec08a21d44d4660e99c93dcd36875a0d914 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 21 Oct 2026 21:42:12 -0600 Subject: [PATCH 1/5] Add authenticated NeoForge Redis MQTT and MySQL transports --- VotingPlugin/pom.xml | 44 +++ .../neoforge/NeoForgeJsonStrings.java | 40 ++ .../neoforge/NeoForgeMysqlDataSource.java | 65 +++ .../neoforge/NeoForgeMysqlQueue.java | 171 ++++++++ .../neoforge/NeoForgeProxyBrokerService.java | 179 +++++++++ .../neoforge/NeoForgeProxyEnvelopeCodec.java | 85 ++++ .../neoforge/NeoForgeProxyMessageService.java | 369 ++++++++++++++++++ .../neoforge/NeoForgeProxyMqttService.java | 67 ++++ .../neoforge/NeoForgeProxyMysqlService.java | 159 ++++++++ .../neoforge/NeoForgeProxyRedisService.java | 48 +++ .../neoforge/NeoForgeProxySocketService.java | 344 +--------------- .../neoforge/NeoForgeProxyTransport.java | 10 + .../NeoForgeProxyTransportConfiguration.java | 186 +++++++++ .../neoforge/NeoForgeRedisConnection.java | 172 ++++++++ .../neoforge/NeoForgeRuntime.java | 50 ++- .../neoforge/NeoForgeVotingPlugin.java | 6 +- .../neoforge/NeoForgeMysqlDataSourceTest.java | 55 +++ .../neoforge/NeoForgeMysqlQueueTest.java | 228 +++++++++++ .../neoforge/NeoForgePlatformGsonTest.java | 87 +++++ .../NeoForgeProxyEnvelopeCodecTest.java | 88 +++++ .../NeoForgeProxyMqttServiceTest.java | 296 ++++++++++++++ .../NeoForgeProxyMysqlServiceTest.java | 247 ++++++++++++ .../NeoForgeProxyRedisServiceTest.java | 297 ++++++++++++++ ...oForgeProxyTransportConfigurationTest.java | 237 +++++++++++ .../neoforge/NeoForgeRedisConnectionTest.java | 214 ++++++++++ .../tests/neoforge/NeoForgeRedisWireTest.java | 186 +++++++++ .../tests/packaging/PackagedArtifactTest.java | 43 ++ docs/neoforge-vote-transports.md | 77 ++++ 28 files changed, 3706 insertions(+), 344 deletions(-) create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeJsonStrings.java create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeMysqlDataSource.java create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeMysqlQueue.java create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyBrokerService.java create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyEnvelopeCodec.java create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyMessageService.java create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyMqttService.java create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyMysqlService.java create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyRedisService.java create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyTransport.java create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyTransportConfiguration.java create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeRedisConnection.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeMysqlDataSourceTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeMysqlQueueTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgePlatformGsonTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyEnvelopeCodecTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyMqttServiceTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyMysqlServiceTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyRedisServiceTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyTransportConfigurationTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeRedisConnectionTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeRedisWireTest.java create mode 100644 docs/neoforge-vote-transports.md diff --git a/VotingPlugin/pom.xml b/VotingPlugin/pom.xml index 0fb0b0a0a0..f42344a518 100644 --- a/VotingPlugin/pom.xml +++ b/VotingPlugin/pom.xml @@ -126,6 +126,10 @@ + + org.mariadb + ${project.groupId}.votingplugin.neoforge.mariadb + com.tcoded.folialib @@ -204,6 +208,13 @@ false false + + org.mariadb.jdbc:mariadb-java-client + + + META-INF/services/java.sql.Driver + + @@ -268,10 +279,38 @@ + + org.apache.maven.plugins + maven-dependency-plugin + 3.8.1 + + + neoforge-platform-gson-fixture + generate-test-resources + copy + + + + com.google.code.gson + gson + + 2.10.1 + ${project.build.directory}/test-platform-libraries + + + + + + org.apache.maven.plugins maven-surefire-plugin 3.5.4 + + + ${project.build.directory}/test-platform-libraries/gson-2.10.1.jar + + default-test @@ -351,6 +390,11 @@ + + org.mariadb.jdbc + mariadb-java-client + 3.5.10 + net.neoforged neoforge diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeJsonStrings.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeJsonStrings.java new file mode 100644 index 0000000000..60d48df0ae --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeJsonStrings.java @@ -0,0 +1,40 @@ +package com.bencodez.votingplugin.neoforge; + +import java.io.IOException; + +/** Strict JSON string lexicon for bounded inputs; callers enforce the object and value grammar. */ +final class NeoForgeJsonStrings { + private NeoForgeJsonStrings() { } + + static void validate(String json) throws IOException { + // Gson 2.10.1's setLenient(false) still accepts raw controls and legacy string escapes. + // Validate the string lexicon before the streaming reader without depending on newer Gson APIs. + boolean quoted = false; + for (int i = 0; i < json.length(); i++) { + char c = json.charAt(i); + if (!quoted) { + if (c == '"') quoted = true; + continue; + } + if (c == '"') { quoted = false; continue; } + if (c < 0x20) throw invalid(); + if (c != '\\') continue; + if (++i == json.length()) throw invalid(); + switch (json.charAt(i)) { + case '"', '\\', '/', 'b', 'f', 'n', 'r', 't' -> { } + case 'u' -> { + if (json.length() - i <= 4) throw invalid(); + for (int end = i + 4; i < end;) { + char digit = json.charAt(++i); + if (!(digit >= '0' && digit <= '9' || digit >= 'a' && digit <= 'f' + || digit >= 'A' && digit <= 'F')) throw invalid(); + } + } + default -> throw invalid(); + } + } + if (quoted) throw invalid(); + } + + private static IOException invalid() { return new IOException("Invalid JSON string"); } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeMysqlDataSource.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeMysqlDataSource.java new file mode 100644 index 0000000000..9a886f5393 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeMysqlDataSource.java @@ -0,0 +1,65 @@ +package com.bencodez.votingplugin.neoforge; + +import java.io.PrintWriter; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.util.logging.Logger; + +import javax.sql.DataSource; + +import org.mariadb.jdbc.Configuration; +import org.mariadb.jdbc.Driver; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; + +/** The bundled, privately relocated driver is selected directly, without global JDBC registration lookup. */ +final class NeoForgeMysqlDataSource { + private NeoForgeMysqlDataSource() { } + + static Configuration configuration(NeoForgeProxyTransportConfiguration.Mysql settings) { + // Programmatic fields keep database names and opaque credentials out of URL option parsing. + return new Configuration.Builder().addHost(settings.host(), settings.port()).database(settings.database()) + .user(settings.username()).password(settings.password()).connectTimeout(2000).socketTimeout(2000) + .sslMode(settings.ssl() ? "verify-full" : "disable") + .allowPublicKeyRetrieval(settings.publicKeyRetrieval()).allowLocalInfile(false) + .allowMultiQueries(false).dumpQueriesOnException(false).build(); + } + + static HikariDataSource open(NeoForgeProxyTransportConfiguration.Mysql settings) { + HikariConfig pool = new HikariConfig(); + pool.setPoolName("VotingPlugin-NeoForge-MySQL"); + pool.setDataSource(new Source(configuration(settings))); + pool.setMaximumPoolSize(settings.maxConnections()); + pool.setMinimumIdle(0); + pool.setConnectionTimeout(2000); + pool.setValidationTimeout(1000); + pool.setInitializationFailTimeout(-1); + return new HikariDataSource(pool); + } + + private static final class Source implements DataSource { + private final Configuration configuration; + Source(Configuration configuration) { this.configuration = configuration; } + @Override public Connection getConnection() throws SQLException { return Driver.connect(configuration); } + @Override public Connection getConnection(String user, String password) throws SQLException { + throw new SQLFeatureNotSupportedException("Use the configured transport credentials"); + } + @Override public PrintWriter getLogWriter() { return null; } + @Override public void setLogWriter(PrintWriter writer) throws SQLException { + if (writer != null) throw new SQLFeatureNotSupportedException("Transport diagnostics are redacted"); + } + @Override public int getLoginTimeout() { return 2; } + @Override public void setLoginTimeout(int seconds) throws SQLException { + if (seconds < 0) throw new SQLException("Invalid login timeout"); + // The immutable driver configuration retains the stricter two-second connection bound. + } + @Override public Logger getParentLogger() { return Logger.getLogger("VotingPlugin-NeoForge-MySQL"); } + @Override public T unwrap(Class type) throws SQLException { + if (type.isInstance(this)) return type.cast(this); + throw new SQLException("Unsupported datasource wrapper"); + } + @Override public boolean isWrapperFor(Class type) { return type.isInstance(this); } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeMysqlQueue.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeMysqlQueue.java new file mode 100644 index 0000000000..eaa3418797 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeMysqlQueue.java @@ -0,0 +1,171 @@ +package com.bencodez.votingplugin.neoforge; + +import java.io.IOException; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Logger; + +import javax.sql.DataSource; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec; + +/** Uses the existing SimpleAPI queue schema, retaining a row until application acceptance. */ +final class NeoForgeMysqlQueue implements NeoForgeProxyMysqlService.Queue { + static final int PAGE_SIZE = 50; + private static final int QUERY_SECONDS = 2; + private static final Logger LOGGER = Logger.getLogger(NeoForgeMysqlQueue.class.getName()); + private static final String TABLE = "VotingPlugin_message_queue"; + private final DataSource source; + private final AutoCloseable closeSource; + private final String server; + private final AtomicBoolean open = new AtomicBoolean(true); + private final AtomicBoolean invalidWarning = new AtomicBoolean(); + private long cursor; + + NeoForgeMysqlQueue(DataSource source, AutoCloseable closeSource, String server) throws SQLException { + this.source = Objects.requireNonNull(source, "source"); + this.closeSource = Objects.requireNonNull(closeSource, "closeSource"); + this.server = Objects.requireNonNull(server, "server"); + try (Connection connection = source.getConnection(); var statement = connection.createStatement()) { + statement.setQueryTimeout(QUERY_SECONDS); + statement.execute("CREATE TABLE IF NOT EXISTS " + TABLE + + " (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, source VARCHAR(36) NOT NULL," + + " destination VARCHAR(36) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP," + + " payload LONGTEXT NOT NULL, PRIMARY KEY (id), INDEX idx_dest_id (destination, id)) ENGINE=InnoDB"); + } + } + + @Override public void poll(NeoForgeProxyMysqlService.Receiver receiver) throws SQLException { + if (!open.get()) return; + List rows = readPage(); + // No pool connection remains checked out during storage work or acknowledgement publication. + for (Row row : rows) { + if (!open.get()) return; + if (row.payload == null) { + if (invalidWarning.compareAndSet(false, true)) LOGGER.warning("Retained invalid MySQL queue payload"); + continue; + } + if (receiver.receive(row.source, row.destination, row.payload) && open.get()) remove(row); + } + } + + private List readPage() throws SQLException { + ArrayList rows = new ArrayList<>(PAGE_SIZE); + try (Connection connection = source.getConnection()) { + String lock = "backend-channel-" + server; + if (!acquire(connection, lock)) return rows; + try { + // Bound the actual server response too: a large LONGTEXT never crosses the JDBC boundary. + try (PreparedStatement select = connection.prepareStatement("SELECT id, source, destination," + + " CASE WHEN OCTET_LENGTH(payload) <= " + NeoForgeProxyEnvelopeCodec.MAX_BYTES + + " THEN payload ELSE NULL END AS payload FROM " + TABLE + + " WHERE destination = ? AND id > ? ORDER BY id LIMIT " + PAGE_SIZE)) { + select.setQueryTimeout(QUERY_SECONDS); + select.setMaxRows(PAGE_SIZE); + select.setString(1, server); + select.setLong(2, cursor); + try (var result = select.executeQuery()) { + while (rows.size() < PAGE_SIZE && result.next()) { + long id = result.getLong("id"); + if (id <= cursor) throw new SQLException("Invalid MySQL queue ordering"); + byte[] payload; + try (Reader reader = result.getCharacterStream("payload")) { + payload = boundedPayload(reader); + } catch (IOException failure) { + throw new SQLException("MySQL queue read failed"); + } + rows.add(new Row(id, result.getString("source"), result.getString("destination"), payload)); + cursor = id; + } + } + } + // Revisit refused rows, while full pages can advance to other players rather than starve them. + if (rows.size() < PAGE_SIZE) cursor = 0; + } finally { + try (PreparedStatement release = connection.prepareStatement("SELECT RELEASE_LOCK(?)")) { + release.setQueryTimeout(QUERY_SECONDS); + release.setString(1, lock); + try (var result = release.executeQuery()) { + if (!result.next() || result.getInt(1) != 1) throw new SQLException("MySQL queue lock not released"); + } + } catch (SQLException failure) { + // Returning a connection with a named lock to the pool can strand peer consumers. + try { connection.abort(Runnable::run); } + catch (SQLException cleanup) { failure.addSuppressed(cleanup); } + throw failure; + } + } + } + return rows; + } + + private static byte[] boundedPayload(Reader reader) throws IOException { + if (reader == null) return null; + StringBuilder text = new StringBuilder(); + char[] buffer = new char[4096]; + int count; + while ((count = reader.read(buffer)) != -1) { + if (text.length() + count > NeoForgeProxyEnvelopeCodec.MAX_BYTES) return null; + text.append(buffer, 0, count); + } + try { + var encoded = StandardCharsets.UTF_8.newEncoder().onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT).encode(CharBuffer.wrap(text)); + if (encoded.remaining() > NeoForgeProxyEnvelopeCodec.MAX_BYTES) return null; + byte[] bytes = new byte[encoded.remaining()]; + encoded.get(bytes); + return bytes; + } catch (CharacterCodingException invalid) { return null; } + } + + private static boolean acquire(Connection connection, String lock) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement("SELECT GET_LOCK(?, 0)")) { + statement.setQueryTimeout(QUERY_SECONDS); + statement.setString(1, lock); + try (var result = statement.executeQuery()) { return result.next() && result.getInt(1) == 1; } + } + } + + private void remove(Row row) throws SQLException { + // Another consumer may have already removed it; a missing row is an idempotent outcome. + try (Connection connection = source.getConnection(); PreparedStatement remove = connection.prepareStatement( + "DELETE FROM " + TABLE + " WHERE id = ? AND source = 'proxy' AND destination = ?")) { + remove.setQueryTimeout(QUERY_SECONDS); + remove.setLong(1, row.id); + remove.setString(2, server); + remove.executeUpdate(); + } + } + + @Override public void publish(JsonEnvelope envelope) throws SQLException { + if (!open.get()) throw new SQLException("MySQL transport is closed"); + String payload = JsonEnvelopeCodec.encode(envelope); + if (payload.getBytes(StandardCharsets.UTF_8).length > NeoForgeProxyEnvelopeCodec.MAX_BYTES) { + throw new SQLException("MySQL envelope exceeds transport limit"); + } + try (Connection connection = source.getConnection(); PreparedStatement insert = connection.prepareStatement( + "INSERT INTO " + TABLE + " (source, destination, payload) VALUES (?, 'proxy', ?)")) { + insert.setQueryTimeout(QUERY_SECONDS); + insert.setString(1, server); + insert.setString(2, payload); + insert.executeUpdate(); + } + } + + @Override public void close() throws Exception { + if (open.compareAndSet(true, false)) closeSource.close(); + } + + private record Row(long id, String source, String destination, byte[] payload) { } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyBrokerService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyBrokerService.java new file mode 100644 index 0000000000..83e7793003 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyBrokerService.java @@ -0,0 +1,179 @@ +package com.bencodez.votingplugin.neoforge; + +import java.io.IOException; +import java.util.Objects; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; +import java.util.logging.Logger; + + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.votingplugin.core.vote.SharedVoteIdentity; +import com.bencodez.votingplugin.proxy.VotingPluginWire; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Domain; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode; +import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption; + +/** Shared broker authentication, durable dispatch and owned connection lifecycle. */ +final class NeoForgeProxyBrokerService implements NeoForgeProxyTransport { + private static final Logger LOGGER = Logger.getLogger(NeoForgeProxyBrokerService.class.getName()); + + interface Connection extends AutoCloseable { + void subscribe(String topic, BiConsumer receiver) throws Exception; + void publish(String topic, JsonEnvelope envelope) throws Exception; + @Override void close() throws Exception; + } + + interface ConnectionFactory { Connection open() throws Exception; } + + private final NeoForgeProxyTransportConfiguration configuration; + private final SharedTransportEnvelopeAuthenticator authenticator; + private final TransportEnvelopeEncryption encryption; + private final ConnectionFactory factory; + private final Domain domain; + private final String inboundTopic; + private final String outboundTopic; + private final NeoForgeProxyMessageService messages; + private final AtomicReference connection = new AtomicReference<>(); + private final AtomicBoolean open = new AtomicBoolean(true); + private final AtomicBoolean invalidWarning = new AtomicBoolean(); + private final Object lifecycle = new Object(); + private final ScheduledThreadPoolExecutor worker; + private long retrySeconds = 1; + + NeoForgeProxyBrokerService(NeoForgeProxyTransportConfiguration configuration, NeoForgeVoteProcessor processor, + NeoForgePlayerDirectory players, SharedTransportEnvelopeAuthenticator authenticator, + TransportEnvelopeEncryption encryption, ConnectionFactory factory, boolean scheduleHeartbeat) { + this.configuration = Objects.requireNonNull(configuration, "configuration"); + if (configuration.method() != NeoForgeProxyTransportConfiguration.Method.MQTT + && configuration.method() != NeoForgeProxyTransportConfiguration.Method.REDIS) { + throw new IllegalArgumentException("Broker settings required"); + } + this.authenticator = Objects.requireNonNull(authenticator, "authenticator"); + this.encryption = Objects.requireNonNull(encryption, "encryption"); + this.factory = Objects.requireNonNull(factory, "factory"); + boolean redis = configuration.method() == NeoForgeProxyTransportConfiguration.Method.REDIS; + domain = redis ? Domain.REDIS_PROXY_BACKEND : Domain.MQTT_PROXY_BACKEND; + inboundTopic = redis ? com.bencodez.votingplugin.proxy.redis.VotingPluginRedisChannels.backend( + configuration.redis().prefix(), configuration.server()) + : configuration.mqtt().prefix() + "votingplugin/servers/" + configuration.server(); + outboundTopic = redis ? com.bencodez.votingplugin.proxy.redis.VotingPluginRedisChannels.proxy( + configuration.redis().prefix()) : configuration.mqtt().prefix() + "votingplugin/servers/proxy"; + if (authenticator.mode() == Mode.COMPATIBILITY) { + LOGGER.warning("Proxy broker compatibility mode accepts legacy unsigned traffic; use REQUIRED after upgrading peers"); + } + worker = new ScheduledThreadPoolExecutor(1, task -> { + Thread thread = new Thread(task, "VotingPlugin-NeoForge-" + configuration.method()); + thread.setDaemon(true); + return thread; + }); + worker.setRemoveOnCancelPolicy(true); + worker.setExecuteExistingDelayedTasksAfterShutdownPolicy(false); + messages = new NeoForgeProxyMessageService(configuration.server(), processor, players, + this::send, () -> { }, scheduleHeartbeat); + worker.execute(this::connect); + } + + private void connect() { + if (!open.get()) return; + Connection candidate = null; + boolean adopted = false; + try { + candidate = factory.open(); + if (!open.get()) return; + candidate.subscribe(inboundTopic, this::receive); + synchronized (lifecycle) { + if (!open.get()) return; + connection.set(candidate); + adopted = true; + } + messages.transportReady(); + } catch (Exception failure) { + if (open.get() && !adopted) { + LOGGER.warning("Proxy broker connection unavailable; retrying without acknowledging unretained votes"); + synchronized (lifecycle) { + if (open.get()) { + worker.schedule(this::connect, retrySeconds, TimeUnit.SECONDS); + retrySeconds = Math.min(60, retrySeconds * 2); + } + } + } + } finally { + if (!adopted && candidate != null) closeConnection(candidate); + } + } + + void receive(String topic, byte[] payload) { + if (!open.get() || !inboundTopic.equals(topic)) return; + try { + JsonEnvelope envelope = NeoForgeProxyEnvelopeCodec.decode(payload); + String sender = envelope.getFields().get(SharedTransportEnvelopeAuthenticator.K_SENDER); + var verified = authenticator.verify(envelope, domain, inboundTopic); + if (!verified.accepted() || (!verified.unsignedCompatibility() + && !configuration.proxyName().equals(sender))) { + warnInvalid(); + return; + } + var decrypted = encryption.decrypt(verified.envelope()); + if (!decrypted.accepted()) { + warnInvalid(); + return; + } + messages.receive(decrypted.envelope()); + } catch (IOException | RuntimeException invalid) { + warnInvalid(); + } + } + + private void warnInvalid() { + if (invalidWarning.compareAndSet(false, true)) LOGGER.warning("Rejected invalid broker proxy message"); + } + + private void send(JsonEnvelope envelope) { + Connection current = connection.get(); + // Initial announcements precede connection admission; transportReady republishes them. + if (current == null) return; + JsonEnvelope.Builder builder = JsonEnvelope.builder(envelope.getSubChannel()).schema(envelope.getSchema()); + envelope.getFields().forEach((key, value) -> { + // NeoForge cannot complete delayed-vote rejection; never negotiate that capability. + if (!VotingPluginWire.K_VOTE_DELAY_REJECTION_ACK_VERSION.equals(key)) builder.put(key, value); + }); + try { + current.publish(outboundTopic, authenticator.sign(prepareOutbound(builder.build()), + domain, configuration.server(), outboundTopic)); + } catch (Exception failure) { + throw new IllegalStateException("Proxy broker publication failed"); + } + } + + private JsonEnvelope prepareOutbound(JsonEnvelope envelope) { + JsonEnvelope encrypted = encryption.encrypt(envelope); + return domain == Domain.REDIS_PROXY_BACKEND ? VotingPluginWire.withRedisDeliveryId(encrypted) : encrypted; + } + + void transportReady() { messages.transportReady(); } + + @Override public void playerOnline(SharedVoteIdentity identity) { messages.playerOnline(identity); } + @Override public void playerOffline(SharedVoteIdentity identity) { messages.playerOffline(identity); } + + @Override public void close() { + synchronized (lifecycle) { + if (!open.compareAndSet(true, false)) return; + } + messages.close(); + synchronized (lifecycle) { + Connection retired = connection.getAndSet(null); + worker.execute(() -> { if (retired != null) closeConnection(retired); }); + worker.shutdown(); + } + } + + private static void closeConnection(Connection retired) { + try { retired.close(); } + catch (Exception failure) { LOGGER.warning("Proxy broker transport cleanup failed"); } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyEnvelopeCodec.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyEnvelopeCodec.java new file mode 100644 index 0000000000..bb7f0159c1 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyEnvelopeCodec.java @@ -0,0 +1,85 @@ +package com.bencodez.votingplugin.neoforge; + +import java.io.IOException; +import java.io.StringReader; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; + +/** Bounded broker input, matching the flat SimpleAPI envelope wire representation. */ +final class NeoForgeProxyEnvelopeCodec { + static final int MAX_BYTES = 256 * 1024; + private NeoForgeProxyEnvelopeCodec() { } + + static JsonEnvelope decode(byte[] payload) throws IOException { + if (payload == null || payload.length == 0 || payload.length > MAX_BYTES) { + throw new IOException("Invalid proxy envelope size"); + } + String json; + try { + json = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(payload)).toString(); + } catch (CharacterCodingException invalid) { + throw new IOException("Invalid proxy envelope encoding"); + } + NeoForgeJsonStrings.validate(json); + try (JsonReader reader = new JsonReader(new StringReader(json))) { + reader.setLenient(false); + reader.beginObject(); + String subchannel = null; + // The established codec defaults an omitted version to schema 1 and omitted fields to empty. + Integer schema = 1; + Map fields = Map.of(); + Set seen = new HashSet<>(); + while (reader.hasNext()) { + String name = reader.nextName(); + if (!seen.add(name)) throw new IOException("Duplicate envelope member"); + switch (name) { + case "t" -> subchannel = text(reader, 64); + case "v" -> { + if (reader.peek() != JsonToken.NUMBER) throw new IOException("Invalid envelope schema"); + String number = reader.nextString(); + if (!number.matches("[0-9]{1,9}")) throw new IOException("Invalid envelope schema"); + schema = Integer.valueOf(number); + } + case "f" -> { + fields = new LinkedHashMap<>(); + reader.beginObject(); + while (reader.hasNext()) { + if (fields.size() >= 128) throw new IOException("Too many envelope fields"); + String key = reader.nextName(); + if (key.isEmpty() || key.length() > 128 || fields.containsKey(key)) { + throw new IOException("Invalid envelope field"); + } + fields.put(key, text(reader, 64 * 1024)); + } + reader.endObject(); + } + default -> throw new IOException("Unknown envelope member"); + } + } + reader.endObject(); + if (reader.peek() != JsonToken.END_DOCUMENT || subchannel == null || subchannel.isBlank() + || schema == null || fields == null) throw new IOException("Incomplete proxy envelope"); + return new JsonEnvelope(subchannel, schema, fields); + } catch (IllegalStateException | NumberFormatException invalid) { + throw new IOException("Invalid proxy envelope"); + } + } + + private static String text(JsonReader reader, int maximum) throws IOException { + if (reader.peek() != JsonToken.STRING) throw new IOException("Envelope text must be a string"); + String value = reader.nextString(); + if (value.length() > maximum) throw new IOException("Envelope text is too long"); + return value; + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyMessageService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyMessageService.java new file mode 100644 index 0000000000..78935b41af --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyMessageService.java @@ -0,0 +1,369 @@ +package com.bencodez.votingplugin.neoforge; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.logging.Logger; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.votingplugin.backendproxy.presence.BackendPlayerPresenceSession; +import com.bencodez.votingplugin.core.vote.SharedVoteIdentity; +import com.bencodez.votingplugin.proxy.VotingPluginWire; +import com.bencodez.votingplugin.util.ServiceSiteValidator; + +/** Shared durable NeoForge vote/presence protocol. Only transport adapters supply verified envelopes. */ +final class NeoForgeProxyMessageService implements AutoCloseable { + private static final Logger LOGGER = Logger.getLogger(NeoForgeProxyMessageService.class.getName()); + private static final int SNAPSHOT_CHUNK_SIZE = 100; + private static final int MAX_PENDING_SENDS = 256; + private static final long HEARTBEAT_SECONDS = 30; + + private final String server; + private final NeoForgeVoteProcessor processor; + private final NeoForgePlayerDirectory players; + private final Consumer outbound; + private final Runnable transportClose; + private final ThreadPoolExecutor sender; + private final ScheduledExecutorService heartbeat; + private final Map sessions = new ConcurrentHashMap<>(); + private final AtomicBoolean open = new AtomicBoolean(true); + private final AtomicBoolean presenceDirty = new AtomicBoolean(); + private final AtomicBoolean broadcastWarningLogged = new AtomicBoolean(); + private final Object presenceLock = new Object(); + private final Object sendLock = new Object(); + private final Object timestampLock = new Object(); + private final UUID incarnationId = UUID.randomUUID(); + private final long startedAt = System.currentTimeMillis(); + private long lastTimestamp = startedAt; + + NeoForgeProxyMessageService(String server, NeoForgeVoteProcessor processor, + NeoForgePlayerDirectory players, Consumer outbound, + Runnable transportClose, boolean scheduleHeartbeat) { + this.server = Objects.requireNonNull(server, "server"); + this.processor = Objects.requireNonNull(processor, "processor"); + this.players = Objects.requireNonNull(players, "players"); + this.outbound = Objects.requireNonNull(outbound, "outbound"); + this.transportClose = Objects.requireNonNull(transportClose, "transportClose"); + heartbeat = scheduleHeartbeat ? Executors.newSingleThreadScheduledExecutor(task -> { + Thread thread = new Thread(task, "VotingPlugin-NeoForge-Presence"); + thread.setDaemon(true); + return thread; + }) : null; + sender = scheduleHeartbeat ? new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, + new ArrayBlockingQueue<>(MAX_PENDING_SENDS), task -> { + Thread thread = new Thread(task, "VotingPlugin-NeoForge-Send"); + thread.setDaemon(true); + return thread; + }, new ThreadPoolExecutor.AbortPolicy()) : null; + for (SharedVoteIdentity identity : players.onlineIdentities()) addSession(identity); + announceStarted(); + sendHeartbeat(); + sessions.values().forEach(this::sendLogin); + if (heartbeat != null) heartbeat.scheduleAtFixedRate(this::sendHeartbeat, + HEARTBEAT_SECONDS, HEARTBEAT_SECONDS, TimeUnit.SECONDS); + } + + boolean isOpen() { return open.get(); } + + void transportReady() { + synchronized (presenceLock) { + if (!open.get()) return; + announceStarted(); + sendHeartbeat(); + sessions.values().forEach(this::sendLogin); + } + } + + public void playerOnline(SharedVoteIdentity identity) { + synchronized (presenceLock) { + if (!open.get()) return; + BackendPlayerPresenceSession session = addSession(identity); + announceStarted(); + sendLogin(session); + } + } + + public void playerOffline(SharedVoteIdentity identity) { + synchronized (presenceLock) { + if (!open.get()) return; + BackendPlayerPresenceSession session = sessions.remove(identity.uuid()); + if (session == null) return; + send(VotingPluginWire.logout(session.getPlayerName(), session.getUuid(), server, + session.getConnectionId(), incarnationId, startedAt, nextTimestamp())); + } + } + + /** True only after a supported envelope has reached its confirmed application boundary. */ + boolean receive(JsonEnvelope envelope) { + if (!open.get() || envelope == null) return false; + if (envelope.getSchema() != VotingPluginWire.SCHEMA_VERSION) return false; + try { + return switch (envelope.getSubChannel()) { + case VotingPluginWire.SUB_VOTE, VotingPluginWire.SUB_VOTE_ONLINE -> receiveVote(envelope); + case VotingPluginWire.SUB_STATUS -> receiveStatus(envelope); + case VotingPluginWire.SUB_VOTE_DELIVERY_RECEIPT_RELEASE -> receiveReceiptRelease(envelope); + case VotingPluginWire.SUB_PRESENCE_RESYNC_REQUEST -> receiveResync(envelope); + case VotingPluginWire.SUB_PRESENCE_SNAPSHOT_REQUEST -> receiveSnapshotRequest(envelope); + default -> false; + }; + } catch (RuntimeException failure) { + LOGGER.warning("NeoForge proxy message " + safeSubchannel(envelope) + + " failed: " + failure.getClass().getSimpleName()); + return false; + } + } + + private boolean receiveVote(JsonEnvelope envelope) { + VotingPluginWire.Vote vote = VotingPluginWire.readVote(envelope); + if (vote.voteId == null || vote.player.isBlank() || vote.player.length() > 64 + || !ServiceSiteValidator.isValid(vote.service)) return false; + // Backend broadcast delivery is not supported by the NeoForge replay + // boundary. Leave the occurrence unacknowledged in the proxy durable + // outbox instead of silently completing an omitted side effect. + if (vote.broadcast) { + if (broadcastWarningLogged.compareAndSet(false, true)) { + LOGGER.warning("NeoForge cannot execute delegated vote broadcasts; deliveries remain unacknowledged. " + + "Exclude this backend from proxy broadcast routing for new votes."); + } + return false; + } + UUID playerId; + try { + playerId = UUID.fromString(vote.uuid); + } catch (IllegalArgumentException invalid) { + return false; + } + // NeoForge does not yet import a proxy-owned totals snapshot. Fail closed + // instead of silently completing a vote with incorrect totals. + if (vote.manageTotals || !vote.setTotals || vote.numberOfVotes < 1 + || vote.num < 1 || vote.num > vote.numberOfVotes) return false; + boolean currentlyOnline = players.online(playerId).isPresent(); + boolean wasOnline = vote.wasOnlineKnown ? vote.wasOnline : currentlyOnline; + NeoForgeVoteResult result = processor.process(new NeoForgeVoteRequest(vote.voteId, playerId, + vote.player, vote.service, vote.time, vote.realVote, true, + currentlyOnline, wasOnline, NeoForgeVoteRequest.Scope.COMPLETE)); + boolean retained = result.durablyRetained() || result.durablyCompleted(); + if (VotingPluginWire.requestsVoteDeliveryAcknowledgement(envelope) && retained) { + send(VotingPluginWire.voteDeliveryAcknowledgement(server, vote.voteId, + envelope.getSubChannel())); + } + return retained; + } + + private boolean receiveStatus(JsonEnvelope envelope) { + if (!isTargetedAtThisBackend(envelope)) return false; + String raw = envelope.getFields().getOrDefault(VotingPluginWire.K_REQUEST_ID, ""); + if (raw.isBlank()) { + send(VotingPluginWire.statusOkay(server)); + return true; + } + try { + send(VotingPluginWire.statusOkay(server, UUID.fromString(raw))); + } catch (IllegalArgumentException invalid) { + // A malformed probe receives no capability response. + return false; + } + return true; + } + + private boolean receiveReceiptRelease(JsonEnvelope envelope) { + if (!isTargetedAtThisBackend(envelope) + || !VotingPluginWire.requestsVoteDeliveryAcknowledgement(envelope)) return false; + String voteSubchannel = envelope.getFields().getOrDefault( + VotingPluginWire.K_VOTE_DELIVERY_SUBCHANNEL, ""); + if (!VotingPluginWire.SUB_VOTE.equals(voteSubchannel) + && !VotingPluginWire.SUB_VOTE_ONLINE.equals(voteSubchannel)) return false; + UUID voteId; + UUID playerId; + try { + voteId = UUID.fromString(envelope.getFields().getOrDefault(VotingPluginWire.K_VOTE_ID, "")); + playerId = UUID.fromString(envelope.getFields().getOrDefault(VotingPluginWire.K_UUID, "")); + } catch (IllegalArgumentException invalid) { + return false; + } + NeoForgeDeferredVoteStore.ReleaseResult result = processor.deferredVotes().release(playerId, voteId); + if (result != NeoForgeDeferredVoteStore.ReleaseResult.RELEASED + && result != NeoForgeDeferredVoteStore.ReleaseResult.ALREADY_RELEASED) return false; + send(VotingPluginWire.voteDeliveryReceiptReleaseAcknowledgement( + server, voteId, voteSubchannel)); + return true; + } + + private boolean receiveResync(JsonEnvelope envelope) { + VotingPluginWire.PresenceResyncRequest request = VotingPluginWire.readPresenceResyncRequest(envelope); + if (request.requestId == null || request.requestedAt <= 0L + || !server.equalsIgnoreCase(request.server)) return false; + announceStarted(); + return true; + } + + private boolean receiveSnapshotRequest(JsonEnvelope envelope) { + VotingPluginWire.PresenceSnapshotRequest request = VotingPluginWire.readPresenceSnapshotRequest(envelope); + if (request.requestId == null || !server.equalsIgnoreCase(request.server) + || !incarnationId.equals(request.backendIncarnationId) + || request.backendStartedAt != startedAt || request.presenceTimestamp <= 0L) return false; + synchronized (presenceLock) { + List snapshot = sessions.values().stream() + .map(session -> new VotingPluginWire.PresencePlayer(session.getPlayerName(), session.getUuid(), + session.getConnectionId().toString())) + .toList(); + int chunks = Math.max(1, (snapshot.size() + SNAPSHOT_CHUNK_SIZE - 1) / SNAPSHOT_CHUNK_SIZE); + long timestamp = nextTimestamp(); + for (int index = 0; index < chunks; index++) { + int from = index * SNAPSHOT_CHUNK_SIZE; + int to = Math.min(snapshot.size(), from + SNAPSHOT_CHUNK_SIZE); + send(VotingPluginWire.presenceSnapshot(server, request.requestId, index, chunks, + snapshot.subList(from, to), incarnationId, startedAt, timestamp)); + } + } + return true; + } + + private BackendPlayerPresenceSession addSession(SharedVoteIdentity identity) { + BackendPlayerPresenceSession session = BackendPlayerPresenceSession.create( + identity.playerName(), identity.uuid().toString()); + if (session == null) throw new IllegalArgumentException("Invalid player identity"); + sessions.put(identity.uuid(), session); + return session; + } + + private void sendLogin(BackendPlayerPresenceSession session) { + synchronized (presenceLock) { + send(VotingPluginWire.login(session.getPlayerName(), session.getUuid(), server, + session.getConnectionId(), incarnationId, startedAt, nextTimestamp())); + } + } + + private void sendHeartbeat() { + synchronized (presenceLock) { + if (!open.get()) return; + announceStarted(); + send(VotingPluginWire.backendHeartbeat(server, incarnationId, + startedAt, nextTimestamp())); + } + } + + private void announceStarted() { + synchronized (presenceLock) { + send(VotingPluginWire.backendStarted(server, incarnationId, startedAt, startedAt)); + } + } + + private boolean isTargetedAtThisBackend(JsonEnvelope envelope) { + return server.equalsIgnoreCase( + envelope.getFields().getOrDefault(VotingPluginWire.K_SERVER, "")); + } + + private long nextTimestamp() { + synchronized (timestampLock) { + lastTimestamp = Math.max(System.currentTimeMillis(), lastTimestamp + 1L); + return lastTimestamp; + } + } + + private void send(JsonEnvelope envelope) { + if (!open.get()) return; + if (sender != null) { + JsonEnvelope queued = envelope; + boolean presence = isPresenceEnvelope(queued); + try { + sender.execute(() -> { + try { + sendNow(queued); + } catch (RuntimeException failure) { + if (presence) presenceDirty.set(true); + LOGGER.warning("Proxy send failed: " + failure.getClass().getSimpleName()); + } finally { + recoverPresenceIfDrained(); + } + }); + } catch (RejectedExecutionException full) { + if (presence) presenceDirty.set(true); + LOGGER.warning("Send queue full; delivery rejected"); + } + return; + } + sendNow(envelope); + } + + private void recoverPresenceIfDrained() { + if (!open.get() || !presenceDirty.get() || !sender.getQueue().isEmpty() + || !presenceDirty.compareAndSet(true, false)) return; + try { + synchronized (presenceLock) { + sendNow(VotingPluginWire.backendStarted(server, incarnationId, + startedAt, nextTimestamp())); + } + } catch (RuntimeException failure) { + presenceDirty.set(true); + LOGGER.warning("Presence recovery send failed: " + failure.getClass().getSimpleName()); + } + } + + private static boolean isPresenceEnvelope(JsonEnvelope envelope) { + return switch (envelope.getSubChannel()) { + case VotingPluginWire.SUB_LOGIN, VotingPluginWire.SUB_LOGOUT, + VotingPluginWire.SUB_BACKEND_STARTED, VotingPluginWire.SUB_PRESENCE_SNAPSHOT -> true; + default -> false; + }; + } + + private void sendNow(JsonEnvelope envelope) { + synchronized (sendLock) { + outbound.accept(envelope); + } + } + + @Override public void close() { + if (!open.compareAndSet(true, false)) return; + if (heartbeat != null) heartbeat.shutdownNow(); + JsonEnvelope stopped; + synchronized (presenceLock) { + stopped = VotingPluginWire.backendStopped(server, incarnationId, + startedAt, nextTimestamp()); + sessions.clear(); + } + if (sender != null) { + try { + sender.execute(() -> sendStopped(stopped)); + } catch (RejectedExecutionException full) { + LOGGER.warning("Stopped-presence send rejected"); + } + sender.shutdown(); + try { + if (!sender.awaitTermination(1, TimeUnit.SECONDS)) sender.shutdownNow(); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + sender.shutdownNow(); + } + } else { + sendStopped(stopped); + } + transportClose.run(); + } + + private void sendStopped(JsonEnvelope stopped) { + try { + sendNow(stopped); + } catch (RuntimeException failure) { + LOGGER.warning("Stopped-presence send failed: " + + failure.getClass().getSimpleName()); + } + } + + private static String safeSubchannel(JsonEnvelope envelope) { + String value = envelope.getSubChannel(); + if (value == null) return "unknown"; + return value.replaceAll("[^A-Za-z0-9._-]", "?").substring(0, Math.min(value.length(), 64)); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyMqttService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyMqttService.java new file mode 100644 index 0000000000..fe64c13c26 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyMqttService.java @@ -0,0 +1,67 @@ +package com.bencodez.votingplugin.neoforge; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Objects; +import java.util.function.BiConsumer; +import org.eclipse.paho.client.mqttv3.MqttConnectOptions; +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.simpleapi.servercomm.mqtt.MqttServerComm; +import com.bencodez.votingplugin.core.vote.SharedVoteIdentity; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator; +import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption; + +/** MQTT network work and reconnects stay outside the native server event thread. */ +final class NeoForgeProxyMqttService implements NeoForgeProxyTransport { + interface Connection extends NeoForgeProxyBrokerService.Connection { } + interface ConnectionFactory extends NeoForgeProxyBrokerService.ConnectionFactory { + @Override Connection open() throws Exception; + } + private final NeoForgeProxyBrokerService broker; + + static NeoForgeProxyMqttService start(Path directory, NeoForgeProxyTransportConfiguration configuration, + NeoForgeVoteProcessor processor, NeoForgePlayerDirectory players) throws IOException { + var authenticator = SharedTransportEnvelopeAuthenticator.load(directory.resolve("secretkey.key"), + configuration.sharedAuthentication()); + var encryption = TransportEnvelopeEncryption.load(directory.resolve("secretkey.key"), + TransportEnvelopeEncryption.Domain.PROXY_BACKEND, configuration.communicationEncryption()); + var settings = configuration.mqtt(); + return new NeoForgeProxyMqttService(configuration, processor, players, authenticator, encryption, + () -> { + MqttConnectOptions options = new MqttConnectOptions(); + options.setCleanSession(true); + options.setConnectionTimeout(5); + options.setKeepAliveInterval(30); + options.setMaxInflight(32); + if (!settings.username().isEmpty()) options.setUserName(settings.username()); + if (!settings.password().isEmpty()) options.setPassword(settings.password().toCharArray()); + MqttServerComm client = new MqttServerComm(settings.clientId(), settings.brokerUrl(), options); + return new Connection() { + @Override public void subscribe(String topic, BiConsumer receiver) + throws Exception { + client.subscribe(topic, 2, (received, message) -> receiver.accept(received, message.getPayload())); + } + @Override public void publish(String topic, JsonEnvelope envelope) throws Exception { + client.publishEnvelope(topic, envelope, 2, false); + } + @Override public void close() throws Exception { client.close(); } + }; + }, true); + } + + NeoForgeProxyMqttService(NeoForgeProxyTransportConfiguration configuration, NeoForgeVoteProcessor processor, + NeoForgePlayerDirectory players, SharedTransportEnvelopeAuthenticator authenticator, + TransportEnvelopeEncryption encryption, ConnectionFactory factory, boolean scheduleHeartbeat) { + Objects.requireNonNull(configuration, "configuration"); + if (configuration.method() != NeoForgeProxyTransportConfiguration.Method.MQTT) { + throw new IllegalArgumentException("MQTT settings required"); + } + broker = new NeoForgeProxyBrokerService(configuration, processor, players, + authenticator, encryption, factory, scheduleHeartbeat); + } + + void receive(String topic, byte[] payload) { broker.receive(topic, payload); } + @Override public void playerOnline(SharedVoteIdentity identity) { broker.playerOnline(identity); } + @Override public void playerOffline(SharedVoteIdentity identity) { broker.playerOffline(identity); } + @Override public void close() { broker.close(); } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyMysqlService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyMysqlService.java new file mode 100644 index 0000000000..1ddb0ab4eb --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyMysqlService.java @@ -0,0 +1,159 @@ +package com.bencodez.votingplugin.neoforge; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Objects; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Logger; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.votingplugin.core.vote.SharedVoteIdentity; +import com.bencodez.votingplugin.proxy.VotingPluginWire; +import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption; + +/** SQL credentials establish the shared database trust domain, not per-node cryptographic identity. */ +final class NeoForgeProxyMysqlService implements NeoForgeProxyTransport { + interface Receiver { boolean receive(String source, String destination, byte[] payload); } + interface Queue extends AutoCloseable { + void poll(Receiver receiver) throws Exception; + void publish(JsonEnvelope envelope) throws Exception; + @Override void close() throws Exception; + } + interface QueueFactory { Queue open() throws Exception; } + + private static final Logger LOGGER = Logger.getLogger(NeoForgeProxyMysqlService.class.getName()); + private final String server; + private final TransportEnvelopeEncryption encryption; + private final QueueFactory factory; + private final NeoForgeProxyMessageService messages; + private final AtomicReference queue = new AtomicReference<>(); + private final AtomicBoolean open = new AtomicBoolean(true); + private final AtomicBoolean invalidWarning = new AtomicBoolean(); + private final AtomicBoolean unavailableWarning = new AtomicBoolean(); + private final Object lifecycle = new Object(); + private final ScheduledThreadPoolExecutor worker; + private long retrySeconds = 1; + private long pollRetryMillis = 250; + + static NeoForgeProxyMysqlService start(Path directory, NeoForgeProxyTransportConfiguration configuration, + NeoForgeVoteProcessor processor, NeoForgePlayerDirectory players) throws IOException { + var encryption = TransportEnvelopeEncryption.load(directory.resolve("secretkey.key"), + TransportEnvelopeEncryption.Domain.PROXY_BACKEND, configuration.communicationEncryption()); + return new NeoForgeProxyMysqlService(configuration, processor, players, encryption, + () -> { + var source = NeoForgeMysqlDataSource.open(configuration.mysql()); + try { return new NeoForgeMysqlQueue(source, source, configuration.server()); } + catch (Exception failure) { source.close(); throw failure; } + }, true); + } + + NeoForgeProxyMysqlService(NeoForgeProxyTransportConfiguration configuration, NeoForgeVoteProcessor processor, + NeoForgePlayerDirectory players, TransportEnvelopeEncryption encryption, QueueFactory factory, + boolean scheduleHeartbeat) { + if (configuration.method() != NeoForgeProxyTransportConfiguration.Method.MYSQL) { + throw new IllegalArgumentException("MySQL transport settings required"); + } + server = configuration.server(); + this.encryption = Objects.requireNonNull(encryption, "encryption"); + this.factory = Objects.requireNonNull(factory, "factory"); + worker = new ScheduledThreadPoolExecutor(1, task -> { + var thread = new Thread(task, "VotingPlugin-NeoForge-MySQL"); + thread.setDaemon(true); + return thread; + }); + worker.setRemoveOnCancelPolicy(true); + worker.setExecuteExistingDelayedTasksAfterShutdownPolicy(false); + messages = new NeoForgeProxyMessageService(server, processor, players, this::send, () -> { }, scheduleHeartbeat); + worker.execute(this::connect); + } + + private void connect() { + if (!open.get()) return; + Queue candidate = null; + boolean adopted = false; + try { + candidate = factory.open(); + synchronized (lifecycle) { + if (!open.get()) return; + queue.set(candidate); + adopted = true; + } + messages.transportReady(); + schedulePoll(0); + } catch (Exception failure) { + if (unavailableWarning.compareAndSet(false, true)) LOGGER.warning("MySQL transport unavailable; retrying"); + synchronized (lifecycle) { + if (open.get() && !adopted) { + worker.schedule(this::connect, retrySeconds, TimeUnit.SECONDS); + retrySeconds = Math.min(60, retrySeconds * 2); + } else if (open.get()) schedulePoll(250); + } + } finally { + if (!adopted && candidate != null) closeQueue(candidate); + } + } + + private void schedulePoll(long delayMillis) { + synchronized (lifecycle) { + if (open.get()) worker.schedule(this::poll, delayMillis, TimeUnit.MILLISECONDS); + } + } + + private void poll() { + if (!open.get()) return; + try { + Queue current = queue.get(); + if (current != null) current.poll(this::receive); + pollRetryMillis = 250; + } catch (Exception failure) { + if (unavailableWarning.compareAndSet(false, true)) LOGGER.warning("MySQL polling failed; rows remain retryable"); + pollRetryMillis = Math.min(60_000, pollRetryMillis * 2); + } finally { schedulePoll(pollRetryMillis); } + } + + boolean receive(String source, String destination, byte[] payload) { + if (!open.get() || !"proxy".equals(source) || !server.equals(destination)) return false; + try { + var decrypted = encryption.decrypt(NeoForgeProxyEnvelopeCodec.decode(payload)); + if (!decrypted.accepted()) return false; + return messages.receive(decrypted.envelope()); + } catch (IOException | RuntimeException failure) { + if (invalidWarning.compareAndSet(false, true)) LOGGER.warning("Retained invalid MySQL proxy envelope"); + return false; + } + } + + private void send(JsonEnvelope envelope) { + Queue current = queue.get(); + if (current == null) return; + var builder = JsonEnvelope.builder(envelope.getSubChannel()).schema(envelope.getSchema()); + envelope.getFields().forEach((key, value) -> { + if (!VotingPluginWire.K_VOTE_DELAY_REJECTION_ACK_VERSION.equals(key)) builder.put(key, value); + }); + try { current.publish(encryption.encrypt(builder.build())); } + catch (Exception failure) { throw new IllegalStateException("MySQL proxy publication failed"); } + } + + @Override public void playerOnline(SharedVoteIdentity identity) { messages.playerOnline(identity); } + @Override public void playerOffline(SharedVoteIdentity identity) { messages.playerOffline(identity); } + + @Override public void close() { + synchronized (lifecycle) { + if (!open.compareAndSet(true, false)) return; + } + messages.close(); + synchronized (lifecycle) { + Queue retired = queue.getAndSet(null); + worker.execute(() -> { if (retired != null) closeQueue(retired); }); + worker.shutdown(); + } + } + + private static void closeQueue(Queue queue) { + try { queue.close(); } + catch (Exception failure) { LOGGER.warning("MySQL transport cleanup failed"); } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyRedisService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyRedisService.java new file mode 100644 index 0000000000..45d1767c6f --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyRedisService.java @@ -0,0 +1,48 @@ +package com.bencodez.votingplugin.neoforge; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; + +import com.bencodez.votingplugin.core.vote.SharedVoteIdentity; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator; +import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption; + +/** Redis adapter for the existing proxy/backend channel and signed envelope protocol. */ +final class NeoForgeProxyRedisService implements NeoForgeProxyTransport { + interface Connection extends NeoForgeProxyBrokerService.Connection { } + interface ConnectionFactory extends NeoForgeProxyBrokerService.ConnectionFactory { + @Override Connection open() throws Exception; + } + private final NeoForgeProxyBrokerService broker; + + static NeoForgeProxyRedisService start(Path directory, NeoForgeProxyTransportConfiguration configuration, + NeoForgeVoteProcessor processor, NeoForgePlayerDirectory players) throws IOException { + var authenticator = SharedTransportEnvelopeAuthenticator.load(directory.resolve("secretkey.key"), + configuration.sharedAuthentication()); + var encryption = TransportEnvelopeEncryption.load(directory.resolve("secretkey.key"), + TransportEnvelopeEncryption.Domain.PROXY_BACKEND, configuration.communicationEncryption()); + AtomicReference reconnected = new AtomicReference<>(() -> { }); + var service = new NeoForgeProxyRedisService(configuration, processor, players, authenticator, encryption, + () -> new NeoForgeRedisConnection(configuration.redis(), () -> reconnected.get().run()), true); + reconnected.set(service.broker::transportReady); + return service; + } + + NeoForgeProxyRedisService(NeoForgeProxyTransportConfiguration configuration, NeoForgeVoteProcessor processor, + NeoForgePlayerDirectory players, SharedTransportEnvelopeAuthenticator authenticator, + TransportEnvelopeEncryption encryption, ConnectionFactory factory, boolean scheduleHeartbeat) { + Objects.requireNonNull(configuration, "configuration"); + if (configuration.method() != NeoForgeProxyTransportConfiguration.Method.REDIS) { + throw new IllegalArgumentException("Redis settings required"); + } + broker = new NeoForgeProxyBrokerService(configuration, processor, players, + authenticator, encryption, factory, scheduleHeartbeat); + } + + void receive(String channel, byte[] payload) { broker.receive(channel, payload); } + @Override public void playerOnline(SharedVoteIdentity identity) { broker.playerOnline(identity); } + @Override public void playerOffline(SharedVoteIdentity identity) { broker.playerOffline(identity); } + @Override public void close() { broker.close(); } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxySocketService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxySocketService.java index 6dbb95d6c7..0bf92c94ed 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxySocketService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxySocketService.java @@ -4,18 +4,7 @@ import java.net.InetSocketAddress; import java.net.ServerSocket; import java.nio.file.Path; -import java.util.List; -import java.util.Map; import java.util.Objects; -import java.util.UUID; -import java.util.concurrent.ArrayBlockingQueue; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executors; -import java.util.concurrent.RejectedExecutionException; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.logging.Logger; @@ -26,41 +15,20 @@ import com.bencodez.simpleapi.servercomm.sockets.SocketHandler; import com.bencodez.simpleapi.servercomm.sockets.SocketReceiver; import com.bencodez.simpleapi.servercomm.sockets.SocketServer; -import com.bencodez.votingplugin.backendproxy.presence.BackendPlayerPresenceSession; import com.bencodez.votingplugin.core.vote.SharedVoteIdentity; import com.bencodez.votingplugin.proxy.VotingPluginWire; import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator; import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Domain; import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode; import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption; -import com.bencodez.votingplugin.util.ServiceSiteValidator; /** NeoForge receiver for the existing encrypted VotingPlugin SOCKETS proxy transport. */ -public final class NeoForgeProxySocketService implements AutoCloseable { +public final class NeoForgeProxySocketService implements NeoForgeProxyTransport { private static final Logger LOGGER = Logger.getLogger(NeoForgeProxySocketService.class.getName()); - private static final int SNAPSHOT_CHUNK_SIZE = 100; - private static final int MAX_PENDING_SENDS = 256; - private static final long HEARTBEAT_SECONDS = 30; - private final NeoForgeProxySocketConfiguration configuration; - private final NeoForgeVoteProcessor processor; - private final NeoForgePlayerDirectory players; - private final Consumer outbound; private final SharedTransportEnvelopeAuthenticator authenticator; private final TransportEnvelopeEncryption encryption; - private final Runnable transportClose; - private final ThreadPoolExecutor sender; - private final ScheduledExecutorService heartbeat; - private final Map sessions = new ConcurrentHashMap<>(); - private final AtomicBoolean open = new AtomicBoolean(true); - private final AtomicBoolean presenceDirty = new AtomicBoolean(); - private final AtomicBoolean broadcastWarningLogged = new AtomicBoolean(); - private final Object presenceLock = new Object(); - private final Object sendLock = new Object(); - private final Object timestampLock = new Object(); - private final UUID incarnationId = UUID.randomUUID(); - private final long startedAt = System.currentTimeMillis(); - private long lastTimestamp = startedAt; + private final NeoForgeProxyMessageService messages; static NeoForgeProxySocketService start(Path directory, NeoForgeProxySocketConfiguration configuration, NeoForgeVoteProcessor processor, NeoForgePlayerDirectory players) { @@ -122,57 +90,24 @@ static NeoForgeProxySocketService start(Path directory, NeoForgeProxySocketConfi NeoForgeProxySocketService(NeoForgeProxySocketConfiguration configuration, NeoForgeVoteProcessor processor, NeoForgePlayerDirectory players, - Consumer outbound, - SharedTransportEnvelopeAuthenticator authenticator, - TransportEnvelopeEncryption encryption, - Runnable transportClose, boolean scheduleHeartbeat) { + Consumer outbound, SharedTransportEnvelopeAuthenticator authenticator, + TransportEnvelopeEncryption encryption, Runnable transportClose, boolean scheduleHeartbeat) { this.configuration = Objects.requireNonNull(configuration, "configuration"); - this.processor = Objects.requireNonNull(processor, "processor"); - this.players = Objects.requireNonNull(players, "players"); - this.outbound = Objects.requireNonNull(outbound, "outbound"); this.authenticator = Objects.requireNonNull(authenticator, "authenticator"); this.encryption = Objects.requireNonNull(encryption, "encryption"); - this.transportClose = Objects.requireNonNull(transportClose, "transportClose"); - heartbeat = scheduleHeartbeat ? Executors.newSingleThreadScheduledExecutor(task -> { - Thread thread = new Thread(task, "VotingPlugin-NeoForge-Presence"); - thread.setDaemon(true); - return thread; - }) : null; - sender = scheduleHeartbeat ? new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, - new ArrayBlockingQueue<>(MAX_PENDING_SENDS), task -> { - Thread thread = new Thread(task, "VotingPlugin-NeoForge-Send"); - thread.setDaemon(true); - return thread; - }, new ThreadPoolExecutor.AbortPolicy()) : null; - for (SharedVoteIdentity identity : players.onlineIdentities()) addSession(identity); - announceStarted(); - sendHeartbeat(); - sessions.values().forEach(this::sendLogin); - if (heartbeat != null) heartbeat.scheduleAtFixedRate(this::sendHeartbeat, - HEARTBEAT_SECONDS, HEARTBEAT_SECONDS, TimeUnit.SECONDS); + Objects.requireNonNull(outbound, "outbound"); + messages = new NeoForgeProxyMessageService(configuration.server(), processor, players, + envelope -> outbound.accept(authenticator.sign(encryption.encrypt(supportedCapabilities(envelope)), + Domain.SOCKET_PROXY_BACKEND, configuration.server(), configuration.proxyName())), + transportClose, scheduleHeartbeat); } - public void playerOnline(SharedVoteIdentity identity) { - synchronized (presenceLock) { - if (!open.get()) return; - BackendPlayerPresenceSession session = addSession(identity); - announceStarted(); - sendLogin(session); - } - } + public void playerOnline(SharedVoteIdentity identity) { messages.playerOnline(identity); } - public void playerOffline(SharedVoteIdentity identity) { - synchronized (presenceLock) { - if (!open.get()) return; - BackendPlayerPresenceSession session = sessions.remove(identity.uuid()); - if (session == null) return; - send(VotingPluginWire.logout(session.getPlayerName(), session.getUuid(), configuration.server(), - session.getConnectionId(), incarnationId, startedAt, nextTimestamp())); - } - } + public void playerOffline(SharedVoteIdentity identity) { messages.playerOffline(identity); } void receive(JsonEnvelope envelope) { - if (!open.get() || envelope == null) return; + if (!messages.isOpen() || envelope == null) return; String sender = envelope.getFields().get(SharedTransportEnvelopeAuthenticator.K_SENDER); SharedTransportEnvelopeAuthenticator.Verification verification = authenticator.verify(envelope, Domain.SOCKET_PROXY_BACKEND, configuration.server()); @@ -185,212 +120,10 @@ void receive(JsonEnvelope envelope) { LOGGER.warning("Rejected invalid encrypted socket message"); return; } - envelope = decryption.envelope(); - if (envelope.getSchema() != VotingPluginWire.SCHEMA_VERSION) return; - try { - switch (envelope.getSubChannel()) { - case VotingPluginWire.SUB_VOTE, VotingPluginWire.SUB_VOTE_ONLINE -> receiveVote(envelope); - case VotingPluginWire.SUB_STATUS -> receiveStatus(envelope); - case VotingPluginWire.SUB_VOTE_DELIVERY_RECEIPT_RELEASE -> receiveReceiptRelease(envelope); - case VotingPluginWire.SUB_PRESENCE_RESYNC_REQUEST -> receiveResync(envelope); - case VotingPluginWire.SUB_PRESENCE_SNAPSHOT_REQUEST -> receiveSnapshotRequest(envelope); - default -> { } - } - } catch (RuntimeException failure) { - LOGGER.warning("NeoForge proxy message " + safeSubchannel(envelope) - + " failed: " + failure.getClass().getSimpleName()); - } - } - - private void receiveVote(JsonEnvelope envelope) { - VotingPluginWire.Vote vote = VotingPluginWire.readVote(envelope); - if (vote.voteId == null || vote.player.isBlank() || vote.player.length() > 64 - || !ServiceSiteValidator.isValid(vote.service)) return; - // Backend broadcast delivery is not supported by the NeoForge replay - // boundary. Leave the occurrence unacknowledged in the proxy durable - // outbox instead of silently completing an omitted side effect. - if (vote.broadcast) { - if (broadcastWarningLogged.compareAndSet(false, true)) { - LOGGER.warning("NeoForge cannot execute delegated vote broadcasts; deliveries remain unacknowledged. " - + "Exclude this backend from proxy broadcast routing for new votes."); - } - return; - } - UUID playerId; - try { - playerId = UUID.fromString(vote.uuid); - } catch (IllegalArgumentException invalid) { - return; - } - // NeoForge does not yet import a proxy-owned totals snapshot. Fail closed - // instead of silently completing a vote with incorrect totals. - if (vote.manageTotals || !vote.setTotals || vote.numberOfVotes < 1 - || vote.num < 1 || vote.num > vote.numberOfVotes) return; - boolean currentlyOnline = players.online(playerId).isPresent(); - boolean wasOnline = vote.wasOnlineKnown ? vote.wasOnline : currentlyOnline; - NeoForgeVoteResult result = processor.process(new NeoForgeVoteRequest(vote.voteId, playerId, - vote.player, vote.service, vote.time, vote.realVote, true, - currentlyOnline, wasOnline, NeoForgeVoteRequest.Scope.COMPLETE)); - if (VotingPluginWire.requestsVoteDeliveryAcknowledgement(envelope) - && (result.durablyRetained() || result.durablyCompleted())) { - send(VotingPluginWire.voteDeliveryAcknowledgement(configuration.server(), vote.voteId, - envelope.getSubChannel())); - } - } - - private void receiveStatus(JsonEnvelope envelope) { - if (!isTargetedAtThisBackend(envelope)) return; - String raw = envelope.getFields().getOrDefault(VotingPluginWire.K_REQUEST_ID, ""); - if (raw.isBlank()) { - send(VotingPluginWire.statusOkay(configuration.server())); - return; - } - try { - send(VotingPluginWire.statusOkay(configuration.server(), UUID.fromString(raw))); - } catch (IllegalArgumentException invalid) { - // A malformed probe receives no capability response. - } - } - - private void receiveReceiptRelease(JsonEnvelope envelope) { - if (!isTargetedAtThisBackend(envelope) - || !VotingPluginWire.requestsVoteDeliveryAcknowledgement(envelope)) return; - String voteSubchannel = envelope.getFields().getOrDefault( - VotingPluginWire.K_VOTE_DELIVERY_SUBCHANNEL, ""); - if (!VotingPluginWire.SUB_VOTE.equals(voteSubchannel) - && !VotingPluginWire.SUB_VOTE_ONLINE.equals(voteSubchannel)) return; - UUID voteId; - UUID playerId; - try { - voteId = UUID.fromString(envelope.getFields().getOrDefault(VotingPluginWire.K_VOTE_ID, "")); - playerId = UUID.fromString(envelope.getFields().getOrDefault(VotingPluginWire.K_UUID, "")); - } catch (IllegalArgumentException invalid) { - return; - } - NeoForgeDeferredVoteStore.ReleaseResult result = processor.deferredVotes().release(playerId, voteId); - if (result != NeoForgeDeferredVoteStore.ReleaseResult.RELEASED - && result != NeoForgeDeferredVoteStore.ReleaseResult.ALREADY_RELEASED) return; - send(VotingPluginWire.voteDeliveryReceiptReleaseAcknowledgement( - configuration.server(), voteId, voteSubchannel)); - } - - private void receiveResync(JsonEnvelope envelope) { - VotingPluginWire.PresenceResyncRequest request = VotingPluginWire.readPresenceResyncRequest(envelope); - if (request.requestId == null || request.requestedAt <= 0L - || !configuration.server().equalsIgnoreCase(request.server)) return; - announceStarted(); - } - - private void receiveSnapshotRequest(JsonEnvelope envelope) { - VotingPluginWire.PresenceSnapshotRequest request = VotingPluginWire.readPresenceSnapshotRequest(envelope); - if (request.requestId == null || !configuration.server().equalsIgnoreCase(request.server) - || !incarnationId.equals(request.backendIncarnationId) - || request.backendStartedAt != startedAt || request.presenceTimestamp <= 0L) return; - synchronized (presenceLock) { - List snapshot = sessions.values().stream() - .map(session -> new VotingPluginWire.PresencePlayer(session.getPlayerName(), session.getUuid(), - session.getConnectionId().toString())) - .toList(); - int chunks = Math.max(1, (snapshot.size() + SNAPSHOT_CHUNK_SIZE - 1) / SNAPSHOT_CHUNK_SIZE); - long timestamp = nextTimestamp(); - for (int index = 0; index < chunks; index++) { - int from = index * SNAPSHOT_CHUNK_SIZE; - int to = Math.min(snapshot.size(), from + SNAPSHOT_CHUNK_SIZE); - send(VotingPluginWire.presenceSnapshot(configuration.server(), request.requestId, index, chunks, - snapshot.subList(from, to), incarnationId, startedAt, timestamp)); - } - } - } - - private BackendPlayerPresenceSession addSession(SharedVoteIdentity identity) { - BackendPlayerPresenceSession session = BackendPlayerPresenceSession.create( - identity.playerName(), identity.uuid().toString()); - if (session == null) throw new IllegalArgumentException("Invalid player identity"); - sessions.put(identity.uuid(), session); - return session; - } - - private void sendLogin(BackendPlayerPresenceSession session) { - synchronized (presenceLock) { - send(VotingPluginWire.login(session.getPlayerName(), session.getUuid(), configuration.server(), - session.getConnectionId(), incarnationId, startedAt, nextTimestamp())); - } - } - - private void sendHeartbeat() { - synchronized (presenceLock) { - if (!open.get()) return; - announceStarted(); - send(VotingPluginWire.backendHeartbeat(configuration.server(), incarnationId, - startedAt, nextTimestamp())); - } - } - - private void announceStarted() { - synchronized (presenceLock) { - send(VotingPluginWire.backendStarted(configuration.server(), incarnationId, startedAt, startedAt)); - } - } - - private boolean isTargetedAtThisBackend(JsonEnvelope envelope) { - return configuration.server().equalsIgnoreCase( - envelope.getFields().getOrDefault(VotingPluginWire.K_SERVER, "")); - } - - private long nextTimestamp() { - synchronized (timestampLock) { - lastTimestamp = Math.max(System.currentTimeMillis(), lastTimestamp + 1L); - return lastTimestamp; - } - } - - private void send(JsonEnvelope envelope) { - if (!open.get()) return; - envelope = supportedCapabilities(envelope); - if (sender != null) { - JsonEnvelope queued = envelope; - boolean presence = isPresenceEnvelope(queued); - try { - sender.execute(() -> { - try { - sendNow(queued); - } catch (RuntimeException failure) { - if (presence) presenceDirty.set(true); - LOGGER.warning("Socket send failed: " + failure.getClass().getSimpleName()); - } finally { - recoverPresenceIfDrained(); - } - }); - } catch (RejectedExecutionException full) { - if (presence) presenceDirty.set(true); - LOGGER.warning("Send queue full; delivery rejected"); - } - return; - } - sendNow(envelope); - } - - private void recoverPresenceIfDrained() { - if (!open.get() || !presenceDirty.get() || !sender.getQueue().isEmpty() - || !presenceDirty.compareAndSet(true, false)) return; - try { - synchronized (presenceLock) { - sendNow(supportedCapabilities(VotingPluginWire.backendStarted(configuration.server(), incarnationId, - startedAt, nextTimestamp()))); - } - } catch (RuntimeException failure) { - presenceDirty.set(true); - LOGGER.warning("Presence recovery send failed: " + failure.getClass().getSimpleName()); - } + messages.receive(decryption.envelope()); } - private static boolean isPresenceEnvelope(JsonEnvelope envelope) { - return switch (envelope.getSubChannel()) { - case VotingPluginWire.SUB_LOGIN, VotingPluginWire.SUB_LOGOUT, - VotingPluginWire.SUB_BACKEND_STARTED, VotingPluginWire.SUB_PRESENCE_SNAPSHOT -> true; - default -> false; - }; - } + @Override public void close() { messages.close(); } private static JsonEnvelope supportedCapabilities(JsonEnvelope envelope) { if (!switch (envelope.getSubChannel()) { @@ -407,50 +140,6 @@ private static JsonEnvelope supportedCapabilities(JsonEnvelope envelope) { return builder.build(); } - private void sendNow(JsonEnvelope envelope) { - synchronized (sendLock) { - outbound.accept(authenticator.sign(encryption.encrypt(envelope), Domain.SOCKET_PROXY_BACKEND, - configuration.server(), configuration.proxyName())); - } - } - - @Override public void close() { - if (!open.compareAndSet(true, false)) return; - if (heartbeat != null) heartbeat.shutdownNow(); - JsonEnvelope stopped; - synchronized (presenceLock) { - stopped = VotingPluginWire.backendStopped(configuration.server(), incarnationId, - startedAt, nextTimestamp()); - sessions.clear(); - } - if (sender != null) { - try { - sender.execute(() -> sendStopped(stopped)); - } catch (RejectedExecutionException full) { - LOGGER.warning("Stopped-presence send rejected"); - } - sender.shutdown(); - try { - if (!sender.awaitTermination(1, TimeUnit.SECONDS)) sender.shutdownNow(); - } catch (InterruptedException interrupted) { - Thread.currentThread().interrupt(); - sender.shutdownNow(); - } - } else { - sendStopped(stopped); - } - transportClose.run(); - } - - private void sendStopped(JsonEnvelope stopped) { - try { - sendNow(stopped); - } catch (RuntimeException failure) { - LOGGER.warning("Stopped-presence send failed: " - + failure.getClass().getSimpleName()); - } - } - private static void verifyListenerPortAvailable(String host, int port) { try (ServerSocket probe = new ServerSocket()) { probe.setReuseAddress(false); @@ -498,9 +187,4 @@ private static RuntimeException appendFailure(RuntimeException current, RuntimeE return current; } - private static String safeSubchannel(JsonEnvelope envelope) { - String value = envelope.getSubChannel(); - if (value == null) return "unknown"; - return value.replaceAll("[^A-Za-z0-9._-]", "?").substring(0, Math.min(value.length(), 64)); - } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyTransport.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyTransport.java new file mode 100644 index 0000000000..54b24f1998 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyTransport.java @@ -0,0 +1,10 @@ +package com.bencodez.votingplugin.neoforge; + +import com.bencodez.votingplugin.core.vote.SharedVoteIdentity; + +/** Lifecycle and presence hooks shared by the native proxy adapters. */ +public interface NeoForgeProxyTransport extends AutoCloseable { + void playerOnline(SharedVoteIdentity identity); + void playerOffline(SharedVoteIdentity identity); + @Override void close(); +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyTransportConfiguration.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyTransportConfiguration.java new file mode 100644 index 0000000000..2ec6917a73 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyTransportConfiguration.java @@ -0,0 +1,186 @@ +package com.bencodez.votingplugin.neoforge; + +import java.math.BigDecimal; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Locale; +import java.util.Objects; + +import org.spongepowered.configurate.ConfigurationNode; + +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode; + +/** Captured transport settings; loading this view never opens a connection or changes user storage. */ +record NeoForgeProxyTransportConfiguration(Method method, String server, String proxyName, + Mode sharedAuthentication, boolean communicationEncryption, boolean debug, + NeoForgeProxySocketConfiguration socket, Redis redis, Mqtt mqtt, Mysql mysql) { + enum Method { DISABLED, SOCKETS, REDIS, MQTT, MYSQL } + + record Redis(String host, int port, String username, String password, int database, + boolean ssl, String prefix) { + @Override public String toString() { return "Redis[configured]"; } + } + + record Mqtt(String brokerUrl, String clientId, String username, String password, String prefix) { + @Override public String toString() { return "Mqtt[configured]"; } + } + + record Mysql(String host, int port, String database, String username, String password, + boolean ssl, boolean publicKeyRetrieval, int maxConnections) { + @Override public String toString() { return "Mysql[configured]"; } + } + + static NeoForgeProxyTransportConfiguration load(ConfigurationNode bungee, ConfigurationNode config) { + Objects.requireNonNull(bungee, "bungee"); + Objects.requireNonNull(config, "config"); + // Preserve the existing selector and its historical misspelling. + if (!bungee.node("UseBungeecord").getBoolean(bungee.node("UseBungeecoord").getBoolean(false))) { + return new NeoForgeProxyTransportConfiguration(Method.DISABLED, "", "", Mode.REQUIRED, + false, false, null, null, null, null); + } + Method method; + try { + method = Method.valueOf(text(bungee.node("BungeeMethod"), "PLUGINMESSAGING", 32) + .toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException invalid) { + throw invalid("BungeeMethod must select SOCKETS, REDIS, MQTT or MYSQL"); + } + if (method == Method.DISABLED) throw invalid("BungeeMethod must select an enabled transport"); + if (method == Method.SOCKETS) { + var socket = NeoForgeProxySocketConfiguration.load(bungee); + return new NeoForgeProxyTransportConfiguration(method, socket.server(), socket.proxyName(), + Mode.REQUIRED, socket.communicationEncryption(), socket.debug(), socket, null, null, null); + } + String server = identity(bungee.node("Server"), "Server"); + String proxy = method == Method.MYSQL ? "" : identity(bungee.node("BungeeServer", "Name"), "BungeeServer.Name"); + Mode authentication = Mode.REQUIRED; + if (method == Method.REDIS || method == Method.MQTT) { + authentication = Mode.parse(text(bungee.node("SharedTransportAuthentication"), "COMPATIBILITY", 32)); + } + boolean encryption = bool(bungee.node("CommunicationEncryption"), false); + boolean debug = bool(bungee.node("BungeeDebug"), false); + Redis redis = null; + Mqtt mqtt = null; + Mysql mysql = null; + switch (method) { + case REDIS -> { + var node = bungee.node("Redis"); + redis = new Redis(required(node.node("Host"), "Redis.Host", 253), + integer(node.node("Port"), 6379, 1, 65535), + credential(node.node("Username")), credential(node.node("Password")), + integer(node.node("Db-Index"), 0, 0, Integer.MAX_VALUE), + bool(node.node("SSL"), false), text(node.node("Prefix"), "", 256)); + } + case MQTT -> { + var node = bungee.node("MQTT"); + String url = text(node.node("BrokerURL"), "tcp://localhost:1883", 4096).trim(); + validateBrokerUrl(url); + String client = text(node.node("ClientID"), "", 128); + mqtt = new Mqtt(url, client.isEmpty() ? server : client, + credential(node.node("Username")), credential(node.node("Password")), + text(node.node("Prefix"), "", 256)); + } + case MYSQL -> { + if (server.length() > 36) throw invalid("MySQL transport server identity exceeds queue schema limit"); + // Transport credentials are independent of the SQLITE user-storage selection. + var node = config.node("Database"); + String type = text(node.node("DbType"), "MYSQL", 32).trim().toUpperCase(Locale.ROOT); + if (!type.equals("MYSQL") && !type.equals("MARIADB")) { + throw invalid("Database.DbType must support the MySQL transport protocol"); + } + String username = credential(node.node("Username")); + if (username.isEmpty()) throw invalid("Database.Username is required"); + mysql = new Mysql(required(node.node("Host"), "Database.Host", 253), + integer(node.node("Port"), 3306, 1, 65535), + required(node.node("Database"), "Database.Database", 64), + username, credential(node.node("Password")), bool(node.node("UseSSL"), false), + bool(node.node("PublicKeyRetrieval"), false), integer(node.node("MaxConnections"), 1, 1, 64)); + } + default -> throw invalid("Unsupported proxy transport"); + } + return new NeoForgeProxyTransportConfiguration(method, server, proxy, authentication, + encryption, debug, null, redis, mqtt, mysql); + } + + boolean enabled() { return method != Method.DISABLED; } + + @Override public String toString() { + // A record's generated representation would otherwise include connection credentials. + return "NeoForgeProxyTransportConfiguration[method=" + method + "]"; + } + + private static String identity(ConfigurationNode node, String path) { + String value = required(node, path, 64); + if (value.equalsIgnoreCase("PleaseSet") || !value.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}")) { + throw invalid(path + " must name a configured peer"); + } + return value; + } + + private static String required(ConfigurationNode node, String path, int limit) { + String value = text(node, "", limit).trim(); + if (value.isEmpty()) throw invalid(path + " is required"); + return value; + } + + private static String text(ConfigurationNode node, String fallback, int limit) { + Object raw = node.rawScalar(); + if (raw == null && node.virtual()) return fallback; + if (!(raw instanceof String value) || value.length() > limit + || value.chars().anyMatch(c -> c == 0 || c == '\r' || c == '\n')) { + throw invalid("Invalid transport text setting"); + } + return value; + } + + private static String credential(ConfigurationNode node) { + if (node.virtual()) return ""; + Object raw = node.rawScalar(); + if (!(raw instanceof String value) || value.length() > 4096) { + throw invalid("Invalid transport credential setting"); + } + // Broker/JDBC credentials are opaque data; do not trim or alter their contents. + return value; + } + + private static int integer(ConfigurationNode node, int fallback, int minimum, int maximum) { + if (node.virtual()) return fallback; + Object raw = node.rawScalar(); + int value; + try { + if (raw instanceof Number number) value = new BigDecimal(number.toString()).intValueExact(); + else if (raw instanceof String text && text.matches("-?[0-9]{1,10}")) value = Integer.parseInt(text); + else throw invalid("Transport port/count must be an integer"); + } catch (NumberFormatException | ArithmeticException failure) { + throw invalid("Transport port/count must be an integer"); + } + if (value < minimum || value > maximum) throw invalid("Transport port/count is outside its supported range"); + return value; + } + + private static boolean bool(ConfigurationNode node, boolean fallback) { + if (node.virtual()) return fallback; + Object raw = node.rawScalar(); + if (raw instanceof Boolean value) return value; + if (raw instanceof String value && (value.equalsIgnoreCase("true") || value.equalsIgnoreCase("false"))) { + return Boolean.parseBoolean(value); + } + throw invalid("Transport flag must be true or false"); + } + + private static void validateBrokerUrl(String value) { + try { + URI uri = new URI(value); + String scheme = uri.getScheme(); + if (scheme == null || !java.util.Set.of("tcp", "ssl", "ws", "wss").contains(scheme) + || uri.getHost() == null || uri.getRawUserInfo() != null + || uri.getFragment() != null || uri.getPort() == 0 || uri.getPort() > 65535) { + throw invalid("MQTT.BrokerURL must select a supported broker URI without embedded credentials"); + } + } catch (URISyntaxException failure) { + throw invalid("MQTT.BrokerURL is invalid"); + } + } + + private static IllegalArgumentException invalid(String message) { return new IllegalArgumentException(message); } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeRedisConnection.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeRedisConnection.java new file mode 100644 index 0000000000..9810f86fef --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeRedisConnection.java @@ -0,0 +1,172 @@ +package com.bencodez.votingplugin.neoforge; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Arrays; +import java.util.Objects; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiConsumer; +import java.util.function.Supplier; +import java.util.logging.Logger; + +import javax.net.ssl.SSLParameters; + +import org.apache.commons.pool2.impl.GenericObjectPoolConfig; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec; + +import redis.clients.jedis.BinaryJedisPubSub; +import redis.clients.jedis.DefaultJedisClientConfig; +import redis.clients.jedis.HostAndPort; +import redis.clients.jedis.Jedis; +import redis.clients.jedis.JedisPool; + +/** One subscriber and one bounded publisher connection; retries retain the same application occurrence. */ +final class NeoForgeRedisConnection implements NeoForgeProxyRedisService.Connection { + private static final Logger LOGGER = Logger.getLogger(NeoForgeRedisConnection.class.getName()); + private final JedisPool publishers; + private final Supplier subscribers; + private final Runnable reconnected; + private final AtomicBoolean open = new AtomicBoolean(true); + private final Object lifecycle = new Object(); + private final CountDownLatch ready = new CountDownLatch(1); + private final CountDownLatch stopped = new CountDownLatch(1); + private Jedis subscriber; + private Thread listener; + + NeoForgeRedisConnection(NeoForgeProxyTransportConfiguration.Redis settings, Runnable reconnected) { + this(pool(settings), () -> new Jedis(new HostAndPort(settings.host(), settings.port()), + clientConfig(settings)), reconnected); + } + + NeoForgeRedisConnection(JedisPool publishers, Supplier subscribers, Runnable reconnected) { + this.publishers = Objects.requireNonNull(publishers, "publishers"); + this.subscribers = Objects.requireNonNull(subscribers, "subscribers"); + this.reconnected = Objects.requireNonNull(reconnected, "reconnected"); + } + + private static JedisPool pool(NeoForgeProxyTransportConfiguration.Redis settings) { + var limits = new GenericObjectPoolConfig(); + limits.setMaxTotal(1); + limits.setMaxIdle(1); + limits.setMinIdle(0); + limits.setMaxWait(Duration.ofSeconds(2)); + return new JedisPool(limits, new HostAndPort(settings.host(), settings.port()), clientConfig(settings)); + } + + static DefaultJedisClientConfig clientConfig(NeoForgeProxyTransportConfiguration.Redis settings) { + var config = DefaultJedisClientConfig.builder().database(settings.database()).ssl(settings.ssl()) + .connectionTimeoutMillis(2000).socketTimeoutMillis(2000).blockingSocketTimeoutMillis(0); + if (!settings.username().isEmpty()) config.user(settings.username()); + if (!settings.password().isEmpty()) config.password(settings.password()); + if (settings.ssl()) { + var ssl = new SSLParameters(); + ssl.setEndpointIdentificationAlgorithm("HTTPS"); + config.sslParameters(ssl); + } + return config.build(); + } + + @Override public void subscribe(String channel, BiConsumer receiver) throws Exception { + Objects.requireNonNull(receiver, "receiver"); + byte[] encoded = channel.getBytes(StandardCharsets.UTF_8); + synchronized (lifecycle) { + if (!open.get() || listener != null) throw new IllegalStateException("Redis subscription not available"); + listener = new Thread(() -> listen(channel, encoded, receiver), "VotingPlugin-NeoForge-Redis-Receive"); + listener.setDaemon(true); + listener.start(); + } + if (!ready.await(3, TimeUnit.SECONDS) || !open.get()) { + throw new IllegalStateException("Redis subscription did not become ready"); + } + } + + private void listen(String channel, byte[] encoded, BiConsumer receiver) { + long retrySeconds = 1; + while (open.get()) { + Jedis candidate = null; + boolean adopted = false; + AtomicBoolean subscribed = new AtomicBoolean(); + try { + candidate = subscribers.get(); + synchronized (lifecycle) { + if (!open.get()) return; + subscriber = candidate; + adopted = true; + } + candidate.subscribe(new BinaryJedisPubSub() { + @Override public void onSubscribe(byte[] actual, int count) { + if (!open.get() || !Arrays.equals(encoded, actual)) return; + subscribed.set(true); + ready.countDown(); + reconnected.run(); + } + @Override public void onMessage(byte[] actual, byte[] payload) { + if (open.get() && Arrays.equals(encoded, actual)) receiver.accept(channel, payload); + } + }, encoded); + } catch (RuntimeException unavailable) { + if (open.get()) LOGGER.warning("Redis subscription unavailable; retrying"); + } finally { + boolean release; + synchronized (lifecycle) { + release = !adopted || subscriber == candidate; + if (subscriber == candidate) subscriber = null; + } + // Shutdown may already own an adopted subscriber; late unadopted clients remain ours. + if (release && candidate != null) closeSubscriber(candidate); + } + if (subscribed.get()) retrySeconds = 1; + try { + if (stopped.await(retrySeconds, TimeUnit.SECONDS)) return; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return; + } + retrySeconds = Math.min(60, retrySeconds * 2); + } + } + + @Override public void publish(String channel, JsonEnvelope envelope) { + if (!open.get()) throw new IllegalStateException("Redis transport is closed"); + try (Jedis publisher = publishers.getResource()) { + publisher.publish(channel.getBytes(StandardCharsets.UTF_8), + JsonEnvelopeCodec.encode(envelope).getBytes(StandardCharsets.UTF_8)); + } + } + + @Override public void close() { + Jedis retired; + Thread thread; + synchronized (lifecycle) { + if (!open.compareAndSet(true, false)) return; + retired = subscriber; + subscriber = null; + thread = listener; + } + stopped.countDown(); + ready.countDown(); + try { + if (retired != null) closeSubscriber(retired); + } finally { + try { + publishers.close(); + } finally { + if (thread != null) { + thread.interrupt(); + try { thread.join(1000); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + if (thread.isAlive()) LOGGER.warning("Redis subscriber cleanup is still pending"); + } + } + } + } + + private static void closeSubscriber(Jedis client) { + try { client.close(); } + catch (RuntimeException failure) { LOGGER.warning("Redis subscriber cleanup failed"); } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeRuntime.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeRuntime.java index d9d9b30c1b..6f6d8ac82e 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeRuntime.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeRuntime.java @@ -21,7 +21,7 @@ import com.bencodez.votingplugin.core.vote.SharedVoteIdentity; import com.bencodez.votingplugin.util.SqliteNativeLibrary; -/** Owns NeoForge bootstrap resources; vote and reward services are not started here. */ +/** Owns NeoForge bootstrap, proxy ingress and reward resources. */ public final class NeoForgeRuntime implements AutoCloseable { static final String USER_TABLE_NAME = "VotingPlugin_NeoForgeUsers"; private final ConfigurationNode config; @@ -34,7 +34,7 @@ public final class NeoForgeRuntime implements AutoCloseable { private final NeoForgeDeferredVoteStore deferredVotes; private final NeoForgeVoteProcessor voteProcessor; private final NeoForgeRewardReplayService rewardReplay; - private final NeoForgeProxySocketService proxySocket; + private final NeoForgeProxyTransport proxyTransport; private final NeoForgeServerScheduler scheduler = new NeoForgeServerScheduler(); private final NeoForgePlayerDirectory players = new NeoForgePlayerDirectory(); private boolean closed; @@ -42,7 +42,7 @@ public final class NeoForgeRuntime implements AutoCloseable { private NeoForgeRuntime(Path directory, ConfigurationNode config, ConfigurationNode voteSites, ConfigurationNode specialRewards, ConfigurationNode bungeeSettings, NeoForgeVoteConfiguration voteConfiguration, - NeoForgeProxySocketConfiguration proxyConfiguration, + NeoForgeProxyTransportConfiguration proxyConfiguration, SqlUserBackend storage, Clock clock, Object server) { this.config = config; this.voteSites = voteSites; @@ -56,14 +56,39 @@ private NeoForgeRuntime(Path directory, ConfigurationNode config, ConfigurationN rewardReplay = server == null ? null : new NeoForgeRewardReplayService(voteConfiguration, new NeoForgeRewardConfiguration(config, voteSites, specialRewards), accounting, deferredVotes, players, new NeoForgeNativeRewardActions(server, scheduler, players)); - NeoForgeProxySocketService startedProxy = null; + NeoForgeProxyTransport startedProxy = null; try { if (server != null && proxyConfiguration.enabled()) { - startedProxy = NeoForgeProxySocketService.start(directory, proxyConfiguration, voteProcessor, players); + startedProxy = switch (proxyConfiguration.method()) { + case SOCKETS -> NeoForgeProxySocketService.start(directory, proxyConfiguration.socket(), + voteProcessor, players); + case MQTT -> { + try { + yield NeoForgeProxyMqttService.start(directory, proxyConfiguration, voteProcessor, players); + } catch (IOException unavailable) { + throw new IllegalStateException("MQTT security initialization failed", unavailable); + } + } + case REDIS -> { + try { + yield NeoForgeProxyRedisService.start(directory, proxyConfiguration, voteProcessor, players); + } catch (IOException unavailable) { + throw new IllegalStateException("Redis security initialization failed", unavailable); + } + } + case MYSQL -> { + try { + yield NeoForgeProxyMysqlService.start(directory, proxyConfiguration, voteProcessor, players); + } catch (IOException unavailable) { + throw new IllegalStateException("MySQL security initialization failed", unavailable); + } + } + default -> throw new IllegalStateException("Selected NeoForge proxy adapter is not implemented"); + }; } if (rewardReplay != null) rewardReplay.start(); } catch (RuntimeException failure) { - NeoForgeProxySocketService failedProxy = startedProxy; + NeoForgeProxyTransport failedProxy = startedProxy; RuntimeException cleanupFailure = closeAll( () -> { if (failedProxy != null) failedProxy.close(); }, () -> { if (rewardReplay != null) rewardReplay.close(); }, @@ -71,7 +96,7 @@ private NeoForgeRuntime(Path directory, ConfigurationNode config, ConfigurationN if (cleanupFailure != null) failure.addSuppressed(cleanupFailure); throw failure; } - proxySocket = startedProxy; + proxyTransport = startedProxy; } public static NeoForgeRuntime start(Path directory) throws IOException { @@ -99,9 +124,9 @@ private static NeoForgeRuntime start(Path directory, Clock clock, Object server) ConfigurationNode specialRewards = YamlConfigurationLoader.builder().path(specialRewardsFile).build().load(); ConfigurationNode bungeeSettings = YamlConfigurationLoader.builder().path(bungeeSettingsFile).build().load(); NeoForgeVoteConfiguration voteConfiguration = NeoForgeVoteConfiguration.load(config, voteSites); - NeoForgeProxySocketConfiguration proxyConfiguration; + NeoForgeProxyTransportConfiguration proxyConfiguration; try { - proxyConfiguration = NeoForgeProxySocketConfiguration.load(bungeeSettings); + proxyConfiguration = NeoForgeProxyTransportConfiguration.load(bungeeSettings, config); } catch (IllegalArgumentException invalid) { throw new IOException("Invalid proxy configuration", invalid); } @@ -160,7 +185,10 @@ private static Path installDefault(Path directory, String name) throws IOExcepti public NeoForgePlayerDirectory players() { return players; } public NeoForgeVoteProcessor voteProcessor() { return voteProcessor; } public Optional rewardReplay() { return Optional.ofNullable(rewardReplay); } - public Optional proxySocket() { return Optional.ofNullable(proxySocket); } + public Optional proxyTransport() { return Optional.ofNullable(proxyTransport); } + public Optional proxySocket() { + return proxyTransport instanceof NeoForgeProxySocketService socket ? Optional.of(socket) : Optional.empty(); + } public void playerJoined(Object player) { playerJoinedIdentity(player); @@ -176,7 +204,7 @@ SharedVoteIdentity playerJoinedIdentity(Object player) { if (closed) return; closed = true; RuntimeException failure = closeAll( - () -> { if (proxySocket != null) proxySocket.close(); }, + () -> { if (proxyTransport != null) proxyTransport.close(); }, voteProcessor::stop, () -> { if (rewardReplay != null) rewardReplay.stopAdmission(); }, scheduler::close, diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeVotingPlugin.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeVotingPlugin.java index f8456b46c9..8fbcb49ec9 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeVotingPlugin.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeVotingPlugin.java @@ -31,7 +31,7 @@ private void started(ServerStartedEvent ignored) { try { runtime = NeoForgeRuntime.start(FMLPaths.CONFIGDIR.get().resolve("votingplugin"), serverFromEvent(ignored)); LOGGER.info("VotingPlugin NeoForge started; reward replay active" - + (runtime.proxySocket().isPresent() ? "; proxy delivery active" : "")); + + (runtime.proxyTransport().isPresent() ? "; proxy transport initialized" : "")); } catch (IOException failure) { throw new IllegalStateException("VotingPlugin NeoForge startup failed", failure); } @@ -57,14 +57,14 @@ private void tick(ServerTickEvent.Post ignored) { private void joined(PlayerEvent.PlayerLoggedInEvent event) { if (runtime != null) { var identity = runtime.playerJoinedIdentity(NeoForgePlayerDirectory.playerFromEvent(event)); - runtime.proxySocket().ifPresent(proxy -> proxy.playerOnline(identity)); + runtime.proxyTransport().ifPresent(proxy -> proxy.playerOnline(identity)); } } private void left(PlayerEvent.PlayerLoggedOutEvent event) { if (runtime != null) { runtime.players().left(NeoForgePlayerDirectory.playerFromEvent(event)) - .ifPresent(identity -> runtime.proxySocket().ifPresent(proxy -> proxy.playerOffline(identity))); + .ifPresent(identity -> runtime.proxyTransport().ifPresent(proxy -> proxy.playerOffline(identity))); } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeMysqlDataSourceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeMysqlDataSourceTest.java new file mode 100644 index 0000000000..4aac0896f6 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeMysqlDataSourceTest.java @@ -0,0 +1,55 @@ +package com.bencodez.votingplugin.tests.neoforge; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.mariadb.jdbc.Configuration; + +import com.bencodez.votingplugin.tests.support.ReflectiveAccess; +import com.zaxxer.hikari.HikariDataSource; + +class NeoForgeMysqlDataSourceTest { + private static final String OWNER = "com.bencodez.votingplugin.neoforge.NeoForgeMysqlDataSource"; + + @Test void programmaticDriverFieldsCannotBecomeUrlOptionsAndTlsVerifiesIdentity() { + String opaque = " " + UUID.randomUUID() + " "; + Object settings = settings(opaque, true); + Configuration configuration = (Configuration) ReflectiveAccess.call(OWNER, null, "configuration", + new String[] {"Mysql"}, new Object[] {settings}); + assertEquals("fixture?sslMode=disable", configuration.database()); + assertEquals(opaque, configuration.user()); + assertEquals(opaque, configuration.password()); + assertEquals("VERIFY_FULL", configuration.sslMode().name()); + assertEquals(2000, configuration.connectTimeout()); + assertEquals(2000, configuration.socketTimeout()); + assertFalse(configuration.allowLocalInfile()); + assertFalse(configuration.allowMultiQueries()); + assertFalse(configuration.dumpQueriesOnException()); + assertFalse(configuration.allowPublicKeyRetrieval()); + } + + @Test void poolStartsWithoutNetworkHasBoundedResourcesAndDoesNotExposeCredentials() { + String opaque = UUID.randomUUID().toString(); + try (HikariDataSource pool = (HikariDataSource) ReflectiveAccess.call(OWNER, null, "open", + new String[] {"Mysql"}, new Object[] {settings(opaque, false)})) { + assertEquals(1, pool.getMaximumPoolSize()); + assertEquals(0, pool.getMinimumIdle()); + assertEquals(2000, pool.getConnectionTimeout()); + assertEquals(1000, pool.getValidationTimeout()); + assertEquals(-1, pool.getInitializationFailTimeout()); + assertEquals(2, pool.getDataSource().getLoginTimeout()); + assertFalse(pool.getDataSource().toString().contains(opaque)); + assertFalse(pool.getDataSource().toString().contains("fixture?sslMode")); + assertNull(pool.getJdbcUrl()); + } catch (java.sql.SQLException failure) { throw new AssertionError(failure); } + } + + private static Object settings(String opaque, boolean ssl) { + return ReflectiveAccess.construct( + "com.bencodez.votingplugin.neoforge.NeoForgeProxyTransportConfiguration$Mysql", + new String[] {"String", "int", "String", "String", "String", "boolean", "boolean", "int"}, + new Object[] {"db.example", 3306, "fixture?sslMode=disable", opaque, opaque, ssl, false, 1}); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeMysqlQueueTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeMysqlQueueTest.java new file mode 100644 index 0000000000..2f70eeb146 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeMysqlQueueTest.java @@ -0,0 +1,228 @@ +package com.bencodez.votingplugin.tests.neoforge; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicInteger; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.Test; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec; +import com.bencodez.votingplugin.tests.support.ReflectiveAccess; + +class NeoForgeMysqlQueueTest { + private static final String OWNER = "com.bencodez.votingplugin.neoforge.NeoForgeMysqlQueue"; + private static final String SERVER = "backend-test"; + private static final String WRAPPER = "{\"t\":\"fixture\",\"v\":1,\"f\":{\"name\":\"雪\\r\\nline\"}}"; + + @Test void deletesOnlyConfirmedRowsAfterReleasingTheSqlConnection() throws Exception { + Jdbc jdbc = new Jdbc(); + jdbc.rows.addAll(List.of(new Row(1, WRAPPER), new Row(2, WRAPPER))); + Object queue = create(jdbc); + try { + poll(queue, new Receiver() { + int calls; + public boolean receive(String source, String destination, byte[] payload) { + assertEquals(0, jdbc.activeConnections.get(), "Acceptance must not wait while owning the SQL pool slot"); + assertEquals("proxy", source); + assertEquals(SERVER, destination); + assertArrayEquals(WRAPPER.getBytes(StandardCharsets.UTF_8), payload); + return ++calls == 2; + } + }); + assertEquals(List.of(1L), jdbc.rows.stream().map(Row::id).toList()); + assertEquals(List.of(2L), jdbc.deleted); + verify(jdbc.select).setQueryTimeout(2); + verify(jdbc.select).setMaxRows(50); + verify(jdbc.acquire).setString(1, "backend-channel-" + SERVER); + assertTrue(jdbc.selectSql.contains("OCTET_LENGTH(payload) <= 262144")); + assertTrue(jdbc.selectSql.endsWith("ORDER BY id LIMIT 50")); + assertTrue(jdbc.deleteSql.contains("source = 'proxy' AND destination = ?")); + } finally { close(queue); } + } + + @Test void deleteFailureLeavesTheOriginalOccurrenceAvailableForRetry() throws Exception { + Jdbc jdbc = new Jdbc(); + jdbc.rows.add(new Row(7, WRAPPER)); + Object queue = create(jdbc); + try { + jdbc.failDelete = true; + assertThrows(SQLException.class, () -> poll(queue, new Receiver())); + assertEquals(1, jdbc.rows.size()); + jdbc.failDelete = false; + poll(queue, new Receiver()); + assertTrue(jdbc.rows.isEmpty()); + assertEquals(List.of(7L), jdbc.deleted); + } finally { close(queue); } + } + + @Test void invalidOrUnconfirmedRowsRemainWithoutStarvingTheNextPage() throws Exception { + Jdbc jdbc = new Jdbc(); + for (int id = 1; id <= 51; id++) jdbc.rows.add(new Row(id, id == 51 ? WRAPPER : null)); + Object queue = create(jdbc); + try { + AtomicInteger accepted = new AtomicInteger(); + Receiver receiver = new Receiver() { + public boolean receive(String source, String destination, byte[] payload) { + accepted.incrementAndGet(); + return true; + } + }; + poll(queue, receiver); + assertEquals(0, accepted.get()); + assertEquals(51, jdbc.rows.size()); + poll(queue, receiver); + assertEquals(1, accepted.get()); + assertEquals(50, jdbc.rows.size()); + assertEquals(List.of(51L), jdbc.deleted); + } finally { close(queue); } + } + + @Test void boundedReaderRejectsByteOverflowAndUnpairedUnicodeWithoutRewritingIt() { + Object exact = bounded("x".repeat(262144)); + assertEquals(262144, ((byte[]) exact).length); + assertNull(bounded("x".repeat(262145))); + assertNull(bounded("雪".repeat(87382))); + assertNull(bounded("\uD800")); + assertArrayEquals(WRAPPER.getBytes(StandardCharsets.UTF_8), (byte[]) bounded(WRAPPER)); + } + + @Test void releaseFailureAbortsTheBorrowedConnectionAndDoesNotClaimAnyRows() throws Exception { + Jdbc jdbc = new Jdbc(); + jdbc.rows.add(new Row(1, WRAPPER)); + when(jdbc.releaseResult.getInt(1)).thenReturn(0); + Object queue = create(jdbc); + try { + assertThrows(SQLException.class, () -> poll(queue, new Receiver() { + public boolean receive(String source, String destination, byte[] payload) { + fail("A failed queue read must not dispatch the batch"); + return false; + } + })); + verify(jdbc.connection).abort(any(Executor.class)); + assertEquals(1, jdbc.rows.size()); + assertTrue(jdbc.deleted.isEmpty()); + } finally { close(queue); } + } + + @Test void publisherUsesTheExistingProxyRoleAndParametersAndCloseIsIdempotent() throws Exception { + Jdbc jdbc = new Jdbc(); + Object queue = create(jdbc); + JsonEnvelope envelope = JsonEnvelope.builder("fixture").put("name", "雪\r\nline").build(); + ReflectiveAccess.call(OWNER, queue, "publish", new String[] {"JsonEnvelope"}, new Object[] {envelope}); + verify(jdbc.insert).setString(1, SERVER); + verify(jdbc.insert).setString(2, JsonEnvelopeCodec.encode(envelope)); + verify(jdbc.insert).setQueryTimeout(2); + assertTrue(jdbc.insertSql.contains("(source, destination, payload) VALUES (?, 'proxy', ?)")); + close(queue); + close(queue); + assertEquals(1, jdbc.closed.get()); + assertThrows(SQLException.class, () -> ReflectiveAccess.call(OWNER, queue, "publish", + new String[] {"JsonEnvelope"}, new Object[] {envelope})); + poll(queue, new Receiver() { + public boolean receive(String source, String destination, byte[] payload) { + fail("Closed queue must not dispatch"); + return false; + } + }); + } + + private static Object bounded(String text) { + return ReflectiveAccess.call(OWNER, null, "boundedPayload", new String[] {"Reader"}, + new Object[] {new StringReader(text)}); + } + private static Object create(Jdbc jdbc) { + return ReflectiveAccess.construct(OWNER, new String[] {"DataSource", "AutoCloseable", "String"}, + new Object[] {jdbc.source, (AutoCloseable) jdbc.closed::incrementAndGet, SERVER}); + } + private static void poll(Object queue, Receiver receiver) { + ReflectiveAccess.call(OWNER, queue, "poll", new String[] {"Receiver"}, new Object[] {receiver}); + } + private static void close(Object queue) { + ReflectiveAccess.call(OWNER, queue, "close", new String[0], new Object[0]); + } + public static class Receiver { + public boolean receive(String source, String destination, byte[] payload) { return true; } + } + private record Row(long id, String payload) { } + + private static final class Jdbc { + final DataSource source = mock(DataSource.class); + final Connection connection = mock(Connection.class); + final Statement schema = mock(Statement.class); + final PreparedStatement acquire = mock(PreparedStatement.class); + final PreparedStatement release = mock(PreparedStatement.class); + final PreparedStatement select = mock(PreparedStatement.class); + final PreparedStatement delete = mock(PreparedStatement.class); + final PreparedStatement insert = mock(PreparedStatement.class); + final ResultSet releaseResult = mock(ResultSet.class); + final List rows = new ArrayList<>(); + final List deleted = new ArrayList<>(); + final AtomicInteger activeConnections = new AtomicInteger(); + final AtomicInteger closed = new AtomicInteger(); + boolean failDelete; + long cursor; + long deleteId; + String selectSql; + String deleteSql; + String insertSql; + + Jdbc() throws Exception { + when(source.getConnection()).thenAnswer(invocation -> { activeConnections.incrementAndGet(); return connection; }); + doAnswer(invocation -> { activeConnections.decrementAndGet(); return null; }).when(connection).close(); + when(connection.createStatement()).thenReturn(schema); + when(connection.prepareStatement(anyString())).thenAnswer(invocation -> { + String sql = invocation.getArgument(0); + if (sql.startsWith("SELECT GET_LOCK")) return acquire; + if (sql.startsWith("SELECT RELEASE_LOCK")) return release; + if (sql.startsWith("SELECT id")) { selectSql = sql; return select; } + if (sql.startsWith("DELETE")) { deleteSql = sql; return delete; } + if (sql.startsWith("INSERT")) { insertSql = sql; return insert; } + throw new AssertionError("Unexpected SQL shape"); + }); + ResultSet acquired = mock(ResultSet.class); + when(acquired.next()).thenReturn(true); + when(acquired.getInt(1)).thenReturn(1); + when(acquire.executeQuery()).thenReturn(acquired); + when(releaseResult.next()).thenReturn(true); + when(releaseResult.getInt(1)).thenReturn(1); + when(release.executeQuery()).thenReturn(releaseResult); + doAnswer(invocation -> { cursor = invocation.getArgument(1); return null; }).when(select).setLong(eq(2), anyLong()); + when(select.executeQuery()).thenAnswer(invocation -> { + List page = rows.stream().filter(row -> row.id > cursor).limit(50).toList(); + AtomicInteger index = new AtomicInteger(-1); + ResultSet result = mock(ResultSet.class); + when(result.next()).thenAnswer(unused -> index.incrementAndGet() < page.size()); + when(result.getLong("id")).thenAnswer(unused -> page.get(index.get()).id); + when(result.getString("source")).thenReturn("proxy"); + when(result.getString("destination")).thenReturn(SERVER); + when(result.getCharacterStream("payload")).thenAnswer(unused -> { + String payload = page.get(index.get()).payload; + return payload == null ? null : new StringReader(payload); + }); + return result; + }); + doAnswer(invocation -> { deleteId = invocation.getArgument(1); return null; }).when(delete).setLong(eq(1), anyLong()); + when(delete.executeUpdate()).thenAnswer(invocation -> { + if (failDelete) throw new SQLException("synthetic delete failure"); + deleted.add(deleteId); + rows.removeIf(row -> row.id == deleteId); + return 1; + }); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgePlatformGsonTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgePlatformGsonTest.java new file mode 100644 index 0000000000..172ca48a3e --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgePlatformGsonTest.java @@ -0,0 +1,87 @@ +package com.bencodez.votingplugin.tests.neoforge; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.votingplugin.neoforge.NeoForgeRuntime; +import com.google.gson.Gson; + +/** Real platform-library linkage, isolated from Maven's newer provided Gson. */ +public class NeoForgePlatformGsonTest { + @Test void completeBrokerIngressUsesOnlyTheActualMinecraftGson() throws Exception { + verify(NeoForgeRuntime.class.getProtectionDomain().getCodeSource().getLocation(), false); + } + + /** Test-only entry point shared with the downloadable-JAR acceptance test. */ + public static void verify(URL plugin, boolean packaged) throws Exception { + Path gson = Path.of(System.getProperty("votingplugin.neoforgeGsonJar")); + assertEquals("4241c14a7727c34feea6507ec801318a3d4a90f070e4525681079fb94ee4c593", + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(gson)))); + URL[] urls = packaged ? new URL[] {plugin, gson.toUri().toURL()} + : new URL[] {plugin, gson.toUri().toURL(), + JsonEnvelope.class.getProtectionDomain().getCodeSource().getLocation()}; + try (URLClassLoader loader = new URLClassLoader(urls, ClassLoader.getPlatformClassLoader())) { + assertEquals(gson.toUri().toURL(), Class.forName("com.google.gson.Gson", true, loader) + .getProtectionDomain().getCodeSource().getLocation()); + assertThrows(ClassNotFoundException.class, + () -> Class.forName("com.google.gson.Strictness", false, loader)); + for (String deferred : new String[] {"Configuration", "IdentityResolver", "Protocol", "Service"}) { + assertThrows(ClassNotFoundException.class, () -> Class.forName( + "com.bencodez.votingplugin.neoforge.NeoForgeVotifier" + deferred, false, loader), + "Direct Votifier ingress is excluded from the proxy transport artifact"); + } + String envelope = "{\"t\":\"status\",\"v\":1,\"f\":{\"note\":\"sample 雪\"}}"; + assertNotNull(broker(loader, envelope)); + assertNotNull(broker(loader, envelope.replace("sample 雪", "sample\\n雪"))); + assertNotNull(broker(loader, new Gson().toJson(Map.of("t", "status", "f", Map.of( + "note", "literal {\"t\":\"data\"} \\ / 雪\n\r\t"))))); + for (char control = 0; control < 0x20; control++) { + String raw = envelope.replace("sample 雪", "sample" + control + "雪"); + assertThrows(IOException.class, () -> broker(loader, raw)); + // Escaped controls are legal JSON string data, unlike literal control characters. + assertNotNull(broker(loader, new Gson().toJson(Map.of("t", "status", "f", Map.of( + "note", "sample" + control + "雪"))))); + } + for (String invalidEscape : new String[] {"\\x", "\\v", "\\u12", "\\uZZZZ", "\\u0000"}) { + assertThrows(IOException.class, + () -> broker(loader, envelope.replace("sample 雪", invalidEscape))); + } + for (String malformed : new String[] {envelope + " {}", + envelope.replace("sample 雪", "sample\n雪"), + envelope.replace("sample 雪", "sample\t雪"), + envelope.replace("sample 雪", "sample\\'雪")}) { + assertThrows(IOException.class, () -> broker(loader, malformed)); + } + } + } + + private static Object broker(ClassLoader loader, String json) throws Exception { + return invoke(loader, "NeoForgeProxyEnvelopeCodec", new Class[] {byte[].class}, + (Object) json.getBytes(StandardCharsets.UTF_8)); + } + + private static Object invoke(ClassLoader loader, String name, Class[] types, Object... values) throws Exception { + var method = Class.forName("com.bencodez.votingplugin.neoforge." + name, true, loader) + .getDeclaredMethod("decode", types); + method.setAccessible(true); + try { return method.invoke(null, values); } + catch (InvocationTargetException wrapped) { + if (wrapped.getCause() instanceof Exception failure) throw failure; + if (wrapped.getCause() instanceof Error failure) throw failure; + throw wrapped; + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyEnvelopeCodecTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyEnvelopeCodecTest.java new file mode 100644 index 0000000000..8bea950d4c --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyEnvelopeCodecTest.java @@ -0,0 +1,88 @@ +package com.bencodez.votingplugin.tests.neoforge; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec; +import com.bencodez.votingplugin.tests.support.ReflectiveAccess; + +class NeoForgeProxyEnvelopeCodecTest { + private static final String OWNER = "com.bencodez.votingplugin.neoforge.NeoForgeProxyEnvelopeCodec"; + + @Test void completeSdkWrappersPreserveUnicodeEscapesAndFieldOrder() throws IOException { + JsonEnvelope original = JsonEnvelope.builder("status").schema(1) + .put("server", "backend-test").put("data", "Unicode \u2603 \ud83d\ude00, quotes \" braces {} and newline\n") + .build(); + JsonEnvelope decoded = decode(JsonEnvelopeCodec.encode(original).getBytes(StandardCharsets.UTF_8)); + assertEquals(original.getSubChannel(), decoded.getSubChannel()); + assertEquals(original.getSchema(), decoded.getSchema()); + assertEquals(original.getFields(), decoded.getFields()); + } + + @Test void rejectsInvalidCompleteWrappers() { + invalid().forEach(input -> assertThrows(IOException.class, + () -> decode(input.getBytes(StandardCharsets.UTF_8)))); + } + + static Stream invalid() { + String prefix = "{\"t\":\"status\",\"v\":1,\"f\":"; + return Stream.of("", "[]", "{}", prefix + "{}}{}", prefix + "{\"server\":1}}", + prefix + "{\"server\":null}}", prefix + "{\"server\":{\"x\":\"y\"}}}", + prefix + "{\"server\":[]}}", prefix + "{\"a\":\"x\",\"a\":\"y\"}}", + prefix + "{\"a\":\"x\",\"\\u0061\":\"y\"}}", + prefix + "{},\"v\":2}", prefix + "{},\"extra\":true}", + prefix.replace("\"v\":1", "\"v\":1.0") + "{}}", + prefix.replace("\"v\":1", "\"v\":\"1\"") + "{}}", + prefix + "{\"a\":\"" + "x".repeat(65_537) + "\"}}", + prefix + "{\"" + "x".repeat(129) + "\":\"y\"}}", + prefix + "[".repeat(2048) + "0" + "]".repeat(2048) + "}"); + } + + @Test void refusesMalformedUtf8AndOversizedByteInputBeforeJsonParsing() { + assertThrows(IOException.class, () -> decode(new byte[] {(byte) 0xc3, 0x28})); + assertThrows(IOException.class, () -> decode(new byte[256 * 1024 + 1])); + assertThrows(IOException.class, () -> decode(null)); + } + + @Test void exactByteLimitUsesUtf8BytesAndAcceptsCompleteCrLfWrappedPayload() throws IOException { + String content = "\r\n{\"t\":\"status\",\"v\":1,\"f\":{\"unicode\":\"\u2603\ud83d\ude00\"}}\r\n"; + int bytes = content.getBytes(StandardCharsets.UTF_8).length; + byte[] maximum = (content + " ".repeat(256 * 1024 - bytes)).getBytes(StandardCharsets.UTF_8); + assertEquals(256 * 1024, maximum.length); + assertEquals("\u2603\ud83d\ude00", decode(maximum).getFields().get("unicode")); + assertThrows(IOException.class, () -> decode(java.util.Arrays.copyOf(maximum, maximum.length + 1))); + } + + @Test void exactFieldAndValueLimitsHaveValidCounterexamples() throws IOException { + JsonEnvelope.Builder builder = JsonEnvelope.builder("status").schema(1); + for (int i = 0; i < 128; i++) builder.put("key" + i, "value"); + assertEquals(128, decode(JsonEnvelopeCodec.encode(builder.build()) + .getBytes(StandardCharsets.UTF_8)).getFields().size()); + builder.put("extra", "value"); + assertThrows(IOException.class, () -> decode(JsonEnvelopeCodec.encode(builder.build()) + .getBytes(StandardCharsets.UTF_8))); + JsonEnvelope largest = JsonEnvelope.builder("status").schema(1).put("value", "x".repeat(65_536)).build(); + assertEquals(65_536, decode(JsonEnvelopeCodec.encode(largest).getBytes(StandardCharsets.UTF_8)) + .getFields().get("value").length()); + } + + @Test void establishedCodecDefaultsRemainCompatibleWithoutInventingData() throws IOException { + JsonEnvelope unversioned = decode("{\"t\":\"status\",\"f\":{\"server\":\"backend-test\"}}" + .getBytes(StandardCharsets.UTF_8)); + assertEquals(1, unversioned.getSchema()); + assertEquals("backend-test", unversioned.getFields().get("server")); + assertEquals(java.util.Map.of(), decode("{\"t\":\"status\"}".getBytes(StandardCharsets.UTF_8)).getFields()); + } + + private static JsonEnvelope decode(byte[] bytes) throws IOException { + return (JsonEnvelope) ReflectiveAccess.call(OWNER, null, "decode", new String[] {"byte[]"}, + new Object[] {bytes}); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyMqttServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyMqttServiceTest.java new file mode 100644 index 0000000000..7e73381110 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyMqttServiceTest.java @@ -0,0 +1,296 @@ +package com.bencodez.votingplugin.tests.neoforge; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiConsumer; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.spongepowered.configurate.yaml.YamlConfigurationLoader; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec; +import com.bencodez.votingplugin.neoforge.NeoForgeProxyTransport; +import com.bencodez.votingplugin.neoforge.NeoForgeRuntime; +import com.bencodez.votingplugin.proxy.VotingPluginWire; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Domain; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode; +import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption; +import com.bencodez.votingplugin.tests.support.ReflectiveAccess; + +class NeoForgeProxyMqttServiceTest { + private static final String OWNER = "com.bencodez.votingplugin.neoforge.NeoForgeProxyMqttService"; + private static final String INBOUND = "fixture/votingplugin/servers/backend-test"; + private static final String OUTBOUND = "fixture/votingplugin/servers/proxy"; + @TempDir Path directory; + private SharedTransportEnvelopeAuthenticator proxy; + + @BeforeEach void configure() throws IOException { + byte[] independentKey = new byte[32]; + new java.security.SecureRandom().nextBytes(independentKey); + Files.writeString(directory.resolve("secretkey.key"), Base64.getEncoder().encodeToString(independentKey)); + proxy = SharedTransportEnvelopeAuthenticator.load(directory.resolve("secretkey.key"), Mode.REQUIRED); + Files.writeString(directory.resolve("Config.yml"), "DataStorage: SQLITE\nAllowUnjoined: true\n"); + Files.writeString(directory.resolve("VoteSites.yml"), """ + VoteSites: + Site: + Enabled: true + ServiceSite: Service + VoteDelay: 24 + WaitUntilVoteDelay: false + GiveOfflineRewards: true + """); + } + + @Test void authenticatedBrokerCallbackRetainsBeforeAckAndDuplicatesSurviveRestart() throws Exception { + UUID player = UUID.randomUUID(); + UUID id = UUID.randomUUID(); + for (int generation = 0; generation < 2; generation++) { + try (NeoForgeRuntime runtime = NeoForgeRuntimeAccess.start(directory)) { + Connection connection = new Connection(); + try (NeoForgeProxyTransport service = create(runtime, connection, Mode.REQUIRED)) { + assertTrue(connection.ready.await(3, TimeUnit.SECONDS)); + connection.sent.clear(); + for (int duplicate = 0; duplicate < 2; duplicate++) { + deliver(connection, proxy.sign(vote(id, player), Domain.MQTT_PROXY_BACKEND, + "proxy-test", INBOUND)); + } + assertEquals(1, runtime.deferredVotes().pending(player).size()); + assertEquals(id, runtime.deferredVotes().pending(player).getFirst().voteId()); + assertEquals(2, connection.sent.size()); + for (JsonEnvelope ack : connection.sent) { + var verified = proxy.verify(ack, Domain.MQTT_PROXY_BACKEND, OUTBOUND); + assertTrue(verified.accepted()); + assertEquals(VotingPluginWire.SUB_VOTE_DELIVERY_ACK, verified.envelope().getSubChannel()); + } + } + assertTrue(connection.closed.await(3, TimeUnit.SECONDS)); + assertEquals(1, connection.closes.get()); + } + } + } + + @Test void wrongTopicDomainSenderUnsignedAndReplayedWrappersCannotCreateAnotherOccurrence() throws Exception { + try (NeoForgeRuntime runtime = NeoForgeRuntimeAccess.start(directory)) { + Connection connection = new Connection(); + try (NeoForgeProxyTransport service = create(runtime, connection, Mode.REQUIRED)) { + assertTrue(connection.ready.await(3, TimeUnit.SECONDS)); + connection.sent.clear(); + UUID player = UUID.randomUUID(); + JsonEnvelope raw = vote(UUID.randomUUID(), player); + deliver(connection, raw); + deliver(connection, proxy.sign(raw, Domain.REDIS_PROXY_BACKEND, "proxy-test", INBOUND)); + deliver(connection, proxy.sign(raw, Domain.MQTT_PROXY_BACKEND, "other-peer", INBOUND)); + deliver(connection, proxy.sign(raw, Domain.MQTT_PROXY_BACKEND, "proxy-test", "other/topic")); + byte[] good = encode(proxy.sign(raw, Domain.MQTT_PROXY_BACKEND, "proxy-test", INBOUND)); + connection.receiver.accept("other/topic", good); + connection.receiver.accept(INBOUND, new byte[] {(byte) 0xff}); + connection.receiver.accept(INBOUND, new byte[256 * 1024 + 1]); + assertTrue(runtime.deferredVotes().pending(player).isEmpty()); + assertTrue(connection.sent.isEmpty()); + connection.receiver.accept(INBOUND, good); + connection.receiver.accept(INBOUND, good); + assertEquals(1, runtime.deferredVotes().pending(player).size()); + assertEquals(1, connection.sent.size()); + service.close(); + connection.sent.clear(); + deliver(connection, proxy.sign(vote(UUID.randomUUID(), player), Domain.MQTT_PROXY_BACKEND, + "proxy-test", INBOUND)); + assertEquals(1, runtime.deferredVotes().pending(player).size()); + assertTrue(connection.sent.isEmpty()); + } + } + } + + @Test void compatibilityIsExplicitAndDoesNotAdvertiseSocketOrDelayRejectionSupport() throws Exception { + try (NeoForgeRuntime runtime = NeoForgeRuntimeAccess.start(directory)) { + Connection connection = new Connection(); + try (NeoForgeProxyTransport service = create(runtime, connection, Mode.COMPATIBILITY)) { + assertTrue(connection.ready.await(3, TimeUnit.SECONDS)); + for (JsonEnvelope announced : connection.sent) { + assertFalse(VotingPluginWire.advertisesAuthenticatedSocketVoteDelivery(announced)); + assertFalse(VotingPluginWire.advertisesVoteDelayRejectionAcknowledgement(announced)); + assertTrue(VotingPluginWire.advertisesVoteDeliveryAcknowledgement(announced)); + } + connection.sent.clear(); + UUID player = UUID.randomUUID(); + deliver(connection, vote(UUID.randomUUID(), player)); + assertEquals(1, runtime.deferredVotes().pending(player).size()); + assertEquals(1, connection.sent.size()); + } + } + } + + @Test void capacityRefusalNeverAcknowledgesAnUnretainedVote() throws Exception { + try (NeoForgeRuntime runtime = NeoForgeRuntimeAccess.start(directory)) { + Connection connection = new Connection(); + try (NeoForgeProxyTransport service = create(runtime, connection, Mode.REQUIRED)) { + assertTrue(connection.ready.await(3, TimeUnit.SECONDS)); + UUID player = UUID.randomUUID(); + for (int index = 0; index < NeoForgeDeferredVoteStoreAccess.MAX_DEFERRED_PER_USER; index++) { + deliver(connection, proxy.sign(vote(UUID.randomUUID(), player), Domain.MQTT_PROXY_BACKEND, + "proxy-test", INBOUND)); + } + connection.sent.clear(); + deliver(connection, proxy.sign(vote(UUID.randomUUID(), player), Domain.MQTT_PROXY_BACKEND, + "proxy-test", INBOUND)); + assertTrue(connection.sent.isEmpty()); + assertEquals(NeoForgeDeferredVoteStoreAccess.MAX_DEFERRED_PER_USER, + runtime.deferredVotes().pending(player).size()); + } + } + } + + @Test void startupConnectionFailureRetriesWithoutBlockingTheCaller() throws Exception { + try (NeoForgeRuntime runtime = NeoForgeRuntimeAccess.start(directory)) { + Connection connection = new Connection(); + AtomicInteger attempts = new AtomicInteger(); + Object factory = new Object() { + public Connection open() throws IOException { + if (attempts.incrementAndGet() == 1) throw new IOException("synthetic unavailable broker"); + return connection; + } + }; + try (NeoForgeProxyTransport service = create(runtime, connection, Mode.REQUIRED, factory)) { + assertTrue(connection.ready.await(4, TimeUnit.SECONDS)); + assertEquals(2, attempts.get()); + } + assertTrue(connection.closed.await(3, TimeUnit.SECONDS)); + assertEquals(1, connection.closes.get()); + } + } + + @Test void shutdownWhileConnectingClosesLateClientWithoutSubscribing() throws Exception { + try (NeoForgeRuntime runtime = NeoForgeRuntimeAccess.start(directory)) { + Connection connection = new Connection(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + Object factory = new Object() { + public Connection open() throws InterruptedException { + entered.countDown(); + assertTrue(release.await(4, TimeUnit.SECONDS)); + return connection; + } + }; + try (NeoForgeProxyTransport service = create(runtime, connection, Mode.REQUIRED, factory)) { + assertTrue(entered.await(3, TimeUnit.SECONDS)); + try { service.close(); } + finally { release.countDown(); } + assertTrue(connection.closed.await(3, TimeUnit.SECONDS)); + assertEquals(1, connection.closes.get()); + assertEquals(null, connection.receiver); + assertTrue(connection.sent.isEmpty()); + } + } + } + + @Test void failedAckPublishKeepsDurableOccurrenceForSameIdRetry() throws Exception { + try (NeoForgeRuntime runtime = NeoForgeRuntimeAccess.start(directory)) { + Connection connection = new Connection(); + try (NeoForgeProxyTransport service = create(runtime, connection, Mode.REQUIRED)) { + assertTrue(connection.ready.await(3, TimeUnit.SECONDS)); + connection.sent.clear(); + connection.failPublishes = true; + UUID player = UUID.randomUUID(); + JsonEnvelope raw = vote(UUID.randomUUID(), player); + deliver(connection, proxy.sign(raw, Domain.MQTT_PROXY_BACKEND, "proxy-test", INBOUND)); + assertEquals(1, runtime.deferredVotes().pending(player).size()); + assertTrue(connection.sent.isEmpty()); + connection.failPublishes = false; + deliver(connection, proxy.sign(raw, Domain.MQTT_PROXY_BACKEND, "proxy-test", INBOUND)); + assertEquals(1, runtime.deferredVotes().pending(player).size()); + assertEquals(1, connection.sent.size()); + } + } + } + + @Test void nativeRuntimeSelectsMqttAndClosesItsActualAdapterWithoutSocketStartup() throws Exception { + Files.writeString(directory.resolve("BungeeSettings.yml"), "UseBungeecord: true\nBungeeMethod: MQTT\n" + + "Server: backend-test\nBungeeServer: {Name: proxy-test}\n"); + Class type = ReflectiveAccess.type(OWNER); + NeoForgeProxyTransport adapter = (NeoForgeProxyTransport) org.mockito.Mockito.mock(type); + try (var mockedFactory = org.mockito.Mockito.mockStatic(type, invocation -> { + assertEquals("start", invocation.getMethod().getName()); + return adapter; + })) { + try (NeoForgeRuntime runtime = NeoForgeRuntimeAccess.start(directory, new Object())) { + assertEquals(adapter, runtime.proxyTransport().orElseThrow()); + assertTrue(runtime.proxySocket().isEmpty()); + } + org.mockito.Mockito.verify(adapter).close(); + } + } + + private NeoForgeProxyTransport create(NeoForgeRuntime runtime, Connection connection, Mode mode) throws IOException { + return create(runtime, connection, mode, new Factory(connection)); + } + + private NeoForgeProxyTransport create(NeoForgeRuntime runtime, Connection connection, Mode mode, + Object factory) throws IOException { + Path file = directory.resolve("mqtt-test-settings.yml"); + Files.writeString(file, "UseBungeecord: true\nBungeeMethod: MQTT\nServer: backend-test\n" + + "BungeeServer: {Name: proxy-test}\nSharedTransportAuthentication: " + mode + + "\nMQTT: {BrokerURL: 'tcp://broker.example:1883', Prefix: 'fixture/'}\n"); + Object config = ReflectiveAccess.call("com.bencodez.votingplugin.neoforge.NeoForgeProxyTransportConfiguration", + null, "load", new String[] {"ConfigurationNode", "ConfigurationNode"}, + new Object[] {YamlConfigurationLoader.builder().path(file).build().load(), runtime.config()}); + return (NeoForgeProxyTransport) ReflectiveAccess.construct(OWNER, + new String[] {"NeoForgeProxyTransportConfiguration", "NeoForgeVoteProcessor", "NeoForgePlayerDirectory", + "SharedTransportEnvelopeAuthenticator", "TransportEnvelopeEncryption", "ConnectionFactory", "boolean"}, + new Object[] {config, runtime.voteProcessor(), runtime.players(), + SharedTransportEnvelopeAuthenticator.load(directory.resolve("secretkey.key"), mode), + TransportEnvelopeEncryption.disabled(TransportEnvelopeEncryption.Domain.PROXY_BACKEND), + factory, false}); + } + + private static void deliver(Connection connection, JsonEnvelope envelope) { + connection.receiver.accept(INBOUND, encode(envelope)); + } + private static byte[] encode(JsonEnvelope envelope) { + return JsonEnvelopeCodec.encode(envelope).getBytes(StandardCharsets.UTF_8); + } + private static JsonEnvelope vote(UUID id, UUID player) { + return VotingPluginWire.requestVoteDeliveryAcknowledgement(VotingPluginWire.vote("Alex", player.toString(), + "Service", 100L, true, true, "", id, false, false, 1, 1)); + } + + public static final class Factory { + private final Connection connection; + Factory(Connection connection) { this.connection = connection; } + public Connection open() { return connection; } + } + public static final class Connection { + final List sent = new CopyOnWriteArrayList<>(); + final CountDownLatch ready = new CountDownLatch(1); + final CountDownLatch closed = new CountDownLatch(1); + final AtomicInteger closes = new AtomicInteger(); + volatile BiConsumer receiver; + volatile boolean failPublishes; + public void subscribe(String topic, BiConsumer callback) { + assertEquals(INBOUND, topic); + receiver = callback; + } + public void publish(String topic, JsonEnvelope envelope) { + assertEquals(OUTBOUND, topic); + if (failPublishes) throw new IllegalStateException("synthetic publication failure"); + sent.add(envelope); + if (envelope.getSubChannel().equals(VotingPluginWire.SUB_BACKEND_HEARTBEAT)) ready.countDown(); + } + public void close() { closes.incrementAndGet(); closed.countDown(); } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyMysqlServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyMysqlServiceTest.java new file mode 100644 index 0000000000..b51e390a4e --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyMysqlServiceTest.java @@ -0,0 +1,247 @@ +package com.bencodez.votingplugin.tests.neoforge; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.SQLException; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.spongepowered.configurate.yaml.YamlConfigurationLoader; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec; +import com.bencodez.votingplugin.neoforge.NeoForgeProxyTransport; +import com.bencodez.votingplugin.neoforge.NeoForgeRuntime; +import com.bencodez.votingplugin.proxy.VotingPluginWire; +import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption; +import com.bencodez.votingplugin.tests.support.ReflectiveAccess; + +class NeoForgeProxyMysqlServiceTest { + private static final String OWNER = "com.bencodez.votingplugin.neoforge.NeoForgeProxyMysqlService"; + @TempDir Path directory; + + @BeforeEach void configure() throws IOException { + Files.writeString(directory.resolve("Config.yml"), "DataStorage: SQLITE\nAllowUnjoined: true\n" + + "Database: {Host: db.example, Database: fixture, Username: fixture-user}\n"); + Files.writeString(directory.resolve("VoteSites.yml"), """ + VoteSites: + Site: + Enabled: true + ServiceSite: Service + VoteDelay: 24 + WaitUntilVoteDelay: false + GiveOfflineRewards: true + """); + } + + @Test void completeQueueEnvelopeReturnsAcceptanceOnlyAfterDurableRetentionAndSurvivesRestart() throws Exception { + UUID player = UUID.randomUUID(); + UUID id = UUID.randomUUID(); + for (int generation = 0; generation < 2; generation++) { + try (NeoForgeRuntime runtime = NeoForgeRuntimeAccess.start(directory)) { + Queue queue = new Queue(); + try (NeoForgeProxyTransport service = create(runtime, queue, new Factory(queue))) { + assertTrue(queue.ready.await(3, TimeUnit.SECONDS)); + queue.sent.clear(); + for (int duplicate = 0; duplicate < 2; duplicate++) { + assertTrue(receive(service, "proxy", "backend-test", encode(vote(id, player)))); + } + assertEquals(1, runtime.deferredVotes().pending(player).size()); + assertEquals(id, runtime.deferredVotes().pending(player).getFirst().voteId()); + assertEquals(2, queue.sent.size()); + assertTrue(queue.sent.stream().allMatch(ack -> + VotingPluginWire.SUB_VOTE_DELIVERY_ACK.equals(ack.getSubChannel()))); + } + assertTrue(queue.closed.await(3, TimeUnit.SECONDS)); + assertEquals(1, queue.closes.get()); + } + } + } + + @Test void wrongSqlRoleDestinationSchemaMalformedAndAdjacentDataCannotAdmitVotes() throws Exception { + try (NeoForgeRuntime runtime = NeoForgeRuntimeAccess.start(directory)) { + Queue queue = new Queue(); + try (NeoForgeProxyTransport service = create(runtime, queue, new Factory(queue))) { + assertTrue(queue.ready.await(3, TimeUnit.SECONDS)); + queue.sent.clear(); + UUID player = UUID.randomUUID(); + byte[] raw = encode(vote(UUID.randomUUID(), player)); + assertFalse(receive(service, "backend-peer", "backend-test", raw)); + assertFalse(receive(service, "proxy", "other-backend", raw)); + assertFalse(receive(service, "proxy", "backend-test", new byte[] {(byte) 0xff})); + assertFalse(receive(service, "proxy", "backend-test", new byte[262145])); + assertFalse(receive(service, "proxy", "backend-test", (new String(raw, StandardCharsets.UTF_8) + + " {}").getBytes(StandardCharsets.UTF_8))); + assertFalse(receive(service, "proxy", "backend-test", encode( + JsonEnvelope.builder("fixture-unsupported").put("name", "雪").build()))); + assertTrue(runtime.deferredVotes().pending(player).isEmpty()); + assertTrue(queue.sent.isEmpty()); + assertTrue(receive(service, "proxy", "backend-test", raw)); + service.close(); + assertFalse(receive(service, "proxy", "backend-test", encode(vote(UUID.randomUUID(), player)))); + assertEquals(1, runtime.deferredVotes().pending(player).size()); + } + } + } + + @Test void capacityRefusalReturnsFalseAndDoesNotAcknowledgeOrDeleteTheSqlOccurrence() throws Exception { + try (NeoForgeRuntime runtime = NeoForgeRuntimeAccess.start(directory)) { + Queue queue = new Queue(); + try (NeoForgeProxyTransport service = create(runtime, queue, new Factory(queue))) { + assertTrue(queue.ready.await(3, TimeUnit.SECONDS)); + UUID player = UUID.randomUUID(); + for (int index = 0; index < NeoForgeDeferredVoteStoreAccess.MAX_DEFERRED_PER_USER; index++) { + assertTrue(receive(service, "proxy", "backend-test", encode(vote(UUID.randomUUID(), player)))); + } + queue.sent.clear(); + assertFalse(receive(service, "proxy", "backend-test", encode(vote(UUID.randomUUID(), player)))); + assertTrue(queue.sent.isEmpty()); + assertEquals(NeoForgeDeferredVoteStoreAccess.MAX_DEFERRED_PER_USER, + runtime.deferredVotes().pending(player).size()); + } + } + } + + @Test void failedAckLeavesTheSameDurableIdRetryable() throws Exception { + try (NeoForgeRuntime runtime = NeoForgeRuntimeAccess.start(directory)) { + Queue queue = new Queue(); + try (NeoForgeProxyTransport service = create(runtime, queue, new Factory(queue))) { + assertTrue(queue.ready.await(3, TimeUnit.SECONDS)); + queue.sent.clear(); + queue.failPublishes = true; + UUID player = UUID.randomUUID(); + UUID id = UUID.randomUUID(); + byte[] raw = encode(vote(id, player)); + assertFalse(receive(service, "proxy", "backend-test", raw)); + assertEquals(id, runtime.deferredVotes().pending(player).getFirst().voteId()); + assertTrue(queue.sent.isEmpty()); + queue.failPublishes = false; + assertTrue(receive(service, "proxy", "backend-test", raw)); + assertEquals(1, runtime.deferredVotes().pending(player).size()); + assertEquals(1, queue.sent.size()); + } + } + } + + @Test void startupRetriesAndPublishesOnlySupportedDurabilityCapabilities() throws Exception { + try (NeoForgeRuntime runtime = NeoForgeRuntimeAccess.start(directory)) { + Queue queue = new Queue(); + AtomicInteger attempts = new AtomicInteger(); + Object factory = new Object() { + public Queue open() throws SQLException { + if (attempts.incrementAndGet() == 1) throw new SQLException("synthetic unavailable database"); + return queue; + } + }; + try (NeoForgeProxyTransport service = create(runtime, queue, factory)) { + assertTrue(queue.ready.await(4, TimeUnit.SECONDS)); + assertEquals(2, attempts.get()); + for (JsonEnvelope announced : queue.sent) { + assertTrue(VotingPluginWire.advertisesVoteDeliveryAcknowledgement(announced)); + assertFalse(VotingPluginWire.advertisesAuthenticatedSocketVoteDelivery(announced)); + assertFalse(VotingPluginWire.advertisesVoteDelayRejectionAcknowledgement(announced)); + } + } + assertTrue(queue.closed.await(3, TimeUnit.SECONDS)); + assertEquals(1, queue.closes.get()); + } + } + + @Test void shutdownDuringFactoryAdmissionClosesLateResourcesWithoutPolling() throws Exception { + try (NeoForgeRuntime runtime = NeoForgeRuntimeAccess.start(directory)) { + Queue queue = new Queue(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + Object factory = new Object() { + public Queue open() throws InterruptedException { + entered.countDown(); + assertTrue(release.await(4, TimeUnit.SECONDS)); + return queue; + } + }; + try (NeoForgeProxyTransport service = create(runtime, queue, factory)) { + assertTrue(entered.await(3, TimeUnit.SECONDS)); + try { service.close(); } + finally { release.countDown(); } + assertTrue(queue.closed.await(3, TimeUnit.SECONDS)); + assertEquals(1, queue.closes.get()); + assertEquals(0, queue.polls.get()); + assertTrue(queue.sent.isEmpty()); + } + } + } + + @Test void nativeRuntimeSelectsMysqlWithoutChangingSqliteUserStorage() throws Exception { + Files.writeString(directory.resolve("BungeeSettings.yml"), + "UseBungeecord: true\nBungeeMethod: MYSQL\nServer: backend-test\n"); + Class type = ReflectiveAccess.type(OWNER); + NeoForgeProxyTransport adapter = (NeoForgeProxyTransport) org.mockito.Mockito.mock(type); + try (var mocked = org.mockito.Mockito.mockStatic(type, invocation -> { + assertEquals("start", invocation.getMethod().getName()); + return adapter; + })) { + try (NeoForgeRuntime runtime = NeoForgeRuntimeAccess.start(directory, new Object())) { + assertEquals(adapter, runtime.proxyTransport().orElseThrow()); + assertTrue(runtime.proxySocket().isEmpty()); + assertEquals("SQLITE", runtime.config().node("DataStorage").getString()); + assertTrue(Files.isRegularFile(directory.resolve("VotingPlugin.db"))); + } + org.mockito.Mockito.verify(adapter).close(); + } + } + + private NeoForgeProxyTransport create(NeoForgeRuntime runtime, Queue queue, Object factory) throws IOException { + Path file = directory.resolve("mysql-test-settings.yml"); + Files.writeString(file, "UseBungeecord: true\nBungeeMethod: MYSQL\nServer: backend-test\n"); + Object configuration = ReflectiveAccess.call( + "com.bencodez.votingplugin.neoforge.NeoForgeProxyTransportConfiguration", null, "load", + new String[] {"ConfigurationNode", "ConfigurationNode"}, + new Object[] {YamlConfigurationLoader.builder().path(file).build().load(), runtime.config()}); + return (NeoForgeProxyTransport) ReflectiveAccess.construct(OWNER, + new String[] {"NeoForgeProxyTransportConfiguration", "NeoForgeVoteProcessor", "NeoForgePlayerDirectory", + "TransportEnvelopeEncryption", "QueueFactory", "boolean"}, + new Object[] {configuration, runtime.voteProcessor(), runtime.players(), + TransportEnvelopeEncryption.disabled(TransportEnvelopeEncryption.Domain.PROXY_BACKEND), factory, false}); + } + + private static boolean receive(NeoForgeProxyTransport service, String source, String destination, byte[] payload) { + return (boolean) ReflectiveAccess.call(OWNER, service, "receive", + new String[] {"String", "String", "byte[]"}, new Object[] {source, destination, payload}); + } + private static byte[] encode(JsonEnvelope envelope) { return JsonEnvelopeCodec.encode(envelope).getBytes(StandardCharsets.UTF_8); } + private static JsonEnvelope vote(UUID id, UUID player) { + return VotingPluginWire.requestVoteDeliveryAcknowledgement(VotingPluginWire.vote("Alex", player.toString(), + "Service", 100L, true, true, "", id, false, false, 1, 1)); + } + public static final class Factory { + private final Queue queue; + Factory(Queue queue) { this.queue = queue; } + public Queue open() { return queue; } + } + public static final class Queue { + final List sent = new CopyOnWriteArrayList<>(); + final CountDownLatch ready = new CountDownLatch(1); + final CountDownLatch closed = new CountDownLatch(1); + final AtomicInteger closes = new AtomicInteger(); + final AtomicInteger polls = new AtomicInteger(); + volatile boolean failPublishes; + public void poll(Object receiver) { polls.incrementAndGet(); } + public void publish(JsonEnvelope envelope) { + if (failPublishes) throw new IllegalStateException("synthetic publication failure"); + sent.add(envelope); + if (VotingPluginWire.SUB_BACKEND_HEARTBEAT.equals(envelope.getSubChannel())) ready.countDown(); + } + public void close() { closes.incrementAndGet(); closed.countDown(); } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyRedisServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyRedisServiceTest.java new file mode 100644 index 0000000000..7b81c9d4b6 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyRedisServiceTest.java @@ -0,0 +1,297 @@ +package com.bencodez.votingplugin.tests.neoforge; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiConsumer; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.spongepowered.configurate.yaml.YamlConfigurationLoader; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec; +import com.bencodez.votingplugin.neoforge.NeoForgeProxyTransport; +import com.bencodez.votingplugin.neoforge.NeoForgeRuntime; +import com.bencodez.votingplugin.proxy.VotingPluginWire; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Domain; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode; +import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption; +import com.bencodez.votingplugin.tests.support.ReflectiveAccess; + +class NeoForgeProxyRedisServiceTest { + private static final String OWNER = "com.bencodez.votingplugin.neoforge.NeoForgeProxyRedisService"; + private static final String INBOUND = "fixture/VotingPlugin_backend-test"; + private static final String OUTBOUND = "fixture/VotingPlugin"; + @TempDir Path directory; + private SharedTransportEnvelopeAuthenticator proxy; + + @BeforeEach void configure() throws IOException { + byte[] independentKey = new byte[32]; + new java.security.SecureRandom().nextBytes(independentKey); + Files.writeString(directory.resolve("secretkey.key"), Base64.getEncoder().encodeToString(independentKey)); + proxy = SharedTransportEnvelopeAuthenticator.load(directory.resolve("secretkey.key"), Mode.REQUIRED); + Files.writeString(directory.resolve("Config.yml"), "DataStorage: SQLITE\nAllowUnjoined: true\n"); + Files.writeString(directory.resolve("VoteSites.yml"), """ + VoteSites: + Site: + Enabled: true + ServiceSite: Service + VoteDelay: 24 + WaitUntilVoteDelay: false + GiveOfflineRewards: true + """); + } + + @Test void authenticatedBrokerCallbackRetainsBeforeAckAndDuplicatesSurviveRestart() throws Exception { + UUID player = UUID.randomUUID(); + UUID id = UUID.randomUUID(); + for (int generation = 0; generation < 2; generation++) { + try (NeoForgeRuntime runtime = NeoForgeRuntimeAccess.start(directory)) { + Connection connection = new Connection(); + try (NeoForgeProxyTransport service = create(runtime, connection, Mode.REQUIRED)) { + assertTrue(connection.ready.await(3, TimeUnit.SECONDS)); + connection.sent.clear(); + for (int duplicate = 0; duplicate < 2; duplicate++) { + deliver(connection, proxy.sign(vote(id, player), Domain.REDIS_PROXY_BACKEND, + "proxy-test", INBOUND)); + } + assertEquals(1, runtime.deferredVotes().pending(player).size()); + assertEquals(id, runtime.deferredVotes().pending(player).getFirst().voteId()); + assertEquals(2, connection.sent.size()); + for (JsonEnvelope ack : connection.sent) { + var verified = proxy.verify(ack, Domain.REDIS_PROXY_BACKEND, OUTBOUND); + assertTrue(verified.accepted()); + UUID.fromString(verified.envelope().getFields().get(VotingPluginWire.K_REDIS_DELIVERY_ID)); + assertEquals(VotingPluginWire.SUB_VOTE_DELIVERY_ACK, verified.envelope().getSubChannel()); + } + } + assertTrue(connection.closed.await(3, TimeUnit.SECONDS)); + assertEquals(1, connection.closes.get()); + } + } + } + + @Test void wrongTopicDomainSenderUnsignedAndReplayedWrappersCannotCreateAnotherOccurrence() throws Exception { + try (NeoForgeRuntime runtime = NeoForgeRuntimeAccess.start(directory)) { + Connection connection = new Connection(); + try (NeoForgeProxyTransport service = create(runtime, connection, Mode.REQUIRED)) { + assertTrue(connection.ready.await(3, TimeUnit.SECONDS)); + connection.sent.clear(); + UUID player = UUID.randomUUID(); + JsonEnvelope raw = vote(UUID.randomUUID(), player); + deliver(connection, raw); + deliver(connection, proxy.sign(raw, Domain.MQTT_PROXY_BACKEND, "proxy-test", INBOUND)); + deliver(connection, proxy.sign(raw, Domain.REDIS_PROXY_BACKEND, "other-peer", INBOUND)); + deliver(connection, proxy.sign(raw, Domain.REDIS_PROXY_BACKEND, "proxy-test", "other/topic")); + byte[] good = encode(proxy.sign(raw, Domain.REDIS_PROXY_BACKEND, "proxy-test", INBOUND)); + connection.receiver.accept("other/topic", good); + connection.receiver.accept(INBOUND, new byte[] {(byte) 0xff}); + connection.receiver.accept(INBOUND, new byte[256 * 1024 + 1]); + assertTrue(runtime.deferredVotes().pending(player).isEmpty()); + assertTrue(connection.sent.isEmpty()); + connection.receiver.accept(INBOUND, good); + connection.receiver.accept(INBOUND, good); + assertEquals(1, runtime.deferredVotes().pending(player).size()); + assertEquals(1, connection.sent.size()); + service.close(); + connection.sent.clear(); + deliver(connection, proxy.sign(vote(UUID.randomUUID(), player), Domain.REDIS_PROXY_BACKEND, + "proxy-test", INBOUND)); + assertEquals(1, runtime.deferredVotes().pending(player).size()); + assertTrue(connection.sent.isEmpty()); + } + } + } + + @Test void compatibilityIsExplicitAndDoesNotAdvertiseSocketOrDelayRejectionSupport() throws Exception { + try (NeoForgeRuntime runtime = NeoForgeRuntimeAccess.start(directory)) { + Connection connection = new Connection(); + try (NeoForgeProxyTransport service = create(runtime, connection, Mode.COMPATIBILITY)) { + assertTrue(connection.ready.await(3, TimeUnit.SECONDS)); + for (JsonEnvelope announced : connection.sent) { + assertFalse(VotingPluginWire.advertisesAuthenticatedSocketVoteDelivery(announced)); + assertFalse(VotingPluginWire.advertisesVoteDelayRejectionAcknowledgement(announced)); + assertTrue(VotingPluginWire.advertisesVoteDeliveryAcknowledgement(announced)); + } + connection.sent.clear(); + UUID player = UUID.randomUUID(); + deliver(connection, vote(UUID.randomUUID(), player)); + assertEquals(1, runtime.deferredVotes().pending(player).size()); + assertEquals(1, connection.sent.size()); + } + } + } + + @Test void capacityRefusalNeverAcknowledgesAnUnretainedVote() throws Exception { + try (NeoForgeRuntime runtime = NeoForgeRuntimeAccess.start(directory)) { + Connection connection = new Connection(); + try (NeoForgeProxyTransport service = create(runtime, connection, Mode.REQUIRED)) { + assertTrue(connection.ready.await(3, TimeUnit.SECONDS)); + UUID player = UUID.randomUUID(); + for (int index = 0; index < NeoForgeDeferredVoteStoreAccess.MAX_DEFERRED_PER_USER; index++) { + deliver(connection, proxy.sign(vote(UUID.randomUUID(), player), Domain.REDIS_PROXY_BACKEND, + "proxy-test", INBOUND)); + } + connection.sent.clear(); + deliver(connection, proxy.sign(vote(UUID.randomUUID(), player), Domain.REDIS_PROXY_BACKEND, + "proxy-test", INBOUND)); + assertTrue(connection.sent.isEmpty()); + assertEquals(NeoForgeDeferredVoteStoreAccess.MAX_DEFERRED_PER_USER, + runtime.deferredVotes().pending(player).size()); + } + } + } + + @Test void startupConnectionFailureRetriesWithoutBlockingTheCaller() throws Exception { + try (NeoForgeRuntime runtime = NeoForgeRuntimeAccess.start(directory)) { + Connection connection = new Connection(); + AtomicInteger attempts = new AtomicInteger(); + Object factory = new Object() { + public Connection open() throws IOException { + if (attempts.incrementAndGet() == 1) throw new IOException("synthetic unavailable broker"); + return connection; + } + }; + try (NeoForgeProxyTransport service = create(runtime, connection, Mode.REQUIRED, factory)) { + assertTrue(connection.ready.await(4, TimeUnit.SECONDS)); + assertEquals(2, attempts.get()); + } + assertTrue(connection.closed.await(3, TimeUnit.SECONDS)); + assertEquals(1, connection.closes.get()); + } + } + + @Test void shutdownWhileConnectingClosesLateClientWithoutSubscribing() throws Exception { + try (NeoForgeRuntime runtime = NeoForgeRuntimeAccess.start(directory)) { + Connection connection = new Connection(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + Object factory = new Object() { + public Connection open() throws InterruptedException { + entered.countDown(); + assertTrue(release.await(4, TimeUnit.SECONDS)); + return connection; + } + }; + try (NeoForgeProxyTransport service = create(runtime, connection, Mode.REQUIRED, factory)) { + assertTrue(entered.await(3, TimeUnit.SECONDS)); + try { service.close(); } + finally { release.countDown(); } + assertTrue(connection.closed.await(3, TimeUnit.SECONDS)); + assertEquals(1, connection.closes.get()); + assertEquals(null, connection.receiver); + assertTrue(connection.sent.isEmpty()); + } + } + } + + @Test void failedAckPublishKeepsDurableOccurrenceForSameIdRetry() throws Exception { + try (NeoForgeRuntime runtime = NeoForgeRuntimeAccess.start(directory)) { + Connection connection = new Connection(); + try (NeoForgeProxyTransport service = create(runtime, connection, Mode.REQUIRED)) { + assertTrue(connection.ready.await(3, TimeUnit.SECONDS)); + connection.sent.clear(); + connection.failPublishes = true; + UUID player = UUID.randomUUID(); + JsonEnvelope raw = vote(UUID.randomUUID(), player); + deliver(connection, proxy.sign(raw, Domain.REDIS_PROXY_BACKEND, "proxy-test", INBOUND)); + assertEquals(1, runtime.deferredVotes().pending(player).size()); + assertTrue(connection.sent.isEmpty()); + connection.failPublishes = false; + deliver(connection, proxy.sign(raw, Domain.REDIS_PROXY_BACKEND, "proxy-test", INBOUND)); + assertEquals(1, runtime.deferredVotes().pending(player).size()); + assertEquals(1, connection.sent.size()); + } + } + } + + @Test void nativeRuntimeSelectsRedisAndClosesItsActualAdapterWithoutSocketStartup() throws Exception { + Files.writeString(directory.resolve("BungeeSettings.yml"), "UseBungeecord: true\nBungeeMethod: REDIS\n" + + "Server: backend-test\nBungeeServer: {Name: proxy-test}\nRedis: {Host: redis.example}\n"); + Class type = ReflectiveAccess.type(OWNER); + NeoForgeProxyTransport adapter = (NeoForgeProxyTransport) org.mockito.Mockito.mock(type); + try (var mockedFactory = org.mockito.Mockito.mockStatic(type, invocation -> { + assertEquals("start", invocation.getMethod().getName()); + return adapter; + })) { + try (NeoForgeRuntime runtime = NeoForgeRuntimeAccess.start(directory, new Object())) { + assertEquals(adapter, runtime.proxyTransport().orElseThrow()); + assertTrue(runtime.proxySocket().isEmpty()); + } + org.mockito.Mockito.verify(adapter).close(); + } + } + + private NeoForgeProxyTransport create(NeoForgeRuntime runtime, Connection connection, Mode mode) throws IOException { + return create(runtime, connection, mode, new Factory(connection)); + } + + private NeoForgeProxyTransport create(NeoForgeRuntime runtime, Connection connection, Mode mode, + Object factory) throws IOException { + Path file = directory.resolve("redis-test-settings.yml"); + Files.writeString(file, "UseBungeecord: true\nBungeeMethod: REDIS\nServer: backend-test\n" + + "BungeeServer: {Name: proxy-test}\nSharedTransportAuthentication: " + mode + + "\nRedis: {Host: redis.example, Prefix: 'fixture/'}\n"); + Object config = ReflectiveAccess.call("com.bencodez.votingplugin.neoforge.NeoForgeProxyTransportConfiguration", + null, "load", new String[] {"ConfigurationNode", "ConfigurationNode"}, + new Object[] {YamlConfigurationLoader.builder().path(file).build().load(), runtime.config()}); + return (NeoForgeProxyTransport) ReflectiveAccess.construct(OWNER, + new String[] {"NeoForgeProxyTransportConfiguration", "NeoForgeVoteProcessor", "NeoForgePlayerDirectory", + "SharedTransportEnvelopeAuthenticator", "TransportEnvelopeEncryption", "ConnectionFactory", "boolean"}, + new Object[] {config, runtime.voteProcessor(), runtime.players(), + SharedTransportEnvelopeAuthenticator.load(directory.resolve("secretkey.key"), mode), + TransportEnvelopeEncryption.disabled(TransportEnvelopeEncryption.Domain.PROXY_BACKEND), + factory, false}); + } + + private static void deliver(Connection connection, JsonEnvelope envelope) { + connection.receiver.accept(INBOUND, encode(envelope)); + } + private static byte[] encode(JsonEnvelope envelope) { + return JsonEnvelopeCodec.encode(envelope).getBytes(StandardCharsets.UTF_8); + } + private static JsonEnvelope vote(UUID id, UUID player) { + return VotingPluginWire.requestVoteDeliveryAcknowledgement(VotingPluginWire.vote("Alex", player.toString(), + "Service", 100L, true, true, "", id, false, false, 1, 1)); + } + + public static final class Factory { + private final Connection connection; + Factory(Connection connection) { this.connection = connection; } + public Connection open() { return connection; } + } + public static final class Connection { + final List sent = new CopyOnWriteArrayList<>(); + final CountDownLatch ready = new CountDownLatch(1); + final CountDownLatch closed = new CountDownLatch(1); + final AtomicInteger closes = new AtomicInteger(); + volatile BiConsumer receiver; + volatile boolean failPublishes; + public void subscribe(String topic, BiConsumer callback) { + assertEquals(INBOUND, topic); + receiver = callback; + } + public void publish(String topic, JsonEnvelope envelope) { + assertEquals(OUTBOUND, topic); + if (failPublishes) throw new IllegalStateException("synthetic publication failure"); + sent.add(envelope); + if (envelope.getSubChannel().equals(VotingPluginWire.SUB_BACKEND_HEARTBEAT)) ready.countDown(); + } + public void close() { closes.incrementAndGet(); closed.countDown(); } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyTransportConfigurationTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyTransportConfigurationTest.java new file mode 100644 index 0000000000..36360ffff7 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyTransportConfigurationTest.java @@ -0,0 +1,237 @@ +package com.bencodez.votingplugin.tests.neoforge; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.spongepowered.configurate.ConfigurationNode; +import org.spongepowered.configurate.yaml.YamlConfigurationLoader; + +import com.bencodez.votingplugin.tests.support.ReflectiveAccess; + +class NeoForgeProxyTransportConfigurationTest { + private static final String OWNER = "com.bencodez.votingplugin.neoforge.NeoForgeProxyTransportConfiguration"; + @TempDir Path directory; + + @Test void disabledProxyIgnoresInactiveInvalidConnectionSettings() throws Exception { + var view = load(""" + UseBungeecord: false + BungeeMethod: REDIS + Redis: + Port: invalid + """, "Database: invalid"); + assertEquals("DISABLED", field(view, "method").toString()); + assertEquals(false, field(view, "enabled")); + } + + @Test void legacyEnableSpellingStillSelectsRedis() throws Exception { + var view = load(base("REDIS").replace("UseBungeecord", "UseBungeecoord") + "Redis: {Host: redis.example}\n", "{}"); + assertEquals("REDIS", field(view, "method").toString()); + assertEquals(true, field(view, "enabled")); + } + + @Test void explicitModernDisableOverridesLegacyEnable() throws Exception { + var view = load("UseBungeecord: false\nUseBungeecoord: true\nBungeeMethod: REDIS\n", "{}"); + assertEquals(false, field(view, "enabled")); + } + + @Test void existingSocketsKeepTheirConfigurationContract() throws Exception { + var view = load(base("sOcKeTs").replace("BungeeServer: {Name: proxy-test}\n", "") + """ + BungeeServer: + Name: proxy-test + Host: proxy.example + Port: 1297 + SpigotServer: + Host: backend.example + Port: 1298 + SocketAuthenticationKeyFile: socket-auth.key + CommunicationEncryption: true + """, "{}"); + Object socket = field(view, "socket"); + assertEquals(true, field(socket, "enabled")); + assertEquals("backend-test", field(socket, "server")); + assertEquals("proxy-test", field(socket, "proxyName")); + assertEquals(1298, field(socket, "backendPort")); + assertEquals("socket-auth.key", field(socket, "authenticationKeyFile")); + assertEquals(true, field(view, "communicationEncryption")); + } + + @Test void redisUsesCapturedCredentialsAndPreservesRoutingPrefixExactly() throws Exception { + var view = load(base("REDIS") + """ + SharedTransportAuthentication: REQUIRED + Redis: + Host: redis.example + Port: '6380' + Username: fixture-user + Password: fixture-secret + Db-Index: 2 + SSL: true + Prefix: ' network:' + MQTT: {BrokerURL: invalid} + """, "Database: invalid"); + Object redis = field(view, "redis"); + assertEquals("redis.example", field(redis, "host")); + assertEquals(6380, field(redis, "port")); + assertEquals(2, field(redis, "database")); + assertEquals(true, field(redis, "ssl")); + assertEquals(" network:", field(redis, "prefix")); + assertEquals("REQUIRED", field(view, "sharedAuthentication").toString()); + assertEquals("fixture-secret", field(redis, "password")); + assertFalse(view.toString().contains("fixture-secret")); + assertFalse(redis.toString().contains("fixture-secret")); + } + + @Test void mqttUsesExistingBrokerKeysAndServerDefaultClientIdentity() throws Exception { + var view = load(base("MQTT") + """ + MQTT: + BrokerURL: ssl://broker.example:8883 + Username: fixture-user + Password: fixture-secret + Prefix: network/ + """, "{}"); + Object mqtt = field(view, "mqtt"); + assertEquals("ssl://broker.example:8883", field(mqtt, "brokerUrl")); + assertEquals("backend-test", field(mqtt, "clientId")); + assertEquals("network/", field(mqtt, "prefix")); + assertEquals("COMPATIBILITY", field(view, "sharedAuthentication").toString()); + assertFalse(mqtt.toString().contains("fixture-secret")); + } + + @Test void mysqlTransportDoesNotSelectOrMutateMysqlUserStorage() throws Exception { + ConfigurationNode config = yaml(""" + DataStorage: SQLITE + Database: + Host: database.example + Database: votes + Username: fixture-user + Password: fixture-secret + Port: 3307 + UseSSL: true + PublicKeyRetrieval: false + MaxConnections: 2 + """); + Object original = config.raw(); + Object view = load(yaml(base("MYSQL")), config); + Object mysql = field(view, "mysql"); + assertEquals("database.example", field(mysql, "host")); + assertEquals(3307, field(mysql, "port")); + assertEquals("votes", field(mysql, "database")); + assertEquals(2, field(mysql, "maxConnections")); + assertEquals(true, field(mysql, "ssl")); + assertEquals(false, field(mysql, "publicKeyRetrieval")); + assertEquals("SQLITE", config.node("DataStorage").getString()); + assertEquals(original, config.raw()); + assertFalse(mysql.toString().contains("fixture-secret")); + } + + @Test void mysqlServerIdentityFitsTheExistingQueueSchemaWithoutChangingBrokerLimits() throws Exception { + String database = "Database: {Host: database.example, Database: votes, Username: fixture-user}\n"; + assertEquals("a".repeat(36), field(load(base("MYSQL").replace("backend-test", "a".repeat(36)), database), "server")); + assertThrows(IllegalArgumentException.class, + () -> load(base("MYSQL").replace("backend-test", "a".repeat(37)), database)); + assertEquals("a".repeat(64), field(load(base("REDIS").replace("backend-test", "a".repeat(64)) + + "Redis: {Host: redis.example}\n", "{}"), "server")); + } + + @Test void opaqueCredentialsAndExplicitClientIdentityRetainTheirExactContents() throws Exception { + String credentials = " Username: ' fixture-user '\n Password: |-\n fixture\n secret\n"; + for (String method : new String[] {"REDIS", "MQTT", "MYSQL"}) { + String bungee = base(method); + String config = "{}"; + if (method.equals("REDIS")) bungee += "Redis:\n Host: redis.example\n" + credentials; + else if (method.equals("MQTT")) bungee += "MQTT:\n ClientID: ' client identity '\n" + credentials; + else config = "Database:\n Host: database.example\n Database: votes\n" + credentials; + Object view = load(bungee, config); + Object connection = field(view, method.toLowerCase(java.util.Locale.ROOT)); + assertEquals(" fixture-user ", field(connection, "username")); + assertEquals("fixture\nsecret", field(connection, "password")); + if (method.equals("MQTT")) assertEquals(" client identity ", field(connection, "clientId")); + assertFalse(connection.toString().contains("fixture")); + } + } + + @Test void unsupportedEnabledMethodsAreRejected() { + for (String method : new String[] {"PLUGINMESSAGING", "HTTP", "DISABLED", "unknown", " SOCKETS "}) { + assertThrows(IllegalArgumentException.class, () -> load(base(method), "{}"), method); + } + } + + @Test void malformedPortsAndCountersDoNotSilentlyUseDefaults() { + for (String value : new String[] {"0", "65536", "-1", "1.5", "not-a-port", "true", "[1, 2]", "{value: 2}"}) { + assertThrows(IllegalArgumentException.class, + () -> load(base("REDIS") + "Redis: {Host: redis.example, Port: " + value + "}\n", "{}"), value); + } + assertThrows(IllegalArgumentException.class, + () -> load(base("REDIS") + "Redis: {Host: redis.example, Db-Index: -1}\n", "{}")); + } + + @Test void validBoundaryPortsAndDatabaseIndicesAreAccepted() throws Exception { + for (int port : new int[] {1, 65535}) { + Object view = load(base("REDIS") + "Redis: {Host: redis.example, Port: " + port + + ", Db-Index: 2147483647}\n", "{}"); + assertEquals(port, field(field(view, "redis"), "port")); + assertEquals(Integer.MAX_VALUE, field(field(view, "redis"), "database")); + } + } + + @Test void requiredPeersAndTextTypesAreValidated() { + for (String server : new String[] {"PleaseSet", "invalid/name", "''", "[backend]"}) { + assertThrows(IllegalArgumentException.class, + () -> load(base("REDIS").replace("Server: backend-test", "Server: " + server) + + "Redis: {Host: redis.example}\n", "{}")); + } + assertThrows(IllegalArgumentException.class, + () -> load(base("REDIS") + "Redis: {Host: {value: redis.example}}\n", "{}")); + } + + @Test void brokerUrisRejectMalformedSchemesPortsAndEmbeddedCredentialsWithoutEchoingThem() { + for (String uri : new String[] {"http://broker.example", "tcp:///missing-host", "tcp://broker.example:65536", + "tcp://fixture-user:fixture-secret@broker.example:1883", "tcp://broker.example:0", "not a URI"}) { + var failure = assertThrows(IllegalArgumentException.class, + () -> load(base("MQTT") + "MQTT: {BrokerURL: '" + uri + "'}\n", "{}")); + assertFalse(failure.getMessage().contains(uri)); + assertNull(failure.getCause()); + } + } + + @Test void invalidTransportBooleansAreNotCoerced() { + assertThrows(IllegalArgumentException.class, + () -> load(base("REDIS") + "Redis: {Host: redis.example, SSL: perhaps}\n", "{}")); + assertThrows(IllegalArgumentException.class, + () -> load(base("MQTT") + "CommunicationEncryption: [true]\n", "{}")); + } + + @Test void mysqlRejectsAnotherDatabaseProtocolAndUnboundedPools() { + for (String extra : new String[] {"DbType: POSTGRESQL", "MaxConnections: 0", "MaxConnections: 65"}) { + assertThrows(IllegalArgumentException.class, () -> load(base("MYSQL"), + "Database: {Host: database.example, Database: votes, Username: fixture-user, " + extra + "}\n")); + } + } + + private static String base(String method) { + return "UseBungeecord: true\nBungeeMethod: '" + method + + "'\nServer: backend-test\nBungeeServer: {Name: proxy-test}\n"; + } + + private Object load(String bungee, String config) throws IOException { return load(yaml(bungee), yaml(config)); } + + private static Object load(ConfigurationNode bungee, ConfigurationNode config) { + return ReflectiveAccess.call(OWNER, null, "load", + new String[] {"ConfigurationNode", "ConfigurationNode"}, + new Object[] {bungee, config}); + } + + private ConfigurationNode yaml(String text) throws IOException { + Path path = Files.createTempFile(directory, "transport-", ".yml"); + Files.writeString(path, text); + return YamlConfigurationLoader.builder().path(path).build().load(); + } + + private static Object field(Object value, String name) { + return ReflectiveAccess.call(value.getClass().getName(), value, name, new String[0], new Object[0]); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeRedisConnectionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeRedisConnectionTest.java new file mode 100644 index 0000000000..d2552e2483 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeRedisConnectionTest.java @@ -0,0 +1,214 @@ +package com.bencodez.votingplugin.tests.neoforge; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; +import java.util.function.Supplier; + +import org.junit.jupiter.api.Test; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec; +import com.bencodez.votingplugin.tests.support.ReflectiveAccess; + +import redis.clients.jedis.BinaryJedisPubSub; +import redis.clients.jedis.DefaultJedisClientConfig; +import redis.clients.jedis.Jedis; +import redis.clients.jedis.JedisPool; + +class NeoForgeRedisConnectionTest { + private static final String OWNER = "com.bencodez.votingplugin.neoforge.NeoForgeRedisConnection"; + private static final String CHANNEL = "fixture/VotingPlugin_backend-test"; + private static final byte[] CHANNEL_BYTES = CHANNEL.getBytes(StandardCharsets.UTF_8); + + @Test void binarySubscriberFiltersChannelsAndPublisherUsesASeparatePooledConnection() throws Exception { + JedisPool pool = mock(JedisPool.class); + Jedis subscriber = mock(Jedis.class); + Jedis publisher = mock(Jedis.class); + when(pool.getResource()).thenReturn(publisher); + var callback = new AtomicReference(); + var release = new CountDownLatch(1); + prepareSubscriber(subscriber, callback, release); + var reconnects = new AtomicInteger(); + var refreshed = new CountDownLatch(1); + Object connection = create(pool, () -> subscriber, () -> { + reconnects.incrementAndGet(); + refreshed.countDown(); + }); + List received = new CopyOnWriteArrayList<>(); + try { + subscribe(connection, (channel, payload) -> { + assertEquals(CHANNEL, channel); + received.add(payload); + }); + assertTrue(refreshed.await(3, TimeUnit.SECONDS)); + assertEquals(1, reconnects.get()); + byte[] completeWrapper = "{\"t\":\"fixture\",\"v\":1,\"f\":{\"name\":\"雪\"}}" + .getBytes(StandardCharsets.UTF_8); + callback.get().onMessage("other/channel".getBytes(StandardCharsets.UTF_8), completeWrapper); + callback.get().onMessage(CHANNEL_BYTES, completeWrapper); + assertEquals(1, received.size()); + assertArrayEquals(completeWrapper, received.getFirst()); + verify(pool, never()).getResource(); + JsonEnvelope envelope = JsonEnvelope.builder("fixture").put("name", "雪").build(); + call(connection, "publish", new String[] {"String", "JsonEnvelope"}, "fixture/VotingPlugin", envelope); + verify(publisher).publish(eq("fixture/VotingPlugin".getBytes(StandardCharsets.UTF_8)), + eq(JsonEnvelopeCodec.encode(envelope).getBytes(StandardCharsets.UTF_8))); + verify(publisher).close(); + verify(subscriber, never()).publish(any(byte[].class), any(byte[].class)); + } finally { + close(connection); + } + close(connection); + callback.get().onMessage(CHANNEL_BYTES, new byte[] {1}); + assertEquals(1, received.size()); + verify(subscriber, times(1)).close(); + verify(pool, times(1)).close(); + } + + @Test void subscribedDisconnectClosesItsClientAndResubscribesBeforeRefreshingPresence() throws Exception { + JedisPool pool = mock(JedisPool.class); + Jedis first = mock(Jedis.class); + Jedis second = mock(Jedis.class); + doAnswer(invocation -> { + BinaryJedisPubSub callback = invocation.getArgument(0); + callback.onSubscribe(CHANNEL_BYTES, 1); + throw new IllegalStateException("synthetic disconnect"); + }).when(first).subscribe(any(BinaryJedisPubSub.class), any(byte[][].class)); + var secondCallback = new AtomicReference(); + var release = new CountDownLatch(1); + prepareSubscriber(second, secondCallback, release); + var opened = new AtomicInteger(); + var refreshed = new CountDownLatch(2); + Object connection = create(pool, () -> opened.getAndIncrement() == 0 ? first : second, refreshed::countDown); + try { + subscribe(connection, (channel, payload) -> { }); + assertTrue(refreshed.await(4, TimeUnit.SECONDS)); + assertEquals(2, opened.get()); + assertNotNull(secondCallback.get()); + verify(first, times(1)).close(); + } finally { + close(connection); + } + verify(second, times(1)).close(); + verify(pool, times(1)).close(); + } + + @Test void shutdownOwnsLateSubscriberWithoutSubscribingOrClosingItTwice() throws Exception { + JedisPool pool = mock(JedisPool.class); + Jedis late = mock(Jedis.class); + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + var closed = new CountDownLatch(1); + doAnswer(invocation -> { closed.countDown(); return null; }).when(late).close(); + Object connection = create(pool, () -> { + entered.countDown(); + boolean interrupted = false; + for (;;) { + try { release.await(); break; } + catch (InterruptedException expected) { interrupted = true; } + } + if (interrupted) Thread.currentThread().interrupt(); + return late; + }, () -> fail("A retired subscriber must not refresh presence")); + try (var caller = Executors.newSingleThreadExecutor()) { + var startup = caller.submit(() -> subscribe(connection, (channel, payload) -> { })); + assertTrue(entered.await(3, TimeUnit.SECONDS)); + try { + close(connection); + var failed = assertThrows(java.util.concurrent.ExecutionException.class, + () -> startup.get(3, TimeUnit.SECONDS)); + assertInstanceOf(IllegalStateException.class, failed.getCause()); + } finally { + release.countDown(); + } + assertTrue(closed.await(3, TimeUnit.SECONDS)); + Thread listener = (Thread) ReflectiveAccess.field(OWNER, connection, "listener"); + listener.join(3000); + assertFalse(listener.isAlive()); + } finally { + release.countDown(); + close(connection); + } + verify(late, never()).subscribe(any(BinaryJedisPubSub.class), any(byte[][].class)); + verify(late, times(1)).close(); + verify(pool, times(1)).close(); + } + + @Test void poolCloseFailureStillInterruptsAndJoinsTheOwnedListener() throws Exception { + JedisPool pool = mock(JedisPool.class); + doThrow(new IllegalStateException("synthetic pool close failure")).when(pool).close(); + Jedis subscriber = mock(Jedis.class); + var interrupted = new CountDownLatch(1); + doAnswer(invocation -> { + BinaryJedisPubSub callback = invocation.getArgument(0); + callback.onSubscribe(CHANNEL_BYTES, 1); + try { new CountDownLatch(1).await(); } + catch (InterruptedException shutdown) { interrupted.countDown(); } + return null; + }).when(subscriber).subscribe(any(BinaryJedisPubSub.class), any(byte[][].class)); + Object connection = create(pool, () -> subscriber, () -> { }); + subscribe(connection, (channel, payload) -> { }); + assertThrows(IllegalStateException.class, () -> close(connection)); + assertEquals(0, interrupted.getCount()); + assertFalse(((Thread) ReflectiveAccess.field(OWNER, connection, "listener")).isAlive()); + close(connection); + verify(subscriber, times(1)).close(); + verify(pool, times(1)).close(); + } + + @Test void tlsCredentialsDatabaseAndTimeoutsReachTheActualJedisClientConfiguration() { + Object settings = ReflectiveAccess.construct( + "com.bencodez.votingplugin.neoforge.NeoForgeProxyTransportConfiguration$Redis", + new String[] {"String", "int", "String", "String", "int", "boolean", "String"}, + new Object[] {"redis.example", 6380, "fixture-user", " synthetic-only ", 3, true, "fixture/"}); + var config = (DefaultJedisClientConfig) ReflectiveAccess.call(OWNER, null, "clientConfig", + new String[] {"Redis"}, new Object[] {settings}); + assertTrue(config.isSsl()); + assertEquals("HTTPS", config.getSslParameters().getEndpointIdentificationAlgorithm()); + assertEquals("fixture-user", config.getUser()); + assertEquals(" synthetic-only ", config.getPassword()); + assertEquals(3, config.getDatabase()); + assertEquals(2000, config.getConnectionTimeoutMillis()); + assertEquals(2000, config.getSocketTimeoutMillis()); + assertEquals(0, config.getBlockingSocketTimeoutMillis()); + } + + private static void prepareSubscriber(Jedis subscriber, AtomicReference reference, + CountDownLatch release) { + doAnswer(invocation -> { + BinaryJedisPubSub callback = invocation.getArgument(0); + byte[][] channels = (byte[][]) invocation.getRawArguments()[1]; + assertEquals(1, channels.length); + assertTrue(Arrays.equals(CHANNEL_BYTES, channels[0])); + reference.set(callback); + callback.onSubscribe(CHANNEL_BYTES, 1); + release.await(5, TimeUnit.SECONDS); + return null; + }).when(subscriber).subscribe(any(BinaryJedisPubSub.class), any(byte[][].class)); + doAnswer(invocation -> { release.countDown(); return null; }).when(subscriber).close(); + } + + private static Object create(JedisPool pool, Supplier subscribers, Runnable reconnected) { + return ReflectiveAccess.construct(OWNER, new String[] {"JedisPool", "Supplier", "Runnable"}, + new Object[] {pool, subscribers, reconnected}); + } + private static void subscribe(Object connection, BiConsumer receiver) { + call(connection, "subscribe", new String[] {"String", "BiConsumer"}, CHANNEL, receiver); + } + private static void close(Object connection) { call(connection, "close", new String[0]); } + private static Object call(Object receiver, String method, String[] signature, Object... values) { + return ReflectiveAccess.call(OWNER, receiver, method, signature, values); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeRedisWireTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeRedisWireTest.java new file mode 100644 index 0000000000..aa61ec3051 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeRedisWireTest.java @@ -0,0 +1,186 @@ +package com.bencodez.votingplugin.tests.neoforge; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.BufferedInputStream; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec; +import com.bencodez.votingplugin.tests.support.ReflectiveAccess; + +/** A bounded in-process RESP peer, not acceptance against an installed Redis broker. */ +class NeoForgeRedisWireTest { + private static final String OWNER = "com.bencodez.votingplugin.neoforge.NeoForgeRedisConnection"; + + @Test void actualJedisClientSubscribesPublishesUtf8AndClosesItsBlockedSocket() throws Exception { + try (var peer = new RespPeer()) { + Object settings = ReflectiveAccess.construct( + "com.bencodez.votingplugin.neoforge.NeoForgeProxyTransportConfiguration$Redis", + new String[] {"String", "int", "String", "String", "int", "boolean", "String"}, + new Object[] {"127.0.0.1", peer.port(), "", "", 0, false, "fixture/"}); + Object connection = ReflectiveAccess.construct(OWNER, new String[] {"Redis", "Runnable"}, + new Object[] {settings, (Runnable) () -> { }}); + String channel = "fixture/VotingPlugin_backend-test"; + var received = new CompletableFuture(); + JsonEnvelope envelope = JsonEnvelope.builder("fixture").put("name", "雪\r\nline").build(); + byte[] expected = JsonEnvelopeCodec.encode(envelope).getBytes(StandardCharsets.UTF_8); + try { + ReflectiveAccess.call(OWNER, connection, "subscribe", new String[] {"String", "BiConsumer"}, + new Object[] {channel, (java.util.function.BiConsumer) (actual, payload) -> { + assertEquals(channel, actual); + received.complete(payload); + }}); + assertEquals(channel, peer.subscription.get(3, TimeUnit.SECONDS)); + ReflectiveAccess.call(OWNER, connection, "publish", new String[] {"String", "JsonEnvelope"}, + new Object[] {channel, envelope}); + assertArrayEquals(expected, peer.published.get(3, TimeUnit.SECONDS)); + assertArrayEquals(expected, received.get(3, TimeUnit.SECONDS)); + assertEquals(2, peer.connections.size()); + assertFalse(peer.failure.isCompletedExceptionally()); + } finally { + ReflectiveAccess.call(OWNER, connection, "close", new String[0], new Object[0]); + } + assertFalse(((Thread) ReflectiveAccess.field(OWNER, connection, "listener")).isAlive()); + } + } + + private static final class RespPeer implements AutoCloseable { + final ServerSocket listener = new ServerSocket(0, 2, InetAddress.getLoopbackAddress()); + final java.util.concurrent.ExecutorService workers = Executors.newFixedThreadPool(3); + final List connections = new CopyOnWriteArrayList<>(); + final CompletableFuture subscription = new CompletableFuture<>(); + final CompletableFuture published = new CompletableFuture<>(); + final CompletableFuture failure = new CompletableFuture<>(); + volatile OutputStream subscribed; + + RespPeer() throws IOException { + workers.submit(() -> { + try { + // Production owns exactly one subscriber and one publisher in this fixture. + for (int count = 0; count < 2; count++) { + Socket socket = listener.accept(); + socket.setSoTimeout(5000); + connections.add(socket); + workers.submit(() -> serve(socket)); + } + } catch (IOException problem) { + if (!listener.isClosed()) failPeer(problem); + } + }); + } + + int port() { return listener.getLocalPort(); } + + void serve(Socket socket) { + try (socket) { + InputStream input = new BufferedInputStream(socket.getInputStream()); + OutputStream output = socket.getOutputStream(); + for (;;) { + List args = command(input); + if (args == null) return; + String command = new String(args.getFirst(), StandardCharsets.UTF_8); + switch (command.toUpperCase(java.util.Locale.ROOT)) { + case "CLIENT", "AUTH", "SELECT" -> write(output, "+OK\r\n"); + case "SUBSCRIBE" -> { + subscribed = output; + array(output, "subscribe".getBytes(StandardCharsets.UTF_8), args.get(1), 1); + subscription.complete(new String(args.get(1), StandardCharsets.UTF_8)); + } + case "PUBLISH" -> { + published.complete(args.get(2)); + array(subscribed, "message".getBytes(StandardCharsets.UTF_8), args.get(1), args.get(2)); + write(output, ":1\r\n"); + } + default -> throw new IOException("Unexpected synthetic RESP command"); + } + } + } catch (IOException problem) { + if (!listener.isClosed()) failPeer(problem); + } catch (RuntimeException problem) { + failPeer(problem); + } + } + + void failPeer(Exception failure) { + this.failure.completeExceptionally(failure); + subscription.completeExceptionally(failure); + published.completeExceptionally(failure); + } + + static List command(InputStream input) throws IOException { + int marker = input.read(); + if (marker == -1) return null; + if (marker != '*') throw new IOException("Expected RESP array"); + int count = Integer.parseInt(line(input)); + if (count < 1 || count > 16) throw new IOException("Synthetic array bound exceeded"); + var result = new ArrayList(count); + for (int index = 0; index < count; index++) { + if (input.read() != '$') throw new IOException("Expected RESP bulk string"); + int length = Integer.parseInt(line(input)); + if (length < 0 || length > 4096) throw new IOException("Synthetic bulk bound exceeded"); + byte[] value = input.readNBytes(length); + if (value.length != length || input.read() != '\r' || input.read() != '\n') throw new EOFException(); + result.add(value); + } + return result; + } + + static String line(InputStream input) throws IOException { + StringBuilder value = new StringBuilder(); + for (int index = 0; index < 32; index++) { + int next = input.read(); + if (next == '\r') { + if (input.read() != '\n') throw new EOFException(); + return value.toString(); + } + if (next < 0) throw new EOFException(); + value.append((char) next); + } + throw new IOException("Synthetic RESP header bound exceeded"); + } + + static void array(OutputStream output, Object... values) throws IOException { + synchronized (output) { + write(output, "*" + values.length + "\r\n"); + for (Object value : values) { + if (value instanceof Integer number) write(output, ":" + number + "\r\n"); + else { + byte[] bytes = (byte[]) value; + write(output, "$" + bytes.length + "\r\n"); + output.write(bytes); + write(output, "\r\n"); + } + } + output.flush(); + } + } + + static void write(OutputStream output, String value) throws IOException { + output.write(value.getBytes(StandardCharsets.UTF_8)); + output.flush(); + } + + @Override public void close() throws Exception { + listener.close(); + for (Socket socket : connections) socket.close(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(3, TimeUnit.SECONDS)); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/packaging/PackagedArtifactTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/packaging/PackagedArtifactTest.java index 9ca7576750..0b38dcb062 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/packaging/PackagedArtifactTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/packaging/PackagedArtifactTest.java @@ -13,6 +13,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ServiceLoader; import java.util.jar.JarFile; import org.junit.jupiter.api.Test; @@ -22,6 +23,12 @@ public class PackagedArtifactTest { private static final long ARTIFACT_SIZE_TARGET_BYTES = 10L * 1024L * 1024L; + @Test + void nativeBrokerIngressLinksAgainstMinecraftProvidedGson() throws Exception { + com.bencodez.votingplugin.tests.neoforge.NeoForgePlatformGsonTest.verify( + packagedJar().toUri().toURL(), true); + } + @Test void containsOneRelocatedRuntimeWithoutUnusedHttpCrypto() throws Exception { Path artifactPath = packagedJar(); @@ -119,6 +126,42 @@ void packagedRedisClientLinksWithModuleApis() throws Exception { } } + @Test + void packagedMysqlDriverAndPrivateServiceProvidersLinkWithoutExternalJdbcLibraries() throws Exception { + String prefix = "com.bencodez.votingplugin.neoforge.mariadb.jdbc."; + try (JarFile artifact = new JarFile(packagedJar().toFile())) { + assertNotNull(artifact.getEntry(prefix.replace('.', '/') + "Driver.class")); + assertNull(artifact.getEntry("org/mariadb/jdbc/Driver.class")); + var drivers = artifact.getEntry("META-INF/services/java.sql.Driver"); + if (drivers != null) { + String providers = new String(artifact.getInputStream(drivers).readAllBytes(), StandardCharsets.UTF_8); + assertFalse(providers.contains("mariadb"), "The private driver must not claim global JDBC URLs"); + } + } + URL platformSlf4j = org.slf4j.Logger.class.getProtectionDomain().getCodeSource().getLocation(); + try (URLClassLoader loader = new URLClassLoader(new URL[] {packagedJar().toUri().toURL(), platformSlf4j}, + ClassLoader.getPlatformClassLoader())) { + assertNotNull(Class.forName(prefix + "Driver", false, loader)); + for (String name : new String[] {"AuthenticationPluginFactory", "TlsSocketPlugin", "Codec", "CredentialPlugin"}) { + Class service = Class.forName(prefix + "plugin." + name, true, loader); + var providers = ServiceLoader.load(service, loader).iterator(); + assertTrue(providers.hasNext(), "Missing relocated " + name + " providers"); + while (providers.hasNext()) assertTrue(service.isInstance(providers.next())); + } + Class settings = Class.forName( + "com.bencodez.votingplugin.neoforge.NeoForgeProxyTransportConfiguration$Mysql", true, loader); + var constructor = settings.getDeclaredConstructors()[0]; + constructor.setAccessible(true); + Object configuration = constructor.newInstance("db.example", 3306, "fixture", "", "", false, false, 1); + Class source = Class.forName("com.bencodez.votingplugin.neoforge.NeoForgeMysqlDataSource", true, loader); + var open = source.getDeclaredMethod("open", settings); + open.setAccessible(true); + try (AutoCloseable pool = (AutoCloseable) open.invoke(null, configuration)) { + assertNotNull(pool); + } + } + } + @Test void packagedJdkTlsIdentityWorksWithoutExternalCrypto(@TempDir Path directory) throws Exception { URL jar = packagedJar().toUri().toURL(); diff --git a/docs/neoforge-vote-transports.md b/docs/neoforge-vote-transports.md new file mode 100644 index 0000000000..2892474fe3 --- /dev/null +++ b/docs/neoforge-vote-transports.md @@ -0,0 +1,77 @@ +# NeoForge vote ingress + +This optional extension adds Redis, MQTT and MySQL proxy ingress +to the existing authenticated SOCKETS adapter. Direct Votifier ingress is not included. It does not add plugin messaging, +change SQLite user storage, or expand the restricted NeoForge reward subset. +Treat this feature as experimental until its composed source review, hosted +checks and assigned native-server acceptance are complete. Headless Java, +loopback and packaged-JAR tests are not a live Minecraft deployment test. + +| Method | Configuration | Authentication and delivery | +| --- | --- | --- | +| SOCKETS | Existing `BungeeSettings.yml` socket keys | Existing independent socket authentication and optional encryption; wire behavior is retained | +| REDIS | `BungeeMethod: REDIS`, `Redis` settings | SDK-compatible binary channels, captured credentials/DB/prefix, verified TLS when enabled, optional shared-envelope authentication/encryption | +| MQTT | `BungeeMethod: MQTT`, `MQTT` settings | SDK-compatible topics and payloads, captured broker/client credentials and prefix, optional shared-envelope authentication/encryption | +| MYSQL | `BungeeMethod: MYSQL`, `Config.yml` `Database` settings | Shared database credentials and exact queue source/destination routing; optional envelope encryption | + +## Proxy transports + +Keep `DataStorage: SQLITE`. Enable `UseBungeecord` in `BungeeSettings.yml`, choose +one proxy transport with `BungeeMethod`, and give `Server` its matching proxy +backend identity. The established `UseBungeecoord` spelling is also recognized; +an explicit `UseBungeecord: false` takes precedence. Inactive transport settings +are ignored. Unsupported methods fail configuration validation. + +Redis uses `Redis.Host`, `Port`, `Username`, `Password`, `Db-Index`, `SSL` and +`Prefix`. MQTT uses `MQTT.BrokerURL`, `Username`, `Password`, `Prefix` and optional +`ClientID` (defaulting to `Server`). Preserve prefixes exactly on both sides. +MQTT supports the SDK's TCP/TLS/WebSocket broker URL forms. Broker credentials +and prefixes establish a routing domain; they do not alone establish an +independent proxy identity. + +For Redis/MQTT, configure `BungeeServer.Name` to the proxy identity. Use +`SharedTransportAuthentication: REQUIRED` once all peers support it, and copy +the existing private network `secretkey.key` through an authorized private +channel. The compatibility default permits legacy unsigned broker traffic for +rolling upgrades. Shared-envelope authentication establishes network membership +and message integrity/freshness, not isolation from other holders of that shared +key. `CommunicationEncryption` is a separate optional confidentiality layer. + +MySQL uses `Database.Host`, `Port`, `Database`, `Username`, `Password`, `UseSSL`, +`PublicKeyRetrieval`, `MaxConnections` and optional `DbType` (`MYSQL` or +`MARIADB`). These are transport settings even though user storage remains SQLite. +The adapter bundles a privately relocated MariaDB Connector/J 3.5.10; no companion +server plugin or separately installed JDBC JAR is required. Enabled SSL verifies +the server certificate and hostname. Credentials are passed as opaque driver +fields rather than concatenated into URL options. TCP and certificate trust +still require the operator's normal database configuration. + +The MySQL adapter creates the existing `VotingPlugin_message_queue` schema if +needed, so its database account needs the corresponding schema/read/write +permissions. Backend `Server` names must fit that schema's 36-character fields. +Rows are read in bounded 50-row pages under the existing SDK named lock; a row +is deleted only after application acceptance. Invalid or refused rows remain +available for investigation. Full pages advance before revisiting refused rows +so one blocked player does not permanently hide later rows. Database writers +share a trust domain; a `source` column is not cryptographic per-node identity. + +## JSON compatibility + +Broker ingress uses the Gson 2.10.1 APIs supplied by Minecraft 1.21.1, with +explicit strict JSON string validation. Isolated unit and packaged-JAR checks +load that exact library separately from Maven's newer provided compile +dependency. No additional Gson library is bundled or required. + +## Durability and limits + +Every adapter feeds the existing complete-vote inbox, replay, accounting, +duplicate-recognition and ambiguous-effect quarantine rules. Unsupported rewards +stay unsupported; acknowledging durable ownership does not mean every reward +has run. Capacity exhaustion refuses new acceptance and preserves existing votes +and receipts. Invalid input, failed persistence, shutdown and retry must not +silently become successful delivery. Completion receipt limits and explicit +retirement rules remain unchanged. + +Choose one proxy delivery path for each real vote. Different occurrence IDs +are not cross-protocol deduplication. No automatic database deployment, production endpoint replacement, +native server launch, release or artifact distribution is part of this setup. From b735a85014ce548bda0be241798bf45b4982754f Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 25 Oct 2026 02:43:15 -0600 Subject: [PATCH 2/5] Protect proxy-only vote origin across shared cache recovery --- .../neoforge/NeoForgeProxyBrokerService.java | 12 +- .../neoforge/NeoForgeProxyMqttService.java | 7 +- .../neoforge/NeoForgeProxyMysqlService.java | 16 +- .../neoforge/NeoForgeProxyRedisService.java | 7 +- .../NeoForgeProxyTransportConfiguration.java | 19 +- .../proxy/VoteTotalsSnapshot.java | 22 +- .../votingplugin/proxy/VotingPluginProxy.java | 236 +++++++- .../proxy/cache/VoteCacheHandler.java | 39 +- .../ProxyOriginEnvelopeSignature.java | 201 +++++++ .../src/main/resources/BungeeSettings.yml | 4 + .../tests/CachedVoteBatchDurabilityTest.java | 45 ++ .../tests/VoteCacheHandlerVoteIdTest.java | 167 ++++++ .../tests/VoteTotalsSnapshotStorageTest.java | 18 + .../NeoForgeProxyMqttServiceTest.java | 65 ++- .../NeoForgeProxyMysqlServiceTest.java | 121 +++- .../neoforge/NeoForgeProxyOriginFixture.java | 32 ++ .../NeoForgeProxyRedisServiceTest.java | 65 ++- ...oForgeProxyTransportConfigurationTest.java | 16 +- .../ProxyOriginEnvelopeSignatureTest.java | 104 ++++ .../security/ProxyOriginProducerTest.java | 543 ++++++++++++++++++ docs/neoforge-vote-transports.md | 86 ++- 21 files changed, 1748 insertions(+), 77 deletions(-) create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/security/ProxyOriginEnvelopeSignature.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyOriginFixture.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/proxy/security/ProxyOriginEnvelopeSignatureTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/proxy/security/ProxyOriginProducerTest.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyBrokerService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyBrokerService.java index 83e7793003..e45333bc48 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyBrokerService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyBrokerService.java @@ -14,6 +14,7 @@ import com.bencodez.votingplugin.core.vote.SharedVoteIdentity; import com.bencodez.votingplugin.proxy.VotingPluginWire; import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator; +import com.bencodez.votingplugin.proxy.security.ProxyOriginEnvelopeSignature; import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Domain; import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode; import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption; @@ -33,6 +34,7 @@ interface ConnectionFactory { Connection open() throws Exception; } private final NeoForgeProxyTransportConfiguration configuration; private final SharedTransportEnvelopeAuthenticator authenticator; private final TransportEnvelopeEncryption encryption; + private final ProxyOriginEnvelopeSignature origin; private final ConnectionFactory factory; private final Domain domain; private final String inboundTopic; @@ -47,7 +49,7 @@ interface ConnectionFactory { Connection open() throws Exception; } NeoForgeProxyBrokerService(NeoForgeProxyTransportConfiguration configuration, NeoForgeVoteProcessor processor, NeoForgePlayerDirectory players, SharedTransportEnvelopeAuthenticator authenticator, - TransportEnvelopeEncryption encryption, ConnectionFactory factory, boolean scheduleHeartbeat) { + TransportEnvelopeEncryption encryption, ProxyOriginEnvelopeSignature origin, ConnectionFactory factory, boolean scheduleHeartbeat) { this.configuration = Objects.requireNonNull(configuration, "configuration"); if (configuration.method() != NeoForgeProxyTransportConfiguration.Method.MQTT && configuration.method() != NeoForgeProxyTransportConfiguration.Method.REDIS) { @@ -55,6 +57,7 @@ interface ConnectionFactory { Connection open() throws Exception; } } this.authenticator = Objects.requireNonNull(authenticator, "authenticator"); this.encryption = Objects.requireNonNull(encryption, "encryption"); + this.origin = Objects.requireNonNull(origin, "origin"); this.factory = Objects.requireNonNull(factory, "factory"); boolean redis = configuration.method() == NeoForgeProxyTransportConfiguration.Method.REDIS; domain = redis ? Domain.REDIS_PROXY_BACKEND : Domain.MQTT_PROXY_BACKEND; @@ -118,7 +121,12 @@ void receive(String topic, byte[] payload) { warnInvalid(); return; } - var decrypted = encryption.decrypt(verified.envelope()); + var verifiedOrigin = origin.verify(verified.envelope(), + domain == Domain.REDIS_PROXY_BACKEND ? ProxyOriginEnvelopeSignature.Domain.REDIS + : ProxyOriginEnvelopeSignature.Domain.MQTT, + configuration.proxyName(), configuration.server()); + if (verifiedOrigin == null) { warnInvalid(); return; } + var decrypted = encryption.decrypt(verifiedOrigin); if (!decrypted.accepted()) { warnInvalid(); return; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyMqttService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyMqttService.java index fe64c13c26..5c4cb302c0 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyMqttService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyMqttService.java @@ -9,6 +9,7 @@ import com.bencodez.simpleapi.servercomm.mqtt.MqttServerComm; import com.bencodez.votingplugin.core.vote.SharedVoteIdentity; import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator; +import com.bencodez.votingplugin.proxy.security.ProxyOriginEnvelopeSignature; import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption; /** MQTT network work and reconnects stay outside the native server event thread. */ @@ -26,7 +27,7 @@ static NeoForgeProxyMqttService start(Path directory, NeoForgeProxyTransportConf var encryption = TransportEnvelopeEncryption.load(directory.resolve("secretkey.key"), TransportEnvelopeEncryption.Domain.PROXY_BACKEND, configuration.communicationEncryption()); var settings = configuration.mqtt(); - return new NeoForgeProxyMqttService(configuration, processor, players, authenticator, encryption, + return new NeoForgeProxyMqttService(configuration, processor, players, authenticator, encryption, configuration.loadOriginVerifier(directory), () -> { MqttConnectOptions options = new MqttConnectOptions(); options.setCleanSession(true); @@ -51,13 +52,13 @@ static NeoForgeProxyMqttService start(Path directory, NeoForgeProxyTransportConf NeoForgeProxyMqttService(NeoForgeProxyTransportConfiguration configuration, NeoForgeVoteProcessor processor, NeoForgePlayerDirectory players, SharedTransportEnvelopeAuthenticator authenticator, - TransportEnvelopeEncryption encryption, ConnectionFactory factory, boolean scheduleHeartbeat) { + TransportEnvelopeEncryption encryption, ProxyOriginEnvelopeSignature origin, ConnectionFactory factory, boolean scheduleHeartbeat) { Objects.requireNonNull(configuration, "configuration"); if (configuration.method() != NeoForgeProxyTransportConfiguration.Method.MQTT) { throw new IllegalArgumentException("MQTT settings required"); } broker = new NeoForgeProxyBrokerService(configuration, processor, players, - authenticator, encryption, factory, scheduleHeartbeat); + authenticator, encryption, origin, factory, scheduleHeartbeat); } void receive(String topic, byte[] payload) { broker.receive(topic, payload); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyMysqlService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyMysqlService.java index 1ddb0ab4eb..2734d3dd86 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyMysqlService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyMysqlService.java @@ -12,9 +12,11 @@ import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; import com.bencodez.votingplugin.core.vote.SharedVoteIdentity; import com.bencodez.votingplugin.proxy.VotingPluginWire; +import com.bencodez.votingplugin.proxy.security.ProxyOriginEnvelopeSignature; +import com.bencodez.votingplugin.proxy.security.ProxyOriginEnvelopeSignature.Domain; import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption; -/** SQL credentials establish the shared database trust domain, not per-node cryptographic identity. */ +/** SQL routing remains untrusted until a pinned proxy-origin signature is verified. */ final class NeoForgeProxyMysqlService implements NeoForgeProxyTransport { interface Receiver { boolean receive(String source, String destination, byte[] payload); } interface Queue extends AutoCloseable { @@ -26,6 +28,8 @@ interface QueueFactory { Queue open() throws Exception; } private static final Logger LOGGER = Logger.getLogger(NeoForgeProxyMysqlService.class.getName()); private final String server; + private final String proxyName; + private final ProxyOriginEnvelopeSignature origin; private final TransportEnvelopeEncryption encryption; private final QueueFactory factory; private final NeoForgeProxyMessageService messages; @@ -42,7 +46,7 @@ static NeoForgeProxyMysqlService start(Path directory, NeoForgeProxyTransportCon NeoForgeVoteProcessor processor, NeoForgePlayerDirectory players) throws IOException { var encryption = TransportEnvelopeEncryption.load(directory.resolve("secretkey.key"), TransportEnvelopeEncryption.Domain.PROXY_BACKEND, configuration.communicationEncryption()); - return new NeoForgeProxyMysqlService(configuration, processor, players, encryption, + return new NeoForgeProxyMysqlService(configuration, processor, players, configuration.loadOriginVerifier(directory), encryption, () -> { var source = NeoForgeMysqlDataSource.open(configuration.mysql()); try { return new NeoForgeMysqlQueue(source, source, configuration.server()); } @@ -51,12 +55,14 @@ static NeoForgeProxyMysqlService start(Path directory, NeoForgeProxyTransportCon } NeoForgeProxyMysqlService(NeoForgeProxyTransportConfiguration configuration, NeoForgeVoteProcessor processor, - NeoForgePlayerDirectory players, TransportEnvelopeEncryption encryption, QueueFactory factory, + NeoForgePlayerDirectory players, ProxyOriginEnvelopeSignature origin, TransportEnvelopeEncryption encryption, QueueFactory factory, boolean scheduleHeartbeat) { if (configuration.method() != NeoForgeProxyTransportConfiguration.Method.MYSQL) { throw new IllegalArgumentException("MySQL transport settings required"); } server = configuration.server(); + proxyName = configuration.proxyName(); + this.origin = Objects.requireNonNull(origin, "origin"); this.encryption = Objects.requireNonNull(encryption, "encryption"); this.factory = Objects.requireNonNull(factory, "factory"); worker = new ScheduledThreadPoolExecutor(1, task -> { @@ -117,7 +123,9 @@ private void poll() { boolean receive(String source, String destination, byte[] payload) { if (!open.get() || !"proxy".equals(source) || !server.equals(destination)) return false; try { - var decrypted = encryption.decrypt(NeoForgeProxyEnvelopeCodec.decode(payload)); + var verified = origin.verify(NeoForgeProxyEnvelopeCodec.decode(payload), Domain.MYSQL, proxyName, server); + if (verified == null) return false; + var decrypted = encryption.decrypt(verified); if (!decrypted.accepted()) return false; return messages.receive(decrypted.envelope()); } catch (IOException | RuntimeException failure) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyRedisService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyRedisService.java index 45d1767c6f..613e0e8800 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyRedisService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyRedisService.java @@ -7,6 +7,7 @@ import com.bencodez.votingplugin.core.vote.SharedVoteIdentity; import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator; +import com.bencodez.votingplugin.proxy.security.ProxyOriginEnvelopeSignature; import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption; /** Redis adapter for the existing proxy/backend channel and signed envelope protocol. */ @@ -24,7 +25,7 @@ static NeoForgeProxyRedisService start(Path directory, NeoForgeProxyTransportCon var encryption = TransportEnvelopeEncryption.load(directory.resolve("secretkey.key"), TransportEnvelopeEncryption.Domain.PROXY_BACKEND, configuration.communicationEncryption()); AtomicReference reconnected = new AtomicReference<>(() -> { }); - var service = new NeoForgeProxyRedisService(configuration, processor, players, authenticator, encryption, + var service = new NeoForgeProxyRedisService(configuration, processor, players, authenticator, encryption, configuration.loadOriginVerifier(directory), () -> new NeoForgeRedisConnection(configuration.redis(), () -> reconnected.get().run()), true); reconnected.set(service.broker::transportReady); return service; @@ -32,13 +33,13 @@ static NeoForgeProxyRedisService start(Path directory, NeoForgeProxyTransportCon NeoForgeProxyRedisService(NeoForgeProxyTransportConfiguration configuration, NeoForgeVoteProcessor processor, NeoForgePlayerDirectory players, SharedTransportEnvelopeAuthenticator authenticator, - TransportEnvelopeEncryption encryption, ConnectionFactory factory, boolean scheduleHeartbeat) { + TransportEnvelopeEncryption encryption, ProxyOriginEnvelopeSignature origin, ConnectionFactory factory, boolean scheduleHeartbeat) { Objects.requireNonNull(configuration, "configuration"); if (configuration.method() != NeoForgeProxyTransportConfiguration.Method.REDIS) { throw new IllegalArgumentException("Redis settings required"); } broker = new NeoForgeProxyBrokerService(configuration, processor, players, - authenticator, encryption, factory, scheduleHeartbeat); + authenticator, encryption, origin, factory, scheduleHeartbeat); } void receive(String channel, byte[] payload) { broker.receive(channel, payload); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyTransportConfiguration.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyTransportConfiguration.java index 3b4bbb226d..642040ff83 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyTransportConfiguration.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeProxyTransportConfiguration.java @@ -11,7 +11,7 @@ import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode; /** Captured transport settings; loading this view never opens a connection or changes user storage. */ -record NeoForgeProxyTransportConfiguration(Method method, String server, String proxyName, +record NeoForgeProxyTransportConfiguration(Method method, String server, String proxyName, String proxyVerificationKeyFile, Mode sharedAuthentication, boolean communicationEncryption, boolean debug, NeoForgeProxySocketConfiguration socket, Redis redis, Mqtt mqtt, Mysql mysql) { enum Method { DISABLED, SOCKETS, REDIS, MQTT, MYSQL } @@ -35,7 +35,7 @@ static NeoForgeProxyTransportConfiguration load(ConfigurationNode bungee, Config Objects.requireNonNull(config, "config"); // Preserve the existing selector and its historical misspelling. if (!bungee.node("UseBungeecord").getBoolean(bungee.node("UseBungeecoord").getBoolean(false))) { - return new NeoForgeProxyTransportConfiguration(Method.DISABLED, "", "", Mode.REQUIRED, + return new NeoForgeProxyTransportConfiguration(Method.DISABLED, "", "", "", Mode.REQUIRED, false, false, null, null, null, null); } Method method; @@ -48,11 +48,12 @@ static NeoForgeProxyTransportConfiguration load(ConfigurationNode bungee, Config if (method == Method.DISABLED) throw invalid("BungeeMethod must select an enabled transport"); if (method == Method.SOCKETS) { var socket = NeoForgeProxySocketConfiguration.load(bungee); - return new NeoForgeProxyTransportConfiguration(method, socket.server(), socket.proxyName(), + return new NeoForgeProxyTransportConfiguration(method, socket.server(), socket.proxyName(), "", Mode.REQUIRED, socket.communicationEncryption(), socket.debug(), socket, null, null, null); } String server = identity(bungee.node("Server"), "Server"); - String proxy = method == Method.MYSQL ? "" : identity(bungee.node("BungeeServer", "Name"), "BungeeServer.Name"); + String proxy = identity(bungee.node("BungeeServer", "Name"), "BungeeServer.Name"); + String verificationKey = required(bungee.node("ProxyVerificationKeyFile"), "ProxyVerificationKeyFile", 256); Mode authentication = Mode.REQUIRED; if (method == Method.REDIS || method == Method.MQTT) { authentication = Mode.parse(text(bungee.node("SharedTransportAuthentication"), "COMPATIBILITY", 32)); @@ -98,10 +99,18 @@ username, credential(node.node("Password")), bool(node.node("UseSSL"), false), } default -> throw invalid("Unsupported proxy transport"); } - return new NeoForgeProxyTransportConfiguration(method, server, proxy, authentication, + return new NeoForgeProxyTransportConfiguration(method, server, proxy, verificationKey, authentication, encryption, debug, null, redis, mqtt, mysql); } + com.bencodez.votingplugin.proxy.security.ProxyOriginEnvelopeSignature loadOriginVerifier(java.nio.file.Path directory) + throws java.io.IOException { + java.nio.file.Path root = directory.toAbsolutePath().normalize(); + java.nio.file.Path file = root.resolve(proxyVerificationKeyFile).normalize(); + if (!file.startsWith(root)) throw new java.io.IOException("Proxy verification key outside data directory"); + return com.bencodez.votingplugin.proxy.security.ProxyOriginEnvelopeSignature.loadVerifier(file); + } + boolean enabled() { return method != Method.DISABLED; } @Override public String toString() { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VoteTotalsSnapshot.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VoteTotalsSnapshot.java index bfbe15be51..5bb1f2572c 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VoteTotalsSnapshot.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VoteTotalsSnapshot.java @@ -17,6 +17,18 @@ public class VoteTotalsSnapshot { // Current storage version private static final int CURRENT_VERSION = 2; + private static final String LOCAL_ORIGIN = "local-proxy-origin-v1"; + /** Trusted cache provenance; shared ingress must clear it before admission. */ + @Getter + @Setter + private boolean localProxyOrigin; + @Getter + @Setter + private String localOriginProof = ""; + public void setLocalProxyOrigin(boolean value) { + localProxyOrigin = value; + if (!value) localOriginProof = ""; + } @Getter @Setter @@ -111,6 +123,13 @@ public static VoteTotalsSnapshot parseStorage(String raw) { break; } + // Old v2 readers ignore trailing fields. Missing/unknown/malformed markers + // remain unclassified; never infer authority from totals or a current config. + if ("v2".equals(t[0]) && t.length == 10 && t[9].length() <= 1024) { + out.localProxyOrigin = LOCAL_ORIGIN.equals(t[9]) || t[9].startsWith(LOCAL_ORIGIN + ":"); + if (out.localProxyOrigin && t[9].startsWith(LOCAL_ORIGIN + ":")) + out.localOriginProof = t[9].substring(LOCAL_ORIGIN.length() + 1); + } return out; } @@ -120,7 +139,8 @@ public static VoteTotalsSnapshot parseStorage(String raw) { public String toStorageString() { return "v" + CURRENT_VERSION + SEP + allTimeTotal + SEP + monthTotal + SEP + weeklyTotal + SEP + dailyTotal - + SEP + points + SEP + votePartyCurrent + SEP + votePartyRequired + SEP + dateMonthTotal; + + SEP + points + SEP + votePartyCurrent + SEP + votePartyRequired + SEP + dateMonthTotal + + (localProxyOrigin ? SEP + LOCAL_ORIGIN + (localOriginProof.isEmpty() ? "" : ":" + localOriginProof) : ""); } @Override 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 bee05e7294..676ccb6ee9 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java @@ -96,6 +96,7 @@ import com.bencodez.votingplugin.proxy.presence.PlayerPresence; import com.bencodez.votingplugin.proxy.redis.VotingPluginRedisChannels; import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator; +import com.bencodez.votingplugin.proxy.security.ProxyOriginEnvelopeSignature; import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Domain; import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode; import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption; @@ -161,6 +162,7 @@ private static final class LiveVoteRetryState { private ArrayList totalsInput; private boolean votePartyApplied; private boolean totalsApplied; + private boolean localProxyOrigin; private final Set broadcastForwardedServers = new LinkedHashSet<>(); private final Set deliveredRewardServers = new LinkedHashSet<>(); private final Map rewardStates = new LinkedHashMap<>(); @@ -261,6 +263,10 @@ public void run() { private HashMap clientHandles; private volatile Map socketAuthenticators = Map.of(); + private Map proxyOriginSigners = Map.of(); + // A shared-key ingress cannot later lend its admitted work proxy-only authority. + // Retain this history across soft reloads, including disabling the listener. + private boolean sharedMultiProxyIngressSeen; private SocketHandler socketHandler; private HttpProxyTransportServer httpTransportServer; @@ -945,6 +951,18 @@ protected synchronized boolean sendVoteEnvelopeAccepted(String server, int delay if (method == BungeeMethod.PLUGINMESSAGING) return sendPluginMessageServerNow(server, envelope); GlobalMessageProxyHandler handler = globalMessageProxyHandler; if (handler == null) return false; + boolean signedTarget; + synchronized (transportSecurityLock) { signedTarget = proxyOriginSigners.containsKey(server); } + if (signedTarget) { + // The legacy router has a void contract. Preserve the actual final + // publisher's refusal rather than retiring the only cached owner. + switch (method) { + case MYSQL: return sendMysqlEnvelopeServer(server, envelope); + case REDIS: return sendRedisEnvelopeServer(server, envelope); + case MQTT: return sendMqttEnvelopeServer(server, envelope); + default: break; + } + } handler.sendMessage(server, delay, envelope); return true; } @@ -2076,7 +2094,7 @@ public void load(IVoteCache jsonStorage, INonVotedPlayersStorage nonVotedCacheJs sharedTransportAuthenticationFailureLogged.set(false); sharedTransportCompatibilityWarningLogged.set(false); communicationEncryptionFailureLogged.set(false); - installTransportSecurity(initialAuthenticator, createCommunicationEncryption()); + installTransportSecurity(initialAuthenticator, createCommunicationEncryption(), createProxyOriginSigners(method)); warnUnsupportedDedicatedVotingProxyMode(); uuidPlayerNameCache = getProxyMySQL().getRowsUUIDNameQuery(); @@ -2735,6 +2753,13 @@ public String getServerName() { public abstract void loadTaskTimer(Runnable runnable, long delaySeconds, long repeatSeconds); public void loadMultiProxySupport() { + synchronized (transportSecurityLock) { + if (usesSharedMultiProxyIngress()) { + if (!proxyOriginSigners.isEmpty()) + throw new IllegalStateException("Proxy origin signing cannot use shared-key multi-proxy ingress"); + sharedMultiProxyIngressSeen = true; + } + } if (multiProxyHandler != null) { multiProxyHandler.close(); } @@ -2895,7 +2920,9 @@ public void setEncryptionHandler(EncryptionHandler encryptionHandler1) { @Override public void triggerVote(String player, String service, boolean realVote, boolean timeQueue, long queueTime, VoteTotalsSnapshot text, String uuid) { - vote(player, service, realVote, timeQueue, queueTime, text, uuid); + UUID id = UUID.randomUUID(); + if (vote(player, service, realVote, timeQueue, queueTime, text, uuid, null, id, + false, false, false) == QueuedVoteResult.RETRY) throw new VoteRetryException(); } @Override @@ -2954,6 +2981,12 @@ protected synchronized void receiveMultiProxyVote(String player, String service, protected synchronized void receiveMultiProxyVote(String player, String service, boolean realVote, boolean timeQueue, long queueTime, VoteTotalsSnapshot totals, String uuid, UUID wireVoteId, String origin, boolean delayValidated, boolean delayValidationKnown) { + // A shared membership key cannot assert locally admitted provenance. Clone + // the decoded totals so no later cache/overflow path retains a wire claim. + if (totals != null) { + totals = VoteTotalsSnapshot.parseStorage(totals.toStorageString()); + totals.setLocalProxyOrigin(false); + } UUID voteId = wireVoteId == null ? UUID.randomUUID() : wireVoteId; if (completedMultiProxyVotes.containsKey(voteId)) { acknowledgeCompletedMultiProxyVote(voteId, origin); @@ -3003,7 +3036,8 @@ private boolean retainForwardedVoteOverflow(String player, String service, boole return getVoteCacheHandler().addTimeVoteToCache(queued); } - private static String[] decodeForwardedQueueTotals(String encoded) { + /** Shared legacy-storage decoder for replay and cache trust-boundary normalization. */ + public static String[] decodeForwardedQueueTotals(String encoded) { if (encoded == null || !encoded.startsWith(FORWARDED_QUEUE_TOTALS_PREFIX)) { return new String[] { "true", encoded == null ? "" : encoded }; } @@ -4147,13 +4181,14 @@ private void reloadRuntime(boolean restartControlServices) { SharedTransportEnvelopeAuthenticator replacementAuthenticator = createSharedTransportAuthenticator( configuredMethod); TransportEnvelopeEncryption replacementEncryption = createCommunicationEncryption(); + Map replacementOriginSigners = createProxyOriginSigners(configuredMethod); synchronized (this) { if (method == BungeeMethod.PLUGINMESSAGING && configuredMethod != method && pluginMessageDispatcher.hasPendingEffects()) throw new IllegalStateException("Queued plugin-message controls must settle before changing transport"); method = retainHttpForPendingDeliveries(configuredMethod); } - installTransportSecurity(replacementAuthenticator, replacementEncryption); + installTransportSecurity(replacementAuthenticator, replacementEncryption, replacementOriginSigners); sharedTransportAuthenticationFailureLogged.set(false); sharedTransportCompatibilityWarningLogged.set(false); warnIfSharedTransportCompatibilityMode(replacementAuthenticator); @@ -4183,9 +4218,143 @@ private TransportEnvelopeEncryption createCommunicationEncryption() { } } + /** Capture optional per-target proxy-only keys before replacing active transport security. */ + private Map createProxyOriginSigners(BungeeMethod selected) { + if (selected != BungeeMethod.MYSQL && selected != BungeeMethod.REDIS && selected != BungeeMethod.MQTT) return Map.of(); + Map result = new HashMap<>(); + Collection servers = getConfig().getSpigotServers(); + if (servers == null) return Map.of(); + for (String server : servers) { + Map entry = getConfig().getSpigotServerConfiguration(server); + if (entry == null || !entry.containsKey("ProxySigningKeyFile")) continue; + Object setting = entry.get("ProxySigningKeyFile"); + if (!(setting instanceof String value) || value.isBlank()) + throw new IllegalArgumentException("Invalid ProxySigningKeyFile setting"); + Path root = getDataFolderPlugin().toPath().toAbsolutePath().normalize(); + Path path = root.resolve(value).normalize(); + if (!path.startsWith(root)) throw new IllegalArgumentException("Proxy signing key outside data directory"); + try { result.put(server, ProxyOriginEnvelopeSignature.loadSigner(path)); } + catch (IOException unavailable) { throw new IllegalStateException("Proxy origin signing key unavailable"); } + } + requireProxyOriginIngressPolicy(result); + return Map.copyOf(result); + } + + private boolean usesSharedMultiProxyIngress() { + return getConfig().getMultiProxySupport(); + } + + private void requireProxyOriginIngressPolicy(Map signers) { + if (signers.isEmpty()) return; + requireProxyPrivateVoteCacheForSigning(); + synchronized (transportSecurityLock) { + if (sharedMultiProxyIngressSeen || usesSharedMultiProxyIngress()) + throw new IllegalStateException("Proxy origin signing cannot use shared-key multi-proxy ingress"); + } + } + + private void requireProxyPrivateVoteCacheForSigning() { + // A shared SQL row can be copied to a different target or resurrected + // after consumption. A signature over its fields cannot prove ownership. + // Check the actual immutable cache mode as well as prospective settings: + // a soft configuration edit does not replace an existing SQL owner. + VoteCacheHandler cache = voteCacheHandler; + if (getConfig().getVoteCacheUseMySQL() || cache != null && cache.usesMySQLStorage()) + throw new IllegalStateException("Proxy origin signing requires proxy-private vote cache storage"); + } + + private static JsonEnvelope localAdmissionRecord(UUID id, String player, String uuid, String service, + long time, boolean real, boolean onlineKnown, boolean online, boolean delayKnown, boolean delay, + VoteTotalsSnapshot totals) { + VoteTotalsSnapshot values = VoteTotalsSnapshot.parseStorage(totals.toStorageString()); + values.setLocalProxyOrigin(false); + return JsonEnvelope.builder("ProxyLocalAdmissionV1").put("id", id == null ? "" : id.toString()) + .put("player", player == null ? "" : player).put("uuid", uuid == null ? "" : uuid) + .put("service", service == null ? "" : service).put("time", time).put("real", real) + .put("onlineKnown", onlineKnown).put("online", online) + // Storage normalizes unknown/unvalidated into explicit false; neither authorizes bypass. + .put("delay", delayKnown && delay).put("totals", values.toStorageString()).build(); + } + + private void stampLocalOriginTotals(UUID id, String player, String uuid, String service, long time, + boolean real, boolean onlineKnown, boolean online, boolean delayKnown, boolean delay, + VoteTotalsSnapshot totals, boolean locallyAdmitted) { + totals.setLocalProxyOrigin(false); + if (!locallyAdmitted) return; + synchronized (transportSecurityLock) { + // One captured proxy-only key attests local origin for this proxy. Its + // configured target name is an identifier, never a path to load from data. + var issuer = proxyOriginSigners.entrySet().stream().sorted(Map.Entry.comparingByKey()).findFirst(); + if (issuer.isEmpty()) return; + var entry = issuer.get(); + String proof = entry.getValue().admissionProof(localAdmissionRecord(id, player, uuid, service, time, + real, onlineKnown, online, delayKnown, delay, totals), getConfig().getProxyServerName(), entry.getKey()); + totals.setLocalProxyOrigin(true); + totals.setLocalOriginProof(Base64.getUrlEncoder().withoutPadding() + .encodeToString(entry.getKey().getBytes(StandardCharsets.UTF_8)) + "." + proof); + } + } + + private boolean hasLocalOriginProof(UUID id, String player, String uuid, String service, long time, + boolean real, boolean onlineKnown, boolean online, boolean delayKnown, boolean delay, + VoteTotalsSnapshot totals) { + if (id == null || !totals.isLocalProxyOrigin()) return false; + String proof = totals.getLocalOriginProof(); + int separator = proof.indexOf('.'); + if (separator <= 0 || proof.length() > 1024) return false; + try { + String issuer = new String(Base64.getUrlDecoder().decode(proof.substring(0, separator)), StandardCharsets.UTF_8); + synchronized (transportSecurityLock) { + ProxyOriginEnvelopeSignature authority = proxyOriginSigners.get(issuer); + return authority != null && authority.acceptsAdmissionProof(localAdmissionRecord(id, player, uuid, service, + time, real, onlineKnown, online, delayKnown, delay, totals), getConfig().getProxyServerName(), issuer, + proof.substring(separator + 1)); + } + } catch (IllegalArgumentException invalid) { return false; } + } + + private boolean hasLocalOriginProof(VoteTimeQueue vote) { + return hasLocalOriginProof(vote.getVoteId(), vote.getName(), vote.getUuid(), vote.getService(), vote.getTime(), + vote.isRealVote(), vote.isWasOnlineKnown(), vote.isWasOnline(), vote.isDelayValidationKnown(), + vote.isDelayValidated(), VoteTotalsSnapshot.parseStorage(vote.getTotals())); + } + + /** Check plaintext and stored admission evidence under the same lock as signing. */ + private void requireProxyOriginVoteAuthority(String server, JsonEnvelope envelope) { + if (!proxyOriginSigners.containsKey(server) + || !(VotingPluginWire.SUB_VOTE.equals(envelope.getSubChannel()) + || VotingPluginWire.SUB_VOTE_ONLINE.equals(envelope.getSubChannel()))) return; + requireProxyPrivateVoteCacheForSigning(); + var vote = VotingPluginWire.readVote(envelope); + if (!hasLocalOriginProof(vote.voteId, vote.player, vote.uuid, vote.service, vote.time, vote.realVote, + vote.wasOnlineKnown, vote.wasOnline, vote.delayValidationKnown, vote.delayValidated, + VoteTotalsSnapshot.parseStorage(vote.totals))) + throw new IllegalStateException("Retained vote lacks local proxy admission provenance"); + } + + private JsonEnvelope signProxyOrigin(JsonEnvelope envelope, ProxyOriginEnvelopeSignature.Domain domain, String server) { + synchronized (transportSecurityLock) { + requireProxyOriginVoteAuthority(server, envelope); + // Admission evidence belongs to trusted recovery, not the peer wire. + VoteTotalsSnapshot stored = VoteTotalsSnapshot.parseStorage(envelope.getFields().get(VotingPluginWire.K_TOTALS)); + if (stored.isLocalProxyOrigin()) { + stored.setLocalProxyOrigin(false); + Map fields = new HashMap<>(envelope.getFields()); + fields.put(VotingPluginWire.K_TOTALS, stored.toStorageString()); + envelope = new JsonEnvelope(envelope.getSubChannel(), envelope.getSchema(), fields); + } + JsonEnvelope prepared = encryptCommunicationEnvelope(envelope); + if (domain == ProxyOriginEnvelopeSignature.Domain.REDIS) + prepared = VotingPluginWire.withRedisDeliveryId(prepared); + ProxyOriginEnvelopeSignature signer = proxyOriginSigners.get(server); + return signer == null ? prepared : signer.sign(prepared, domain, getConfig().getProxyServerName(), server); + } + } + private void installTransportSecurity(SharedTransportEnvelopeAuthenticator authenticator, - TransportEnvelopeEncryption encryption) { + TransportEnvelopeEncryption encryption, Map originSigners) { synchronized (transportSecurityLock) { + requireProxyOriginIngressPolicy(originSigners); if (sharedTransportAuthenticator == null || authenticator == null || !sharedTransportAuthenticator.hasEquivalentInboundPolicy(authenticator)) { sharedTransportAuthenticator = authenticator; @@ -4194,6 +4363,7 @@ private void installTransportSecurity(SharedTransportEnvelopeAuthenticator authe || !communicationEncryption.hasEquivalentInboundPolicy(encryption)) { communicationEncryption = encryption; } + proxyOriginSigners = originSigners; communicationEncryptionFailureLogged.set(false); } } @@ -4553,10 +4723,11 @@ private static String safeServerName(String server) { private boolean sendMysqlEnvelopeServer(String server, JsonEnvelope envelope) { if (proxyMysqlMessenger == null) return false; try { - proxyMysqlMessenger.sendToBackend(server, encryptCommunicationEnvelope(envelope)); + proxyMysqlMessenger.sendToBackend(server, signProxyOrigin(envelope, + ProxyOriginEnvelopeSignature.Domain.MYSQL, server)); return true; - } catch (SQLException failure) { - debug(failure.getMessage()); + } catch (SQLException | RuntimeException failure) { + debug("MySQL envelope publication failed"); return false; } } @@ -5299,6 +5470,7 @@ public void validateReplacementTransportSecurity() { BungeeMethod configuredMethod = BungeeMethod.getByName(getConfig().getBungeeMethod()); if (configuredMethod == null) configuredMethod = BungeeMethod.PLUGINMESSAGING; createSharedTransportAuthenticator(configuredMethod); + createProxyOriginSigners(configuredMethod); try { TransportEnvelopeEncryption.load(getDataFolderPlugin().toPath().resolve("secretkey.key"), TransportEnvelopeEncryption.Domain.PROXY_BACKEND, getConfig().getCommunicationEncryption()); @@ -5395,8 +5567,8 @@ private boolean sendRedisEnvelopeServer(String server, JsonEnvelope envelope, bo JsonEnvelope authenticated; synchronized (transportSecurityLock) { channel = VotingPluginRedisChannels.backend(getConfig().getRedisPrefix(), server); - JsonEnvelope identified = VotingPluginWire.withRedisDeliveryId(encryptCommunicationEnvelope(envelope)); - authenticated = sharedTransportAuthenticatorLocked().sign(identified, Domain.REDIS_PROXY_BACKEND, + authenticated = sharedTransportAuthenticatorLocked().sign(signProxyOrigin(envelope, + ProxyOriginEnvelopeSignature.Domain.REDIS, server), Domain.REDIS_PROXY_BACKEND, getConfig().getProxyServerName(), channel); } long subscribers = jedis.publish(channel, JsonEnvelopeCodec.encode(authenticated)); @@ -5420,8 +5592,8 @@ public boolean sendMqttEnvelopeServer(String server, JsonEnvelope envelope) { String topic = getConfig().getMqttPrefix() + "votingplugin/servers/" + server; JsonEnvelope authenticated; synchronized (transportSecurityLock) { - authenticated = sharedTransportAuthenticatorLocked().sign(encryptCommunicationEnvelope(envelope), - Domain.MQTT_PROXY_BACKEND, getConfig().getProxyServerName(), topic); + authenticated = sharedTransportAuthenticatorLocked().sign(signProxyOrigin(envelope, + ProxyOriginEnvelopeSignature.Domain.MQTT, server), Domain.MQTT_PROXY_BACKEND, getConfig().getProxyServerName(), topic); } mqttHandler.publishEnvelope(topic, authenticated); return true; @@ -6245,6 +6417,10 @@ public synchronized boolean retainIncomingVoteForRestart(PendingIncomingVote pen public synchronized VoteTimeQueue snapshotIncomingVoteForRestart(PendingIncomingVote pending) { VoteTimeQueue queued = new VoteTimeQueue(pending.getVoteId(), pending.getPlayer(), pending.getService(), pending.getAcceptedAt()); + VoteTotalsSnapshot admitted = new VoteTotalsSnapshot(); + stampLocalOriginTotals(pending.getVoteId(), pending.getPlayer(), "", pending.getService(), + pending.getAcceptedAt(), true, false, false, false, false, admitted, true); + if (!admitted.getLocalOriginProof().isEmpty()) queued.setTotals(admitted.toStorageString()); LiveVoteRetryState retry = liveVoteRetries.get(pending.getVoteId()); if (retry != null) { queued.setUuid(retry.uuid == null ? "" : retry.uuid); @@ -6274,7 +6450,16 @@ public synchronized VoteTimeQueue snapshotIncomingVoteForRestart(PendingIncoming } queued.setMultiProxyForwardingHandled(retry.multiProxyForwardingHandled); if (retry.delayValidationKnown) queued.setDelayValidated(retry.delayValidated); - if (retry.totals != null) queued.setTotals(retry.totals.toString()); + // Finalize identity and classification before attesting the snapshot, + // including retries that failed before a totals snapshot was assigned. + { + VoteTotalsSnapshot retained = retry.totals == null ? new VoteTotalsSnapshot() + : VoteTotalsSnapshot.parseStorage(retry.totals.toStorageString()); + stampLocalOriginTotals(queued.getVoteId(), queued.getName(), queued.getUuid(), queued.getService(), + queued.getTime(), queued.isRealVote(), queued.isWasOnlineKnown(), queued.isWasOnline(), + queued.isDelayValidationKnown(), queued.isDelayValidated(), retained, retry.localProxyOrigin); + queued.setTotals(retained.toStorageString()); + } } return queued; } @@ -6341,12 +6526,19 @@ private synchronized QueuedVoteResult vote(String player, String service, boolea private synchronized QueuedVoteResult vote(String player, String service, boolean realVote, boolean timeQueue, long queueTime, VoteTotalsSnapshot text, String uuid, VoteTimeQueue queuedVote, UUID requestedVoteId) { return vote(player, service, realVote, timeQueue, queueTime, text, uuid, queuedVote, requestedVoteId, false, - false); + false, queuedVote == null); } private synchronized QueuedVoteResult vote(String player, String service, boolean realVote, boolean timeQueue, long queueTime, VoteTotalsSnapshot text, String uuid, VoteTimeQueue queuedVote, UUID requestedVoteId, boolean upstreamDelayValidated, boolean upstreamDelayValidationKnown) { + return vote(player, service, realVote, timeQueue, queueTime, text, uuid, queuedVote, requestedVoteId, + upstreamDelayValidated, upstreamDelayValidationKnown, false); + } + + private synchronized QueuedVoteResult vote(String player, String service, boolean realVote, boolean timeQueue, + long queueTime, VoteTotalsSnapshot text, String uuid, VoteTimeQueue queuedVote, UUID requestedVoteId, + boolean upstreamDelayValidated, boolean upstreamDelayValidationKnown, boolean locallyAdmitted) { try { String requestPlayer = player; if (!ServiceSiteValidator.isValid(service)) { @@ -6377,6 +6569,9 @@ private synchronized QueuedVoteResult vote(String player, String service, boolea } } LiveVoteRetryState retryState = liveVoteRetries.get(voteId); + boolean localProxyOrigin = retryState != null ? retryState.localProxyOrigin + : queuedVote != null ? hasLocalOriginProof(queuedVote) + : locallyAdmitted; if (retryState != null && !requestIdentity.equals(retryState.requestIdentity)) { throw new IllegalArgumentException("Retry ID does not match the original vote"); } @@ -6532,6 +6727,9 @@ && getGlobalDataHandler().isTimeChangedHappened()) { // replaying broadcasts that already reached a backend. if (queueForTimeChange) { VoteTotalsSnapshot projectedTotals = managesTotals ? getProjectedRolloverTotals(data, player) : text; + if (projectedTotals == null) projectedTotals = new VoteTotalsSnapshot(); + stampLocalOriginTotals(voteId, player, uuid, service, time, realVote, true, playerOnline, + authoritativeDelayValidationKnown, authoritativeDelayValidated, projectedTotals, localProxyOrigin); if (canValidateStandaloneBroadcast && proxyBroadcastDecider.usesImmediateForwarding(playerOnline)) { broadcastTargets.addAll(proxyBroadcastDecider.resolveTargets(false, null)); proxyBroadcastHandled = true; @@ -6581,6 +6779,7 @@ && getGlobalDataHandler().isTimeChangedHappened()) { if (queuedVote != null && queuedVote.isTotalsApplied()) { retryState.totals = text != null ? text : VoteTotalsSnapshot.parseStorage(queuedVote.getTotals()); } + retryState.localProxyOrigin = localProxyOrigin; retryState.player = requestPlayer; retryState.service = service; retryState.uuid = uuid; @@ -6674,6 +6873,8 @@ && getGlobalDataHandler().isTimeChangedHappened()) { debug("Setting totals " + text.toString() + ", voteId=" + voteId + " for " + player + "/" + service); + stampLocalOriginTotals(voteId, player, uuid, service, time, realVote, true, playerOnline, + authoritativeDelayValidationKnown, authoritativeDelayValidated, text, localProxyOrigin); retryState.totals = text; retryState.totalsApplied = true; getProxyMySQL().update(uuid, update); @@ -6684,7 +6885,9 @@ && getGlobalDataHandler().isTimeChangedHappened()) { if (text == null) { text = new VoteTotalsSnapshot(0, 0, 0, 0, 0, votePartyVotes, currentVotePartyVotesRequired, 0); } - retryState.totals = text; + stampLocalOriginTotals(voteId, player, uuid, service, time, realVote, true, playerOnline, + authoritativeDelayValidationKnown, authoritativeDelayValidated, text, localProxyOrigin); + retryState.totals = text; retryState.totalsApplied = true; } else { text = retryState.totals; @@ -6693,6 +6896,9 @@ && getGlobalDataHandler().isTimeChangedHappened()) { text = new VoteTotalsSnapshot(0, 0, 0, 0, 0, votePartyVotes, currentVotePartyVotesRequired, 0); } + stampLocalOriginTotals(voteId, player, uuid, service, time, realVote, true, playerOnline, + authoritativeDelayValidationKnown, authoritativeDelayValidated, text, localProxyOrigin); + VoteLogStatus voteStatus = VoteLogStatus.IMMEDIATE; boolean standaloneProxyBroadcast = canValidateStandaloneBroadcast && (proxyBroadcastHandled || proxyBroadcastDecider.usesImmediateForwarding(playerOnline)); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/VoteCacheHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/VoteCacheHandler.java index 3b27c3093d..2645f1c16b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/VoteCacheHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/VoteCacheHandler.java @@ -24,6 +24,8 @@ import com.bencodez.simpleapi.sql.mysql.MySQL; import com.bencodez.simpleapi.sql.mysql.config.MysqlConfig; import com.bencodez.votingplugin.proxy.OfflineBungeeVote; +import com.bencodez.votingplugin.proxy.VoteTotalsSnapshot; +import com.bencodez.votingplugin.proxy.VotingPluginProxy; import com.bencodez.votingplugin.timequeue.VoteTimeQueue; import com.bencodez.votingplugin.util.DurableFiles; @@ -1482,7 +1484,7 @@ public synchronized void load() { serverRows.forEach(voteRow -> { OfflineBungeeVote vote = new OfflineBungeeVote(voteRow.getVoteId(), voteRow.getPlayerName(), voteRow.getUuid(), voteRow.getService(), voteRow.getTime(), voteRow.isRealVote(), - voteRow.getText(), voteRow.isBroadcastForwarded(), voteRow.isProxyBroadcastHandled(), + withoutSharedAdmissionAuthority(voteRow.getText()), voteRow.isBroadcastForwarded(), voteRow.isProxyBroadcastHandled(), VoteTimeQueue.decodeBroadcastForwardedServers(voteRow.getBroadcastTargets()), VoteTimeQueue.decodeBroadcastForwardedServers(voteRow.getBroadcastForwardedServers()), voteRow.isRewardDelivered(), OfflineBungeeVote.decodeHttpDeliveryIds(voteRow.getHttpDeliveryIds()), @@ -1500,7 +1502,7 @@ public synchronized void load() { onlineRows.forEach(voteRow -> { OfflineBungeeVote vote = new OfflineBungeeVote(voteRow.getVoteId(), voteRow.getPlayerName(), voteRow.getUuid(), voteRow.getService(), voteRow.getTime(), voteRow.isRealVote(), - voteRow.getText(), voteRow.isBroadcastForwarded(), voteRow.isProxyBroadcastHandled(), + withoutSharedAdmissionAuthority(voteRow.getText()), voteRow.isBroadcastForwarded(), voteRow.isProxyBroadcastHandled(), VoteTimeQueue.decodeBroadcastForwardedServers(voteRow.getBroadcastTargets()), VoteTimeQueue.decodeBroadcastForwardedServers(voteRow.getBroadcastForwardedServers()), voteRow.isRewardDelivered(), OfflineBungeeVote.decodeHttpDeliveryIds(voteRow.getHttpDeliveryIds()), @@ -1521,7 +1523,7 @@ public synchronized void load() { timedVoteRow.getService(), timedVoteRow.getTime(), timedVoteRow.isProxyBroadcastHandled(), VoteTimeQueue.decodeBroadcastForwardedServers(timedVoteRow.getBroadcastTargets()), VoteTimeQueue.decodeBroadcastForwardedServers(timedVoteRow.getBroadcastForwardedServers()), - timedVoteRow.getTotals(), timedVoteRow.isProcessed(), + withoutSharedAdmissionAuthority(timedVoteRow.getTotals()), timedVoteRow.isProcessed(), timedVoteRow.isMultiProxyForwardingHandled(), timedVoteRow.getUuid(), VoteTimeQueue.decodeHttpBroadcastDeliveryIds(timedVoteRow.getHttpBroadcastDeliveryIds())); voteTimeQueue.setMultiProxyForwardingRequired(timedVoteRow.isMultiProxyForwardingRequired()); @@ -2033,7 +2035,38 @@ private int batchInteger(DataNode data, String field) { return new java.math.BigDecimal(data.get(field).asString()).intValueExact(); } + /** Shared cache data must not become local authority through an emergency-file copy. */ + private static String withoutSharedAdmissionAuthority(String stored) { + String inner = stored; + List forwardedFlags = new ArrayList<>(); + while (inner != null) { + String[] decoded = VotingPluginProxy.decodeForwardedQueueTotals(inner); + // Every supported wrapper decodes to strictly fewer bytes/characters. + // Invalid/plain values remain unchanged, matching replay's decoder. + if (decoded[1].length() >= inner.length()) break; + forwardedFlags.add(decoded[0]); + inner = decoded[1]; + } + VoteTotalsSnapshot totals = VoteTotalsSnapshot.parseStorage(inner); + if (!totals.isLocalProxyOrigin()) return stored; + totals.setLocalProxyOrigin(false); + String cleared = totals.toStorageString(); + // Preserve every legacy real/test-vote classification, including nested + // wrappers that may otherwise be unwrapped across successive retries. + for (int i = forwardedFlags.size() - 1; i >= 0; i--) { + cleared = "\u0000VP-FWD:" + (Boolean.parseBoolean(forwardedFlags.get(i)) ? "1:" : "0:") + + java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString(cleared.getBytes(StandardCharsets.UTF_8)); + } + return cleared; + } + private final boolean useMySQL; + + /** Immutable storage boundary used by proxy-only delivery authorization. */ + public boolean usesMySQLStorage() { + return useMySQL; + } private ProxyVoteCacheTable voteCacheTable; private ProxyTimedVoteCacheTable timedVoteCacheTable; private ProxyOnlineVoteCacheTable onlineVoteCacheTable; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/security/ProxyOriginEnvelopeSignature.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/security/ProxyOriginEnvelopeSignature.java new file mode 100644 index 0000000000..8be6dbd129 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/security/ProxyOriginEnvelopeSignature.java @@ -0,0 +1,201 @@ +package com.bencodez.votingplugin.proxy.security; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +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.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.Signature; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.time.Clock; +import java.util.Base64; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; + +/** Proxy-only signing authority, separate from shared broker/database membership. */ +public final class ProxyOriginEnvelopeSignature { + public enum Domain { MYSQL, REDIS, MQTT } + private static final String PREFIX = "_vpOrigin"; + private static final String VERSION = PREFIX + "Version"; + private static final String PROOF = PREFIX + "Signature"; + private static final String TIME = PREFIX + "Time"; + // Shorter than the durable inbox's seven-day released-receipt retention. + private static final long MAX_AGE_MILLIS = java.util.concurrent.TimeUnit.DAYS.toMillis(1); + private static final long MAX_FUTURE_SKEW_MILLIS = 120_000L; + private static final Set METADATA = Set.of(VERSION, TIME, PROOF); + private final PrivateKey signingKey; + private final PublicKey verificationKey; + private final Clock clock; + + private ProxyOriginEnvelopeSignature(PrivateKey signingKey, PublicKey verificationKey, Clock clock) { + this.signingKey = signingKey; + this.verificationKey = verificationKey; + this.clock = clock; + } + + /** Base64 DER PKCS8 private key; it must remain on the proxy. No key is generated or fetched. */ + public static ProxyOriginEnvelopeSignature loadSigner(Path file) throws IOException { + try { + PrivateKey key = KeyFactory.getInstance("Ed25519").generatePrivate(new PKCS8EncodedKeySpec(readKey(file))); + if (!(key instanceof java.security.interfaces.EdECPrivateKey ed) || !"Ed25519".equals(ed.getParams().getName())) + throw new GeneralSecurityException("Unsupported signing key"); + return new ProxyOriginEnvelopeSignature(key, null, Clock.systemUTC()); + } catch (GeneralSecurityException | IllegalArgumentException | IOException invalid) { + throw new IOException("Proxy origin signing key is invalid"); + } + } + + /** Base64 DER X509 public key; possessing this key grants no signing authority. */ + public static ProxyOriginEnvelopeSignature loadVerifier(Path file) throws IOException { + try { + PublicKey key = KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(readKey(file))); + if (!(key instanceof java.security.interfaces.EdECPublicKey ed) || !"Ed25519".equals(ed.getParams().getName())) + throw new GeneralSecurityException("Unsupported verification key"); + return new ProxyOriginEnvelopeSignature(null, key, Clock.systemUTC()); + } catch (GeneralSecurityException | IllegalArgumentException | IOException invalid) { + throw new IOException("Proxy origin verification key is invalid"); + } + } + + private static byte[] readKey(Path file) throws IOException { + if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Proxy origin key unavailable"); + try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { + byte[] bytes = input.readNBytes(4097); + if (bytes.length == 0 || bytes.length > 4096) throw new IOException("Proxy origin key size invalid"); + return Base64.getDecoder().decode(new String(bytes, StandardCharsets.US_ASCII).trim()); + } + } + + /** Durable local-admission evidence, separate from the fresh wire proof. */ + public String admissionProof(JsonEnvelope record, String proxy, String issuer) { + if (signingKey == null) throw new IllegalStateException("Proxy signing authority unavailable"); + validateEnvelope(record); + try { + Signature signer = Signature.getInstance("Ed25519"); + signer.initSign(signingKey); + signer.update("VotingPlugin local admission v1\0".getBytes(StandardCharsets.US_ASCII)); + signer.update(canonical(record, record.getFields(), Domain.MYSQL, proxy, issuer)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(signer.sign()); + } catch (GeneralSecurityException failure) { + throw new IllegalStateException("Proxy local admission signing failed"); + } + } + + /** Ed25519 is deterministic; compare against this captured private authority. */ + public boolean acceptsAdmissionProof(JsonEnvelope record, String proxy, String issuer, String proof) { + if (proof == null || proof.length() != 86) return false; + try { + return java.security.MessageDigest.isEqual(Base64.getUrlDecoder().decode(proof), + Base64.getUrlDecoder().decode(admissionProof(record, proxy, issuer))); + } catch (IllegalArgumentException invalid) { + return false; + } + } + + public JsonEnvelope sign(JsonEnvelope envelope, Domain domain, String proxy, String target) { + if (signingKey == null) throw new IllegalStateException("Proxy signing authority unavailable"); + validateEnvelope(envelope); + if (envelope.getFields().keySet().stream().anyMatch(key -> key.startsWith(PREFIX))) { + throw new IllegalArgumentException("Proxy origin metadata already present"); + } + var fields = new TreeMap<>(envelope.getFields()); + fields.put(VERSION, "1"); + fields.put(TIME, Long.toString(clock.millis())); + try { + Signature signer = Signature.getInstance("Ed25519"); + signer.initSign(signingKey); + signer.update(canonical(envelope, fields, domain, proxy, target)); + fields.put(PROOF, Base64.getEncoder().encodeToString(signer.sign())); + return new JsonEnvelope(envelope.getSubChannel(), envelope.getSchema(), fields); + } catch (GeneralSecurityException failure) { + throw new IllegalStateException("Proxy origin signing failed"); + } + } + + /** + * Null means rejected. SQL retries may present the same signed row within the proof lifetime; + * application vote IDs and durable receipts own deduplication, not an expiring + * signature nonce. Proof expiry is shorter than durable released-receipt retention; + * the proxy re-signs outgoing retries. Broker replay checks remain in the outer MAC. + */ + public JsonEnvelope verify(JsonEnvelope envelope, Domain domain, String proxy, String target) { + if (verificationKey == null) return null; + try { + validateEnvelope(envelope); + var fields = new TreeMap<>(envelope.getFields()); + if (!"1".equals(fields.get(VERSION)) || !fields.keySet().containsAll(METADATA) + || fields.keySet().stream().anyMatch(key -> key.startsWith(PREFIX) && !METADATA.contains(key))) return null; + String time = fields.get(TIME); + if (!time.matches("[0-9]{1,19}")) return null; + long timestamp = Long.parseLong(time); + long now = clock.millis(); + if (timestamp < now - MAX_AGE_MILLIS || timestamp > now + MAX_FUTURE_SKEW_MILLIS) return null; + String rawProof = fields.remove(PROOF); + if (rawProof.length() != 88) return null; + byte[] proof = Base64.getDecoder().decode(rawProof); + if (proof.length != 64 || !Base64.getEncoder().encodeToString(proof).equals(rawProof)) return null; + Signature verifier = Signature.getInstance("Ed25519"); + verifier.initVerify(verificationKey); + verifier.update(canonical(envelope, fields, domain, proxy, target)); + if (!verifier.verify(proof)) return null; + METADATA.forEach(fields::remove); + return new JsonEnvelope(envelope.getSubChannel(), envelope.getSchema(), fields); + } catch (GeneralSecurityException | RuntimeException invalid) { + return null; + } + } + + private static void validateEnvelope(JsonEnvelope envelope) { + if (envelope == null || envelope.getSubChannel() == null || envelope.getSubChannel().length() > 128 + || envelope.getFields().size() > 128) throw new IllegalArgumentException("Invalid proxy origin envelope"); + envelope.getFields().forEach((key, value) -> { + if (key == null || value == null || key.length() > 128 || value.length() > 256 * 1024) { + throw new IllegalArgumentException("Invalid proxy origin field"); + } + }); + } + + private static byte[] canonical(JsonEnvelope envelope, Map fields, + Domain domain, String proxy, String target) { + if (domain == null || proxy == null || !proxy.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}") + || target == null || !target.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}")) { + throw new IllegalArgumentException("Invalid proxy origin binding"); + } + try { + var bytes = new ByteArrayOutputStream(); + var output = new DataOutputStream(bytes); + text(output, "votingplugin-proxy-origin-v1"); + text(output, domain.name()); + text(output, proxy); + text(output, target); + output.writeInt(envelope.getSchema()); + text(output, envelope.getSubChannel()); + output.writeInt(fields.size()); + for (var entry : fields.entrySet()) { + text(output, entry.getKey()); + text(output, entry.getValue()); + } + return bytes.toByteArray(); + } catch (IOException impossible) { + throw new IllegalStateException(impossible); + } + } + + private static void text(DataOutputStream output, String value) throws IOException { + var encoded = StandardCharsets.UTF_8.newEncoder().encode(java.nio.CharBuffer.wrap(value)); + output.writeInt(encoded.remaining()); + byte[] bytes = new byte[encoded.remaining()]; + encoded.get(bytes); + output.write(bytes); + } +} diff --git a/VotingPlugin/src/main/resources/BungeeSettings.yml b/VotingPlugin/src/main/resources/BungeeSettings.yml index 6ef62e337e..f9af259b60 100644 --- a/VotingPlugin/src/main/resources/BungeeSettings.yml +++ b/VotingPlugin/src/main/resources/BungeeSettings.yml @@ -109,6 +109,10 @@ BungeeDebug: false # while older JARs remain. After every node has the same secretkey.key and an # upgraded JAR, set REQUIRED everywhere and reload or restart. SharedTransportAuthentication: COMPATIBILITY + +# NeoForge REDIS/MQTT/MYSQL also require an independently pinned proxy public key. +# See docs/neoforge-vote-transports.md; never copy the proxy private signing key here. +# ProxyVerificationKeyFile: proxy-origin-public.key # If true, offline rewards will be stored per server, rather than global # This shouldn't need to be set unless you get alot of cross server rewards issues diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/CachedVoteBatchDurabilityTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/CachedVoteBatchDurabilityTest.java index ce9af90a77..aee3474640 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/CachedVoteBatchDurabilityTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/CachedVoteBatchDurabilityTest.java @@ -28,6 +28,10 @@ import org.junit.jupiter.api.parallel.ExecutionMode; import com.bencodez.votingplugin.proxy.OfflineBungeeVote; +import com.bencodez.votingplugin.proxy.VoteTotalsSnapshot; +import com.bencodez.votingplugin.tests.neoforge.NeoForgeProxyOriginFixture; +import com.bencodez.votingplugin.timequeue.VoteTimeQueue; +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; import com.bencodez.votingplugin.proxy.bungee.BungeeJsonVoteCache; import com.bencodez.votingplugin.proxy.bungee.VotingPluginBungee; import com.bencodez.votingplugin.proxy.cache.IVoteCache; @@ -138,6 +142,47 @@ Stream partialReplayAndRestartRetainOriginalPositionAndDeliveryFlag }); } + @TestFactory + Stream completeCacheWrappersPreserveAdmissionEvidenceWithoutInventingIt() { + return lanes((velocity, online) -> { + var signer = NeoForgeProxyOriginFixture.create(directory); + var values = new VoteTotalsSnapshot(1, 2, 3, 4, 5, 6, 7, 8); + values.setLocalProxyOrigin(true); + String proof = signer.admissionProof(JsonEnvelope.builder("storage-fixture") + .put("content", "Unicode 雪😀\r\nserialized data").build(), "proxy-test", BACKEND); + values.setLocalOriginProof(java.util.Base64.getUrlEncoder().withoutPadding().encodeToString( + BACKEND.getBytes(java.nio.charset.StandardCharsets.UTF_8)) + "." + proof); + OfflineBungeeVote local = vote(UUID.randomUUID()), unknown = vote(UUID.randomUUID()); + local.setText(values.toStorageString()); + values.setLocalProxyOrigin(false); unknown.setText(values.toStorageString()); + var writer = handler(cache(velocity)); add(writer, online, local); add(writer, online, unknown); + assertTrue(writer.prepareCachedVoteBatch(owner(online), online, List.of(local, unknown), 2)); + var timed = new VoteTimeQueue(UUID.randomUUID(), "Alex", "fixture-site", 1234L); + timed.setTotals(local.getText()); assertTrue(writer.addTimeVoteToCache(timed)); + var restarted = handler(cache(velocity)); restarted.load(); + for (OfflineBungeeVote row : votes(restarted, online)) { + String expected = row.getVoteId().equals(local.getVoteId()) ? local.getText() : unknown.getText(); + assertEquals(expected, row.getText()); + assertEquals(row.getVoteId().equals(local.getVoteId()), + VoteTotalsSnapshot.parseStorage(row.getText()).isLocalProxyOrigin()); + } + assertEquals(2, votes(restarted, online).size()); + assertEquals(local.getText(), restarted.getTimeChangeQueue().peek().getTotals()); + // SQL is represented at the existing row boundary, not claimed as a live DB run. + var sql = sqlHandler(cache(velocity), online, List.of(local, unknown), List.of(71, 72)); sql.load(); + // SQL is shared input, even if its bytes carry a genuine old proof. + // Preserve the private-file assertions above; shared imports retain + // numeric values and occurrence IDs without retaining that authority. + assertEquals(List.of(unknown.getText(), unknown.getText()), + votes(sql, online).stream().map(OfflineBungeeVote::getText).toList()); + assertEquals(List.of(local.getVoteId(), unknown.getVoteId()), + votes(sql, online).stream().map(OfflineBungeeVote::getVoteId).toList()); + assertTrue(votes(sql, online).stream().allMatch(row -> + !VoteTotalsSnapshot.parseStorage(row.getText()).isLocalProxyOrigin() + && VoteTotalsSnapshot.parseStorage(row.getText()).getLocalOriginProof().isEmpty())); + }); + } + @TestFactory Stream newVotesBesideAPartialBatchHaveTheirOwnCompletePositions() { return lanes((velocity, online) -> { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VoteCacheHandlerVoteIdTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VoteCacheHandlerVoteIdTest.java index 597b077195..bf49ab6982 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VoteCacheHandlerVoteIdTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VoteCacheHandlerVoteIdTest.java @@ -53,6 +53,173 @@ * Regression tests for proxy vote identity across caches and delayed processing. */ public class VoteCacheHandlerVoteIdTest { + @Test + public void sqlImportsCannotLaunderAdmissionProofThroughPrivateFallback() throws Exception { + assertSqlImportsCannotLaunderAdmissionProof(0); + } + + @Test + public void legacyWrappedSqlImportsCannotLaunderAdmissionProofThroughPrivateFallback() throws Exception { + assertSqlImportsCannotLaunderAdmissionProof(1); + assertSqlImportsCannotLaunderAdmissionProof(2); + assertSqlImportsCannotLaunderAdmissionProof(8); + } + + private void assertSqlImportsCannotLaunderAdmissionProof(int wrappers) throws Exception { + Path caseDirectory = tempDir.resolve("wrappers-" + wrappers); + Files.createDirectories(caseDirectory); + var platform = mock(VotingPluginBungee.class); + when(platform.getDataFolder()).thenReturn(caseDirectory.toFile()); + var realStorage = new BungeeJsonVoteCache(platform); + var sqlOwner = newMysqlEnabledLoadHandler(realStorage); + var totals = new com.bencodez.votingplugin.proxy.VoteTotalsSnapshot(4, 5, 6, 7, 8, 9, 10, 11); + String numeric = totals.toStorageString(); + totals.setLocalProxyOrigin(true); totals.setLocalOriginProof("independently-synthetic-admission"); + String untrusted = totals.toStorageString(); + for (int i = 0; i < wrappers; i++) { + numeric = legacyForwardedTotals(numeric, i % 2 == 0); + untrusted = legacyForwardedTotals(untrusted, i % 2 == 0); + } + var serverId = UUID.randomUUID(); var onlineId = UUID.randomUUID(); var timedId = UUID.randomUUID(); + var serverRow = new ProxyVoteCacheTable.VoteRow(1, serverId.toString(), "player-uuid", "Player", + "Service", 100L, true, false, false, untrusted, false, false, "", "", false, "", "", "server"); + var onlineRow = new ProxyOnlineVoteCacheTable.VoteRow(2, onlineId.toString(), "player-uuid", "Player", + "Service", 100L, true, false, false, untrusted, false, false, "", "", false, "", ""); + var timedRow = new ProxyTimedVoteCacheTable.TimedVoteRow(3, "Player", "Service", 100L, timedId, + "player-uuid", false, "", "", untrusted, false, false, false, false, true, "", false, + "", "", "", "", false, false); + var serverTable = mock(ProxyVoteCacheTable.class); + var onlineTable = mock(ProxyOnlineVoteCacheTable.class); + var timedTable = mock(ProxyTimedVoteCacheTable.class); + when(serverTable.getAllVotesChecked()).thenReturn(List.of(serverRow)); + when(onlineTable.getAllVotesChecked()).thenReturn(List.of(onlineRow)); + when(timedTable.getAllVotesChecked()).thenReturn(List.of(timedRow)); + setPrivateField(sqlOwner, "voteCacheTable", serverTable); + setPrivateField(sqlOwner, "onlineVoteCacheTable", onlineTable); + setPrivateField(sqlOwner, "timedVoteCacheTable", timedTable); + sqlOwner.load(); + var server = sqlOwner.getVotes("server").get(0); + var online = sqlOwner.getOnlineVotes("player-uuid").get(0); + var timed = sqlOwner.getTimeChangeQueue().element(); + assertEquals(numeric, server.getText()); assertEquals(numeric, online.getText()); + assertEquals(numeric, timed.getTotals()); + assertEquals(serverId, server.getVoteId()); assertEquals(onlineId, online.getVoteId()); + assertEquals(timedId, timed.getVoteId()); assertEquals(untrusted, serverRow.getText()); + assertTrue(sqlOwner.prepareCachedVoteBatch("server", false, List.of(server), 1)); + assertTrue(sqlOwner.prepareCachedVoteBatch("player-uuid", true, List.of(online), 1)); + assertFalse(sqlOwner.updateTimeVote(timed)); // An unconfirmed SQL update retains its existing owner. + assertTrue(sqlOwner.getTimeChangeQueue().contains(timed)); + var privateOwner = newHandler(realStorage); privateOwner.load(); + assertEquals(numeric, privateOwner.getVotes("server").get(0).getText()); + assertEquals(numeric, privateOwner.getOnlineVotes("player-uuid").get(0).getText()); + assertTrue(privateOwner.addTimeVoteToCache(timed)); // A later private-owner handoff is not re-attestation. + var reloadedPrivateOwner = newHandler(realStorage); reloadedPrivateOwner.load(); + assertEquals(numeric, reloadedPrivateOwner.getTimeChangeQueue().element().getTotals()); + } + + @Test + public void legacySqlProofCannotSurviveActualEmergencyTwinAndRestart() throws Exception { + for (boolean realVote : List.of(false, true)) { + Path folder = tempDir.resolve(Boolean.toString(realVote)); + Files.createDirectories(folder); + com.bencodez.votingplugin.tests.neoforge.NeoForgeProxyOriginFixture.create(folder); + var proxy = new VotingPluginProxyTestImpl(); + proxy.setDataFolder(folder.toFile()); + when(proxy.getConfig().getSpigotServers()).thenReturn(List.of("backend-test")); + when(proxy.getConfig().getSpigotServerConfiguration("backend-test")) + .thenReturn(Map.of("ProxySigningKeyFile", "synthetic-proxy-private.key")); + when(proxy.getConfig().getProxyServerName()).thenReturn("proxy-test"); + var proxyType = com.bencodez.votingplugin.proxy.VotingPluginProxy.class; + var signersMethod = proxyType.getDeclaredMethod("createProxyOriginSigners", + com.bencodez.votingplugin.proxy.BungeeMethod.class); + signersMethod.setAccessible(true); + var signers = proxyType.getDeclaredField("proxyOriginSigners"); + signers.setAccessible(true); + signers.set(proxy, signersMethod.invoke(proxy, com.bencodez.votingplugin.proxy.BungeeMethod.MYSQL)); + var id = UUID.randomUUID(); + var totals = new com.bencodez.votingplugin.proxy.VoteTotalsSnapshot(4, 5, 6, 7, 8, 9, 10, 11); + String numeric = totals.toStorageString(); + var stamp = proxyType.getDeclaredMethod("stampLocalOriginTotals", UUID.class, String.class, + String.class, String.class, long.class, boolean.class, boolean.class, boolean.class, + boolean.class, boolean.class, com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class, boolean.class); + stamp.setAccessible(true); + stamp.invoke(proxy, id, "雪😀\r\nPlayer", "player-uuid", "Service", 100L, realVote, + false, false, false, false, totals, true); + var original = new VoteTimeQueue(id, "雪😀\r\nPlayer", "Service", 100L, false, + Set.of(), Set.of(), totals.toStorageString(), false, "player-uuid"); + original.setRealVote(realVote); + var accepts = proxyType.getDeclaredMethod("hasLocalOriginProof", VoteTimeQueue.class); + accepts.setAccessible(true); + assertTrue((boolean) accepts.invoke(proxy, original), "positive control: captured real admission proof"); + String wrapped = legacyForwardedTotals(original.getTotals(), realVote); + original.setTotals(wrapped); + var platform = mock(VotingPluginBungee.class); + when(platform.getDataFolder()).thenReturn(folder.toFile()); + var disk = new BungeeJsonVoteCache(platform); + disk.addTimedVote(0, original); + disk.save(); + var sqlOwner = newMysqlEnabledLoadHandler(disk); + var table = mock(ProxyTimedVoteCacheTable.class); + when(table.getAllVotesChecked()).thenReturn(List.of(new ProxyTimedVoteCacheTable.TimedVoteRow( + 3, original.getName(), original.getService(), 100L, id, "player-uuid", false, "", "", + wrapped, false, false, false, false, realVote, "", false, "", "", "", "", false, false))); + when(table.updateTimedVote(org.mockito.ArgumentMatchers.any())).thenReturn(true); + setPrivateField(sqlOwner, "timedVoteCacheTable", table); + sqlOwner.load(); + var loaded = sqlOwner.getTimeChangeQueue().element(); + assertTrue(sqlOwner.updateTimeVote(loaded), "confirmed SQL update also persists actual emergency twin"); + var privateOwner = newHandler(new BungeeJsonVoteCache(platform)); + privateOwner.load(); + var recovered = privateOwner.getTimeChangeQueue().element(); + String[] decoded = decodeLegacyForwardedTotals(recovered.getTotals()); + assertEquals(Boolean.toString(realVote), decoded[0]); + recovered.setTotals(decoded[1]); + recovered.setRealVote(Boolean.parseBoolean(decoded[0])); + assertEquals(numeric, recovered.getTotals()); + assertEquals(id, recovered.getVoteId()); + assertFalse((boolean) accepts.invoke(proxy, recovered), "shared bytes must not regain signing authority"); + } + } + + @Test + public void privateLegacyWrapperAndMalformedSqlValuesRetainTheirExistingMeaning() throws Exception { + String plain = "v2//4//5//6//7//8//9//10//11"; + for (String raw : List.of(plain, legacyForwardedTotals(plain, false), legacyForwardedTotals(plain, true), + "\u0000VP-FWD:2:ignored", "\u0000VP-FWD:1:!bad", "\u0000VP-FWD:1", "雪\r\n" + plain)) { + var owner = newMysqlEnabledLoadHandler(storage); + var table = mock(ProxyTimedVoteCacheTable.class); + when(table.getAllVotesChecked()).thenReturn(List.of(new ProxyTimedVoteCacheTable.TimedVoteRow( + 3, "Player", "Service", 100L, UUID.randomUUID(), "player-uuid", false, "", "", raw, + false, false, false, false, true, "", false, "", "", "", "", false, false))); + setPrivateField(owner, "timedVoteCacheTable", table); + owner.load(); + assertEquals(raw, owner.getTimeChangeQueue().element().getTotals()); + } + var platform = mock(VotingPluginBungee.class); + when(platform.getDataFolder()).thenReturn(tempDir.toFile()); + var disk = new BungeeJsonVoteCache(platform); + var totals = com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.parseStorage(plain); + totals.setLocalProxyOrigin(true); totals.setLocalOriginProof("independently-synthetic-admission"); + String trusted = legacyForwardedTotals(totals.toStorageString(), false); + var queued = new VoteTimeQueue(UUID.randomUUID(), "Player", "Service", 100L, false, + Set.of(), Set.of(), trusted, false, "player-uuid"); + assertTrue(newHandler(disk).addTimeVoteToCache(queued)); + var reloaded = newHandler(new BungeeJsonVoteCache(platform)); reloaded.load(); + assertEquals(trusted, reloaded.getTimeChangeQueue().element().getTotals(), "private ownership is not declassified"); + } + + private static String legacyForwardedTotals(String totals, boolean real) { + return "\u0000VP-FWD:" + (real ? "1:" : "0:") + java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString(totals.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + + private static String[] decodeLegacyForwardedTotals(String totals) throws Exception { + var method = com.bencodez.votingplugin.proxy.VotingPluginProxy.class + .getDeclaredMethod("decodeForwardedQueueTotals", String.class); + method.setAccessible(true); + return (String[]) method.invoke(null, totals); + } + @Test public void legacySqlRowConstructorsPreserveUnknownDelayValidation() { ProxyVoteCacheTable.VoteRow server = new ProxyVoteCacheTable.VoteRow(1, "vote", "uuid", "Player", diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VoteTotalsSnapshotStorageTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VoteTotalsSnapshotStorageTest.java index db1a5c9a62..de664d953a 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VoteTotalsSnapshotStorageTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VoteTotalsSnapshotStorageTest.java @@ -23,6 +23,24 @@ */ public class VoteTotalsSnapshotStorageTest { + @Test + public void localAdmissionProvenanceRoundTripsWithoutChangingLegacyTotals() { + VoteTotalsSnapshot local = new VoteTotalsSnapshot(1, 2, 3, 4, 5, 6, 7, 8); + String legacy = local.toStorageString(); + assertFalse(VoteTotalsSnapshot.parseStorage(legacy).isLocalProxyOrigin()); + local.setLocalProxyOrigin(true); + String stored = local.toStorageString(); + assertTrue(stored.startsWith(legacy + "//")); + VoteTotalsSnapshot recovered = VoteTotalsSnapshot.parseStorage(stored); + assertTrue(recovered.isLocalProxyOrigin()); + assertEquals(stored, recovered.toStorageString()); + recovered.setLocalProxyOrigin(false); + assertEquals(legacy, recovered.toStorageString()); + for (String malformed : new String[] {stored + "//extra", stored.replace("v2//", "v3//"), + stored.replace("local-proxy-origin-v1", "unknown"), stored + "\r\n"}) + assertFalse(VoteTotalsSnapshot.parseStorage(malformed).isLocalProxyOrigin()); + } + @Test public void projectedTotalsReplaceBroadcastPlaceholdersWithoutChangingStorage() { VoteTotalsSnapshot data = new VoteTotalsSnapshot(1234, 45, 12, 3, 9876, 8, 20, 45); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyMqttServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyMqttServiceTest.java index 7e73381110..84275bef0e 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyMqttServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyMqttServiceTest.java @@ -39,8 +39,10 @@ class NeoForgeProxyMqttServiceTest { private static final String OUTBOUND = "fixture/votingplugin/servers/proxy"; @TempDir Path directory; private SharedTransportEnvelopeAuthenticator proxy; + private com.bencodez.votingplugin.proxy.security.ProxyOriginEnvelopeSignature origin; @BeforeEach void configure() throws IOException { + origin = NeoForgeProxyOriginFixture.create(directory); byte[] independentKey = new byte[32]; new java.security.SecureRandom().nextBytes(independentKey); Files.writeString(directory.resolve("secretkey.key"), Base64.getEncoder().encodeToString(independentKey)); @@ -67,7 +69,7 @@ class NeoForgeProxyMqttServiceTest { assertTrue(connection.ready.await(3, TimeUnit.SECONDS)); connection.sent.clear(); for (int duplicate = 0; duplicate < 2; duplicate++) { - deliver(connection, proxy.sign(vote(id, player), Domain.MQTT_PROXY_BACKEND, + deliver(connection, signed(vote(id, player), Domain.MQTT_PROXY_BACKEND, "proxy-test", INBOUND)); } assertEquals(1, runtime.deferredVotes().pending(player).size()); @@ -94,10 +96,10 @@ class NeoForgeProxyMqttServiceTest { UUID player = UUID.randomUUID(); JsonEnvelope raw = vote(UUID.randomUUID(), player); deliver(connection, raw); - deliver(connection, proxy.sign(raw, Domain.REDIS_PROXY_BACKEND, "proxy-test", INBOUND)); - deliver(connection, proxy.sign(raw, Domain.MQTT_PROXY_BACKEND, "other-peer", INBOUND)); - deliver(connection, proxy.sign(raw, Domain.MQTT_PROXY_BACKEND, "proxy-test", "other/topic")); - byte[] good = encode(proxy.sign(raw, Domain.MQTT_PROXY_BACKEND, "proxy-test", INBOUND)); + deliver(connection, signed(raw, Domain.REDIS_PROXY_BACKEND, "proxy-test", INBOUND)); + deliver(connection, signed(raw, Domain.MQTT_PROXY_BACKEND, "other-peer", INBOUND)); + deliver(connection, signed(raw, Domain.MQTT_PROXY_BACKEND, "proxy-test", "other/topic")); + byte[] good = encode(signed(raw, Domain.MQTT_PROXY_BACKEND, "proxy-test", INBOUND)); connection.receiver.accept("other/topic", good); connection.receiver.accept(INBOUND, new byte[] {(byte) 0xff}); connection.receiver.accept(INBOUND, new byte[256 * 1024 + 1]); @@ -109,7 +111,7 @@ class NeoForgeProxyMqttServiceTest { assertEquals(1, connection.sent.size()); service.close(); connection.sent.clear(); - deliver(connection, proxy.sign(vote(UUID.randomUUID(), player), Domain.MQTT_PROXY_BACKEND, + deliver(connection, signed(vote(UUID.randomUUID(), player), Domain.MQTT_PROXY_BACKEND, "proxy-test", INBOUND)); assertEquals(1, runtime.deferredVotes().pending(player).size()); assertTrue(connection.sent.isEmpty()); @@ -117,6 +119,32 @@ class NeoForgeProxyMqttServiceTest { } } + @Test void sharedKeyHolderCannotMintProxyOriginOrRedirectItsSignedPayload() throws Exception { + try (NeoForgeRuntime runtime = NeoForgeRuntimeAccess.start(directory)) { + Connection connection = new Connection(); + try (NeoForgeProxyTransport service = create(runtime, connection, Mode.REQUIRED)) { + assertTrue(connection.ready.await(3, TimeUnit.SECONDS)); + connection.sent.clear(); + UUID player = UUID.randomUUID(); + JsonEnvelope raw = vote(UUID.randomUUID(), player); + var originDomain = com.bencodez.votingplugin.proxy.security.ProxyOriginEnvelopeSignature.Domain.MQTT; + for (JsonEnvelope forged : List.of(raw, + origin.sign(raw, originDomain, "proxy-test", "other-backend"), + origin.sign(raw, originDomain, "other-proxy", "backend-test"), + origin.sign(raw, com.bencodez.votingplugin.proxy.security.ProxyOriginEnvelopeSignature.Domain.MYSQL, + "proxy-test", "backend-test"))) { + // Attacker possesses the real network membership key, but not proxy signing authority. + deliver(connection, proxy.sign(forged, Domain.MQTT_PROXY_BACKEND, "proxy-test", INBOUND)); + assertTrue(runtime.deferredVotes().pending(player).isEmpty()); + assertTrue(connection.sent.isEmpty()); + } + deliver(connection, signed(raw, Domain.MQTT_PROXY_BACKEND, "proxy-test", INBOUND)); + assertEquals(1, runtime.deferredVotes().pending(player).size()); + assertEquals(1, connection.sent.size()); + } + } + } + @Test void compatibilityIsExplicitAndDoesNotAdvertiseSocketOrDelayRejectionSupport() throws Exception { try (NeoForgeRuntime runtime = NeoForgeRuntimeAccess.start(directory)) { Connection connection = new Connection(); @@ -129,7 +157,8 @@ class NeoForgeProxyMqttServiceTest { } connection.sent.clear(); UUID player = UUID.randomUUID(); - deliver(connection, vote(UUID.randomUUID(), player)); + deliver(connection, origin.sign(vote(UUID.randomUUID(), player), + com.bencodez.votingplugin.proxy.security.ProxyOriginEnvelopeSignature.Domain.MQTT, "proxy-test", "backend-test")); assertEquals(1, runtime.deferredVotes().pending(player).size()); assertEquals(1, connection.sent.size()); } @@ -143,11 +172,11 @@ class NeoForgeProxyMqttServiceTest { assertTrue(connection.ready.await(3, TimeUnit.SECONDS)); UUID player = UUID.randomUUID(); for (int index = 0; index < NeoForgeDeferredVoteStoreAccess.MAX_DEFERRED_PER_USER; index++) { - deliver(connection, proxy.sign(vote(UUID.randomUUID(), player), Domain.MQTT_PROXY_BACKEND, + deliver(connection, signed(vote(UUID.randomUUID(), player), Domain.MQTT_PROXY_BACKEND, "proxy-test", INBOUND)); } connection.sent.clear(); - deliver(connection, proxy.sign(vote(UUID.randomUUID(), player), Domain.MQTT_PROXY_BACKEND, + deliver(connection, signed(vote(UUID.randomUUID(), player), Domain.MQTT_PROXY_BACKEND, "proxy-test", INBOUND)); assertTrue(connection.sent.isEmpty()); assertEquals(NeoForgeDeferredVoteStoreAccess.MAX_DEFERRED_PER_USER, @@ -208,11 +237,11 @@ public Connection open() throws InterruptedException { connection.failPublishes = true; UUID player = UUID.randomUUID(); JsonEnvelope raw = vote(UUID.randomUUID(), player); - deliver(connection, proxy.sign(raw, Domain.MQTT_PROXY_BACKEND, "proxy-test", INBOUND)); + deliver(connection, signed(raw, Domain.MQTT_PROXY_BACKEND, "proxy-test", INBOUND)); assertEquals(1, runtime.deferredVotes().pending(player).size()); assertTrue(connection.sent.isEmpty()); connection.failPublishes = false; - deliver(connection, proxy.sign(raw, Domain.MQTT_PROXY_BACKEND, "proxy-test", INBOUND)); + deliver(connection, signed(raw, Domain.MQTT_PROXY_BACKEND, "proxy-test", INBOUND)); assertEquals(1, runtime.deferredVotes().pending(player).size()); assertEquals(1, connection.sent.size()); } @@ -221,7 +250,7 @@ public Connection open() throws InterruptedException { @Test void nativeRuntimeSelectsMqttAndClosesItsActualAdapterWithoutSocketStartup() throws Exception { Files.writeString(directory.resolve("BungeeSettings.yml"), "UseBungeecord: true\nBungeeMethod: MQTT\n" - + "Server: backend-test\nBungeeServer: {Name: proxy-test}\n"); + + "Server: backend-test\n" + NeoForgeProxyOriginFixture.settings()); Class type = ReflectiveAccess.type(OWNER); NeoForgeProxyTransport adapter = (NeoForgeProxyTransport) org.mockito.Mockito.mock(type); try (var mockedFactory = org.mockito.Mockito.mockStatic(type, invocation -> { @@ -244,18 +273,24 @@ private NeoForgeProxyTransport create(NeoForgeRuntime runtime, Connection connec Object factory) throws IOException { Path file = directory.resolve("mqtt-test-settings.yml"); Files.writeString(file, "UseBungeecord: true\nBungeeMethod: MQTT\nServer: backend-test\n" - + "BungeeServer: {Name: proxy-test}\nSharedTransportAuthentication: " + mode + + NeoForgeProxyOriginFixture.settings() + "SharedTransportAuthentication: " + mode + "\nMQTT: {BrokerURL: 'tcp://broker.example:1883', Prefix: 'fixture/'}\n"); Object config = ReflectiveAccess.call("com.bencodez.votingplugin.neoforge.NeoForgeProxyTransportConfiguration", null, "load", new String[] {"ConfigurationNode", "ConfigurationNode"}, new Object[] {YamlConfigurationLoader.builder().path(file).build().load(), runtime.config()}); return (NeoForgeProxyTransport) ReflectiveAccess.construct(OWNER, new String[] {"NeoForgeProxyTransportConfiguration", "NeoForgeVoteProcessor", "NeoForgePlayerDirectory", - "SharedTransportEnvelopeAuthenticator", "TransportEnvelopeEncryption", "ConnectionFactory", "boolean"}, + "SharedTransportEnvelopeAuthenticator", "TransportEnvelopeEncryption", "ProxyOriginEnvelopeSignature", "ConnectionFactory", "boolean"}, new Object[] {config, runtime.voteProcessor(), runtime.players(), SharedTransportEnvelopeAuthenticator.load(directory.resolve("secretkey.key"), mode), TransportEnvelopeEncryption.disabled(TransportEnvelopeEncryption.Domain.PROXY_BACKEND), - factory, false}); + NeoForgeProxyOriginFixture.verifier(directory), factory, false}); + } + + private JsonEnvelope signed(JsonEnvelope envelope, Domain domain, String sender, String recipient) { + var proof = origin.sign(envelope, com.bencodez.votingplugin.proxy.security.ProxyOriginEnvelopeSignature.Domain.MQTT, + "proxy-test", "backend-test"); + return proxy.sign(proof, domain, sender, recipient); } private static void deliver(Connection connection, JsonEnvelope envelope) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyMysqlServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyMysqlServiceTest.java index b51e390a4e..9c07c7a333 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyMysqlServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyMysqlServiceTest.java @@ -30,8 +30,10 @@ class NeoForgeProxyMysqlServiceTest { private static final String OWNER = "com.bencodez.votingplugin.neoforge.NeoForgeProxyMysqlService"; @TempDir Path directory; + private com.bencodez.votingplugin.proxy.security.ProxyOriginEnvelopeSignature origin; @BeforeEach void configure() throws IOException { + origin = NeoForgeProxyOriginFixture.create(directory); Files.writeString(directory.resolve("Config.yml"), "DataStorage: SQLITE\nAllowUnjoined: true\n" + "Database: {Host: db.example, Database: fixture, Username: fixture-user}\n"); Files.writeString(directory.resolve("VoteSites.yml"), """ @@ -69,6 +71,103 @@ class NeoForgeProxyMysqlServiceTest { } } + @Test void sqlWriterCannotForgeProxyRoleForAnotherBackend() throws Exception { + assertProxyOnlyRoleRequired(false); + } + + @Test void sharedNetworkEncryptionKeyDoesNotGrantProxyVoteOrigin() throws Exception { + assertProxyOnlyRoleRequired(true); + } + + @Test void actualProxyPublisherThroughCompleteQueuePayloadReachesDurableConsumerExactlyOnce() throws Exception { + var producer = new com.bencodez.votingplugin.tests.VotingPluginProxyTestImpl(); + producer.setDataFolder(directory.toFile()); + producer.setMethod(com.bencodez.votingplugin.proxy.BungeeMethod.MYSQL); + org.mockito.Mockito.when(producer.getConfig().getSpigotServers()).thenReturn(List.of("backend-test")); + org.mockito.Mockito.when(producer.getConfig().getSpigotServerConfiguration("backend-test")) + .thenReturn(java.util.Map.of("ProxySigningKeyFile", "synthetic-proxy-private.key")); + org.mockito.Mockito.when(producer.getConfig().getProxyServerName()).thenReturn("proxy-test"); + Class producerType = com.bencodez.votingplugin.proxy.VotingPluginProxy.class; + var signers = ReflectiveAccess.call(producerType.getName(), producer, "createProxyOriginSigners", + new String[] {"BungeeMethod"}, new Object[] {com.bencodez.votingplugin.proxy.BungeeMethod.MYSQL}); + var signerField = producerType.getDeclaredField("proxyOriginSigners"); + signerField.setAccessible(true); + signerField.set(producer, signers); + var sql = org.mockito.Mockito.mock(com.bencodez.simpleapi.servercomm.mysql.MySqlMessenger.class); + var queueField = producerType.getDeclaredField("proxyMysqlMessenger"); + queueField.setAccessible(true); + queueField.set(producer, sql); + UUID player = UUID.randomUUID(); + UUID voteId = UUID.randomUUID(); + JsonEnvelope unclassified = vote(voteId, player); + assertFalse(com.bencodez.votingplugin.tests.proxy.VotingPluginProxyAccess.sendProxyBroadcastEnvelopeNow( + producer, "backend-test", unclassified)); + org.mockito.Mockito.verifyNoInteractions(sql); + var parsed = VotingPluginWire.readVote(unclassified); + var totals = com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.parseStorage(parsed.totals); + ReflectiveAccess.call(producerType.getName(), producer, "stampLocalOriginTotals", + new String[] {"UUID", "String", "String", "String", "long", "boolean", "boolean", "boolean", + "boolean", "boolean", "VoteTotalsSnapshot", "boolean"}, + new Object[] {parsed.voteId, parsed.player, parsed.uuid, parsed.service, parsed.time, parsed.realVote, + parsed.wasOnlineKnown, parsed.wasOnline, parsed.delayValidationKnown, parsed.delayValidated, + totals, true}); + var admittedFields = new java.util.HashMap<>(unclassified.getFields()); + admittedFields.put(VotingPluginWire.K_TOTALS, totals.toStorageString()); + assertTrue(com.bencodez.votingplugin.tests.proxy.VotingPluginProxyAccess.sendProxyBroadcastEnvelopeNow( + producer, "backend-test", new JsonEnvelope(unclassified.getSubChannel(), unclassified.getSchema(), admittedFields))); + var capture = org.mockito.ArgumentCaptor.forClass(JsonEnvelope.class); + org.mockito.Mockito.verify(sql).sendToBackend(org.mockito.ArgumentMatchers.eq("backend-test"), capture.capture()); + byte[] completePayload = encodeUnsigned(capture.getValue()); + try (NeoForgeRuntime runtime = NeoForgeRuntimeAccess.start(directory)) { + Queue queue = new Queue(); + try (NeoForgeProxyTransport service = create(runtime, queue, new Factory(queue))) { + assertTrue(queue.polled.await(3, TimeUnit.SECONDS)); + queue.sent.clear(); + for (int duplicate = 0; duplicate < 2; duplicate++) { + assertTrue(receive(service, "proxy", "backend-test", completePayload)); + } + assertEquals(1, runtime.deferredVotes().pending(player).size()); + assertEquals(voteId, runtime.deferredVotes().pending(player).getFirst().voteId()); + assertEquals(2, queue.sent.size(), "retries re-ACK durable ownership without creating another vote"); + } + } + } + + private void assertProxyOnlyRoleRequired(boolean encrypted) throws Exception { + byte[] networkKey = new byte[32]; + new java.security.SecureRandom().nextBytes(networkKey); + Path keyFile = directory.resolve("synthetic-network.key"); + Files.writeString(keyFile, java.util.Base64.getEncoder().encodeToString(networkKey)); + var receiverEncryption = TransportEnvelopeEncryption.load(keyFile, + TransportEnvelopeEncryption.Domain.PROXY_BACKEND, encrypted); + // A different backend can possess the shared network key and queue-write + // access without being authorized to originate proxy votes for this node. + var peerEncryption = TransportEnvelopeEncryption.load(keyFile, + TransportEnvelopeEncryption.Domain.PROXY_BACKEND, encrypted); + try (NeoForgeRuntime runtime = NeoForgeRuntimeAccess.start(directory)) { + Queue queue = new Queue(); + try (NeoForgeProxyTransport service = create(runtime, queue, new Factory(queue), + receiverEncryption, encrypted)) { + assertTrue(queue.polled.await(3, TimeUnit.SECONDS)); + queue.sent.clear(); + UUID player = UUID.randomUUID(); + var forged = peerEncryption.encrypt(vote(UUID.randomUUID(), player)); + boolean accepted = receive(service, "proxy", "backend-test", encodeUnsigned(forged)); + assertAll("Queue metadata and shared membership must not establish proxy-only authority", + () -> assertFalse(accepted), + () -> assertTrue(runtime.deferredVotes().pending(player).isEmpty()), + () -> assertTrue(queue.sent.isEmpty())); + // Same complete encrypted wrapper is accepted only with an independently pinned proxy proof. + var valid = origin.sign(forged, + com.bencodez.votingplugin.proxy.security.ProxyOriginEnvelopeSignature.Domain.MYSQL, + "proxy-test", "backend-test"); + assertTrue(receive(service, "proxy", "backend-test", encodeUnsigned(valid))); + assertEquals(1, runtime.deferredVotes().pending(player).size()); + assertEquals(1, queue.sent.size()); + } + } + } + @Test void wrongSqlRoleDestinationSchemaMalformedAndAdjacentDataCannotAdmitVotes() throws Exception { try (NeoForgeRuntime runtime = NeoForgeRuntimeAccess.start(directory)) { Queue queue = new Queue(); @@ -184,7 +283,7 @@ public Queue open() throws InterruptedException { @Test void nativeRuntimeSelectsMysqlWithoutChangingSqliteUserStorage() throws Exception { Files.writeString(directory.resolve("BungeeSettings.yml"), - "UseBungeecord: true\nBungeeMethod: MYSQL\nServer: backend-test\n"); + "UseBungeecord: true\nBungeeMethod: MYSQL\nServer: backend-test\n" + NeoForgeProxyOriginFixture.settings()); Class type = ReflectiveAccess.type(OWNER); NeoForgeProxyTransport adapter = (NeoForgeProxyTransport) org.mockito.Mockito.mock(type); try (var mocked = org.mockito.Mockito.mockStatic(type, invocation -> { @@ -202,24 +301,33 @@ public Queue open() throws InterruptedException { } private NeoForgeProxyTransport create(NeoForgeRuntime runtime, Queue queue, Object factory) throws IOException { + return create(runtime, queue, factory, + TransportEnvelopeEncryption.disabled(TransportEnvelopeEncryption.Domain.PROXY_BACKEND), false); + } + + private NeoForgeProxyTransport create(NeoForgeRuntime runtime, Queue queue, Object factory, + TransportEnvelopeEncryption encryption, boolean encrypted) throws IOException { Path file = directory.resolve("mysql-test-settings.yml"); - Files.writeString(file, "UseBungeecord: true\nBungeeMethod: MYSQL\nServer: backend-test\n"); + Files.writeString(file, "UseBungeecord: true\nBungeeMethod: MYSQL\nServer: backend-test\n" + NeoForgeProxyOriginFixture.settings() + + "CommunicationEncryption: " + encrypted + "\n"); Object configuration = ReflectiveAccess.call( "com.bencodez.votingplugin.neoforge.NeoForgeProxyTransportConfiguration", null, "load", new String[] {"ConfigurationNode", "ConfigurationNode"}, new Object[] {YamlConfigurationLoader.builder().path(file).build().load(), runtime.config()}); return (NeoForgeProxyTransport) ReflectiveAccess.construct(OWNER, new String[] {"NeoForgeProxyTransportConfiguration", "NeoForgeVoteProcessor", "NeoForgePlayerDirectory", - "TransportEnvelopeEncryption", "QueueFactory", "boolean"}, + "ProxyOriginEnvelopeSignature", "TransportEnvelopeEncryption", "QueueFactory", "boolean"}, new Object[] {configuration, runtime.voteProcessor(), runtime.players(), - TransportEnvelopeEncryption.disabled(TransportEnvelopeEncryption.Domain.PROXY_BACKEND), factory, false}); + NeoForgeProxyOriginFixture.verifier(directory), encryption, factory, false}); } private static boolean receive(NeoForgeProxyTransport service, String source, String destination, byte[] payload) { return (boolean) ReflectiveAccess.call(OWNER, service, "receive", new String[] {"String", "String", "byte[]"}, new Object[] {source, destination, payload}); } - private static byte[] encode(JsonEnvelope envelope) { return JsonEnvelopeCodec.encode(envelope).getBytes(StandardCharsets.UTF_8); } + private byte[] encode(JsonEnvelope envelope) { return encodeUnsigned(origin.sign(envelope, + com.bencodez.votingplugin.proxy.security.ProxyOriginEnvelopeSignature.Domain.MYSQL, "proxy-test", "backend-test")); } + private static byte[] encodeUnsigned(JsonEnvelope envelope) { return JsonEnvelopeCodec.encode(envelope).getBytes(StandardCharsets.UTF_8); } private static JsonEnvelope vote(UUID id, UUID player) { return VotingPluginWire.requestVoteDeliveryAcknowledgement(VotingPluginWire.vote("Alex", player.toString(), "Service", 100L, true, true, "", id, false, false, 1, 1)); @@ -232,11 +340,12 @@ public static final class Factory { public static final class Queue { final List sent = new CopyOnWriteArrayList<>(); final CountDownLatch ready = new CountDownLatch(1); + final CountDownLatch polled = new CountDownLatch(1); final CountDownLatch closed = new CountDownLatch(1); final AtomicInteger closes = new AtomicInteger(); final AtomicInteger polls = new AtomicInteger(); volatile boolean failPublishes; - public void poll(Object receiver) { polls.incrementAndGet(); } + public void poll(Object receiver) { polls.incrementAndGet(); polled.countDown(); } public void publish(JsonEnvelope envelope) { if (failPublishes) throw new IllegalStateException("synthetic publication failure"); sent.add(envelope); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyOriginFixture.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyOriginFixture.java new file mode 100644 index 0000000000..f9326894ff --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyOriginFixture.java @@ -0,0 +1,32 @@ +package com.bencodez.votingplugin.tests.neoforge; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.KeyPairGenerator; +import java.util.Base64; + +import com.bencodez.votingplugin.proxy.security.ProxyOriginEnvelopeSignature; + +/** Independently generated synthetic proxy-only keys; no deployment values. */ +public final class NeoForgeProxyOriginFixture { + public static ProxyOriginEnvelopeSignature create(Path directory) throws IOException { + try { + var pair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + Files.writeString(directory.resolve("synthetic-proxy-private.key"), Base64.getEncoder().encodeToString(pair.getPrivate().getEncoded())); + Files.writeString(directory.resolve("synthetic-proxy-public.key"), Base64.getEncoder().encodeToString(pair.getPublic().getEncoded())); + return ProxyOriginEnvelopeSignature.loadSigner(directory.resolve("synthetic-proxy-private.key")); + } catch (GeneralSecurityException failure) { + throw new IOException("Synthetic signing fixture unavailable", failure); + } + } + + public static ProxyOriginEnvelopeSignature verifier(Path directory) throws IOException { + return ProxyOriginEnvelopeSignature.loadVerifier(directory.resolve("synthetic-proxy-public.key")); + } + + public static String settings() { + return "BungeeServer: {Name: proxy-test}\nProxyVerificationKeyFile: synthetic-proxy-public.key\n"; + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyRedisServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyRedisServiceTest.java index 7b81c9d4b6..7b1ef318c5 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyRedisServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyRedisServiceTest.java @@ -39,8 +39,10 @@ class NeoForgeProxyRedisServiceTest { private static final String OUTBOUND = "fixture/VotingPlugin"; @TempDir Path directory; private SharedTransportEnvelopeAuthenticator proxy; + private com.bencodez.votingplugin.proxy.security.ProxyOriginEnvelopeSignature origin; @BeforeEach void configure() throws IOException { + origin = NeoForgeProxyOriginFixture.create(directory); byte[] independentKey = new byte[32]; new java.security.SecureRandom().nextBytes(independentKey); Files.writeString(directory.resolve("secretkey.key"), Base64.getEncoder().encodeToString(independentKey)); @@ -67,7 +69,7 @@ class NeoForgeProxyRedisServiceTest { assertTrue(connection.ready.await(3, TimeUnit.SECONDS)); connection.sent.clear(); for (int duplicate = 0; duplicate < 2; duplicate++) { - deliver(connection, proxy.sign(vote(id, player), Domain.REDIS_PROXY_BACKEND, + deliver(connection, signed(vote(id, player), Domain.REDIS_PROXY_BACKEND, "proxy-test", INBOUND)); } assertEquals(1, runtime.deferredVotes().pending(player).size()); @@ -95,10 +97,10 @@ class NeoForgeProxyRedisServiceTest { UUID player = UUID.randomUUID(); JsonEnvelope raw = vote(UUID.randomUUID(), player); deliver(connection, raw); - deliver(connection, proxy.sign(raw, Domain.MQTT_PROXY_BACKEND, "proxy-test", INBOUND)); - deliver(connection, proxy.sign(raw, Domain.REDIS_PROXY_BACKEND, "other-peer", INBOUND)); - deliver(connection, proxy.sign(raw, Domain.REDIS_PROXY_BACKEND, "proxy-test", "other/topic")); - byte[] good = encode(proxy.sign(raw, Domain.REDIS_PROXY_BACKEND, "proxy-test", INBOUND)); + deliver(connection, signed(raw, Domain.MQTT_PROXY_BACKEND, "proxy-test", INBOUND)); + deliver(connection, signed(raw, Domain.REDIS_PROXY_BACKEND, "other-peer", INBOUND)); + deliver(connection, signed(raw, Domain.REDIS_PROXY_BACKEND, "proxy-test", "other/topic")); + byte[] good = encode(signed(raw, Domain.REDIS_PROXY_BACKEND, "proxy-test", INBOUND)); connection.receiver.accept("other/topic", good); connection.receiver.accept(INBOUND, new byte[] {(byte) 0xff}); connection.receiver.accept(INBOUND, new byte[256 * 1024 + 1]); @@ -110,7 +112,7 @@ class NeoForgeProxyRedisServiceTest { assertEquals(1, connection.sent.size()); service.close(); connection.sent.clear(); - deliver(connection, proxy.sign(vote(UUID.randomUUID(), player), Domain.REDIS_PROXY_BACKEND, + deliver(connection, signed(vote(UUID.randomUUID(), player), Domain.REDIS_PROXY_BACKEND, "proxy-test", INBOUND)); assertEquals(1, runtime.deferredVotes().pending(player).size()); assertTrue(connection.sent.isEmpty()); @@ -118,6 +120,32 @@ class NeoForgeProxyRedisServiceTest { } } + @Test void sharedKeyHolderCannotMintProxyOriginOrRedirectItsSignedPayload() throws Exception { + try (NeoForgeRuntime runtime = NeoForgeRuntimeAccess.start(directory)) { + Connection connection = new Connection(); + try (NeoForgeProxyTransport service = create(runtime, connection, Mode.REQUIRED)) { + assertTrue(connection.ready.await(3, TimeUnit.SECONDS)); + connection.sent.clear(); + UUID player = UUID.randomUUID(); + JsonEnvelope raw = vote(UUID.randomUUID(), player); + var originDomain = com.bencodez.votingplugin.proxy.security.ProxyOriginEnvelopeSignature.Domain.REDIS; + for (JsonEnvelope forged : List.of(raw, + origin.sign(raw, originDomain, "proxy-test", "other-backend"), + origin.sign(raw, originDomain, "other-proxy", "backend-test"), + origin.sign(raw, com.bencodez.votingplugin.proxy.security.ProxyOriginEnvelopeSignature.Domain.MYSQL, + "proxy-test", "backend-test"))) { + // Attacker possesses the real network membership key, but not proxy signing authority. + deliver(connection, proxy.sign(forged, Domain.REDIS_PROXY_BACKEND, "proxy-test", INBOUND)); + assertTrue(runtime.deferredVotes().pending(player).isEmpty()); + assertTrue(connection.sent.isEmpty()); + } + deliver(connection, signed(raw, Domain.REDIS_PROXY_BACKEND, "proxy-test", INBOUND)); + assertEquals(1, runtime.deferredVotes().pending(player).size()); + assertEquals(1, connection.sent.size()); + } + } + } + @Test void compatibilityIsExplicitAndDoesNotAdvertiseSocketOrDelayRejectionSupport() throws Exception { try (NeoForgeRuntime runtime = NeoForgeRuntimeAccess.start(directory)) { Connection connection = new Connection(); @@ -130,7 +158,8 @@ class NeoForgeProxyRedisServiceTest { } connection.sent.clear(); UUID player = UUID.randomUUID(); - deliver(connection, vote(UUID.randomUUID(), player)); + deliver(connection, origin.sign(vote(UUID.randomUUID(), player), + com.bencodez.votingplugin.proxy.security.ProxyOriginEnvelopeSignature.Domain.REDIS, "proxy-test", "backend-test")); assertEquals(1, runtime.deferredVotes().pending(player).size()); assertEquals(1, connection.sent.size()); } @@ -144,11 +173,11 @@ class NeoForgeProxyRedisServiceTest { assertTrue(connection.ready.await(3, TimeUnit.SECONDS)); UUID player = UUID.randomUUID(); for (int index = 0; index < NeoForgeDeferredVoteStoreAccess.MAX_DEFERRED_PER_USER; index++) { - deliver(connection, proxy.sign(vote(UUID.randomUUID(), player), Domain.REDIS_PROXY_BACKEND, + deliver(connection, signed(vote(UUID.randomUUID(), player), Domain.REDIS_PROXY_BACKEND, "proxy-test", INBOUND)); } connection.sent.clear(); - deliver(connection, proxy.sign(vote(UUID.randomUUID(), player), Domain.REDIS_PROXY_BACKEND, + deliver(connection, signed(vote(UUID.randomUUID(), player), Domain.REDIS_PROXY_BACKEND, "proxy-test", INBOUND)); assertTrue(connection.sent.isEmpty()); assertEquals(NeoForgeDeferredVoteStoreAccess.MAX_DEFERRED_PER_USER, @@ -209,11 +238,11 @@ public Connection open() throws InterruptedException { connection.failPublishes = true; UUID player = UUID.randomUUID(); JsonEnvelope raw = vote(UUID.randomUUID(), player); - deliver(connection, proxy.sign(raw, Domain.REDIS_PROXY_BACKEND, "proxy-test", INBOUND)); + deliver(connection, signed(raw, Domain.REDIS_PROXY_BACKEND, "proxy-test", INBOUND)); assertEquals(1, runtime.deferredVotes().pending(player).size()); assertTrue(connection.sent.isEmpty()); connection.failPublishes = false; - deliver(connection, proxy.sign(raw, Domain.REDIS_PROXY_BACKEND, "proxy-test", INBOUND)); + deliver(connection, signed(raw, Domain.REDIS_PROXY_BACKEND, "proxy-test", INBOUND)); assertEquals(1, runtime.deferredVotes().pending(player).size()); assertEquals(1, connection.sent.size()); } @@ -222,7 +251,7 @@ public Connection open() throws InterruptedException { @Test void nativeRuntimeSelectsRedisAndClosesItsActualAdapterWithoutSocketStartup() throws Exception { Files.writeString(directory.resolve("BungeeSettings.yml"), "UseBungeecord: true\nBungeeMethod: REDIS\n" - + "Server: backend-test\nBungeeServer: {Name: proxy-test}\nRedis: {Host: redis.example}\n"); + + "Server: backend-test\n" + NeoForgeProxyOriginFixture.settings() + "Redis: {Host: redis.example}\n"); Class type = ReflectiveAccess.type(OWNER); NeoForgeProxyTransport adapter = (NeoForgeProxyTransport) org.mockito.Mockito.mock(type); try (var mockedFactory = org.mockito.Mockito.mockStatic(type, invocation -> { @@ -245,18 +274,24 @@ private NeoForgeProxyTransport create(NeoForgeRuntime runtime, Connection connec Object factory) throws IOException { Path file = directory.resolve("redis-test-settings.yml"); Files.writeString(file, "UseBungeecord: true\nBungeeMethod: REDIS\nServer: backend-test\n" - + "BungeeServer: {Name: proxy-test}\nSharedTransportAuthentication: " + mode + + NeoForgeProxyOriginFixture.settings() + "SharedTransportAuthentication: " + mode + "\nRedis: {Host: redis.example, Prefix: 'fixture/'}\n"); Object config = ReflectiveAccess.call("com.bencodez.votingplugin.neoforge.NeoForgeProxyTransportConfiguration", null, "load", new String[] {"ConfigurationNode", "ConfigurationNode"}, new Object[] {YamlConfigurationLoader.builder().path(file).build().load(), runtime.config()}); return (NeoForgeProxyTransport) ReflectiveAccess.construct(OWNER, new String[] {"NeoForgeProxyTransportConfiguration", "NeoForgeVoteProcessor", "NeoForgePlayerDirectory", - "SharedTransportEnvelopeAuthenticator", "TransportEnvelopeEncryption", "ConnectionFactory", "boolean"}, + "SharedTransportEnvelopeAuthenticator", "TransportEnvelopeEncryption", "ProxyOriginEnvelopeSignature", "ConnectionFactory", "boolean"}, new Object[] {config, runtime.voteProcessor(), runtime.players(), SharedTransportEnvelopeAuthenticator.load(directory.resolve("secretkey.key"), mode), TransportEnvelopeEncryption.disabled(TransportEnvelopeEncryption.Domain.PROXY_BACKEND), - factory, false}); + NeoForgeProxyOriginFixture.verifier(directory), factory, false}); + } + + private JsonEnvelope signed(JsonEnvelope envelope, Domain domain, String sender, String recipient) { + var proof = origin.sign(envelope, com.bencodez.votingplugin.proxy.security.ProxyOriginEnvelopeSignature.Domain.REDIS, + "proxy-test", "backend-test"); + return proxy.sign(proof, domain, sender, recipient); } private static void deliver(Connection connection, JsonEnvelope envelope) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyTransportConfigurationTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyTransportConfigurationTest.java index cb81610ee9..292519dff3 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyTransportConfigurationTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/neoforge/NeoForgeProxyTransportConfigurationTest.java @@ -231,9 +231,23 @@ class NeoForgeProxyTransportConfigurationTest { } } + @Test void enabledSharedTransportsRequireAPinnedVerifierAndProxyIdentity() throws Exception { + for (String method : java.util.List.of("MYSQL", "REDIS", "MQTT")) { + String shared = base(method); + if (method.equals("REDIS")) shared += "Redis: {Host: redis.example}\n"; + String database = "Database: {Host: db.example, Database: fixture, Username: fixture-user}\n"; + final String noVerifier = shared.replace("ProxyVerificationKeyFile: synthetic-proxy-public.key\n", ""); + assertTrue(assertThrows(IllegalArgumentException.class, () -> load(noVerifier, database)) + .getMessage().contains("ProxyVerificationKeyFile")); + final String noProxy = shared.replace("BungeeServer: {Name: proxy-test}\n", ""); + assertTrue(assertThrows(IllegalArgumentException.class, () -> load(noProxy, database)) + .getMessage().contains("BungeeServer.Name")); + } + } + private static String base(String method) { return "UseBungeecord: true\nBungeeMethod: '" + method - + "'\nServer: backend-test\nBungeeServer: {Name: proxy-test}\n"; + + "'\nServer: backend-test\nBungeeServer: {Name: proxy-test}\nProxyVerificationKeyFile: synthetic-proxy-public.key\n"; } private Object load(String bungee, String config) throws IOException { return load(yaml(bungee), yaml(config)); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/proxy/security/ProxyOriginEnvelopeSignatureTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/proxy/security/ProxyOriginEnvelopeSignatureTest.java new file mode 100644 index 0000000000..77ac635c98 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/proxy/security/ProxyOriginEnvelopeSignatureTest.java @@ -0,0 +1,104 @@ +package com.bencodez.votingplugin.tests.proxy.security; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPairGenerator; +import java.util.Base64; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec; +import com.bencodez.votingplugin.proxy.security.ProxyOriginEnvelopeSignature; +import com.bencodez.votingplugin.proxy.security.ProxyOriginEnvelopeSignature.Domain; + +class ProxyOriginEnvelopeSignatureTest { + @TempDir Path directory; + ProxyOriginEnvelopeSignature signer; + ProxyOriginEnvelopeSignature verifier; + + @BeforeEach void keys() throws Exception { + var key = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + Files.writeString(directory.resolve("proxy-private.key"), Base64.getEncoder().encodeToString(key.getPrivate().getEncoded())); + Files.writeString(directory.resolve("proxy-public.key"), Base64.getEncoder().encodeToString(key.getPublic().getEncoded())); + signer = ProxyOriginEnvelopeSignature.loadSigner(directory.resolve("proxy-private.key")); + verifier = ProxyOriginEnvelopeSignature.loadVerifier(directory.resolve("proxy-public.key")); + } + + @Test void completeSerializedUnicodePayloadRetainsAllDataAndPermitsDurableQueueRetry() { + var raw = JsonEnvelope.builder("Vote").put("name", "雪😀\r\nα").put("data", "{\"command\":\"data only\"}").build(); + var signed = signer.sign(raw, Domain.MYSQL, "proxy", "backend"); + var decoded = JsonEnvelopeCodec.decode(JsonEnvelopeCodec.encode(signed)); + var accepted = verifier.verify(decoded, Domain.MYSQL, "proxy", "backend"); + assertNotNull(accepted); + assertEquals(raw.getFields(), accepted.getFields()); + assertNotNull(verifier.verify(decoded, Domain.MYSQL, "proxy", "backend")); + // Origin proof does not expire retained SQL work; application IDs own deduplication. + assertNotNull(verifier.verify(signer.sign(raw, Domain.MYSQL, "proxy", "backend"), Domain.MYSQL, "proxy", "backend")); + } + + @Test void publicKeyHolderWrongRoleTargetDomainAndChangedPayloadHaveNoAuthority() throws Exception { + var raw = JsonEnvelope.builder("Vote").put("voteId", "synthetic-occurrence").build(); + assertThrows(IllegalStateException.class, () -> verifier.sign(raw, Domain.MYSQL, "proxy", "backend")); + var signed = signer.sign(raw, Domain.MYSQL, "proxy", "backend"); + assertNull(verifier.verify(raw, Domain.MYSQL, "proxy", "backend")); + assertNull(verifier.verify(signed, Domain.REDIS, "proxy", "backend")); + assertNull(verifier.verify(signed, Domain.MYSQL, "other-proxy", "backend")); + assertNull(verifier.verify(signed, Domain.MYSQL, "proxy", "other-backend")); + var fields = new java.util.TreeMap<>(signed.getFields()); + fields.put("voteId", "changed"); + assertNull(verifier.verify(new JsonEnvelope(signed.getSubChannel(), signed.getSchema(), fields), Domain.MYSQL, "proxy", "backend")); + assertNull(verifier.verify(new JsonEnvelope("Other", signed.getSchema(), signed.getFields()), Domain.MYSQL, "proxy", "backend")); + assertNull(verifier.verify(new JsonEnvelope(signed.getSubChannel(), signed.getSchema() + 1, signed.getFields()), Domain.MYSQL, "proxy", "backend")); + assertNotNull(verifier.verify(signed, Domain.MYSQL, "proxy", "backend"), "invalid attempts must not poison legitimate input"); + } + + @Test void missingWrongMalformedOversizedAndPrivateVerificationKeysFailClosed() throws Exception { + assertThrows(java.io.IOException.class, () -> ProxyOriginEnvelopeSignature.loadVerifier(directory.resolve("absent"))); + assertThrows(java.io.IOException.class, () -> ProxyOriginEnvelopeSignature.loadVerifier(directory.resolve("proxy-private.key"))); + Files.writeString(directory.resolve("invalid"), "invalid-key-data"); + assertThrows(java.io.IOException.class, () -> ProxyOriginEnvelopeSignature.loadVerifier(directory.resolve("invalid"))); + Files.writeString(directory.resolve("oversized"), "a".repeat(4097)); + assertThrows(java.io.IOException.class, () -> ProxyOriginEnvelopeSignature.loadVerifier(directory.resolve("oversized"))); + var other = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + Files.writeString(directory.resolve("other"), Base64.getEncoder().encodeToString(other.getPublic().getEncoded())); + var wrong = ProxyOriginEnvelopeSignature.loadVerifier(directory.resolve("other")); + var signed = signer.sign(JsonEnvelope.builder("Vote").build(), Domain.MYSQL, "proxy", "backend"); + assertNull(wrong.verify(signed, Domain.MYSQL, "proxy", "backend")); + var wrongAlgorithm = KeyPairGenerator.getInstance("Ed448").generateKeyPair(); + Files.writeString(directory.resolve("wrong-algorithm-public"), Base64.getEncoder().encodeToString(wrongAlgorithm.getPublic().getEncoded())); + Files.writeString(directory.resolve("wrong-algorithm-private"), Base64.getEncoder().encodeToString(wrongAlgorithm.getPrivate().getEncoded())); + assertThrows(java.io.IOException.class, () -> ProxyOriginEnvelopeSignature.loadVerifier(directory.resolve("wrong-algorithm-public"))); + assertThrows(java.io.IOException.class, () -> ProxyOriginEnvelopeSignature.loadSigner(directory.resolve("wrong-algorithm-private"))); + } + + @Test void unknownPartialOrSpoofedSignatureMetadataIsNeverUnsignedCompatibility() { + for (var fields : java.util.List.of(Map.of("_vpOriginVersion", "1"), Map.of("_vpOriginExtra", "data"))) { + var value = new JsonEnvelope("Vote", 1, fields); + assertNull(verifier.verify(value, Domain.MYSQL, "proxy", "backend")); + assertThrows(IllegalArgumentException.class, () -> signer.sign(value, Domain.MYSQL, "proxy", "backend")); + } + } + + @Test void validRepeatedSqlRetriesRemainPossibleButAncientCapturedProofsAndFutureProofsAreRejected() throws Exception { + var pair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + var now = java.time.Instant.parse("2026-01-01T00:00:00Z"); + var constructor = ProxyOriginEnvelopeSignature.class.getDeclaredConstructor( + java.security.PrivateKey.class, java.security.PublicKey.class, java.time.Clock.class); + constructor.setAccessible(true); + var signer = constructor.newInstance(pair.getPrivate(), null, java.time.Clock.fixed(now, java.time.ZoneOffset.UTC)); + var proof = signer.sign(JsonEnvelope.builder("Vote").put("voteId", "stable-id").build(), Domain.MYSQL, "proxy", "backend"); + var retry = constructor.newInstance(null, pair.getPublic(), java.time.Clock.fixed(now.plusSeconds(12 * 3600), java.time.ZoneOffset.UTC)); + assertNotNull(retry.verify(proof, Domain.MYSQL, "proxy", "backend")); + assertNotNull(retry.verify(proof, Domain.MYSQL, "proxy", "backend")); + for (long age : new long[] {24 * 3600 + 1, 8 * 24 * 3600, -121}) { + var verifier = constructor.newInstance(null, pair.getPublic(), java.time.Clock.fixed(now.plusSeconds(age), java.time.ZoneOffset.UTC)); + assertNull(verifier.verify(proof, Domain.MYSQL, "proxy", "backend")); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/proxy/security/ProxyOriginProducerTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/proxy/security/ProxyOriginProducerTest.java new file mode 100644 index 0000000000..2f2187f163 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/proxy/security/ProxyOriginProducerTest.java @@ -0,0 +1,543 @@ +package com.bencodez.votingplugin.tests.proxy.security; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec; +import com.bencodez.simpleapi.servercomm.mqtt.MqttHandler; +import com.bencodez.simpleapi.servercomm.mysql.MySqlMessenger; +import com.bencodez.votingplugin.proxy.BungeeMethod; +import com.bencodez.votingplugin.proxy.OfflineBungeeVote; +import com.bencodez.votingplugin.proxy.VoteTotalsSnapshot; +import com.bencodez.votingplugin.proxy.VotingPluginWire; +import com.bencodez.votingplugin.proxy.VotingPluginProxy; +import com.bencodez.votingplugin.proxy.ProxyRuntimeReplacementLifecycle; +import com.bencodez.votingplugin.proxy.multiproxy.MultiProxyHandler; +import com.bencodez.votingplugin.proxy.security.ProxyOriginEnvelopeSignature; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode; +import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption; +import com.bencodez.votingplugin.tests.VotingPluginProxyTestImpl; +import com.bencodez.votingplugin.tests.neoforge.NeoForgeProxyOriginFixture; +import com.bencodez.votingplugin.tests.proxy.VotingPluginProxyAccess; +import com.bencodez.votingplugin.tests.support.ReflectiveAccess; + +import redis.clients.jedis.Jedis; +import redis.clients.jedis.JedisPool; + +class ProxyOriginProducerTest { + @TempDir Path directory; + VotingPluginProxyTestImpl proxy; + ProxyOriginEnvelopeSignature verifier; + SharedTransportEnvelopeAuthenticator membership; + + @BeforeEach void setup() throws Exception { + NeoForgeProxyOriginFixture.create(directory); + verifier = NeoForgeProxyOriginFixture.verifier(directory); + byte[] key = new byte[32]; + new java.security.SecureRandom().nextBytes(key); + Files.writeString(directory.resolve("secretkey.key"), Base64.getEncoder().encodeToString(key)); + membership = SharedTransportEnvelopeAuthenticator.load(directory.resolve("secretkey.key"), Mode.REQUIRED); + proxy = new VotingPluginProxyTestImpl(); + proxy.setDataFolder(directory.toFile()); + when(proxy.getConfig().getSpigotServers()).thenReturn(List.of("backend-test", "legacy")); + when(proxy.getConfig().getSpigotServerConfiguration("backend-test")).thenReturn( + Map.of("ProxySigningKeyFile", "synthetic-proxy-private.key")); + when(proxy.getConfig().getSpigotServerConfiguration("legacy")).thenReturn(Map.of()); + when(proxy.getConfig().getProxyServerName()).thenReturn("proxy-test"); + when(proxy.getConfig().getRedisPrefix()).thenReturn("fixture/"); + when(proxy.getConfig().getMqttPrefix()).thenReturn("fixture/"); + Object captured = ReflectiveAccess.call(VotingPluginProxy.class.getName(), proxy, + "createProxyOriginSigners", new String[] {"BungeeMethod"}, new Object[] {BungeeMethod.MYSQL}); + set("proxyOriginSigners", captured); + set("sharedTransportAuthenticator", membership); + // Publication must use captured key bytes, not perform file I/O in a send callback. + Files.delete(directory.resolve("synthetic-proxy-private.key")); + } + + @Test void actualMysqlPublisherBindsCompleteEncryptedEnvelopeAndKeepsLegacyWire() throws Exception { + var sql = mock(MySqlMessenger.class); + set("proxyMysqlMessenger", sql); + proxy.setMethod(BungeeMethod.MYSQL); + var encryption = encryption(); + set("communicationEncryption", encryption); + JsonEnvelope raw = localVote("雪😀\r\nAlex"); + assertTrue(VotingPluginProxyAccess.sendProxyBroadcastEnvelopeNow(proxy, "backend-test", raw)); + var capture = ArgumentCaptor.forClass(JsonEnvelope.class); + verify(sql).sendToBackend(eq("backend-test"), capture.capture()); + JsonEnvelope proof = verifier.verify(roundTrip(capture.getValue()), ProxyOriginEnvelopeSignature.Domain.MYSQL, + "proxy-test", "backend-test"); + assertNotNull(proof); + assertEquals(wireFields(raw), encryption.decrypt(proof).envelope().getFields()); + assertNull(verifier.verify(capture.getValue(), ProxyOriginEnvelopeSignature.Domain.MYSQL, "proxy-test", "other")); + assertTrue(VotingPluginProxyAccess.sendProxyBroadcastEnvelopeNow(proxy, "legacy", raw)); + var legacy = ArgumentCaptor.forClass(JsonEnvelope.class); + verify(sql).sendToBackend(eq("legacy"), legacy.capture()); + assertFalse(legacy.getValue().getFields().keySet().stream().anyMatch(k -> k.startsWith("_vpOrigin"))); + } + + private JsonEnvelope localVote(String player) throws Exception { + var id = java.util.UUID.randomUUID(); + var totals = new VoteTotalsSnapshot(); + ReflectiveAccess.call(VotingPluginProxy.class.getName(), proxy, "stampLocalOriginTotals", + new String[] {"UUID", "String", "String", "String", "long", "boolean", "boolean", "boolean", + "boolean", "boolean", "VoteTotalsSnapshot", "boolean"}, + new Object[] {id, player, id.toString(), "fixture-site", 1234L, true, true, false, false, false, + totals, true}); + return VotingPluginWire.vote(player, id.toString(), "fixture-site", 1234L, false, true, + totals.toStorageString(), id, false, false, 1, 1); + } + + private static Map wireFields(JsonEnvelope raw) { + var fields = new java.util.HashMap<>(raw.getFields()); + var totals = VoteTotalsSnapshot.parseStorage(fields.get("totals")); + totals.setLocalProxyOrigin(false); + fields.put("totals", totals.toStorageString()); + return fields; + } + + @Test void replacementCannotGrantAuthorityToRecoveredUnclassifiedVotes() throws Exception { + // A fresh runtime has keys but no old instance's shared-ingress flag. + // This persisted row has the same shape as pre-provenance offline work. + var recovered = new OfflineBungeeVote(java.util.UUID.randomUUID(), "Alex", + java.util.UUID.randomUUID().toString(), "fixture-site", 1234L, true, + "v2//0//0//0//0//0//0//0//0", false); + var sql = mock(MySqlMessenger.class); + set("proxyMysqlMessenger", sql); + proxy.setMethod(BungeeMethod.MYSQL); + for (boolean online : List.of(false, true)) { + JsonEnvelope replay = (JsonEnvelope) ReflectiveAccess.call(VotingPluginProxy.class.getName(), proxy, + "cachedVoteEnvelope", new String[] {"OfflineBungeeVote", "boolean", "boolean", "int", "int"}, + new Object[] {recovered, online, false, 1, 1}); + assertFalse(VotingPluginProxyAccess.sendProxyBroadcastEnvelopeNow(proxy, "backend-test", replay), + "recovery cannot manufacture locally admitted provenance"); + } + verifyNoInteractions(sql); + assertFalse(recovered.isRewardDelivered()); + } + + @Test void actualRedisAndMqttPublishersComposeMembershipOriginAndEncryptionInConsumerOrder() throws Exception { + var encryption = encryption(); + set("communicationEncryption", encryption); + JsonEnvelope raw = localVote("Alex"); + var mqtt = mock(MqttHandler.class); + set("mqttHandler", mqtt); + assertTrue(proxy.sendMqttEnvelopeServer("backend-test", raw)); + var capture = ArgumentCaptor.forClass(JsonEnvelope.class); + String topic = "fixture/votingplugin/servers/backend-test"; + verify(mqtt).publishEnvelope(eq(topic), capture.capture()); + assertConsumerLayers(raw, roundTrip(capture.getValue()), encryption, + SharedTransportEnvelopeAuthenticator.Domain.MQTT_PROXY_BACKEND, ProxyOriginEnvelopeSignature.Domain.MQTT, topic); + + var pool = mock(JedisPool.class); + var jedis = mock(Jedis.class); + when(pool.getResource()).thenReturn(jedis); + when(jedis.publish(anyString(), anyString())).thenReturn(1L); + set("redisPublisherPool", pool); + assertTrue(proxy.sendRedisEnvelopeServer("backend-test", raw)); + var channel = ArgumentCaptor.forClass(String.class); + var payload = ArgumentCaptor.forClass(String.class); + verify(jedis).publish(channel.capture(), payload.capture()); + assertConsumerLayers(raw, JsonEnvelopeCodec.decode(payload.getValue()), encryption, + SharedTransportEnvelopeAuthenticator.Domain.REDIS_PROXY_BACKEND, ProxyOriginEnvelopeSignature.Domain.REDIS, + channel.getValue()); + } + + @Test void alteredRecoveryFieldsAndBareFlagsCannotAcquireAuthorityOnAnyPublisher() throws Exception { + JsonEnvelope local = localVote("Alex"); + var sql = mock(MySqlMessenger.class); + set("proxyMysqlMessenger", sql); + proxy.setMethod(BungeeMethod.MYSQL); + var mqtt = mock(MqttHandler.class); + set("mqttHandler", mqtt); + var pool = mock(JedisPool.class); + var jedis = mock(Jedis.class); + when(pool.getResource()).thenReturn(jedis); + when(jedis.publish(anyString(), anyString())).thenReturn(1L); + set("redisPublisherPool", pool); + for (String changed : List.of("voteId", "player", "uuid", "service", "time", "realVote", + "wasOnline", "delayValidated", "totals")) { + var fields = new java.util.HashMap<>(local.getFields()); + fields.put(changed, switch (changed) { + case "voteId", "uuid" -> java.util.UUID.randomUUID().toString(); + case "time" -> "1235"; + case "realVote" -> "false"; + case "wasOnline", "delayValidated" -> "true"; + case "totals" -> fields.get("totals").replace("v2//0", "v2//99"); + default -> "changed"; + }); + JsonEnvelope altered = roundTrip(new JsonEnvelope(local.getSubChannel(), local.getSchema(), fields)); + assertFalse(VotingPluginProxyAccess.sendProxyBroadcastEnvelopeNow(proxy, "backend-test", altered), changed); + assertFalse(proxy.sendRedisEnvelopeServer("backend-test", altered), changed); + assertFalse(proxy.sendMqttEnvelopeServer("backend-test", altered), changed); + } + var bare = new VoteTotalsSnapshot(); bare.setLocalProxyOrigin(true); + var fields = new java.util.HashMap<>(local.getFields()); fields.put("totals", bare.toString()); + assertFalse(proxy.sendMqttEnvelopeServer("backend-test", new JsonEnvelope("Vote", 1, fields))); + verifyNoInteractions(sql, mqtt); + verify(jedis, never()).publish(anyString(), anyString()); + } + + @Test void sharedWireCannotCarryEvenAnExistingLocalAdmissionProofIntoRetries() throws Exception { + JsonEnvelope local = localVote("Alex"); + var authenticated = membership.sign(roundTrip(local), + SharedTransportEnvelopeAuthenticator.Domain.REDIS_MULTI_PROXY, "claimed-proxy", "fixture/channel"); + var checked = membership.verify(roundTrip(authenticated), + SharedTransportEnvelopeAuthenticator.Domain.REDIS_MULTI_PROXY, "fixture/channel"); + assertTrue(checked.accepted()); + var vote = VotingPluginWire.readVote(checked.envelope()); + var claimed = VoteTotalsSnapshot.parseStorage(vote.totals); + assertTrue(claimed.isLocalProxyOrigin()); + set("voteCacheHandler", mock(com.bencodez.votingplugin.proxy.cache.VoteCacheHandler.class)); + set("enabled", false); // Inspect the retained admitted request before its async attempt. + ReflectiveAccess.call(VotingPluginProxy.class.getName(), proxy, "receiveMultiProxyVote", + new String[] {"String", "String", "boolean", "boolean", "long", "VoteTotalsSnapshot", "String", "UUID"}, + new Object[] {vote.player, vote.service, vote.realVote, false, vote.time, claimed, vote.uuid, vote.voteId}); + var retries = VotingPluginProxy.class.getDeclaredField("multiProxyVoteRetries"); retries.setAccessible(true); + Object retry = ((Map) retries.get(proxy)).get(vote.voteId); + assertNotNull(retry); + var value = retry.getClass().getDeclaredField("totals"); value.setAccessible(true); + var admitted = (VoteTotalsSnapshot) value.get(retry); + assertFalse(admitted.isLocalProxyOrigin()); + assertTrue(admitted.getLocalOriginProof().isEmpty()); + assertTrue(claimed.isLocalProxyOrigin(), "untrusted decoded input is cloned, not mutated"); + } + + @Test void legitimateRecoveredVoteKeepsAuthorityAndDoesNotExportTheAdmissionProof() throws Exception { + var local = localVote("Alex"); + var vote = VotingPluginWire.readVote(local); + var recovered = new OfflineBungeeVote(vote.voteId, vote.player, vote.uuid, vote.service, vote.time, + vote.realVote, vote.totals, false); + recovered.setWasOnline(vote.wasOnline); + var sql = mock(MySqlMessenger.class); set("proxyMysqlMessenger", sql); proxy.setMethod(BungeeMethod.MYSQL); + for (boolean online : List.of(false, true)) { + var replay = (JsonEnvelope) ReflectiveAccess.call(VotingPluginProxy.class.getName(), proxy, + "cachedVoteEnvelope", new String[] {"OfflineBungeeVote", "boolean", "boolean", "int", "int"}, + new Object[] {recovered, online, false, 1, 1}); + assertTrue(VotingPluginProxyAccess.sendProxyBroadcastEnvelopeNow(proxy, "backend-test", roundTrip(replay))); + } + var sent = ArgumentCaptor.forClass(JsonEnvelope.class); verify(sql, times(2)).sendToBackend(eq("backend-test"), sent.capture()); + for (var envelope : sent.getAllValues()) { + var verified = verifier.verify(envelope, ProxyOriginEnvelopeSignature.Domain.MYSQL, "proxy-test", "backend-test"); + assertNotNull(verified); + assertFalse(VoteTotalsSnapshot.parseStorage(verified.getFields().get("totals")).isLocalProxyOrigin()); + } + assertTrue(VoteTotalsSnapshot.parseStorage(recovered.getText()).isLocalProxyOrigin()); + assertFalse(recovered.isRewardDelivered(), "a low-level publisher cannot retire the cache owner"); + } + + private com.bencodez.votingplugin.proxy.cache.VoteCacheHandler durableCache() { + var platform = mock(com.bencodez.votingplugin.proxy.bungee.VotingPluginBungee.class); + when(platform.getDataFolder()).thenReturn(directory.toFile()); + var storage = new com.bencodez.votingplugin.proxy.bungee.BungeeJsonVoteCache(platform); + return new com.bencodez.votingplugin.proxy.cache.VoteCacheHandler(null, false, false, null, false, storage) { + @Override public void logInfo1(String message) { } + @Override public void logSevere1(String message) { } + @Override public void debug1(Exception failure) { } + @Override public void debug1(Throwable failure) { } + @Override public void debug1(String message) { } + }; + } + + @Test void realLocalAndSharedAdmissionKeepDifferentAuthorityAfterDiskRecovery() throws Exception { + var cache = durableCache(); set("voteCacheHandler", cache); set("enabled", true); + proxy.setMethod(BungeeMethod.MYSQL); proxy.setPlayerOnline(false); + proxy.setStandaloneBroadcastForwarding(false); proxy.setAvailableServers("backend-test"); + proxy.setGlobalMessageProxyHandlerForTest(mock(com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler.class)); + when(proxy.getConfig().getOnlineMode()).thenReturn(true); + when(proxy.getConfig().getBlockedServers()).thenReturn(List.of()); + var localId = java.util.UUID.randomUUID(); String localPlayer = java.util.UUID.randomUUID().toString(); + assertDoesNotThrow(() -> proxy.vote("Alex", "fixture-site", true, false, 1234L, null, localPlayer, localId)); + var sharedId = java.util.UUID.randomUUID(); String sharedPlayer = java.util.UUID.randomUUID().toString(); + var forged = VoteTotalsSnapshot.parseStorage(localVote("Alex").getFields().get("totals")); + ReflectiveAccess.call(VotingPluginProxy.class.getName(), proxy, "receiveMultiProxyVote", + new String[] {"String", "String", "boolean", "boolean", "long", "VoteTotalsSnapshot", "String", "UUID"}, + new Object[] {"Alex", "fixture-site", true, false, 1234L, forged, sharedPlayer, sharedId}); + var replacementCache = durableCache(); replacementCache.load(); + var recoveredLocal = replacementCache.getOnlineVotes(localPlayer).get(0); + var recoveredShared = replacementCache.getOnlineVotes(sharedPlayer).get(0); + assertTrue(VoteTotalsSnapshot.parseStorage(recoveredLocal.getText()).isLocalProxyOrigin()); + assertFalse(VoteTotalsSnapshot.parseStorage(recoveredShared.getText()).isLocalProxyOrigin()); + assertEquals(localId, recoveredLocal.getVoteId()); + assertEquals(localPlayer, recoveredLocal.getUuid()); + assertEquals(1234L, recoveredLocal.getTime()); + assertTrue(recoveredLocal.isWasOnlineKnown()); + assertFalse(recoveredLocal.isWasOnline()); + assertTrue(recoveredLocal.isDelayValidationKnown(), "new rows persist explicit unvalidated classification"); + assertFalse(recoveredLocal.isDelayValidated()); + var sql = mock(MySqlMessenger.class); set("proxyMysqlMessenger", sql); + for (var vote : List.of(recoveredLocal, recoveredShared)) { + var replay = (JsonEnvelope) ReflectiveAccess.call(VotingPluginProxy.class.getName(), proxy, + "cachedVoteEnvelope", new String[] {"OfflineBungeeVote", "boolean", "boolean", "int", "int"}, + new Object[] {vote, true, false, 1, 1}); + var parsed = VotingPluginWire.readVote(replay); + assertEquals(vote.isWasOnlineKnown(), parsed.wasOnlineKnown); + assertEquals(vote.isWasOnline(), parsed.wasOnline); + assertEquals(vote.isDelayValidationKnown(), parsed.delayValidationKnown); + assertEquals(vote == recoveredLocal, + VotingPluginProxyAccess.sendProxyBroadcastEnvelopeNow(proxy, "backend-test", roundTrip(replay))); + assertFalse(vote.isRewardDelivered()); + } + verify(sql, times(1)).sendToBackend(eq("backend-test"), any(JsonEnvelope.class)); + } + + @Test void copiedSqlCacheAdmissionCannotMintAnotherTargetDelivery() throws Exception { + var keys = VotingPluginProxy.class.getDeclaredField("proxyOriginSigners"); keys.setAccessible(true); + var captured = (Map) keys.get(proxy); + set("proxyOriginSigners", Map.of("backend-test", captured.get("backend-test"), + "other-backend", captured.get("backend-test"))); + JsonEnvelope copied = roundTrip(localVote("Alex")); + when(proxy.getConfig().getVoteCacheUseMySQL()).thenReturn(true); + proxy.setMethod(BungeeMethod.MYSQL); + var sql = mock(MySqlMessenger.class); set("proxyMysqlMessenger", sql); + assertFalse(VotingPluginProxyAccess.sendProxyBroadcastEnvelopeNow(proxy, "other-backend", copied), + "shared cache bytes cannot prove proxy-owned target selection or unconsumed delivery"); + verifyNoInteractions(sql); + } + + @Test void signingPreflightRejectsSqlVoteCacheBeforeReplacingRuntime() throws Exception { + NeoForgeProxyOriginFixture.create(directory); + when(proxy.getConfig().getVoteCacheUseMySQL()).thenReturn(true); + for (BungeeMethod method : List.of(BungeeMethod.MYSQL, BungeeMethod.REDIS, BungeeMethod.MQTT)) { + when(proxy.getConfig().getBungeeMethod()).thenReturn(method.name()); + assertThrows(IllegalStateException.class, proxy::validateReplacementTransportSecurity, + "signing requires a proxy-private pending-work owner before any retirement"); + } + when(proxy.getConfig().getSpigotServerConfiguration("backend-test")).thenReturn(Map.of()); + assertDoesNotThrow(proxy::validateReplacementTransportSecurity, + "absent signing preserves existing SQL-cache configurations"); + when(proxy.getConfig().getBungeeMethod()).thenReturn("SOCKETS"); + when(proxy.getConfig().getSpigotServerConfiguration("backend-test")) + .thenReturn(Map.of("ProxySigningKeyFile", "missing-inactive.key")); + assertDoesNotThrow(proxy::validateReplacementTransportSecurity, + "inactive signing does not impose new cache restrictions"); + } + + @Test void activeSqlOwnerCannotBeReclassifiedByEditingConfiguration() throws Exception { + var cache = mock(com.bencodez.votingplugin.proxy.cache.VoteCacheHandler.class); + when(cache.usesMySQLStorage()).thenReturn(true); set("voteCacheHandler", cache); + when(proxy.getConfig().getVoteCacheUseMySQL()).thenReturn(false); + NeoForgeProxyOriginFixture.create(directory); + when(proxy.getConfig().getBungeeMethod()).thenReturn("MYSQL"); + assertThrows(IllegalStateException.class, proxy::validateReplacementTransportSecurity); + proxy.setMethod(BungeeMethod.MYSQL); + var sql = mock(MySqlMessenger.class); set("proxyMysqlMessenger", sql); + assertFalse(VotingPluginProxyAccess.sendProxyBroadcastEnvelopeNow(proxy, "backend-test", localVote("Alex"))); + verifyNoInteractions(sql); + } + + @Test void legitimateSignedLegacyDeliveryReportsActualAcceptance() throws Exception { + proxy.setMethod(BungeeMethod.MYSQL); + set("legacyVoteDeliveryServers", new java.util.HashSet<>(List.of("backend-test"))); + var sql = mock(MySqlMessenger.class); set("proxyMysqlMessenger", sql); + var handler = mock(com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler.class); + proxy.setGlobalMessageProxyHandlerForTest(handler); + assertTrue(proxy.sendVoteEnvelopeAcceptedForTest("backend-test", 2, localVote("Alex"))); + verify(sql).sendToBackend(eq("backend-test"), any(JsonEnvelope.class)); + verifyNoInteractions(handler); + set("proxyOriginSigners", Map.of()); + when(proxy.getConfig().getVoteCacheUseMySQL()).thenReturn(true); + assertTrue(proxy.sendVoteEnvelopeAcceptedForTest("backend-test", 2, localVote("Blake"))); + verify(handler).sendMessage(eq("backend-test"), eq(2), any(JsonEnvelope.class)); + } + + @Test void legacySignedDeliveryDoesNotAcceptRejectedPublisher() throws Exception { + proxy.setMethod(BungeeMethod.MYSQL); + set("legacyVoteDeliveryServers", new java.util.HashSet<>(List.of("backend-test"))); + var sql = mock(MySqlMessenger.class); set("proxyMysqlMessenger", sql); + var handler = mock(com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler.class); + proxy.setGlobalMessageProxyHandlerForTest(handler); + doAnswer(call -> { + assertFalse(VotingPluginProxyAccess.sendProxyBroadcastEnvelopeNow(proxy, + call.getArgument(0), call.getArgument(2))); + return null; + }).when(handler).sendMessage(anyString(), anyInt(), any(JsonEnvelope.class)); + var fields = new java.util.HashMap<>(localVote("Alex").getFields()); + var totals = VoteTotalsSnapshot.parseStorage(fields.get("totals")); + totals.setLocalProxyOrigin(false); fields.put("totals", totals.toStorageString()); + assertFalse(proxy.sendVoteEnvelopeAcceptedForTest("backend-test", 0, + new JsonEnvelope(VotingPluginWire.SUB_VOTE, 1, fields)), + "a void adapter must not turn signing refusal into cache retirement"); + verifyNoInteractions(sql); + } + + @Test void emergencySnapshotRestampsFinalFieldsWhenPartyFailureLeavesNoTotals() throws Exception { + proxy = spy(proxy); + set("voteCacheHandler", durableCache()); set("enabled", true); + proxy.setMethod(BungeeMethod.MYSQL); proxy.setPlayerOnline(false); + when(proxy.getConfig().getOnlineMode()).thenReturn(true); + when(proxy.getConfig().getBlockedServers()).thenReturn(List.of()); + doThrow(new IllegalStateException("synthetic party failure")).when(proxy).addVoteParty(); + var id = java.util.UUID.randomUUID(); var uuid = java.util.UUID.randomUUID().toString(); + assertThrows(RuntimeException.class, + () -> proxy.vote("Alex", "fixture-site", true, false, 1234L, null, uuid, id)); + var saved = proxy.snapshotIncomingVoteForRestart( + new com.bencodez.votingplugin.proxy.PendingIncomingVote(id, "Alex", "fixture-site", 1234L)); + assertEquals(uuid, saved.getUuid()); assertTrue(saved.isWasOnlineKnown()); + assertEquals(true, ReflectiveAccess.call(VotingPluginProxy.class.getName(), proxy, + "hasLocalOriginProof", new String[] {"VoteTimeQueue"}, new Object[] {saved}), + "proof must cover the final UUID and online state even before totals exist"); + assertTrue(proxy.retainIncomingVoteForRestart(saved)); + var recovered = durableCache(); recovered.load(); + var fromDisk = recovered.getTimeChangeQueue().stream() + .filter(vote -> id.equals(vote.getVoteId())).findFirst().orElseThrow(); + assertEquals(uuid, fromDisk.getUuid()); + assertEquals(true, ReflectiveAccess.call(VotingPluginProxy.class.getName(), proxy, + "hasLocalOriginProof", new String[] {"VoteTimeQueue"}, new Object[] {fromDisk}), + "the complete emergency storage representation must retain finalized authority"); + } + + @Test void invalidConfiguredKeyCannotReplaceCapturedWorkingAuthority() throws Exception { + when(proxy.getConfig().getSpigotServerConfiguration("backend-test")) + .thenReturn(Map.of("ProxySigningKeyFile", "missing.key")); + assertThrows(IllegalStateException.class, () -> ReflectiveAccess.call(VotingPluginProxy.class.getName(), proxy, + "createProxyOriginSigners", new String[] {"BungeeMethod"}, new Object[] {BungeeMethod.MYSQL})); + // Preparation failure leaves the actual installed signer map intact. + JsonEnvelope signed = (JsonEnvelope) ReflectiveAccess.call(VotingPluginProxy.class.getName(), proxy, + "signProxyOrigin", new String[] {"JsonEnvelope", "Domain", "String"}, + new Object[] {localVote("Alex"), ProxyOriginEnvelopeSignature.Domain.MYSQL, "backend-test"}); + assertNotNull(verifier.verify(signed, ProxyOriginEnvelopeSignature.Domain.MYSQL, "proxy-test", "backend-test")); + assertEquals(Map.of(), ReflectiveAccess.call(VotingPluginProxy.class.getName(), proxy, + "createProxyOriginSigners", new String[] {"BungeeMethod"}, new Object[] {BungeeMethod.SOCKETS}), + "inactive signing settings must not alter the established socket contract"); + } + + @Test void proxyOnlySigningCannotBeGrantedToSharedKeyMultiProxyIngress() throws Exception { + NeoForgeProxyOriginFixture.create(directory); + when(proxy.getConfig().getMultiProxySupport()).thenReturn(true); + // REQUIRED membership still proves only possession of the backend-held key. + JsonEnvelope forged = membership.sign(JsonEnvelope.builder("Vote").put("player", "Alex").build(), + SharedTransportEnvelopeAuthenticator.Domain.REDIS_MULTI_PROXY, "claimed-proxy", "fixture/channel"); + assertTrue(membership.verify(roundTrip(forged), + SharedTransportEnvelopeAuthenticator.Domain.REDIS_MULTI_PROXY, "fixture/channel").accepted()); + for (String ingress : List.of("REDIS", "SOCKETS")) { + when(proxy.getConfig().getMultiProxyMethod()).thenReturn(ingress); + for (BungeeMethod selected : List.of(BungeeMethod.MYSQL, BungeeMethod.REDIS, BungeeMethod.MQTT)) { + assertThrows(IllegalStateException.class, () -> ReflectiveAccess.call(VotingPluginProxy.class.getName(), proxy, + "createProxyOriginSigners", new String[] {"BungeeMethod"}, new Object[] {selected}), + "a shared-key ingress must not become a proxy-only signing authority"); + } + } + } + + @Test void fullReplacementChecksMissingAndMalformedKeysBeforeRetiringHealthyRuntime() throws Exception { + var previous = spy(proxy); + when(previous.getConfig().getBungeeMethod()).thenReturn("MYSQL"); + doNothing().when(previous).prepareForRuntimeReplacement(); + for (String file : List.of("missing.key", "malformed.key")) { + if (file.equals("malformed.key")) Files.writeString(directory.resolve(file), "synthetic invalid key"); + when(previous.getConfig().getSpigotServerConfiguration("backend-test")) + .thenReturn(Map.of("ProxySigningKeyFile", file)); + assertThrows(IllegalStateException.class, () -> ProxyRuntimeReplacementLifecycle.prepare(previous)); + verify(previous, never()).prepareForRuntimeReplacement(); + verify(previous, never()).completeRuntimeReplacementShutdown(); + JsonEnvelope retained = (JsonEnvelope) ReflectiveAccess.call(VotingPluginProxy.class.getName(), previous, + "signProxyOrigin", new String[] {"JsonEnvelope", "Domain", "String"}, + new Object[] {localVote("Alex"), ProxyOriginEnvelopeSignature.Domain.MYSQL, + "backend-test"}); + assertNotNull(verifier.verify(retained, ProxyOriginEnvelopeSignature.Domain.MYSQL, + "proxy-test", "backend-test")); + } + } + + @Test void enablingSharedIngressRejectsBeforeClosingExistingHandler() throws Exception { + var previous = mock(MultiProxyHandler.class); + set("multiProxyHandler", previous); + when(proxy.getConfig().getMultiProxySupport()).thenReturn(true); + for (String ingress : List.of("REDIS", "SOCKETS")) { + when(proxy.getConfig().getMultiProxyMethod()).thenReturn(ingress); + assertThrows(IllegalStateException.class, proxy::loadMultiProxySupport); + verify(previous, never()).close(); + } + } + + @Test void sharedIngressHistoryRejectsBothSignerPreparationAndLateInstallation() throws Exception { + NeoForgeProxyOriginFixture.create(directory); + Object prepared = ReflectiveAccess.call(VotingPluginProxy.class.getName(), proxy, + "createProxyOriginSigners", new String[] {"BungeeMethod"}, new Object[] {BungeeMethod.MYSQL}); + set("proxyOriginSigners", Map.of()); + set("sharedMultiProxyIngressSeen", true); + // A disabled listener does not prove already-admitted asynchronous work retired. + when(proxy.getConfig().getMultiProxySupport()).thenReturn(false); + assertThrows(IllegalStateException.class, () -> ReflectiveAccess.call(VotingPluginProxy.class.getName(), proxy, + "createProxyOriginSigners", new String[] {"BungeeMethod"}, new Object[] {BungeeMethod.MYSQL})); + assertThrows(IllegalStateException.class, () -> ReflectiveAccess.call(VotingPluginProxy.class.getName(), proxy, + "installTransportSecurity", new String[] {"SharedTransportEnvelopeAuthenticator", + "TransportEnvelopeEncryption", "Map"}, new Object[] {membership, encryption(), prepared})); + var sql = mock(MySqlMessenger.class); + set("proxyMysqlMessenger", sql); + proxy.setMethod(BungeeMethod.MYSQL); + assertTrue(VotingPluginProxyAccess.sendProxyBroadcastEnvelopeNow(proxy, "backend-test", + localVote("Alex"))); + var sent = ArgumentCaptor.forClass(JsonEnvelope.class); + verify(sql).sendToBackend(eq("backend-test"), sent.capture()); + assertNull(verifier.verify(sent.getValue(), ProxyOriginEnvelopeSignature.Domain.MYSQL, + "proxy-test", "backend-test"), "rejected installation must not grant a signing capability"); + } + + @Test void signingPreflightAllowsSingleProxyAndPreservesCapturedGeneration() throws Exception { + NeoForgeProxyOriginFixture.create(directory); + when(proxy.getConfig().getBungeeMethod()).thenReturn("MYSQL"); + when(proxy.getConfig().getMultiProxyMethod()).thenReturn("REDIS"); + when(proxy.getConfig().getMultiProxySupport()).thenReturn(false); + assertDoesNotThrow(proxy::validateReplacementTransportSecurity); + // Validation reads prospective files but never replaces the working captured key. + JsonEnvelope retained = (JsonEnvelope) ReflectiveAccess.call(VotingPluginProxy.class.getName(), proxy, + "signProxyOrigin", new String[] {"JsonEnvelope", "Domain", "String"}, + new Object[] {localVote("Alex"), ProxyOriginEnvelopeSignature.Domain.MYSQL, + "backend-test"}); + assertNotNull(verifier.verify(retained, ProxyOriginEnvelopeSignature.Domain.MYSQL, + "proxy-test", "backend-test")); + } + + @Test void existingSharedMultiProxyWithoutNewSigningOptionRetainsPreflight() throws Exception { + when(proxy.getConfig().getBungeeMethod()).thenReturn("MYSQL"); + when(proxy.getConfig().getMultiProxySupport()).thenReturn(true); + when(proxy.getConfig().getMultiProxyMethod()).thenReturn("REDIS"); + when(proxy.getConfig().getSpigotServerConfiguration("backend-test")).thenReturn(Map.of()); + set("proxyOriginSigners", Map.of()); + set("sharedMultiProxyIngressSeen", true); + for (String ingress : List.of("REDIS", "SOCKETS")) { + when(proxy.getConfig().getMultiProxyMethod()).thenReturn(ingress); + assertDoesNotThrow(proxy::validateReplacementTransportSecurity, + "the absent new option must preserve established shared-key configurations"); + } + } + + private void assertConsumerLayers(JsonEnvelope raw, JsonEnvelope wire, TransportEnvelopeEncryption encryption, + SharedTransportEnvelopeAuthenticator.Domain membershipDomain, ProxyOriginEnvelopeSignature.Domain originDomain, + String target) { + var member = membership.verify(wire, membershipDomain, target); + assertTrue(member.accepted()); + var origin = verifier.verify(member.envelope(), originDomain, "proxy-test", "backend-test"); + assertNotNull(origin); + var decrypted = encryption.decrypt(origin); + assertTrue(decrypted.accepted()); + assertEquals(wireFields(raw), decrypted.envelope().getFields()); + } + + private TransportEnvelopeEncryption encryption() throws Exception { + return TransportEnvelopeEncryption.load(directory.resolve("secretkey.key"), + TransportEnvelopeEncryption.Domain.PROXY_BACKEND, true); + } + private static JsonEnvelope roundTrip(JsonEnvelope envelope) { + return JsonEnvelopeCodec.decode(JsonEnvelopeCodec.encode(envelope)); + } + private void set(String name, Object value) throws Exception { + var field = VotingPluginProxy.class.getDeclaredField(name); + field.setAccessible(true); + field.set(proxy, value); + } +} diff --git a/docs/neoforge-vote-transports.md b/docs/neoforge-vote-transports.md index 2892474fe3..d0cbb49ce2 100644 --- a/docs/neoforge-vote-transports.md +++ b/docs/neoforge-vote-transports.md @@ -10,9 +10,9 @@ loopback and packaged-JAR tests are not a live Minecraft deployment test. | Method | Configuration | Authentication and delivery | | --- | --- | --- | | SOCKETS | Existing `BungeeSettings.yml` socket keys | Existing independent socket authentication and optional encryption; wire behavior is retained | -| REDIS | `BungeeMethod: REDIS`, `Redis` settings | SDK-compatible binary channels, captured credentials/DB/prefix, verified TLS when enabled, optional shared-envelope authentication/encryption | -| MQTT | `BungeeMethod: MQTT`, `MQTT` settings | SDK-compatible topics and payloads, captured broker/client credentials and prefix, optional shared-envelope authentication/encryption | -| MYSQL | `BungeeMethod: MYSQL`, `Config.yml` `Database` settings | Shared database credentials and exact queue source/destination routing; optional envelope encryption | +| REDIS | `BungeeMethod: REDIS`, `Redis` settings | Pinned proxy-origin signature, SDK-compatible channels, captured credentials/DB/prefix, verified TLS when enabled, separate shared-envelope authentication/encryption | +| MQTT | `BungeeMethod: MQTT`, `MQTT` settings | Pinned proxy-origin signature, SDK-compatible topics, captured credentials/prefix, separate shared-envelope authentication/encryption | +| MYSQL | `BungeeMethod: MYSQL`, `Config.yml` `Database` settings | Pinned proxy-origin signature and exact queue routing; optional envelope encryption | ## Proxy transports @@ -29,6 +29,82 @@ MQTT supports the SDK's TCP/TLS/WebSocket broker URL forms. Broker credentials and prefixes establish a routing domain; they do not alone establish an independent proxy identity. +All three new methods require `BungeeServer.Name` matching the proxy's +`ProxyServerName`, plus `ProxyVerificationKeyFile` in the NeoForge +`BungeeSettings.yml`. This is a base64 DER X509 Ed25519 **public** key, pinned +through an authorized channel. On the proxy, configure the matching base64 DER +PKCS8 Ed25519 **private** key with +`SpigotServers..ProxySigningKeyFile`. Paths are relative to each plugin's +data directory. Keep the signing file private and only on the proxy; never copy +it or configure the network-wide `secretkey.key` as this key. The plugin does not +generate, download or enroll these keys automatically. + +The proxy signs the complete outbound envelope, including any encrypted payload, +schema, subchannel, exact backend identity, proxy identity, transport domain and +issuance time. NeoForge verifies that proof before decryption or durable vote +admission. A database writer, broker publisher or another holder of the shared +network key cannot mint proxy-origin messages using only that access. A backend +receives only the public verifier. Missing, malformed or unavailable verification +settings fail startup; absent or invalid message proofs are rejected. Update the +proxy and configure its signing entry before enabling the new NeoForge receiver. +Other proxy backend entries without a signing setting retain their existing +wire behavior, and SOCKETS keeps its existing independent authentication. + +Proxy-origin signing requires `VoteCacheUseMySQL: false` and a proxy-private local +vote cache. This restricts pending-work storage, not the MySQL message transport. +A backend-accessible SQL cache can copy a valid admission record to another +target or restore it after consumption; its signature cannot establish current +delivery ownership. Startup and reload reject signing with configured or active +SQL vote caching before retiring the working runtime. Changing configuration +alone cannot reclassify an existing SQL cache owner. No cached data is deleted, +migrated or automatically re-attested. Existing SQL-cache setups without the new +signing option retain their established behavior. The new signing mode does not +support shared SQL vote-cache recovery. +SQL-imported server, online and timed rows lose internal admission evidence before +any private-file fallback; numeric totals and original occurrence identities remain. +Moving a shared row into a local file cannot promote it to proxy-owned authority. + +Proxy-origin signing also requires `MultiProxySupport: false`. Both current multi-proxy +methods use the network-wide `secretkey.key`, which a backend may also possess; +forwarding their votes through a private signer would incorrectly upgrade network +membership into proxy-only authority. Startup and reload reject this combination +before replacing working security or retiring a runtime. Single-proxy setups +with private caches support the new signing option. A proxy instance that has activated multi-proxy +ingress cannot enable signing later through a soft reload, even after disabling +that ingress: admitted work may still exist. A restart alone does not classify +retained votes as locally admitted. Local admission evidence is signed with an already configured proxy-only key and +persisted with vote totals through offline, timed and emergency recovery. It binds +the occurrence ID, player/UUID, service/time, real-vote and online/delay decisions, +and numeric totals. Shared-wire claims are cleared before admission. A plain flag +or altered stored fields cannot supply authority. This internal evidence is removed +from peer payloads before encryption/signing. Final signed vote publication requires +valid evidence under the still-configured issuing key, +including after full runtime replacement or restart. Older or shared-origin +records without it remain retained for reconciliation and cannot acquire signing +authority from new configuration. Installing or replacing keys does not retroactively +attest old work; retain the issuing key while its records remain pending. Unknown +or unverifiable records keep their original IDs and payloads for reconciliation; +unsigned legacy targets retain their existing delivery path. The plugin never +restarts a process. Existing setups without a signing entry retain their transport +behavior. + +Full runtime-replacement preflight also reads every configured active signing key. +Missing or malformed replacement files leave the predecessor's captured keys and +runtime available; preflight does not install the prospective keys. + +Origin proofs are valid for at most 24 hours, with two minutes of permitted +future clock skew. This is shorter than the inbox's seven-day released-receipt +retention, preventing ancient captured deliveries from outliving duplicate +fences. Within that interval the same signed SQL row can be retried after failed +acknowledgement or capacity refusal. The signature does not consume a nonce before +durable admission; stable vote IDs and completion receipts retain that ownership. +The proxy signs retransmissions with the current issuance time. Expired SQL rows +remain refused and retained rather than deleted; recovery requires a fresh +authorized proxy delivery, preserving the vote ID. Synchronize proxy/backend +clocks. Broker membership freshness/replay checks remain an additional outer +layer. This change does not add independent backend-to-proxy identity to the +existing shared-key ACK/presence protocol. + For Redis/MQTT, configure `BungeeServer.Name` to the proxy identity. Use `SharedTransportAuthentication: REQUIRED` once all peers support it, and copy the existing private network `secretkey.key` through an authorized private @@ -53,7 +129,9 @@ Rows are read in bounded 50-row pages under the existing SDK named lock; a row is deleted only after application acceptance. Invalid or refused rows remain available for investigation. Full pages advance before revisiting refused rows so one blocked player does not permanently hide later rows. Database writers -share a trust domain; a `source` column is not cryptographic per-node identity. +share a routing domain; a `source` column grants no proxy-origin authority. +The pinned signature is required even when the row claims `source='proxy'` and +optional network-wide encryption is enabled. ## JSON compatibility From 8ff30f58c74a5576f27b51ae8892b2806daa2540 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 25 Oct 2026 03:43:58 -0600 Subject: [PATCH 3/5] Preserve recorded progress during proxy admission recovery --- .../votingplugin/proxy/VotingPluginProxy.java | 16 +- .../security/ProxyOriginProducerTest.java | 154 ++++++++++++++++++ 2 files changed, 169 insertions(+), 1 deletion(-) 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 676ccb6ee9..cb6191b606 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java @@ -6484,7 +6484,7 @@ public synchronized boolean retainIncomingVoteForRestart(VoteTimeQueue recovery) if (retry == null && !recovery.isVotePartyApplied() && !recovery.isTotalsApplied() && recovery.isRealVote() && !recovery.isWasOnlineKnown() && (recovery.getUuid() == null || recovery.getUuid().isEmpty()) - && (recovery.getTotals() == null || recovery.getTotals().isEmpty())) return true; + && isAdmissionOnlyRecoveryTotals(recovery.getTotals())) return true; VoteTimeQueue state = retry == null ? recovery : snapshotIncomingVoteForRestart(new PendingIncomingVote(recovery.getVoteId(), recovery.getName(), recovery.getService(), recovery.getTime())); @@ -6514,6 +6514,20 @@ enum QueuedVoteResult { SUCCESS, RETRY, RETRY_NONBLOCKING, TERMINAL } + private static boolean isAdmissionOnlyRecoveryTotals(String stored) { + if (stored == null || stored.isEmpty()) return true; + VoteTotalsSnapshot parsed = VoteTotalsSnapshot.parseStorage(stored); + if (!parsed.isLocalProxyOrigin() + || !parsed.getLocalOriginProof().matches("[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]{86}")) return false; + VoteTotalsSnapshot admission = new VoteTotalsSnapshot(); + admission.setLocalProxyOrigin(true); + admission.setLocalOriginProof(parsed.getLocalOriginProof()); + // Match only the exact zero snapshot emitted before any effects. The storage + // parser is deliberately lenient, so parsed zeros alone are not sufficient. + // This preserves an existing owner; it does not authenticate incoming data. + return stored.equals(admission.toStorageString()); + } + protected QueuedVoteResult replayQueuedVote(VoteTimeQueue vote, VoteTotalsSnapshot totals, boolean realVote) { return vote(vote.getName(), vote.getService(), realVote, false, vote.getTime(), totals, vote.getUuid(), vote); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/proxy/security/ProxyOriginProducerTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/proxy/security/ProxyOriginProducerTest.java index 2f2187f163..295c6f4fca 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/proxy/security/ProxyOriginProducerTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/proxy/security/ProxyOriginProducerTest.java @@ -373,6 +373,160 @@ private com.bencodez.votingplugin.proxy.cache.VoteCacheHandler durableCache() { verifyNoInteractions(sql); } + @Test void admissionOnlyEmergencyJournalCannotEraseRecordedProgress() throws Exception { + var cache = durableCache(); set("voteCacheHandler", cache); + for (boolean signed : List.of(false, true)) { + for (boolean rescue : List.of(false, true)) { + var id = java.util.UUID.randomUUID(); + var pending = new com.bencodez.votingplugin.proxy.PendingIncomingVote( + id, "Synthetic", "fixture-site", 1234L); + var admission = proxy.snapshotIncomingVoteForRestart(pending); + assertFalse(admission.getTotals().isEmpty(), "exercise the installed real signer"); + if (!signed) admission.setTotals(""); + var row = new com.bencodez.votingplugin.timequeue.VoteTimeQueue( + id, "Synthetic", "fixture-site", 1234L); + String player = java.util.UUID.randomUUID().toString(); + String totals = new VoteTotalsSnapshot(9, 8, 7, 6, 5, 4, 3, 2).toStorageString(); + row.setUuid(player); row.setWasOnline(false); row.setDelayValidated(true); + row.setVotePartyApplied(true); row.setTotalsApplied(true); row.setTotals(totals); + row.setProxyBroadcastHandled(true); row.getBroadcastTargets().add("backend-test"); + row.getBroadcastForwardedServers().add("backend-test"); + row.setHttpBroadcastDeliveryId("legacy", id.toString()); + row.setMultiProxyForwardingHandled(true); + assertTrue(cache.addTimeVoteToCache(row)); + var journalDirectory = directory.resolve("emergency-" + id); + var journal = rescue + ? com.bencodez.votingplugin.proxy.PendingIncomingVoteJournal.rescue(journalDirectory) + : new com.bencodez.votingplugin.proxy.PendingIncomingVoteJournal(journalDirectory); + journal.merge(List.of(admission)); + var loaded = journal.load().get(0); + assertEquals(admission.getTotals(), loaded.getTotals()); + // Preserving recorded progress must not depend on still having the old signer. + if (signed && rescue) set("proxyOriginSigners", Map.of()); + // Both platform import loops use this overload after journal deserialization. + assertTrue(proxy.retainIncomingVoteForRestart(loaded)); + assertAll("journal import preserves committed progress", + () -> assertEquals(player, row.getUuid()), + () -> assertEquals(totals, row.getTotals()), + () -> assertTrue(row.isVotePartyApplied()), + () -> assertTrue(row.isTotalsApplied())); + // The direct shutdown overload must preserve the same owner too. + assertTrue(proxy.retainIncomingVoteForRestart(pending)); + var restarted = durableCache(); restarted.load(); + var recovered = restarted.getTimeChangeQueue().stream() + .filter(vote -> id.equals(vote.getVoteId())).findFirst().orElseThrow(); + assertAll("admission is not an effects checkpoint", + () -> assertEquals(player, recovered.getUuid()), + () -> assertEquals(totals, recovered.getTotals()), + () -> assertTrue(recovered.isVotePartyApplied()), + () -> assertTrue(recovered.isTotalsApplied()), + () -> assertTrue(recovered.isWasOnlineKnown()), + () -> assertFalse(recovered.isWasOnline()), + () -> assertTrue(recovered.isDelayValidated()), + () -> assertTrue(recovered.isProxyBroadcastHandled()), + () -> assertTrue(recovered.getBroadcastTargets().contains("backend-test")), + () -> assertTrue(recovered.getBroadcastForwardedServers().contains("backend-test")), + () -> assertEquals(id.toString(), recovered.getHttpBroadcastDeliveryId("legacy")), + () -> assertTrue(recovered.isMultiProxyForwardingHandled())); + } + } + } + + @Test void admissionOnlySnapshotStillCreatesAnAuthenticatedNewQueueOwner() throws Exception { + var cache = durableCache(); set("voteCacheHandler", cache); + var pending = new com.bencodez.votingplugin.proxy.PendingIncomingVote( + java.util.UUID.randomUUID(), "Synthetic", "fixture-site", 1234L); + var journal = new com.bencodez.votingplugin.proxy.PendingIncomingVoteJournal(directory.resolve("new-owner")); + journal.merge(List.of(proxy.snapshotIncomingVoteForRestart(pending))); + assertTrue(proxy.retainIncomingVoteForRestart(journal.load().get(0))); + var restarted = durableCache(); restarted.load(); + var recovered = restarted.getTimeChangeQueue().peek(); + assertEquals(pending.getVoteId(), recovered.getVoteId()); + assertFalse(recovered.isVotePartyApplied()); assertFalse(recovered.isTotalsApplied()); + assertEquals(true, ReflectiveAccess.call(VotingPluginProxy.class.getName(), proxy, + "hasLocalOriginProof", new String[] {"VoteTimeQueue"}, new Object[] {recovered})); + } + + @Test void actualRecordedRecoveryIsNotMistakenForAdmissionOnlyTotals() throws Exception { + var cache = durableCache(); set("voteCacheHandler", cache); + for (int kind = 0; kind < 13; kind++) { + var pending = new com.bencodez.votingplugin.proxy.PendingIncomingVote( + java.util.UUID.randomUUID(), "Synthetic", "fixture-site", 1234L); + var incoming = proxy.snapshotIncomingVoteForRestart(pending); + var recorded = new com.bencodez.votingplugin.timequeue.VoteTimeQueue( + pending.getVoteId(), "Synthetic", "fixture-site", 1234L); + recorded.setTotals("previous totals"); + assertTrue(cache.addTimeVoteToCache(recorded)); + if (kind < 8) { + String[] fields = incoming.getTotals().split("//", -1); + fields[kind + 1] = "1"; incoming.setTotals(String.join("//", fields)); + } else if (kind == 8) incoming.setUuid(java.util.UUID.randomUUID().toString()); + else if (kind == 9) incoming.setWasOnline(false); + else if (kind == 10) incoming.setVotePartyApplied(true); + else if (kind == 11) incoming.setTotalsApplied(true); + else incoming.setRealVote(false); + assertTrue(proxy.retainIncomingVoteForRestart(incoming)); + var restarted = durableCache(); restarted.load(); + var recovered = restarted.getTimeChangeQueue().stream() + .filter(vote -> pending.getVoteId().equals(vote.getVoteId())).findFirst().orElseThrow(); + assertEquals(incoming.getTotals(), recovered.getTotals(), "recorded state " + kind); + assertEquals(incoming.getUuid(), recovered.getUuid()); + assertEquals(incoming.isVotePartyApplied(), recovered.isVotePartyApplied()); + assertEquals(incoming.isTotalsApplied(), recovered.isTotalsApplied()); + assertEquals(incoming.isWasOnlineKnown(), recovered.isWasOnlineKnown()); + assertEquals(incoming.isRealVote(), recovered.isRealVote()); + } + } + + @Test void malformedAndUnsignedZeroTotalsKeepTheirExistingRecoveryMeaning() throws Exception { + var cache = durableCache(); set("voteCacheHandler", cache); + for (int kind = 0; kind < 10; kind++) { + var pending = new com.bencodez.votingplugin.proxy.PendingIncomingVote( + java.util.UUID.randomUUID(), "Synthetic", "fixture-site", 1234L); + var incoming = proxy.snapshotIncomingVoteForRestart(pending); + String[] fields = incoming.getTotals().split("//", -1); + if (kind == 0) fields[1] = "bad"; + else if (kind == 1) fields[1] = "00"; + else if (kind == 2) fields[1] = "+0"; + else if (kind == 3) fields[0] = "v99"; + else if (kind == 4) fields[9] = "local-proxy-origin-v1"; + incoming.setTotals(kind == 5 ? new VoteTotalsSnapshot().toStorageString() : String.join("//", fields)); + if (kind == 6) incoming.setTotals(incoming.getTotals() + "\n"); + if (kind == 7) incoming.setTotals(incoming.getTotals() + "//adjacent"); + if (kind == 8) incoming.setTotals(incoming.getTotals().substring(0, incoming.getTotals().length() - 1)); + if (kind == 9) incoming.setTotals(incoming.getTotals().replace("local-proxy-origin-v1:", "local-proxy-origin-v1:.")); + var recorded = new com.bencodez.votingplugin.timequeue.VoteTimeQueue( + pending.getVoteId(), "Synthetic", "fixture-site", 1234L); + recorded.setTotals("previous totals"); assertTrue(cache.addTimeVoteToCache(recorded)); + assertTrue(proxy.retainIncomingVoteForRestart(incoming)); + assertEquals(incoming.getTotals(), recorded.getTotals(), "not a canonical signed admission " + kind); + } + } + + @Test void activeRetryWinsOverAdmissionOnlyJournalForExistingQueue() throws Exception { + proxy = spy(proxy); + var cache = durableCache(); set("voteCacheHandler", cache); set("enabled", true); + proxy.setMethod(BungeeMethod.MYSQL); proxy.setPlayerOnline(false); + when(proxy.getConfig().getOnlineMode()).thenReturn(true); + when(proxy.getConfig().getBlockedServers()).thenReturn(List.of()); + doThrow(new IllegalStateException("synthetic party failure")).when(proxy).addVoteParty(); + var id = java.util.UUID.randomUUID(); var uuid = java.util.UUID.randomUUID().toString(); + assertThrows(RuntimeException.class, + () -> proxy.vote("Synthetic", "fixture-site", true, false, 1234L, null, uuid, id)); + var admission = new com.bencodez.votingplugin.timequeue.VoteTimeQueue( + id, "Synthetic", "fixture-site", 1234L); + assertTrue(cache.addTimeVoteToCache(admission)); + var journal = new com.bencodez.votingplugin.proxy.PendingIncomingVoteJournal(directory.resolve("live-retry")); + journal.merge(List.of(new com.bencodez.votingplugin.timequeue.VoteTimeQueue( + id, "Synthetic", "fixture-site", 1234L))); + assertTrue(proxy.retainIncomingVoteForRestart(journal.load().get(0))); + var restarted = durableCache(); restarted.load(); + var recovered = restarted.getTimeChangeQueue().peek(); + assertEquals(uuid, recovered.getUuid()); assertTrue(recovered.isWasOnlineKnown()); + assertEquals(true, ReflectiveAccess.call(VotingPluginProxy.class.getName(), proxy, + "hasLocalOriginProof", new String[] {"VoteTimeQueue"}, new Object[] {recovered})); + } + @Test void emergencySnapshotRestampsFinalFieldsWhenPartyFailureLeavesNoTotals() throws Exception { proxy = spy(proxy); set("voteCacheHandler", durableCache()); set("enabled", true); From f522ecd81f07092eba46935b9445df893ae57a94 Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 27 Oct 2026 05:15:51 -0600 Subject: [PATCH 4/5] Correct NeoForge support documentation --- .../main/resources/META-INF/neoforge.mods.toml | 2 +- docs/neoforge-bootstrap.md | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/VotingPlugin/src/main/resources/META-INF/neoforge.mods.toml b/VotingPlugin/src/main/resources/META-INF/neoforge.mods.toml index 7681f27ed1..a9c67c0395 100644 --- a/VotingPlugin/src/main/resources/META-INF/neoforge.mods.toml +++ b/VotingPlugin/src/main/resources/META-INF/neoforge.mods.toml @@ -6,7 +6,7 @@ license="CC BY 3.0" modId="votingplugin" version="${project.version}" displayName="VotingPlugin" -description="VotingPlugin NeoForge bootstrap (vote processing is not available yet)." +description="Experimental VotingPlugin NeoForge support for authenticated proxy votes and limited reward replay." [[dependencies.votingplugin]] modId="neoforge" diff --git a/docs/neoforge-bootstrap.md b/docs/neoforge-bootstrap.md index 15a2ec9e8e..1ffee84bae 100644 --- a/docs/neoforge-bootstrap.md +++ b/docs/neoforge-bootstrap.md @@ -1,18 +1,18 @@ -# NeoForge bootstrap status +# NeoForge support status -The single `VotingPlugin.jar` contains an experimental NeoForge 21.1 entry point for Minecraft 1.21.1. Native vote ingress remains disabled. Bukkit and proxy behavior remains on its existing paths. +The single `VotingPlugin.jar` contains experimental NeoForge 21.1 support for Minecraft 1.21.1. It can receive authenticated proxy votes over `SOCKETS`, Redis, MQTT or MySQL from BungeeCord and Velocity and replay the restricted reward subset described below. Native Votifier ingress is not supported. Bukkit and proxy behavior remains on its existing paths. -On server start, the entry point creates `config/votingplugin/`, installs the packaged `Config.yml`, `VoteSites.yml`, and `SpecialRewards.yml` only when missing, reads them, and opens the existing AdvancedCore SQLite user backend in `VotingPlugin.db`. The bootstrap currently accepts `DataStorage: SQLITE` only. Its separate `VotingPlugin_NeoForgeUsers` table stores the player name, all-time/monthly/daily/weekly totals, points, and per-site last-vote timestamps needed by the shared accounting step. Existing UUID-only bootstrap tables are extended in place. The accounting adapter is a storage foundation and is not wired to live vote ingress. The runtime closes the backend and clears queued tasks and online player identities on server stop. +On server start, the entry point creates `config/votingplugin/`, installs the packaged `Config.yml`, `VoteSites.yml`, and `SpecialRewards.yml` only when missing, reads them, and opens the existing AdvancedCore SQLite user backend in `VotingPlugin.db`. The bootstrap currently accepts `DataStorage: SQLITE` only. Its separate `VotingPlugin_NeoForgeUsers` table stores the player name, all-time/monthly/daily/weekly totals, points, and per-site last-vote timestamps needed by the shared accounting step. Existing UUID-only bootstrap tables are extended in place. Incoming proxy votes enter the durable inbox; the bounded replay worker commits accounting with supported reward completion. The runtime closes the backend and clears queued tasks and online player identities on server stop. The NeoForge bridge tracks login/logout identity and runs submitted bootstrap tasks on server ticks. Player identity access uses reflection because NeoForge's published universal Maven JAR does not include Minecraft classes on Maven's compile path; these method names were checked against the installed 1.21.1 server JAR. -An internal synthetic vote boundary now supports identity and enabled-site resolution, Bukkit-compatible vote-delay decisions, and the existing shared totals/points policy in one atomic SQLite transaction. `ACCOUNTING_ONLY` is deliberately an internal test/migration scope and returns `ACCOUNTED`, rather than claiming that a production vote completed. `COMPLETE` votes do not change timestamps, totals, or points. They are retained in an ordered SQLite inbox under their stable vote ID until NeoForge implements rewards, offline queues, vote party, broadcasts, streaks, milestones, cooldown actions, post-vote events, and placeholder updates. Repeated submission of the same vote ID is idempotent even after player or site state changes. The inbox admits at most 64 votes per player and 4,096 votes across the runtime. A full inbox returns an explicit capacity result that a future ingress must treat as non-success. +`ACCOUNTING_ONLY` is an internal test/migration scope for identity and enabled-site resolution, Bukkit-compatible vote-delay decisions, and the shared totals/points policy in one atomic SQLite transaction. It returns `ACCOUNTED`, rather than claiming that a production vote completed. Production `COMPLETE` votes first enter an ordered SQLite inbox under their stable vote IDs without immediately changing timestamps, totals, or points. The bounded replay worker later completes supported votes; unsupported reward or post-vote semantics keep them pending. Repeated submission of the same vote ID is idempotent even after player or site state changes. The inbox admits at most 64 votes per player and 4,096 votes across the runtime. A full inbox returns an explicit capacity result that the proxy must treat as non-success. Native Votifier ingress remains disabled. VotifierPlus's hardened packet handling is currently packaged with its Bukkit, BungeeCord, and Velocity plugin runtime rather than as a platform-neutral API that this single VotingPlugin artifact can reuse. Depending on that full plugin would pull unrelated platform code into VotingPlugin, while copying its security-sensitive protocol implementation would create a second implementation. ## Proxy vote delivery -NeoForge can receive real votes from ordinary VotingPlugin installations on BungeeCord and Velocity through the existing encrypted `SOCKETS` transport. Both proxy platforms use the same VotingPlugin proxy runtime and wire protocol. The backend durably retains a COMPLETE vote before sending its delivery acknowledgement; a duplicate stable vote ID is acknowledged idempotently and cannot create another pending occurrence or repeat an already completed vote. +NeoForge can receive real proxy votes from VotingPlugin installations on BungeeCord and Velocity over `SOCKETS`, Redis, MQTT or MySQL. The socket setup below applies to the existing encrypted `SOCKETS` transport; see [NeoForge vote ingress](neoforge-vote-transports.md) for broker and MySQL setup, required proxy-origin signatures, and private-cache restrictions. Both proxy platforms use the same VotingPlugin proxy runtime and wire protocol. The backend durably retains a COMPLETE vote before sending its delivery acknowledgement; a duplicate stable vote ID is acknowledged idempotently and cannot create another pending occurrence or repeat an already completed vote. Copy the same `secretkey.key` to the proxy VotingPlugin directory and `config/votingplugin/` on the NeoForge server. Keep this key private. Also create a separate random 32-byte base64 socket authentication key for this backend and copy only that file to the proxy and this NeoForge backend. Do not reuse one backend's socket authentication key for another backend. On the NeoForge backend, configure `BungeeSettings.yml` with values matching the proxy's server map: @@ -60,17 +60,17 @@ Current transport support is: | Native Votifier packets | Not yet supported | | Plugin messaging | Not yet supported | | HTTP | Not yet supported | -| Redis / MQTT / MySQL | Not yet supported | +| Redis / MQTT / MySQL | Experimental proxy ingress; see [transport requirements](neoforge-vote-transports.md) | Only the reward subset described below can complete. Votes requiring unsupported reward or post-vote semantics remain durable and observable instead of being acknowledged as fully processed and lost. -Deferred votes can now be claimed in memory and completed through one SQLite transaction that writes a completion receipt and removes the pending payload. An abandoned claim leaves the durable payload pending, while a committed transaction leaves no pending-without-receipt window. Completion receipts are bounded to 4,096 per player and 262,144 across the runtime. They are not evicted by age because no native ingress acknowledgement can yet prove that a sender has stopped retrying. If either receipt limit is full, completion stops and keeps the original pending payload. A later ingress handshake may add safe receipt release with a bounded post-release tombstone, following the existing proxy delivery pattern. A bounded replay worker now processes at most 16 retained votes per scan. It supports one existing inline `Messages.Player` or `Commands` action per vote. Player messages and console commands run on the NeoForge server tick lane; SQLite scans and completion writes remain on the replay worker. The worker validates the entire reward tree before running any action. A vote stays pending with an observable blocked or waiting result if it requires multiple or unsupported reward actions, `AnySiteRewards`, an offline player message, vote broadcasts, vote party, streaks, milestones, monthly vote limiting, cooldown events, `CloseInventoryOnVote`, or `WaitUntilVoteDelay` processing. An ambiguous external action or completion-write result is durably quarantined and remains fenced across restart for operator reconciliation. Legacy retained rows without an accepted accounting snapshot also remain blocked instead of being recalculated with newer configuration. The packaged defaults enable some unsupported features, so administrators must explicitly disable them before retained votes can complete. Configuration changes currently take effect after a server restart. +Deferred votes can now be claimed in memory and completed through one SQLite transaction that writes a completion receipt and removes the pending payload. An abandoned claim leaves the durable payload pending, while a committed transaction leaves no pending-without-receipt window. Completion receipts are bounded to 4,096 per player and 262,144 across the runtime. Unreleased completion receipts do not expire by age. If either receipt limit is full, completion stops and keeps the original pending payload. The existing proxy receipt-release handshake permits bounded released tombstones to expire as described below. A bounded replay worker now processes at most 16 retained votes per scan. It supports one existing inline `Messages.Player` or `Commands` action per vote. Player messages and console commands run on the NeoForge server tick lane; SQLite scans and completion writes remain on the replay worker. The worker validates the entire reward tree before running any action. A vote stays pending with an observable blocked or waiting result if it requires multiple or unsupported reward actions, `AnySiteRewards`, an offline player message, vote broadcasts, vote party, streaks, milestones, monthly vote limiting, cooldown events, `CloseInventoryOnVote`, or `WaitUntilVoteDelay` processing. An ambiguous external action or completion-write result is durably quarantined and remains fenced across restart for operator reconciliation. Legacy retained rows without an accepted accounting snapshot also remain blocked instead of being recalculated with newer configuration. The packaged defaults enable some unsupported features, so administrators must explicitly disable them before retained votes can complete. Configuration changes currently take effect after a server restart. Before a supported external action is dispatched, replay durably fences the pending occurrence. After the action succeeds, accounting, the completion receipt, and pending removal commit in one SQLite transaction. A completed vote ID remains fenced across restart and cannot rerun accounting. If the JVM or completion write fails after the pre-dispatch fence, automatic replay stays stopped for operator reconciliation, so it cannot blindly repeat a command or message. A crash between writing that fence and executing the action is indistinguishable from a crash immediately after execution and can therefore leave an action omitted unless an operator retries it; a manual retry after an uncertain outcome can instead duplicate it. The current reward APIs cannot make a Minecraft command and SQLite commit atomic, so this version does not claim exactly-once external side effects. -`SharedVoteProcessor.Operations` was reviewed operation by operation. NeoForge can currently implement its configuration, identity, site, delay, timestamp, and accounting operations. Logging and proxy-only identity fields have no processing effect at this internal non-proxy boundary. Reward delivery, offline queuing, vote party, broadcast, inventory/effects, streak, milestone, cooldown, post-event, and placeholder operations require future platform services. The adapter therefore reuses `SharedVoteIdentity`, `SharedVoteInput`, `SharedVotePolicy`, and `SharedVoteAccounting` without implementing the broad Bukkit production interface with false no-ops. +The NeoForge adapter reuses `SharedVoteIdentity`, `SharedVoteInput`, `SharedVotePolicy`, and `SharedVoteAccounting` without implementing the broad Bukkit production interface with false no-ops. Its native action adapter supports only the player-message or console-command subset above. Full Bukkit reward trees, offline-queue parity, vote party, broadcasts, inventory/effects, streaks, milestones, cooldown actions, post-vote events, and placeholder updates still require additional platform services. -Validation for this step covers unit startup/shutdown, isolated classloader startup/shutdown from the packaged JAR, and a NeoForge 21.1.211 dedicated server smoke run. The server reached its ready state, initialized the bootstrap, and exited cleanly after `stop`. This does not verify player joins, vote receipt, or rewards. +Existing headless and packaged-JAR tests exercise bootstrap startup/shutdown, isolated class loading, and the restricted proxy/replay paths. A historical NeoForge 21.1.211 dedicated-server smoke test reached its ready state and exited cleanly after `stop`; it covered startup and stop only. It does not establish live proxy receipt, player reward execution, or restart recovery for a current release candidate. ### Proxy receipt release before deferred completion From 22f7b439beba1f7e9edf6bd0b59d4bc174dd241e Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 27 Oct 2026 07:11:53 -0600 Subject: [PATCH 5/5] Preserve external JDBC driver selection --- VotingPlugin/pom.xml | 9 ++++++ .../tests/packaging/PackagedArtifactTest.java | 28 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/VotingPlugin/pom.xml b/VotingPlugin/pom.xml index da98bd4309..8bf39b7355 100644 --- a/VotingPlugin/pom.xml +++ b/VotingPlugin/pom.xml @@ -126,9 +126,18 @@ + org.mariadb ${project.groupId}.votingplugin.neoforge.mariadb + org.mariadb.jdbc.Driver + + + org/mariadb/jdbc/Driver + com/bencodez/votingplugin/neoforge/mariadb/jdbc/Driver + true com.tcoded.folialib diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/packaging/PackagedArtifactTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/packaging/PackagedArtifactTest.java index 0b38dcb062..563b2b1858 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/packaging/PackagedArtifactTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/packaging/PackagedArtifactTest.java @@ -162,6 +162,34 @@ void packagedMysqlDriverAndPrivateServiceProvidersLinkWithoutExternalJdbcLibrari } } + @Test + void packagedProxySqlDriverSelectionKeepsExternalMariaDbIdentity() throws Exception { + // The private NeoForge transport driver must not become the default proxy SQL driver. + // Shade can otherwise rewrite string constants in both plugin and shaded SDK classes. + try (URLClassLoader loader = new URLClassLoader(new URL[] {packagedJar().toUri().toURL()}, + ClassLoader.getPlatformClassLoader())) { + Class installer = Class.forName( + "com.bencodez.votingplugin.proxy.velocity.VelocityDatabaseDriverInstaller", true, loader); + var field = installer.getDeclaredField("MARIADB"); + field.setAccessible(true); + assertTrue("org.mariadb.jdbc.Driver".equals(field.get(null)), + "Velocity must probe the external MariaDB driver, not the NeoForge-private driver"); + } + try (JarFile artifact = new JarFile(packagedJar().toFile())) { + for (String path : new String[] { + "com/bencodez/votingplugin/simpleapi/sql/mysql/ConnectionManager.class", + "com/bencodez/votingplugin/control/NetworkHealthStorageFacts.class"}) { + var entry = artifact.getEntry(path); + assertNotNull(entry, "Missing packaged driver-selection class: " + path); + String bytes = new String(artifact.getInputStream(entry).readAllBytes(), StandardCharsets.ISO_8859_1); + assertTrue(bytes.contains("org.mariadb.jdbc.Driver"), + "Proxy SQL must retain the external MariaDB driver identity: " + path); + assertFalse(bytes.contains("com.bencodez.votingplugin.neoforge.mariadb.jdbc.Driver"), + "NeoForge's private driver must not replace the proxy SQL default: " + path); + } + } + } + @Test void packagedJdkTlsIdentityWorksWithoutExternalCrypto(@TempDir Path directory) throws Exception { URL jar = packagedJar().toUri().toURL();